diff --git a/README.md b/README.md index 9de8562..88bc6ad 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ The project is currently an early but usable Telnet client. Its interface is bui - Connection profiles with name, host, port, TLS, encoding, and automatic-reading preferences - Plain Telnet and certificate-verified TLS connections - UTF-8, Windows-1252, and ISO-8859-1 character encodings -- Streaming Telnet negotiation with ECHO, Suppress Go Ahead, and Terminal Type support +- Streaming Telnet negotiation with ECHO, Suppress Go Ahead, Terminal Type, and MSP support - MCCP2 compressed-stream negotiation and bounded streaming decompression - GMCP negotiation with bounded UTF-8/JSON parsing and a raw diagnostic inspector - Accessible live views for character, room, items, skills, group, and communication data -- Opt-in Client.Media audio with captions, per-profile volumes, HTTPS-only bounded downloads, and cache limits +- Opt-in Client.Media and MSP audio with captions, per-profile volumes, bounded downloads, and cache limits +- Safe start-of-line MSP fallback for legacy servers, including explicitly disclosed HTTP media support - Streaming ANSI control-sequence filtering - Accessible scrollback and command entry - Automatic reading of incoming lines and prompts @@ -45,6 +46,7 @@ The output and command fields provide additional actions through their context m - `lib/meow/telnet.rb` implements the streaming Telnet state machine. - `lib/meow/gmcp.rb` parses GMCP and maintains package state. - `lib/meow/media.rb` provides protocol-neutral media requests, safe caching, and Elten audio playback. +- `lib/meow/msp.rb` parses line and Telnet-option MSP commands into shared media requests. - `lib/meow/session_gmcp.rb` integrates GMCP packages with accessible session views. - `lib/meow/text_pipeline.rb` handles ANSI filtering, decoding, lines, and prompts. - `lib/meow/session.rb` owns connected-session state, scrollback, input, and speech. @@ -58,10 +60,9 @@ Automated regression coverage now exercises the streaming Telnet parser and text Planned work, roughly in order: -1. Add MSP parsing as a second adapter to the shared media request and playback layer. -2. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. -3. Add multiple simultaneous sessions with independent output, media, and speech queues. -4. Add localization catalogs and package/signing automation for releases. +1. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. +2. Add multiple simultaneous sessions with independent output, media, and speech queues. +3. Add localization catalogs and package/signing automation for releases. ## Running the tests diff --git a/__app.rb b/__app.rb index f563e6b..27036a2 100644 --- a/__app.rb +++ b/__app.rb @@ -2,8 +2,8 @@ { "id": "51a92051-84e0-415f-84e3-98f08a320a15", "name": "Meow", - "version": "0.2.0", - "build_id": 4, + "version": "0.3.0", + "build_id": 5, "EltenAPIVersion": "3.0.3", "author": "Talon", "main_language": "en", @@ -22,6 +22,7 @@ require_relative "lib/meow/gmcp" require_relative "lib/meow/telnet" require_relative "lib/meow/text_pipeline" require_relative "lib/meow/media" +require_relative "lib/meow/msp" require_relative "lib/meow/transport" require_relative "lib/meow/session_gmcp" require_relative "lib/meow/session" diff --git a/lib/meow/media.rb b/lib/meow/media.rb index 65b736f..7061070 100644 --- a/lib/meow/media.rb +++ b/lib/meow/media.rb @@ -9,7 +9,7 @@ module MeowClient PlayRequest = Struct.new( :name, :url, :kind, :tag, :volume, :fade_in, :fade_out, :start_at, :finish_at, :loops, :priority, :continue_existing, :key, :caption, - keyword_init: true + :allow_insecure_http, keyword_init: true ) StopRequest = Struct.new( :name, :kind, :tag, :priority, :key, :fade_away, :fade_out, @@ -20,21 +20,23 @@ module MeowClient Track = Struct.new(:request, :handle, :url, :remaining_loops, :started_at, keyword_init: true) class URLPolicy - AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac].freeze + AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac .mid .midi].freeze - def resolve(base_url, name, explicit_url = nil) + def resolve(base_url, name, explicit_url = nil, allow_insecure_http: false) clean_name = validate_name(name) base = explicit_url.to_s.strip == "" ? base_url : explicit_url raise ArgumentError, "No media base URL was supplied" if base.to_s.strip == "" - base_uri = validate_uri(URI.parse(base.to_s)) + base_uri = validate_uri(URI.parse(base.to_s), :allow_insecure_http => allow_insecure_http) value = clean_name == "" ? base_uri : URI.join(ensure_directory(base_uri.to_s), clean_name) - validate_uri(value) + validate_uri(value, :allow_insecure_http => allow_insecure_http) rescue URI::InvalidURIError => error raise ArgumentError, "Invalid media URL: #{error.message}" end - def validate_uri(uri) - raise ArgumentError, "Only HTTPS media URLs are allowed" unless uri.scheme.to_s.downcase == "https" + def validate_uri(uri, allow_insecure_http: false) + scheme = uri.scheme.to_s.downcase + allowed = scheme == "https" || (allow_insecure_http && scheme == "http") + raise ArgumentError, allow_insecure_http ? "Only HTTP or HTTPS media URLs are allowed" : "Only HTTPS media URLs are allowed" unless allowed raise ArgumentError, "Media URL must include a host" if uri.host.to_s == "" raise ArgumentError, "Media URL credentials are not allowed" if uri.userinfo != nil raise ArgumentError, "Media URL contains control characters" if uri.to_s.match?(/[\x00-\x1f\x7f]/) @@ -90,7 +92,7 @@ module MeowClient @worker.report_on_exception = false end - def enqueue(id, uri) + def enqueue(id, uri, allow_insecure_http: false) return false if @closed cached = cache_path(uri) if File.file?(cached) @@ -98,7 +100,7 @@ module MeowClient @results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s) return true end - @queue.push([id, uri], true) + @queue.push([id, uri, allow_insecure_http], true) true rescue ThreadError false @@ -119,9 +121,9 @@ module MeowClient loop do job = @queue.pop break if job == nil || @closed - id, uri = job + id, uri, allow_insecure_http = job begin - path, final_uri = download(uri) + path, final_uri = download(uri, 0, :allow_insecure_http => allow_insecure_http) @results << DownloadResult.new(:id => id, :path => path, :url => final_uri.to_s) rescue Exception => error @results << DownloadResult.new(:id => id, :url => uri.to_s, :error => error.message) @@ -129,14 +131,14 @@ module MeowClient end end - def download(uri, redirects = 0) + def download(uri, redirects = 0, allow_insecure_http: false) raise "Too many media redirects" if redirects > MAX_REDIRECTS - current = @policy.validate_uri(uri) + current = @policy.validate_uri(uri, :allow_insecure_http => allow_insecure_http) response = nil Net::HTTP.start( current.host, current.port, - :use_ssl => true, + :use_ssl => current.scheme.to_s.downcase == "https", :open_timeout => OPEN_TIMEOUT, :read_timeout => READ_TIMEOUT ) do |http| @@ -146,8 +148,11 @@ module MeowClient if incoming.is_a?(Net::HTTPRedirection) location = incoming["location"] raise "Media redirect did not include a location" if location.to_s == "" - redirected = @policy.validate_uri(URI.join(current.to_s, location)) - return download(redirected, redirects + 1) + redirected = @policy.validate_uri( + URI.join(current.to_s, location), + :allow_insecure_http => allow_insecure_http + ) + return download(redirected, redirects + 1, :allow_insecure_http => allow_insecure_http) end raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess) length = Integer(incoming["content-length"], :exception => false) @@ -274,11 +279,22 @@ module MeowClient end def default_url=(value) - @default_url = @policy.validate_uri(URI.parse(value.to_s)).to_s + @default_url = normalize_base_url(value) rescue ArgumentError, URI::InvalidURIError => error add_error(error.message) end + def normalize_base_url(value, allow_insecure_http: false) + @policy.validate_uri( + URI.parse(value.to_s), + :allow_insecure_http => allow_insecure_http + ).to_s + end + + def report_error(value) + add_error(value) + end + def preload(request) queue(request, false) end @@ -353,15 +369,27 @@ module MeowClient def queue(request, play_after) loaded = @loaded_urls[request.name.to_s] if request.url.to_s == "" if loaded != nil - uri = @policy.validate_uri(URI.parse(loaded)) + uri = @policy.validate_uri( + URI.parse(loaded), + :allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true + ) else base = request.url.to_s == "" ? @default_url : request.url - uri = @policy.resolve(base, request.name, request.url) + uri = @policy.resolve( + base, + request.name, + request.url, + :allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true + ) end raise "Unsupported audio file type" unless @policy.supported_audio?(uri) @sequence += 1 @pending[@sequence] = [request, play_after] - unless @downloader.enqueue(@sequence, uri) + unless @downloader.enqueue( + @sequence, + uri, + :allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true + ) @pending.delete(@sequence) raise "Media download queue is full" end diff --git a/lib/meow/msp.rb b/lib/meow/msp.rb new file mode 100644 index 0000000..78990bb --- /dev/null +++ b/lib/meow/msp.rb @@ -0,0 +1,123 @@ +module MeowClient + module MSP + MAX_TRIGGER_BYTES = 8_192 + + Action = Struct.new(:type, :request, :url, :error, keyword_init: true) + + class Adapter + attr_reader :default_url + + def initialize + @default_url = nil + end + + def default_url=(value) + @default_url = value.to_s + end + + def parse(text, out_of_band: false) + value = text.to_s + raise ArgumentError, "MSP trigger exceeds #{MAX_TRIGGER_BYTES} bytes" if value.bytesize > MAX_TRIGGER_BYTES + value = "!!#{value}" if out_of_band && value.match?(/\A(?:SOUND|MUSIC)\(/i) + match = value.match(/\A!!(SOUND|MUSIC)\((.*)\)\z/i) + return nil if match == nil + + kind = match[1].downcase + tokens = tokenize(match[2]) + raise ArgumentError, "MSP trigger does not include a file name" if tokens.empty? + name = tokens.shift + parameters = parse_parameters(tokens) + url = parameters["U"] + + if name.casecmp?("off") + return Action.new(:type => :default_url, :url => url) if url != nil + return Action.new(:type => :stop, :request => Media::StopRequest.new(:kind => kind)) + end + + raise ArgumentError, "MSP wildcard media names require a local sound pack" if name.include?("*") || name.include?("?") + raise ArgumentError, "MSP media name must use forward slashes" if name.include?("\\") + name += kind == "sound" ? ".wav" : ".mid" if File.extname(name) == "" + + volume = integer_parameter(parameters, "V", 100, 0..100) + loops = integer_parameter(parameters, "L", 1, nil) + raise ArgumentError, "MSP L must be -1 or greater than zero" unless loops == -1 || loops > 0 + priority = integer_parameter(parameters, "P", 50, 0..100) + continuing = integer_parameter(parameters, "C", 1, 0..1) == 1 + tag = parameters["T"] + if tag.to_s == "" + directory = File.dirname(name) + tag = directory unless directory == "." + end + + request = Media::PlayRequest.new( + :name => name, + :url => url || @default_url, + :kind => kind, + :tag => tag, + :volume => volume, + :loops => loops, + :priority => priority, + :continue_existing => kind == "music" ? continuing : false, + :key => kind == "music" ? "msp:music" : nil, + :allow_insecure_http => true + ) + Action.new(:type => :play, :request => request) + rescue ArgumentError => error + Action.new(:type => :error, :error => error.message) + end + + def parse_bytes(bytes, encoding) + value = bytes.to_s.b.force_encoding(encoding.to_s).encode( + Encoding::UTF_8, + :invalid => :replace, + :undef => :replace, + :replace => "\uFFFD" + ) + parse(value, :out_of_band => true) + rescue Encoding::ConverterNotFoundError => error + Action.new(:type => :error, :error => "Could not decode MSP trigger: #{error.message}") + end + + private + + def tokenize(body) + tokens = [] + token = +"" + quoted = false + body.to_s.each_char do |character| + if character == '"' + quoted = !quoted + token << character + elsif character.match?(/\s/) && !quoted + unless token.empty? + tokens << token + token = +"" + end + else + token << character + end + end + raise ArgumentError, "MSP trigger contains an unterminated quote" if quoted + tokens << token unless token.empty? + tokens + end + + def parse_parameters(tokens) + tokens.each_with_object({}) do |token, parameters| + name, value = token.split("=", 2) + next if value == nil || !name.match?(/\A[A-Za-z]\z/) + value = value[1...-1] if value.start_with?('"') && value.end_with?('"') + parameters[name.upcase] = value + end + end + + def integer_parameter(parameters, name, default, range) + return default unless parameters.key?(name) + value = Integer(parameters[name], :exception => false) + raise ArgumentError, "MSP #{name} must be an integer" if value == nil + raise ArgumentError, "MSP #{name} is outside its supported range" if range != nil && !range.cover?(value) + value + end + end + end +end diff --git a/lib/meow/session.rb b/lib/meow/session.rb index c3c58ac..43a704d 100644 --- a/lib/meow/session.rb +++ b/lib/meow/session.rb @@ -14,7 +14,7 @@ module MeowClient @program = program @profile = profile @transport = transport - @pipeline = TextPipeline.new(profile.encoding) + @pipeline = TextPipeline.new(profile.encoding, :enable_msp => profile.remote_media_enabled) @lines = [] @line_bytes = 0 @history = [] @@ -26,7 +26,14 @@ module MeowClient @gmcp_enabled = false @gmcp_state = GMCP::State.new @gmcp_log = GMCP::MessageLog.new - @media = nil + @msp = profile.remote_media_enabled ? MSP::Adapter.new : nil + @media = if profile.remote_media_enabled + Media::Manager.new( + @program.cache_path("mud-media"), + :sound_volume => profile.sound_volume.to_f / 100.0, + :music_volume => profile.music_volume.to_f / 100.0 + ) + end @closed = false build_form end @@ -55,9 +62,10 @@ module MeowClient @speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading")) @stop_speech = Button.new(_("Stop current speech")) @gmcp_button = Button.new(_("GMCP information")) + @media_button = Button.new(_("Media")) @disconnect = Button.new(_("Disconnect")) @form = Form.new( - [@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @disconnect], + [@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @disconnect], :quiet => true ) @form.accept_button = @send @@ -68,6 +76,7 @@ module MeowClient @speech_button.on(:press) { toggle_speech } @stop_speech.on(:press) { speech_stop } @gmcp_button.on(:press) { open_gmcp_hub } + @media_button.on(:press) { open_media_view } @disconnect.on(:press) { request_disconnect } @output.add_tip(_("Use the context menu to jump to the latest output.")) @@ -113,7 +122,7 @@ module MeowClient result = @pipeline.feed(event.payload) result.replies.each { |reply| @transport.send_bytes(reply) } result.events.each { |protocol_event| handle_protocol_event(protocol_event) } - result.records.each { |record| receive_record(record) } + result.records.each { |record| receive_protocol_record(record) } when :disconnected, :error @pipeline.finish.each { |record| receive_record(record) } add_system_line(event.payload.to_s) @@ -123,6 +132,32 @@ module MeowClient rescue ThreadError end + def receive_protocol_record(record) + if @msp != nil + action = @msp.parse(record) + return handle_msp_action(action) if action != nil + end + receive_record(record) + end + + def handle_msp_action(action) + case action.type + when :play + @media.play(action.request) + when :stop + @media.stop(action.request) + when :default_url + normalized = @media.normalize_base_url(action.url, :allow_insecure_http => true) + @msp.default_url = normalized + when :error + @media.report_error(action.error) + end + true + rescue ArgumentError, URI::InvalidURIError => error + @media.report_error(error.message) + true + end + def receive_record(record) text = record.to_s append_line(text) diff --git a/lib/meow/session_gmcp.rb b/lib/meow/session_gmcp.rb index d7830dd..79f44e1 100644 --- a/lib/meow/session_gmcp.rb +++ b/lib/meow/session_gmcp.rb @@ -11,6 +11,17 @@ module MeowClient receive_gmcp(payload) when :gmcp_error @gmcp_log.add_error(payload) + when :msp_enabled + add_system_line(payload ? _("MSP negotiated.") : _("MSP disabled by the server.")) + when :msp + action = @msp.parse_bytes(payload, @profile.encoding) if @msp != nil + if action == nil + @media.report_error("Invalid MSP subnegotiation") if @media != nil + else + handle_msp_action(action) + end + when :msp_error + @media.report_error(payload) if @media != nil end end @@ -22,13 +33,6 @@ module MeowClient packages = ["Char 1", "Char.Items 1", "Char.Skills 1", "Room 1", "Group 1", "Comm 1"] packages << "Client.Media 1" if @profile.remote_media_enabled send_gmcp("Core.Supports.Set", packages) - if @profile.remote_media_enabled - @media = Media::Manager.new( - @program.cache_path("mud-media"), - :sound_volume => @profile.sound_volume.to_f / 100.0, - :music_volume => @profile.music_volume.to_f / 100.0 - ) - end add_system_line(_("GMCP negotiated.")) end @@ -37,8 +41,6 @@ module MeowClient @gmcp_enabled = false @gmcp_button.label = _("GMCP information") if @gmcp_button != nil @gmcp_state.reset - @media.close if @media != nil - @media = nil add_system_line(_("GMCP disabled by the server.")) end diff --git a/lib/meow/telnet.rb b/lib/meow/telnet.rb index ce60664..137593e 100644 --- a/lib/meow/telnet.rb +++ b/lib/meow/telnet.rb @@ -15,6 +15,7 @@ module MeowClient SUPPRESS_GO_AHEAD = 3 TERMINAL_TYPE = 24 COMPRESS2 = 86 + MSP = 90 GMCP = 201 TTYPE_IS = 0 TTYPE_SEND = 1 @@ -40,7 +41,7 @@ module MeowClient class Parser attr_reader :remote_echo - def initialize + def initialize(enable_msp: false) @state = :data @negotiation_command = nil @subnegotiation_option = nil @@ -51,6 +52,7 @@ module MeowClient @local_options = {} @inflater = nil @compression_finished = false + @enable_msp = enable_msp == true end def feed(bytes) @@ -167,7 +169,7 @@ module MeowClient def negotiate(command, option) case command when WILL - enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option) + enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option) || (option == MSP && @enable_msp) changed = @remote_options[option] != enabled @remote_options[option] = enabled return [nil, nil] unless changed @@ -176,6 +178,7 @@ module MeowClient return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]] end return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled + return [packet(DO, option), [:msp_enabled, true]] if option == MSP && enabled [packet(enabled ? DO : DONT, option), nil] when WONT changed = @remote_options[option] != false @@ -185,6 +188,7 @@ module MeowClient return [packet(DONT, option), [:remote_echo, false]] if changed end return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed + return [changed ? packet(DONT, option) : nil, [:msp_enabled, false]] if option == MSP && changed && @enable_msp [changed ? packet(DONT, option) : nil, nil] when DO enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option) @@ -203,6 +207,7 @@ module MeowClient def finish_subnegotiation if @subnegotiation_overflow return [nil, [:gmcp_error, "Telnet subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == GMCP + return [nil, [:msp_error, "MSP subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == MSP return [nil, [:subnegotiation, @subnegotiation_option]] end if @subnegotiation_option == GMCP && @remote_options[GMCP] == true @@ -212,6 +217,9 @@ module MeowClient return [nil, [:gmcp_error, error.message]] end end + if @subnegotiation_option == MSP && @remote_options[MSP] == true + return [nil, [:msp, @subnegotiation.dup]] + end if @subnegotiation_option == COMPRESS2 && @remote_options[COMPRESS2] == true && @subnegotiation.empty? diff --git a/lib/meow/text_pipeline.rb b/lib/meow/text_pipeline.rb index f45aafe..ab0f549 100644 --- a/lib/meow/text_pipeline.rb +++ b/lib/meow/text_pipeline.rb @@ -41,8 +41,8 @@ module MeowClient class TextPipeline Result = Struct.new(:records, :replies, :events, keyword_init: true) - def initialize(encoding) - @telnet = Telnet::Parser.new + def initialize(encoding, enable_msp: false) + @telnet = Telnet::Parser.new(:enable_msp => enable_msp) @ansi = AnsiFilter.new @encoding = encoding.to_s @decode_pending = +"".b diff --git a/lib/meow/ui.rb b/lib/meow/ui.rb index 058d8f7..0b6e91e 100644 --- a/lib/meow/ui.rb +++ b/lib/meow/ui.rb @@ -81,7 +81,7 @@ module MeowClient port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s) tls = CheckBox.new(_("Use TLS"), :checked => profile.tls) auto_read = CheckBox.new(_("Read incoming text automatically"), :checked => profile.auto_read) - remote_media = CheckBox.new(_("Allow this server to download and play HTTPS media"), :checked => profile.remote_media_enabled) + remote_media = CheckBox.new(_("Allow remote media; legacy MSP servers may use insecure HTTP"), :checked => profile.remote_media_enabled) speak_captions = CheckBox.new(_("Read media captions automatically"), :checked => profile.speak_media_captions) sound_volume = EditBox.new(_("Sound volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.sound_volume.to_i.to_s) music_volume = EditBox.new(_("Music volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.music_volume.to_i.to_s) diff --git a/lib/meow/version.rb b/lib/meow/version.rb index ab3772b..b7fd055 100644 --- a/lib/meow/version.rb +++ b/lib/meow/version.rb @@ -1,3 +1,3 @@ module MeowClient - VERSION = "0.2.0" + VERSION = "0.3.0" end diff --git a/test/media_test.rb b/test/media_test.rb index 7c99381..411f95e 100644 --- a/test/media_test.rb +++ b/test/media_test.rb @@ -14,8 +14,8 @@ module MeowMediaTest @requests = [] end - def enqueue(id, uri) - @requests << [id, uri] + def enqueue(id, uri, allow_insecure_http: false) + @requests << [id, uri, allow_insecure_http] true end @@ -76,13 +76,34 @@ module MeowMediaTest MeowTest.assert_equal("https://media.example/game/weather/rain.ogg", uri.to_s) end - MeowTest.test("Media URL policy rejects insecure private and traversing URLs") do + MeowTest.test("Media URL policy isolates legacy HTTP from secure requests") do policy = Media::URLPolicy.new MeowTest.assert_raises(ArgumentError) { policy.resolve("http://example.com/", "sound.mp3") } - MeowTest.assert_raises(ArgumentError) { policy.resolve("https://127.0.0.1/", "sound.mp3") } + legacy = policy.resolve("http://example.com/", "sound.mp3", nil, :allow_insecure_http => true) + MeowTest.assert_equal("http://example.com/sound.mp3", legacy.to_s) + MeowTest.assert_raises(ArgumentError) do + policy.resolve("http://127.0.0.1/", "sound.mp3", nil, :allow_insecure_http => true) + end MeowTest.assert_raises(ArgumentError) { policy.resolve("https://example.com/", "../secret.mp3") } end + MeowTest.test("Media manager carries HTTP permission on each request") do + downloader = FakeDownloader.new + manager = Media::Manager.new("unused", :downloader => downloader) + secure = Media::PlayRequest.new(:name => "secure.mp3", :url => "https://media.example/") + legacy = Media::PlayRequest.new( + :name => "legacy.mid", + :url => "http://media.example/", + :allow_insecure_http => true + ) + manager.play(secure) + manager.play(legacy) + MeowTest.assert_equal(false, downloader.requests[0][2]) + MeowTest.assert_equal(true, downloader.requests[1][2]) + ensure + manager.close if manager != nil + end + MeowTest.test("Client.Media adapter normalizes play and stop commands") do play = Media::ClientMedia.play_request({"name" => "rain.ogg", "type" => "music", "loops" => -1, "caption" => "Rain"}) stop = Media::ClientMedia.stop_request({"type" => "music", "fadeaway" => true}) diff --git a/test/msp_test.rb b/test/msp_test.rb new file mode 100644 index 0000000..2d40a28 --- /dev/null +++ b/test/msp_test.rb @@ -0,0 +1,68 @@ +# encoding: UTF-8 + +require_relative "test_helper" +require_relative "../lib/meow/media" +require_relative "../lib/meow/msp" + +module MeowMSPTest + MSP = MeowClient::MSP + + MeowTest.test("MSP maps sound defaults into a shared media request") do + action = MSP::Adapter.new.parse("!!SOUND(weather/thunder)") + request = action.request + MeowTest.assert_equal(:play, action.type) + MeowTest.assert_equal("weather/thunder.wav", request.name) + MeowTest.assert_equal("sound", request.kind) + MeowTest.assert_equal("weather", request.tag) + MeowTest.assert_equal(100, request.volume) + MeowTest.assert_equal(1, request.loops) + MeowTest.assert_equal(50, request.priority) + MeowTest.refute(request.continue_existing) + MeowTest.assert(request.allow_insecure_http) + end + + MeowTest.test("MSP accepts case-insensitive parameters and quoted legacy URLs") do + action = MSP::Adapter.new.parse( + '!!MUSIC(theme v=75 l=-1 c=0 t=zone u="http://media.example/game sounds/")' + ) + request = action.request + MeowTest.assert_equal("theme.mid", request.name) + MeowTest.assert_equal("music", request.kind) + MeowTest.assert_equal("zone", request.tag) + MeowTest.assert_equal(75, request.volume) + MeowTest.assert_equal(-1, request.loops) + MeowTest.refute(request.continue_existing) + MeowTest.assert_equal("msp:music", request.key) + MeowTest.assert_equal("http://media.example/game sounds/", request.url) + end + + MeowTest.test("MSP default URL and stop actions remain protocol-local") do + adapter = MSP::Adapter.new + default_action = adapter.parse("!!SOUND(Off U=http://media.example/sounds/)") + MeowTest.assert_equal(:default_url, default_action.type) + adapter.default_url = default_action.url + + play = adapter.parse("!!SOUND(bell.wav)") + stop = adapter.parse("!!MUSIC(Off)") + MeowTest.assert_equal("http://media.example/sounds/", play.request.url) + MeowTest.assert_equal(:stop, stop.type) + MeowTest.assert_equal("music", stop.request.kind) + end + + MeowTest.test("MSP recognizes out-of-band bodies but not mid-line text") do + adapter = MSP::Adapter.new + action = adapter.parse_bytes("SOUND(chime.wav U=http://media.example/)".b, "UTF-8") + MeowTest.assert_equal(:play, action.type) + MeowTest.assert_equal(nil, adapter.parse("chat says !!SOUND(chime.wav)")) + end + + MeowTest.test("MSP reports invalid values and unsupported wildcards") do + adapter = MSP::Adapter.new + bad_volume = adapter.parse("!!SOUND(chime.wav V=101)") + wildcard = adapter.parse("!!SOUND(chime*.wav)") + unterminated = adapter.parse('!!SOUND(chime.wav U="http://media.example/)') + MeowTest.assert_equal(:error, bad_volume.type) + MeowTest.assert_equal(:error, wildcard.type) + MeowTest.assert_equal(:error, unterminated.type) + end +end diff --git a/test/run.rb b/test/run.rb index 8673de0..c394160 100644 --- a/test/run.rb +++ b/test/run.rb @@ -2,6 +2,7 @@ require_relative "test_helper" require_relative "telnet_test" require_relative "gmcp_test" require_relative "media_test" +require_relative "msp_test" require_relative "text_pipeline_test" begin diff --git a/test/telnet_test.rb b/test/telnet_test.rb index 821d5e6..a562de7 100644 --- a/test/telnet_test.rb +++ b/test/telnet_test.rb @@ -66,6 +66,24 @@ module MeowTelnetTest MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::WONT, 43)], local_unknown.replies) end + MeowTest.test("Telnet negotiates MSP only when media is enabled") do + disabled = Telnet::Parser.new.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::MSP)) + enabled = Telnet::Parser.new(:enable_msp => true).feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::MSP)) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DONT, Telnet::MSP)], disabled.replies) + MeowTest.assert_empty(disabled.events) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::MSP)], enabled.replies) + MeowTest.assert_equal([[:msp_enabled, true]], enabled.events) + end + + MeowTest.test("Telnet emits negotiated MSP subnegotiation payloads") do + parser = Telnet::Parser.new(:enable_msp => true) + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::MSP)) + body = "SOUND(chime.wav U=http://media.example/)".b + packet = bytes(Telnet::IAC, Telnet::SB, Telnet::MSP) + body + bytes(Telnet::IAC, Telnet::SE) + result = collect(parser, packet.bytes.map { |byte| bytes(byte) }) + MeowTest.assert_equal([[:msp, body]], result[:events]) + end + MeowTest.test("Telnet sends terminal type only after accepting the option") do request = bytes(Telnet::IAC, Telnet::SB, Telnet::TERMINAL_TYPE, Telnet::TTYPE_SEND, Telnet::IAC, Telnet::SE) parser = Telnet::Parser.new