diff --git a/README.md b/README.md index 8d2fe26..9de8562 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ The project is currently an early but usable Telnet client. Its interface is bui - UTF-8, Windows-1252, and ISO-8859-1 character encodings - Streaming Telnet negotiation with ECHO, Suppress Go Ahead, and Terminal Type 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 - Streaming ANSI control-sequence filtering - Accessible scrollback and command entry - Automatic reading of incoming lines and prompts @@ -40,6 +43,9 @@ The output and command fields provide additional actions through their context m - `lib/meow/profile_repository.rb` stores and validates connection profiles. - `lib/meow/transport.rb` owns TCP/TLS sockets and background I/O. - `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/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. - `lib/meow/ui.rb` implements the connection manager and profile editor. @@ -52,11 +58,10 @@ Automated regression coverage now exercises the streaming Telnet parser and text Planned work, roughly in order: -1. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector. -2. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls. -3. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. -4. Add multiple simultaneous sessions with independent output and speech queues. -5. Add localization catalogs and package/signing automation for releases. +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. ## Running the tests diff --git a/__app.rb b/__app.rb index 4ec6fd6..f563e6b 100644 --- a/__app.rb +++ b/__app.rb @@ -2,8 +2,8 @@ { "id": "51a92051-84e0-415f-84e3-98f08a320a15", "name": "Meow", - "version": "0.1.0", - "build_id": 3, + "version": "0.2.0", + "build_id": 4, "EltenAPIVersion": "3.0.3", "author": "Talon", "main_language": "en", @@ -16,10 +16,14 @@ } =end Elten3AppInfo +require_relative "lib/meow/version" require_relative "lib/meow/profile_repository" +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/transport" +require_relative "lib/meow/session_gmcp" require_relative "lib/meow/session" require_relative "lib/meow/ui" diff --git a/lib/meow/gmcp.rb b/lib/meow/gmcp.rb new file mode 100644 index 0000000..1d36b54 --- /dev/null +++ b/lib/meow/gmcp.rb @@ -0,0 +1,231 @@ +require "json" + +module MeowClient + module GMCP + MAX_PAYLOAD_BYTES = 65_536 + MAX_DEPTH = 24 + MAX_NODES = 4_096 + MAX_MESSAGES = 200 + MAX_MESSAGE_BYTES = 262_144 + PACKAGE_PATTERN = /\A[A-Za-z]+(?:\.[A-Za-z]+)*\z/ + + Message = Struct.new(:package, :data, :raw_json, keyword_init: true) do + def key + package.to_s.downcase + end + end + + ParseError = Class.new(StandardError) + + module Codec + module_function + + def parse(bytes) + input = bytes.to_s.b + raise ParseError, "GMCP message exceeds #{MAX_PAYLOAD_BYTES} bytes" if input.bytesize > MAX_PAYLOAD_BYTES + separator = input.index(" ".b) + package_bytes = separator == nil ? input : input.byteslice(0, separator) + json_bytes = separator == nil ? nil : input.byteslice(separator + 1..-1).to_s.b + package = utf8(package_bytes, "package") + raise ParseError, "Invalid GMCP package name" unless package.match?(PACKAGE_PATTERN) + + raw_json = json_bytes == nil ? nil : utf8(json_bytes, "JSON payload") + data = raw_json == nil || raw_json == "" ? nil : JSON.parse(raw_json) + validate_shape(data) + Message.new(:package => package, :data => data, :raw_json => raw_json) + rescue JSON::ParserError => error + raise ParseError, "Invalid GMCP JSON: #{error.message}" + end + + def encode(package, data = :__absent__) + name = package.to_s + raise ArgumentError, "Invalid GMCP package name" unless name.match?(PACKAGE_PATTERN) + return name.b if data == :__absent__ + json = JSON.generate(data) + raise ArgumentError, "GMCP message exceeds #{MAX_PAYLOAD_BYTES} bytes" if name.bytesize + 1 + json.bytesize > MAX_PAYLOAD_BYTES + (name + " " + json).encode(Encoding::UTF_8).b + end + + def utf8(bytes, label) + value = bytes.to_s.b.force_encoding(Encoding::UTF_8) + raise ParseError, "Invalid UTF-8 in GMCP #{label}" unless value.valid_encoding? + value + end + + def validate_shape(root) + nodes = 0 + visit = lambda do |value, depth| + raise ParseError, "GMCP JSON is nested too deeply" if depth > MAX_DEPTH + nodes += 1 + raise ParseError, "GMCP JSON contains too many values" if nodes > MAX_NODES + case value + when Hash + value.each do |key, child| + raise ParseError, "GMCP JSON object key is too large" if key.to_s.bytesize > MAX_PAYLOAD_BYTES + visit.call(child, depth + 1) + end + when Array + value.each { |child| visit.call(child, depth + 1) } + when String + raise ParseError, "GMCP JSON string is too large" if value.bytesize > MAX_PAYLOAD_BYTES + end + end + visit.call(root, 0) + end + end + + class MessageLog + attr_reader :messages, :errors + + def initialize + @messages = [] + @message_bytes = 0 + @errors = [] + end + + def add(message) + entry = format_message(message) + @messages << entry + @message_bytes += entry.bytesize + trim + entry + end + + def add_error(text) + @errors << text.to_s + @errors.shift while @errors.size > 50 + end + + def text + parts = [] + parts << "Diagnostics:\n#{@errors.join("\n")}" unless @errors.empty? + parts << @messages.join("\n") unless @messages.empty? + parts.empty? ? "No GMCP messages have been received." : parts.join("\n\n") + end + + private + + def format_message(message) + body = message.raw_json + body == nil || body == "" ? message.package.to_s : "#{message.package} #{body}" + end + + def trim + while @messages.size > MAX_MESSAGES || @message_bytes > MAX_MESSAGE_BYTES + @message_bytes -= @messages.shift.to_s.bytesize + end + end + end + + class State + MAX_COMMUNICATIONS = 200 + + attr_reader :character, :room, :items, :skill_groups, :skill_lists, + :skill_info, :group, :communications + + def initialize + reset + end + + def reset + @character = {} + @room = {} + @items = {} + @skill_groups = [] + @skill_lists = {} + @skill_info = {} + @group = {} + @communications = [] + end + + def apply(message) + key = message.key + data = message.data + case key + when "char.base", "char.name", "char.vitals", "char.stats", "char.maxstats", "char.statusvars", "char.status", "char.worth" + @character[key.split(".", 2)[1]] = copy(data) + when "room.info" + @room["info"] = hash(data) + when "room.players" + @room["players"] = array(data) + when "room.addplayer" + players = (@room["players"] ||= []) + additions = data.is_a?(Array) ? array(data) : [hash(data)] + additions.each do |player| + name = hash(player)["name"].to_s.downcase + players.reject! { |row| hash(row)["name"].to_s.downcase == name } + players << copy(player) + end + when "room.removeplayer" + name = data.is_a?(Hash) ? data["name"] : data + (@room["players"] ||= []).reject! { |row| hash(row)["name"].to_s.casecmp?(name.to_s) } + when "room.wrongdir" + @room["wrong_direction"] = copy(data) + when "char.items.list" + value = hash(data) + @items[value["location"].to_s] = array(value["items"]) + when "char.items.add", "char.items.update" + update_item(hash(data), key.end_with?("update")) + when "char.items.remove" + remove_item(hash(data)) + when "char.skills.group", "char.skills.groups" + @skill_groups = array(data) + when "char.skills.list" + value = hash(data) + @skill_lists[value["group"].to_s] = copy(value) + when "char.skills.info" + value = hash(data) + @skill_info[[value["group"].to_s, (value["skill"] || value["name"]).to_s]] = copy(value) + when "group" + @group = hash(data) + else + add_communication(message) if key.start_with?("comm.") + end + self + end + + private + + def hash(value) + value.is_a?(Hash) ? copy(value) : {} + end + + def array(value) + value.is_a?(Array) ? copy(value) : [] + end + + def copy(value) + Marshal.load(Marshal.dump(value)) + rescue TypeError + value + end + + def update_item(data, update) + location = data["location"].to_s + item = hash(data["item"]) + rows = (@items[location] ||= []) + id = item["id"].to_s + index = rows.index { |row| hash(row)["id"].to_s == id } + if index == nil + rows << item + elsif update + rows[index] = hash(rows[index]).merge(item) + else + rows[index] = item + end + end + + def remove_item(data) + location = data["location"].to_s + item = data["item"] + id = item.is_a?(Hash) ? item["id"] : item + (@items[location] ||= []).reject! { |row| hash(row)["id"].to_s == id.to_s } + end + + def add_communication(message) + @communications << {"package" => message.package, "data" => copy(message.data)} + @communications.shift while @communications.size > MAX_COMMUNICATIONS + end + end + end +end diff --git a/lib/meow/media.rb b/lib/meow/media.rb new file mode 100644 index 0000000..65b736f --- /dev/null +++ b/lib/meow/media.rb @@ -0,0 +1,487 @@ +require "digest" +require "fileutils" +require "ipaddr" +require "net/http" +require "uri" + +module MeowClient + module Media + 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 + ) + StopRequest = Struct.new( + :name, :kind, :tag, :priority, :key, :fade_away, :fade_out, + keyword_init: true + ) + PreloadRequest = Struct.new(:name, :url, keyword_init: true) + DownloadResult = Struct.new(:id, :path, :url, :error, keyword_init: true) + 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 + + def resolve(base_url, name, explicit_url = nil) + 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)) + value = clean_name == "" ? base_uri : URI.join(ensure_directory(base_uri.to_s), clean_name) + validate_uri(value) + 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" + 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]/) + reject_private_literal(uri.host) + uri + end + + def validate_name(name) + value = name.to_s.tr("\\", "/") + raise ArgumentError, "Media name contains control characters" if value.match?(/[\x00-\x1f\x7f]/) + raise ArgumentError, "Media name must be relative" if value.start_with?("/", "//") || value.match?(/\A[A-Za-z]:/) + segments = value.split("/") + raise ArgumentError, "Media name may not traverse parent directories" if segments.include?("..") + value + end + + def supported_audio?(uri) + AUDIO_EXTENSIONS.include?(File.extname(uri.path.to_s).downcase) + end + + private + + def ensure_directory(value) + value.end_with?("/") ? value : value + "/" + end + + def reject_private_literal(host) + ip = IPAddr.new(host) + private_ip = ip.loopback? || ip.link_local? || ip.private? || ip.multicast? || ip.unspecified? + raise ArgumentError, "Private or reserved media addresses are not allowed" if private_ip + rescue IPAddr::InvalidAddressError + end + end + + class Downloader + MAX_FILE_BYTES = 25 * 1_048_576 + MAX_CACHE_BYTES = 100 * 1_048_576 + MAX_QUEUE = 32 + MAX_REDIRECTS = 5 + OPEN_TIMEOUT = 10 + READ_TIMEOUT = 15 + + attr_reader :results + + def initialize(cache_directory, policy = URLPolicy.new) + @cache_directory = cache_directory.to_s + @policy = policy + @queue = SizedQueue.new(MAX_QUEUE) + @results = Queue.new + @closed = false + FileUtils.mkdir_p(@cache_directory) + @worker = Thread.new { work } + @worker.report_on_exception = false + end + + def enqueue(id, uri) + return false if @closed + cached = cache_path(uri) + if File.file?(cached) + File.utime(Time.now, Time.now, cached) rescue nil + @results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s) + return true + end + @queue.push([id, uri], true) + true + rescue ThreadError + false + end + + def close + return if @closed + @closed = true + @queue.push(nil, true) rescue nil + @worker.join(1.0) if @worker != nil && @worker != Thread.current + @worker.kill if @worker != nil && @worker.alive? + nil + end + + private + + def work + loop do + job = @queue.pop + break if job == nil || @closed + id, uri = job + begin + path, final_uri = download(uri) + @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) + end + end + end + + def download(uri, redirects = 0) + raise "Too many media redirects" if redirects > MAX_REDIRECTS + current = @policy.validate_uri(uri) + response = nil + Net::HTTP.start( + current.host, + current.port, + :use_ssl => true, + :open_timeout => OPEN_TIMEOUT, + :read_timeout => READ_TIMEOUT + ) do |http| + request = Net::HTTP::Get.new(current.request_uri) + http.request(request) do |incoming| + response = incoming + 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) + end + raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess) + length = Integer(incoming["content-length"], :exception => false) + raise "Media file exceeds #{MAX_FILE_BYTES} bytes" if length != nil && length > MAX_FILE_BYTES + target = cache_path(current) + temporary = target + ".part-#{Thread.current.object_id}" + total = 0 + begin + File.open(temporary, "wb") do |file| + incoming.read_body do |chunk| + total += chunk.bytesize + raise "Media file exceeds #{MAX_FILE_BYTES} bytes" if total > MAX_FILE_BYTES + file.write(chunk) + end + end + if File.file?(target) + File.delete(temporary) + else + File.rename(temporary, target) + end + ensure + File.delete(temporary) if File.exist?(temporary) + end + prune_cache(target) + return [target, current] + end + end + raise "Media download failed" if response == nil + end + + def cache_path(uri) + extension = File.extname(uri.path.to_s).downcase + extension = ".media" unless URLPolicy::AUDIO_EXTENSIONS.include?(extension) + File.join(@cache_directory, Digest::SHA256.hexdigest(uri.to_s) + extension) + end + + def prune_cache(protected_path) + rows = Dir.glob(File.join(@cache_directory, "*")).filter_map do |file| + next unless File.file?(file) && !File.basename(file).include?(".part-") + stat = File.stat(file) + [file, stat.size, stat.mtime] + rescue SystemCallError + nil + end + total = rows.sum { |row| row[1] } + rows.sort_by { |row| row[2] }.each do |file, size, _time| + break if total <= MAX_CACHE_BYTES + next if file == protected_path + File.delete(file) rescue next + total -= size + end + end + end + + class EltenSoundHandle + def initialize(path, request, category_volume) + @sound = Sound.new(path, :loop => request.loops.to_i == -1) + @target_volume = [[request.volume.to_i, 0].max, 100].min / 100.0 * category_volume + @sound.position = request.start_at.to_f / 1000.0 if request.start_at.to_i > 0 + volume = @sound.attribute(:volume) + volume.value = request.fade_in.to_i > 0 ? 0.0 : @target_volume + @sound.play + volume.slide(@target_volume, :duration => request.fade_in.to_f / 1000.0) if request.fade_in.to_i > 0 + end + + def finished? + @sound.finished? + end + + def position_ms + (@sound.position.to_f * 1000).to_i + end + + def restart(position_ms) + @sound.position = position_ms.to_f / 1000.0 + @sound.play + end + + def stop(fade_ms = 0) + if fade_ms.to_i > 0 && !finished? + @sound.attribute(:volume).slide(0.0, :duration => fade_ms.to_f / 1000.0) + Thread.new do + sleep(fade_ms.to_f / 1000.0) + close + end + else + close + end + end + + def close + @sound.stop rescue nil + @sound.close rescue nil + end + end + + class EltenBackend + def start(path, request, category_volume) + EltenSoundHandle.new(path, request, category_volume) + end + end + + class Manager + MAX_TRACKS = 16 + + attr_reader :captions, :errors, :tracks + attr_reader :muted + + def initialize(cache_directory, sound_volume: 1.0, music_volume: 1.0, backend: EltenBackend.new, downloader: nil) + @policy = URLPolicy.new + @downloader = downloader || Downloader.new(cache_directory, @policy) + @backend = backend + @sound_volume = sound_volume.to_f + @music_volume = music_volume.to_f + @tracks = [] + @pending = {} + @sequence = 0 + @captions = [] + @pending_captions = [] + @errors = [] + @default_url = nil + @loaded_urls = {} + @muted = false + end + + def default_url=(value) + @default_url = @policy.validate_uri(URI.parse(value.to_s)).to_s + rescue ArgumentError, URI::InvalidURIError => error + add_error(error.message) + end + + def preload(request) + queue(request, false) + end + + def play(request) + request.kind = normalize_kind(request.kind) + request.volume = 50 if request.volume == nil + request.loops = 1 if request.loops == nil || request.loops.to_i == 0 + request.priority = 50 if request.priority == nil + remember_caption(request.caption) + return nil if @muted + existing = matching_identity(request) + return existing if request.continue_existing != false && existing != nil + stop(StopRequest.new(:key => request.key)) if request.key.to_s != "" + queue(request, true) + rescue Exception => error + add_error(error.message) + nil + end + + def muted=(value) + @muted = value == true + stop if @muted + @muted + end + + def stop(request = StopRequest.new) + pending_ids = @pending.filter_map do |id, (pending_request, play_after)| + id if play_after && stop_match?(pending_request, request) + end + pending_ids.each { |id| @pending.delete(id) } + + selected = @tracks.select { |track| stop_match?(track.request, request) } + selected.each do |track| + fade = request.fade_away ? (track.request.fade_out || request.fade_out || 5_000) : (request.fade_out || 0) + track.handle.stop(fade) + @tracks.delete(track) + end + selected.size + pending_ids.size + end + + def drain_captions + values = @pending_captions + @pending_captions = [] + values + end + + def tick + drain_downloads + @tracks.dup.each do |track| + request = track.request + if request.finish_at.to_i > 0 && track.handle.position_ms >= request.finish_at.to_i + finish_iteration(track) + elsif track.handle.finished? + finish_iteration(track) + end + rescue Exception => error + add_error(error.message) + remove_track(track) + end + end + + def close + @pending.clear + @tracks.dup.each { |track| remove_track(track) } + @downloader.close + nil + end + + private + + 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)) + else + base = request.url.to_s == "" ? @default_url : request.url + uri = @policy.resolve(base, request.name, request.url) + end + raise "Unsupported audio file type" unless @policy.supported_audio?(uri) + @sequence += 1 + @pending[@sequence] = [request, play_after] + unless @downloader.enqueue(@sequence, uri) + @pending.delete(@sequence) + raise "Media download queue is full" + end + @loaded_urls[request.name.to_s] = uri.to_s + @sequence + end + + def drain_downloads + loop do + result = @downloader.results.pop(true) + request, play_after = @pending.delete(result.id) + next if request == nil + if result.error != nil + add_error(result.error) + elsif play_after + start_track(request, result.path, result.url) + end + end + rescue ThreadError + end + + def start_track(request, path, url) + priority = request.priority.to_i + return if @tracks.any? { |track| track.request.priority.to_i > priority } + @tracks.select { |track| track.request.priority.to_i < priority }.each { |track| remove_track(track) } + if @tracks.size >= MAX_TRACKS + victim = @tracks.min_by { |track| [track.request.priority.to_i, track.started_at] } + return if victim.request.priority.to_i > priority + remove_track(victim) + end + volume = request.kind == "music" ? @music_volume : @sound_volume + handle = @backend.start(path, request, volume) + loops = request.loops.to_i + @tracks << Track.new( + :request => request, + :handle => handle, + :url => url, + :remaining_loops => loops < 0 ? -1 : loops, + :started_at => Process.clock_gettime(Process::CLOCK_MONOTONIC) + ) + end + + def finish_iteration(track) + if track.remaining_loops == -1 + track.handle.restart(track.request.start_at.to_i) + elsif track.remaining_loops > 1 + track.remaining_loops -= 1 + track.handle.restart(track.request.start_at.to_i) + else + remove_track(track) + end + end + + def remove_track(track) + @tracks.delete(track) + track.handle.close rescue nil + end + + def matching_identity(request) + @tracks.find do |track| + (request.key.to_s != "" && track.request.key.to_s == request.key.to_s) || + track.request.name.to_s == request.name.to_s + end + end + + def stop_match?(playing, stop) + filters = [] + filters << playing.name.to_s == stop.name.to_s if stop.name.to_s != "" + filters << playing.kind.to_s == stop.kind.to_s if stop.kind.to_s != "" + filters << playing.tag.to_s == stop.tag.to_s if stop.tag.to_s != "" + filters << playing.key.to_s == stop.key.to_s if stop.key.to_s != "" + filters << playing.priority.to_i <= stop.priority.to_i if stop.priority != nil + filters.empty? || filters.all? + end + + def normalize_kind(value) + %w[sound music video].include?(value.to_s.downcase) ? value.to_s.downcase : "sound" + end + + def remember_caption(value) + return if value.to_s.strip == "" + @captions << value.to_s + @pending_captions << value.to_s + @captions.shift while @captions.size > 100 + end + + def add_error(value) + @errors << value.to_s + @errors.shift while @errors.size > 50 + end + end + + module ClientMedia + module_function + + def play_request(data) + value = data.is_a?(Hash) ? data : {} + PlayRequest.new( + :name => value["name"], :url => value["url"], :kind => value["type"], + :tag => value["tag"], :volume => value["volume"], :fade_in => value["fadein"], + :fade_out => value["fadeout"], :start_at => value["start"], :finish_at => value["finish"], + :loops => value["loops"], :priority => value["priority"], + :continue_existing => value.fetch("continue", true), :key => value["key"], + :caption => value["caption"] + ) + end + + def stop_request(data) + value = data.is_a?(Hash) ? data : {} + StopRequest.new( + :name => value["name"], :kind => value["type"], :tag => value["tag"], + :priority => value["priority"], :key => value["key"], + :fade_away => value["fadeaway"] == true, :fade_out => value["fadeout"] + ) + end + + def preload_request(data) + value = data.is_a?(Hash) ? data : {} + PreloadRequest.new(:name => value["name"], :url => value["url"]) + end + end + end +end diff --git a/lib/meow/profile_repository.rb b/lib/meow/profile_repository.rb index 6747c66..640af83 100644 --- a/lib/meow/profile_repository.rb +++ b/lib/meow/profile_repository.rb @@ -3,6 +3,7 @@ require "securerandom" module MeowClient ConnectionProfile = Struct.new( :id, :name, :host, :port, :tls, :encoding, :auto_read, + :remote_media_enabled, :speak_media_captions, :sound_volume, :music_volume, keyword_init: true ) do def to_h @@ -13,14 +14,18 @@ module MeowClient "port" => port, "tls" => tls == true, "encoding" => encoding, - "auto_read" => auto_read != false + "auto_read" => auto_read != false, + "remote_media_enabled" => remote_media_enabled == true, + "speak_media_captions" => speak_media_captions == true, + "sound_volume" => sound_volume.to_i, + "music_volume" => music_volume.to_i } end end class ProfileRepository FILE_NAME = "profiles.json" - SCHEMA_VERSION = 1 + SCHEMA_VERSION = 2 ENCODINGS = { "UTF-8" => "UTF-8", "Windows-1252" => "Windows-1252", @@ -74,7 +79,11 @@ module MeowClient :port => attributes[:port] || 23, :tls => attributes[:tls] == true, :encoding => attributes[:encoding] || "UTF-8", - :auto_read => attributes.fetch(:auto_read, true) + :auto_read => attributes.fetch(:auto_read, true), + :remote_media_enabled => attributes[:remote_media_enabled] == true, + :speak_media_captions => attributes[:speak_media_captions] == true, + :sound_volume => attributes.fetch(:sound_volume, 100), + :music_volume => attributes.fetch(:music_volume, 100) ) end @@ -85,11 +94,18 @@ module MeowClient port = Integer(profile.port, :exception => false) errors << _("Port must be between 1 and 65535.") if port == nil || port < 1 || port > 65_535 errors << _("Choose a supported character encoding.") unless ENCODINGS.value?(profile.encoding.to_s) + errors << _("Sound volume must be between 0 and 100.") unless valid_volume?(profile.sound_volume) + errors << _("Music volume must be between 0 and 100.") unless valid_volume?(profile.music_volume) errors end private + def valid_volume?(value) + number = Integer(value, :exception => false) + number != nil && number >= 0 && number <= 100 + end + def default_document {"version" => SCHEMA_VERSION, "profiles" => []} end @@ -102,7 +118,11 @@ module MeowClient :port => Integer(profile.port, :exception => false) || 23, :tls => profile.tls == true, :encoding => ENCODINGS.value?(profile.encoding.to_s) ? profile.encoding.to_s : "UTF-8", - :auto_read => profile.auto_read != false + :auto_read => profile.auto_read != false, + :remote_media_enabled => profile.remote_media_enabled == true, + :speak_media_captions => profile.speak_media_captions == true, + :sound_volume => [[profile.sound_volume.to_i, 0].max, 100].min, + :music_volume => [[profile.music_volume.to_i, 0].max, 100].min ) end @@ -115,7 +135,11 @@ module MeowClient :port => row["port"], :tls => row["tls"] == true, :encoding => row["encoding"], - :auto_read => row["auto_read"] != false + :auto_read => row["auto_read"] != false, + :remote_media_enabled => row["remote_media_enabled"] == true, + :speak_media_captions => row["speak_media_captions"] == true, + :sound_volume => row.fetch("sound_volume", 100), + :music_volume => row.fetch("music_volume", 100) ) validate(profile).empty? ? profile : nil end diff --git a/lib/meow/session.rb b/lib/meow/session.rb index d3fbe4e..c3c58ac 100644 --- a/lib/meow/session.rb +++ b/lib/meow/session.rb @@ -1,5 +1,8 @@ +require_relative "session_gmcp" + module MeowClient class Session + include SessionGMCP MAX_LINES = 5_000 MAX_BYTES = 1_048_576 MAX_HISTORY = 100 @@ -20,6 +23,10 @@ module MeowClient @speech_paused = profile.auto_read == false @speech_overflow = 0 @sensitive_next = false + @gmcp_enabled = false + @gmcp_state = GMCP::State.new + @gmcp_log = GMCP::MessageLog.new + @media = nil @closed = false build_form end @@ -28,6 +35,7 @@ module MeowClient @form.wait ensure @pipeline.close + @media.close if @media != nil @transport.close @program.release(@transport, :close => false) end @@ -46,9 +54,10 @@ module MeowClient @send = Button.new(_("Send")) @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")) @disconnect = Button.new(_("Disconnect")) @form = Form.new( - [@status, @output, @command, @send, @speech_button, @stop_speech, @disconnect], + [@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @disconnect], :quiet => true ) @form.accept_button = @send @@ -58,6 +67,7 @@ module MeowClient @send.on(:press) { send_command } @speech_button.on(:press) { toggle_speech } @stop_speech.on(:press) { speech_stop } + @gmcp_button.on(:press) { open_gmcp_hub } @disconnect.on(:press) { request_disconnect } @output.add_tip(_("Use the context menu to jump to the latest output.")) @@ -83,6 +93,8 @@ module MeowClient record = @pipeline.flush_prompt receive_record(record) if record != nil end + @media.tick if @media != nil + speak_new_media_captions dispatch_speech rescue Telnet::CompressionError add_system_line(_("The compressed data stream could not be processed safely. The connection was closed.")) @@ -100,6 +112,7 @@ module MeowClient when :bytes 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) } when :disconnected, :error @pipeline.finish.each { |record| receive_record(record) } @@ -258,6 +271,8 @@ module MeowClient def close_form return if @closed @closed = true + @media.close if @media != nil + @media = nil @transport.close @form.resume end diff --git a/lib/meow/session_gmcp.rb b/lib/meow/session_gmcp.rb new file mode 100644 index 0000000..d7830dd --- /dev/null +++ b/lib/meow/session_gmcp.rb @@ -0,0 +1,272 @@ +module MeowClient + module SessionGMCP + private + + def handle_protocol_event(event) + type, payload = event + case type + when :gmcp_enabled + payload ? enable_gmcp : disable_gmcp + when :gmcp + receive_gmcp(payload) + when :gmcp_error + @gmcp_log.add_error(payload) + end + end + + def enable_gmcp + return if @gmcp_enabled + @gmcp_enabled = true + @gmcp_button.label = _("GMCP information, available") if @gmcp_button != nil + send_gmcp("Core.Hello", {"client" => "Meow", "version" => MeowClient::VERSION}) + 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 + + def disable_gmcp + return unless @gmcp_enabled + @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 + + def send_gmcp(package, data = :__absent__) + return false unless @gmcp_enabled + @transport.send_bytes(Telnet.gmcp_packet(package, data)) + rescue ArgumentError => error + @gmcp_log.add_error(error.message) + false + end + + def send_gmcp_raw(body) + return false unless @gmcp_enabled + @transport.send_bytes(Telnet.gmcp_raw_packet(body)) + rescue ArgumentError => error + @gmcp_log.add_error(error.message) + false + end + + def receive_gmcp(message) + @gmcp_log.add(message) + @gmcp_state.apply(message) + case message.key + when "core.goodbye" + add_system_line(message.data.to_s) + when "core.ping" + send_gmcp("Core.Ping") + when "client.media.default" + @media.default_url = message.data["url"] if @media != nil && message.data.is_a?(Hash) + when "client.media.load" + @media.preload(Media::ClientMedia.preload_request(message.data)) if @media != nil + when "client.media.play" + @media.play(Media::ClientMedia.play_request(message.data)) if @media != nil + when "client.media.stop" + @media.stop(Media::ClientMedia.stop_request(message.data)) if @media != nil + end + rescue Exception => error + @gmcp_log.add_error("#{message.package}: #{error.message}") + end + + def speak_new_media_captions + return if @media == nil + captions = @media.drain_captions + return unless @profile.speak_media_captions + captions.each do |caption| + enqueue_speech(_("Sound: %{caption}") % {:caption => caption}) + end + end + + def open_gmcp_hub + return alert(_("This server has not negotiated GMCP."), false) unless @gmcp_enabled + status = Static.new(_("GMCP is active.")) + character = Button.new(_("Character")) + room = Button.new(_("Room")) + items = Button.new(_("Items")) + skills = Button.new(_("Skills")) + group = Button.new(_("Group")) + communications = Button.new(_("Communications")) + media = Button.new(_("Media")) + inspector = Button.new(_("Raw GMCP inspector")) + back = Button.new(_("Back")) + form = Form.new( + [status, character, room, items, skills, group, communications, media, inspector, back], + :quiet => true + ) + form.cancel_button = back + form.add_timer(FormTimer.new(0.05, :repeat => true) { tick }) + character.on(:press) { show_live_text(_("Character")) { formatted_or_empty(@gmcp_state.character) } } + room.on(:press) { show_live_text(_("Room")) { formatted_or_empty(@gmcp_state.room) } } + items.on(:press) { open_items_view } + skills.on(:press) { open_skills_view } + group.on(:press) { open_group_view } + communications.on(:press) { show_live_text(_("Communications")) { formatted_or_empty(@gmcp_state.communications) } } + media.on(:press) { open_media_view } + inspector.on(:press) { show_live_text(_("Raw GMCP inspector")) { @gmcp_log.text } } + back.on(:press) { form.resume } + form.wait + end + + def show_live_text(title, &source) + output = EditBox.new( + title, + :type => EditBox::Flags::ReadOnly | EditBox::Flags::MultiLine, + :text => source.call.to_s + ) + back = Button.new(_("Back")) + form = Form.new([output, back], :quiet => true) + form.cancel_button = back + form.add_timer(FormTimer.new(0.1, :repeat => true) do + tick + output.set_text(source.call.to_s, false) + end) + back.on(:press) { form.resume } + form.wait + end + + def open_items_view + rows = @gmcp_state.items.values.flatten + labels = rows.map { |item| item.is_a?(Hash) ? item["name"].to_s : item.to_s } + list = ListBox.new(labels, :header => _("Known items"), :empty_label => _("No item data has been received.")) + inventory = Button.new(_("Refresh inventory")) + room = Button.new(_("Refresh room items")) + contents = Button.new(_("Open selected container")) + details = Button.new(_("View all item details")) + back = Button.new(_("Back")) + form = Form.new([list, inventory, room, contents, details, back], :quiet => true) + form.cancel_button = back + form.add_timer(FormTimer.new(0.05, :repeat => true) { tick }) + inventory.on(:press) { send_gmcp("Char.Items.Inv", "") } + room.on(:press) { send_gmcp("Char.Items.Room", "") } + contents.on(:press) do + item = rows[list.index] + if item.is_a?(Hash) && item["id"] != nil + send_gmcp("Char.Items.Contents", item["id"]) + else + alert(_("Select an item with an identifier."), false) + end + end + details.on(:press) { show_live_text(_("Item details")) { formatted_or_empty(@gmcp_state.items) } } + back.on(:press) { form.resume } + form.wait + end + + def open_skills_view + groups = @gmcp_state.skill_groups + labels = groups.map do |group| + group.is_a?(Hash) ? "#{group["name"]}: #{group["rank"]}" : group.to_s + end + list = ListBox.new(labels, :header => _("Skill groups"), :empty_label => _("No skill groups have been received.")) + refresh = Button.new(_("Refresh skill groups")) + load = Button.new(_("Load selected group")) + details = Button.new(_("View loaded skills")) + back = Button.new(_("Back")) + form = Form.new([list, refresh, load, details, back], :quiet => true) + form.cancel_button = back + form.add_timer(FormTimer.new(0.05, :repeat => true) { tick }) + refresh.on(:press) { send_gmcp("Char.Skills.Get", {}) } + load.on(:press) do + group = groups[list.index] + name = group.is_a?(Hash) ? group["name"] : group + send_gmcp("Char.Skills.Get", {"group" => name.to_s}) if name != nil + end + details.on(:press) do + show_live_text(_("Skills")) do + formatted_or_empty({"lists" => @gmcp_state.skill_lists, "details" => @gmcp_state.skill_info}) + end + end + back.on(:press) { form.resume } + form.wait + end + + def open_group_view + enable = Button.new(_("Enable Aardwolf group updates")) + details = Button.new(_("View group")) + back = Button.new(_("Back")) + form = Form.new([enable, details, back], :quiet => true) + form.cancel_button = back + form.add_timer(FormTimer.new(0.05, :repeat => true) { tick }) + enable.on(:press) { send_gmcp_raw("group on") } + details.on(:press) { show_live_text(_("Group")) { formatted_or_empty(@gmcp_state.group) } } + back.on(:press) { form.resume } + form.wait + end + + def open_media_view + return alert(_("Remote media is disabled for this connection profile."), false) if @media == nil + summary = EditBox.new( + _("Media activity"), + :type => EditBox::Flags::ReadOnly | EditBox::Flags::MultiLine, + :text => media_text + ) + mute = Button.new(@media.muted ? _("Unmute media") : _("Mute media")) + stop_music = Button.new(_("Stop music")) + stop_sounds = Button.new(_("Stop sounds")) + stop_all = Button.new(_("Stop all media")) + back = Button.new(_("Back")) + form = Form.new([summary, mute, stop_music, stop_sounds, stop_all, back], :quiet => true) + form.cancel_button = back + form.add_timer(FormTimer.new(0.1, :repeat => true) do + tick + summary.set_text(media_text, false) + end) + mute.on(:press) do + @media.muted = !@media.muted + mute.label = @media.muted ? _("Unmute media") : _("Mute media") + end + stop_music.on(:press) { @media.stop(Media::StopRequest.new(:kind => "music")) } + stop_sounds.on(:press) { @media.stop(Media::StopRequest.new(:kind => "sound")) } + stop_all.on(:press) { @media.stop } + back.on(:press) { form.resume } + form.wait + end + + def media_text + lines = [_("Active tracks: %{count}") % {:count => @media.tracks.size}] + lines << _("Captions:") + "\n" + @media.captions.join("\n") unless @media.captions.empty? + lines << _("Errors:") + "\n" + @media.errors.join("\n") unless @media.errors.empty? + lines.join("\n\n") + end + + def formatted_or_empty(value) + return _("No data has been received.") if value.respond_to?(:empty?) && value.empty? + format_gmcp_value(value) + end + + def format_gmcp_value(value, indent = 0) + prefix = " " * indent + case value + when Hash + value.keys.sort_by(&:to_s).map do |key| + child = value[key] + if child.is_a?(Hash) || child.is_a?(Array) + "#{prefix}#{key}:\n#{format_gmcp_value(child, indent + 1)}" + else + "#{prefix}#{key}: #{child}" + end + end.join("\n") + when Array + value.each_with_index.map do |child, index| + if child.is_a?(Hash) || child.is_a?(Array) + "#{prefix}#{index + 1}:\n#{format_gmcp_value(child, indent + 1)}" + else + "#{prefix}#{child}" + end + end.join("\n") + else + "#{prefix}#{value}" + end + end + end +end diff --git a/lib/meow/telnet.rb b/lib/meow/telnet.rb index 39c3d7f..ce60664 100644 --- a/lib/meow/telnet.rb +++ b/lib/meow/telnet.rb @@ -1,4 +1,5 @@ require "zlib" +require_relative "gmcp" module MeowClient module Telnet @@ -14,13 +15,28 @@ module MeowClient SUPPRESS_GO_AHEAD = 3 TERMINAL_TYPE = 24 COMPRESS2 = 86 + GMCP = 201 TTYPE_IS = 0 TTYPE_SEND = 1 MAX_INFLATED_BYTES_PER_FEED = 1_048_576 + MAX_SUBNEGOTIATION_BYTES = 65_536 Result = Struct.new(:data, :replies, :events, keyword_init: true) CompressionError = Class.new(StandardError) + def self.gmcp_packet(package, data = :__absent__) + payload = MeowClient::GMCP::Codec.encode(package, data) + escaped = payload.gsub([IAC].pack("C"), [IAC, IAC].pack("C*")) + [IAC, SB, GMCP].pack("C*") + escaped + [IAC, SE].pack("C*") + end + + def self.gmcp_raw_packet(payload) + value = payload.to_s.encode(Encoding::UTF_8).b + raise ArgumentError, "GMCP message exceeds #{MAX_SUBNEGOTIATION_BYTES} bytes" if value.bytesize > MAX_SUBNEGOTIATION_BYTES + escaped = value.gsub([IAC].pack("C"), [IAC, IAC].pack("C*")) + [IAC, SB, GMCP].pack("C*") + escaped + [IAC, SE].pack("C*") + end + class Parser attr_reader :remote_echo @@ -29,6 +45,7 @@ module MeowClient @negotiation_command = nil @subnegotiation_option = nil @subnegotiation = +"".b + @subnegotiation_overflow = false @remote_echo = false @remote_options = {} @local_options = {} @@ -89,16 +106,17 @@ module MeowClient when :sb_option @subnegotiation_option = byte @subnegotiation.clear + @subnegotiation_overflow = false @state = :sb when :sb if byte == IAC @state = :sb_iac else - @subnegotiation << byte + append_subnegotiation(byte) end when :sb_iac if byte == IAC - @subnegotiation << IAC + append_subnegotiation(IAC) @state = :sb elsif byte == SE reply, event = finish_subnegotiation @@ -149,7 +167,7 @@ module MeowClient def negotiate(command, option) case command when WILL - enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2].include?(option) + enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option) changed = @remote_options[option] != enabled @remote_options[option] = enabled return [nil, nil] unless changed @@ -157,6 +175,7 @@ module MeowClient @remote_echo = enabled return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]] end + return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled [packet(enabled ? DO : DONT, option), nil] when WONT changed = @remote_options[option] != false @@ -165,6 +184,7 @@ module MeowClient @remote_echo = false return [packet(DONT, option), [:remote_echo, false]] if changed end + return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed [changed ? packet(DONT, option) : nil, nil] when DO enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option) @@ -181,6 +201,17 @@ module MeowClient end def finish_subnegotiation + if @subnegotiation_overflow + return [nil, [:gmcp_error, "Telnet subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == GMCP + return [nil, [:subnegotiation, @subnegotiation_option]] + end + if @subnegotiation_option == GMCP && @remote_options[GMCP] == true + begin + return [nil, [:gmcp, MeowClient::GMCP::Codec.parse(@subnegotiation)]] + rescue MeowClient::GMCP::ParseError => error + return [nil, [:gmcp_error, error.message]] + end + end if @subnegotiation_option == COMPRESS2 && @remote_options[COMPRESS2] == true && @subnegotiation.empty? @@ -198,6 +229,15 @@ module MeowClient ensure @subnegotiation_option = nil @subnegotiation.clear + @subnegotiation_overflow = false + end + + def append_subnegotiation(byte) + if @subnegotiation.bytesize < MAX_SUBNEGOTIATION_BYTES + @subnegotiation << byte + else + @subnegotiation_overflow = true + end end end end diff --git a/lib/meow/ui.rb b/lib/meow/ui.rb index 98725d9..058d8f7 100644 --- a/lib/meow/ui.rb +++ b/lib/meow/ui.rb @@ -81,6 +81,10 @@ 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) + 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) encoding_names = ProfileRepository::ENCODINGS.keys encoding_index = [ProfileRepository::ENCODINGS.values.index(profile.encoding) || 0, 0].max encoding = ChoiceListBox.new( @@ -89,7 +93,7 @@ module MeowClient ) save = Button.new(_("Save")) cancel = Button.new(_("Cancel")) - form = Form.new([name, host, port, tls, auto_read, encoding, save, cancel], :quiet => true) + form = Form.new([name, host, port, tls, auto_read, remote_media, speak_captions, sound_volume, music_volume, encoding, save, cancel], :quiet => true) form.accept_button = save form.cancel_button = cancel saved = false @@ -102,7 +106,11 @@ module MeowClient :port => port.text, :tls => tls.checked, :encoding => ProfileRepository::ENCODINGS[encoding_names[encoding.value(0)]], - :auto_read => auto_read.checked + :auto_read => auto_read.checked, + :remote_media_enabled => remote_media.checked, + :speak_media_captions => speak_captions.checked, + :sound_volume => sound_volume.text, + :music_volume => music_volume.text ) errors = @profiles.validate(candidate) if errors.empty? diff --git a/lib/meow/version.rb b/lib/meow/version.rb new file mode 100644 index 0000000..ab3772b --- /dev/null +++ b/lib/meow/version.rb @@ -0,0 +1,3 @@ +module MeowClient + VERSION = "0.2.0" +end diff --git a/test/gmcp_test.rb b/test/gmcp_test.rb new file mode 100644 index 0000000..70ad03d --- /dev/null +++ b/test/gmcp_test.rb @@ -0,0 +1,81 @@ +# encoding: UTF-8 + +require_relative "test_helper" +require_relative "../lib/meow/gmcp" +require_relative "../lib/meow/telnet" + +module MeowGMCPTest + Telnet = MeowClient::Telnet + GMCP = MeowClient::GMCP + + def self.bytes(*values) + values.pack("C*") + end + + def self.frame(payload) + bytes(Telnet::IAC, Telnet::SB, Telnet::GMCP) + payload.b + bytes(Telnet::IAC, Telnet::SE) + end + + MeowTest.test("GMCP negotiation sends DO and emits enabled once") do + parser = Telnet::Parser.new + packet = bytes(Telnet::IAC, Telnet::WILL, Telnet::GMCP) + first = parser.feed(packet) + duplicate = parser.feed(packet) + MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::GMCP)], first.replies) + MeowTest.assert_equal([[:gmcp_enabled, true]], first.events) + MeowTest.assert_empty(duplicate.replies) + MeowTest.assert_empty(duplicate.events) + end + + MeowTest.test("GMCP parses fragmented UTF-8 JSON without entering text output") do + parser = Telnet::Parser.new + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::GMCP)) + packet = frame('Room.Info {"name":"Café","exits":{"n":2}}'.encode("UTF-8")) + result = {:data => +"".b, :events => []} + packet.each_byte do |byte| + parsed = parser.feed(bytes(byte)) + result[:data] << parsed.data + result[:events].concat(parsed.events) + end + message = result[:events][0][1] + MeowTest.assert_empty(result[:data]) + MeowTest.assert_equal("Room.Info", message.package) + MeowTest.assert_equal("Café", message.data["name"]) + end + + MeowTest.test("GMCP reports invalid JSON and continues parsing") do + parser = Telnet::Parser.new + parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::GMCP)) + bad = parser.feed(frame("Char.Vitals {bad")) + good = parser.feed(frame('Char.Vitals {"hp":10}')) + MeowTest.assert_equal(:gmcp_error, bad.events[0][0]) + MeowTest.assert_equal(10, good.events[0][1].data["hp"]) + end + + MeowTest.test("GMCP outbound packets JSON encode and escape IAC") do + packet = Telnet.gmcp_packet("Core.Hello", {"client" => "Meow", "marker" => "\u00ff"}) + MeowTest.assert_equal(bytes(Telnet::IAC, Telnet::SB, Telnet::GMCP), packet.byteslice(0, 3)) + MeowTest.assert_equal(bytes(Telnet::IAC, Telnet::SE), packet.byteslice(-2, 2)) + MeowTest.assert(packet.include?('"client":"Meow"')) + end + + MeowTest.test("GMCP state applies incremental room and item updates") do + state = GMCP::State.new + state.apply(GMCP::Message.new(:package => "Room.Players", :data => [{"name" => "One"}])) + state.apply(GMCP::Message.new(:package => "room.addplayer", :data => {"name" => "Two"})) + state.apply(GMCP::Message.new(:package => "ROOM.REMOVEPLAYER", :data => "One")) + state.apply(GMCP::Message.new(:package => "Char.Items.List", :data => {"location" => "inv", "items" => [{"id" => 1, "name" => "coin"}]})) + state.apply(GMCP::Message.new(:package => "Char.Items.Update", :data => {"location" => "inv", "item" => {"id" => 1, "attrib" => "t"}})) + MeowTest.assert_equal(["Two"], state.room["players"].map { |row| row["name"] }) + MeowTest.assert_equal("coin", state.items["inv"][0]["name"]) + MeowTest.assert_equal("t", state.items["inv"][0]["attrib"]) + end + + MeowTest.test("GMCP codec enforces payload and nesting bounds") do + oversized = "Core.Ping " + ("x" * GMCP::MAX_PAYLOAD_BYTES) + MeowTest.assert_raises(GMCP::ParseError) { GMCP::Codec.parse(oversized) } + nested = "null" + (GMCP::MAX_DEPTH + 2).times { nested = "[#{nested}]" } + MeowTest.assert_raises(GMCP::ParseError) { GMCP::Codec.parse("Test.Value #{nested}") } + end +end diff --git a/test/media_test.rb b/test/media_test.rb new file mode 100644 index 0000000..7c99381 --- /dev/null +++ b/test/media_test.rb @@ -0,0 +1,154 @@ +# encoding: UTF-8 + +require_relative "test_helper" +require_relative "../lib/meow/media" + +module MeowMediaTest + Media = MeowClient::Media + + class FakeDownloader + attr_reader :results, :requests + + def initialize + @results = Queue.new + @requests = [] + end + + def enqueue(id, uri) + @requests << [id, uri] + true + end + + def close + end + end + + class FakeHandle + attr_accessor :position_ms + attr_reader :closed, :restarts, :fade + + def initialize + @position_ms = 0 + @closed = false + @finished = false + @restarts = [] + end + + def finished? + @finished + end + + def finish! + @finished = true + end + + def restart(position) + @finished = false + @restarts << position + end + + def stop(fade = 0) + @fade = fade + @closed = true + end + + def close + @closed = true + end + end + + class FakeBackend + attr_reader :starts + + def initialize + @starts = [] + end + + def start(path, request, volume) + handle = FakeHandle.new + @starts << [path, request, volume, handle] + handle + end + end + + MeowTest.test("Media URL policy accepts safe relative HTTPS audio") do + uri = Media::URLPolicy.new.resolve("https://media.example/game/", "weather/rain.ogg") + 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 + 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") } + MeowTest.assert_raises(ArgumentError) { policy.resolve("https://example.com/", "../secret.mp3") } + 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}) + MeowTest.assert_equal("rain.ogg", play.name) + MeowTest.assert_equal(-1, play.loops) + MeowTest.assert(stop.fade_away) + end + + MeowTest.test("Media stop cancels queued playback before its download completes") do + downloader = FakeDownloader.new + backend = FakeBackend.new + manager = Media::Manager.new("unused", :downloader => downloader, :backend => backend) + manager.default_url = "https://media.example/game/" + request = Media::PlayRequest.new(:name => "late.mp3", :kind => "sound", :key => "door") + id = manager.play(request) + MeowTest.assert_equal(1, manager.stop(Media::StopRequest.new(:key => "door"))) + downloader.results << Media::DownloadResult.new(:id => id, :path => "cached.mp3", :url => "https://media.example/game/late.mp3") + manager.tick + MeowTest.assert_empty(backend.starts) + ensure + manager.close if manager != nil + end + + MeowTest.test("Media priority prevents lower-priority playback") do + downloader = FakeDownloader.new + backend = FakeBackend.new + manager = Media::Manager.new("unused", :downloader => downloader, :backend => backend) + manager.default_url = "https://media.example/game/" + + high = Media::PlayRequest.new(:name => "high.mp3", :kind => "sound", :priority => 80) + high_id = manager.play(high) + downloader.results << Media::DownloadResult.new(:id => high_id, :path => "high.mp3", :url => "https://media.example/game/high.mp3") + manager.tick + + low = Media::PlayRequest.new(:name => "low.mp3", :kind => "sound", :priority => 20) + low_id = manager.play(low) + downloader.results << Media::DownloadResult.new(:id => low_id, :path => "low.mp3", :url => "https://media.example/game/low.mp3") + manager.tick + MeowTest.assert_equal(1, backend.starts.size) + MeowTest.assert_equal("high.mp3", backend.starts[0][0]) + ensure + manager.close if manager != nil + end + + MeowTest.test("Media manager downloads starts loops and selectively stops") do + downloader = FakeDownloader.new + backend = FakeBackend.new + manager = Media::Manager.new("unused", :downloader => downloader, :backend => backend, :music_volume => 0.5) + manager.default_url = "https://media.example/game/" + request = Media::PlayRequest.new(:name => "theme.mp3", :kind => "music", :loops => 2, :volume => 80, :caption => "Theme") + id = manager.play(request) + downloader.results << Media::DownloadResult.new(:id => id, :path => "cached.mp3", :url => "https://media.example/game/theme.mp3") + manager.tick + MeowTest.assert_equal(1, manager.tracks.size) + MeowTest.assert_equal(0.5, backend.starts[0][2]) + MeowTest.assert_equal(["Theme"], manager.captions) + MeowTest.assert_equal(["Theme"], manager.drain_captions) + MeowTest.assert_empty(manager.drain_captions) + handle = backend.starts[0][3] + handle.finish! + manager.tick + MeowTest.assert_equal([0], handle.restarts) + manager.stop(Media::StopRequest.new(:kind => "music", :fade_away => true)) + MeowTest.assert(handle.closed) + MeowTest.assert_equal(5_000, handle.fade) + ensure + manager.close if manager != nil + end +end diff --git a/test/run.rb b/test/run.rb index 4faae6e..8673de0 100644 --- a/test/run.rb +++ b/test/run.rb @@ -1,5 +1,7 @@ require_relative "test_helper" require_relative "telnet_test" +require_relative "gmcp_test" +require_relative "media_test" require_relative "text_pipeline_test" begin