Add MSP media protocol support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+48
-20
@@ -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
|
||||
|
||||
+123
@@ -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
|
||||
+39
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+10
-2
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
module MeowClient
|
||||
VERSION = "0.2.0"
|
||||
VERSION = "0.3.0"
|
||||
end
|
||||
|
||||
+25
-4
@@ -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})
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user