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
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
@@ -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
|
||||||
|
|||||||
@@ -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 "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
@@ -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?
|
||||||
|
|||||||
@@ -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 "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
|
||||||
|
|||||||
Reference in New Issue
Block a user