Initial Meow MUD client
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
*.eltenapp
|
||||||
|
*.eltsetup
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
/.cache/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user