Add GMCP support and shared media layer

This commit is contained in:
2026-09-17 17:15:46 +02:00
parent 158ee2568c
commit 3fd28c2e0b
13 changed files with 1344 additions and 18 deletions
+10 -5
View File
@@ -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 - 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, and Terminal Type support
- MCCP2 compressed-stream negotiation and bounded streaming decompression - 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 - Streaming ANSI control-sequence filtering
- Accessible scrollback and command entry - Accessible scrollback and command entry
- Automatic reading of incoming lines and prompts - 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/profile_repository.rb` stores and validates connection profiles.
- `lib/meow/transport.rb` owns TCP/TLS sockets and background I/O. - `lib/meow/transport.rb` owns TCP/TLS sockets and background I/O.
- `lib/meow/telnet.rb` implements the streaming Telnet state machine. - `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/text_pipeline.rb` handles ANSI filtering, decoding, lines, and prompts.
- `lib/meow/session.rb` owns connected-session state, scrollback, input, and speech. - `lib/meow/session.rb` owns connected-session state, scrollback, input, and speech.
- `lib/meow/ui.rb` implements the connection manager and profile editor. - `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: Planned work, roughly in order:
1. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector. 1. Add MSP parsing as a second adapter to the shared media request and playback layer.
2. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls. 2. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts.
3. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. 3. Add multiple simultaneous sessions with independent output, media, and speech queues.
4. Add multiple simultaneous sessions with independent output and speech queues. 4. Add localization catalogs and package/signing automation for releases.
5. Add localization catalogs and package/signing automation for releases.
## Running the tests ## Running the tests
+6 -2
View File
@@ -2,8 +2,8 @@
{ {
"id": "51a92051-84e0-415f-84e3-98f08a320a15", "id": "51a92051-84e0-415f-84e3-98f08a320a15",
"name": "Meow", "name": "Meow",
"version": "0.1.0", "version": "0.2.0",
"build_id": 3, "build_id": 4,
"EltenAPIVersion": "3.0.3", "EltenAPIVersion": "3.0.3",
"author": "Talon", "author": "Talon",
"main_language": "en", "main_language": "en",
@@ -16,10 +16,14 @@
} }
=end Elten3AppInfo =end Elten3AppInfo
require_relative "lib/meow/version"
require_relative "lib/meow/profile_repository" require_relative "lib/meow/profile_repository"
require_relative "lib/meow/gmcp"
require_relative "lib/meow/telnet" require_relative "lib/meow/telnet"
require_relative "lib/meow/text_pipeline" require_relative "lib/meow/text_pipeline"
require_relative "lib/meow/media"
require_relative "lib/meow/transport" require_relative "lib/meow/transport"
require_relative "lib/meow/session_gmcp"
require_relative "lib/meow/session" require_relative "lib/meow/session"
require_relative "lib/meow/ui" require_relative "lib/meow/ui"
+231
View File
@@ -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
+487
View File
@@ -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
+29 -5
View File
@@ -3,6 +3,7 @@ require "securerandom"
module MeowClient module MeowClient
ConnectionProfile = Struct.new( ConnectionProfile = Struct.new(
:id, :name, :host, :port, :tls, :encoding, :auto_read, :id, :name, :host, :port, :tls, :encoding, :auto_read,
:remote_media_enabled, :speak_media_captions, :sound_volume, :music_volume,
keyword_init: true keyword_init: true
) do ) do
def to_h def to_h
@@ -13,14 +14,18 @@ module MeowClient
"port" => port, "port" => port,
"tls" => tls == true, "tls" => tls == true,
"encoding" => encoding, "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
end end
class ProfileRepository class ProfileRepository
FILE_NAME = "profiles.json" FILE_NAME = "profiles.json"
SCHEMA_VERSION = 1 SCHEMA_VERSION = 2
ENCODINGS = { ENCODINGS = {
"UTF-8" => "UTF-8", "UTF-8" => "UTF-8",
"Windows-1252" => "Windows-1252", "Windows-1252" => "Windows-1252",
@@ -74,7 +79,11 @@ module MeowClient
:port => attributes[:port] || 23, :port => attributes[:port] || 23,
:tls => attributes[:tls] == true, :tls => attributes[:tls] == true,
:encoding => attributes[:encoding] || "UTF-8", :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 end
@@ -85,11 +94,18 @@ module MeowClient
port = Integer(profile.port, :exception => false) port = Integer(profile.port, :exception => false)
errors << _("Port must be between 1 and 65535.") if port == nil || port < 1 || port > 65_535 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 << _("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 errors
end end
private private
def valid_volume?(value)
number = Integer(value, :exception => false)
number != nil && number >= 0 && number <= 100
end
def default_document def default_document
{"version" => SCHEMA_VERSION, "profiles" => []} {"version" => SCHEMA_VERSION, "profiles" => []}
end end
@@ -102,7 +118,11 @@ module MeowClient
:port => Integer(profile.port, :exception => false) || 23, :port => Integer(profile.port, :exception => false) || 23,
:tls => profile.tls == true, :tls => profile.tls == true,
:encoding => ENCODINGS.value?(profile.encoding.to_s) ? profile.encoding.to_s : "UTF-8", :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 end
@@ -115,7 +135,11 @@ module MeowClient
:port => row["port"], :port => row["port"],
:tls => row["tls"] == true, :tls => row["tls"] == true,
:encoding => row["encoding"], :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 validate(profile).empty? ? profile : nil
end end
+16 -1
View File
@@ -1,5 +1,8 @@
require_relative "session_gmcp"
module MeowClient module MeowClient
class Session class Session
include SessionGMCP
MAX_LINES = 5_000 MAX_LINES = 5_000
MAX_BYTES = 1_048_576 MAX_BYTES = 1_048_576
MAX_HISTORY = 100 MAX_HISTORY = 100
@@ -20,6 +23,10 @@ module MeowClient
@speech_paused = profile.auto_read == false @speech_paused = profile.auto_read == false
@speech_overflow = 0 @speech_overflow = 0
@sensitive_next = false @sensitive_next = false
@gmcp_enabled = false
@gmcp_state = GMCP::State.new
@gmcp_log = GMCP::MessageLog.new
@media = nil
@closed = false @closed = false
build_form build_form
end end
@@ -28,6 +35,7 @@ module MeowClient
@form.wait @form.wait
ensure ensure
@pipeline.close @pipeline.close
@media.close if @media != nil
@transport.close @transport.close
@program.release(@transport, :close => false) @program.release(@transport, :close => false)
end end
@@ -46,9 +54,10 @@ module MeowClient
@send = Button.new(_("Send")) @send = Button.new(_("Send"))
@speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading")) @speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading"))
@stop_speech = Button.new(_("Stop current speech")) @stop_speech = Button.new(_("Stop current speech"))
@gmcp_button = Button.new(_("GMCP information"))
@disconnect = Button.new(_("Disconnect")) @disconnect = Button.new(_("Disconnect"))
@form = Form.new( @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 :quiet => true
) )
@form.accept_button = @send @form.accept_button = @send
@@ -58,6 +67,7 @@ module MeowClient
@send.on(:press) { send_command } @send.on(:press) { send_command }
@speech_button.on(:press) { toggle_speech } @speech_button.on(:press) { toggle_speech }
@stop_speech.on(:press) { speech_stop } @stop_speech.on(:press) { speech_stop }
@gmcp_button.on(:press) { open_gmcp_hub }
@disconnect.on(:press) { request_disconnect } @disconnect.on(:press) { request_disconnect }
@output.add_tip(_("Use the context menu to jump to the latest output.")) @output.add_tip(_("Use the context menu to jump to the latest output."))
@@ -83,6 +93,8 @@ module MeowClient
record = @pipeline.flush_prompt record = @pipeline.flush_prompt
receive_record(record) if record != nil receive_record(record) if record != nil
end end
@media.tick if @media != nil
speak_new_media_captions
dispatch_speech dispatch_speech
rescue Telnet::CompressionError rescue Telnet::CompressionError
add_system_line(_("The compressed data stream could not be processed safely. The connection was closed.")) add_system_line(_("The compressed data stream could not be processed safely. The connection was closed."))
@@ -100,6 +112,7 @@ module MeowClient
when :bytes when :bytes
result = @pipeline.feed(event.payload) result = @pipeline.feed(event.payload)
result.replies.each { |reply| @transport.send_bytes(reply) } 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_record(record) }
when :disconnected, :error when :disconnected, :error
@pipeline.finish.each { |record| receive_record(record) } @pipeline.finish.each { |record| receive_record(record) }
@@ -258,6 +271,8 @@ module MeowClient
def close_form def close_form
return if @closed return if @closed
@closed = true @closed = true
@media.close if @media != nil
@media = nil
@transport.close @transport.close
@form.resume @form.resume
end end
+272
View File
@@ -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
+43 -3
View File
@@ -1,4 +1,5 @@
require "zlib" require "zlib"
require_relative "gmcp"
module MeowClient module MeowClient
module Telnet module Telnet
@@ -14,13 +15,28 @@ module MeowClient
SUPPRESS_GO_AHEAD = 3 SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24 TERMINAL_TYPE = 24
COMPRESS2 = 86 COMPRESS2 = 86
GMCP = 201
TTYPE_IS = 0 TTYPE_IS = 0
TTYPE_SEND = 1 TTYPE_SEND = 1
MAX_INFLATED_BYTES_PER_FEED = 1_048_576 MAX_INFLATED_BYTES_PER_FEED = 1_048_576
MAX_SUBNEGOTIATION_BYTES = 65_536
Result = Struct.new(:data, :replies, :events, keyword_init: true) Result = Struct.new(:data, :replies, :events, keyword_init: true)
CompressionError = Class.new(StandardError) 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 class Parser
attr_reader :remote_echo attr_reader :remote_echo
@@ -29,6 +45,7 @@ module MeowClient
@negotiation_command = nil @negotiation_command = nil
@subnegotiation_option = nil @subnegotiation_option = nil
@subnegotiation = +"".b @subnegotiation = +"".b
@subnegotiation_overflow = false
@remote_echo = false @remote_echo = false
@remote_options = {} @remote_options = {}
@local_options = {} @local_options = {}
@@ -89,16 +106,17 @@ module MeowClient
when :sb_option when :sb_option
@subnegotiation_option = byte @subnegotiation_option = byte
@subnegotiation.clear @subnegotiation.clear
@subnegotiation_overflow = false
@state = :sb @state = :sb
when :sb when :sb
if byte == IAC if byte == IAC
@state = :sb_iac @state = :sb_iac
else else
@subnegotiation << byte append_subnegotiation(byte)
end end
when :sb_iac when :sb_iac
if byte == IAC if byte == IAC
@subnegotiation << IAC append_subnegotiation(IAC)
@state = :sb @state = :sb
elsif byte == SE elsif byte == SE
reply, event = finish_subnegotiation reply, event = finish_subnegotiation
@@ -149,7 +167,7 @@ module MeowClient
def negotiate(command, option) def negotiate(command, option)
case command case command
when WILL when WILL
enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2].include?(option) enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option)
changed = @remote_options[option] != enabled changed = @remote_options[option] != enabled
@remote_options[option] = enabled @remote_options[option] = enabled
return [nil, nil] unless changed return [nil, nil] unless changed
@@ -157,6 +175,7 @@ module MeowClient
@remote_echo = enabled @remote_echo = enabled
return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]] return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]]
end end
return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled
[packet(enabled ? DO : DONT, option), nil] [packet(enabled ? DO : DONT, option), nil]
when WONT when WONT
changed = @remote_options[option] != false changed = @remote_options[option] != false
@@ -165,6 +184,7 @@ module MeowClient
@remote_echo = false @remote_echo = false
return [packet(DONT, option), [:remote_echo, false]] if changed return [packet(DONT, option), [:remote_echo, false]] if changed
end end
return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed
[changed ? packet(DONT, option) : nil, nil] [changed ? packet(DONT, option) : nil, nil]
when DO when DO
enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option) enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option)
@@ -181,6 +201,17 @@ module MeowClient
end end
def finish_subnegotiation 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 && if @subnegotiation_option == COMPRESS2 &&
@remote_options[COMPRESS2] == true && @remote_options[COMPRESS2] == true &&
@subnegotiation.empty? @subnegotiation.empty?
@@ -198,6 +229,15 @@ module MeowClient
ensure ensure
@subnegotiation_option = nil @subnegotiation_option = nil
@subnegotiation.clear @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 end
end end
+10 -2
View File
@@ -81,6 +81,10 @@ module MeowClient
port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s) port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s)
tls = CheckBox.new(_("Use TLS"), :checked => profile.tls) tls = CheckBox.new(_("Use TLS"), :checked => profile.tls)
auto_read = CheckBox.new(_("Read incoming text automatically"), :checked => profile.auto_read) 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_names = ProfileRepository::ENCODINGS.keys
encoding_index = [ProfileRepository::ENCODINGS.values.index(profile.encoding) || 0, 0].max encoding_index = [ProfileRepository::ENCODINGS.values.index(profile.encoding) || 0, 0].max
encoding = ChoiceListBox.new( encoding = ChoiceListBox.new(
@@ -89,7 +93,7 @@ module MeowClient
) )
save = Button.new(_("Save")) save = Button.new(_("Save"))
cancel = Button.new(_("Cancel")) 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.accept_button = save
form.cancel_button = cancel form.cancel_button = cancel
saved = false saved = false
@@ -102,7 +106,11 @@ module MeowClient
:port => port.text, :port => port.text,
:tls => tls.checked, :tls => tls.checked,
:encoding => ProfileRepository::ENCODINGS[encoding_names[encoding.value(0)]], :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) errors = @profiles.validate(candidate)
if errors.empty? if errors.empty?
+3
View File
@@ -0,0 +1,3 @@
module MeowClient
VERSION = "0.2.0"
end
+81
View File
@@ -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
+154
View File
@@ -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
+2
View File
@@ -1,5 +1,7 @@
require_relative "test_helper" require_relative "test_helper"
require_relative "telnet_test" require_relative "telnet_test"
require_relative "gmcp_test"
require_relative "media_test"
require_relative "text_pipeline_test" require_relative "text_pipeline_test"
begin begin