ExRatatui.SSH (ExRatatui v0.7.1)

Copy Markdown View Source

OTP :ssh_server_channel implementation that serves an ExRatatui.App over a single SSH channel.

One instance is spawned per SSH channel (i.e. per connected client). It owns an ExRatatui.Session for that channel and a linked internal server running the user's app module in :ssh transport mode. Bytes the ratatui backend writes into the session's in-memory buffer are shipped back to the client via :ssh_connection.send/3, and bytes the client types come in as {:data, _, _, _} events that get fed through the session's ANSI parser and dispatched to the server as {:ex_ratatui_event, event} messages.

Two entry points

SSH has two ways a client can ask a server to run "something":

  • Shellssh host with no command; the client expects the server to start the user's default shell and wire it to the channel.
  • Subsystemssh host -s <name>; the client asks for a named non-shell handler (this is what nerves_ssh uses for its subsystems: config, what sftp rides on, etc.).

Both are supported here. Shell mode is required for the standalone daemon so plain ssh just works. Subsystem mode is how nerves_ssh plugs this into its existing daemon — see subsystem/1 for the exact shape it expects.

Shell vs subsystem startup

The two modes use different triggers to spin up the server:

  • Shell mode waits for the client's pty_req (to size the session) and then shell_req (to launch). If the client skips the pty, the channel is rejected on the shell request.

  • Subsystem mode can't wait for those messages: OTP :ssh matches the subsystem name against the daemon's :subsystems config and consumes the {:subsystem, ...} request itself to dispatch us — handle_ssh_msg/2 never sees it. Worse, when the client passes -t OTP also consumes the pty_req before the subsystem dispatch fires: OTP hands it to the daemon's default CLI handler (IEx on a nerves_ssh device) and silently orphans that CLI process the moment our subsystem handler takes over the channel. Neither pty_req nor its dimensions reach us through any channel request.

    To work around that, on {:ssh_channel_up, ...} we synthesize a default 80x24 session, start the server, and immediately emit a Cursor Position Report roundtrip — ESC[s ESC[9999;9999H ESC[6n ESC[u — which parks the cursor at (9999, 9999) so the client clamps it to the actual pty size, then asks the client to report the position. The response arrives on the next {:data, ...} channel message, is parsed into a %Event.Resize{} by the session's ANSI input parser, and the data handler resizes the session in place + notifies the server. Clients can still send {:window_change, ...} afterwards to track real runtime resizes.

subsystem/1 bakes subsystem: true into the init args so the channel handler can tell the two flows apart. Shell-mode init (via ssh_cli:) leaves the flag at its default false.

Client-side caveat: always pass -t in subsystem mode

OpenSSH does not allocate a PTY by default for ssh host -s name — that's designed for protocols like sftp that don't need one. For an interactive TUI you MUST force it with -t:

ssh -t nerves.local -s Elixir.MyApp.TUI   # ✓ works
ssh nerves.local -s Elixir.MyApp.TUI      # ✗ local tty stays in
                                          #   cooked mode, keys are
                                          #   line-buffered + echoed
                                          #   locally, screen redraw
                                          #   bleeds into the shell
                                          #   prompt on exit

Without -t, render bytes still reach the client and the TUI runs — it just can't be driven interactively.

Subsystem helper

Returns a {charlist, {module, init_args}} tuple in exactly the shape OTP :ssh expects for its subsystems: option. Plug it into a nerves_ssh subsystems list from config/runtime.exs:

# config/runtime.exs
import Config

if Application.spec(:nerves_ssh) do
  config :nerves_ssh,
    subsystems: [
      :ssh_sftpd.subsystem_spec(cwd: ~c"/"),
      ExRatatui.SSH.subsystem(MyApp.TUI)
    ]
end

This must live in runtime.exs, not target.exs. On a fresh MIX_TARGET=rpi4 mix compile Mix evaluates compile-time configs before it builds deps for the target, so ExRatatui.SSH isn't on the code path yet and the subsystem/1 call would crash with module ExRatatui.SSH is not available. runtime.exs runs at device boot after every beam file is loaded but before the OTP application controller starts :nerves_ssh, which is exactly the window we need. The Application.spec(:nerves_ssh) guard keeps host builds (where :nerves_ssh isn't a dep) silent. See guides/ssh_transport.md for the full write-up.

Dependency injection for tests

init/1 accepts optional :sender and :starter overrides so unit tests can substitute fakes for :ssh_connection.send/3 and the internal server's start function without standing up real infrastructure. Defaults point at the real OTP + Server functions; production callers never pass either key.

Summary

Functions

Returns a {charlist_name, {ExRatatui.SSH, init_args}} tuple in the shape OTP :ssh's :subsystems option (and nerves_ssh's subsystems: list) expects.

Types

t()

@type t() :: %ExRatatui.SSH{
  app_opts: keyword(),
  channel_id: non_neg_integer() | nil,
  conn: term() | nil,
  esc_timer: reference() | nil,
  mod: module(),
  rendering: boolean(),
  replier: (term(), boolean(), :success | :failure, non_neg_integer() -> :ok),
  sender: (term(), non_neg_integer(), iodata() -> :ok | {:error, term()}),
  server_pid: pid() | nil,
  session: ExRatatui.Session.t() | nil,
  starter: (keyword() -> {:ok, pid()} | {:error, term()}),
  subsystem_mode: boolean()
}

Functions

subsystem(mod)

@spec subsystem(module()) :: {charlist(), {module(), keyword()}}

Returns a {charlist_name, {ExRatatui.SSH, init_args}} tuple in the shape OTP :ssh's :subsystems option (and nerves_ssh's subsystems: list) expects.

The charlist name is the full module name (e.g. ~c"Elixir.MyApp.TUI") so each app module gets its own distinct subsystem. Two apps configured into the same daemon will not collide.

The init args include subsystem: true so the channel handler knows it was spawned via OTP's subsystem dispatch (which consumes the {:subsystem, ...} message internally) and can start the TUI server as soon as the channel is up, instead of waiting for a shell request that will never arrive.

Examples

iex> ExRatatui.SSH.subsystem(SomeTUIModule)
{~c"Elixir.SomeTUIModule", {ExRatatui.SSH, [mod: SomeTUIModule, subsystem: true]}}