Initial Meow MUD client

This commit is contained in:
2026-09-17 01:08:16 +02:00
commit 5e6d9138c4
9 changed files with 1123 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
*.eltenapp
*.eltsetup
*.log
*.tmp
/.cache/
+64
View File
@@ -0,0 +1,64 @@
# Meow
Meow is an accessible, voice-driven MUD client for [Elten](https://elten-net.eu/), the application platform for blind and visually impaired people.
The project is currently an early but usable Telnet client. Its interface is built from Elten's native spoken and Braille-aware controls rather than a graphical interface with accessibility added afterward.
## Current features
- Connection profiles with name, host, port, TLS, encoding, and automatic-reading preferences
- Plain Telnet and certificate-verified TLS connections
- UTF-8, Windows-1252, and ISO-8859-1 character encodings
- Streaming Telnet negotiation with ECHO, Suppress Go Ahead, and Terminal Type support
- Streaming ANSI control-sequence filtering
- Accessible scrollback and command entry
- Automatic reading of incoming lines and prompts
- Pause, resume, stop-current-speech, and resume-from-latest controls
- Bounded in-memory scrollback and speech queues
- In-memory command history
- Automatic password-prompt detection and an explicit sensitive-input mode
- Clean socket, worker-thread, and program resource shutdown
Passwords, command history, transcripts, and scrollback are not persisted. Only connection profiles are written to the program's private data directory.
## Using Meow
Meow targets Elten API 3.0.3 and is currently intended for Elten developer mode.
1. Load the `meow` source program in Elten.
2. Open **Meow** from the programs menu.
3. Create a connection profile with the MUD host and port.
4. Select the profile and choose **Connect**.
5. Enter commands in the Command field and press Enter to send them.
The output and command fields provide additional actions through their context menus, including command recall, sensitive input, jumping to the latest output, and skipping queued speech.
## Project structure
- `__app.rb` contains the Elten manifest and program entry point.
- `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/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.
## Next steps
The protocol and session layers are intentionally separated so richer MUD functionality can be added without coupling it to the accessible interface.
Planned work, roughly in order:
1. Add automated regression tests to the repository and exercise more Telnet edge cases.
2. Add MCCP2 compressed-stream support.
3. Add GMCP negotiation, bounded JSON parsing, typed package events, and an accessible inspector.
4. Add opt-in MSP sound and music with safe URL handling, limits, caching, and independent playback controls.
5. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts.
6. Add multiple simultaneous sessions with independent output and speech queues.
7. Add localization catalogs and package/signing automation for releases.
## Development status
The current implementation has been checked with Elten's Ruby syntax checker and exercised with in-process protocol tests plus a loopback TCP server. Real MUDs differ considerably in their Telnet behavior, so interoperability reports and reproducible protocol captures are welcome.
The application UUID is `51a92051-84e0-415f-84e3-98f08a320a15` and must remain stable across releases.
+31
View File
@@ -0,0 +1,31 @@
=begin Elten3AppInfo
{
"id": "51a92051-84e0-415f-84e3-98f08a320a15",
"name": "Meow",
"version": "0.1.0",
"build_id": 2,
"EltenAPIVersion": "3.0.3",
"author": "Talon",
"main_language": "en",
"supported_languages": ["en"],
"main": "__app.rb",
"main_class": "Meow",
"platforms": ["all"],
"menu": {"main": "Meow"},
"description": "An accessible, voice-driven Telnet MUD client."
}
=end Elten3AppInfo
require_relative "lib/meow/profile_repository"
require_relative "lib/meow/telnet"
require_relative "lib/meow/text_pipeline"
require_relative "lib/meow/transport"
require_relative "lib/meow/session"
require_relative "lib/meow/ui"
class Meow < Program
def program_main
MeowClient::UI.new(self).main
end
end
+124
View File
@@ -0,0 +1,124 @@
require "securerandom"
module MeowClient
ConnectionProfile = Struct.new(
:id, :name, :host, :port, :tls, :encoding, :auto_read,
keyword_init: true
) do
def to_h
{
"id" => id,
"name" => name,
"host" => host,
"port" => port,
"tls" => tls == true,
"encoding" => encoding,
"auto_read" => auto_read != false
}
end
end
class ProfileRepository
FILE_NAME = "profiles.json"
SCHEMA_VERSION = 1
ENCODINGS = {
"UTF-8" => "UTF-8",
"Windows-1252" => "Windows-1252",
"ISO-8859-1" => "ISO-8859-1"
}.freeze
def initialize(program)
@program = program
end
def all
data = @program.read_json(FILE_NAME, :default => default_document)
return [] unless data.is_a?(Hash)
rows = data["profiles"]
return [] unless rows.is_a?(Array)
rows.filter_map { |row| profile_from(row) }
rescue Exception => error
Log.warning("Meow profile read failed: #{error.class}: #{error.message}") if defined?(Log)
[]
end
def save(profile)
normalized = normalize(profile)
@program.update_json(FILE_NAME, :default => default_document) do |data|
data.replace(default_document) unless data.is_a?(Hash)
data["version"] = SCHEMA_VERSION
data["profiles"] = [] unless data["profiles"].is_a?(Array)
index = data["profiles"].index { |row| row.is_a?(Hash) && row["id"].to_s == normalized.id }
if index == nil
data["profiles"] << normalized.to_h
else
data["profiles"][index] = normalized.to_h
end
end
normalized
end
def delete(id)
@program.update_json(FILE_NAME, :default => default_document) do |data|
data.replace(default_document) unless data.is_a?(Hash)
data["profiles"] = [] unless data["profiles"].is_a?(Array)
data["profiles"].delete_if { |row| row.is_a?(Hash) && row["id"].to_s == id.to_s }
end
end
def build(attributes = {})
ConnectionProfile.new(
:id => attributes[:id] || SecureRandom.uuid,
:name => attributes[:name] || "",
:host => attributes[:host] || "",
:port => attributes[:port] || 23,
:tls => attributes[:tls] == true,
:encoding => attributes[:encoding] || "UTF-8",
:auto_read => attributes.fetch(:auto_read, true)
)
end
def validate(profile)
errors = []
errors << _("Enter a profile name.") if profile.name.to_s.strip == ""
errors << _("Enter a server host name.") if profile.host.to_s.strip == ""
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
end
private
def default_document
{"version" => SCHEMA_VERSION, "profiles" => []}
end
def normalize(profile)
ConnectionProfile.new(
:id => profile.id.to_s == "" ? SecureRandom.uuid : profile.id.to_s,
:name => profile.name.to_s.strip,
:host => profile.host.to_s.strip,
: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
)
end
def profile_from(row)
return nil unless row.is_a?(Hash)
profile = build(
:id => row["id"],
:name => row["name"],
:host => row["host"],
:port => row["port"],
:tls => row["tls"] == true,
:encoding => row["encoding"],
:auto_read => row["auto_read"] != false
)
validate(profile).empty? ? profile : nil
end
end
end
+262
View File
@@ -0,0 +1,262 @@
module MeowClient
class Session
MAX_LINES = 5_000
MAX_BYTES = 1_048_576
MAX_HISTORY = 100
MAX_SPEECH = 200
SPEECH_CHUNK = 600
PASSWORD_PATTERN = /\b(?:password|passphrase|secret|token|pin)\b.*[:?>]\s*\z/i
def initialize(program, profile, transport)
@program = program
@profile = profile
@transport = transport
@pipeline = TextPipeline.new(profile.encoding)
@lines = []
@line_bytes = 0
@history = []
@history_index = nil
@speech_queue = []
@speech_paused = profile.auto_read == false
@speech_overflow = 0
@sensitive_next = false
@closed = false
build_form
end
def run
@form.wait
ensure
@transport.close
@program.release(@transport, :close => false)
end
private
def build_form
@status = Static.new(_("Connected to %{name}.") % {:name => @profile.name})
@output = EditBox.new(
_("MUD output"),
:type => EditBox::Flags::ReadOnly | EditBox::Flags::MultiLine,
:text => "",
:detect_text_links => false
)
@command = EditBox.new(_("Command"), :text => "")
@send = Button.new(_("Send"))
@speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading"))
@stop_speech = Button.new(_("Stop current speech"))
@disconnect = Button.new(_("Disconnect"))
@form = Form.new(
[@status, @output, @command, @send, @speech_button, @stop_speech, @disconnect],
:quiet => true
)
@form.accept_button = @send
@form.cancel_button = @disconnect
@form.add_timer(FormTimer.new(0.05, :repeat => true) { tick })
@send.on(:press) { send_command }
@speech_button.on(:press) { toggle_speech }
@stop_speech.on(:press) { speech_stop }
@disconnect.on(:press) { request_disconnect }
@output.add_tip(_("Use the context menu to jump to the latest output."))
@output.bind_context do |menu|
menu.option(_("Jump to latest output")) { jump_to_latest }
end
@command.add_tip(_("Use the context menu to recall commands or mark the next command as sensitive."))
@command.bind_context do |menu|
menu.option(_("Previous command")) { recall(-1) }
menu.option(_("Next command")) { recall(1) }
menu.option(_("Sensitive command")) { set_sensitive(true) }
menu.option(_("Resume reading from latest output")) { resume_from_latest }
end
add_system_line(_("Connected to %{host} on port %{port}.") % {
:host => @profile.host,
:port => @profile.port
})
end
def tick
drain_events
if @pipeline.prompt_due?
record = @pipeline.flush_prompt
receive_record(record) if record != nil
end
dispatch_speech
rescue Exception => error
Log.error("Meow session update failed: #{error.class}: #{error.message}") if defined?(Log)
add_system_line(_("Session update error: %{message}") % {:message => error.message})
close_form
end
def drain_events
loop do
event = @transport.events.pop(true)
case event.type
when :bytes
result = @pipeline.feed(event.payload)
result.replies.each { |reply| @transport.send_bytes(reply) }
result.records.each { |record| receive_record(record) }
when :disconnected, :error
@pipeline.finish.each { |record| receive_record(record) }
add_system_line(event.payload.to_s)
close_form
end
end
rescue ThreadError
end
def receive_record(record)
text = record.to_s
append_line(text)
@sensitive_next = true if text.strip.match?(PASSWORD_PATTERN)
apply_sensitive_flag
enqueue_speech(text)
end
def append_line(text)
value = text.to_s
@lines << value
@line_bytes += value.bytesize + 1
while @lines.size > MAX_LINES || @line_bytes > MAX_BYTES
removed = @lines.shift.to_s
@line_bytes -= removed.bytesize + 1
end
@output.set_text(@lines.join("\n"), false)
end
def add_system_line(text)
append_line("[#{text}]")
end
def send_command
text = @command.text.to_s
return if text == ""
begin
encoded = text.encode(@profile.encoding)
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
alert(_("This command contains characters that cannot be represented by %{encoding}.") % {
:encoding => @profile.encoding
})
return
end
unless @transport.send_bytes(encoded.b + "\r\n".b)
alert(_("The connection is no longer available."))
close_form
return
end
sensitive = @sensitive_next
unless sensitive
add_history(text)
append_line("> #{text}") unless @pipeline.remote_echo?
end
@command.set_text("")
@history_index = nil
set_sensitive(false, false)
end
def add_history(text)
@history.delete(text)
@history << text
@history.shift while @history.size > MAX_HISTORY
end
def recall(direction)
return alert(_("No command history is available."), false) if @history.empty?
if @history_index == nil
@history_index = direction < 0 ? @history.size - 1 : 0
else
@history_index = [[@history_index + direction, 0].max, @history.size - 1].min
end
@command.set_text(@history[@history_index])
@command.index = @command.text.length
@command.check = @command.index
set_sensitive(false, false)
@form.index = @command
@command.focus
end
def set_sensitive(value, announce = true)
@sensitive_next = value == true
apply_sensitive_flag
if announce
speak(@sensitive_next ? _("Sensitive input enabled.") : _("Sensitive input disabled."))
end
end
def apply_sensitive_flag
if @sensitive_next
@command.flags |= EditBox::Flags::Password
else
@command.flags &= ~EditBox::Flags::Password
end
end
def enqueue_speech(text)
return if text.strip == ""
chunks = split_speech(text)
chunks.each do |chunk|
if @speech_queue.size >= MAX_SPEECH
@speech_queue.shift
@speech_overflow += 1
end
@speech_queue << chunk
end
end
def split_speech(text)
value = text.to_s.strip
return [] if value == ""
value.scan(/.{1,#{SPEECH_CHUNK}}/m).map(&:strip).reject(&:empty?)
end
def dispatch_speech
return if @speech_paused || speech_actived
if @speech_overflow > 0
count = @speech_overflow
@speech_overflow = 0
speak(n_("%{count} older line was skipped.", "%{count} older lines were skipped.", count) % {:count => count})
return
end
text = @speech_queue.shift
speak(text, :stop => false) if text != nil
end
def toggle_speech
@speech_paused = !@speech_paused
@speech_button.label = @speech_paused ? _("Resume automatic reading") : _("Pause automatic reading")
speak(@speech_paused ? _("Automatic reading paused.") : _("Automatic reading resumed."))
end
def resume_from_latest
@speech_queue.clear
@speech_overflow = 0
@speech_paused = false
@speech_button.label = _("Pause automatic reading")
speak(_("Automatic reading resumed from the latest output."))
end
def jump_to_latest
position = @output.text.length
@output.index = position
@output.check = position
@form.index = @output
@output.focus
end
def request_disconnect
if @closed || confirm(_("Disconnect from %{name}?") % {:name => @profile.name})
add_system_line(_("Disconnected."))
close_form
end
end
def close_form
return if @closed
@closed = true
@transport.close
@form.resume
end
end
end
+148
View File
@@ -0,0 +1,148 @@
module MeowClient
module Telnet
IAC = 255
DONT = 254
DO = 253
WONT = 252
WILL = 251
SB = 250
SE = 240
ECHO = 1
SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24
TTYPE_IS = 0
TTYPE_SEND = 1
Result = Struct.new(:data, :replies, :events, keyword_init: true)
class Parser
attr_reader :remote_echo
def initialize
@state = :data
@negotiation_command = nil
@subnegotiation_option = nil
@subnegotiation = +"".b
@remote_echo = false
@remote_options = {}
@local_options = {}
end
def feed(bytes)
output = +"".b
replies = []
events = []
bytes.to_s.b.each_byte do |byte|
case @state
when :data
if byte == IAC
@state = :iac
else
output << byte
end
when :iac
handle_iac(byte, output)
when :negotiation
reply, event = negotiate(@negotiation_command, byte)
replies << reply if reply != nil
events << event if event != nil
@state = :data
when :sb_option
@subnegotiation_option = byte
@subnegotiation.clear
@state = :sb
when :sb
if byte == IAC
@state = :sb_iac
else
@subnegotiation << byte
end
when :sb_iac
if byte == IAC
@subnegotiation << IAC
@state = :sb
elsif byte == SE
reply, event = finish_subnegotiation
replies << reply if reply != nil
events << event if event != nil
@state = :data
else
@state = :data
end
end
end
Result.new(:data => output, :replies => replies, :events => events)
end
private
def handle_iac(byte, output)
case byte
when IAC
output << IAC
@state = :data
when WILL, WONT, DO, DONT
@negotiation_command = byte
@state = :negotiation
when SB
@state = :sb_option
else
@state = :data
end
end
def packet(command, option)
[IAC, command, option].pack("C*")
end
def negotiate(command, option)
case command
when WILL
enabled = [ECHO, SUPPRESS_GO_AHEAD].include?(option)
changed = @remote_options[option] != enabled
@remote_options[option] = enabled
return [nil, nil] unless changed
if option == ECHO
@remote_echo = enabled
return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]]
end
[packet(enabled ? DO : DONT, option), nil]
when WONT
changed = @remote_options[option] != false
@remote_options[option] = false
if option == ECHO
@remote_echo = false
return [packet(DONT, option), [:remote_echo, false]] if changed
end
[changed ? packet(DONT, option) : nil, nil]
when DO
enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option)
changed = @local_options[option] != enabled
@local_options[option] = enabled
[changed ? packet(enabled ? WILL : WONT, option) : nil, nil]
when DONT
changed = @local_options[option] != false
@local_options[option] = false
[changed ? packet(WONT, option) : nil, nil]
else
[nil, nil]
end
end
def finish_subnegotiation
if @subnegotiation_option == TERMINAL_TYPE &&
@local_options[TERMINAL_TYPE] == true &&
@subnegotiation.getbyte(0) == TTYPE_SEND
body = [IAC, SB, TERMINAL_TYPE, TTYPE_IS].pack("C*") + "MEOW".b
return [body + [IAC, SE].pack("C*"), [:terminal_type, "MEOW"]]
end
[nil, [:subnegotiation, @subnegotiation_option]]
ensure
@subnegotiation_option = nil
@subnegotiation.clear
end
end
end
end
+180
View File
@@ -0,0 +1,180 @@
module MeowClient
class AnsiFilter
def initialize
@state = :text
end
def feed(bytes)
output = +"".b
bytes.to_s.b.each_byte do |byte|
case @state
when :text
if byte == 27
@state = :escape
elsif byte == 9 || byte == 10 || byte == 13 || byte >= 32
output << byte
end
when :escape
if byte == 91
@state = :csi
elsif byte == 93
@state = :osc
else
@state = :text
end
when :csi
@state = :text if byte >= 64 && byte <= 126
when :osc
if byte == 7
@state = :text
elsif byte == 27
@state = :osc_escape
end
when :osc_escape
@state = byte == 92 ? :text : :osc
end
end
output
end
end
class TextPipeline
Result = Struct.new(:records, :replies, :events, keyword_init: true)
def initialize(encoding)
@telnet = Telnet::Parser.new
@ansi = AnsiFilter.new
@encoding = encoding.to_s
@decode_pending = +"".b
@line = +""
@pending_cr = false
@last_text_at = nil
end
def remote_echo?
@telnet.remote_echo == true
end
def feed(bytes)
parsed = @telnet.feed(bytes)
filtered = @ansi.feed(parsed.data)
text = decode(filtered)
records = frame(text)
@last_text_at = monotonic_time if text != ""
Result.new(:records => records, :replies => parsed.replies, :events => parsed.events)
end
def prompt_due?(delay = 0.2)
@line != "" && @last_text_at != nil && monotonic_time - @last_text_at >= delay
end
def flush_prompt
return nil if @line == ""
value = @line.dup
@line.clear
@pending_cr = false
@last_text_at = nil
value
end
def finish
tail = if @encoding == "UTF-8"
value = @decode_pending.dup.force_encoding(Encoding::UTF_8).scrub
@decode_pending.clear
value
else
""
end
records = frame(tail)
prompt = flush_prompt
records << prompt if prompt != nil
records
rescue Exception
prompt = flush_prompt
prompt == nil ? [] : [prompt]
end
private
def decode(bytes)
if @encoding != "UTF-8"
return bytes.to_s.b.force_encoding(@encoding).encode(
Encoding::UTF_8,
:invalid => :replace,
:undef => :replace,
:replace => "\uFFFD"
)
end
@decode_pending << bytes.to_s.b
keep = incomplete_utf8_suffix_length(@decode_pending)
ready_size = @decode_pending.bytesize - keep
ready = @decode_pending.byteslice(0, ready_size).to_s.b
@decode_pending = keep == 0 ? +"".b : @decode_pending.byteslice(ready_size, keep).to_s.b
ready.force_encoding(Encoding::UTF_8).scrub
end
def incomplete_utf8_suffix_length(bytes)
size = bytes.bytesize
return 0 if size == 0
continuation = 0
index = size - 1
while index >= 0 && continuation < 3 && (bytes.getbyte(index) & 0xC0) == 0x80
continuation += 1
index -= 1
end
return 0 if index < 0
lead = bytes.getbyte(index)
expected = if (lead & 0x80) == 0
1
elsif (lead & 0xE0) == 0xC0
2
elsif (lead & 0xF0) == 0xE0
3
elsif (lead & 0xF8) == 0xF0
4
else
1
end
available = size - index
available < expected ? available : 0
end
def frame(text)
records = []
text.each_char do |character|
if @pending_cr
if character == "\n" || character == "\0"
records << take_line
@pending_cr = false
next
else
records << take_line
@pending_cr = false
end
end
case character
when "\r"
@pending_cr = true
when "\n"
records << take_line
when "\0"
else
@line << character
end
end
records
end
def take_line
value = @line.dup
@line.clear
value
end
def monotonic_time
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
end
+139
View File
@@ -0,0 +1,139 @@
require "socket"
require "openssl"
module MeowClient
TransportEvent = Struct.new(:type, :payload, keyword_init: true)
class Transport
CONNECT_TIMEOUT = 15
attr_reader :events
def self.connect(profile)
tcp = nil
socket = nil
begin
tcp = Socket.tcp(profile.host, profile.port, :connect_timeout => CONNECT_TIMEOUT)
if profile.tls
context = EltenAPI::TLS.client_context
socket = OpenSSL::SSL::SSLSocket.new(tcp, context)
socket.hostname = profile.host if socket.respond_to?(:hostname=)
socket.sync_close = true
socket.connect
else
socket = tcp
end
new(socket)
rescue Exception
begin
socket.close if socket != nil
rescue Exception
end
begin
tcp.close if tcp != nil && tcp != socket
rescue Exception
end
raise
end
end
def initialize(socket)
@socket = socket
@events = Queue.new
@outbound = Queue.new
@write_buffer = +"".b
@close_mutex = Mutex.new
@closed = false
@thread = Thread.new { run }
@thread.report_on_exception = false
end
def send_bytes(bytes)
return false if closed?
@outbound << bytes.to_s.b
true
end
def close
thread = nil
@close_mutex.synchronize do
return if @closed
@closed = true
thread = @thread
begin
@socket.close
rescue Exception
end
end
thread.join(1.0) if thread != nil && thread != Thread.current
nil
end
def closed?
@close_mutex.synchronize { @closed }
end
private
def run
@events << TransportEvent.new(:type => :connected)
loop do
break if closed?
fill_write_buffer
readable = [@socket]
writable = @write_buffer.empty? ? [] : [@socket]
selected = IO.select(readable, writable, nil, 0.1)
next if selected == nil
read_available if selected[0].include?(@socket)
write_available if selected[1].include?(@socket)
end
rescue EOFError
@events << TransportEvent.new(:type => :disconnected, :payload => _("The server closed the connection."))
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => error
unless closed?
@events << TransportEvent.new(
:type => :error,
:payload => _("Connection error: %{message}") % {:message => error.message}
)
end
rescue Exception => error
unless closed?
Log.error("Meow transport failure: #{error.class}: #{error.message}") if defined?(Log)
@events << TransportEvent.new(
:type => :error,
:payload => _("Unexpected connection error: %{message}") % {:message => error.message}
)
end
ensure
@close_mutex.synchronize { @closed = true }
begin
@socket.close
rescue Exception
end
end
def fill_write_buffer
loop do
@write_buffer << @outbound.pop(true)
end
rescue ThreadError
end
def read_available
data = @socket.read_nonblock(16_384)
raise EOFError if data == nil || data == ""
@events << TransportEvent.new(:type => :bytes, :payload => data.b)
rescue IO::WaitReadable, OpenSSL::SSL::SSLErrorWaitReadable
rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
end
def write_available
return if @write_buffer.empty?
count = @socket.write_nonblock(@write_buffer)
@write_buffer = @write_buffer.byteslice(count..-1).to_s.b
rescue IO::WaitWritable, OpenSSL::SSL::SSLErrorWaitWritable
rescue IO::WaitReadable, OpenSSL::SSL::SSLErrorWaitReadable
end
end
end
+170
View File
@@ -0,0 +1,170 @@
module MeowClient
class UI
def initialize(program)
@program = program
@profiles = ProfileRepository.new(program)
end
def main
loop do
action, profile = connection_manager
case action
when :connect
connect(profile)
when :new
edit_profile(nil)
when :edit
edit_profile(profile)
when :delete
delete_profile(profile)
else
break
end
end
end
private
def connection_manager
profiles = @profiles.all
labels = profiles.map do |profile|
security = profile.tls ? _("TLS") : _("plain")
_("%{name}, %{host}, port %{port}, %{security}") % {
:name => profile.name,
:host => profile.host,
:port => profile.port,
:security => security
}
end
list = ListBox.new(
labels,
:header => _("Connections"),
:empty_label => _("No connection profiles. Choose New to create one.")
)
connect_button = Button.new(_("Connect"))
new_button = Button.new(_("New"))
edit_button = Button.new(_("Edit"))
delete_button = Button.new(_("Delete"))
exit_button = Button.new(_("Exit"))
form = Form.new([list, connect_button, new_button, edit_button, delete_button, exit_button], :quiet => true)
form.accept_button = connect_button
form.cancel_button = exit_button
result = nil
choose = proc do |action|
if profiles.empty? && [:connect, :edit, :delete].include?(action)
alert(_("Create a connection profile first."))
else
result = [action, profiles[list.index]]
form.resume
end
end
connect_button.on(:press) { choose.call(:connect) }
new_button.on(:press) do
result = [:new, nil]
form.resume
end
edit_button.on(:press) { choose.call(:edit) }
delete_button.on(:press) { choose.call(:delete) }
exit_button.on(:press) do
result = [:exit, nil]
form.resume
end
form.wait
result || [:exit, nil]
end
def edit_profile(existing)
profile = existing || @profiles.build
name = EditBox.new(_("Profile name"), :text => profile.name)
host = EditBox.new(_("Server host"), :text => profile.host)
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)
encoding_names = ProfileRepository::ENCODINGS.keys
encoding_index = [ProfileRepository::ENCODINGS.values.index(profile.encoding) || 0, 0].max
encoding = ChoiceListBox.new(
[[_("Character encoding"), encoding_names, encoding_index]],
:header => _("Connection options")
)
save = Button.new(_("Save"))
cancel = Button.new(_("Cancel"))
form = Form.new([name, host, port, tls, auto_read, encoding, save, cancel], :quiet => true)
form.accept_button = save
form.cancel_button = cancel
saved = false
save.on(:press) do
candidate = @profiles.build(
:id => profile.id,
:name => name.text,
:host => host.text,
:port => port.text,
:tls => tls.checked,
:encoding => ProfileRepository::ENCODINGS[encoding_names[encoding.value(0)]],
:auto_read => auto_read.checked
)
errors = @profiles.validate(candidate)
if errors.empty?
@profiles.save(candidate)
saved = true
form.resume
else
alert(errors.join("\n"))
end
end
cancel.on(:press) { form.resume }
form.wait
speak(_("Connection profile saved.")) if saved
rescue Exception => error
Log.error("Meow profile editor failed: #{error.class}: #{error.message}") if defined?(Log)
alert(_("Could not save the connection profile: %{message}") % {:message => error.message})
end
def delete_profile(profile)
return if profile == nil
return unless confirm(_("Delete connection %{name}?") % {:name => profile.name})
@profiles.delete(profile.id)
speak(_("Connection profile deleted."))
rescue Exception => error
alert(_("Could not delete the connection profile: %{message}") % {:message => error.message})
end
def connect(profile)
transport = begin
EltenAPI::Tasks.run(
:title => _("Connecting to %{name}") % {:name => profile.name},
:timeout => 20
) do |_progress, token|
token.raise_if_cancelled!
Transport.connect(profile)
end
rescue EltenAPI::Tasks::Cancelled
speak(_("Connection cancelled."))
return
rescue EltenAPI::Tasks::TimedOut
alert(_("The connection attempt timed out."))
return
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, EltenAPI::TLS::Error => error
alert(_("Could not connect: %{message}") % {:message => error.message})
return
rescue Exception => error
Log.error("Meow connection failed: #{error.class}: #{error.message}") if defined?(Log)
alert(_("Could not connect: %{message}") % {:message => error.message})
return
end
@program.manage(transport)
begin
Session.new(@program, profile, transport).run
rescue Exception => error
Log.error("Meow session failed: #{error.class}: #{error.message}") if defined?(Log)
alert(_("The session closed because of an error: %{message}") % {:message => error.message})
ensure
transport.close
@program.release(transport, :close => false)
end
end
end
end