From 5e6d9138c4a79a78bd272da285d2974a232aeac1 Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 17 Sep 2026 01:08:16 +0200 Subject: [PATCH] Initial Meow MUD client --- .gitignore | 5 + README.md | 64 ++++++++ __app.rb | 31 ++++ lib/meow/profile_repository.rb | 124 ++++++++++++++++ lib/meow/session.rb | 262 +++++++++++++++++++++++++++++++++ lib/meow/telnet.rb | 148 +++++++++++++++++++ lib/meow/text_pipeline.rb | 180 ++++++++++++++++++++++ lib/meow/transport.rb | 139 +++++++++++++++++ lib/meow/ui.rb | 170 +++++++++++++++++++++ 9 files changed, 1123 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __app.rb create mode 100644 lib/meow/profile_repository.rb create mode 100644 lib/meow/session.rb create mode 100644 lib/meow/telnet.rb create mode 100644 lib/meow/text_pipeline.rb create mode 100644 lib/meow/transport.rb create mode 100644 lib/meow/ui.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c92487 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.eltenapp +*.eltsetup +*.log +*.tmp +/.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c46cb6 --- /dev/null +++ b/README.md @@ -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. diff --git a/__app.rb b/__app.rb new file mode 100644 index 0000000..6569b6d --- /dev/null +++ b/__app.rb @@ -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 + diff --git a/lib/meow/profile_repository.rb b/lib/meow/profile_repository.rb new file mode 100644 index 0000000..6747c66 --- /dev/null +++ b/lib/meow/profile_repository.rb @@ -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 + diff --git a/lib/meow/session.rb b/lib/meow/session.rb new file mode 100644 index 0000000..98c6e59 --- /dev/null +++ b/lib/meow/session.rb @@ -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 + diff --git a/lib/meow/telnet.rb b/lib/meow/telnet.rb new file mode 100644 index 0000000..4d979e4 --- /dev/null +++ b/lib/meow/telnet.rb @@ -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 + diff --git a/lib/meow/text_pipeline.rb b/lib/meow/text_pipeline.rb new file mode 100644 index 0000000..ff22fc6 --- /dev/null +++ b/lib/meow/text_pipeline.rb @@ -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 + diff --git a/lib/meow/transport.rb b/lib/meow/transport.rb new file mode 100644 index 0000000..98a58db --- /dev/null +++ b/lib/meow/transport.rb @@ -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 + diff --git a/lib/meow/ui.rb b/lib/meow/ui.rb new file mode 100644 index 0000000..98725d9 --- /dev/null +++ b/lib/meow/ui.rb @@ -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 +