Add GMCP support and shared media layer
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+16
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+10
-2
@@ -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?
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module MeowClient
|
||||
VERSION = "0.2.0"
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user