diff --git a/AGENTS.md b/AGENTS.md index a7eb28e..f4399ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,16 @@ # RedTerm — agent notes +## The warning gate, stated first because it was missed once + +**The build must be clean and it must be checked.** Zero compiler errors, zero +deprecation warnings, zero other warnings — no more than the fixed set of three +pre-existing `Aligned16KB` lint warnings from the upstream termux `.so` files. No +new suppressions (`@Suppress`, `@SuppressLint`, empty catch) are allowed, ever. +The gate is reading the output, not the exit code: Gradle exits green over a wall +of warnings unless someone reads the `w:` lines of `compileDebugKotlin` and the +lint report line by line. Fix at the source or remove the code; a deprecated call +is a call the platform has already withdrawn its promise for. + ## What this is Android terminal emulator + SSH client. Kotlin, single `:app` module, package `com.redtermapp`. @@ -60,9 +71,19 @@ Constraints that are not obvious and will waste an hour if rediscovered: builds a non-selectable `TextView`, which is fine for "Archive already exists" and useless for the several hundred characters of `tar` that explain a failed restore: not selectable, not copyable, not scrollable on a phone, and gone on OK. Tool output - goes through `ToolOutputDialog` — selectable, scrollable, monospaced. A restore failure + goes through `ToolOutputDialog` — selectable, scrollable, monospaced. **Selection + needs the movement method `setTextIsSelectable` installs**: setting + `ScrollingMovementMethod` after it is what broke long-press selection (and the + NestedScrollView scrolls anyway), so the view sets selectable last and no other + movement method; and a **Copy button** sits beside selectable text, because one + tap that always works beats a long-press that usually does. A restore failure also goes to `AppLog`, because `android.util.Log` is invisible in Diagnostics and the dialog is the only copy that gets dismissed. +- **A success does not want the diagnosis.** The connection test always ran `ssh -v` + and printed the whole trace whatever the outcome, so a success card was a wall of + key-exchange debug lines — which reads like something went wrong. The trace is the + failure's report; a success shows the method, the key used and the server's version + banner, and nothing else. - **A multi-minute operation needs a progress bar, not just a line of text.** Backing up or restoring a real distro takes minutes, and a dialog saying only "backing up" is indistinguishable from one that has hung. Both now report a named phase and a fraction. @@ -94,6 +115,15 @@ Constraints that are not obvious and will waste an hour if rediscovered: field carrying the error. - `danger` is a themed colour with a light/dark pair, like `extraKeysBg`. A destructive button that looks like the neutral ones beside it is how the wrong thing gets destroyed. +- **A SAF picker's MIME filter is a promise the provider did not make.** Providers report + the same font as `font/ttf`, `application/x-font-ttf` or plain `application/octet-stream`; + `font/*` matched none of them, and a picker that filtered by it opened every folder empty + and hid exactly the files the user was reaching for. Filter nothing, and validate what + was picked (`util/FontImport.kt`) — where the display name via `OpenableColumns` is on + record, because a document URI's `lastPathSegment` is a provider id on anything but the + common local storage provider. The mistake also had a fallback that looked like a fix and + could never run: it lived inside the `catch` of `launch()`, which throws only when there + is no picker at all, not when the picker opens fine and shows nothing. ## Active work — the SSH feature overhaul @@ -172,12 +202,16 @@ Agreed plan, all phases to be delivered. Status is tracked below and updated as - `SSH_ASKPASS` hands ssh one phrase with no way to say which key it is asking about, so a passphrase key is offered **alone** on the command line. Offering several would answer one key's question with another's phrase. -- **Ask once per key, then stop.** Every listing and every transfer is its own - connection, so a rule that re-asks about a key on each one is nagging for ever: the - user dismisses the dialog and the next tap brings it straight back. `SshAskpass` - holds phrases *and* declines, and a key is only asked about when it has neither. - Prompts are serialised, because a listing still running while a transfer starts - would otherwise stack two dialogs and the answer to the second goes into the first. +- **One question per connection attempt, and it must return.** A prompt dismissed + once used to be recorded in process-wide state and every later test and transfer + went on failing without ever asking again — the opposite of the anti-nagging it + was built for. A dismissal now lasts exactly as long as the connection attempt it + interrupted (`SshKeyAuthenticator` asks at most once per attempt): the next one + asks again, because a question that never returns cannot be answered. A phrase + held in memory is also never proof the next attempt cannot need a fresh one — a + held value can itself be why the attempt refused. Prompts stay serialised, because + a listing still running while a transfer starts would otherwise stack two dialogs + and the answer to the second goes into the first. - **The browser keeps one connection open.** `SftpSource` holds the `SftpClient` for its whole life rather than dialling per operation, which is both the latency the user asked for and what stops the passphrase question being asked five times. A @@ -200,13 +234,38 @@ Agreed plan, all phases to be delivered. Status is tracked below and updated as the list as it comes means the *oldest* key on the device always wins and a key made for a server being added right now is never offered. Bound means the user's explicit choice and is used alone; ties break on file name so the order is reproducible. -- **Never ask up front; ask when a connection is refused.** A passphrase-protected key and - a key the server has no record of both produce `Permission denied (publickey)` after - `Server accepts key`, so the recorded `encrypted` flag is a hint and nothing is gated on - it. The flag was also unwritable — added to `Entry` and to the reader, not the writer — - so *every* protected key loaded as unprotected. `SshKeyStore.encode`/`decode` are now - pure functions round-tripped by a test, because a missing writer field produces no error - anywhere, only a key the server accepted reported as refused. +- **Never ask up front; ask when a connection is refused — the key file settles the + question.** A passphrase-protected key and a key the server has no record of both + produce `Permission denied (publickey)` after `Server accepts key`, so the recorded + `encrypted` flag is a hint and nothing is gated on it; the flag was also once + unwritable, so *every* protected key loaded as unprotected. But the two cases are + not indistinguishable to the key file itself: `ssh-keygen -y -P "" -f ` reads + an open key with exit 0 and refuses an encrypted one, over no network at all + (`SshKeyImporter.readsWithoutPassphrase`). A refused attempt runs that probe before + any question is asked — an open key can never make a passphrase work, so asking + about one was the app reporting "the server does not know this key" in the words of + a passphrase dialog, which is how a user who generated a key with no passphrase was + asked for one. An encrypted key is asked about freed from any held or declined + state, because a held phrase may itself be why the attempt refused. +- **"No prompt needed" and "prompt dismissed" are different outcomes, and only one + of them spends the question.** The loop's one-shot asking was implemented as "the + first refusal calls ask and marks the question spent", which made an *open* key's + refusal — where the asker answers `NotNeeded` without showing anything — consume + the only prompt the attempt had, so an encrypted key further down the list was + never asked and a server that accepts it rejected the connection. `ask` now + returns `Prompt.NotNeeded` or `Prompt.Answered`, and only `Answered` sets the + flag: an opportunity is spent by a question actually put, not by a refusal that + came before it. +- **A lock held for a transfer's whole duration must not be read from the main + thread.** `SftpSource.hasConnection` took `connectionLock`, the same lock + `withConnection` holds through the ssh dial and every SFTP round trip, so a tap + starting a load could freeze the UI behind a running transfer. `connection` is + now `@Volatile` and the getter takes no lock — and no second hand-maintained + flag either, which is a copied value waiting to drift; the advisory read is + allowed to be stale, because the next call dials if it has to. +- `SshKeyStore.encode`/`decode` are pure functions round-tripped by a test, because + a missing writer field produces no error anywhere, only a key the server accepted + reported as refused. - **A secret travels in `ProcessBuilder.environment()`, never in the command.** The command is written into a launcher script in app storage and logged on the way past, so an inline `VAR=value ssh ...` prefix puts the passphrase on disk in the one place it was promised not @@ -532,8 +591,696 @@ changing anything about distro images or extraction. ran, so an index generated from local state alone hides everything it did not build. Ubuntu has no local tarball and would simply disappear. `scripts/merge-index.py` is what prevents it. +## mosh is being built, and two things were measured rather than designed + +- **An Android UDP socket survives a network change by itself.** This is the whole + question mosh on a phone turns on, and it was measured rather than assumed because + the assumption was expensive to be wrong about: Android binds a socket to a + network, so a WiFi to cellular change is expected to destroy it, and mosh's + roaming only works if the client's socket keeps sending. + + Measured on a device, over six switches in ~50s, with one socket opened before + the first switch and never closed, never re-pinned and never rebuilt: + + 18:40:07.676 probe #152 FAIL ENETUNREACH + ... + 18:40:08.191 capsChanged cellular validated + 18:40:08.590 --> RECOVERED on the SAME socket after 4 failure(s), + with no intervention + + So there is a **~900ms window** where the socket answers `ENETUNREACH`, and then + it works again on its own. Five of the six switches were seamless. Re-pinning the + process with `ConnectivityManager.bindProcessToNetwork` **does not** revive it + inside that window, which is why the answer is "wait" rather than "re-bind". + + Consequence: the native transport needs no network awareness at all. Nothing + listens for connectivity changes, and nothing re-creates the socket. A ~1s gap is + well inside what SSP's RTT-based retransmission already absorbs. + + Three earlier versions of the probe each produced a log that *looked* like + evidence and was not, and the reasons are worth keeping: + - **DNS is the wrong probe.** UDP/53 to 8.8.8.8 *and* 1.1.1.1 times out on a + healthy WiFi, because Android's Private DNS (default since Android 9) resolves + over DoT on TCP/853 and never answers plain UDP/53 from an app. "No reply" was + the probe's fault, not the network's. + - **Android invalidates a Network in place.** The same id lost its `wifi` + transport and gained `cellular` with no `onAvailable` and no `onLost`. Anything + keying on Network *identity* sees no change at all. Compare transports. + - **A switch is a window, not an instant.** Acting inside it measures the window. + A re-pin and a brand new socket both "failed" there and both worked on the very + next probe with no intervention. A probe that keeps both sockets open and asks + only once the network has settled is the only version of this that can be + believed. + And one arithmetic detail: when a query falls through from one server to the next, + the first server's late reply stays in the socket, so the next call consumes it and + reports **0ms**. Draining before each query is what makes a reported round trip + mean a round trip. + +- **nettle, not OpenSSL, for mosh's AES.** mosh's configure accepts either, and the + difference is not close. Measured for a single aarch64 ABI: + + | | OpenSSL | nettle | + |---|---|---| + | stripped `.so` | 5,351,472 | **978,496** | + | crypto library | 10.2 MB | **2.15 MB** | + | crypto build | 7,837s | **1,159s** | + + The reason is symbol count: **OpenSSL contributes 3393 symbols, mosh contributes + 31.** One call to `Crypto::Session::encrypt` drags in the whole of OpenSSL 3.5's + AES path. With nettle, mosh needs no OpenSSL at all — `configure` reports + `internal OCB, Nettle AES` — and OpenSSH keeps its own, already built. + +- **`libmoshterminal` is not curses-free**, contrary to what an iOS build of mosh + suggests. `src/terminal/terminaldisplayinit.cc` calls `setupterm`, `tigetflag` and + `tigetstr` and hard-errors without a curses header. Nothing references + `TerminalDisplayInit` — it exists for the curses frontend, which + `--disable-client` does not build — so a **declaration-only** `curses.h` stub + compiles the object and it is never pulled into the `.so`. If a future change ever + does reference it, the link fails on an undefined `setupterm` rather than + silently binding to a terminfo database we do not ship. + +- **Three of mosh's, protobuf's and nettle's own build trees try to regenerate their + autotools output**, because timestamps did not survive packaging: + + make[1]: *** [Makefile:405: Makefile.in] Error 1 + missing: line 81: automake-1.16: command not found + make[1]: *** [Makefile:1699: aclocal.m4] Error 127 + + Same fix `native/build-openssh.sh` already uses: touch `aclocal.m4`, then + `configure`/`config.h.in`, then every `Makefile.in`, so make considers them + current. nettle needs `make desdata` first with a **host** compiler — it generates + `rotors.h`, and built with the cross wrapper it is an aarch64 binary that cannot + run here — and `stamp-h.in` touched, or `make install` reaches for `autoheader`. + +- **Two more bionic gaps, both of the kind `native/ssh_compat.c` already exists + for.** `nl_langinfo` is declared only under `__ANDROID_API__ >= 26` and the app + supports 24, so it is neither declared nor present; mosh uses exactly one item, + `CODESET`, only to rewrite glibc's `ANSI_X3.4-1968`, so a stand-in is honest. The + force-include has to go on **`CXXFLAGS`**, not just `CFLAGS`, or it compiles the C + sources and fails the first C++ one, which reads exactly like the shim not working. + +- **protobuf needs the *lite* runtime and nothing else.** mosh's three `.proto` + files are generated `optimize_for = LITE_RUNTIME`. Asking automake for + `install-libLTLIBRARIES` builds libprotobuf, libprotobuf-lite, **libprotoc and + libupb** — over an hour of a single-core host for two libraries mosh never links. + Copy `src/.libs/libprotobuf-lite.a` by hand. Its public headers include `.inc` + files as well as `.h`, and mosh's configure reports the unhelpful "Could not build + output generated by protoc" when one is missing. + +- **The `.so` size so far is crypto-only** (978,496 bytes) because the probe + exercised `Crypto::Session` and therefore pulled in neither protobuf nor statesync. + The real client will be larger. What is proven is that OpenSSL's ~4.4 MB is gone, + and that the shared object needs only bionic (`libz liblog libdl libm libc`), so + no extra `.so` ships. + +## mosh's clock is frozen on purpose, and that is the entire send path + +- **`Network::timestamp()` returns `frozen_timestamp()`, and `frozen_timestamp()` reads a + millisecond cache exactly once.** It then returns that same value for ever. The cache is + advanced only by an explicit `freeze_timestamp()` call, and in the whole of mosh 1.4.0 + there are exactly three callers: + + frontend/stmclient.cc:567 freeze_timestamp(); + frontend/mosh-server.cc:141 freeze_timestamp(); + util/select.h:187 freeze_timestamp() + +- **Write the client loop without that call and mosh sends your first frame and nothing + else, for ever.** The initial terminal state goes out, the clock sticks at the first + timestamp the client ever read, and every timer test stays `now < next_send_time`, so + `TransportSender::tick()` returns before it ever reaches `send_to_receiver()`. Keystrokes + queue correctly and visibly — `get_current_state().size()` goes to 20 — the sender is + asked to tick and declines, and the remote shell never sees them. There is no error, no + exception and no output: the session is connected, authenticated, drawing a prompt, and + one-way. + +- **Nothing in the trace points at it.** `revents` was 0x1 (data really was arriving), + the handshake had completed, and the framebuffer was updating. The clock was the only + thing not moving, and nothing prints it. It was found by patching + `transportsender-impl.h` to print `now`, `next_send_time` and `next_ack_time`: every + tick showed the same `now`, and `next_send_time = 18446744073709551615` — `uint64_t(-1)` + — which is what an empty diff sets it to. + +- **It is a test facility, and the name is the warning.** `frozen_timestamp()` exists so + mosh's own tests are deterministic. Shipping mosh's libraries means shipping a frozen + clock *and* its driver, and the driver lives in the frontends rather than in the + library, so nothing tells you one is required. + +## mosh needs an executable, because TerminalSession cannot be anything else + +- **`TerminalSession` is `final` and its constructor takes a shell *path*.** It builds the + subprocess itself through `JNI.createSubprocess`; there is no constructor that accepts a + `Process`, and there is no `JNIShell` in v0.118.3 to subclass. `TerminalSession` is also + not ours — it is the upstream jitpack library. + +- **So a mosh session is a process, and the process is a native client.** The app already + ships real executables as per-ABI assets (`assets/ssh//`) extracted to the rootfs + and run through proot's `-L`, so mosh follows the same path rather than inventing one: + link `redterm_mosh_client.cpp` and `mosh_display_compat.cpp` into an executable that + reads its endpoint from the environment and proxies stdin/stdout. `TerminalSession` + then needs no change at all, which is the point — the session machinery is not ours to + modify and must not have to be. + +- **`MOSH_KEY` belongs in the environment, not the command line.** The launcher script is + written to app storage and logged on the way past, so this is the rule from `SshAskpass` + arriving unchanged. The executable takes the address, port and key from its environment + and passes the key to the session in memory. + +## What mosh-server actually prints, and where the address comes from + +- **`mosh-server new` prints `MOSH CONNECT ` and nothing else.** It does not + print `MOSH IP` or `MOSH SSH_CONNECTION`, and `scripts/mosh`'s parser looks for all + three. Measured against 1.4.0's own binary with and without `SSH_CONNECTION` set: the + only `printf` of a `MOSH` line is the connect one. + +- **`mosh-server` binds `$SSH_CONNECTION`'s server-side field, and that is *not* the + address the client should send to.** Measured on a real Oracle instance: + + ip -4 addr -> 127.0.0.1, 10.0.0.79 (enp0s6), 100.68.49.100 (tailscale0) + no public IP on any interface; 129.146.25.232 is 1:1 NAT + + Reached over Tailscale, the ssh session arrives on `tailscale0`, so the field is + `100.68.49.100` and everything works. Reached by public IP, the session arrives on + `enp0s6` after the NAT, so the field is `10.0.0.79` — `mosh-server` binds it correctly + and the cloud would forward inbound UDP to it happily, but a client that trusted the + number sent its packets into the VPC from the internet and every session timed out + having proved nothing. **This was written down as if it were the right answer before it + had been measured; it is right for what the server binds and wrong for where the client + sends.** An earlier note here claimed the opposite with confidence; that claim is what + cost the whole feature its first working run over a public IP. + +- **The destination is the address ssh connected to** — the host the user configured, + resolved on the device, preferring IPv4. It is always reachable because it is the one + that just worked. `$SSH_CONNECTION` is kept only for the case where the host cannot be + resolved on the device at all, which is the only case it is good for. + `MoshBootstrap.udpDestination` is the rule and it is a pure function with tests. + +- **`mosh-server` still has to be started with the connection echoed**, because the + bootstrap wants to know the server is listening at all, and gets that from + `MOSH CONNECT`. It is started as + `/bin/sh -c "echo MOSH SSH_CONNECTION \$SSH_CONNECTION; exec mosh-server new"` in the + same ssh invocation, so it costs no second connection and no second authentication. + +- **`mosh-server` forks and detaches after printing, so ssh exits on its own.** The + parent exits and the detached child keeps the UDP port bound, which means a + wait-for-exit call is sufficient and no streaming read is needed. Verified by checking + the foreground process had exited while the port was still bound. + +- **The escaping is three shells deep and two of the three are traps.** `$SSH_CONNECTION` + must survive the device shell unexpanded and be expanded by the *remote* shell. Double + quotes with `\$` do that; single quotes do not, because a backslash inside them is + literal and the remote shell would print the text instead of the value. `exec` matters + too — without it the wrapper shell lingers as mosh-server's parent and the bootstrap + appears to hang on a session that is already ready. Both levels were run against the + real binary rather than reasoned about. + +## Display's constructor is the only curses-dependent thing we need, and it is not + +- **`Terminal::Display` cannot be replaced.** `new_frame()` is what turns a framebuffer + diff into the escape sequences the Android emulator understands, and reimplementing it + means reimplementing wrapping, scrolling regions, wide characters and SGR bookkeeping. + +- **`build-mosh.sh` deletes `terminaldisplayinit.o` from `libmoshterminal.a`** because it + is the one file in `src/terminal` calling `setupterm`/`tigetflag`/`tigetstr`. That was + sound while nothing referenced it — and `Display::Display(bool)` lives in that same + file, so the moment `Display` is used the link fails on an undefined constructor. This + is the documented failure arriving: it was noted that referencing it would break the + link rather than silently bind to a terminfo database, and it did. + +- **Given `use_environment == false` the constructor body is the member initialiser list + and nothing else.** All the capability discovery is inside `if ( use_environment )`, + which is exactly what an Android device cannot answer anyway. `mosh_display_compat.cpp` + supplies it, with `smcup`/`rmcup` null (no alternate screen on a phone), `has_bce` true + (erases carry their background colour), `has_ech` true and `has_title` true. A `true` + argument throws rather than guessing, which is what upstream does when the database is + missing. + +## The shim yields the core, and two of its bugs were only findable by running it + +- **`redterm-mosh` is host-testable, and that is how two of its three bugs were found.** + The same sources that go into the `.so` link into an executable, and against a real + `mosh-server` on this machine it can be driven end to end: start a server, point the + shim at it, type a command, look for the echo. Both of the following were invisible to + reading and obvious to running. + +- **A give-up timer must count milliseconds, not loop iterations.** The first version + gave up after 50 idle rounds. mosh's `wait_time()` returns **zero** when its timers are + due, so each round returned immediately and 50 rounds took milliseconds: the client + exited with "the connection went away" before the first UDP packet had been read. The + same class of error as the frozen clock above — an iteration is not a duration. + +- **Then it still failed, and the cause is this host, not the code.** The session pumps + its own network on a worker thread, and the shim's loop polls stdin with whatever + `wait_time()` said. With a zero timeout that is a spin, and this machine has **one CPU + core**, so the spinner and the worker split it and the worker — the only thing that can + bring data in — got almost none. `poll` now has a **20 ms floor** so the loop yields. + On a multicore phone this would have been intermittent rather than total, which is a + considerably worse bug to chase. + +- **`TerminalView` in v0.118.3 has no column/row getter** (`mEmulator` is package private + on the session), so the initial `MOSH_COLS`/`MOSH_ROWS` are a seed rather than an + answer. The shim re-reads the pty on every SIGWINCH and `TerminalSession` sends one as + soon as the view is laid out, so the first frame is corrected almost immediately. + +## Two traps that each looked like something else + +- **A compiler wrapper that appends a flag silently overrides the caller.** The `cxx` + wrapper in `build-mosh.sh` ended `exec "$REALCXX" "$@" ... -Wl,-z,max-page-size=$PAGE_SIZE`, + so the executable's `4096` was followed by the wrapper's `16384` and lost. Nothing + failed: the link succeeded, the binary was simply aligned the other way, and the only + sign was a check comparing against a number the wrapper had just changed. A wrapper that + adds a flag has to let the caller win, or it is not a default — the same shape as + `SshAskpass.environmentPrefix` once being applied to every argument. + +- **The two ABI vocabularies disagree, and the failure is a missing file.** `assets/` uses + build-openssh.sh's names (`aarch64`, `armv7a`, `x86_64`, `i686`); `Build.SUPPORTED_ABIS` + and jniLibs use `arm64-v8a`, `armeabi-v7a`, `x86_64`, `x86`. Using the ABI name verbatim + opens `assets/mosh/arm64-v8a/…`, which does not exist — so *every* device would report + "could not install the mosh client", including ones the binary was built for, and the + APK would visibly contain the right file. `MoshClient.assetArch` is a pure function with + a test over every shipped ABI for this reason. + +## The mosh toggle is a preference, never a requirement + +- **`useMosh` is on both `SshStore.Server` and `SessionDescriptor.Ssh`, written *and* + read**, with a round-trip test and a hand-written pre-mosh record to prove the reader + tolerates its absence. A field added to the reader and not the writer has no symptom + anywhere: the server loads, the toggle renders, and every session quietly opens over ssh. + That is the same shape of bug as the `encrypted` flag on key entries. + +- **Falling back is the design, not an error path.** mosh needs `mosh-server` on the host + and UDP 60000-61000 open to it; the app can arrange neither and can barely see either. So + `TerminalActivity` bootstraps on a worker thread and attaches whatever came back, saying + through `AppLog` which protocol it used and why — a silent fallback is indistinguishable + from the preference having been ignored. + +- **`MOSH_KEY` reaches the client in the process environment**, through the `env` array + `TerminalSession` passes to the subprocess, and the launcher script contains nothing but + the binary's path. This is `SshAskpass`'s rule a third time, now in a subsystem that + has no askpass in it. + +## The terminal view hides a null renderer behind a zero width + +- **`TerminalView.mRenderer` is written by exactly two methods in the library: + `setTextSize(int)` and `setTypeface(Typeface)`.** Nothing else — not the constructor, + not `onSizeChanged`, not `attachSession`. Confirmed from the bytecode of v0.118.3, + where those are the only two `putfield mRenderer` in the class. + +- **`attachSession` calls `updateSize()` unconditionally**, and `updateSize()` guards on + `width == 0 || height == 0 || mTermSession == null` — **but not on the renderer being + non-null**. The line after the guard divides the width by `mRenderer.mFontWidth`. + +- **So attaching a session before the view has been laid out is safe, and attaching it + afterwards is not.** During `onCreate` the width is 0, `updateSize()` returns at once, + and the missing renderer costs nothing. Move the attach past the first layout — which + is what mosh's bootstrap does, since it takes seconds — and the guard passes, the + renderer is read, and the app crashes on `mFontWidth`. + +- **Two of the three attach paths in `TerminalActivity` had the right order all along** + (font, then `attachSession`); the fresh-session one did not, and nothing noticed + because the width was 0 on every launch it had ever done. **The lesson is not "call + `setTextSize` earlier" — it is that a guard which returns early can hide a missing + invariant for as long as the early-exit condition is always true, and an always-true + condition stops being true the moment anything becomes asynchronous.** When making a + path async, re-check what the synchronous path was accidentally relying on. + +- **The rule then had to survive a fourth surface, and did not by itself.** + `RecordingPlayerActivity` built its own `TerminalView` in code, attached a + session, and never called `setTextSize` or `setTypeface` — so the crash needed + no async at all: no width-0 accident, just a view with no renderer anywhere. + The fix is the same order the session paths follow, with the palette applied + after the attach (the emulator only exists then, and the recording's SGR + indexes replay through the same theme palette the session drew with). Font and + palette helpers moved out of `TerminalActivity` into `TerminalTheming`, so a + new terminal surface starts from the shared one rather than from a copy. + +- **And a fifth layer under the same rule: the view's *client*.** `updateSize()` + also calls `mClient.onEmulatorSet()` — and `mClient` is written by + `setTerminalViewClient` and nothing else, with no guard anywhere between. The + renderer was set and the crash moved one call deeper, on the next unguarded + pluggable. A `TerminalView` laid out needs font (renderer), a view client, and + then — and only then — anything that reads the emulator's size. Pinch on the + player answers like the session terminal does (resize the font), because the + gesture handler feeds the client's return back into the view's scale state: + an unchanged return is an unapplied zoom, not a rejected one. + +- **And a sixth: the pty underneath the session, which the player never needed.** + The replay view fed its file into a pty expecting a `stty`-configured child to + echo the bytes back, so the very first process spawn decided whether replay + worked: this device's `stty` does not know `-icanon`, the child exits code 1 + before a frame is fed, and the replay is a blank page. The pty existed only + because `TerminalView` needs a `TerminalSession` — the emulator does not. A + `TerminalEmulator` consumes an escape-sequence stream on its own, and the + library's `TerminalRenderer(int, Typeface)` draws one onto any canvas + (`render(emulator, canvas, topRow, selection rows/pair, selection col pair)`), + which is what playback now does — no process, no stty, no distro prerequisite. + The emulator's constructor is `TerminalOutput`-bound and `TerminalOutput` is a + six-method abstract class; the session-client stub is still its passenger. + +## A `when` that returns null for two different things is not a check + +- The mosh preference was read as + + when { !server.useMosh -> null; !loaded -> reason; !installed -> reason; else -> null } + + which answers "if mosh is in play and cannot be had, why not" and returns null both for + *the server never asked for mosh* and for *everything is fine*. The two are then + indistinguishable, so a server with the toggle off fell through to the bootstrap + anyway: every plain SSH session paid a multi-second delay, and could be handed a mosh + session it never asked for. + +- **A branch that yields the same value for "no" and for "yes" cannot gate on it.** The + preference itself has to be a separate, explicit check — `if (!server.useMosh) { ssh; + return }` — and the availability check is a separate question after it. Splitting the + two is also what makes the log line truthful: it can say *why* mosh was skipped instead + of skipping silently. + +## A session's transport cannot be changed, only replaced + +- **ssh and mosh are separate processes speaking separate protocols**, so "switch this + session to mosh" cannot mean anything except "open another one and close this one". + The menu item says **Reconnect** for that reason. Anything less leaves a mosh process + labelled as ssh, or an ssh session quietly claiming to be mosh. + +- **`SessionDescriptor.Ssh.useMosh` is written by `prepareMosh` and nowhere else**, so it + is the truth about what connected rather than a copy of the preference. That is what + makes the title safe to derive from: it reports the transport in use, which is the + interesting case — a server whose card says *mosh* and whose session says *· ssh* has + told the user exactly the thing they needed to know, with no extra state. + +- **The transport suffix is display-only and lives outside `sessionLabel`.** That function + is the session's identity and feeds the exported transcript's filename; a suffix that + changed when a session reconnected would give one session two file names. `updateCwdTitle` + has to use the decorated label too, because it runs on every cwd poll and would + otherwise drop the suffix the first time the remote changed directory. + +- **A silent fallback is indistinguishable from a preference that was ignored**, so the + reason is both logged and toasted. The session is about to look perfectly normal three + seconds after the tap, and that instant is the only chance to say why mosh is not in use. + +## Copying a field by hand is not persistence + +- **The same bug has now happened three times**, each one a field added to + `SshStore.Server` and forgotten somewhere the server travels: + 1. added to `Entry`/`Server` and the reader but not the writer, so every protected key + loaded as unprotected and the server had accepted it reported as refused; + 2. `useMosh` on `Server` and on `SessionDescriptor.Ssh`, written and read correctly in + JSON, and then dropped on the way through the **Intent** that opens a session; + 3. `useMosh` on the Intent, and then dropped anyway by `launchSsh`, which hand-copies + six fields into extras. + +- **None of them has a symptom in one place.** A server whose card says *mosh* opens + over ssh; the card and the session title are on different screens, so neither looks + wrong on its own and nothing says to go looking for a mismatch. The only reason this + was found is that the indicator was added at the same time and made the disagreement + visible — **a field that is silently lost is indistinguishable from a field that was + never set**, which is exactly why the transport indicator was worth building even + though it was not part of the original bug. + +- **The fix is not to add the field to one more list.** `launchSsh` carries a + hand-maintained subset of a nine-field record, and a list of nine is nine chances. The + saved record is now looked up by id and **wins** over whatever the intent carried; the + extras are the fallback for a server that is not in the list, which is the only case + they were ever needed for. `TerminalActivity.resolveSshServer` is the rule and it is a + pure function with tests. + +- **The general form: when one value is copied into another place by hand, prefer + re-reading the original wherever the receiving end can reach it.** The extras still + exist and still carry a stale copy, which is fine, because nothing reads it in the + case that mattered. + +## Loopback hides a timer bug that a real network finds in a hundred milliseconds + +- **The shim gave up instantly against every real server, and worked perfectly against + `127.0.0.1`.** The give-up timer was + + const long long silent = last_rx_ms ? now - last_rx_ms : now - start_ms; + + reduced to `now - last_rx_ms` with `last_rx_ms` starting at zero. That looks correct + and is not: the clock is `CLOCK_MONOTONIC`, so "now" is the milliseconds since boot — + millions of them — and zero does not mean "now, a moment ago" but "before the machine + started". The comparison is therefore true on the first iteration and the client exits + 4 in about a millisecond. + +- **On loopback the handshake comes back inside that same iteration**, so `last_rx_ms` + was already set by the time the check ran and the host harness passed every time. A + real server is a hundred milliseconds and upwards away, so the check ran first. The + symptom on device was `started a mosh session` immediately followed by + `exited with status 4`, which reads as the server refusing to speak mosh. + +- **A test has to be able to fail for the right reason, and "loopback works" is not a + test of a timeout.** The case is an endpoint that *accepts but never replies*: a bare + UDP listener, so there is no ICMP port-unreachable to end it by exception either. With + that, the old logic exits 4 in under a second and the new one is still waiting when + killed. Verified both ways by rebuilding the old expression. + +- **The first version of that test was wrong in a way worth remembering too.** It fed + the client `/dev/null` as stdin. The client treats stdin EOF as "the terminal went + away" and exits 0 immediately — correct behaviour, and a test that passed for an + entirely unrelated reason. Holding stdin open with `sleep N |` is what makes the + network the thing under test. + +## mosh needs a firewall that ssh alone does not imply + +- **A server reachable over TCP 22 is not necessarily reachable over UDP.** The cloud + instance worked over Tailscale and not over its public address, with the identical + bootstrap both times: `mosh-server` starts, binds its port and prints `MOSH CONNECT`, + and then nothing arrives. Tailscale carries the UDP inside its own overlay, so the + server's own firewall never sees the traffic. That is the whole explanation, and it is + invisible from inside the app — which is the problem. + +- **So a mosh session that produces no output must not be left on screen.** The shim + exits `4` for "nothing ever came back", opens a dialog naming both causes, opens a + working ssh session regardless, and offers to either stop asking for mosh for this + server or just this once. Falling back is not an error path here any more than it is + at bootstrap time. + +- **Both causes have to be named, because fixing the server can be the wrong answer.** + Measured on a cloud instance over four attempts: the app sent UDP to the right public + address, the host firewall was opened for `60000:61005` and persisted, and the packet + counter on that rule stayed at **0** throughout while tcpdump on the interface saw + nothing and the rest of the INPUT chain counted thousands of packets. So the packets + never arrived at all — which no server-side configuration can fix, because either the + cloud's security list is still closed or the phone's own network is refusing to carry + UDP. TCP 22 works in both cases; only mosh needs the return path, and a client behind + carrier-grade NAT has no UDP mapping for a flow it did not open. mosh over the same + server's Tailscale address works throughout, because it tunnels over a connection the + phone initiated — which is the shape of the difference, and not an accident. + +- **`-C` is never passed to a mosh bootstrap**, which is the one deliberate departure + from the SFTP command line it otherwise mirrors exactly. Compression is negotiated + over the SSH channel, and this channel carries one connect line and then closes: the + session proper is UDP with its own authenticated encryption. So it compresses nothing, + and a server without zlib answers it with `Compression not supported, disabling.` — + which is **OpenSSH's** message, not mosh's (the string appears nowhere in mosh 1.4.0's + source), emitted onto the very stream the bootstrap is parsing. mosh's own wrapper + does not offer compression at all. +- **Mirroring a command line is not mirroring a handshake.** The bootstrap copied + SFTP's arguments to the letter, and rode along on half of the BatchMode/askpass + pairing: `BatchMode` was dropped for a held secret, as SFTP drops it, but the + askpass environment that answers the prompt was never attached — ssh was allowed + to ask a question with no way to hear the answer, and every passphrase-protected + key and password server exited 255 into the SSH fallback. `SshAskpass.environment` + goes with every `SshClient.run` that can omit BatchMode; the SFTP client and + `runTest` wired it, and the bootstrap now does too. + +## "Nothing at all" and "something arrived" are both the wrong signal + +- **The shim's "is this session alive" check was `did we render anything`, and the very + first render always differs** — the remote's terminal size is not the one we were + seeded with, so mosh emits a diff before a single packet has arrived. Timing the + give-up on that meant the "waiting for the session" state ended almost immediately, and + a blocked UDP path was reported as a session that had *stopped*. Symptom on device: an + empty terminal that stayed empty for 57 seconds and never produced the dialog, while the + app was confident a session existed. + +- **The signal has to be whether the network spoke to us**, which is what + `mosh_session_is_connected` already answers — the worker sets it only after a + successful `recv()`. A framebuffer diff is ours; a received packet is the server's. + +- **There is now exactly one timer, and it is about one thing.** Waiting 20 s for the + first byte from the server, then never again. A mosh session is silent most of the + time somebody is reading a man page, and killing it for that would break the one thing + mosh exists for. mosh's own client has no such timer either. + +- **A blank terminal has to say something — and in this app the place it says so is + not the client.** The client used to print `mosh: connecting to :…` to + stdout and erase it with `\r\033[2K` on the first frame. That line and all of its + erase bookkeeping have since been removed, on purpose: a status printed into the + terminal competes with the shell's output for the same screen (one far-end scroll + strands the rewritten line at the top and the shell prints underneath), and during + a connect there is no far end to scroll anything — the terminal belongs to the + client alone. Connecting is the app's business: `TerminalActivity`'s overlay covers + the attach and ends on the session's first drawn output, so the terminal is blank + for nobody. The client prints only the failure report (`say` in + `redterm_mosh_main.cpp`), which is the one text the terminal is the right place for. + +## A failure the user never reads is a failure nobody can fix + +- **The library prints `[Process completed (code N) - press Enter]`, and we destroy the + session before anybody can read it.** `handleSessionFinished` removes the session as + soon as the process exits, so the stock acknowledgement is gone with it — and it + contains a status number and nothing else, which is not a diagnosis. The mosh client + therefore prints its own explanation and waits for a keypress before exiting, bounded + so a session nobody is watching still closes. + +- **Pressing Enter *before* the explanation is not an acknowledgement of it.** The client + is still in its main loop then, and a keypress there is ordinary terminal input queued + for a session that will never answer — so it is consumed and the failure still waits. + A test that sends the key "some time during the handshake" therefore passes nothing; + it has to send it after the message, which is what a user does. + +- **The Enter the report promises survives the report's own process, and nobody else + is left to answer it.** `wait_for_ack` consumed the key that closed the failed + client, but the text stays on screen and the user keeps pressing — a dead session's + stdin is a buffer nobody reads, so every further Enter is eaten and the promise + ("close this session and open one over SSH") goes unkept. The app answers now: + dismissing the failure dialog *without* opening an ssh session arms the promise, + and an Enter aimed at that dead session is consumed at the one place the library + hands out keys before they reach the terminal — `TerminalViewClient.onKeyDown` + (key events) and `onCodePoint` (an IME's committed Enter, which the library + normalises to `\r`, code point 13). The answer is guarded: the keypress must be + aimed at the session the report belongs to, and that session must still be dead, + so a live session behaves as a terminal and a replaced session inherits nothing. + A cancel must also mean *stay with the mosh preference* — Enter opens SSH the way + "this time only" does; "stop trying" is the only path that writes the preference. + +- **The progress line the client once printed is gone, and the reasons it was built + are gone with it.** A blank terminal for twenty seconds is no longer + indistinguishable from a hung one, because the app's overlay covers that wait and + ends on the session's first drawn output. What the removal fixed of its own making + is worth remembering, because each looks like a good idea again: a status + printed before `TerminalView` had built its emulator went out before there was + anything to draw to, and a status left unerased ended up as litter under the + shell's output. None of it is worth resurrecting; the client keeps only `say`, + the failure report, which is used nowhere else. + +- **`exit` at the remote shell was being said goodbye to, and nothing was listening.** + On a real server typing `exit` left the session stuck at `logout` for ever, and no + error-based detector could ever fix it, because by the time the shell exits *every* + packet state is acknowledged — SSP sends nothing more, the network falls silent in + both directions, an error-based detector never hears an error, and "five consecutive + receive errors" never happens. mosh has its own protocol shutdown the whole time: + `mosh-server` sees the pty close, calls `start_shutdown()`, and sends special packets + with `new_num = uint64_t(-1)` until acked; the client's ack then flips to -1, which is + what `counterparty_shutdown_ack_sent()` reports. The real mosh client's loop breaks + clean on exactly that (plus the two shutdown-timer combinations) — owning it meant + reading mosh's `stmclient.cc`, because nothing in any trace mentions shutdown. The + client now checks the atomic the worker samples off those calls and exits 0 without + printing its failure report; the ICMP-port-unreachable detector (five errors in a + row, then give up) stays as the crash fallback, never the primary signal. Verified + both directions against a real `mosh-server` on the build host: the pre-fix shim was + still running at 50 s when it was killed, the post-fix one said a clean goodbye and + exited 0 in under five seconds. +- **If every packet of both the shutdown and its ack is lost mid-flight, the result is + two-way silence the protocol cannot tell from a live-but-idle session** — fixing + that would need a timeout that kills genuinely quiet sessions, the trade the + idle-timeout removal above already rejected. Deliberately unfixed. + +## The overlay ends where the session makes it false + +- **A plain ssh connect does its slow work *after* the session process starts.** + `prepareSsh` picks keys and writes a launcher — milliseconds. Overlaying that meant + the indicator ended exactly when it stopped being needed, and the seconds of TCP, + key exchange and authentication ran in full view on a blank terminal. The overlay + now outlives the attach and ends on the session's **first drawn output**: the + banner, the prompt, or ssh's own in-terminal passphrase question — which is also + why it must end on *any* first output, not on "success" only: the prompt is output + the user needs to read and answer. +- **A mosh session skips the wait no longer.** The Ready path used to hide the + overlay at attach time because the client was believed to draw its own connecting + status — which stopped being true the day the progress line was removed, and left + a blocked-UDP session as twenty seconds of nothing. It now waits the same way a + plain ssh session does: the first frame, the failure report, the session's death + or the 30-second cap is what ends it. +- **The library's `onTextChanged` is a faithful "the remote spoke" signal** — + confirmed from the v0.118.3 bytecode: the *only* callers of + `TerminalSession.notifyScreenUpdate()` (which is the only caller of + `client.onTextChanged`) are the reader thread after `TerminalEmulator.append` and + the session's own `write`/echo path. `TerminalEmulator.resize` does not fire it, + so a layout-induced resize cannot masquerade as remote output. When an event + looks ambiguous, read the bytecode before inventing a guard for a race that does + not exist. +- **Ending on first output still needs a cap.** A TCP connect to a black hole + produces no output at all, so the 30-second timeout is what stops a silent + connect, not the output signal. And a session that dies without ever producing a + line — refused connect arriving as a bare process exit — reports through + `onSessionFinished`, which also clears the wait; output, death and timeout are + the three ends, and only "never attached" is not one. +- **Cancel has to mean the same thing at every point of the wait, and attempts + cannot share one flag.** Cancelling during the *attach-wait* also stops the + session the overlay sits on (`finishIfRunning`) — recorded in + `deliberatelyClosed` first, because the kill reports as status -9 and would + otherwise come back as a failure dialog for the cancel the user just chose. + Discarding results is by **generation token**, not a boolean: a shared + `attemptAbandoned` could not tell two attempts apart, so a stalled attempt + cancelled by the user went on to pass the reset flag a newer attempt began. + Each `showConnecting` returns a token, Cancel invalidates only the token it + was shown for, and a worker attaches only when its token is still the newest. + State that is answered by "the next phase will consume it" is stale the moment + phases can be missed; state that is answered by "probably the newest attempt" + is stale the moment two attempts overlap. + - **A fresh attempt also clears the attach-wait.** `showConnecting` calls + `stopConnectWatch` before showing: an earlier ssh session's first output would + otherwise end the new attempt's overlay, and cancelling would end a session + from an attempt the user is not looking at. + +## A recording is a screen capture, read from the emulator's own ground truth + +- **Record means the session it sits on.** The earlier design armed a recording + for the *next* session that happened to open, and every piece of it was worse: + the target was whatever the user opened next, the filename was guessed before + anything was recorded, and nothing ran if no session followed. A recording of + the current session starts, stops and names itself from that session — distro + or server — and ends when the session ends, by hand, or when the activity dies. +- **A recording is a sequence of screen frames, re-encoded from the same + `TerminalRow` data the renderer reads.** The terminal library offers no hook on + what a session *writes* — its byte-level entry feeds process stdin — so this is + the honest alternative: every drawn frame becomes positioned rows (`ESC n;1H`) + plus SGR **deltas** from the state the last run left, with trailing + default-styled blanks resolved away and mid-row blank-default runs as bare + spaces (the cursor advance is what those spaces are for). The player's + `RecordingPlayerActivity` already speaks exactly this. +- **The style word's decoded output is the ground truth, never its decimal.** + The default style is `fg 256 / bg 257` — the raw word is 281474993553408, which + at a glance is **indistinguishable from** 281474976710656 (2^48, which decodes + to `bg 0`). The digits that differ sit low in the decimal, far from where the + eye compares. Every stale "bg = 0" conclusion below would have followed from + eyeballing that number instead of printing `TextStyle.decodeBackColor`. +- **A `TerminalEmulator` is JVM-testable**, constructor `(output, columns, rows, + cellWidthPx, cellHeightPx, Integer transcriptRows, client)`, fed through + `append` — which is why the encoder's tests drive a real emulator with escape + sequences rather than asserting on hand-built style words. `TerminalRow. + getStyle(col)` is per column; the *text* is read through a running `mText` + offset, and the order matters: one frame's position-assertion was first written + with "column 3 = index 3 arithmetic" even though CUP col 3 lands at index 2 — + the emulator, not the assertion writer, decides where a cell lands. +- **`Backend.onFrame` coalesces per frame, not per append.** It fires inside the + existing Choreographer callback in `onTextChanged`: two sessions appending in + one 16 ms frame means one snapshot, the later of them. Acceptable: frames are + snapshots, and a frame that only the loser of a race renders is one frame + short. +- **One place writes, one place names.** `FrameRecorder` owns the file handle and + the byte counts; `SessionRecorder` owns the directory and the naming; and + neither invents what the other knows. A tool that succeeded silently was the + old failure mode: the recording's existence is now AppLog-visible at each end — + started, stopped (frame and byte counts), and abandoned with no frames written. + ## Known follow-ups (deliberately out of scope) +- The bundled ssh binary still carries the build host's sysconfdir compiled in + (`/tmp/.../ssh-/etc`), printed by its own `-v` trace — filtered at the test + card, but a rebuild would remove it at the source. When the rebuild runs, give + ssh's configure `--prefix=/usr` and `--sysconfdir=/etc/ssh` — the script still + builds with the work-directory paths that are baked into the shipped binaries. + It has not been run because `/tmp/redterm-openssh` (sources and the stamped + OpenSSL trees) was wiped, so it costs a full OpenSSL fetch and four ABI builds + of hours on one core. + When it runs, bump `SshClient.CLIENT_VERSION` so devices re-extract. The client + then begins reading the distro's real `/etc/ssh/ssh_config`, which is why + `HashKnownHosts=no` is pinned in `SshLaunchOptions.BASE_OPTIONS` — Debian-family + configs hash new entries, and the known-hosts screen would list them as `|1|…` + gibberish. + - Split view is still exactly two panes. - Remote recursive search is one level only: each level is a round trip. - Downloading a whole remote folder is not offered; the files inside it are. diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b7f49..601c4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,203 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.3.0] + +### Added + +- **mosh, as a per-server option.** A saved server can be set to open over mosh instead of + SSH. mosh keeps no connection at all once the shell is up — it runs over UDP — so a + session survives switching between WiFi and mobile data, a lift, or a cell handover, all + of which drop a long-lived TCP connection. The protocol is mosh's own, built from mosh + 1.4.0's libraries and shipped as a per-ABI native client for all four architectures, the + same way the bundled `ssh` binary already ships. +- **The transport is visible.** The session title and the notification show the transport + that actually connected, not the one that was requested, so a server whose card says + *mosh* next to a session saying *· ssh* has made the fallback visible without any extra + state. See Fixed. +- **Switching transport from a running session.** **⋮ → Connection → Reconnect using mosh** + or *Reconnect using SSH*, on SSH sessions. It opens a new session over the other protocol + and closes the old one — the two are separate processes, so one cannot be converted into + the other — and the server's saved preference moves with it. +- **A connecting indicator** while an SSH or mosh session is brought up, covering the + plain-SSH connect and the SFTP browser's first connection as well, each with a way + out if the connection stalls. + +### Fixed + +- **Recording a session actually records that session.** Recordings used to mean + "arm it now, and the *next* session you open gets captured" — which often + happened to be a throwaway, produced a file whose name was guessed at start-up, + and said nothing when no recording ever recovered the terminal output. Now the + Record button captures the session it sits on: the emulator's visible screen is + snapshotted on every drawn frame and re-encoded as plain ANSI (positioned rows + and SGR state deltas, the same playback the Recordings player already speaks), + so exactly what the session drew is what replays. Files are written under the + app's recordings directory in one place, named at start from the session's own + identity (distro or server), flushed per frame, and cleaned up when a recording + ends empty — ending with its session, by hand, or when the activity dies. Both + ends log through AppLog: started, stopped (with frame and byte counts), and why + a storage failure or a frame write stopped it. +- **Playing a recording no longer crashes the app.** The replay view attached its + session and never gave it a font, so the first layout ran straight into + TerminalView's null renderer — the same crash the mosh bootstrap hit, from the + same missing order. The player now sets the size and typeface before the + attach, and applies the theme's palette after it, so a recording replays in + the same colours it was made with. Font and palette helpers moved to a shared + `TerminalTheming` so every surface showing a terminal uses the same ones. The + view's client pointer is the next unguarded slot in the same row of machinery + (`updateSize` calls its `onEmulatorSet` with no guard), so the player answers + that too — and pinch zooms the replay the way the session terminal does. +- **And replay no longer runs a shell at all.** Feeding the file through a pty + made playback depend on whichever `stty` vocabulary the device ships: on this + one the child exits `stty: -icanon: unknown option`, code 1, before a single + frame is fed in — the replay was a blank page that finished before it started. + The pty existed only because `TerminalView` needs a session; the emulator does + not. Playback now feeds chunks of the file into a private `TerminalEmulator` + on a background thread and draws it with the library's own renderer — no + process, no stty, nothing device-dependent in the middle. +- **"Press Enter to close this session and open one over SSH" now works.** The + failed mosh session's report is printed by a process that then exits, so nobody + was listening for the Enter it asked for: dismissing the failure dialog left a + dead terminal eating keys. The dialog's dismissal now arms the promise — and an + Enter aimed at that dead session is intercepted at the terminal client, where + the library offers both key events and input-method-committed text before it + types anything — closed, and the same server opens over SSH, without changing + the mosh preference the user chose to keep. +- **The build is back at zero warnings.** Six compiler warnings from Android 16/17's + deprecation wave are fixed at the source rather than suppressed: the deprecated + `TRIM_MEMORY_RUNNING_LOW` threshold (any trim signal now drops a cache that is a + map clear to rebuild), the deprecated `announceForAccessibility` (the connecting + overlay's message is now an accessibility live region whose text changes on screen, + the sanctioned replacement), the deprecated `SHOW_IMPLICIT` flag (deprecated and + without effect since Android 16; the flag is simply omitted), the Int `versionCode` + field (`PackageInfoCompat.getLongVersionCode`), and two conditions that compared + things they could never satisfy — a `File` tested for null and a non-null list's + element tested for null. +- **`exit` at the remote shell now ends a mosh session.** mosh-server says goodbye + on its own protocol — encrypted packets that arrive wherever the session's + packets do, ICMP-filtering NAT and all — and the client never acted on it: by + the time the shell exits every state is acknowledged, so nothing more is sent, + the network falls silent in both directions, an error-based detector never + hears an error, and the session sat at `logout` for ever. Measured before the + fix, against a real mosh-server: killed after 50 s, still running. After it: + a clean goodbye, exit 0, no failure text, in a few seconds. +- **A mosh session that never reached the server is covered until it says so.** The + connecting overlay used to end the moment the bootstrap handed back a listening + server, on the theory that the client draws its own connecting status — which it + no longer does. A blocked UDP path then read as a blank terminal for the full + twenty-second wait. The overlay now ends on the session's first drawn output, the + failure report or the 30-second cap, exactly as a plain ssh connect is handled. +- **Closing a mosh session that never connected can no longer leave the screen + behind it stale.** The no-reply dialog's early return skipped the session + bookkeeping the ordinary close runs — the split teardown, the re-attach to the + surviving session, the title, the drawer and the home-screen widget — so drawer + rows could point at sessions that no longer existed. Only the activity's own + close is skipped while the dialog is up; everything else runs. +- **The no-reply dialog no longer claims a session was opened.** Cancel opens + nothing, and the buttons open one on choice; the message now says the server can + be opened over SSH instead. +- **The credential loop stops on a failure no credential can fix.** An attempt + that is not a refusal — the host unreachable, or it accepted a key and then had + no mosh-server to run — used to send the remaining keys and the password stage + at the same question, each a fresh connection and possibly a prompt on a server + that had already shown its hand. The first such attempt now ends the loop and + carries its own reason out, instead of the last credential refusal masking it. +- **The native mosh client's error text can no longer be read while it is being + rewritten.** `mosh_session_last_error` returned a pointer into a string the + worker keeps reassigning, which a caller formatting the previous failure while + the next `recv()` threw could read mid-mutation — the remote-exit path does + exactly that. The text is now copied out under the lock. The client's + `connected` flag is atomic (the caller's thread reads it while the worker + writes) and its consecutive-error counter is initialised rather than left + indeterminate. +- **A font collection is no longer accepted and then hidden.** `.ttc`/`.otc` + passed the import filter but neither the settings list nor the terminal's font + loader honours them, so an imported collection appeared in no list and could not + be removed. They are refused at the picker, where the refusal is readable. +- **Cancelling one connect attempt can no longer spoil another.** The attempts + shared one abandoned-flag, so a stalled attempt the user cancelled went on to + attach when a newer attempt had already begun — and could discard the session + they had just asked for. Results are now tracked per attempt: Cancel invalidates + only the attempt it was shown for, and a worker attaches only when its token is + still the newest. +- **The browser's SFTP connection check can no longer freeze the UI.** Asking + whether a connection is up took the same lock a running transfer holds for its + whole duration, so a tap that started a load waited behind it. The check now + takes no lock. +- **A passphrase key further down the list gets its prompt.** An open key's + refusal — where no dialog can help — was spending the connection's one + opportunity to ask, so a server that accepted the *second* key, an encrypted + one, rejected the connection without ever asking for its phrase. Only a + question actually asked now counts as asked. +- **Cancelling during the attach-wait is not a session failure.** It ended the + session the overlay was attached to, which arrived as a kill and was then + reported to the user as "session ended" with status -9 — a dialog for the + cancel they had just chosen. +- **The connection test's result can actually be copied now.** Long-press selection + was broken by a movement-method conflict and is fixed; a Copy button has been + added to the card as well, which puts the whole result on the clipboard in one + tap without depending on selection working at all. +- **A successful connection test no longer prints a wall of debug trace.** The + `-v` output that diagnoses a failure is noise on a success; the success card is + now what the connection used and what answered — the method, the key's label + where one was used, and the server's own version — and the failure card keeps + the trace, with lines merely naming the build machine's directories filtered + out of it. +- **The passphrase hint on the test card is shown when it is true.** It used to + appear on a success, saying the check "can only pass for a key without one" — + after a check that had just passed with a passphrase key. It now appears on a + refusal, and says both things a refusal can mean: a passphrase that never + arrived, or a key the server has no record of. +- **The SSH connecting indicator now covers the actual connection.** It used to end + when the session process started, which is the fastest part: TCP, key exchange and + authentication happen afterwards, on a blank terminal. The indicator now stays + until the session draws its first output — the banner, the prompt, or ssh's own + passphrase question — with a 30-second cap so a connect that produces nothing + cannot trap the user behind it. Cancelling while it is up also stops the session + it was attached to, including during that wait. +- **A key made with a passphrase now works everywhere, and asking for it stopped + going wrong.** The connection test and file transfer failed on such keys without + ever offering the prompt, for two reasons: a passphrase question dismissed once + was remembered in process-wide state and every later connection kept failing + without asking again, and a phrase held in memory was treated as proof that no + question could help — so one mistyped passphrase silenced asking for the rest of + the app's life. Both are gone: a dismissed prompt belongs only to the connection + attempt it interrupted, and on refusal the key file itself is probed locally — + an open key can never make a passphrase work, so it is never asked about; an + encrypted key is offered its dialog on every connection attempt. +- **Font import works on real devices.** The document picker filtered with `font/*`, but + providers report the same font under many names — `application/x-font-ttf`, + `application/font-sfnt`, or plain `application/octet-stream` for anything a browser + downloaded — so every folder containing fonts opened empty and nothing could ever be + selected. The picker now opens unfiltered, and what is picked is validated instead by the + display name the provider records; a non-font file is named in a message rather than + imported as one. `ttc`/`otc` collections are accepted where the old import would have + rewritten them to `.ttf`. +- **A failed mosh session no longer leaves an empty terminal.** It opens over SSH instead and + says why, offering either to keep asking for mosh next time or to stop asking for this + server. Nothing about it is silent: a fallback with no explanation is indistinguishable + from the preference having been ignored. +- **`exit` at the remote shell ends the session.** `mosh-server` closes its UDP port, the + next packet draws ICMP port-unreachable and every receive fails; the client counted those + and now treats several in a row with no successful receive as the far end saying the + session is over. Previously the terminal printed `logout` and then sat there for ever. +- **A live but quiet session is no longer at risk.** The earlier fix for the above removed an + idle timeout, which had the opposite failure — a mosh session is silent most of the time + somebody is reading a man page, and killing it for being quiet would break the one thing + mosh exists for. There is now one timer, and it is about waiting for the first packet. + +### Known limits + +- **mosh needs UDP 60000–61005 open to the server, all the way through**, which means the + cloud provider's security list *and* the host's own firewall. Cloud images commonly ship a + ruleset allowing TCP 22 and rejecting everything else, and opening the port in the + provider's console alone is not enough. +- **A phone behind carrier NAT may not be able to receive the UDP at all.** SSH works in + both cases; only mosh needs the return path. mosh over an overlay network such as Tailscale + usually works regardless, because the traffic travels inside a connection the phone + already opened. + ## [v2.2.0] ### Highlights diff --git a/README.md b/README.md index 225e29c..7edbdb0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ install from either and update in place without reinstalling. - Multiple sessions per distro, and a separate session for every distro - One-tap package updates, storage usage, SSH servers, ANSI recordings and base image updates from the home screen - Bundled OpenSSH client: saved servers connect straight away, with no distro required +- **mosh** as a per-server option — survives WiFi/mobile switches that drop SSH, falling back to SSH automatically when the server or your network cannot carry UDP - **SFTP file browser** for saved servers — upload, download, search, permissions and symlinks - Keyring for SSH: named keys, ed25519/ECDSA/RSA-4096, passphrases, import, and per-server binding - Distro backup and restore that survives uninstalling the app @@ -757,6 +758,68 @@ passphrase. which is what you do when a server is rebuilt or moved and its key legitimately changes. **Import config** reads an OpenSSH `config` including its `Host` blocks. +#### mosh + +**mosh** is a different way to reach the same shell. SSH is one long TCP connection, and a +phone changes networks constantly — switching between WiFi and mobile data, or a lift, or a +cell handover — which drops it. mosh keeps no connection at all: it runs your shell over UDP +and the phone's address is allowed to change underneath it. That is the entire point, and it +is why it survives a network switch where SSH would need reconnecting. + +The protocol is mosh's own, implemented here from mosh 1.4.0's libraries and shipped as a +per-ABI native client — the same way the bundled `ssh` binary ships. Nothing to install on +the device. + +**Turning it on** is one checkbox on a saved server: **Options → Use mosh instead of SSH, +when it is available**. It is a preference and never a requirement — see *When it cannot be +used* below. + +**What the server needs.** Two things, and both are outside the app: + +1. `mosh-server` installed on the host (`apt install mosh-server` and it is done). +2. **UDP 60000–61005 open to your device — all the way through.** That means the cloud + provider's security list *and* the host's own firewall. Cloud images commonly ship a + ruleset that allows TCP 22 and rejects everything else, and opening the port in the + provider's console alone is not enough. + +**When it cannot be used, you get SSH, and you are told why.** If `mosh-server` is missing, +the port is blocked, or your own network will not carry UDP, the session opens over SSH +instead and says so. It never leaves you with an empty terminal. + +**Knowing which one you have.** The session title and the notification both say the transport +in use — `Test · mosh` or `Test · ssh`. That is the transport that actually connected, not +the one that was asked for, which is what makes the interesting case visible: a server card +that says *mosh* next to a session that says *· ssh* has told you the fallback happened, +without any extra state. + +**Switching from a running session.** **⋮ → Connection → Reconnect using mosh** (or *using +SSH*), available on SSH sessions. It opens a new session over the other protocol and closes +the old one — the two are separate processes, so one cannot be converted into the other. The +server's saved preference moves with it, so the next tap reverses it. + +**Over Tailscale and other overlays, mosh usually just works.** The traffic travels inside a +connection the phone already opened, so a server's own firewall never sees it. Over a public +IP it has to be open, and on a phone behind carrier NAT it may not be — see below. + +**If a session hangs and then reports that nothing arrived,** the client never received a +single UDP packet back from the server. Which direction dropped is not something the client +can see — the outbound packet may have arrived and the reply been lost on the way back — so +check both: + +- UDP 60000–61005 is not open **to** the server, so the outbound packet never arrived, or +- the path back **from** the server is blocked — the cloud provider's firewalls are + separate inbound and outbound, and a reply to a UDP flow the phone opened can be dropped + by carrier NAT on mobile data. + +SSH works in either case; only mosh needs the UDP. To check the host side: + +```sh +sudo iptables -L INPUT -n --line-numbers | grep -E '60000|REJECT' +``` + +A catch-all `REJECT` with no UDP rule above it is the usual finding. Oracle and other cloud +images ship one. + #### SFTP **SFTP** is a home-screen tool: **Tools → 🔑 SSH → Browse files**. It is not a terminal command, and diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9994e3..2cd0438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.redtermapp" minSdk = 24 targetSdk = 37 - versionCode = 5 - versionName = "2.2.0" + versionCode = 6 + versionName = "2.3.0" } signingConfigs { diff --git a/app/src/main/assets/mosh/aarch64/redterm-mosh b/app/src/main/assets/mosh/aarch64/redterm-mosh new file mode 100755 index 0000000..5049d17 Binary files /dev/null and b/app/src/main/assets/mosh/aarch64/redterm-mosh differ diff --git a/app/src/main/assets/mosh/armv7a/redterm-mosh b/app/src/main/assets/mosh/armv7a/redterm-mosh new file mode 100755 index 0000000..6520e39 Binary files /dev/null and b/app/src/main/assets/mosh/armv7a/redterm-mosh differ diff --git a/app/src/main/assets/mosh/i686/redterm-mosh b/app/src/main/assets/mosh/i686/redterm-mosh new file mode 100755 index 0000000..0e4d7af Binary files /dev/null and b/app/src/main/assets/mosh/i686/redterm-mosh differ diff --git a/app/src/main/assets/mosh/x86_64/redterm-mosh b/app/src/main/assets/mosh/x86_64/redterm-mosh new file mode 100755 index 0000000..34defa3 Binary files /dev/null and b/app/src/main/assets/mosh/x86_64/redterm-mosh differ diff --git a/app/src/main/java/com/redtermapp/RedTermApp.kt b/app/src/main/java/com/redtermapp/RedTermApp.kt index a4583d2..d604fdc 100644 --- a/app/src/main/java/com/redtermapp/RedTermApp.kt +++ b/app/src/main/java/com/redtermapp/RedTermApp.kt @@ -26,6 +26,7 @@ class RedTermApp : Application() { com.redtermapp.util.Notifier.trackLifecycles(this) createNotificationChannel() loadRootfsCatalogue() + verifyMoshNative() } /** @@ -52,9 +53,10 @@ class RedTermApp : Application() { */ override fun onTrimMemory(level: Int) { super.onTrimMemory(level) - if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { - com.redtermapp.distro.DistroIconStore.trim() - } + // The RUNNING_* thresholds this used to compare against are deprecated in the + // current SDK; any trim signal is a good moment to drop a cache that is a map + // clear to rebuild. + com.redtermapp.distro.DistroIconStore.trim() } override fun onLowMemory() { @@ -79,4 +81,38 @@ class RedTermApp : Application() { const val CHANNEL_TERMINAL = "terminal" const val NOTIF_ID_TERMINAL = 1 } + + /** + * Phase 1 device gate: does libredtermmosh.so load on this device's ABI, and does + * mosh's authenticated encryption round-trip here? + * + * Off the main thread because `System.loadLibrary` on a missing library throws, and + * a throw at startup is a crash rather than a diagnosis. The result goes to AppLog, + * which Diagnostics shows, because an ABI we never built is invisible otherwise -- + * mosh would just quietly never be available on that device. + * + * Both numbers matter and mean different things: 1 is a correct round trip, 0 is a + * wrong one, and -1 means an exception escaped the native layer. "It did not work" + * and "it threw" are different bugs. + */ + private fun verifyMoshNative() { + val ctx = applicationContext + Thread({ + val loaded = com.redtermapp.mosh.MoshNative.ensureLoaded(ctx) + if (!loaded) { + com.redtermapp.util.AppLog.e( + ctx, "Mosh", + "native unavailable: ${com.redtermapp.mosh.MoshNative.loadError}" + ) + return@Thread + } + val v = com.redtermapp.mosh.MoshNative.version + val t = com.redtermapp.mosh.MoshNative.selfTest(ctx) + if (t == 1) { + com.redtermapp.util.AppLog.i(ctx, "Mosh", "native ok, mosh $v, crypto round trip verified") + } else { + com.redtermapp.util.AppLog.e(ctx, "Mosh", "native loaded (mosh $v) but crypto round trip returned $t") + } + }, "mosh-native-check").apply { isDaemon = true }.start() + } } diff --git a/app/src/main/java/com/redtermapp/distro/DistroIconStore.kt b/app/src/main/java/com/redtermapp/distro/DistroIconStore.kt index 0c54fae..90d61ac 100644 --- a/app/src/main/java/com/redtermapp/distro/DistroIconStore.kt +++ b/app/src/main/java/com/redtermapp/distro/DistroIconStore.kt @@ -8,6 +8,7 @@ import java.io.File import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.ConcurrentHashMap +import androidx.core.content.pm.PackageInfoCompat /** * Fetches a distro's logo once and keeps it on disk. @@ -61,7 +62,9 @@ object DistroIconStore { */ private fun stamp(context: Context): String = try { val info = context.packageManager.getPackageInfo(context.packageName, 0) - "${info.versionCode}" + // The Int versionCode field is deprecated for the 64-bit form, which is + // also what a device's actual code is when an update installed a long. + PackageInfoCompat.getLongVersionCode(info).toString() } catch (_: Exception) { "0" } diff --git a/app/src/main/java/com/redtermapp/distro/SessionRecorder.kt b/app/src/main/java/com/redtermapp/distro/SessionRecorder.kt deleted file mode 100644 index 5b39543..0000000 --- a/app/src/main/java/com/redtermapp/distro/SessionRecorder.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.redtermapp.distro - -import android.content.Context -import com.redtermapp.R -import com.redtermapp.util.AppLog -import java.io.File - -/** - * Records the real output of a command, escape sequences included. - * - * The terminal library exposes no raw output hook (its only byte-level entry - * point feeds the process stdin), so the capture is done the way a person - * would do it: run the command under `script`, which allocates a pty and - * therefore keeps colours and cursor control intact. - */ -class SessionRecorder(private val context: Context) { - - data class Recording(val file: File, val command: String) - - /** - * Where recordings live. - * - * App-specific external storage rather than `/sdcard/RedTerm`. The shared - * directory needs "all files access" to write or list on Android 11 and later, - * and without that grant every read comes back empty and every write goes - * nowhere — so the list was permanently empty and every recording failed, with - * nothing on screen to say why. - * - * `getExternalFilesDir` is the app's own area, which needs no grant at all, and - * `/sdcard` is bind-mounted into the distro rootfs, so `script` running inside - * proot writes to the same file the app reads. Falls back to internal storage - * when no external volume is mounted. - */ - fun dir(): File = ( - context.getExternalFilesDir(null) - ?.let { File(it, "RedTerm/recordings") } - ?: File(context.filesDir, "RedTerm/recordings") - ).apply { if (!exists()) mkdirs() } - - /** Where the directory actually ended up, for telling the user where to find it. */ - fun describeDir(): String = dir().absolutePath - - fun list(): List = - dir().listFiles { f -> f.isFile && f.name.endsWith(".ansi") } - ?.sortedByDescending { it.lastModified() } - ?: emptyList() - - /** Whether the directory can be written to, checked rather than assumed. */ - fun isWritable(): Boolean = dir().canWrite() - - /** - * The command a shell runs under so the whole session is captured, or null when - * the distro cannot. - * - * The shell is started as `script ` rather than wrapped after the fact. The - * terminal library offers no hook on what a session writes, so there is nothing - * to attach to later — `script` allocates a pty and the session's output passes - * through it, which is also what keeps colour and cursor control in the capture. - * - * `-f` flushes after every write, so a session that ends badly still leaves a - * usable file rather than an empty one. - * - * Null when the distro has no `script`. Reported rather than silently recording - * nothing, which is what the previous version did. - */ - fun sessionCommand(distroName: String, runner: DistroRunner): String? { - if (!runner.hasBinary(distroName, "script")) return null - val out = newFile(distroName) - val quoted = quoteForShell(out.absolutePath) - // -q no header, -a append so a restart does not truncate, -f flush. - return "script -q -a -f $quoted" - } - - /** Where the next recording for [distroName] would land. */ - fun newFile(distroName: String): File { - val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US) - .format(java.util.Date()) - val safe = distroName.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-').ifBlank { "distro" } - return File(dir(), "redterm-$stamp-$safe.ansi") - } - - fun delete(file: File) { - runCatching { file.delete() } - } - - /** How many recordings there are. */ - fun count(): Int = list().size - - /** - * Whether a capture finished with something in it. - * - * Called when a recorded session ends: `script` has exited by then and flushed, - * but a shell that was killed rather than exited can leave a file with no content, - * and an empty file in the list is a worse thing than no file. - */ - fun isUsable(file: File): Boolean = file.isFile && file.length() > 0L - - private fun quoteForShell(command: String): String = - "'" + command.replace("'", "'\\''") + "'" - - data class Result(val recording: Recording?, val error: String?) -} diff --git a/app/src/main/java/com/redtermapp/mosh/MoshBootstrap.kt b/app/src/main/java/com/redtermapp/mosh/MoshBootstrap.kt new file mode 100644 index 0000000..4ae9a02 --- /dev/null +++ b/app/src/main/java/com/redtermapp/mosh/MoshBootstrap.kt @@ -0,0 +1,369 @@ +package com.redtermapp.mosh + +import android.app.Activity +import android.content.Context +import java.net.InetAddress +import com.redtermapp.ui.SshKeyAuthenticator +import com.redtermapp.ui.SshKeyStore +import com.redtermapp.ui.SshLaunchOptions +import com.redtermapp.ui.SshStore +import com.redtermapp.util.AppLog +import com.redtermapp.distro.ProotLaunch +import com.redtermapp.util.SshClient +import com.redtermapp.util.sftp.SshAskpass + +/** + * Turns a saved server into a running mosh session. + * + * mosh is two protocols and one secret. SSH runs `mosh-server new` on the far end, which + * picks a UDP port, prints it together with a one-time key, and detaches; from then on + * the session is UDP only and the phone's IP may change underneath it. Nothing here is + * optional — without `mosh-server` on the host there is no mosh session, only ssh — so + * every failure here is reported as a reason and the caller falls back. + */ +object MoshBootstrap { + + private const val TAG = "mosh" + + /** Where the UDP side of the session lives, and the secret that opens it. */ + data class Endpoint(val ip: String, val port: Int, val key: String) + + sealed interface Outcome { + /** A server was started and handed back its address. */ + data class Ready(val endpoint: Endpoint) : Outcome + + /** + * mosh is not going to happen for this server, and [reason] says why in words a + * user can act on. [detail] is whatever the host said, kept for Diagnostics. + * + * [anotherCredentialCouldHelp] marks a refusal the authenticator should follow + * with another credential: the ssh session itself was turned down — exit 255, + * which is the only exit that means that — so a different key or password may + * still be accepted. False for every other failure, where authentication + * already succeeded and the far end could not run `mosh-server`; running again + * under a different key produces the same answer more slowly. + */ + data class Unavailable( + val reason: String, + val detail: String = "", + val anotherCredentialCouldHelp: Boolean = false, + ) : Outcome + } + + /** + * The command run on the far end. + * + * `mosh-server` prints only `MOSH CONNECT `; it never says which address + * to send those UDP datagrams to, and the address is not always the hostname's: + * behind a jump host it is the *exit* host, and a hostname may resolve to something + * else entirely. mosh's own wrapper solves this by reading `$SSH_CONNECTION`, whose + * fourth field is the far end's own idea of its address — the same value + * `mosh-server` uses to decide which interface to bind. Asking for it in the same + * ssh invocation keeps this to one connection and one authentication. + * + * The escaping is three shells deep and each one matters: + * + * - `$SSH_CONNECTION` must reach the *remote* shell unexpanded. It is inside double + * quotes here, so `\$` is what survives the device shell as a literal `$`, which + * the remote shell then expands. Single quotes would have been wrong twice over: + * a backslash inside them is literal, so the remote shell would have seen + * `\$SSH_CONNECTION` and printed the text rather than the value. + * - `exec` matters because `mosh-server` detaches by forking. Without it the wrapper + * shell lingers as mosh-server's parent and ssh's channel stays open, so the + * bootstrap appears to hang on a session that is in fact ready. + * - `mosh-server` is invoked by name, not by path, because the path differs between + * distributions (`/usr/bin`, `/usr/local/bin`) and a hardcoded one is a failure + * that only appears on some hosts. + */ + private val REMOTE_COMMAND = + "/bin/sh -c \"echo MOSH SSH_CONNECTION \\${'$'}SSH_CONNECTION; exec mosh-server new\"" + + private val CONNECT_LINE = Regex("""^MOSH CONNECT (\d+) ([A-Za-z0-9+/]{22})\s*$""") + private val LEGACY_KEY_LINE = Regex("""^MOSH_KEY=(\S+)\s*$""") + private val SSH_CONNECTION_LINE = + Regex("""^MOSH SSH_CONNECTION (\S+) \d+ (\S+) \d+\s*$""") + + /** + * Starts a mosh session for [server], trying each of its credentials in turn. + * + * Runs on a worker thread: it opens an ssh connection, and on a raw-file device that + * is seconds of blocking work that has no business on the main thread. + */ + fun connect( + activity: Activity, + context: Context, + server: SshStore.Server, + cols: Int, + rows: Int, + ): Outcome { + if (!MoshNative.ensureLoaded(context)) { + return Outcome.Unavailable( + "mosh is not available on this device", + MoshNative.loadError.orEmpty(), + ) + } + + // A Rejected that stopped on a failure that was NOT about credentials carries + // that attempt's own detail — the authenticator stops at the first such + // attempt — and this lambda recorded the same value as it went past. The + // fallback is for the other kind of Rejected, every credential refused: a + // host that accepted a key and then had no mosh-server to run must not be + // reported as "no credential accepted". + var nonCredentialReason: String? = null + return when ( + val auth = SshKeyAuthenticator.authenticate(activity, context, server) { key, secret -> + AppLog.d( + context, TAG, + if (key == null) { + "try a password for ${server.host}" + } else { + "try ${key.label} (${key.fileName})" + + if (secret.isEmpty()) "" else " with a held passphrase" + }, + ) + launch( + context = context, + server = server, + identities = key?.let { listOf(SshKeyStore.identityPathFor(context, it)) } + ?: emptyList(), + passphrase = secret, + ).also { attempt -> + if (attempt is SshKeyAuthenticator.Attempt.Other) { + nonCredentialReason = attempt.detail + } + } + } + ) { + is SshKeyAuthenticator.Result.Connected -> auth.value + is SshKeyAuthenticator.Result.Rejected -> Outcome.Unavailable( + nonCredentialReason ?: "no SSH credential on this device was accepted", + auth.detail, + ) + SshKeyAuthenticator.Result.NoKey -> Outcome.Unavailable( + "this server has no SSH key to start mosh with", + ) + } + } + + private fun launch( + context: Context, + server: SshStore.Server, + identities: List, + passphrase: String?, + ): SshKeyAuthenticator.Attempt { + val command = buildCommand(server, identities, passphrase) + + // Two minutes is generous for "start a server that prints one line and forks", + // and the point of the bound is the failure it turns into a message instead of. + // + // buildCommand drops BatchMode for a held secret, so ssh is allowed to ask for + // a passphrase or password — and the askpass environment is the only way it + // gets an answer, because this run has no terminal. The command line here + // mirrors SFTP's exactly, and so must the environment: SshAskpass carries the + // secret in the process environment, never in the command, and returns an + // empty map when there is no secret, which is the BatchMode case. + val (code, output) = SshClient.run( + context = context, + command = command, + timeoutMinutes = 2, + environment = SshAskpass.environment(context, passphrase), + ) + + val parsed = parse(output) + if (parsed != null) { + if (parsed !is Outcome.Ready) { + return classify(parsed) + } + // Resolve the address ssh connected to, which is the one we can reach the + // server on. See udpDestination: behind a 1:1 NAT the two are different + // networks and only this one is reachable from the phone. + val reported = parsed.endpoint.ip + val resolved = resolveOnDevice(context, server.host) + val destination = udpDestination(resolved, reported) + if (resolved != null && resolved != reported) { + AppLog.d( + context, TAG, + "sending UDP to $resolved (the address ssh connected to), not " + + "$reported (the address mosh-server bound, which is the host's " + + "own interface and is private behind a NAT)", + ) + } + if (destination == null) { + return classify( + Outcome.Unavailable( + "the address to send mosh traffic to could not be determined", + "host ${server.host}, reported by the server as $reported", + ), + ) + } + return classify(Outcome.Ready(parsed.endpoint.copy(ip = destination))) + } + + return classify( + if (code == 255) { + // 255 is ssh's own exit code, for everything it fails at itself — + // including authentication. The credential was refused, so the next + // one is worth offering. + Outcome.Unavailable( + "the server refused to start mosh", + output.trim().takeLast(600), + anotherCredentialCouldHelp = true, + ) + } else { + // Authentication worked: 127 from the remote shell — what `exec + // mosh-server new` leaves behind when the binary is not there — or 0 + // with no connect line on the odd host that prints an error and exits + // cleanly. Both mean mosh-server could not be run, which is a package + // the user can install, and another key changes nothing. + Outcome.Unavailable( + "mosh-server is not installed on this server", + output.trim().takeLast(600), + ) + }, + ) + } + + /** + * The host as the device can address it, or null if it will not resolve. + * + * IPv4 is preferred: `mosh-server` binds whatever the SSH connection arrived on, + * and a v4 session behind a v4 NAT is the case this whole problem exists for. + */ + private fun resolveOnDevice(context: Context, host: String): String? = try { + InetAddress.getAllByName(host) + .firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address } + ?.hostAddress + } catch (e: Exception) { + AppLog.d(context, TAG, "could not resolve $host on this device: ${e.message}") + null + } + + private fun classify(outcome: Outcome): SshKeyAuthenticator.Attempt = + when (outcome) { + is Outcome.Ready -> SshKeyAuthenticator.Attempt.Ok(outcome) + is Outcome.Unavailable -> + if (outcome.anotherCredentialCouldHelp) { + SshKeyAuthenticator.Attempt.Refused(outcome.detail) + } else { + SshKeyAuthenticator.Attempt.Other(outcome.reason) + } + } + + /** + * Reads mosh-server's and ssh's startup lines out of a combined output stream. + * + * Internal rather than private so it can be tested directly: this is the only part of + * the bootstrap with logic in it, and its failure modes are all invisible from the + * outside -- a missing address and a missing port both arrive as "null", which reads + * from the UI as "mosh-server is not installed" and sends the user off to install a + * package that is already there. + */ + internal fun parse(output: String): Outcome? { + var port = -1 + var key: String? = null + var ip: String? = null + var sawConnection = false + + for (raw in output.lineSequence()) { + val line = raw.trimEnd('\r') + val connect = CONNECT_LINE.matchEntire(line) + if (connect != null) { + port = connect.groupValues[1].toInt() + key = connect.groupValues[2] + continue + } + // Older mosh-servers only ever printed the key, and the port had to be + // guessed. Kept for completeness rather than because it is expected. + LEGACY_KEY_LINE.matchEntire(line)?.let { key = it.groupValues[1] } + SSH_CONNECTION_LINE.matchEntire(line)?.let { m -> + ip = m.groupValues[2] + sawConnection = true + } + } + + if (key == null || port <= 0) return null + + if (ip == null) { + return Outcome.Unavailable( + if (sawConnection) { + "the server did not report its own address" + } else { + "the server did not report its own address, " + + "which it does only over an ordinary SSH connection" + }, + "MOSH CONNECT was received but no address came back with it", + ) + } + + return Outcome.Ready(Endpoint(ip, port, key)) + } + + /** + * Where the UDP packets should actually be sent. + * + * This is the address ssh connected to, not the address `mosh-server` reports, and + * the difference is the whole feature on a cloud server. + * + * `mosh-server` reads `$SSH_CONNECTION` and binds the server-side field. Behind a + * 1:1 NAT — which is what a public IP on Oracle, AWS, GCP and most clouds are — the + * ssh session arrives on the instance's *internal* interface, so that field is the + * private address: on a real Oracle instance, `10.0.0.79`. `mosh-server` binds it + * correctly and the cloud's NAT would forward inbound UDP to it happily, but a + * client that trusted the number would send its packets into the VPC from the + * internet, where nothing answers, and every session would time out having proved + * nothing. + * + * The address ssh connected *to* is always reachable, because it is the one that + * just worked: the public IP the user typed, or the Tailscale address, or whatever + * a hostname resolved to on the device. + * + * [resolvedHost] is that lookup, and [reportedBy] is `$SSH_CONNECTION`'s field — kept + * only for the case where the host cannot be resolved on the device at all. + */ + internal fun udpDestination(resolvedHost: String?, reportedBy: String?): String? = + resolvedHost?.takeIf { it.isNotBlank() } ?: reportedBy + + /** + * Builds the ssh command line, mirroring the SFTP client's exactly. + * + * The same arguments, in the same order, for the same reasons — a divergence here + * would mean a server that works for transfers but not for mosh, with nothing in the + * trace pointing at the mosh side. The one difference is the trailing command: + * `sftp` names a subsystem and `mosh-server new` is a program to run, so this one + * needs no `-s` for the subsystem and instead passes `new` as the remote argument. + */ + internal fun buildCommand( + server: SshStore.Server, + identities: List, + passphrase: String?, + ): String = buildList { + val batch = if (passphrase.isNullOrEmpty()) listOf("BatchMode=yes") else emptyList() + add("/bin/ssh") + add("-p") + add(ProotLaunch.quoteForShell(server.port.toString())) + for (argument in SshLaunchOptions.jumpArgs(server)) { + add(ProotLaunch.quoteForShell(argument)) + } + // No -C, even when the server is saved with compression on. + // + // This is the one place the bootstrap deliberately departs from the SFTP + // command line it otherwise mirrors exactly. Compression is negotiated over the + // SSH channel, and the channel here carries a single connect line and then + // closes: the session proper is UDP with its own authenticated encryption. So it + // compresses nothing, and a server that does not support zlib answers it with + // "Compression not supported, disabling." on the very channel the bootstrap is + // reading its answer from — noise in the one stream that has to be parsed + // cleanly. mosh's own wrapper does not offer compression at all. + for (argument in SshLaunchOptions.options( + identities = identities, + extraOptions = batch + listOf("ConnectTimeout=20", "ConnectionAttempts=1"), + server = server, + )) { + add(ProotLaunch.quoteForShell(argument)) + } + // Ends the options, so a host beginning with a dash cannot be taken for a flag. + add("--") + add(ProotLaunch.quoteForShell(SshLaunchOptions.target(server.host, server.user))) + add(ProotLaunch.quoteForShell(REMOTE_COMMAND)) + }.joinToString(" ") +} \ No newline at end of file diff --git a/app/src/main/java/com/redtermapp/mosh/MoshClient.kt b/app/src/main/java/com/redtermapp/mosh/MoshClient.kt new file mode 100644 index 0000000..28443a4 --- /dev/null +++ b/app/src/main/java/com/redtermapp/mosh/MoshClient.kt @@ -0,0 +1,213 @@ +package com.redtermapp.mosh + +import android.content.Context +import androidx.core.content.edit +import android.os.Build +import android.util.Log +import com.redtermapp.distro.ProotLaunch +import com.redtermapp.util.AppLog +import com.redtermapp.util.SshClient +import java.io.File + +/** + * Installs and launches the mosh client executable. + * + * This is an executable rather than a library call because `TerminalSession` is `final` + * and its constructor takes a shell *path* — it builds the subprocess itself through + * `JNI.createSubprocess`, so a mosh session that is not a process cannot be a session at + * all. The app already ships cross-built binaries as per-ABI assets and runs them through + * proot's `-L` loader, and mosh takes that same route, which means the session machinery, + * the emulator, split panes, resize and the session list need no change whatsoever. + * + * **The session key goes in the environment, never on a command line.** The launcher + * script is written into app storage and logged on the way past, so an `MOSH_KEY=… ssh` + * prefix or an argument would put the key on disk in exactly the place it was promised not + * to be. This is `SshAskpass`'s rule arriving a second time, in a different subsystem. + */ +object MoshClient { + + private const val TAG = "mosh" + + /** Bumped whenever the shipped binary changes, so an upgrade re-extracts. + * -2: the client acts on mosh's own shutdown protocol, so `exit` ends the + * session instead of leaving it at `logout`. */ + const val VERSION = "1.4.0-2" + + private const val PREFS = "mosh_client" + private const val KEY_STAMP = "ready" + + /** The asset directory name, which is build-openssh.sh's naming, not jniLibs'. */ + private const val PROGRAM = "redterm-mosh" + + /** + * Where the client lives, as the guest sees it. + * + * One constant for the install path *and* the launcher's command, because those two + * being different is a 127 with nothing to explain it. They were: the client was + * extracted to `rootfs/bin/` beside ssh, and the launcher asked for + * `rootfs/root/bin/`, because inside the rootfs `/root` is the ssh client's *home* + * rather than a directory to put programs in. The session then exited 127 on every + * mosh server on every device, with the bootstrap reporting a perfectly healthy + * `started a mosh session` line just before it. + */ + internal const val PROGRAM_GUEST_PATH = "/bin/$PROGRAM" + + /** The file [ensureInstalled] writes, given the rootfs directory. */ + internal fun installFile(rootfs: File): File = File(rootfs, PROGRAM_GUEST_PATH) + + /** The command the launcher runs. Same string as [PROGRAM_GUEST_PATH]. */ + internal fun launcherTarget(): String = ProotLaunch.quoteForShell(PROGRAM_GUEST_PATH) + + private val installing = java.util.concurrent.atomic.AtomicBoolean(false) + + /** The reason mosh cannot be used on this device, or null when it can. */ + @Volatile + var installError: String? = null + private set + + /** + * The asset directory for this device. + * + * `Build.SUPPORTED_ABIS` speaks jniLibs (`arm64-v8a`, `armeabi-v7a`, `x86_64`, + * `x86`) and the assets speak build-openssh.sh's names (`aarch64`, `armv7a`, + * `x86_64`, `i686`). Using the ABI name verbatim means opening + * `assets/mosh/arm64-v8a/…`, which does not exist — and `assets.open` on a missing + * path throws, so the symptom is "could not install the mosh client" on every + * device, including ones the binary was built for. + */ + private fun archName(): String? = assetArch(Build.SUPPORTED_ABIS.firstOrNull()) + + /** + * The asset directory name for a device ABI. + * + * A pure function so it can be tested against every ABI the app ships, because the + * two vocabularies differ in every single case and the failure is a missing asset + * rather than an error: the binary is built, it is in the APK, and every device says + * the client could not be installed. + */ + internal fun assetArch(abi: String?): String? = when (abi) { + "arm64-v8a", "aarch64" -> "aarch64" + "armeabi-v7a", "armv7a", "arm" -> "armv7a" + "x86_64" -> "x86_64" + "x86", "i686" -> "i686" + else -> null + } + + fun binary(context: Context): File = installFile(SshClient.rootfs(context)) + + /** + * Copies the executable out of the assets for this device's ABI, once per version. + * + * Re-extraction is keyed on the version as well as the ABI: an upgrade that leaves a + * stale binary in place would run a session client from before a fix, which is the + * kind of bug that reads as "mosh is unreliable" rather than "mosh is two releases + * old". + */ + fun ensureInstalled(context: Context): Boolean { + if (installing.get()) return true + if (!installing.compareAndSet(false, true)) return true + return try { + ensureInstalledNow(context) + } finally { + installing.set(false) + } + } + + private fun ensureInstalledNow(context: Context): Boolean { + val arch = archName() + if (arch == null) { + installError = "this device reports no supported ABI" + AppLog.e(context, TAG, installError!!) + return false + } + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val stamp = "$arch:$VERSION" + val out = binary(context) + if (prefs.getString(KEY_STAMP, null) == stamp && out.length() > 0) { + return true + } + + // A newer ABI has to remove the older one: `Build.SUPPORTED_ABIS[0]` is the + // preference order, and the alternative is a binary that matches no shipped + // ABI being left in the rootfs where nothing can run it. + val asset = "mosh/$arch/$PROGRAM" + return try { + val parent = out.parentFile + if (parent != null && !parent.exists() && !parent.mkdirs()) { + installError = "could not create $parent" + AppLog.e(context, TAG, installError!!) + return false + } + // Through a temporary name: a partially-copied executable that is executable + // and truncated is a session that fails in a way that names nothing. + val staging = File(parent, "$PROGRAM.staging") + context.assets.open(asset).use { input -> + staging.outputStream().use { output -> input.copyTo(output) } + } + if (staging.length() == 0L) { + installError = "the packaged client is empty" + AppLog.e(context, TAG, installError!!) + staging.delete() + return false + } + if (!staging.renameTo(out)) { + staging.copyTo(out, overwrite = true) + staging.delete() + } + out.setExecutable(true, true) + prefs.edit { putString(KEY_STAMP, stamp) } + AppLog.d(context, TAG, "installed mosh client for $arch (${out.length()} bytes)") + true + } catch (e: Exception) { + installError = "could not install the mosh client: ${e.message}" + AppLog.e(context, TAG, installError!!) + Log.w(TAG, "mosh client install failed", e) + false + } + } + + /** + * Writes the launcher script that runs the client under proot, and returns its path. + * + * The command inside the launcher is **only** the binary's path. Everything the + * session needs — address, port, key, size — is passed through [environmentFor] as + * process environment, because this script is a file in app storage that gets logged + * when it runs. + */ + fun launcher(context: Context, scriptName: String): String? { + if (!ensureInstalled(context)) return null + return try { + ProotLaunch.writeLauncher( + context = context, + rootfsDir = SshClient.rootfs(context), + startInner = "/root", + command = launcherTarget(), + scriptName = scriptName, + mergeStderr = true, + ).absolutePath + } catch (e: Exception) { + Log.w(TAG, "could not write the mosh launcher", e) + null + } + } + + /** + * The session's environment, including the secret. + * + * Returned as a list rather than logged: this is the one place `MOSH_KEY` is written + * down in the app, and `TerminalSession` passes it straight to the subprocess. + */ + fun environmentFor(context: Context, endpoint: MoshBootstrap.Endpoint, cols: Int, rows: Int): Array { + val home = SshClient.homeDir(context) + return arrayOf( + "HOME=${home.absolutePath}", + "TERM=xterm-256color", + "PATH=/system/bin", + "MOSH_KEY=${endpoint.key}", + "MOSH_IP=${endpoint.ip}", + "MOSH_PORT=${endpoint.port}", + "MOSH_COLS=$cols", + "MOSH_ROWS=$rows", + ) + } +} diff --git a/app/src/main/java/com/redtermapp/mosh/MoshNative.kt b/app/src/main/java/com/redtermapp/mosh/MoshNative.kt new file mode 100644 index 0000000..1f059c8 --- /dev/null +++ b/app/src/main/java/com/redtermapp/mosh/MoshNative.kt @@ -0,0 +1,231 @@ +package com.redtermapp.mosh + +import android.content.Context +import android.util.Log +import com.redtermapp.util.AppLog + +/** + * The JNI boundary for mosh's protocol libraries. + * + * Phase 1's requirement is narrow: the library loads on every ABI RedTerm ships and + * demonstrably works on a device. That sounds weak, and it is deliberately so — an + * earlier probe of this same build linked a 5,216-byte object with a single dynamic + * symbol, which proved the archives were on disk and nothing else, because nothing + * referenced anything. The link now runs with `--no-undefined`, and this class exists + * so there is a real call across the boundary to be wrong about. + * + * Built by `native/build-mosh.sh` into `app/src/main/jniLibs//libredtermmosh.so`. + * The sources it wraps are mosh's own: the State Synchronization Protocol, its + * terminal state machine, and its AES-128-OCB authenticated encryption over nettle. + */ +object MoshNative { + + private const val TAG = "Mosh" + + /** + * Why the library is unavailable, or null when it is fine. + * + * Null here and a message both leave mosh out of the picture; the difference is + * that one is a decision and the other is a fault the user cannot see. A device + * whose ABI we never built, or a load that fails, must be reported rather than + * silently falling back to ssh on every server forever. + */ + @Volatile + var loadError: String? = null + private set + + @Volatile + private var loaded = false + + /** + * The application context, kept so failures can reach Diagnostics. + * + * A session outlives the activity that started it — that is the whole point of one + * — so [MoshSession] has no Context of its own to log with, and a native method + * that is missing from the library still has to say so somewhere the user can + * read. Captured once at load time for that reason. + */ + @Volatile + private var appContext: Context? = null + + @Volatile + var version: String? = null + private set + + /** + * Loads the library once per process. + * + * Every caller is a UI thread, and `System.loadLibrary` on a missing library + * throws, so this cannot be allowed to reach an uncaught-exception handler. The + * error is kept in [loadError] and mosh then behaves as "not available", which is + * a state the UI already knows how to render. + */ + @Synchronized + fun ensureLoaded(context: Context): Boolean { + if (loaded) return true + loadError?.let { return false } + return try { + appContext = context.applicationContext + System.loadLibrary("redtermmosh") + version = nativeVersion() + loaded = true + true + } catch (e: UnsatisfiedLinkError) { + loadError = "libredtermmosh.so did not load: ${e.message}" + AppLog.e(context, TAG, loadError!!) + false + } catch (e: SecurityException) { + loadError = "libredtermmosh.so was refused: ${e.message}" + AppLog.e(context, TAG, loadError!!) + false + } + } + + /** + * Round-trips a message through mosh's authenticated encryption on this device. + * + * Returns 1 on a correct round trip, 0 on a wrong one, -1 if an exception escaped + * the native layer. Any of them means the native side is broken in a way that has + * nothing to do with Kotlin, which is why it is reported by number rather than as + * a boolean: "it did not work" and "it threw" are different bugs. + */ + fun selfTest(context: Context): Int { + if (!ensureLoaded(context)) return -1 + return try { + nativeSelfTest() + } catch (e: UnsatisfiedLinkError) { + AppLog.e(context, TAG, "selfTest could not call native: ${e.message}") + -1 + } + } + + private external fun nativeSelfTest(): Int + private external fun nativeVersion(): String + + /** + * Starts a session against a mosh-server that ssh has already started. + * + * [key] is the secret half of MOSH_KEY and must never be logged, put in a command + * line or written to disk — see the notes in `native/redterm_mosh.c`, which zeroes + * its copy as soon as the session has taken one. The native side never returns it, + * not even in an error message. + * + * Returns null on failure, with [errorOut] set to something readable. Callers treat + * null as "fall back to ssh", because a device that cannot start mosh should still + * be able to reach the host. + */ + fun start( + key: String, + ip: String, + port: Int, + cols: Int, + rows: Int, + errorOut: StringBuilder, + ): MoshSession? { + if (!loaded) { + errorOut.append(loadError ?: "mosh native library is not loaded") + return null + } + val handle = try { + nativeStart(key, ip, port, cols, rows, errorOut) + } catch (e: UnsatisfiedLinkError) { + appContext?.let { AppLog.e(it, TAG, "nativeStart is missing from this build: ${e.message}") } + errorOut.append("mosh native library is out of date") + 0L + } + return if (handle == 0L) null else MoshSession(handle) + } + + internal external fun nativeStart( + key: String, + ip: String, + port: Int, + cols: Int, + rows: Int, + errorOut: StringBuilder, + ): Long + + /** + * A live mosh session, and the only thing that owns its native handle. + * + * The handle is a raw pointer held in a Long, so its lifetime has to be explicit: + * [close] stops the worker thread *before* freeing, which is the only ordering in + * which no native call can touch freed memory. [stop] exists separately because a + * dropped connection and a finished session are different things — stopping leaves + * the last framebuffer readable, so a session can be shown as ended rather than + * blanked. + */ + class MoshSession internal constructor(private var handle: Long) : AutoCloseable { + + @Volatile + private var stopped = false + + val isConnected: Boolean + get() = handle != 0L && runCatching { nativeIsConnected(handle) }.getOrDefault(false) + + /** mosh's own idea of how long to wait, already clamped by the native layer. */ + val waitTimeMs: Int + get() = if (handle == 0L) 0 else runCatching { nativeWaitTime(handle) }.getOrDefault(0) + + /** The last failure the worker thread saw, for Diagnostics. Never the key. */ + val lastError: String + get() = if (handle == 0L) "" else runCatching { nativeLastError(handle) }.getOrDefault("") + + fun input(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size - offset) { + if (handle == 0L || length <= 0) return + val slice = if (offset == 0 && length == bytes.size) bytes else bytes.copyOfRange(offset, offset + length) + runCatching { nativeInput(handle, slice) } + } + + fun input(text: String) = input(text.toByteArray(Charsets.UTF_8)) + + /** Columns and rows, in that order, because that is the order mosh takes. */ + fun resize(cols: Int, rows: Int) { + if (handle == 0L) return + runCatching { nativeResize(handle, cols, rows) } + } + + /** + * Drains whatever the remote has sent into [into], and returns how many bytes. + * + * Zero is not the same as "the connection is gone": mosh tolerates loss, and a + * server that has gone away leaves the UDP socket readable and empty, which is + * indistinguishable from an idle link at this level. Callers that need to know + * whether the session is alive ask [isConnected] after a period of silence — + * which is mosh's own model, and the reason it survives a network switch. + */ + fun drain(into: ByteArray): Int { + if (handle == 0L || into.isEmpty()) return 0 + return runCatching { nativePoll(handle, into) }.getOrDefault(0) + } + + /** Stops the worker thread. The session stays readable until [close]. */ + fun stop() { + if (handle != 0L && !stopped) { + stopped = true + runCatching { nativeStop(handle) } + } + } + + override fun close() { + if (handle != 0L) { + stop() + runCatching { nativeFree(handle) } + handle = 0L + } + } + } + + internal external fun nativeIsConnected(handle: Long): Boolean + internal external fun nativeWaitTime(handle: Long): Int + internal external fun nativeLastError(handle: Long): String + internal external fun nativeInput(handle: Long, bytes: ByteArray) + internal external fun nativeResize(handle: Long, cols: Int, rows: Int) + internal external fun nativePoll(handle: Long, into: ByteArray): Int + internal external fun nativeStop(handle: Long) + internal external fun nativeFree(handle: Long) + + init { + Log.i(TAG, "MoshNative loaded") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/redtermapp/service/TerminalService.kt b/app/src/main/java/com/redtermapp/service/TerminalService.kt index 6ef9374..55addaf 100644 --- a/app/src/main/java/com/redtermapp/service/TerminalService.kt +++ b/app/src/main/java/com/redtermapp/service/TerminalService.kt @@ -1,5 +1,6 @@ package com.redtermapp.service +import com.redtermapp.ui.transportSuffixOf import android.app.PendingIntent import android.app.Service import android.content.Context @@ -290,7 +291,8 @@ class TerminalService : Service() { when (val d = com.redtermapp.ui.TerminalViewModel.get(application).descriptorFor(session)) { is com.redtermapp.ui.SessionDescriptor.Local -> d.distro.replaceFirstChar { it.uppercase() } - is com.redtermapp.ui.SessionDescriptor.Ssh -> d.label.ifBlank { d.host } + is com.redtermapp.ui.SessionDescriptor.Ssh -> + d.label.ifBlank { d.host } + transportSuffixOf(d) else -> session.mSessionName.takeIf { it.isNotBlank() } } }.distinct() diff --git a/app/src/main/java/com/redtermapp/ui/ConnectingOverlay.kt b/app/src/main/java/com/redtermapp/ui/ConnectingOverlay.kt new file mode 100644 index 0000000..ab6188b --- /dev/null +++ b/app/src/main/java/com/redtermapp/ui/ConnectingOverlay.kt @@ -0,0 +1,153 @@ +package com.redtermapp.ui + +import android.app.Activity +import android.content.res.ColorStateList +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.cardview.widget.CardView +import com.redtermapp.R + +/** + * A connecting overlay: a dim scrim with a spinner, a line saying what is + * connecting, and a way out. + * + * A session is not ready when its process starts. A mosh bootstrap opens an SSH + * connection, runs `mosh-server new` and reads back a port and a key — several + * seconds on a real network — and a plain SSH session or an SFTP browser's first + * listing each dial their own connection in the same span. Saying so belongs here + * rather than in the terminal, because the terminal is where the user's own output + * goes — a status line printed there competes with a command they just typed, and + * any attempt to rewrite it in place is orphaned the moment the far end scrolls. + * + * Shared by the terminal and the SFTP browser so both connections are described by + * the same words and the same look. Callers on the main thread [show] and [hide]; + * showing while already shown is a no-op, and hiding when hidden is too. + */ +class ConnectingOverlay( + private val activity: Activity, + /** The view the scrim is added to, painted over it completely. */ + private val parent: ViewGroup +) { + private var view: View? = null + + val isShowing: Boolean get() = view != null + + /** + * Shows the overlay for [message], fading in. + * + * [onCancel] is invoked after the overlay is dismissed by the cancel button. + * Without a way out, a connection that stalls leaves the user looking at an + * opaque scrim with nothing to press. The connecting work is a blocking ssh + * call and cannot be interrupted usefully, so cancelling is about the + * *result*, not the attempt — the caller decides what a discarded result means. + * Omitting it is for connects where stopping to wait still ends in a result + * the activity wants, such as a browser whose listing lands either way. + */ + fun show(message: String, onCancel: (() -> Unit)? = null) { + if (view != null) return + val density = activity.resources.displayMetrics.density + val pad = (24 * density).toInt() + val gap = (12 * density).toInt() + + val scrim = FrameLayout(activity).apply { + setBackgroundColor(0xB0000000.toInt()) + // Swallow taps: a terminal or a file list must not take focus mid-connect. + isClickable = true + isFocusable = true + elevation = 8 * density + alpha = 0f + } + val card = CardView(activity).apply { + radius = 14 * density + setCardBackgroundColor(activity.cardColor()) + } + val column = LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(pad, pad, pad, pad) + // Without a floor the card is exactly as wide as its text, so a short + // message gives a thin strip and a long one fills the screen. A minimum + // reads as deliberate at either end. + minimumWidth = (220 * density).toInt() + } + column.addView( + ProgressBar(activity).apply { + // The framework default is the platform accent, which is not this + // app's accent on every theme — a spinner is the one thing on screen + // during a connection, so it is worth matching. + indeterminateTintList = ColorStateList.valueOf(activity.accentColor()) + }, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + val label = TextView(activity).apply { + textSize = 14f + gravity = Gravity.CENTER + setTextColor(activity.cardTextColor()) + setPadding(0, gap, 0, 0) + // Android 16 deprecated announceForAccessibility as disruptive; the + // sanctioned replacement for a status message is a live region that + // announces its own text when the text changes while it is on screen. + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + } + column.addView( + label, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + if (onCancel != null) { + column.addView( + ScreenWidgets.actionButton(activity, R.string.cancel) { + hide() + onCancel() + }, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { topMargin = gap } + ) + } + card.addView(column) + scrim.addView( + card, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ) + ) + parent.addView( + scrim, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT + ) + ) + view = scrim + scrim.animate().alpha(1f).setDuration(160).start() + // The text is set only now, once the live region is on screen: a region + // announces a change, and text set before the view was attached would + // have changed nothing after it became visible. + label.text = message + } + + /** Fades out and removes the overlay; a no-op when it is not shown. */ + fun hide() { + val fading = view ?: return + view = null + // Faded out rather than removed outright: the content that replaces this + // arrives in the same frame, and a hard cut reads as a flicker. + fading.animate().alpha(0f).setDuration(140).withEndAction { + (fading.parent as? ViewGroup)?.removeView(fading) + }.start() + } +} diff --git a/app/src/main/java/com/redtermapp/ui/FrameRecorder.kt b/app/src/main/java/com/redtermapp/ui/FrameRecorder.kt new file mode 100644 index 0000000..5f887ab --- /dev/null +++ b/app/src/main/java/com/redtermapp/ui/FrameRecorder.kt @@ -0,0 +1,54 @@ +package com.redtermapp.ui + +import java.io.BufferedOutputStream +import java.io.File + +/** + * One open recording file, written one encoded frame at a time. + * + * A frame never leaves its caller until it is on disk (`flush` per write), so a + * session that dies abruptly — kill, crash, power loss — leaves every frame up to + * the last one drawn rather than whatever survived in the stream when the process + * ended. The frames and byte counts are kept for the stop log, because "recording + * stopped" with no numbers tells a user looking at a suspiciously short file + * nothing they could work from. + */ +class FrameRecorder(val file: File) { + + private val stream = BufferedOutputStream(file.outputStream(), 1 shl 16) + private var closed = false + + var frames = 0 + private set + var bytes = 0L + private set + var lastError: String? = null + private set + + /** False only when a write failed: the owner then stops and reports it. */ + fun write(frame: String?): Boolean { + if (closed || frame == null) return !closed + return try { + val bytes = frame.toByteArray(Charsets.UTF_8) + stream.write(bytes) + stream.flush() + frames++ + this.bytes += bytes.size + true + } catch (e: Exception) { + lastError = e.message + closed = true + false + } + } + + fun close() { + if (!closed) { + closed = true + runCatching { stream.flush(); stream.close() } + } + } + + /** True from just after construction until a failure or [close]. */ + val isOpen: Boolean get() = !closed +} diff --git a/app/src/main/java/com/redtermapp/ui/NightModeReceiver.kt b/app/src/main/java/com/redtermapp/ui/NightModeReceiver.kt index 466515b..a91654a 100644 --- a/app/src/main/java/com/redtermapp/ui/NightModeReceiver.kt +++ b/app/src/main/java/com/redtermapp/ui/NightModeReceiver.kt @@ -13,6 +13,18 @@ class NightModeReceiver : BroadcastReceiver() { companion object { const val ACTION_CHANGED = "com.redtermapp.NIGHT_MODE_CHANGED" + + /** + * The theme a fresh install uses, until the user picks another. + * + * Read in four places -- this receiver, the settings picker, its config export + * and the terminal activity -- so it lives here once. A default typed into each + * of them is a default that will eventually be changed in three of them. + * + * Only affects installs that have never chosen a theme: a stored preference + * always wins, so nobody's existing setting moves. + */ + const val DEFAULT_THEME = "dynamic" private const val NIGHT_START_HOUR = 18 private const val NIGHT_END_HOUR = 6 @@ -24,7 +36,11 @@ class NightModeReceiver : BroadcastReceiver() { fun effectiveTheme(prefs: SharedPreferences): String { if (isNightModeActive(prefs)) return "amoled" - return prefs.getString("theme", "amoled") ?: "amoled" + // "dynamic" is the default: chrome from the base theme, terminal colours + // sampled from the system. Auto-night mode above still forces amoled, which + // is the point of it -- a phone that has gone dark for the night should not + // stay light because that is what a fresh install asked for. + return prefs.getString("theme", DEFAULT_THEME) ?: DEFAULT_THEME } fun scheduleNightMode(context: Context, prefs: SharedPreferences) { diff --git a/app/src/main/java/com/redtermapp/ui/RecordingActivity.kt b/app/src/main/java/com/redtermapp/ui/RecordingActivity.kt index 4ad937c..953f340 100644 --- a/app/src/main/java/com/redtermapp/ui/RecordingActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/RecordingActivity.kt @@ -12,7 +12,6 @@ import com.google.android.material.card.MaterialCardView import androidx.appcompat.app.AppCompatActivity import com.redtermapp.R import com.redtermapp.distro.DistroInstaller -import com.redtermapp.distro.SessionRecorder import com.redtermapp.util.Notifier import java.io.File diff --git a/app/src/main/java/com/redtermapp/ui/RecordingPlayerActivity.kt b/app/src/main/java/com/redtermapp/ui/RecordingPlayerActivity.kt index 9b785a4..6177aae 100644 --- a/app/src/main/java/com/redtermapp/ui/RecordingPlayerActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/RecordingPlayerActivity.kt @@ -1,26 +1,74 @@ package com.redtermapp.ui +import android.content.Context +import android.graphics.Canvas import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.redtermapp.R -import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalOutput import com.termux.terminal.TerminalSessionClient -import com.termux.view.TerminalView +import com.termux.view.TerminalRenderer import java.io.File /** - * Replays a recorded ANSI file. + * Replays a recorded ANSI file by feeding it into the terminal library's parser. * - * The terminal view only accepts a live process, so replay feeds the captured - * bytes into a session running a pty with echo disabled. The pty echoes the - * escape sequences back as output, which the emulator then renders exactly as - * it would have rendered the original recording. + * The player used to feed the file into a pty whose child was `stty` and then nothing, + * expecting the pty itself to echo the fed bytes back as terminal output — which made + * playback depend on whichever stty vocabulary the device ships. On this device that + * verdict is `stty: -icanon: unknown option`, the child exits with code 1 before a + * single frame is fed into, and the replay is a blank page that finishes before it + * starts. The parsing had nothing to do with any of it: [TerminalEmulator] consumes + * escape sequences from a byte stream on its own, without a session or a process, and + * the library's [TerminalRenderer] draws it onto any canvas. Playback is chunks of + * the file into a private emulator on a background thread — the same one-thread-per- + * terminal shape a session's own reader uses — paced, with invalidation posted to the + * main thread. No pty, no stty, no distro prerequisite. */ class RecordingPlayerActivity : AppCompatActivity() { companion object { const val EXTRA_PATH = "path" + const val CHUNK_SIZE = 4096 + const val PACE_MS = 12L + } + + private lateinit var playbackView: PlaybackView + private lateinit var sessionFile: File + private var fontSize = 20 + + private val output = object : TerminalOutput() { + override fun write(data: ByteArray, offset: Int, count: Int) {} + override fun titleChanged(title: String?, subtitle: String?) {} + override fun onCopyTextToClipboard(text: String?) {} + override fun onPasteTextFromClipboard() {} + override fun onBell() {} + override fun onColorsChanged() {} + } + + private val client = object : TerminalSessionClient { + override fun onTextChanged(session: com.termux.terminal.TerminalSession) = Unit + override fun onTitleChanged(session: com.termux.terminal.TerminalSession) = Unit + override fun onSessionFinished(session: com.termux.terminal.TerminalSession) = Unit + override fun onCopyTextToClipboard( + session: com.termux.terminal.TerminalSession, + text: String? + ) = Unit + + override fun onPasteTextFromClipboard(session: com.termux.terminal.TerminalSession) = Unit + override fun onBell(session: com.termux.terminal.TerminalSession) = Unit + override fun onColorsChanged(session: com.termux.terminal.TerminalSession) = Unit + override fun onTerminalCursorStateChange(terminalCursorVisible: Boolean) = Unit + override fun getTerminalCursorStyle(): Int? = null + override fun logError(tag: String?, message: String?) = Unit + override fun logWarn(tag: String?, message: String?) = Unit + override fun logInfo(tag: String?, message: String?) = Unit + override fun logDebug(tag: String?, message: String?) = Unit + override fun logVerbose(tag: String?, message: String?) = Unit + override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) = Unit + override fun logStackTrace(tag: String?, e: Exception?) = Unit } override fun onCreate(savedInstanceState: Bundle?) { @@ -34,61 +82,97 @@ class RecordingPlayerActivity : AppCompatActivity() { finish() return } + sessionFile = file - val terminalView = TerminalView(this, null).apply { - id = R.id.replay_terminal - } - setContentView(terminalView) + fontSize = getSharedPreferences("settings", Context.MODE_PRIVATE) + .getInt("font_size", 20) + + playbackView = PlaybackView(this).apply { id = R.id.replay_terminal } + setContentView(playbackView) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.title = getString(R.string.recording_replay) + } - val client = object : TerminalSessionClient { - override fun onTextChanged(session: TerminalSession) = Unit - override fun onTitleChanged(session: TerminalSession) = Unit - override fun onSessionFinished(session: TerminalSession) = Unit - override fun onCopyTextToClipboard(session: TerminalSession, text: String) = Unit - override fun onPasteTextFromClipboard(session: TerminalSession) = Unit - override fun onBell(session: TerminalSession) = Unit - override fun onColorsChanged(session: TerminalSession) = Unit - override fun onTerminalCursorStateChange(terminalCursorVisible: Boolean) = Unit - override fun getTerminalCursorStyle(): Int? = null - override fun logError(tag: String?, message: String?) = Unit - override fun logWarn(tag: String?, message: String?) = Unit - override fun logInfo(tag: String?, message: String?) = Unit - override fun logDebug(tag: String?, message: String?) = Unit - override fun logVerbose(tag: String?, message: String?) = Unit - override fun logStackTraceWithMessage( - tag: String?, message: String?, e: Exception? - ) = Unit - - override fun logStackTrace(tag: String?, e: Exception?) = Unit - } - - // echo off: without it the pty would echo the input and the emulator - // would render every byte twice. - val session = TerminalSession( - "/system/bin/sh", filesDir.absolutePath, - arrayOf("stty", "-echo", "-icanon", "min", "1", "time", "0"), - emptyArray(), 2000, client - ) - session.mSessionName = file.name - terminalView.attachSession(session) - terminalView.onScreenUpdated() - terminalView.post { - Thread({ - try { - file.inputStream().use { input -> - val buffer = ByteArray(4096) - while (true) { - val read = input.read(buffer) - if (read <= 0) break - session.write(buffer.copyOf(read), 0, read) - Thread.sleep(12) - } + /** + * Feeds the file into [emulator], [CHUNK_SIZE] bytes per [PACE_MS], on a + * background thread the way a session's own reader feeds its emulator, with + * invalidation posted to the main thread. + */ + private fun startPlayback(emulator: TerminalEmulator) { + Thread({ + try { + sessionFile.inputStream().use { input -> + val buffer = ByteArray(CHUNK_SIZE) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + emulator.append(buffer, read) + playbackView.post { playbackView.invalidate() } + Thread.sleep(PACE_MS) } - } catch (_: Exception) { } - }, "redterm-replay").apply { isDaemon = true }.start() + } catch (e: InterruptedException) { + // The activity went away mid-play; the last frame simply stands. + } catch (e: Exception) { + // A read failure replays as terminal text, the only surface this + // screen has, in default styling and at the cursor. + emulator.append("replay stopped: ${e.message}".toByteArray(), "replay stopped: ${e.message}".length) + playbackView.post { playbackView.invalidate() } + } + }, "redterm-replay").apply { isDaemon = true }.start() + } + + /** + * The playing surface: the library's renderer draws a private emulator, because + * nothing about a replay asks the view layer to host a session. The emulator + * comes into being at the first layout, from the view's own column and row + * counts — positioned frames replay into whatever size the viewing device has, + * which can clip wide sessions on a narrower screen. + */ + private inner class PlaybackView(context: Context) : android.view.View(context) { + + private var emulator: TerminalEmulator? = null + private var renderer: TerminalRenderer? = null + private var background: Int + + init { + val (fg, bg) = TerminalTheming.themedColours(this@RecordingPlayerActivity) + background = bg + } + + override fun onDraw(canvas: Canvas) { + val e = emulator ?: return + canvas.drawColor(background) + renderer?.render(e, canvas, 0, -1, -1, 0, 0) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + if (w == 0 || h == 0) return + val r = renderer ?: TerminalRenderer( + fontSize, + TerminalTheming.fontFromPrefs( + context, + context.getSharedPreferences("settings", Context.MODE_PRIVATE) + ) + ).also { renderer = it } + val fontWidth = r.getFontWidth().toInt().coerceAtLeast(1) + val lineHeight = r.getFontLineSpacing().toInt().coerceAtLeast(1) + val cols = (w / fontWidth).coerceAtLeast(1) + val rows = (h / lineHeight).coerceAtLeast(1) + val existing = emulator + if (existing == null) { + val e = TerminalEmulator(output, cols, rows, fontWidth, lineHeight, null, client) + TerminalTheming.applyPalette( + TerminalTheming.themedColours(this@RecordingPlayerActivity).first, + background, + e + ) + emulator = e + startPlayback(e) + } else { + existing.resize(cols, rows, fontWidth, lineHeight) + invalidate() + } } } @@ -101,7 +185,7 @@ class RecordingPlayerActivity : AppCompatActivity() { } private fun applyTheme() { - val prefs = getSharedPreferences("settings", android.content.Context.MODE_PRIVATE) + val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE) when (NightModeReceiver.effectiveTheme(prefs)) { "red" -> setTheme(R.style.Theme_RedTermApp_Red) "amoled" -> setTheme(R.style.Theme_RedTermApp_AMOLED) diff --git a/app/src/main/java/com/redtermapp/ui/ScreenColors.kt b/app/src/main/java/com/redtermapp/ui/ScreenColors.kt index 95745bf..932dfe9 100644 --- a/app/src/main/java/com/redtermapp/ui/ScreenColors.kt +++ b/app/src/main/java/com/redtermapp/ui/ScreenColors.kt @@ -18,10 +18,19 @@ import com.redtermapp.R * whole discipline: a screen that chooses a colour is a screen that is wrong on * whichever theme disagrees with the choice, and there are ten of them. * - * UI chrome takes its text from `textColorPrimary`/`textColorSecondary` rather than - * from the terminal's own foreground. The terminal colours are for terminal - * characters — a green terminal wants a green prompt — and reusing them for labels and - * headings is what turns a whole screen the colour of the shell. + * UI chrome takes its text from `extraKeysText` rather than from the terminal's own + * foreground. The terminal colours are for terminal characters — a green terminal wants a + * green prompt — and reusing them for labels and headings is what turns a whole screen + * the colour of the shell. + * + * **Read the attribute the themes actually declare.** These two functions used to read + * `android:attr/textColorPrimary` and `android:attr/textColorSecondary`, and not one of + * the ten themes declares either: the framework default was taken every time, and the + * hardcoded fallback for it was the app's *background* colour rather than its text. So + * every label on every card was drawn near-black on a near-black card on all nine dark + * themes, and a screen could be invisible on exactly the themes it looked correct on. + * `extraKeysText` is declared by all ten, and it is the colour meant for text sitting on + * a card, so it is the one to ask. */ fun Context.themeColor(@AttrRes attr: Int, @ColorInt fallback: Int): Int { val typed = theme.obtainStyledAttributes(intArrayOf(attr)) @@ -32,7 +41,13 @@ fun Context.themeColor(@AttrRes attr: Int, @ColorInt fallback: Int): Int { } } -/** The colour the app's own cards use, per theme. */ +/** + * The colour the app's own cards use, per theme. + * + * The fallback is this app's *default* theme's value, so a custom theme that omits the + * attribute degrades to looking like the default rather than to something unrelated. + * The same rule is what [cardTextColor] and [mutedTextColor] follow below. + */ @ColorInt fun Context.cardColor(): Int = themeColor(R.attr.extraKeysBg, 0xFF181825.toInt()) @@ -45,24 +60,33 @@ fun Context.cardColor(): Int = themeColor(R.attr.extraKeysBg, 0xFF181825.toInt() */ @ColorInt fun Context.cardTextColor(): Int = - themeColor(android.R.attr.textColorPrimary, 0xFF1E1E2E.toInt()) + themeColor(R.attr.extraKeysText, 0xFFCDD6F4.toInt()) /** Secondary text on a card, for facts under a label. */ @ColorInt fun Context.mutedTextColor(): Int = - withAlpha(themeColor(android.R.attr.textColorSecondary, 0xFF45475A.toInt()), 0.8f) + withAlpha(themeColor(R.attr.extraKeysText, 0xFFCDD6F4.toInt()), 0.8f) /** * Emphasis, for a status that must be noticed without being read. * - * `android:attr/colorAccent` rather than Material's `colorPrimary`: the app's `R.attr` - * is non-transitive, so a library attribute is not reachable from here, while every - * framework attribute is. Material themes derive the accent from the primary anyway, - * so it follows the theme either way. + * Our own `accentColor` attribute, which all ten themes set explicitly to the value they + * give `colorPrimary`. + * + * This used to read `android:attr/colorAccent`, on the reasoning that a library attribute + * is not reachable from an app module. That is true of the app's *own* `R.attr` and false + * of a dependency's -- but Material's `R` turns out not to be exposed here either, and + * `colorAccent` is a framework attribute these Material3 themes do not usefully define. + * Whatever it resolved to was not the accent any theme chose, and the cost was chips drawn + * in a colour nobody could read: `mosh` on a server card and "protected by a passphrase" on + * a key card, while every other colour on the same cards was fine. + * + * Declaring the attribute and setting it in every theme is the version of this that can be + * checked: `ThemeContrastTest` asserts each theme declares it, and that it has contrast + * against that theme's own card colour. */ @ColorInt -fun Context.accentColor(): Int = - themeColor(android.R.attr.colorAccent, 0xFF89B4FA.toInt()) +fun Context.accentColor(): Int = themeColor(R.attr.accentColor, 0xFF89B4FA.toInt()) /** * Destructive actions, from the framework's own error colour. diff --git a/app/src/main/java/com/redtermapp/ui/ScreenWidgets.kt b/app/src/main/java/com/redtermapp/ui/ScreenWidgets.kt index 6d575e5..d4967ab 100644 --- a/app/src/main/java/com/redtermapp/ui/ScreenWidgets.kt +++ b/app/src/main/java/com/redtermapp/ui/ScreenWidgets.kt @@ -75,6 +75,33 @@ object ScreenWidgets { setOnClickListener { onClick() } } + /** + * The one thing you came to this card to do. + * + * Filled, where [actionButton] is outlined, because a row of identically-styled + * buttons has no answer to "which one do I press" -- and on a list of saved servers + * that question is asked every single time. Exactly one per card: two primaries is + * the same as none. + * + * The label is chosen by measured contrast against the accent rather than assumed + * white, because a pale accent under white text is unreadable and the theme decides + * the accent, not this file. + */ + fun primaryButton( + context: Context, + labelRes: Int, + onClick: () -> Unit + ): MaterialButton = MaterialButton(context).apply { + setText(labelRes) + isAllCaps = false + maxLines = 1 + ellipsize = android.text.TextUtils.TruncateAt.END + val accent = context.accentColor() + backgroundTintList = android.content.res.ColorStateList.valueOf(accent) + setTextColor(com.redtermapp.distro.DistroBrand.readableOn(accent)) + setOnClickListener { onClick() } + } + /** * A destructive action. * diff --git a/app/src/main/java/com/redtermapp/ui/ServerExtrasView.kt b/app/src/main/java/com/redtermapp/ui/ServerExtrasView.kt index 51065e9..e867c6e 100644 --- a/app/src/main/java/com/redtermapp/ui/ServerExtrasView.kt +++ b/app/src/main/java/com/redtermapp/ui/ServerExtrasView.kt @@ -5,6 +5,9 @@ import android.text.InputType import android.view.Gravity import android.view.View import android.widget.CheckBox +import android.widget.ScrollView +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.widget.EditText import android.widget.LinearLayout import android.widget.RadioButton @@ -32,6 +35,7 @@ class ServerExtrasView( val keepAliveSeconds: Int, val keepAliveCount: Int, val compress: Boolean, + val useMosh: Boolean, val forwardAgent: Boolean, val jump: SshStore.Jump?, val auth: SshStore.Auth, @@ -57,6 +61,32 @@ class ServerExtrasView( setText(R.string.ssh_compress) isChecked = existing?.compress == true } + /** + * mosh instead of ssh. + * + * A checkbox rather than a switch because it sits among several other options in a + * dialog, and a switch implies a change that takes effect immediately — this one + * only takes effect the next time a session is opened, which is what a dialog full of + * "Save"-shaped choices has already taught the user to expect. + * + * The label says what it needs rather than only what it is, because this option is + * the one on this card that can be set here and still fail on connect: the host must + * have `mosh-server` installed and UDP 60000-61000 open to it, and neither is + * visible from here. When it cannot be satisfied the session falls back to ssh. + */ + private val useMosh = CheckBox(context).apply { + setText(R.string.ssh_use_mosh) + isChecked = existing?.useMosh == true + } + private val moshHint = TextView(context).apply { + setText(R.string.ssh_use_mosh_hint) + textSize = 12f + // The themed secondary colour, not a fixed grey: 0xFF9A9A9A is dark-theme ink + // and is unreadable on the light theme's white dialog. Line 245 below was already + // doing it this way, so this was the odd one out rather than the rule. + setTextColor(context.mutedTextColor()) + setPadding(0, (2 * context.resources.displayMetrics.density).toInt(), 0, 0) + } private val forwardAgent = CheckBox(context).apply { setText(R.string.ssh_forward_agent) isChecked = existing?.forwardAgent == true @@ -162,6 +192,8 @@ class ServerExtrasView( column.addView(keepAlive) column.addView(keepAliveCount) column.addView(compress) + column.addView(useMosh) + column.addView(moshHint) column.addView(forwardAgent) column.addView(heading(R.string.ssh_section_jump)) @@ -200,7 +232,21 @@ class ServerExtrasView( ).apply { topMargin = (4 * density).toInt() } ) - view = column + /* + * Wrapped so nothing is unreachable. + * + * This column carries sixteen views on its own and sits in a dialog that does not + * scroll: label, host, user and port above it, and a key chooser. On a short + * screen the tail of it -- the forwards box, the jump host fields -- can fall off + * the bottom with no way to get to it and nothing to say so. Adding the mosh + * option and its hint made that likelier rather than introducing it. + * + * A ScrollView costs nothing when the content fits, which is the common case. + */ + view = ScrollView(context).apply { + isFillViewport = true + addView(column, android.widget.FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) + } } /** @@ -241,6 +287,7 @@ class ServerExtrasView( keepAliveSeconds = keepAlive.text.toString().trim().toIntOrNull()?.coerceAtLeast(0) ?: 0, keepAliveCount = keepAliveCount.text.toString().trim().toIntOrNull()?.coerceAtLeast(0) ?: 0, compress = compress.isChecked, + useMosh = useMosh.isChecked, forwardAgent = forwardAgent.isChecked, jump = jump, auth = auth, diff --git a/app/src/main/java/com/redtermapp/ui/SessionDescriptor.kt b/app/src/main/java/com/redtermapp/ui/SessionDescriptor.kt index acd21d2..38f3bbf 100644 --- a/app/src/main/java/com/redtermapp/ui/SessionDescriptor.kt +++ b/app/src/main/java/com/redtermapp/ui/SessionDescriptor.kt @@ -28,16 +28,7 @@ sealed class SessionDescriptor { override val id: String, override val label: String, val distro: String, - val startDir: String? = null, - /** - * Whether this session's output is being written to a recording. - * - * Carried on the descriptor so the indicator survives the process and so a - * restored session is still known to be recorded — the file name is derived - * from the distribution and the time, so it can be found again afterwards - * without storing it twice. - */ - val recording: Boolean = false + val startDir: String? = null ) : SessionDescriptor() /** @@ -57,7 +48,18 @@ sealed class SessionDescriptor { val port: Int, val user: String, val keyId: String? = null, - val startDir: String? = null + val startDir: String? = null, + /** + * Whether this session was started with mosh. + * + * Recorded so a restored session re-runs the bootstrap rather than reporting a + * mosh session that died with the process. The UDP endpoint is not durable — the + * key is spent, and `mosh-server` is long gone — so restoring has to start a new + * one, which it does by asking the server again. What would be wrong is silently + * restoring it as an ssh session: same label, same host, and nothing saying the + * transport changed underneath. + */ + val useMosh: Boolean = false ) : SessionDescriptor() fun toJson(): JSONObject = JSONObject().apply { @@ -75,6 +77,7 @@ sealed class SessionDescriptor { put("port", port) put("user", user) put("keyId", keyId ?: "") + put("useMosh", useMosh) put("startDir", startDir ?: "") } } @@ -111,6 +114,7 @@ sealed class SessionDescriptor { port = o.optInt("port", 22).coerceIn(1, 65535), user = o.optString("user"), keyId = o.optString("keyId").takeIf { it.isNotBlank() }, + useMosh = o.optBoolean("useMosh", false), startDir = startDir ) } diff --git a/app/src/main/java/com/redtermapp/ui/SessionRecorder.kt b/app/src/main/java/com/redtermapp/ui/SessionRecorder.kt new file mode 100644 index 0000000..e04b255 --- /dev/null +++ b/app/src/main/java/com/redtermapp/ui/SessionRecorder.kt @@ -0,0 +1,68 @@ +package com.redtermapp.ui + +import android.content.Context +import java.io.File + +/** + * Owns the recordings directory: listing, naming and deletion. + * + * App-specific external storage rather than `/sdcard/RedTerm`. The shared directory + * needs "all files access" to write or list on Android 11 and later, and without + * that grant every read comes back empty and every write goes nowhere — so the list + * was permanently empty and every recording failed, with nothing on screen to say + * why. `getExternalFilesDir` is the app's own area, which needs no grant at all. + * Falls back to internal storage when no external volume is mounted. + * + * What goes *into* the files is decided elsewhere: the frame recorder in + * [TerminalActivity] captures the emulator, so a recording can be started on any + * session, running or not, and needs nothing from the distro it was made in. + */ +class SessionRecorder(private val context: Context) { + + /** Where recordings live. */ + fun dir(): File = ( + context.getExternalFilesDir(null) + ?.let { File(it, "RedTerm/recordings") } + ?: File(context.filesDir, "RedTerm/recordings") + ).apply { if (!exists()) mkdirs() } + + /** Where the directory actually ended up, for telling the user where to find it. */ + fun describeDir(): String = dir().absolutePath + + fun list(): List = + dir().listFiles { f -> f.isFile && f.name.endsWith(".ansi") } + ?.sortedByDescending { it.lastModified() } + ?: emptyList() + + /** Whether the directory can be written to, checked rather than assumed. */ + fun isWritable(): Boolean = dir().canWrite() + + /** + * The file the next recording lands in. + * + * [label] names what is being recorded (a distro or an ssh host, or whatever the + * session is labelled); it goes in the file name, so a listing tells the user + * what they recorded without opening anything. + */ + fun newFile(label: String): File { + val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US) + .format(java.util.Date()) + val safe = label.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-').ifBlank { "session" } + return File(dir(), "redterm-$stamp-$safe.ansi") + } + + fun delete(file: File) { + runCatching { file.delete() } + } + + /** How many recordings there are. */ + fun count(): Int = list().size + + /** + * Whether a capture finished with something in it. + * + * A session that ends before its first drawn frame leaves an empty file, and an + * empty file in the list is a worse thing than no file. + */ + fun isUsable(file: File): Boolean = file.isFile && file.length() > 0L +} diff --git a/app/src/main/java/com/redtermapp/ui/SessionTransportLabel.kt b/app/src/main/java/com/redtermapp/ui/SessionTransportLabel.kt new file mode 100644 index 0000000..5945210 --- /dev/null +++ b/app/src/main/java/com/redtermapp/ui/SessionTransportLabel.kt @@ -0,0 +1,31 @@ +package com.redtermapp.ui + +import android.content.Context +import androidx.annotation.StringRes +import com.redtermapp.R + +/** + * The transport an SSH session is on, for display next to its label. + * + * The decision lives in [transportLabelRes] rather than in the descriptor itself, because + * the text is user-facing and belongs in `strings.xml`, while a `SessionDescriptor` has no + * `Context` and should not grow one to format a title. Having the model hand back a ready + * string was how two hardcoded English fragments got into the title and the notification. + */ +@StringRes +internal fun transportLabelRes(descriptor: SessionDescriptor?): Int = when (descriptor) { + // A distro session is not a choice the user made, so there is nothing to report. + is SessionDescriptor.Local -> 0 + // The transport in use rather than the one that was asked for, because the + // interesting case is a server that wanted mosh and did not get it. `useMosh` is + // written by `prepareMosh` and nowhere else, so it is the truth about what connected. + is SessionDescriptor.Ssh -> + if (descriptor.useMosh) R.string.session_transport_mosh else R.string.session_transport_ssh + null -> 0 +} + +/** The suffix to append to a session label: empty when there is nothing to say. */ +internal fun Context.transportSuffixOf(descriptor: SessionDescriptor?): String { + val res = transportLabelRes(descriptor) + return if (res == 0) "" else getString(res) +} diff --git a/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt b/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt index e72a8dd..070b7aa 100644 --- a/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/SettingsActivity.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.provider.OpenableColumns import android.util.Log import android.os.Environment import android.view.Gravity @@ -28,6 +29,8 @@ import com.redtermapp.BuildConfig import com.redtermapp.R import com.redtermapp.distro.DistroInstaller import com.redtermapp.service.TerminalService +import com.redtermapp.util.AppLog +import com.redtermapp.util.FontImport import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -55,11 +58,40 @@ class SettingsActivity : AppCompatActivity() { for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) } if (uris.isEmpty()) data.data?.let { uris.add(it) } - for (uri in uris) importFonts(uri) - val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE) - renderCustomFontList(prefs) - rebuildFontSpinner(prefs) - Toast.makeText(this, if (uris.size > 1) "${uris.size} fonts imported" else "Font imported", Toast.LENGTH_SHORT).show() + + var imported = 0 + val rejected = mutableListOf() + val failed = mutableListOf() + for (uri in uris) { + val name = fontFileName(uri) + if (name != null && FontImport.isSupportedFontFile(name)) { + if (importFont(uri, name)) imported++ + else failed.add(FontImport.labelFor(name)) + } else { + rejected.add(FontImport.labelFor(name)) + } + } + + if (rejected.isNotEmpty()) { + Toast.makeText(this, getString(R.string.font_import_none, rejected.joinToString(", ")), Toast.LENGTH_LONG).show() + } + if (failed.isNotEmpty()) { + Toast.makeText(this, getString(R.string.font_import_failed), Toast.LENGTH_SHORT).show() + } + if (imported > 0) { + if (rejected.isEmpty() && failed.isEmpty()) { + Toast.makeText(this, + resources.getQuantityString(R.plurals.font_imported, imported, imported), + Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, + resources.getQuantityString(R.plurals.font_import_blocked, imported, imported, rejected.joinToString(", ")), + Toast.LENGTH_SHORT).show() + } + val prefs = getSharedPreferences("settings", Context.MODE_PRIVATE) + renderCustomFontList(prefs) + rebuildFontSpinner(prefs) + } } } @@ -94,7 +126,7 @@ class SettingsActivity : AppCompatActivity() { val versionInfo = findViewById(R.id.version_info) val fontSpinner = findViewById(R.id.font_spinner) - val currentTheme = prefs.getString("theme", "amoled") + val currentTheme = prefs.getString("theme", NightModeReceiver.DEFAULT_THEME) val themeNames = listOf("Catppuccin Dark", "AMOLED Black", "Green Terminal", "Red Terminal", "Light", "Dracula", "Nord", "Tokyo Night", "Gruvbox Dark", "Custom", "Dynamic") val themeValues = listOf("default", "amoled", "green", "red", "light", "dracula", "nord", "tokyo", "gruvbox", "custom", "dynamic") val themeSpinner = findViewById(R.id.theme_spinner) @@ -154,24 +186,23 @@ class SettingsActivity : AppCompatActivity() { } findViewById(R.id.import_font_btn).setOnClickListener { + // `font/*` emptied every folder it did not decide the files matched: a + // provider may report the same font as application/x-font-ttf or plain + // application/octet-stream, and the picker hides children whose MIME + // does not match — while the folder itself stays browsable. The `*/*` + // "fallback" was inside the catch and only ran when launch() threw, so + // a picker that opened fine but showed nothing never reached it. The + // picker is opened unfiltered and what arrives is validated instead. val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) - type = "font/*" + type = "*/*" putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } try { importFontLauncher.launch(intent) } catch (_: Exception) { - val fallback = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - } - try { - importFontLauncher.launch(fallback) - } catch (_: Exception) { - Toast.makeText(this, "No file picker available", Toast.LENGTH_SHORT).show() - } + Toast.makeText(this, "No file picker available", Toast.LENGTH_SHORT).show() } } @@ -293,7 +324,7 @@ class SettingsActivity : AppCompatActivity() { findViewById(R.id.export_config_btn).setOnClickListener { try { val json = org.json.JSONObject().apply { - put("theme", prefs.getString("theme", "amoled")) + put("theme", prefs.getString("theme", NightModeReceiver.DEFAULT_THEME)) put("custom_bg", prefs.getInt("custom_bg", 0)) put("custom_text", prefs.getInt("custom_text", 0)) put("custom_primary", prefs.getInt("custom_primary", 0)) @@ -413,10 +444,11 @@ class SettingsActivity : AppCompatActivity() { val outDir = java.io.File( android.os.Environment.getExternalStorageDirectory(), "RedTerm" ) + // The only availability check that exists: a directory that + // cannot be created means shared storage is not writable. An + // earlier version also tested `outDir == null`, which a File + // reference can never satisfy. if (!outDir.exists() && !outDir.mkdirs()) { - outDir.mkdirs() - } - if (outDir == null) { Toast.makeText( this, R.string.backup_storage_unavailable, Toast.LENGTH_LONG ).show() @@ -970,11 +1002,29 @@ class SettingsActivity : AppCompatActivity() { } } - private fun importFonts(uri: Uri) { + /** + * The file's real display name, from the provider that holds it. A document + * URI's lastPathSegment is a provider-specific id — a display name only on some + * kinds of providers, and often a token like `msf:100` — so it is the fallback + * and never the first answer. + */ + private fun fontFileName(uri: Uri): String? { + try { + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && c.moveToFirst() && !c.isNull(idx)) { + val name = c.getString(idx) + if (name.isNotBlank()) return name + } + } + } catch (_: Exception) { + } + return uri.lastPathSegment?.substringAfterLast('/') + } + + private fun importFont(uri: Uri, displayName: String): Boolean { try { - val name = uri.lastPathSegment?.substringAfterLast('/') - ?.takeIf { it.isNotBlank() } ?: "font.ttf" - val cleanName = name.replace(Regex("[^A-Za-z0-9._-]"), "_") + val cleanName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_") .takeIf { it.isNotBlank() } ?: "font.ttf" val ext = if (cleanName.substringAfterLast('.', "").equals("otf", true)) "otf" else "ttf" val base = cleanName.substringBeforeLast('.', cleanName) @@ -988,9 +1038,14 @@ class SettingsActivity : AppCompatActivity() { } contentResolver.openInputStream(uri)?.use { input -> target.outputStream().use { input.copyTo(it) } + } ?: run { + AppLog.e(this, "SettingsActivity", "font import: no input stream for $displayName") + return false } + return true } catch (e: Exception) { - Toast.makeText(this, "Import failed", Toast.LENGTH_SHORT).show() + AppLog.e(this, "SettingsActivity", "font import failed: $displayName: $e") + return false } } diff --git a/app/src/main/java/com/redtermapp/ui/SshKeyAuthenticator.kt b/app/src/main/java/com/redtermapp/ui/SshKeyAuthenticator.kt index b0cf66c..bfddb78 100644 --- a/app/src/main/java/com/redtermapp/ui/SshKeyAuthenticator.kt +++ b/app/src/main/java/com/redtermapp/ui/SshKeyAuthenticator.kt @@ -13,6 +13,15 @@ import android.content.Context * ask about the key actually in use. That is also how an ssh-agent behaves, and it is * the only arrangement in which every key on the device remains usable. * + * A passphrase is asked only where an answer can still change the outcome. ssh's own + * output cannot tell a key needing decryption apart from a server that does not + * recognise the key at all, but the key file can, over no network at all — so a + * refused attempt is settled by [SshKeyImporter.readsWithoutPassphrase] before any + * question is put to the user. An open key is reported as refused and no phrase is + * requested for it; an encrypted key is offered its dialog on every connection + * attempt, whatever a previous one left behind, because a held phrase may itself + * have been the failure. + * * Keys are tried in [SshKeyStore.planIdentities] order: a bound key alone, otherwise * the newest first. A password, when the server accepts one, comes last: keys first * is what ssh does anyway, and a password prompt the user did not expect is worse than @@ -26,7 +35,9 @@ object SshKeyAuthenticator { /** The server turned this down. A different credential may still work. */ data class Refused(val detail: String) : Attempt() - /** Anything else. Trying another credential would not help. */ + /** Anything else: a failure no credential can fix. The loop stops here — + * the remaining candidates, and the password stage after them, would + * only repeat it. See [authenticateFlow]. */ data class Other(val detail: String) : Attempt() } @@ -39,6 +50,15 @@ object SshKeyAuthenticator { data object NoKey : Result() } + /** What asking a refused key for its passphrase came to. */ + sealed interface Prompt { + /** The key file is open: no dialog was offered, because no phrase can help. */ + data object NotNeeded : Prompt + + /** A dialog was offered. [phrase] is what was typed, empty when none was. */ + data class Answered(val phrase: String) : Prompt + } + /** The message a failed attempt leaves behind, whichever kind it was. */ private fun detailOf(attempt: Attempt<*>): String = when (attempt) { is Attempt.Refused -> attempt.detail @@ -46,16 +66,6 @@ object SshKeyAuthenticator { is Attempt.Ok -> "" } - /** - * Runs [attempt] against each candidate and returns the first success. - * - * [attempt] is given the key to offer — null when trying a password — and the - * secret to arm the askpass helper with, which is empty when there is none. - * - * A passphrase is asked for at most once per call, and only for a key that has no - * phrase held and has not been declined already, so a device full of keys the - * server does not know cannot produce a queue of dialogs. - */ fun authenticate( activity: Activity, context: Context, @@ -72,39 +82,100 @@ object SshKeyAuthenticator { // would be a reason that names the wrong thing. if (keys.isEmpty() && !server.auth.wantsPassword) return Result.NoKey + return authenticateFlow( + keys = keys, + wantsPassword = server.auth.wantsPassword, + heldPassword = if (server.auth.wantsPassword) { + SshPasswordPrompt.available(context, server) + } else { + null + }, + attempt = attempt, + ask = { key -> + // An open key turns down every passphrase — the question for it is + // only the device saying "the server does not know this key" in the + // words of a passphrase prompt, which is how a key with no password + // came to be asked about one. Reporting NotNeeded rather than + // "dismissed" is what keeps that answer from spending the one + // opportunity this attempt has: an encrypted key later in the list + // is still offered its dialog. + if (SshKeyImporter.readsWithoutPassphrase(context, key)) { + Prompt.NotNeeded + } else { + Prompt.Answered(SshKeyUnlock.requestPassphraseBlocking(activity, key) ?: "") + } + }, + askPassword = { SshPasswordPrompt.requestBlocking(activity, server) } + ) + } + + /** + * The credential loop, with every dialog behind a lambda so a test can run it + * without an activity. + * + * [ask] reports what asking a refused key came to: [Prompt.NotNeeded] for a key + * that can never be helped by a phrase, and [Prompt.Answered] for a question + * that was actually put. The one shot at asking this attempt has is spent only + * by an answered question — an open key's refusal is not a question anybody + * could answer, and treating it as one denied the prompt to the next key in + * the list. + */ + internal fun authenticateFlow( + keys: List, + wantsPassword: Boolean, + heldPassword: String?, + attempt: (key: SshKeyStore.Entry?, secret: String) -> Attempt, + ask: (SshKeyStore.Entry) -> Prompt, + askPassword: () -> String? + ): Result { var asked = false var detail = "" var tries = 0 for (key in keys) { tries++ - val held = SshKeyUnlock.availableFor(key) - var outcome = attempt(key, held) - if (outcome is Attempt.Refused && held.isEmpty() && !asked) { - asked = true - val phrase = SshKeyUnlock.requestPassphraseBlocking(activity, key) - if (!phrase.isNullOrEmpty()) outcome = attempt(key, phrase) + var outcome = attempt(key, SshKeyUnlock.availableFor(key)) + if (outcome is Attempt.Refused && !asked) { + when (val prompt = ask(key)) { + is Prompt.NotNeeded -> { + // Consumes nothing. The refusal stands; the next key's + // question is still coming. + } + is Prompt.Answered -> { + // One question per attempt, whatever the answer: a phrase + // the user dismissed or mistyped was a question the user + // has had, and asking again for the same refusal is the + // nagging this flag exists to prevent. + asked = true + if (prompt.phrase.isNotEmpty()) { + outcome = attempt(key, prompt.phrase) + } + } + } } if (outcome is Attempt.Ok) return Result.Connected(outcome.value) detail = detailOf(outcome) + // "Anything else" is a failure no credential can fix — a refused + // connect, the host failing after it accepted one — and the remaining + // candidates, and the password stage after them, would only repeat it: + // more connections, and possibly a prompt on a server that has already + // shown its hand. + if (outcome is Attempt.Other) return Result.Rejected(detail, tries) } - if (server.auth.wantsPassword) { + if (wantsPassword) { tries++ - var held = SshPasswordPrompt.available(context, server) - var outcome = attempt(null, held) - if (outcome is Attempt.Refused && held.isEmpty()) { - val asked2 = SshPasswordPrompt.requestBlocking(activity, server) - if (!asked2.isNullOrEmpty()) { - held = asked2 - outcome = attempt(null, asked2) - } + var outcome = attempt(null, heldPassword ?: "") + if (outcome is Attempt.Refused) { + val password = askPassword() + if (!password.isNullOrEmpty()) outcome = attempt(null, password) } if (outcome is Attempt.Ok) return Result.Connected(outcome.value) detail = detailOf(outcome) + if (outcome is Attempt.Other) return Result.Rejected(detail, tries) } - if (keys.isEmpty() && !server.auth.wantsPassword) return Result.NoKey + if (keys.isEmpty() && !wantsPassword) return Result.NoKey return Result.Rejected( detail.ifBlank { "The server accepted none of the $tries credentials tried." }, tries diff --git a/app/src/main/java/com/redtermapp/ui/SshKeyImporter.kt b/app/src/main/java/com/redtermapp/ui/SshKeyImporter.kt index a493c42..f6f4808 100644 --- a/app/src/main/java/com/redtermapp/ui/SshKeyImporter.kt +++ b/app/src/main/java/com/redtermapp/ui/SshKeyImporter.kt @@ -129,9 +129,31 @@ object SshKeyImporter { }.getOrDefault(false) } + /** + * Whether the key file opens with an empty passphrase. + * + * This is how a refused connection tells "the key needs its passphrase" from + * "the server does not know this key": the two are indistinguishable in ssh's + * own output, because both end as `Permission denied (publickey)`, but the file + * answers locally over no network at all. An open key reads back here, and + * asking a passphrase for one can only ever be refused — the answer could never + * be right. An encrypted key, or a file this cannot read, answers false, so + * the caller gets its question instead of silence. + */ + fun readsWithoutPassphrase(context: Context, entry: SshKeyStore.Entry): Boolean { + val file = SshKeyStore.fileFor(context, entry) + if (!file.isFile) return false + val inRootfs = SshClient.inRootfs(context, file) + val (code, _) = SshClient.run( + context = context, + command = "/bin/ssh-keygen -y -P '' -f " + ProotLaunch.quoteForShell(inRootfs), + timeoutMinutes = 1 + ) + return code == 0 + } + /** The key's own `SHA256:…` fingerprint, for showing the user what they imported. */ - fun fingerprintOf(context: Context, privateKey: File): String { - val inRootfs = SshClient.inRootfs(context, privateKey) + fun fingerprintOf(context: Context, privateKey: File): String { val inRootfs = SshClient.inRootfs(context, privateKey) val (code, output) = SshClient.run( context = context, command = "/bin/ssh-keygen -lf " + ProotLaunch.quoteForShell(inRootfs), diff --git a/app/src/main/java/com/redtermapp/ui/SshKeyUnlock.kt b/app/src/main/java/com/redtermapp/ui/SshKeyUnlock.kt index bb615c1..8b265c6 100644 --- a/app/src/main/java/com/redtermapp/ui/SshKeyUnlock.kt +++ b/app/src/main/java/com/redtermapp/ui/SshKeyUnlock.kt @@ -43,6 +43,13 @@ object SshKeyUnlock { * the connection is retried with no passphrase, and the user is left watching a * dialog they already answered followed by the same refusal as before. * + * A dismissal belongs to the connection attempt it interrupted, and stops the + * asking for exactly as long: the [com.redtermapp.ui.SshKeyAuthenticator] asks at + * most once per attempt. It does not outlive it — a cancel was recorded in + * process-wide state and every later test and file transfer went on failing + * without ever asking again, which is a prompt that stops being available to + * the one thing it exists for. + * * One key at a time, deliberately: `SSH_ASKPASS` supplies a phrase with no way * to say which key it is for, so a connection must ask about the key it is * actually trying rather than offer several and let the answer land on the @@ -50,12 +57,10 @@ object SshKeyUnlock { */ fun requestPassphraseBlocking(activity: Activity, key: SshKeyStore.Entry): String? { if (activity.isFinishing || activity.isDestroyed) return null - if (SshAskpass.isDeclined(key.fileName)) return null // One dialog at a time. Two operations can overlap — a listing still running // while a transfer starts — and two stacked prompts mean the answer to the // second is typed into the first. synchronized(promptLock) { - if (SshAskpass.isDeclined(key.fileName)) return null if (activity.isFinishing || activity.isDestroyed) return null return ask(activity, key) } @@ -75,9 +80,9 @@ object SshKeyUnlock { } val phrase = answer.await(TIMEOUT_SECONDS) if (phrase.isNullOrEmpty()) { - // Dismissed or left blank: recorded so the next operation does not ask - // the same question again. - SshAskpass.decline(key.fileName) + // Nothing usable was typed. The silence lasts until this attempt is over: + // the next connection attempt asks again, because a question that never + // returns is a question that cannot be answered. return null } SshAskpass.remember(key.fileName, phrase) diff --git a/app/src/main/java/com/redtermapp/ui/SshKeysActivity.kt b/app/src/main/java/com/redtermapp/ui/SshKeysActivity.kt index afaced1..516621e 100644 --- a/app/src/main/java/com/redtermapp/ui/SshKeysActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/SshKeysActivity.kt @@ -157,8 +157,12 @@ class SshKeysActivity : AppCompatActivity() { LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) ) val statusGap = (6 * resources.displayMetrics.density).toInt() + // Muted, not accent: the algorithm is what this key *is*, the same way the label + // is. It was accent-coloured alongside the passphrase chip, which is about what + // the key *does*, so two chips on one card meant the same thing twice and + // neither read as the status it is. header.addView( - ScreenWidgets.chip(this, key.typeLabel, accentColor()), + ScreenWidgets.chip(this, key.typeLabel, mutedTextColor()), LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT @@ -205,7 +209,13 @@ class SshKeysActivity : AppCompatActivity() { add(R.string.ssh_copy_public_key) { copyPublicKey(key) } add(R.string.ssh_copy_path) { copyPath(key) } add(R.string.ssh_rename_key) { promptRename(key) } - add(R.string.ssh_change_passphrase) { promptChangePassphrase(key) } + // Only on a key that has one to change. The dialog asks for the *current* + // passphrase, so on an unprotected key it could only ever fail -- an action that + // cannot succeed is worse than no action at all, because it looks like the key + // needs something done to it. + if (key.encrypted) { + add(R.string.ssh_change_passphrase) { promptChangePassphrase(key) } + } // Left on its own line by wrapping, and in the destructive colour: sitting // beside "Copy public key" in an identical button is how the wrong thing gets // destroyed on a phone. diff --git a/app/src/main/java/com/redtermapp/ui/SshLaunchOptions.kt b/app/src/main/java/com/redtermapp/ui/SshLaunchOptions.kt index ae4ee24..9bd1508 100644 --- a/app/src/main/java/com/redtermapp/ui/SshLaunchOptions.kt +++ b/app/src/main/java/com/redtermapp/ui/SshLaunchOptions.kt @@ -30,10 +30,16 @@ object SshLaunchOptions { * against the fingerprint sshd offers, but adopts it without asking, which * is the only workable option when the prompt would land on a terminal the * user is not watching. Anything weaker than this is a real downgrade. + * + * HashKnownHosts is pinned off: the bundled client now reads the distro's own + * /etc/ssh/ssh_config, and Debian-family configs hash new entries — which the + * app's known-hosts screen would then list as `|1|…` gibberish instead of host + * names. The app owns that file's format. */ private val BASE_OPTIONS = listOf( "UserKnownHostsFile=$KNOWN_HOSTS", - "StrictHostKeyChecking=accept-new" + "StrictHostKeyChecking=accept-new", + "HashKnownHosts=no" ) /** Arguments for a saved server. */ diff --git a/app/src/main/java/com/redtermapp/ui/SshManagerActivity.kt b/app/src/main/java/com/redtermapp/ui/SshManagerActivity.kt index e130929..2a0a88d 100644 --- a/app/src/main/java/com/redtermapp/ui/SshManagerActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/SshManagerActivity.kt @@ -146,7 +146,10 @@ class SshManagerActivity : AppCompatActivity() { } body.addView(header) body.addView(TextView(this).apply { - text = getString(R.string.ssh_server_row, server.user, server.host, server.port) + // No port here. It was printed twice whenever it was non-default -- once as + // the chip above, which exists precisely because a non-default port is the + // thing worth noticing, and again at the end of this line. + text = getString(R.string.ssh_server_row, server.user, server.host) textSize = 12f setTextColor(mutedTextColor()) maxLines = 1 @@ -159,15 +162,35 @@ class SshManagerActivity : AppCompatActivity() { gravity = android.view.Gravity.CENTER_VERTICAL setPadding(0, (6 * resources.displayMetrics.density).toInt(), 0, 0) } + // The key chip is muted whether or not one is bound, and the bound/unbound + // distinction is carried by the wording rather than by the colour. + // + // Both chips were accent-coloured, which made the key and "mosh" look like the + // same kind of fact twice. Accent is reserved here for the one thing that makes + // this server behave differently from an ordinary ssh session — the same + // treatment a non-default port already gets — so accent always means something. keyRow.addView( if (key != null) { - ScreenWidgets.chip(this, key.label, accentColor()) + ScreenWidgets.chip(this, key.label, mutedTextColor()) } else { // Not a warning: an unbound server still works, by trying the keys on // the device. Say which, so it is not read as a missing setting. ScreenWidgets.chip(this, getString(R.string.ssh_key_chip_any_key), mutedTextColor()) } ) + // Behaviour, not a fact about when the server was added, so it is a chip of its + // own rather than another muted line -- a card that renders "mosh" at the same + // weight as the hostname reads as trivia rather than as something the app will + // now try. + if (server.useMosh) { + keyRow.addView( + ScreenWidgets.chip(this, getString(R.string.ssh_mosh_chip), accentColor()), + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { setMargins(gap, 0, 0, 0) } + ) + } body.addView(keyRow) // A wrapping row rather than rows of equal weights. Weights divide the width // before anything is measured, so three buttons on a phone each get a third of @@ -177,7 +200,11 @@ class SshManagerActivity : AppCompatActivity() { fun action(labelRes: Int, onClick: () -> Unit) { actions.addView(ScreenWidgets.actionButton(this, labelRes, onClick)) } - action(R.string.ssh_connect) { connect(server) } + // The one button here that is not peer to the others: Connect is what this card is + // for, and five outlined buttons in a row gave no answer to which one to press. + actions.addView( + ScreenWidgets.primaryButton(this, R.string.ssh_connect) { connect(server) } + ) action(R.string.ssh_test) { testConnection(server) } if (server.forwards.any { it.enabled && it.usable }) { action( @@ -294,6 +321,7 @@ class SshManagerActivity : AppCompatActivity() { keepAliveSeconds = e.keepAliveSeconds, keepAliveCount = e.keepAliveCount, compress = e.compress, + useMosh = e.useMosh, forwardAgent = e.forwardAgent, jump = e.jump, auth = e.auth, @@ -409,6 +437,39 @@ class SshManagerActivity : AppCompatActivity() { output.contains("incorrect passphrase", ignoreCase = true) || output.contains("Enter passphrase", ignoreCase = true) + /** + * The server's own version banner, as the trace reported it. + * + * One line a success card can answer "connected to what" with, read from the + * `Remote protocol version` line ssh prints once per run rather than parsed out + * of known_hosts or anywhere else that exists. Null when the trace says nothing. + */ + private fun remoteBanner(output: String): String? { + val line = output.lineSequence().firstOrNull { + it.contains("remote software version", ignoreCase = true) + } ?: return null + return line.substringAfter("remote software version", "").trim() + .ifEmpty { null } + } + + /** + * Strips the build machine out of an ssh trace. + * + * The bundled client was configured with its sysconfdir under the directory it + * was built in, so every -v run prints several `load_hostkeys: fopen` lines + * naming that directory — a machine the user has never heard of, which can + * never exist on a device and whose absence carries no information: the app + * pins its known-hosts file with -o, so nothing there is ever read. The lines + * are platform noise like a linker warning, not something ssh said about the + * connection, and they do not survive into a report. + */ + private fun stripBuildNoise(output: String): String = + output.lineSequence() + .filterNot { + it.contains("load_hostkeys") && it.contains("/redterm-openssh/ssh-") + } + .joinToString("\n") + /** * Lists what the app trusts, and lets each one be forgotten. * @@ -621,28 +682,55 @@ class SshManagerActivity : AppCompatActivity() { ) runOnUiThread { dialog.dismiss() - AlertDialog.Builder(this) - .setTitle( - if (code == 0) R.string.ssh_test_ok - else R.string.ssh_test_failed + // The result is the one place a connection problem can be diagnosed + // from the phone, and a failure the user cannot read is a failure + // nobody can fix: the tool-output view is selectable, scrollable and + // copyable, and the ssh trace leaves the phone with it. + if (code == 0) { + // Success needs a line, not a trace. The -v output that explains a + // failure is noise on a success, and a card of it reads as + // something went wrong; what a success is about is *what the + // connection used* and *what answered*. + ToolOutputDialog.show( + this, + getString(R.string.ssh_test_ok), + buildString { + append(getString(R.string.ssh_test_authenticated, server.host, server.user)) + if (how.isNotEmpty()) { + append('\n') + if (usedPassword) { + append(getString(R.string.ssh_test_auth_password)) + } else { + append(getString(R.string.ssh_test_auth_key, how)) + } + } + remoteBanner(output)?.let { + append('\n') + append(getString(R.string.ssh_test_remote_version, it)) + } + } ) - .setMessage( - // "Permission denied (publickey)" on its own reads as the - // server refusing the key, which is the wrong conclusion - // when the real cause is a passphrase nobody can type. + } else { + // Failure keeps the trace, which is the diagnosis. Above it, when + // a key was actually offered: a refused key is ambiguous between a + // passphrase that never arrived and a key the server has no + // record of, and the trace alone does not say which. + ToolOutputDialog.show( + this, + getString(R.string.ssh_test_failed), buildString { - if (usedKey != null) { - append(getString(R.string.ssh_test_passphrase_hint)) + if (output.contains("Offering public key")) { + append(getString(R.string.ssh_test_key_refused)) append("\n\n") } append( - output.trim().ifEmpty { getString(R.string.ssh_test_no_output) } + stripBuildNoise(output).trim() + .ifEmpty { getString(R.string.ssh_test_no_output) } .takeLast(4000) ) } ) - .setPositiveButton(R.string.ok, null) - .show() + } } }, "ssh-test").start() } diff --git a/app/src/main/java/com/redtermapp/ui/SshPasswordPrompt.kt b/app/src/main/java/com/redtermapp/ui/SshPasswordPrompt.kt index 087db7f..777a535 100644 --- a/app/src/main/java/com/redtermapp/ui/SshPasswordPrompt.kt +++ b/app/src/main/java/com/redtermapp/ui/SshPasswordPrompt.kt @@ -36,12 +36,15 @@ object SshPasswordPrompt { * * The answer is the password itself, not a boolean: it has to reach ssh, so it is * cached on the way out. + * + * Like the key passphrase, a dismissal lasts until the connection attempt it + * interrupted is over and no longer: a refusal that arrived with a wrong or + * stale held value still earns a fresh question, because there is no way to + * know the held value was the wrong part. */ fun requestBlocking(activity: Activity, server: SshStore.Server): String? { if (activity.isFinishing || activity.isDestroyed) return null - if (SshAskpass.isPasswordDeclined(server.id)) return null synchronized(promptLock) { - if (SshAskpass.isPasswordDeclined(server.id)) return null if (activity.isFinishing || activity.isDestroyed) return null val field = EditText(activity).apply { @@ -65,7 +68,6 @@ object SshPasswordPrompt { val password = answer.await(TIMEOUT_SECONDS) if (password.isNullOrEmpty()) { - SshAskpass.declinePassword(server.id) return null } SshAskpass.rememberPassword(server.id, password) diff --git a/app/src/main/java/com/redtermapp/ui/SshStore.kt b/app/src/main/java/com/redtermapp/ui/SshStore.kt index 0dbc9db..96b5c16 100644 --- a/app/src/main/java/com/redtermapp/ui/SshStore.kt +++ b/app/src/main/java/com/redtermapp/ui/SshStore.kt @@ -63,6 +63,17 @@ object SshStore { /** Id of the [SshKeyStore.Entry] this server authenticates with, if any. */ val keyId: String? = null, + /** + * Whether to start this server's sessions with mosh rather than ssh. + * + * A preference, never a requirement: mosh needs `mosh-server` installed on the + * far end and UDP 60000-61000 reachable to it, and neither is ours to arrange. + * When the bootstrap cannot start a mosh session the session falls back to ssh + * and says so, because a terminal that refuses to open is strictly worse than + * one that opened the other way. + */ + val useMosh: Boolean = false, + /** * Seconds between keepalives. Zero disables them. * @@ -155,6 +166,7 @@ object SshStore { put("keyId", s.keyId ?: "") put("keepAliveSeconds", s.keepAliveSeconds) put("keepAliveCount", s.keepAliveCount) + put("useMosh", s.useMosh) put("compress", s.compress) put("forwardAgent", s.forwardAgent) put("auth", s.auth.name) @@ -204,6 +216,7 @@ object SshStore { keyId = o.optString("keyId").takeIf { it.isNotBlank() }, keepAliveSeconds = o.optInt("keepAliveSeconds", 0).coerceAtLeast(0), keepAliveCount = o.optInt("keepAliveCount", 0).coerceAtLeast(0), + useMosh = o.optBoolean("useMosh", false), compress = o.optBoolean("compress", false), forwardAgent = o.optBoolean("forwardAgent", false), jump = o.optJSONObject("jump")?.let { j -> diff --git a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt index d7aa041..fe40e2c 100644 --- a/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/TerminalActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Environment +import android.os.Looper import android.provider.Settings import android.view.ContextThemeWrapper import android.view.Gravity @@ -30,10 +31,12 @@ import androidx.core.view.isVisible import androidx.drawerlayout.widget.DrawerLayout import com.redtermapp.R import com.redtermapp.ui.filelist.SftpBrowserActivity +import com.redtermapp.mosh.MoshBootstrap +import com.redtermapp.mosh.MoshClient +import com.redtermapp.mosh.MoshNative +import com.redtermapp.util.AppLog import com.redtermapp.util.SshClient import com.redtermapp.distro.DistroInstaller -import com.redtermapp.distro.DistroRunner -import com.redtermapp.distro.SessionRecorder import com.redtermapp.distro.ProotLaunch import com.redtermapp.service.TerminalService import com.termux.terminal.TerminalEmulator @@ -77,6 +80,7 @@ class TerminalActivity : AppCompatActivity() { const val EXTRA_SSH_PORT = "ssh_port" const val EXTRA_SSH_USER = "ssh_user" const val EXTRA_SSH_KEY_ID = "ssh_key_id" + const val EXTRA_SSH_USE_MOSH = "ssh_use_mosh" /** * Opens a new terminal session for a saved server. @@ -86,6 +90,23 @@ class TerminalActivity : AppCompatActivity() { * key it belongs to, and an argv tells it neither. It also never needs a * distro, so this works on a device with none installed. */ + /** + * Which of two descriptions of the same server to believe. + * + * The saved record, when there is one: it is the thing the user edited, and the + * intent's copy is a hand-maintained subset of it. The key override still wins, + * because choosing a key for one connection is the entire reason the caller can + * pass one. + */ + fun resolveSshServer( + saved: SshStore.Server?, + carried: SshStore.Server, + keyId: String?, + ): SshStore.Server { + val base = saved ?: carried + return if (keyId != null) base.copy(keyId = keyId) else base + } + fun launchSsh(context: Context, server: SshStore.Server, keyId: String? = null) { context.startActivity( Intent(context, TerminalActivity::class.java).apply { @@ -95,6 +116,7 @@ class TerminalActivity : AppCompatActivity() { putExtra(EXTRA_SSH_PORT, server.port) putExtra(EXTRA_SSH_USER, server.user) putExtra(EXTRA_SSH_KEY_ID, keyId ?: server.keyId ?: "") + putExtra(EXTRA_SSH_USE_MOSH, server.useMosh) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP } @@ -156,10 +178,23 @@ class TerminalActivity : AppCompatActivity() { val server: SshStore.Server, val keyId: String? = null, val startDir: String? = null, - override val command: String? = null + override val command: String? = null, + /** + * Force a transport for this one session, or null to follow the server's + * saved preference. + * + * Needed because the two are not the same question. "Reconnect using mosh" has + * to work on a server whose saved preference is ssh — that is the entire point + * of the action — and a server that fell back to ssh because mosh-server was + * missing has to be retryable without going back to the server list first. + */ + val forceTransport: Transport? = null ) : SessionRequest() } + /** How an SSH session reaches its host. */ + private enum class Transport { Mosh, Ssh } + private val nightReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) { if (isFinishing || isDestroyed) return @@ -178,7 +213,10 @@ class TerminalActivity : AppCompatActivity() { private fun wireBackend(backend: TerminalBackend) { backend.onSessionFinished = { finishedSession -> handleSessionFinished(finishedSession) } + backend.onChildOutput = { session -> onConnectWatchOutput(session) } backend.onLinkTap = { link, isPath -> handleLinkTap(link, isPath) } + backend.onDeadSessionEnter = { session -> openSshAfterFailedMosh(session) } + backend.onFrame = { session -> onRecordFrame(session) } } private fun handleLinkTap(link: String, isPath: Boolean) { @@ -335,6 +373,21 @@ class TerminalActivity : AppCompatActivity() { .edit { putString("last_distro", requested) } } terminalView = findViewById(R.id.terminal_view) + // The backend and the view's client are attached here, not lazily on the first + // session. TerminalView dereferences its client in onCreateInputConnection, and + // Android starts input on window-focus-gain — so a view that exists, is visible + // and has focus but no client is a crash on a null pointer inside the library, + // before any of our code runs. + // + // That window used to be zero-width because a session was attached + // synchronously. mosh made it real: its bootstrap opens an SSH connection, runs + // mosh-server and reads back a port and a key, so the session is attached some + // seconds later. In between, the activity is up and focused with a client-less + // terminal view, and the device crashed on entry and again on every retry. + terminalBackend = TerminalBackend(terminalView, this).also { + terminalView.setTerminalViewClient(it) + wireBackend(it) + } searchHighlight = findViewById(R.id.search_highlight_overlay) searchHighlight.attachTerminalView(terminalView) drawerLayout = findViewById(R.id.drawer_layout) @@ -536,7 +589,7 @@ class TerminalActivity : AppCompatActivity() { private fun SessionDescriptor.asRequest(): SessionRequest = when (this) { is SessionDescriptor.Local -> SessionRequest.Local(distro, startDir) is SessionDescriptor.Ssh -> SessionRequest.Ssh( - SshStore.Server(serverId, label, host, port, user, keyId), + SshStore.Server(serverId, label, host, port, user, keyId, useMosh = useMosh), keyId = keyId, startDir = startDir ) @@ -547,20 +600,35 @@ class TerminalActivity : AppCompatActivity() { return true } - /** Reads a saved-server request out of an intent, or null if it is not one. */ + /** + * The saved server an intent refers to, or null if it is not an SSH request. + * + * The saved record wins over whatever the intent carries, and that is the whole + * point. The extras are a hand-copied subset of `SshStore.Server`, so every field + * added to the server has to be remembered here as well — and the one time it was + * not, a server whose card said *mosh* opened over ssh while the session title said + * *· ssh*, on two different screens, so neither half looked wrong on its own. The + * extras are now a fallback for a server that is not in the list at all, which is + * the only case they are actually needed for. + */ private fun Intent?.sshServerRequest(): SshServerRequest? { if (this == null) return null val host = getStringExtra(EXTRA_SSH_HOST)?.takeIf { it.isNotBlank() } ?: return null + val id = getStringExtra(EXTRA_SSH_SERVER_ID).orEmpty().ifBlank { host } + val keyId = getStringExtra(EXTRA_SSH_KEY_ID)?.takeIf { it.isNotBlank() } + val carried = SshStore.Server( + id = id, + label = getStringExtra(EXTRA_SSH_LABEL)?.takeIf { it.isNotBlank() } ?: host, + host = host, + port = getIntExtra(EXTRA_SSH_PORT, 22).coerceIn(1, 65535), + user = getStringExtra(EXTRA_SSH_USER).orEmpty(), + keyId = keyId, + useMosh = getBooleanExtra(EXTRA_SSH_USE_MOSH, false) + ) + val saved = SshStore.load(this@TerminalActivity).firstOrNull { it.id == id } return SshServerRequest( - server = SshStore.Server( - id = getStringExtra(EXTRA_SSH_SERVER_ID).orEmpty().ifBlank { host }, - label = getStringExtra(EXTRA_SSH_LABEL)?.takeIf { it.isNotBlank() } ?: host, - host = host, - port = getIntExtra(EXTRA_SSH_PORT, 22).coerceIn(1, 65535), - user = getStringExtra(EXTRA_SSH_USER).orEmpty(), - keyId = getStringExtra(EXTRA_SSH_KEY_ID)?.takeIf { it.isNotBlank() } - ), - keyId = getStringExtra(EXTRA_SSH_KEY_ID)?.takeIf { it.isNotBlank() } + server = resolveSshServer(saved, carried, keyId), + keyId = keyId ) } @@ -583,7 +651,17 @@ class TerminalActivity : AppCompatActivity() { } } - private fun currentSessionLabel(): String = sessionLabel(session) + /** + * The action-bar title: the label plus the transport, when the transport is a choice. + * + * Deliberately not folded into `sessionLabel`, which is the session's *identity* and + * feeds the exported transcript's filename. A suffix that changes when a session + * reconnects would give the same session two different file names. + */ + private fun currentSessionLabel(): String { + val base = sessionLabel(session) + return base + transportSuffixOf(sessionModel.descriptorFor(session)) + } /** * Filesystem-safe stem for an exported transcript. @@ -1043,10 +1121,10 @@ class TerminalActivity : AppCompatActivity() { // 12+, so it is launched through proot's -L loader exactly like the // distro binaries. val command = args.joinToString(" ") { ProotLaunch.quoteForShell(it) } - com.redtermapp.util.AppLog.i(this, "ssh", "session requested: $command") + AppLog.i(this, "ssh", "session requested: $command") val launcher = com.redtermapp.util.SshClient.launcher(this, command, launcherName(id)) if (launcher == null) { - com.redtermapp.util.AppLog.e(this, "ssh", "no usable ssh rootfs") + AppLog.e(this, "ssh", "no usable ssh rootfs") showError(getString(R.string.ssh_client_unavailable)) return null } @@ -1108,27 +1186,12 @@ class TerminalActivity : AppCompatActivity() { val startHost = startInner ?.let { File(rootfsDir, it.removePrefix("/")) }?.absolutePath ?: filesDir.absolutePath - // A recorded session starts its shell under `script`, which allocates a pty - // and writes everything through it. There is no way to attach to a shell that - // is already running — the terminal library exposes no hook on session - // output — so this can only be chosen when the session is opened. The toggle - // says so rather than appearing to do nothing to the session in front of you. - val recording = takeRecordingRequest(distro) - val recorder = SessionRecorder(applicationContext) - val recordCommand = if (recording) { - recorder.sessionCommand(distro, DistroRunner(applicationContext)) - } else { - null - } - if (recording && recordCommand == null) { - showError(getString(R.string.record_script_missing, distro)) - } val launchSh = ProotLaunch.writeLauncher( context = this, rootfsDir = rootfsDir, startInner = startInner, - command = recordCommand, + command = null, scriptName = launcherName(id) ) @@ -1143,8 +1206,7 @@ class TerminalActivity : AppCompatActivity() { id = id, label = distro.replaceFirstChar { it.uppercase() }, distro = distro, - startDir = startInner, - recording = recordCommand != null + startDir = startInner ) ) } @@ -1196,63 +1258,125 @@ class TerminalActivity : AppCompatActivity() { @androidx.annotation.ColorInt background: Int, vararg views: com.termux.view.TerminalView ) { - val palette = TerminalPalette.build(foreground, background) - for (view in views) { - view.setBackgroundColor(background) - val emulator = view.mEmulator ?: continue - val current = emulator.mColors.mCurrentColors - val shared = minOf(current.size, palette.size, TerminalPalette.SIZE) - for (i in 0 until shared) current[i] = palette[i] - view.mTermSession?.onColorsChanged() - view.onScreenUpdated() - } + TerminalTheming.applyTerminalColours(foreground, background, *views) } /** - * Consumes a pending "record the next session" request for [distro]. + * The recorders with a session in front of them and files with output in them. * - * Consumed rather than left set, so the request applies to the one session the - * user asked for instead of silently recording every session afterwards. + * One recorder per recorded session: recording follows the session it was + * started on, so a second session can be recorded at the same time and a + * finished session leaves a finished file behind. Keys are identity, because + * sessions are not compared by value. */ - private fun takeRecordingRequest(distro: String): Boolean { - val prefs = getSharedPreferences("settings", MODE_PRIVATE) - val key = "record_next_$distro" - if (!prefs.getBoolean(key, false)) return false - prefs.edit { remove(key) } - return true - } + private val recorders = java.util.IdentityHashMap() + + private val recorder by lazy { SessionRecorder(applicationContext) } /** - * Turns recording on for the *next* session in this distribution. + * Starts or stops recording the session currently on screen. * - * Cannot apply to the session already on screen: there is no hook on what a - * running session writes, so the shell would have to have been started under - * `script` from the beginning. The dialog says so rather than looking broken, and - * the switch is consumed by the next session so it does not silently record - * everything afterwards. + * Recording used to be something armed for the *next* session, because the + * capture ran inside the guest (`script`) and could not join a shell that was + * already running. The capture is the emulator snapshot now, which needs + * nothing from the session's insides, so the button does what every terminal + * with a record button does: it records what is on screen, right now. */ private fun toggleRecording() { - val turningOn = !recordingRequested(distroName) - setRecordingRequest(distroName, turningOn) + val session = terminalView.mTermSession + if (session == null) { + Toast.makeText(this, R.string.record_no_session, Toast.LENGTH_SHORT).show() + return + } val button = findViewById(R.id.panel_record) - setCardButtonBg(button, turningOn) - if (turningOn) { - AlertDialog.Builder(this) - .setTitle(R.string.record_session) - .setMessage(R.string.record_applies_to_next) - .setPositiveButton(R.string.ok, null) - .show() + val recording = recorders[session] + if (recording != null) { + stopRecording(session, "stopped by hand") + setCardButtonBg(button, false) + Toast.makeText(this, R.string.recording_stopped_toast, Toast.LENGTH_SHORT).show() + return + } + startRecording(session) + setCardButtonBg(button, recorders[session] != null) + } + + /** + * Opens the recording file and captures the first frame immediately. + * + * The on-screen state at the moment recording starts belongs in the recording: + * a prompt shown before any keystroke is still session output, and waiting for + * a frame that may never come would leave an empty file behind when a session + * ends before drawing anything. A screen with nothing on it encodes as no frame, + * which is fine: the next drawn frame writes the whole screen. + */ + private fun startRecording(session: TerminalSession) { + val emulator = session.emulator ?: run { + Toast.makeText(this, R.string.record_no_session, Toast.LENGTH_SHORT).show() + return } + val file = recorder.newFile(recordingLabel(session)) + val recording = try { + FrameRecorder(file) + } catch (e: java.io.IOException) { + // The directory always exists (SessionRecorder.dir creates it); this is + // a full volume or a denied filesystem, either of which the user should + // hear about in words and this app should hear about in the log. + AppLog.e(this, "recording", "could not open ${file.path}: ${e.message}") + Toast.makeText(this, R.string.record_storage_unwritable, Toast.LENGTH_SHORT).show() + return + } + recorders[session] = recording + if (!recording.write(TerminalFrameEncoder.encode(emulator))) { + stopRecording(session, "first frame failed: ${recording.lastError}") + Toast.makeText(this, R.string.record_storage_unwritable, Toast.LENGTH_SHORT).show() + return + } + AppLog.i(this, "recording", "started: ${file.name} (${recordingLabel(session)}); first frame written") + Toast.makeText(this, R.string.recording_started_toast, Toast.LENGTH_SHORT).show() } - private fun setRecordingRequest(distro: String, on: Boolean) { - getSharedPreferences("settings", MODE_PRIVATE).edit { - putBoolean("record_next_$distro", on) + /** The backend's per-frame hook for recording the session it is about to draw. */ + private fun onRecordFrame(session: TerminalSession) { + val recording = recorders[session] ?: return + val emulator = session.emulator ?: return + if (recording.write(TerminalFrameEncoder.encode(emulator))) return + // A recording that cannot write is stopped, not left spinning: the frames + // already in the file stay usable... + stopRecording(session, "frame write failed: ${recording.lastError}") + setCardButtonBg(findViewById(R.id.panel_record), false) + Toast.makeText(this, R.string.recording_stopped_toast, Toast.LENGTH_SHORT).show() + } + + /** Where a recording's name comes from: the descriptor, not the editable name. */ + private fun recordingLabel(session: TerminalSession): String { + val descriptor = sessionModel.descriptorFor(session) + return when (descriptor) { + is SessionDescriptor.Local -> descriptor.distro + is SessionDescriptor.Ssh -> descriptor.host + null -> session.mSessionName ?: "session" } } - private fun recordingRequested(distro: String): Boolean = - getSharedPreferences("settings", MODE_PRIVATE).getBoolean("record_next_$distro", false) + /** + * Closes [session]'s recording, naming the reason in the log. + * + * A recording that captured nothing is deleted: an empty file in the + * RecordingActivity list is a worse thing than no file, and "started, then + * nothing ever drew" is a state the user never asked to keep. + */ + private fun stopRecording(session: TerminalSession, why: String) { + val recording = recorders.remove(session) ?: return + recording.close() + if (recording.frames == 0) { + recorder.delete(recording.file) + AppLog.d(this, "recording", "dropped ${recording.file.name}: no frames written ($why)") + return + } + AppLog.i( + this, "recording", + "stopped: ${recording.file.name} (${recording.frames} frames, ${recording.bytes} bytes) — $why" + ) + } private fun scrollbackRows(prefs: android.content.SharedPreferences): Int = intArrayOf(500, 1000, 2000, 3000, 5000, 7500, 10000, 15000, 20000, 30000)[ @@ -1270,14 +1394,203 @@ class TerminalActivity : AppCompatActivity() { // Read before the branch: a smart cast does not survive a `when` that // assigns, so `request.command` would not resolve afterwards. val command = request.command - val prepared = when (request) { - is SessionRequest.Local -> prepareLocal(request, id) - is SessionRequest.Ssh -> prepareSsh(request, id) - } ?: return - attachSession(prepared, command) + when (request) { + is SessionRequest.Local -> + prepareLocal(request, id)?.let { attachSession(it, command) } + is SessionRequest.Ssh -> createSshOrMoshSession(request, id, command) + } } - private fun attachSession(prepared: PreparedSession, command: String?) { + /** + * The size a mosh session is told about before the pty exists. + * + * TerminalView in v0.118.3 exposes no column/row getter -- `mEmulator` is package + * private on the session and there is nothing on the view -- and there is no honest + * way to measure a cell from here. So this is a seed, not an answer: the client asks + * the pty for its real size on every SIGWINCH, and `TerminalSession` sends one as + * soon as the view is laid out, so the first frame is corrected almost immediately. + * Getting this exactly right would mean the emulator drawing at one size and then + * reflowing, which is worse than starting from a conventional size. + */ + private val MOSH_SEED_COLS = 80 + private val MOSH_SEED_ROWS = 24 + + /** + * Starts an SSH session, preferring mosh when the server asks for it. + * + * mosh's bootstrap opens an SSH connection of its own, runs `mosh-server new` on the + * far end and reads back a port and a key, so it is seconds of blocking network work + * and it may ask for a passphrase on the way. That cannot happen on the thread that + * called this, so the session is attached when the answer arrives rather than before. + * + * **Falling back is not a failure path, it is the design.** mosh needs `mosh-server` + * on the host and UDP 60000-61000 open to it, neither of which the app can arrange or + * even see. A server that cannot do mosh still does ssh, and a user whose session + * opened over the other protocol should be told which and why rather than left to + * wonder why their phone never roamed. + */ + private fun createSshOrMoshSession(request: SessionRequest.Ssh, id: String, command: String?) { + val server = request.server.copy(keyId = request.keyId ?: request.server.keyId) + + // Asked for nothing, so go straight to ssh. + // + // This has to be its own check rather than one case in the `when` below. That + // expression answers "if mosh is in play and cannot be had, say why" and returns + // null both when the server never asked for mosh and when it asked and mosh is + // fine — so a server with the toggle off could not be told apart from a ready + // one, fell through to the bootstrap anyway, and every plain SSH session paid a + // multi-second delay and could be handed a mosh session it never asked for. + val wantsMosh = request.forceTransport == Transport.Mosh || + (request.forceTransport == null && server.useMosh) + + if (!wantsMosh) { + // Same words as the mosh path. A session is not ready when the tap lands: + // keys are read out of the store and the launcher is written on first use, + // and none of that used to happen silently. But the slow part of an ssh + // connect is none of that: the session does its TCP, key exchange and + // authentication after it starts, and until then the terminal is blank. + // So the overlay outlives the attach and the session's first drawn output + // is what ends it — the same way a listing ends an SFTP overlay and a + // handshake ends a mosh bootstrap's. + val token = showConnecting(getString(R.string.ssh_connecting, server.label)) + Thread({ + val prepared = prepareSsh(request, id) + runOnUiThread { + // A newer attempt superseded this one, or the user cancelled it: + // what this worker prepared belongs to a request that is no + // longer on screen, and hiding the overlay from here would hide + // the newer attempt's. + if (token != attemptGeneration) { + AppLog.i(this@TerminalActivity, "session", "discarding a cancelled attempt to reach ${server.label}") + return@runOnUiThread + } + if (isFinishing || isDestroyed) return@runOnUiThread + if (prepared == null) { + // prepareSsh showed the failure before returning null. + hideConnecting() + return@runOnUiThread + } + watchConnect(attachSession(prepared, command)) + } + }, "ssh-prepare").start() + return + } + + val token = showConnecting(getString(R.string.mosh_connecting, server.label)) + + val moshUnavailableReason = when { + !MoshNative.ensureLoaded(this) -> MoshNative.loadError ?: "mosh is not available" + !MoshClient.ensureInstalled(this) -> MoshClient.installError ?: "mosh is not installed" + else -> null + } + + if (moshUnavailableReason != null) { + AppLog.i(this, "mosh", "using ssh for ${server.label}: $moshUnavailableReason") + // The ssh session the server is falling back to connects the way every + // other one does, so it gets the same output-watched wait. + prepareSsh(request, id)?.let { watchConnect(attachSession(it, command)) } + ?: hideConnecting() + return + } + + Thread({ + val outcome = MoshBootstrap.connect(this, applicationContext, server, MOSH_SEED_COLS, MOSH_SEED_ROWS) + runOnUiThread { + // A newer attempt superseded this one, or the user cancelled it: the + // bootstrap result belongs to a request that is no longer on screen, + // and hiding the overlay from here would hide the newer attempt's. + if (token != attemptGeneration) { + AppLog.i(this@TerminalActivity, "mosh", "discarding a cancelled attempt to reach ${server.label}") + return@runOnUiThread + } + if (isFinishing || isDestroyed) return@runOnUiThread + when (outcome) { + is MoshBootstrap.Outcome.Ready -> { + AppLog.i(this, "mosh", "started a mosh session to ${server.host}:${outcome.endpoint.port}") + // The overlay covers the attach the same way it covers a plain + // ssh one, and ends on the session's first drawn output. There + // is no in-terminal progress line to cover this any more: the + // client used to print one and print nothing else until the + // first frame, which for a blocked UDP path is 20 s of blank + // screen saying neither "trying" nor hung. + val moshSession = prepareMosh(outcome.endpoint, request, id) + if (moshSession != null) { + watchConnect(attachSession(moshSession, command)) + } else { + // The fallback is a plain ssh session again. + prepareSsh(request, id)?.let { watchConnect(attachSession(it, command)) } + ?: hideConnecting() + } + } + is MoshBootstrap.Outcome.Unavailable -> { + // Reported, because a silent fallback is indistinguishable from + // the preference having been ignored. + AppLog.i(this, "mosh", "using ssh for ${server.label}: ${outcome.reason}") + if (outcome.detail.isNotBlank()) { + AppLog.i(this, "mosh", "detail: ${outcome.detail.takeLast(600)}") + } + // Said out loud as well as logged. The session is about to look + // perfectly normal, and "mosh is not available on this server" + // arriving three seconds after the tap is the only chance to say + // why it is not being used. + android.widget.Toast.makeText( + this, + getString(R.string.mosh_fell_back, server.label, outcome.reason), + android.widget.Toast.LENGTH_LONG + ).show() + // And the ssh session it falls back to is a connecting ssh + // session like any other. + prepareSsh(request, id)?.let { watchConnect(attachSession(it, command)) } + ?: hideConnecting() + } + } + } + }, "mosh-bootstrap").start() + } + + /** + * Builds the session that runs the mosh client executable. + * + * The endpoint and the key go into the subprocess *environment*, never into the + * command: the launcher is a file in app storage that gets written to a script and + * logged on its way past, and the key is the one thing in this session that must + * never reach a file. + */ + private fun prepareMosh( + endpoint: MoshBootstrap.Endpoint, + request: SessionRequest.Ssh, + id: String, + ): PreparedSession? { + val server = request.server.copy(keyId = request.keyId ?: request.server.keyId) + val launcher = MoshClient.launcher(this, launcherName(id)) + if (launcher == null) { + AppLog.e(this, "mosh", "no usable mosh launcher") + return null + } + val prefs = getSharedPreferences("settings", MODE_PRIVATE) + return PreparedSession( + shell = "/system/bin/sh", + cwd = SshClient.homeDir(applicationContext).absolutePath, + args = arrayOf("-c", launcher), + env = MoshClient.environmentFor( + applicationContext, endpoint, MOSH_SEED_COLS, MOSH_SEED_ROWS, + ), + scrollback = scrollbackRows(prefs), + descriptor = SessionDescriptor.Ssh( + id = id, + label = server.label, + serverId = server.id, + host = server.host, + port = server.port, + user = server.user, + keyId = server.keyId, + startDir = request.startDir, + useMosh = true, + ), + ) + } + + private fun attachSession(prepared: PreparedSession, command: String?): TerminalSession { val prefs = getSharedPreferences("settings", MODE_PRIVATE) val backend = terminalBackend ?: TerminalBackend(terminalView, this).also { terminalBackend = it @@ -1295,15 +1608,28 @@ class TerminalActivity : AppCompatActivity() { session.mSessionName = prepared.descriptor.label sessionModel.addSession(session, prepared.descriptor) - terminalView.attachSession(session) - terminalView.onScreenUpdated() - supportActionBar?.title = prepared.descriptor.label + // Font and colours before the attach, not after. TerminalView's renderer is + // created by setTextSize() and setTypeface() and by nothing else, and + // attachSession() calls updateSize() unconditionally — which reads the + // renderer's font width, guarded only on the view having a width and height and + // a session, and not on the renderer existing. + // + // That guard is what hid this. Attaching during onCreate happened before the + // first layout, so getWidth() was 0, updateSize() returned immediately and a + // null renderer was harmless. The other two attach paths in this file have + // always set the font first for exactly that reason. mosh's bootstrap attaches + // seconds after layout, the width is real by then, the guard passes, and the + // renderer is read — so the latent ordering bug became a crash on entry. currentFontSize = prefs.getInt("font_size", 20) terminalView.setTextSize(currentFontSize) applyFontFromPrefs(prefs) applyTerminalColours(terminalFg, terminalBg, terminalView) + terminalView.attachSession(session) + terminalView.onScreenUpdated() + supportActionBar?.title = prepared.descriptor.label + terminalView.post { showImeWhenTerminalTapped(terminalView) terminalView.requestFocus() @@ -1313,6 +1639,7 @@ class TerminalActivity : AppCompatActivity() { updateDrawer() RedTermWidgetProvider.updateAll(this) if (command != null) runCommand(command) + return session } /** @@ -1430,9 +1757,22 @@ class TerminalActivity : AppCompatActivity() { terminalView.onScreenUpdated() supportActionBar?.title = sessionLabel(sessions[index]) updateDrawer() + // A visible quick panel keeps its record button truthful: each session is + // recorded separately or not at all, so the button tracks the switch. + findViewById(R.id.panel_record).let { setCardButtonBg(it, isRecordingCurrentSession()) } } private fun handleSessionFinished(finishedSession: TerminalSession) { + // A connect-watched session that died without producing a line — a refused TCP + // connect can arrive as a process exit with no output at all — must not leave + // the overlay up waiting for output that is never coming. + if (finishedSession === connectWatchSession) { + stopConnectWatch() + hideConnecting() + } + // A recording ends with the session that made it, so the file is closed + // before the session leaves the list and stops being reachable. + stopRecording(finishedSession, "ended with its session") val idx = sessions.indexOf(finishedSession) if (idx < 0) return // Read before the session is dropped: this is the only place its descriptor is @@ -1445,14 +1785,38 @@ class TerminalActivity : AppCompatActivity() { -1 } sessionModel.removeSession(idx) - if (descriptor is SessionDescriptor.Ssh && status != 0) { - reportSshExit(descriptor, status) + + // A session we closed on purpose is not a session that failed. + // + // TerminalSession.finishIfRunning() kills the process, so closing one arrives here + // with status -9, and every deliberate close — reconnecting over the other + // transport, closing a pane — turned into an error dialog reporting the kill the + // user had just asked for. The entry is consumed here, so it can only ever + // suppress the one exit it was created for. + var holdOpen = false + if (descriptor != null && deliberatelyClosed.remove(descriptor.id)) { + AppLog.d(this, "session", "closed \"${descriptor.label}\" on purpose; not an error") + } else if (descriptor is SessionDescriptor.Ssh && status != 0) { + if (descriptor.useMosh && status == MOSH_EXIT_NO_REPLY) { + // The session never functioned, so there is nothing for the user to be + // doing and the question is how a working one opens instead. The + // dialog decides that; the activity must not fold up behind it. + explainMoshNoReply(descriptor, finishedSession) + holdOpen = true + } else { + reportSshExit(descriptor, status) + } } if (splitActive) { exitSplit() } if (sessions.isEmpty()) { - finish() + // holdOpen skips only the close: the split teardown, the drawer and the + // widget above run either way, and a hung-up session that stays out of + // every list is the point of them. + if (!holdOpen) { + finish() + } } else { terminalView.attachSession(sessions[currentIndex]) terminalView.onScreenUpdated() @@ -1466,10 +1830,107 @@ class TerminalActivity : AppCompatActivity() { * Says why an ssh session ended, because the terminal it was running in is * usually gone by the time it does and "it just closed" tells the user nothing. */ + /** The shim's exit code for "nothing ever came back". See redterm_mosh_main.cpp. */ + private val MOSH_EXIT_NO_REPLY = 4 + + /** + * mosh-server started, and no UDP reply ever arrived. + * + * This is what a blocked UDP path looks like, and it is the common one: a cloud + * server's firewall allows TCP 22 for ssh and nothing else, so the bootstrap + * succeeds perfectly and the session that follows is silent. Tailscale or any other + * overlay network makes the same server work, which is exactly why it is confusing + * enough to be worth naming rather than reporting as an exit status. + * + * A working terminal is opened either way. The only question left for the user is + * whether to keep asking for mosh next time. + */ + private fun explainMoshNoReply(descriptor: SessionDescriptor.Ssh, failedSession: TerminalSession) { + val server = SshStore.load(this).firstOrNull { it.id == descriptor.serverId } + val host = server?.host ?: descriptor.host + // The report on screen promises "Press Enter ... and open one over SSH". Enter + // is answered by openSshAfterFailedMosh, armed here for every dismissal that + // did not already open a working session — cancel, back, or tapping outside. + // Taking no action leaves a dead terminal that eats keys, which is exactly the + // expectation the user read. + var opensSsh = false + AlertDialog.Builder(this) + .setTitle(R.string.mosh_no_reply_title) + .setMessage(getString(R.string.mosh_no_reply, host)) + // Opens a working session now, and stops asking for mosh on this server. + .setPositiveButton(R.string.mosh_stop_trying) { _, _ -> + opensSsh = true + setServerMosh(descriptor, false) + reopenOverSsh(descriptor) + } + // Opens a working session but leaves the preference alone, for a network + // that blocks UDP today and may not tomorrow. + .setNeutralButton(R.string.mosh_use_ssh_this_time) { _, _ -> + opensSsh = true + reopenOverSsh(descriptor) + } + .setNegativeButton(R.string.cancel, null) + .setOnDismissListener { + if (!opensSsh) { + failedMoshSession = failedSession + failedMoshDescriptor = descriptor + } + } + .show() + } + + /** The dead mosh session still on screen, and the answer Enter owes it. */ + private var failedMoshSession: TerminalSession? = null + private var failedMoshDescriptor: SessionDescriptor.Ssh? = null + + /** + * The "press Enter" of the failure report. + * + * Armed only when the dialog was dismissed without opening a working session, and + * answered only by a keypress aimed at the session the report belongs to: a live + * session must keep behaving like a terminal, and a session replaced in the view + * must not inherit the dead one's promise. + */ + private fun openSshAfterFailedMosh(session: TerminalSession): Boolean { + if (session !== failedMoshSession || session.isRunning) return false + val descriptor = failedMoshDescriptor ?: return false + failedMoshSession = null + failedMoshDescriptor = null + AppLog.i(this, "mosh", "enter on the failed session; opening ${descriptor.host} over ssh") + reopenOverSsh(descriptor) + return true + } + + private fun setServerMosh(descriptor: SessionDescriptor.Ssh, useMosh: Boolean) { + val servers = SshStore.load(this).toMutableList() + val index = servers.indexOfFirst { it.id == descriptor.serverId } + if (index < 0) return + servers[index] = servers[index].copy(useMosh = useMosh) + SshStore.save(this, servers) + AppLog.i(this, "mosh", "mosh turned ${if (useMosh) "on" else "off"} for ${servers[index].label}") + } + + /** Opens the same server over ssh, without changing its saved preference. */ + private fun reopenOverSsh(descriptor: SessionDescriptor.Ssh) { + val server = SshStore.load(this).firstOrNull { it.id == descriptor.serverId } + ?: SshStore.Server( + descriptor.serverId, descriptor.label, descriptor.host, + descriptor.port, descriptor.user, descriptor.keyId, + ) + createSession( + SessionRequest.Ssh( + server = server, + keyId = descriptor.keyId, + forceTransport = Transport.Ssh, + ) + ) + } + private fun reportSshExit(descriptor: SessionDescriptor.Ssh, status: Int) { val server = SshStore.load(this).firstOrNull { it.id == descriptor.serverId } - val reason = when (status) { - 255 -> getString(R.string.ssh_session_refused) + val reason = when { + status == 255 -> getString(R.string.ssh_session_refused) + descriptor.useMosh -> getString(R.string.mosh_session_ended, status) else -> getString(R.string.ssh_session_ended, status) } val where = server?.let { getString(R.string.ssh_session_where, it.label) } ?: "" @@ -1483,6 +1944,102 @@ class TerminalActivity : AppCompatActivity() { } } + /** + * Descriptor ids of sessions this activity closed on purpose. + * + * Bounded by the number of sessions in flight and consumed on the exit that follows + * each one, so it cannot grow and cannot mask a later genuine failure. + */ + private val deliberatelyClosed = HashSet() + + /** + * The connecting overlay. Built against the drawer so the scrim also covers the + * navigation drawer; the shared class owns the details. + */ + private val connecting by lazy { ConnectingOverlay(this, drawerLayout) } + + /** + * Bumped at the start of every connect attempt and again by Cancel; a worker + * attaches only when the token it took is still the newest. + * + * The thread doing the work cannot be interrupted usefully -- it is inside a + * blocking ssh call -- so cancelling is about the *result*, not the attempt. + * A single shared abandoned-flag cannot tell attempts apart: a stalled attempt + * cancelled by the user goes on to pass the reset flag a newer attempt began, + * and no later callback exists to consume it then. The server's own + * `mosh-server` times out on its side if no client ever arrives, which is the + * same thing that happens when the phone goes to sleep mid-connect. + */ + private var attemptGeneration = 0 + + /** Returns the token this attempt's worker must still see to attach. */ + private fun showConnecting(message: String): Int { + val token = ++attemptGeneration + // A fresh attempt starts with nothing being watched: the previous wait has + // no later callback that would consume it, and its watched session's first + // output would end an overlay that now belongs to a different attempt — + // as would cancelling, which would otherwise end a session from an + // attempt the user is not looking at. + stopConnectWatch() + connecting.show(message) { + // Invalidate this attempt only; a newer one has already superseded it. + if (attemptGeneration == token) attemptGeneration++ + // Cancelling the wait also ends a session the overlay already sits on, + // so cancelling cannot leave one running behind the scrim. It is closed + // on purpose — the kill arrives as status -9, which without the entry + // becomes a failure dialog for the cancel the user just chose. + connectWatchSession?.let { watched -> + stopConnectWatch() + sessionModel.descriptorFor(watched)?.let { deliberatelyClosed.add(it.id) } + watched.finishIfRunning() + } + AppLog.i(this@TerminalActivity, "session", "connection attempt abandoned") + } + return token + } + + private fun hideConnecting() { + connecting.hide() + } + + // The session whose first drawn output stops the connecting overlay, and the cap + // that stops it instead when nothing ever arrives. A plain ssh connect does its + // real work — TCP, key exchange, authentication — after the session process starts, + // during which the terminal is blank; prepareSsh only picks keys and writes a + // launcher, so an overlay that ends there covers the wrong interval. The library's + // onTextChanged fires only when the process appends to the emulator, so the first + // one after attach is the banner, the prompt, or ssh's own question — and an ssh + // session may prompt in the terminal for a passphrase, which the overlay must not + // be covering by then, which is the same reason the cap is needed: a connect that + // produces no output must not trap the user behind a message for ever. + private var connectWatchSession: TerminalSession? = null + private val connectWatchTimedOut = Runnable { + connectWatchSession = null + hideConnecting() + } + + private fun watchConnect(session: TerminalSession) { + stopConnectWatch() + connectWatchSession = session + // Bounded: a TCP connect itself usually times out in ssh's own window and does + // report, but nothing here guarantees the session will ever say anything. + terminalView.postDelayed(connectWatchTimedOut, 30_000L) + } + + private fun stopConnectWatch() { + connectWatchSession = null + terminalView.removeCallbacks(connectWatchTimedOut) + } + + // Rewritten in place on the same callback. The overlay swallows touches, so nothing + // the user does — no paste, no keystroke — can produce this while the wait is on. + private fun onConnectWatchOutput(session: TerminalSession) { + if (session === connectWatchSession) { + stopConnectWatch() + hideConnecting() + } + } + private var splitActive = false private var splitBackend: TerminalBackend? = null private var splitLeftBackend: TerminalBackend? = null @@ -1592,7 +2149,7 @@ class TerminalActivity : AppCompatActivity() { views.add(findViewById(R.id.terminal_view_left)) views.add(findViewById(R.id.terminal_view_right)) } - return views.filter { it != null && it.id != View.NO_ID } + return views.filter { it.id != View.NO_ID } } private fun focusedBackend(): TerminalBackend? { @@ -1603,6 +2160,9 @@ class TerminalActivity : AppCompatActivity() { private fun closeSession(index: Int) { if (sessions.size <= 1) return + sessionModel.descriptorFor(sessions.getOrNull(index))?.let { + deliberatelyClosed.add(it.id) + } sessionModel.removeSession(index) if (currentIndex >= 0) { terminalView.attachSession(sessions[currentIndex]) @@ -1750,6 +2310,13 @@ class TerminalActivity : AppCompatActivity() { } private fun showError(msg: String) { + // The ssh session is prepared on a worker, and a failure there surfaces path: + // every reader of this method touches the terminal view, which belongs to the + // main thread. Hopping here keeps a worker's failure looking like any other. + if (Looper.myLooper() != Looper.getMainLooper()) { + runOnUiThread { showError(msg) } + return + } val errorFile = File(cacheDir, "opencode_error.txt") errorFile.writeText(msg) @@ -1808,7 +2375,10 @@ class TerminalActivity : AppCompatActivity() { private fun updateCwdTitle() { val s = session ?: return - val title = sessionLabel(s) + // currentSessionLabel, not sessionLabel: this runs on every cwd poll, so the + // transport suffix has to be part of what it compares or the title silently + // loses it the first time the remote changes directory. + val title = currentSessionLabel() if (supportActionBar?.title != title) { supportActionBar?.title = title } @@ -1854,6 +2424,12 @@ class TerminalActivity : AppCompatActivity() { } override fun onDestroy() { + stopConnectWatch() + hideConnecting() + // Sessions survive process death; a recording is still-frame-per-frame writing + // that stops being driven the moment no view is attached to its session. Every + // open one is closed here, so the files on disk stay complete and valid. + for (session in sessions.toList()) stopRecording(session, "activity destroyed") RedTermWidgetProvider.updateAll(this) unregisterReceiver(nightReceiver) if (sessions.isEmpty()) { @@ -1902,7 +2478,10 @@ class TerminalActivity : AppCompatActivity() { v.requestFocus() imeShownByTap = true (getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager) - ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) + // SHOW_IMPLICIT is deprecated and has had no effect since + // Android 16, where every show request became implicit; the + // deprecation note's replacement is to pass no flag at all. + ?.showSoftInput(v, 0) } false } @@ -1984,14 +2563,63 @@ class TerminalActivity : AppCompatActivity() { menu?.add(0, 9, 0, "Snippets") menu?.add(0, 10, 0, "Quick settings") menu?.add(0, 11, 0, "Split view") + val connectionSub = menu?.addSubMenu(0, 12, 0, getString(R.string.session_connection)) + connectionSub?.add(0, 121, 0, getString(R.string.session_reconnect_mosh)) + connectionSub?.add(0, 122, 0, getString(R.string.session_reconnect_ssh)) return true } override fun onPrepareOptionsMenu(menu: Menu?): Boolean { rebuildCustomFontMenuItems(menu) + + // The connection items only mean something for an SSH session, and only one of + // them does at a time: offering "reconnect using mosh" inside a mosh session + // would be an action that changes nothing and reads as a broken menu. + val descriptor = sessionModel.descriptorFor(session) as? SessionDescriptor.Ssh + menu?.findItem(12)?.isVisible = descriptor != null + menu?.findItem(121)?.isVisible = descriptor != null && !descriptor.useMosh + menu?.findItem(122)?.isVisible = descriptor != null && descriptor.useMosh + return super.onPrepareOptionsMenu(menu) } + /** + * Reopens the current SSH session over the other transport. + * + * The two protocols are separate processes speaking separate protocols, so this + * cannot convert a live session — it opens a new one and closes the old one. Anything + * less would leave a mosh session labelled as ssh, or an ssh session quietly + * pretending to be mosh. + * + * The server's saved preference moves with it. A user who has just asked for mosh + * wants mosh for this server, not for this one session, and the next tap reverses it. + */ + private fun reconnectWithTransport(descriptor: SessionDescriptor.Ssh, transport: Transport) { + val servers = SshStore.load(this).toMutableList() + val index = servers.indexOfFirst { it.id == descriptor.serverId } + if (index < 0) { + // Deleted from the server list while this session is open. Say so rather + // than reopening a host the user has removed. + showError(getString(R.string.ssh_server_gone, descriptor.host)) + return + } + val updated = servers[index].copy(useMosh = transport == Transport.Mosh) + servers[index] = updated + SshStore.save(this, servers) + + deliberatelyClosed.add(descriptor.id) + sessionModel.removeSessions { it.id == descriptor.id } + createSession( + SessionRequest.Ssh( + server = updated, + keyId = descriptor.keyId, + startDir = descriptor.startDir, + forceTransport = transport, + ) + ) + invalidateOptionsMenu() + } + private fun rebuildCustomFontMenuItems(menu: Menu?) { val fontSub = menu?.findItem(7)?.subMenu ?: return for (i in 0 until 10) { @@ -2066,7 +2694,7 @@ class TerminalActivity : AppCompatActivity() { private fun updateTerminalBg() { val prefs = getSharedPreferences("settings", MODE_PRIVATE) - val theme = prefs.getString("theme", "amoled") + val theme = prefs.getString("theme", NightModeReceiver.DEFAULT_THEME) val bg = when { theme == "custom" -> { prefs.getInt("custom_bg", 0xFF1E1E2E.toInt()) @@ -2099,13 +2727,27 @@ class TerminalActivity : AppCompatActivity() { terminalView.setBackgroundColor(bgWithAlpha) } + /** + * Terminal colours for the `dynamic` theme: (background, extra-keys background, text). + * + * `system_neutral1_N` runs light to dark — 0 is near-white, 1000 is black — so the + * foreground has to come from the *light* end of the ramp. It was taken from the dark + * end instead, `neutral1_100` beside a `neutral1_1000` background, which is black text + * on black: about 1:1, and a terminal you cannot read. Harmless while `dynamic` was + * something a user chose deliberately, which is the only reason it went unnoticed; + * as the default theme it would have been the first thing a new install saw. + * + * Returns null below API 31, where none of these exist, and on any failure, so a + * device that cannot answer falls back to the theme's own colours rather than to + * something unreadable. + */ private fun dynamicTerminalColors(): Triple? { if (android.os.Build.VERSION.SDK_INT < 31) return null return try { Triple( - getColor(android.R.color.system_neutral1_1000), - getColor(android.R.color.system_neutral1_900), - getColor(android.R.color.system_neutral1_100) + getColor(android.R.color.system_neutral1_1000), // background: the dark end + getColor(android.R.color.system_neutral1_900), // extra keys, one step up + getColor(android.R.color.system_neutral1_50) // text: the light end ) } catch (_: Exception) { null @@ -2175,7 +2817,7 @@ class TerminalActivity : AppCompatActivity() { } findViewById(R.id.panel_record).apply { setOnClickListener { toggleRecording() } - setCardButtonBg(this, recordingRequested(distroName)) + setCardButtonBg(this, isRecordingCurrentSession()) } findViewById(R.id.panel_split).setOnClickListener { toggleSplit() } @@ -2205,13 +2847,17 @@ class TerminalActivity : AppCompatActivity() { private fun toggleQuickPanel() { panelVisible = !panelVisible findViewById(R.id.quick_panel).visibility = if (panelVisible) android.view.View.VISIBLE else android.view.View.GONE - // Re-read on every reveal: the request is consumed by the next session, so the - // button has to stop showing as armed the moment that happens. + // Re-read on every reveal: recording tracks the session in front of the user, + // so a switch to a different one must show that one's state, not the last. findViewById(R.id.panel_record)?.let { button -> - setCardButtonBg(button, recordingRequested(distroName)) + setCardButtonBg(button, isRecordingCurrentSession()) } } + /** Whether what is on screen right now is being recorded. */ + private fun isRecordingCurrentSession(): Boolean = + terminalView.mTermSession?.let { recorders.containsKey(it) } ?: false + private fun setCardButtonBg(tv: TextView, active: Boolean) { tv.setBackgroundColor(if (active) 0xFF45475A.toInt() else 0x33000000) tv.setTextColor(if (active) 0xFF89B4FA.toInt() else tc(R.attr.terminalText, 0xFFCDD6F4.toInt())) @@ -2223,6 +2869,18 @@ class TerminalActivity : AppCompatActivity() { android.R.id.home -> { finish(); true } 1 -> { drawerLayout.openDrawer(GravityCompat.START); true } 2 -> { promptForNewSession(); true } + 121 -> { + (sessionModel.descriptorFor(session) as? SessionDescriptor.Ssh)?.let { + reconnectWithTransport(it, Transport.Mosh) + } + true + } + 122 -> { + (sessionModel.descriptorFor(session) as? SessionDescriptor.Ssh)?.let { + reconnectWithTransport(it, Transport.Ssh) + } + true + } 3 -> { currentFontSize = (currentFontSize + 2).coerceAtMost(36); terminalView.setTextSize(currentFontSize); true } 4 -> { currentFontSize = (currentFontSize - 2).coerceAtLeast(8); terminalView.setTextSize(currentFontSize); true } 5 -> { @@ -2272,51 +2930,22 @@ class TerminalActivity : AppCompatActivity() { } } - private val fontCache = HashMap() + private fun customFontFiles(): List = File(filesDir, "fonts").listFiles { f -> f.isFile && (f.extension.equals("ttf", true) || f.extension.equals("otf", true)) }?.sortedBy { it.name.lowercase() } ?: emptyList() - private fun loadFont(assetPath: String): android.graphics.Typeface? = - fontCache.getOrPut(assetPath) { - try { - android.graphics.Typeface.createFromAsset(assets, assetPath) - } catch (_: Exception) { - null - } - } + private fun applyFontFromPrefs(prefs: android.content.SharedPreferences) { val tf = fontFromPrefs(prefs) terminalView.setTypeface(tf) } - private fun fontFromPrefs(prefs: android.content.SharedPreferences): android.graphics.Typeface { - val fontName = prefs.getString("font", "monospace") - val tf = when { - fontName != null && fontName.startsWith("custom:") -> - try { - android.graphics.Typeface.createFromFile( - File(filesDir, "fonts/${fontName.removePrefix("custom:")}") - ) - } catch (_: Exception) { - null - } - else -> when (fontName) { - "JetBrains Mono" -> loadFont("fonts/JetBrainsMono.ttf") - "Fira Code" -> loadFont("fonts/FiraCode.ttf") - "Source Code Pro" -> loadFont("fonts/SourceCodePro.ttf") - "Ubuntu Mono" -> loadFont("fonts/UbuntuMono.ttf") - "Droid Sans Mono" -> loadFont("fonts/DroidSansMono.ttf") - "Noto Sans Mono" -> loadFont("fonts/NotoSansMono.ttf") - "Cascadia Code" -> loadFont("fonts/CascadiaCode.ttf") - else -> android.graphics.Typeface.MONOSPACE - } - } - return tf ?: android.graphics.Typeface.MONOSPACE - } + private fun fontFromPrefs(prefs: android.content.SharedPreferences): android.graphics.Typeface = + TerminalTheming.fontFromPrefs(this, prefs) private fun applyFontToView(view: com.termux.view.TerminalView, prefs: android.content.SharedPreferences) { view.setTypeface(fontFromPrefs(prefs)) diff --git a/app/src/main/java/com/redtermapp/ui/TerminalBackend.kt b/app/src/main/java/com/redtermapp/ui/TerminalBackend.kt index 07ba96f..1a8d8d7 100644 --- a/app/src/main/java/com/redtermapp/ui/TerminalBackend.kt +++ b/app/src/main/java/com/redtermapp/ui/TerminalBackend.kt @@ -43,12 +43,34 @@ class TerminalBackend( var onBellFired: (() -> Unit)? = null var onLinkTap: ((String, Boolean) -> Unit)? = null + // The child produced output. Invoked from onTextChanged, which the library fires + // only when the process has appended to the emulator — a resize does not fire it — + // so it is a faithful "the remote spoke" signal for a connecting overlay to stop on. + var onChildOutput: ((TerminalSession) -> Unit)? = null + + // A failed mosh session's report promises "Press Enter ... and open one over SSH", + // but the process that printed it is gone and nobody reads a dead session's stdin. + // The library offers the key before it types anything into the terminal, in the two + // places a keypress can arrive: onKeyDown for key events and onCodePoint for text + // committed by an input method. Returning true consumed the key; returning false + // leaves both paths untouched. + var onDeadSessionEnter: ((TerminalSession) -> Boolean)? = null + + // A recording is one snapshot per drawn frame, not one per appended byte: a burst + // of output fires onTextChanged many times inside one frame and only the state at + // the end of it is what the user sees. Posted on the same Choreographer callback + // that invalidates the view, so the snapshot and what appears on screen are the + // same state. [session] is the one whose append triggered the frame. + var onFrame: ((TerminalSession) -> Unit)? = null + override fun onTextChanged(session: TerminalSession) { + onChildOutput?.invoke(session) if (invalidatePending) return invalidatePending = true android.view.Choreographer.getInstance().postFrameCallback { invalidatePending = false view.invalidate() + onFrame?.invoke(session) } } @@ -189,6 +211,9 @@ class TerminalBackend( override fun copyModeChanged(copyMode: Boolean) {} override fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession): Boolean { + if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER) { + if (onDeadSessionEnter?.invoke(session) == true) return true + } val fKeySequence = when (keyCode) { KeyEvent.KEYCODE_F1 -> "\u001bOP" KeyEvent.KEYCODE_F2 -> "\u001bOQ" @@ -224,7 +249,15 @@ class TerminalBackend( fun setShift(v: Boolean) { shiftDown = v } fun setFn(v: Boolean) { fnDown = v } - override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession): Boolean = false + override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession): Boolean { + // An enter committed as text arrives here regardless of which keyboard sent + // it; the library itself normalises LF to CR on the way in, so both spellings + // are accepted. + if (codePoint == '\n'.code || codePoint == '\r'.code) { + if (onDeadSessionEnter?.invoke(session) == true) return true + } + return false + } override fun onEmulatorSet() {} diff --git a/app/src/main/java/com/redtermapp/ui/TerminalFrameEncoder.kt b/app/src/main/java/com/redtermapp/ui/TerminalFrameEncoder.kt new file mode 100644 index 0000000..074fae2 --- /dev/null +++ b/app/src/main/java/com/redtermapp/ui/TerminalFrameEncoder.kt @@ -0,0 +1,282 @@ +package com.redtermapp.ui + +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalRow +import com.termux.terminal.TextStyle +import com.termux.terminal.WcWidth + +/** + * Encodes one snapshot of the emulator's visible screen as plain ANSI. + * + * The terminal library exposes no hook on what a session *writes* — its byte-level + * entry point only feeds the process stdin — so a recording is a sequence of screen + * dumps: every drawn frame is re-encoded from the same `TerminalRow` data the + * renderer reads, and the player reconstructs it by feeding the ANSI back into an + * emulator of its own. Colour indexes and attributes travel as SGR, so a file made + * here replays through whatever palette the playing emulator holds; in this app that + * is the same theme machinery, so a themed recording stays coherent. + * + * The decode side is `TextStyle.decodeForeColor/decodeBackColor/decodeEffect` — the + * ground truth, checked against its bytecode: a palette index ≤ 255, the default + * fg/bg sentinels 256/257, and eight attribute bits sharing one effects word with + * the two truecolour markers. The cell walk mirrors `TerminalRenderer.render`: + * cells are read through a running `mText` offset (combining marks travel with + * their base character), a double-width character consumes two columns, and the + * `0` character in the second half of a wide cell is geometry rather than text. + * + * Two stream-shaping rules decide the rest: + * - **State is tracked, not assumed.** Codes are the delta from the state the last + * run left the emulator in. A default run following a coloured one must print an + * explicit reset, or the spaces it travels with render in the earlier colour. + * - **A default space and an unwritten cell paint identically.** Trailing + * default-styled spaces are not written (so a whole blank row contributes + * nothing), and a blank-default run in the middle of a row is written as bare + * spaces — the cursor advance is what those spaces are *for*. + */ +object TerminalFrameEncoder { + + private const val ESC = 27.toChar() + private const val SPACE = ' ' + + // Attribute bits, as `decodeEffect` returns them: the attributes share the + // effects word with the two truecolour markers, which is why everything needed + // sits inside one 11-bit mask (2047) rather than in separate fields. + private const val ATTR_BOLD = TextStyle.CHARACTER_ATTRIBUTE_BOLD + private const val ATTR_ITALIC = TextStyle.CHARACTER_ATTRIBUTE_ITALIC + private const val ATTR_UNDERLINE = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE + private const val ATTR_BLINK = TextStyle.CHARACTER_ATTRIBUTE_BLINK + private const val ATTR_INVERSE = TextStyle.CHARACTER_ATTRIBUTE_INVERSE + private const val ATTR_INVISIBLE = TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE + private const val ATTR_STRIKETHROUGH = TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH + private const val ATTR_DIM = 1 shl 8 + private const val EFFECT_TRUECOLOR_FG = 1 shl 9 + private const val EFFECT_TRUECOLOR_BG = 1 shl 10 + + /** SGR word for each attribute bit, in canonical enable order. */ + private val attributeWords = arrayOf( + intArrayOf(ATTR_BOLD, 1), + intArrayOf(ATTR_DIM, 2), + intArrayOf(ATTR_ITALIC, 3), + intArrayOf(ATTR_UNDERLINE, 4), + intArrayOf(ATTR_BLINK, 5), + intArrayOf(ATTR_INVERSE, 7), + intArrayOf(ATTR_INVISIBLE, 8), + intArrayOf(ATTR_STRIKETHROUGH, 9) + ) + + private const val FG_DEFAULT = "39" + private const val BG_DEFAULT = "49" + + /** Default-foreground sentinel index inside a style word. */ + private const val COLOR_INDEX_FOREGROUND = 256 + /** Default-background sentinel index inside a style word. */ + private const val COLOR_INDEX_BACKGROUND = 257 + + /** What one run asks the emulator to be. */ + private data class Shape(val fg: String, val bg: String, val attrs: Int) + + private class Run(val shape: Shape, val text: StringBuilder) { + /** Everything default and nothing but spaces: pure cursor travel. */ + fun isBlankDefault(): Boolean { + if (shape.fg != FG_DEFAULT || shape.bg != BG_DEFAULT || shape.attrs != 0) return false + for (i in 0 until text.length) if (text[i] != SPACE) return false + return true + } + } + + /** The frame's pending SGR state, and the stream it is being written into. */ + private class Writer { + val stream = StringBuilder() + var fg = FG_DEFAULT + var bg = BG_DEFAULT + var attrs = 0 // attribute bit mask actually in effect + + private fun isDefault(): Boolean = + fg == FG_DEFAULT && bg == BG_DEFAULT && attrs == 0 + + /** State after emitting [codes] for [want]: `want` exactly. */ + fun settle(want: Shape) { fg = want.fg; bg = want.bg; attrs = want.attrs } + + fun travel(count: Int) { + if (!isDefault()) { + // Blank-default travel landed in the middle of a coloured run's + // aftermath: without the reset the spaces render in that colour. + stream.append(ESC).append("[0m") + fg = FG_DEFAULT + bg = BG_DEFAULT + attrs = 0 + } + repeat(count) { stream.append(SPACE) } + } + } + + /** + * One frame, or null when the screen has no content at all. + * + * Rows are positioned (`ESC n;1H`) rather than walked line by line, so the + * output is independent of any viewer's wrap handling, and the frame opens + * with home + clear + reset so replay begins from known default state. + */ + fun encode(emulator: TerminalEmulator): String? { + val screen = emulator.getScreen() + val writer = Writer() + var lastContentRow = -1 + for (row in 0 until emulator.mRows) { + val runs = buildRuns(screen, screen.externalToInternalRow(row), emulator.mColumns) ?: continue + writer.stream.append(ESC).append('[').append(row + 1).append(";1H") + for (run in runs) { + if (run.isBlankDefault()) { + writer.travel(run.text.length) + continue + } + val codes = codesBetween(writer, run.shape) + if (codes.isNotEmpty()) { + writer.stream.append(ESC).append('[') + codes.forEachIndexed { index, code -> + if (index > 0) writer.stream.append(';') + writer.stream.append(code) + } + writer.stream.append('m') + } + writer.settle(run.shape) + writer.stream.append(run.text) + } + lastContentRow = row + } + if (lastContentRow < 0) return null + val frame = StringBuilder(writer.stream.length + 12) + frame.append(ESC).append("[H").append(ESC).append("[2J").append(ESC).append("[m") + frame.append(writer.stream) + return frame.toString() + } + + /** The codes that move the writer's current state into [want]. */ + private fun codesBetween(writer: Writer, want: Shape): List { + if (writer.fg == want.fg && writer.bg == want.bg && writer.attrs == want.attrs) { + return emptyList() + } + val codes = ArrayList(4) + val removed = writer.attrs and want.attrs.inv() + if (removed != 0) { + // Attributes cannot be switched off singly in SGR: reset everything, + // then list exactly what this run does want. + codes.add("0") + if (want.fg != FG_DEFAULT) codes.add(want.fg) + if (want.bg != BG_DEFAULT) codes.add(want.bg) + for (word in attributeWords) { + if (want.attrs and word[0] != 0) codes.add(word[1].toString()) + } + } else { + if (want.fg != writer.fg) codes.add(want.fg) + if (want.bg != writer.bg) codes.add(want.bg) + val added = want.attrs and writer.attrs.inv() + for (word in attributeWords) { + if (added and word[0] != 0) codes.add(word[1].toString()) + } + } + return codes + } + + /** + * The row's runs with trailing default-styled blanks resolved away, or null + * when nothing on it is worth writing. + */ + private fun buildRuns( + screen: com.termux.terminal.TerminalBuffer, + internalRow: Int, + columns: Int + ): List? { + val row: TerminalRow = screen.allocateFullLineIfNecessary(internalRow) + val text = row.mText + val runs = ArrayList(columns / 4 + 1) + var offset = 0 + var written = 0 + while (written < columns && offset < text.size) { + val c = text[offset] + val charLen = if (Character.isHighSurrogate(c)) 2 else 1 + val codePoint = if (charLen == 2 && offset + 1 < text.size) { + Character.toCodePoint(c, text[offset + 1]) + } else { + c.code + } + val style = row.getStyle(written) + if (codePoint == 0) { + // Second half of a wide cell: geometry, no text of its own. + offset += 1 + written += 1 + continue + } + val cell = StringBuilder(4) + cell.appendCodePoint(codePoint) + var end = offset + charLen + // Zero-width characters stored after the base belong to this cell. + while (end < text.size && WcWidth.width(text[end].code) <= 0) { + cell.append(text[end]) + end++ + } + offset = end + written += maxOf(1, WcWidth.width(codePoint)) + if (TextStyle.decodeEffect(style) and ATTR_INVISIBLE != 0) continue + val shape = shapeOf(style) + val last = runs.lastOrNull() + if (last != null && last.shape == shape) { + last.text.append(cell) + } else { + runs.add(Run(shape, cell)) + } + } + + // Trailing cells are left as the clear made them: whole trailing blank + // default runs go, then a trailing default run's unused spaces. + while (runs.isNotEmpty() && runs.last().isBlankDefault()) runs.removeAt(runs.size - 1) + runs.lastOrNull()?.let { last -> + if (isDefaultShape(last.shape)) { + var len = last.text.length + while (len > 0 && last.text[len - 1] == SPACE) len-- + last.text.setLength(len) + } + } + return if (runs.isEmpty()) null else runs + } + + /** Everything a style word says, as the three things one run's SGR would set. */ + private fun shapeOf(style: Long): Shape { + val effects = TextStyle.decodeEffect(style) + val attrs = run { + var mask = 0 + for (word in attributeWords) if (effects and word[0] != 0) mask = mask or word[0] + mask + } + return Shape(foregroundCode(style, effects), backgroundCode(style, effects), attrs) + } + + private fun isDefaultShape(shape: Shape): Boolean = + shape.fg == FG_DEFAULT && shape.bg == BG_DEFAULT && shape.attrs == 0 + + /** Foreground half of a style word as an SGR code ("31", "38;5;202", ...). */ + private fun foregroundCode(style: Long, effects: Int): String { + if (effects and EFFECT_TRUECOLOR_FG != 0) { + val rgb = TextStyle.decodeForeColor(style) and 0xFFFFFF + return "38;2;${(rgb shr 16) and 0xFF};${(rgb shr 8) and 0xFF};${rgb and 0xFF}" + } + val index = TextStyle.decodeForeColor(style) + // 256 and above name the emulator's own foreground/background/cursor + // sentinels rather than palette entries; they replay as the default colour. + if (index >= COLOR_INDEX_FOREGROUND) return FG_DEFAULT + return if (index < 8) (30 + index).toString() + else if (index < 16) (82 + index).toString() + else "38;5;$index" + } + + private fun backgroundCode(style: Long, effects: Int): String { + if (effects and EFFECT_TRUECOLOR_BG != 0) { + val rgb = TextStyle.decodeBackColor(style) and 0xFFFFFF + return "48;2;${(rgb shr 16) and 0xFF};${(rgb shr 8) and 0xFF};${rgb and 0xFF}" + } + val index = TextStyle.decodeBackColor(style) + if (index >= COLOR_INDEX_BACKGROUND) return BG_DEFAULT + return if (index < 8) (40 + index).toString() + else if (index < 16) (92 + index).toString() + else "48;5;$index" + } +} diff --git a/app/src/main/java/com/redtermapp/ui/TerminalTheming.kt b/app/src/main/java/com/redtermapp/ui/TerminalTheming.kt new file mode 100644 index 0000000..e7a42fc --- /dev/null +++ b/app/src/main/java/com/redtermapp/ui/TerminalTheming.kt @@ -0,0 +1,107 @@ +package com.redtermapp.ui + +import android.content.Context +import android.graphics.Typeface +import com.redtermapp.R +import com.termux.view.TerminalView +import java.io.File + +/** + * The terminal's look — font and palette — applied from themes and preferences + * from one place, for every surface that shows a terminal view: sessions and + * split panes, and the recording player. + * + * The font helpers used to live next to TerminalActivity's session plumbing, and + * the player was left setting neither font nor palette: a view with no renderer + * crashes on its first layout (TerminalView's renderer is created by + * `setTextSize` and `setTypeface` and nothing else, and `updateSize` reads the + * renderer's font width guarded only on width, height and session), and a replay + * drew in the library's default palette whatever the app was themed to. + */ +object TerminalTheming { + + private val fontCache = HashMap() + + /** The theme's terminal foreground and background, for the paths that are not a theme switch. */ + fun themedColours(activity: android.app.Activity): Pair { + fun color(attr: Int, default: Int): Int { + val ta = activity.theme.obtainStyledAttributes(intArrayOf(attr)) + val c = ta.getColor(0, default) + ta.recycle() + return c + } + return Pair( + color(R.attr.terminalText, 0xFFCDD6F4.toInt()), + color(R.attr.terminalBg, 0xFF1E1E2E.toInt()) + ) + } + + /** + * Puts the current theme's palette onto [views]. + * + * The view's background is not enough on its own: a session draws its text with + * colours held in its own palette inside the terminal library, and nothing the + * layout sets reaches them. Applied after every attach rather than once, + * because a palette belongs to a session: switching to one that was started + * before the theme changed would otherwise keep the colours it launched with. + */ + fun applyTerminalColours( + foreground: Int, + background: Int, + vararg views: TerminalView + ) { + for (view in views) { + view.setBackgroundColor(background) + val emulator = view.mEmulator ?: continue + applyPalette(foreground, background, emulator) + view.mTermSession?.onColorsChanged() + view.onScreenUpdated() + } + } + + /** The 259-colour palette the emulator draws text with, built from a theme's two colours. */ + fun applyPalette( + foreground: Int, + background: Int, + emulator: com.termux.terminal.TerminalEmulator + ) { + val palette = TerminalPalette.build(foreground, background) + val current = emulator.mColors.mCurrentColors + val shared = minOf(current.size, palette.size, TerminalPalette.SIZE) + for (i in 0 until shared) current[i] = palette[i] + } + + fun fontFromPrefs(context: Context, prefs: android.content.SharedPreferences): Typeface { + val fontName = prefs.getString("font", "monospace") + val tf = when { + fontName != null && fontName.startsWith("custom:") -> + try { + Typeface.createFromFile( + File(context.filesDir, "fonts/${fontName.removePrefix("custom:")}") + ) + } catch (_: Exception) { + null + } + else -> when (fontName) { + "JetBrains Mono" -> loadFont(context, "fonts/JetBrainsMono.ttf") + "Fira Code" -> loadFont(context, "fonts/FiraCode.ttf") + "Source Code Pro" -> loadFont(context, "fonts/SourceCodePro.ttf") + "Ubuntu Mono" -> loadFont(context, "fonts/UbuntuMono.ttf") + "Droid Sans Mono" -> loadFont(context, "fonts/DroidSansMono.ttf") + "Noto Sans Mono" -> loadFont(context, "fonts/NotoSansMono.ttf") + "Cascadia Code" -> loadFont(context, "fonts/CascadiaCode.ttf") + else -> Typeface.MONOSPACE + } + } + return tf ?: Typeface.MONOSPACE + } + + private fun loadFont(context: Context, assetPath: String): Typeface? = + fontCache.getOrPut(assetPath) { + try { + Typeface.createFromAsset(context.assets, assetPath) + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/redtermapp/ui/ToolOutputDialog.kt b/app/src/main/java/com/redtermapp/ui/ToolOutputDialog.kt index d8b4e01..8379e98 100644 --- a/app/src/main/java/com/redtermapp/ui/ToolOutputDialog.kt +++ b/app/src/main/java/com/redtermapp/ui/ToolOutputDialog.kt @@ -1,9 +1,9 @@ package com.redtermapp.ui +import android.content.ClipData import android.view.ViewGroup import android.content.Context import android.graphics.Typeface -import android.text.method.ScrollingMovementMethod import android.util.TypedValue import android.widget.TextView import androidx.appcompat.app.AlertDialog @@ -29,13 +29,16 @@ object ToolOutputDialog { val pad = (20 * context.resources.displayMetrics.density).toInt() val body = TextView(context).apply { this.text = text - setTextIsSelectable(true) // Tool output is never a paragraph, so a fixed pitch line keeps paths and // flags lined up instead of wrapping into something unreadable. typeface = Typeface.MONOSPACE setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) setPadding(pad, pad / 2, pad, pad / 2) - movementMethod = ScrollingMovementMethod() + // Selectable LAST, and no movement method of our own. setTextIsSelectable + // installs the movement method selection needs; overwriting it afterwards + // with ScrollingMovementMethod is what stopped long-press selection from + // working, and the NestedScrollView in show() does the scrolling anyway. + setTextIsSelectable(true) } return body } @@ -45,6 +48,10 @@ object ToolOutputDialog { * * Long output is scrolled rather than allowed to grow past the screen: a failure * message of two hundred lines in a wrap-around `TextView` is a wall, not a report. + * + * A Copy button is on the dialog as well as selectable text: one tap puts the + * whole output on the clipboard, without depending on long-press selection + * working on every device. */ fun show(context: Context, title: CharSequence, text: CharSequence) { val body = view(context, text) @@ -61,6 +68,16 @@ object ToolOutputDialog { .setTitle(title) .setView(scroll) .setPositiveButton(R.string.ok, null) + .setNeutralButton(R.string.label_copy) { _, _ -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) + as? android.content.ClipboardManager + if (clipboard != null) { + clipboard.setPrimaryClip(ClipData.newPlainText("output", text)) + android.widget.Toast.makeText( + context, R.string.copied, android.widget.Toast.LENGTH_SHORT + ).show() + } + } .show() } } \ No newline at end of file diff --git a/app/src/main/java/com/redtermapp/ui/filelist/BaseFileBrowserActivity.kt b/app/src/main/java/com/redtermapp/ui/filelist/BaseFileBrowserActivity.kt index f73adea..f9636a1 100644 --- a/app/src/main/java/com/redtermapp/ui/filelist/BaseFileBrowserActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/filelist/BaseFileBrowserActivity.kt @@ -110,6 +110,20 @@ abstract class BaseFileBrowserActivity : AppCompatActivity() { if (requested != null && isDirectory(requested)) loadDir(requested) else loadHome() } + /** + * Called on the main thread when a directory load is about to touch the source + * on a worker, and again when the result or the failure has been handled. + * + * A remote source's first load opens its ssh connection inside the call, which + * is seconds of a blank list. Subclasses that want to say so will find here the + * two moments the connection can be established; every load passes through + * loadHome or loadDir, and every exit — result, refusal or an exception reaching + * the background catch — passes through [onLoadEnded]. + */ + protected open fun onLoadStarting() {} + + protected open fun onLoadEnded() {} + /** The path the caller asked for, if any. */ protected open fun initialPath(): String? = null @@ -123,9 +137,13 @@ abstract class BaseFileBrowserActivity : AppCompatActivity() { protected fun loadHome() { hideSearchBar() searching = false + onLoadStarting() background { val home = source().home - main { loadDir(home) } + main { + onLoadEnded() + loadDir(home) + } } } @@ -172,12 +190,14 @@ abstract class BaseFileBrowserActivity : AppCompatActivity() { if (suffix.isNotEmpty()) append(" · ").append(suffix) } + onLoadStarting() background { val entries = source().list(path) val visible = entries ?.filter { showHidden || !it.isHidden } ?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) main { + onLoadEnded() if (entries == null) { reportError(source().lastError) // Keep the previous listing: replacing it with nothing would @@ -711,7 +731,13 @@ abstract class BaseFileBrowserActivity : AppCompatActivity() { try { work() } catch (e: Exception) { - main { reportError(e.message ?: e.javaClass.simpleName) } + main { + // An unexpected nothing — not one of the refusals a source + // reports as a null listing, so the load's own main block never + // ran — must still count the load as over. + onLoadEnded() + reportError(e.message ?: e.javaClass.simpleName) + } } }, name).start() } diff --git a/app/src/main/java/com/redtermapp/ui/filelist/SftpBrowserActivity.kt b/app/src/main/java/com/redtermapp/ui/filelist/SftpBrowserActivity.kt index dfb6ea2..299fee1 100644 --- a/app/src/main/java/com/redtermapp/ui/filelist/SftpBrowserActivity.kt +++ b/app/src/main/java/com/redtermapp/ui/filelist/SftpBrowserActivity.kt @@ -3,10 +3,12 @@ package com.redtermapp.ui.filelist import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns +import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import com.redtermapp.R +import com.redtermapp.ui.ConnectingOverlay import com.redtermapp.ui.SshStore import com.redtermapp.util.AppLog import java.io.File @@ -39,6 +41,17 @@ class SftpBrowserActivity : BaseFileBrowserActivity() { private var transferThread: Thread? = null private var transferDialog: AlertDialog? = null + /** + * The same overlay a connecting session shows, over this screen instead of the + * terminal: the first listing pays for a full ssh connection, and until it is up + * the list below is empty — which reads identically to a folder that has nothing + * in it. The scrim parent is the activity's own content, so it covers the + * toolbar as well. + */ + private val connecting by lazy { + ConnectingOverlay(this, window.decorView.findViewById(android.R.id.content) as ViewGroup) + } + override fun onCreate(savedInstanceState: Bundle?) { serverId = intent?.getStringExtra(EXTRA_SERVER_ID).orEmpty() keyId = intent?.getStringExtra(EXTRA_KEY_ID)?.takeIf { it.isNotBlank() } @@ -74,6 +87,26 @@ class SftpBrowserActivity : BaseFileBrowserActivity() { override fun source(): FileSource = sftpSource + /** + * Shows the connecting overlay before a load that is about to dial, hides it for + * every load that just ended. + * + * Only the first touch pays for an ssh connection: later loads ride the one + * connection the browser keeps open, and a progress screen in front of a cached + * listing would be a flash for nothing. When that connection has dropped, the + * retry dial gets the overlay again, because [SftpSource.hasConnection] answers + * false until the new one is up. + */ + override fun onLoadStarting() { + if (!sftpSource.hasConnection) { + connecting.show(getString(R.string.sftp_connecting, server.label)) + } + } + + override fun onLoadEnded() { + connecting.hide() + } + override fun initialPath(): String? = startPath override fun titleSuffix(): String = server.label diff --git a/app/src/main/java/com/redtermapp/ui/filelist/SftpSource.kt b/app/src/main/java/com/redtermapp/ui/filelist/SftpSource.kt index 739b499..b706cde 100644 --- a/app/src/main/java/com/redtermapp/ui/filelist/SftpSource.kt +++ b/app/src/main/java/com/redtermapp/ui/filelist/SftpSource.kt @@ -16,11 +16,11 @@ import java.io.File /** * A server's filesystem, reached over SFTP. * - * Every call opens a fresh connection and closes it again. Reusing one connection - * would be faster for a long browse, but a session that dies mid-browse then takes - * the whole screen with it, and a transfer on a big file holds the connection for - * a long time. One short-lived connection per operation keeps each failure - * contained and each operation independently retryable. + * The browser keeps one connection open for its whole life rather than dialling per + * operation: a per-operation dial made every listing pay a full ssh handshake and + * re-asked the passphrase question each time. A dying session still does not take + * the screen with it — a lost connection is retried once here on a fresh one, and + * the failure is handed back for the caller to show. */ class SftpSource( private val activity: android.app.Activity, @@ -32,8 +32,26 @@ class SftpSource( /** The one connection the browser keeps open for its whole life. */ private val connectionLock = Object() + + // Volatile so `hasConnection` can answer without taking [connectionLock]: that + // lock is held (on a worker) for the whole ssh dial and for every transfer, and + // the browser asks [hasConnection] on the main thread before starting a load — + // synchronising there could freeze the UI for as long as a big transfer runs. + // The reference is written under the lock and read unlocked; a stale answer is + // harmless, because the next call dials if it has to. + @Volatile private var connection: SftpClient? = null + /** + * Whether the browser's connection is already up. + * + * Read off the main thread between loads to decide whether the next load is + * about to pay for a dial. A false answer does not mean one cannot be made: + * the next call dials if it can. + */ + val hasConnection: Boolean + get() = connection != null + /** * The keys to authenticate with, as seen inside the client rootfs. * diff --git a/app/src/main/java/com/redtermapp/util/FontImport.kt b/app/src/main/java/com/redtermapp/util/FontImport.kt new file mode 100644 index 0000000..de30b7d --- /dev/null +++ b/app/src/main/java/com/redtermapp/util/FontImport.kt @@ -0,0 +1,32 @@ +package com.redtermapp.util + +/** + * What counts as a font once it has been picked. + * + * The document picker filters by MIME, and different providers report the same font + * under different names — `font/ttf` from the system picker, `application/x-font-ttf` + * or `application/font-sfnt` from third-party file managers, and plain + * `application/octet-stream` for anything a browser downloaded. A MIME filter built + * on the font wildcard therefore matched nothing on many devices: the folder opened + * and listed no files at all, so no font could ever be selected. The picker is + * opened unfiltered instead, and the picked names are validated here — the one + * place the provider is on record about what the file is called. + */ +object FontImport { + /** Single-face fonts only, matching what the terminal actually lists and loads. */ + val EXTENSIONS = setOf("ttf", "otf") + + /** + * Only the final extension decides. A provider may hand the full document id + * across as the name, including its owner prefix and directories, and a file + * masquerading as a font (`a.ttf.jpg`) is still not a font. + */ + fun isSupportedFontFile(name: String?): Boolean { + val ext = name?.substringAfterLast('.', "")?.lowercase() ?: return false + return ext in EXTENSIONS + } + + /** Something readable in the rejection toast when the provider named nothing. */ + fun labelFor(name: String?): String = + name?.takeIf { it.isNotBlank() } ?: "(name unknown)" +} diff --git a/app/src/main/java/com/redtermapp/util/sftp/SshAskpass.kt b/app/src/main/java/com/redtermapp/util/sftp/SshAskpass.kt index edbf629..9370049 100644 --- a/app/src/main/java/com/redtermapp/util/sftp/SshAskpass.kt +++ b/app/src/main/java/com/redtermapp/util/sftp/SshAskpass.kt @@ -71,20 +71,9 @@ object SshAskpass { */ private val cached = HashMap() - /** - * Keys whose passphrase the user declined to supply, for this process. - * - * Without this, a key that is simply not the server's is asked about on every - * single operation: each folder listing and each transfer is a separate - * connection, so dismissing the dialog once simply means being asked again on - * the next one. Asked once and then left alone is the only behaviour that is - * not nagging. - */ - private val declined = HashSet() - @Synchronized fun remember(fileName: String, passphrase: String) { - rememberSlot(slot(KIND_PASSPHRASE, fileName), passphrase) + cached[slot(KIND_PASSPHRASE, fileName)] = passphrase } /** @@ -97,41 +86,16 @@ object SshAskpass { */ @Synchronized fun rememberPassword(serverId: String, password: String) { - rememberSlot(slot(KIND_PASSWORD, serverId), password) + cached[slot(KIND_PASSWORD, serverId)] = password } @Synchronized fun cachedPassword(serverId: String): String? = cached[slot(KIND_PASSWORD, serverId)] - private fun rememberSlot(slot: String, secret: String) { - cached[slot] = secret - declined.remove(slot) - } - - /** The user dismissed the prompt for this key. Do not ask again this session. */ - @Synchronized - fun decline(fileName: String) { - declined.add(slot(KIND_PASSPHRASE, fileName)) - } - - @Synchronized - fun declinePassword(serverId: String) { - declined.add(slot(KIND_PASSWORD, serverId)) - } - - @Synchronized - fun isDeclined(fileName: String): Boolean = - slot(KIND_PASSPHRASE, fileName) in declined - - @Synchronized - fun isPasswordDeclined(serverId: String): Boolean = - slot(KIND_PASSWORD, serverId) in declined - - /** Forgets both phrases and declines, e.g. when the key list changes. */ + /** Forgets every held phrase, e.g. when the key list changes. */ @Synchronized fun forgetAll() { cached.clear() - declined.clear() } @Synchronized @@ -140,18 +104,13 @@ object SshAskpass { /** Used when a key is deleted, so a stale phrase cannot linger in memory. */ @Synchronized fun forget(fileName: String) { - forgetSlot(slot(KIND_PASSPHRASE, fileName)) + cached.remove(slot(KIND_PASSPHRASE, fileName)) } /** Used when a server is deleted. */ @Synchronized fun forgetPassword(serverId: String) { - forgetSlot(slot(KIND_PASSWORD, serverId)) - } - - private fun forgetSlot(slot: String) { - cached.remove(slot) - declined.remove(slot) + cached.remove(slot(KIND_PASSWORD, serverId)) } /** diff --git a/app/src/main/jniLibs/arm64-v8a/libredtermmosh.so b/app/src/main/jniLibs/arm64-v8a/libredtermmosh.so new file mode 100755 index 0000000..e61dcf4 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libredtermmosh.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libredtermmosh.so b/app/src/main/jniLibs/armeabi-v7a/libredtermmosh.so new file mode 100755 index 0000000..b279502 Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libredtermmosh.so differ diff --git a/app/src/main/jniLibs/x86/libredtermmosh.so b/app/src/main/jniLibs/x86/libredtermmosh.so new file mode 100755 index 0000000..85eb813 Binary files /dev/null and b/app/src/main/jniLibs/x86/libredtermmosh.so differ diff --git a/app/src/main/jniLibs/x86_64/libredtermmosh.so b/app/src/main/jniLibs/x86_64/libredtermmosh.so new file mode 100755 index 0000000..e7acfb7 Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libredtermmosh.so differ diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 16ddc9b..dce40c7 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -4,4 +4,14 @@ + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index fa47a54..f9a43d0 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -40,6 +40,14 @@ #FF1E1E2E #FFE0E0E0 #FF1E1E2E + + #FF2563A8 #FF282A36 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e94b908..263af45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,16 @@ Font Size 8 40 + + Font imported + %1$d fonts imported + + + %1$d imported, skipped: %2$s + %1$d imported, skipped: %2$s + + Not a font file: %1$s + Import failed Terminal Scrollback lines Reopen sessions after a restart @@ -198,9 +208,10 @@ Generate key pair Key created Recordings - %1$s has no \"script\" utility, so ANSI output cannot be captured. Install util-linux in that distro. Record - Recording starts with the next session opened for this distribution, and stops when you exit its shell. It cannot start part-way through the one already open. + No session to record + Recording this session + Recording saved to Recordings Recordings cannot be written to. Storage is unavailable on this device. No recordings yet Play @@ -220,7 +231,7 @@ 📁 %1$s %2$s 📄 %1$s %2$s %1$s · %2$s - %1$s@%2$s:%3$d + %1$s@%2$s %1$d Tools Installed image: %1$s @@ -324,6 +335,24 @@ Keepalive seconds (0 = off) Missed keepalives before giving up Compress the connection + Use mosh instead of SSH, when it is available + Needs mosh-server installed on the host and UDP 60000–61000 open to it. If mosh cannot start, the session uses SSH instead. + mosh + Connection + " \u00b7 mosh" + " \u00b7 ssh" + Reconnect using mosh + Reconnect using SSH + Opening %1$s over SSH instead — %2$s + %1$s is no longer in the server list. + Connecting to %1$s over mosh\u2026 + Connecting to %1$s over ssh\u2026 + Connecting to %1$s\u2026 + Mosh could not reach this server + mosh-server started on %1$s, but no UDP reply ever arrived. The session needs UDP 60000–61000 open to this device, and most firewalls allow TCP 22 for SSH and nothing else.\n\nYou can open this server over SSH instead. + Open SSH, stop using mosh + Open SSH this time + The mosh connection to this server ended (status %1$d). Forward this device\'s keys to the remote host Ask before trusting a new host key Connect through another host @@ -358,13 +387,17 @@ This server does not report free space. SFTP Unlock %1$s - The server refused this key. If it has a passphrase, enter it here: file transfer and this check have no terminal to type it into. It is kept only while the app is running. + The server refused this key. Type its passphrase if the key has one — file transfers and the connection check have no terminal to type into, so the passphrase is asked for here and kept in memory only while the app is running. Protected by a passphrase SSH session closed The server closed the connection before the session started. Usually the key it was given is not one it accepts, or the passphrase was declined. The session ended with status %1$d. Server: %1$s - The key for this server has a passphrase. This check has no terminal to type it into, so it can only pass for a key without one. + Authenticated to %1$s as %2$s. + Method: key %1$s + Method: password + Server software: %1$s + The server refused this key. If the key has a passphrase, a missed or mistyped answer to the app\'s prompt looks exactly like this; otherwise the key may not be installed on the server. That server is no longer saved. Download to device Upload file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index e3b19e9..dfeeeb2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,6 +2,7 @@ """, + RegexOption.DOT_MATCHES_ALL, + ).findAll(file.readText()).map { it.groupValues[1] to it.groupValues[2] }.toList() + } + + /** The colour a theme declares for [attr], resolved through colors.xml. */ + private fun resolve(theme: String, body: String, attr: String): Int { + val ref = Regex("""@color/([a-z_]+)""") + .find(body)?.groupValues?.get(1) + ?: error("$theme does not set $attr to a @color reference") + val value = colors()[ref] ?: error("$theme: $attr points at @color/$ref, not in colors.xml") + return value + } + + /** + * Every theme's text has to be readable on that theme's own card. + * + * Resolved per theme rather than from a `_extra_keys_text` naming convention, + * because the Custom theme declares the default theme's colours and is overridden at + * runtime: there is no `custom_extra_keys_bg` to look up, and a test that derived a + * prefix from the theme's name would fail on it for no reason. + */ + @Test + fun `every theme's text is readable on its own card`() { + val themes = themes() + assertTrue("expected several themes, found ${themes.size}", themes.size >= 8) + for ((name, body) in themes) { + val bg = resolve(name, body, "extraKeysBg") + val text = resolve(name, body, "extraKeysText") + val ratio = DistroBrand.contrastOf(text, bg) + assertTrue( + "$name: text ${hex(text)} on card ${hex(bg)} is " + + "${ratio.oneDecimal()}:1, below the 4.5:1 body text needs", + ratio >= 4.5, + ) + } + } + + /** + * Every theme's accent has to be readable on that theme's own card. + * + * Chips draw their text in the accent, so an accent that is fine as a filled button + * with white text can still be unreadable at 11sp. The light theme's primary was + * exactly that: 2.53:1 against its own card, and invisible as a chip label. + */ + @Test + fun `every theme's accent is readable on its own card`() { + for ((name, body) in themes()) { + val bg = resolve(name, body, "extraKeysBg") + val accent = resolve(name, body, "accentColor") + val ratio = DistroBrand.contrastOf(accent, bg) + assertTrue( + "$name: accent ${hex(accent)} on card ${hex(bg)} is " + + "${ratio.oneDecimal()}:1, below the 4.5:1 a chip label needs", + ratio >= 4.5, + ) + } + } + + /** + * Every theme declares every attribute the code-built screens read. + * + * This is the mechanism behind the original bug rather than its cause. A theme that + * omits one does not fail — it silently falls back to a literal, which is how nine + * dark themes ended up drawing near-black labels on near-black cards with nothing + * anywhere reporting it. + */ + @Test + fun `every theme declares the attributes the screens read`() { + val missing = mutableListOf() + for ((name, body) in themes()) { + for (attr in listOf("extraKeysBg", "extraKeysText", "accentColor")) { + if (!Regex("""""").containsMatchIn(body)) { + missing += "$name:$attr" + } + } + } + assertEquals("themes missing an attribute the screens read: $missing", emptyList(), missing) + } + + /** No card colour in colors.xml is left without the text colour that goes with it. */ + @Test + fun `no card colour is left without its text`() { + val colors = colors() + val orphans = colors.keys.filter { it.endsWith("_extra_keys_bg") } + .map { it.removeSuffix("_extra_keys_bg") } + .filterNot { colors.containsKey("${it}_extra_keys_text") } + assertEquals("card colours with no text colour: $orphans", emptyList(), orphans) + } + + private fun Double.oneDecimal(): Double = Math.round(this * 10) / 10.0 + + private fun hex(color: Int): String = "#%06X".format(color and 0xFFFFFF) +} diff --git a/app/src/test/java/com/redtermapp/util/FontImportTest.kt b/app/src/test/java/com/redtermapp/util/FontImportTest.kt new file mode 100644 index 0000000..aa765cd --- /dev/null +++ b/app/src/test/java/com/redtermapp/util/FontImportTest.kt @@ -0,0 +1,83 @@ +package com.redtermapp.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Validation happens after the pick, so it is this function that decides whether + * the user ever gets a font. + * + * The document picker could not be trusted to pre-filter the way v2.2.0 assumed: + * a MIME filter built on the font wildcard matched only files whose provider + * reported a `font/ttf` MIME, while real fonts arrive reported as + * `application/x-font-ttf`, `application/font-sfnt` or plain + * `application/octet-stream` — so every folder with fonts in it showed up + * empty and nothing could be selected. The picker now opens unfiltered and every + * picked name is checked here instead. + */ +class FontImportTest { + + @Test + fun `every shipped font kind is accepted`() { + for (name in listOf( + "JetBrainsMono-Regular.ttf", + "ligated.otf" + )) assertTrue(name, FontImport.isSupportedFontFile(name)) + } + + @Test + fun `a font collection is not offered for import`() { + // ttf/otf are what the terminal lists and loads. A .ttc or .otc accepted at + // the picker was copied to filesDir/fonts and then appeared in no list and + // no spinner, with no way to remove it. + for (name in listOf( + "coptic.ttc", + "kanji-g3.otc" + )) assertFalse(name, FontImport.isSupportedFontFile(name)) + } + + @Test + fun `the extension is decided case-insensitively`() { + assertTrue(FontImport.isSupportedFontFile("font.TTF")) + assertTrue(FontImport.isSupportedFontFile("Font.Otf")) + assertTrue(FontImport.isSupportedFontFile("mono.Ttf")) + } + + @Test + fun `only the final extension decides`() { + // A masquerading file is rejected even when an earlier extension looks like + // a font: a.ttf.jpg never becomes a font by being imported. + assertFalse(FontImport.isSupportedFontFile("a.ttf.jpg")) + assertFalse(FontImport.isSupportedFontFile("archive.tar.gz")) + assertFalse(FontImport.isSupportedFontFile("capsule.mtf")) + } + + @Test + fun `a document id prefix around the name does not change anything`() { + // Some providers hand the whole document id across as the display name. + assertTrue( + FontImport.isSupportedFontFile("primary:Download/JetBrainsMono-Regular.ttf") + ) + assertFalse(FontImport.isSupportedFontFile("primary:Download/msf:100")) + } + + @Test + fun `nothing that is not a name is accepted`() { + assertFalse("null", FontImport.isSupportedFontFile(null)) + assertFalse("empty", FontImport.isSupportedFontFile("")) + assertFalse("no extension", FontImport.isSupportedFontFile("font")) + assertFalse("trailing dot", FontImport.isSupportedFontFile("font.")) + assertFalse("blank", FontImport.isSupportedFontFile(" ")) + } + + @Test + fun `a named rejection never renders as blank`() { + // The toast is the whole explanation for a skipped file; a blank label in it + // would tell the user nothing about which pick was dropped. + assertTrue(FontImport.labelFor("a.ttf.jpg").contains("a.ttf.jpg")) + assertFalse(FontImport.labelFor("").isEmpty()) + assertFalse(FontImport.labelFor(null).isEmpty()) + assertFalse(FontImport.labelFor(" ").isEmpty()) + } +} diff --git a/app/src/test/java/com/redtermapp/util/sftp/SshAskpassDeclineTest.kt b/app/src/test/java/com/redtermapp/util/sftp/SshAskpassDeclineTest.kt deleted file mode 100644 index 43c7cc2..0000000 --- a/app/src/test/java/com/redtermapp/util/sftp/SshAskpassDeclineTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.redtermapp.util.sftp - -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * Which keys may be asked about, and when. - * - * Every folder listing and every transfer is a separate connection, so anything that - * re-asks about a key on each one turns into being nagged for ever — the user - * dismisses the dialog and the next tap brings it straight back. - */ -class SshAskpassDeclineTest { - - private val key = "id_ed25519_oracle" - - /** The store is process-wide, so each test starts from a clean one. */ - @Before - fun reset() = SshAskpass.forgetAll() - - @Test - fun `a key with a phrase held is never asked about again`() { - SshAskpass.remember(key, "phrase-one") - assertEquals("phrase-one", SshAskpass.cached(key)) - assertFalse("a held phrase must not be askable", SshAskpass.isDeclined(key)) - } - - @Test - fun `declining a key stops it being asked about`() { - SshAskpass.decline(key) - assertTrue(SshAskpass.isDeclined(key)) - } - - /** Answering once clears the decline: the phrase works, so stop asking. */ - @Test - fun `supplying a phrase clears a previous decline`() { - SshAskpass.decline(key) - SshAskpass.remember(key, "phrase-two") - assertFalse(SshAskpass.isDeclined(key)) - assertEquals("phrase-two", SshAskpass.cached(key)) - } - - @Test - fun `a decline is forgotten with everything else`() { - SshAskpass.remember(key, "phrase") - SshAskpass.decline(key) - SshAskpass.forgetAll() - assertNull(SshAskpass.cached(key)) - assertFalse(SshAskpass.isDeclined(key)) - } - - /** One key being declined must not affect another. */ - @Test - fun `a decline applies only to the key it was given for`() { - SshAskpass.remember(key, "phrase") - SshAskpass.decline("id_ed25519_work") - assertTrue(SshAskpass.isDeclined("id_ed25519_work")) - assertFalse(SshAskpass.isDeclined(key)) - assertEquals("phrase", SshAskpass.cached(key)) - } - - @Test - fun `a key with nothing held and nothing declined has no phrase`() { - SshAskpass.decline("id_ed25519_never_seen") - assertNull(SshAskpass.cached("id_ed25519_never_seen")) - } -} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1a31fd1..2a66ec2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/native/build-mosh.sh b/native/build-mosh.sh new file mode 100755 index 0000000..ad56115 --- /dev/null +++ b/native/build-mosh.sh @@ -0,0 +1,621 @@ +#!/bin/bash +# Builds mosh's protocol libraries into libredtermmosh.so, one per ABI. +# +# Why native at all, rather than a Kotlin mosh client: mosh's State Synchronization +# Protocol is fiddly and getting it wrong is subtle, so the wire protocol comes from +# upstream's own C++ and the frontend is ours. +# +# What is NOT built: mosh-client, mosh-server and libmoshterminal's curses frontend. +# --disable-client does that, and nothing in the tree references TerminalDisplayInit, +# so the terminfo-dependent object is never pulled into the library. See the note on +# native/mosh_curses_stub/curses.h. +# +# What IS built, and why these versions: +# +# mosh 1.4.0 the wire protocol, statesync, terminal state, and the crypto. +# protobuf 3.21.12 mosh's three .proto files are optimize_for = LITE_RUNTIME, so +# only libprotobuf-lite is needed. The full C++ runtime and the +# protoc compiler are not. +# nettle 3.9.1 mosh's AES. NOT OpenSSL: OpenSSL 3.5 contributes 3393 symbols to +# the linked object against mosh's own 31, costs 7,837s to build for +# one ABI against nettle's 1,159s, and takes the .so from 978,496 +# bytes to 5,351,472. OpenSSH keeps its own OpenSSL and this script +# does not touch it. +# +# Install: app/src/main/jniLibs//libredtermmosh.so +# +# Usage: +# native/build-mosh.sh [--arch | --all] [--ndk ] +# [--jobs ] [--clean] +# +# Everything is written under $WORK first and installed only after a link succeeds, so +# a failed build cannot leave a half-written library in the APK. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +# Not under /tmp. Two reasons, and the second only became obvious by losing it: +# - /tmp is a 2.9 GB tmpfs on this host, and four ABIs of nettle, protobuf and +# mosh do not fit in that even one at a time; +# - it was wiped mid-build once, taking an hour of single-core cross-compilation +# with it. The tree is a cache keyed on a sha256 per source, so it rebuilds, but +# the cost is real on one core. +# Override with REDTERM_WORK, or TMPDIR if that is somewhere durable. +WORK="${REDUCTERM_WORK:-${TMPDIR:-/home/ubuntu/.cache}/redterm-mosh}" +OUTPUT_DIR="$REPO_ROOT/app/src/main/jniLibs" + +MOSH_VERSION=1.4.0 +PROTOBUF_VERSION=3.21.12 +# protobuf's release tag is "v21.12" while its version is "3.21.12" and its asset is +# named "protobuf-cpp-3.21.12.tar.gz". Interpolating one from the other is how the tag +# comes out as v3.21.12 and 404s. +PROTOBUF_TAG=21.12 +NETTLE_VERSION=3.9.1 + +API=24 +# 16384, not the 4096 that native/build-openssh.sh uses, and the difference is not +# cosmetic. An executable loaded by proot's -L has to be 4 KB aligned, because proot +# maps the target itself and a 16 KB-aligned ELF breaks it on a 4 KB-page device. A +# jniLibs .so is loaded by Android's own linker, which wants 16 KB for a device with +# 16 KB pages -- that is what the Aligned16KB lint check is about, and why +# libproot.so is built at 16384. +PAGE_SIZE=16384 + +# The executable wants the *other* alignment, and the reason is the loading mechanism +# rather than the file type. RedTerm already runs cross-built binaries (ssh, ssh-keygen) +# from assets/// through proot's -L loader, and proot maps the target itself, +# so those must be 4 KB aligned; a jniLibs .so is loaded by Android's own linker instead, +# which is why it wants 16 KB. Same source tree, two different requirements, one of them +# the opposite of the other. +BINARY_PAGE_SIZE=4096 + +# Assets use build-openssh.sh's directory names, which are not the jniLibs ones. +BINARY_OUTPUT_DIR="$REPO_ROOT/app/src/main/assets/mosh" + +JOBS="${REDUCTERM_JOBS:-2}" + +# Recorded and verified. build-openssh.sh records these without checking them, which +# means a changed tarball is only noticed by whatever breaks. +MOSH_SHA256=872e4b134e5df29c8933dff12350785054d2fd2839b5ae6b5587b14db1465ddd +PROTOBUF_SHA256=4eab9b524aa5913c6fffb20b2a8abf5ef7f95a80bc0701f3a6dbb4c607f73460 +NETTLE_SHA256=ccfeff981b0ca71bbd6fbcb054f407c60ffb644389a5be80d6716d5b550c6ce3 + +ARCHES=() +NDK="" +CLEAN=0 + +usage() { + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --all) ARCHES=(aarch64 armv7 x86_64 i686); shift ;; + --arch) ARCHES=("$2"); shift 2 ;; + --ndk) NDK="$2"; shift 2 ;; + --jobs) JOBS="$2"; shift 2 ;; + --clean) CLEAN=1; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown option: $1" >&2; usage 1 ;; + esac +done +[ ${#ARCHES[@]} -gt 0 ] || ARCHES=(aarch64) + +[ -n "$NDK" ] || NDK="${ANDROID_NDK_ROOT:-${ANDROID_NDK:-/opt/ndk/android-ndk-r27c}}" +[ -d "$NDK" ] || { echo "!! NDK not found: $NDK" >&2; exit 1; } + +TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64" +[ -d "$TOOLCHAIN" ] || TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$(uname -m)" +SYSROOT="$TOOLCHAIN/sysroot" +CLANG_VER="$(ls "$TOOLCHAIN/lib/clang" | sort -V | tail -1)" + +abi_for() { + case "$1" in + aarch64) echo "arm64-v8a" ;; + armv7) echo "armeabi-v7a" ;; + x86_64) echo "x86_64" ;; + i686) echo "x86" ;; + *) echo "unknown arch: $1" >&2; exit 2 ;; + esac +} + +asset_dir_for() { + case "$1" in + aarch64) echo "aarch64" ;; + armv7) echo "armv7a" ;; + x86_64) echo "x86_64" ;; + i686) echo "i686" ;; + *) echo "unknown arch: $1" >&2; exit 2 ;; + esac +} + +triple_for() { + case "$1" in + aarch64) echo "aarch64-linux-android$API" ;; + armv7) echo "armv7a-linux-androideabi$API" ;; + x86_64) echo "x86_64-linux-android$API" ;; + i686) echo "i686-linux-android$API" ;; + esac +} + +# Without --host, configure assumes a native build and tries to run its test +# binaries, which fails for every Android ABI. +host_for() { + triple_for "$1" | sed "s/-android$API\$/-android/; s/-androideabi$API\$/-androideabi/" +} + +apidir_for() { + case "$1" in + aarch64) echo "aarch64-linux-android" ;; + armv7) echo "arm-linux-androideabi" ;; + x86_64) echo "x86_64-linux-android" ;; + i686) echo "i686-linux-android" ;; + esac +} + +say() { printf '\n=== %s ===\n' "$*"; } + +# ------------------------------------------------------------------ sources + +fetch_sources() { + mkdir -p "$WORK/src" "$WORK/logs" + if [ "$CLEAN" = 1 ]; then + echo "--clean: discarding the cached tree" + rm -rf "$WORK"/* + mkdir -p "$WORK/src" "$WORK/logs" + fi + + # Each is fetched once into a shared name, verified, and unpacked only if absent. + # The root directory each tarball produces is named what the build expects, which + # is not the same as the download name for protobuf. + local pair + for pair in \ + "mosh-$MOSH_VERSION https://github.com/mobile-shell/mosh/releases/download/mosh-$MOSH_VERSION/mosh-$MOSH_VERSION.tar.gz $MOSH_SHA256" \ + "protobuf-$PROTOBUF_VERSION https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOBUF_TAG/protobuf-cpp-$PROTOBUF_VERSION.tar.gz $PROTOBUF_SHA256" \ + "nettle-$NETTLE_VERSION https://ftp.gnu.org/gnu/nettle/nettle-$NETTLE_VERSION.tar.gz $NETTLE_SHA256" + do + set -- $pair + local name="$1" url="$2" want="$3" + if [ ! -f "$WORK/src/$name.tar.gz" ]; then + echo "-- fetching $name" + curl -sfL "$url" -o "$WORK/src/$name.tar.gz" || { + echo "!! could not download $url" >&2 + exit 1 + } + fi + local got + got="$(sha256sum "$WORK/src/$name.tar.gz" | cut -d' ' -f1)" + if [ "$got" != "$want" ]; then + echo "!! $name sha256 mismatch" >&2 + echo " expected $want" >&2 + echo " got $got" >&2 + exit 1 + fi + if [ ! -d "$WORK/$name" ]; then + echo "-- unpacking $name" + tar xf "$WORK/src/$name.tar.gz" -C "$WORK" || exit 1 + fi + [ -d "$WORK/$name" ] || { echo "!! $name.tar.gz did not unpack to $name/" >&2; exit 1; } + done +} + +# ------------------------------------------------------------------ wrapper + +# Proven by native/build-openssh.sh, which cannot cross-compile without it: +# +# - the NDK's clang cannot locate its own crtbegin_static.o for a static link, and +# ships no libgcc.a, so compiler-rt's builtins archive is symlinked into place. +# - a *dynamic* link must be left to the driver. Injecting -nostartfiles there drops +# crtbegin_so.o, which supplies the .init_array framing and __dso_handle; the +# dynamic linker then runs off the end of .init_array and SIGSEGVs while loading, +# before main ever runs. +write_wrapper() { + local arch="$1" stage="$2" triple="$3" apidir="$4" + local crtdir="$SYSROOT/usr/lib/$apidir/$API" + [ -f "$crtdir/crtbegin_static.o" ] || crtdir="$SYSROOT/usr/lib/$apidir/21" + mkdir -p "$stage" + + local builtins="$TOOLCHAIN/lib/clang/$CLANG_VER/lib/linux/libclang_rt.builtins-$triple.a" + [ -f "$builtins" ] || builtins="$TOOLCHAIN/lib/clang/$CLANG_VER/lib/linux/libclang_rt.builtins-$arch-android.a" + ln -sf "$builtins" "$stage/libgcc.a" + + cat > "$stage/cc" < "$stage/cxx" </dev/null || true + sleep 1; find . -name 'Makefile.in' -exec touch {} + 2>/dev/null || true + + ./configure --prefix="$nettle" --host="$(host_for "$arch")" \ + --disable-shared --enable-static --disable-documentation --disable-assembler \ + CC="$stage/cc" CC_FOR_BUILD=gcc \ + AR="$TOOLCHAIN/bin/llvm-ar" RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + > "$WORK/logs/nettle-config-$arch.log" 2>&1 || exit 1 + + # desdata is a HOST program: it generates rotors.h. Built with the cross + # wrapper it would be an aarch64 binary and could not run here, which is what + # `/bin/sh: 2: ./desdata: not found` under make -j2 was really about -- a + # parallel-make race, not a missing tool. + make -j"$JOBS" desdata > "$WORK/logs/nettle-build-$arch.log" 2>&1 || exit 1 + make -j"$JOBS" >> "$WORK/logs/nettle-build-$arch.log" 2>&1 || exit 1 + make install >> "$WORK/logs/nettle-build-$arch.log" 2>&1 || exit 1 + ) || { + echo "!! nettle failed for $arch; see $WORK/logs/nettle-build-$arch.log" >&2 + tail -20 "$WORK/logs/nettle-build-$arch.log" >&2 + return 1 + } + [ -f "$nettle/lib/libnettle.a" ] || { echo "!! nettle produced no libnettle.a" >&2; return 1; } + echo " nettle: $(stat -c%s "$nettle/lib/libnettle.a") bytes in $((SECONDS-t0))s" +} + +# ------------------------------------------------------------------ protobuf-lite + +build_protobuf_lite() { + local arch="$1" stage="$2" pblite="$3" t0=$SECONDS + if [ -f "$pblite/lib/libprotobuf-lite.a" ]; then + echo " protobuf-lite: already built" + return 0 + fi + echo "-- libprotobuf-lite $PROTOBUF_VERSION for $arch" + rm -rf "$WORK/protobuf-build-$arch" + cp -r "$WORK/protobuf-$PROTOBUF_VERSION" "$WORK/protobuf-build-$arch" + ( + cd "$WORK/protobuf-build-$arch" || exit 1 + # Same autotools trap as nettle. protoc is generated by make depend. + touch aclocal.m4 || true + sleep 1; touch configure config.h.in || true + sleep 1; find . -name 'Makefile.in' -exec touch {} + 2>/dev/null || true + + ./configure --prefix="$pblite" --host="$(host_for "$arch")" \ + --disable-shared --enable-static --disable-tests --disable-java \ + --with-protoc=/usr/bin/protoc \ + CC="$stage/cc" CXX="$stage/cxx" \ + AR="$TOOLCHAIN/bin/llvm-ar" RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + CFLAGS="-Os -fPIC" CXXFLAGS="-Os -fPIC" \ + > "$WORK/logs/pb-config-$arch.log" 2>&1 || exit 1 + + # ONLY the lite target. automake's install-libLTLIBRARIES installs libprotobuf, + # libprotobuf-lite, libprotoc AND libupb, so asking for it builds the protoc + # compiler as well -- over an hour of a single-core host for two libraries mosh + # never links. mosh's .proto files are optimize_for = LITE_RUNTIME. + make -C src -j"$JOBS" libprotobuf-lite.la > "$WORK/logs/pb-build-$arch.log" 2>&1 || exit 1 + ) || { + echo "!! protobuf-lite failed for $arch; see $WORK/logs/pb-build-$arch.log" >&2 + tail -20 "$WORK/logs/pb-build-$arch.log" >&2 + return 1 + } + + # Installed by hand, for the same reason. The headers include .inc files as well + # as .h, and mosh's configure reports the unhelpful "Could not build output + # generated by protoc" when one is missing. + mkdir -p "$pblite/lib" "$pblite/include" + cp "$WORK/protobuf-build-$arch/src/.libs/libprotobuf-lite.a" "$pblite/lib/" || return 1 + ( cd "$WORK/protobuf-build-$arch/src" && \ + find . -path './google/protobuf/*' \( -name '*.h' -o -name '*.inc' \) \ + -exec cp --parents {} "$pblite/include/" \; ) || return 1 + echo " protobuf-lite: $(stat -c%s "$pblite/lib/libprotobuf-lite.a") bytes in $((SECONDS-t0))s" +} + +# ------------------------------------------------------------------ mosh + +build_mosh() { + local arch="$1" stage="$2" sslp="$3" pblite="$4" nettle="$5" moshp="$6" t0=$SECONDS + if [ -f "$moshp/lib/libmoshstatesync.a" ]; then + echo " mosh: already built" + return 0 + fi + echo "-- mosh $MOSH_VERSION protocol libraries for $arch" + + # mosh's configure probes for tinfo/ncurses unconditionally and errors out with + # "requires either tinfo, NcursesW or Ncurses library" -- even under + # --disable-client, which compiles no curses code at all. Satisfy the probe with a + # stub rather than cross-compiling ncurses we would not use. + mkdir -p "$WORK/stub-pc" + cat > "$WORK/stub-pc/tinfo.pc" <<'EOF' +Name: tinfo +Description: stub. mosh is configured --disable-client, so no curses code is compiled. +Version: 0.0 +Libs: +Cflags: +EOF + + rm -rf "$WORK/mosh-build-$arch" + cp -r "$WORK/mosh-$MOSH_VERSION" "$WORK/mosh-build-$arch" + ( + cd "$WORK/mosh-build-$arch" || exit 1 + touch aclocal.m4 || true + sleep 1; touch configure config.h.in || true + sleep 1; find . -name 'Makefile.in' -exec touch {} + 2>/dev/null || true + + PKG_CONFIG_PATH="$WORK/stub-pc:$nettle/lib/pkgconfig" ./configure \ + --prefix="$moshp" --host="$(host_for "$arch")" \ + --disable-client --disable-server --disable-examples \ + --without-utempter --disable-syslog \ + --with-crypto-library=nettle \ + CC="$stage/cc" CXX="$stage/cxx" \ + AR="$TOOLCHAIN/bin/llvm-ar" RANLIB="$TOOLCHAIN/bin/llvm-ranlib" \ + PROTOC=/usr/bin/protoc \ + CFLAGS="-Os -fPIC -I$nettle/include -I$SCRIPT_DIR/mosh_curses_stub -include $SCRIPT_DIR/mosh_compat.h" \ + CXXFLAGS="-Os -fPIC -I$nettle/include -I$SCRIPT_DIR/mosh_curses_stub -include $SCRIPT_DIR/mosh_compat.h" \ + LDFLAGS="-L$nettle/lib" \ + protobuf_CFLAGS="-I$pblite/include" \ + protobuf_LIBS="-L$pblite/lib -lprotobuf-lite" \ + > "$WORK/logs/mosh-config-$arch.log" 2>&1 || exit 1 + + grep -E "Cryptography:" "$WORK/logs/mosh-config-$arch.log" || exit 1 + + # Spelled out rather than derived from the directory name, because + # src/protobufs builds libmoshprotos.a and not libmoshprotobufs.a -- the + # obvious loop asks for the wrong name and fails in a way that reads like a + # broken toolchain. + for pair in crypto:libmoshcrypto util:libmoshutil protobufs:libmoshprotos \ + network:libmoshnetwork statesync:libmoshstatesync terminal:libmoshterminal + do + d="${pair%%:*}"; lib="${pair##*:}" + make -C "src/$d" -j"$JOBS" "$lib.a" >> "$WORK/logs/mosh-build-$arch.log" 2>&1 || exit 1 + done + + # Belt and braces for the curses-free object. A static archive only pulls in + # members that are referenced, so this is not the mechanism -- it is here so a + # reference creeping in fails at the archive rather than at link time. + "$TOOLCHAIN/bin/llvm-ar" d src/terminal/libmoshterminal.a terminaldisplayinit.o 2>/dev/null || true + ) || { + echo "!! mosh failed for $arch; see $WORK/logs/mosh-build-$arch.log" >&2 + tail -20 "$WORK/logs/mosh-build-$arch.log" >&2 + return 1 + } + mkdir -p "$moshp/lib" + local d + for d in crypto util protobufs network statesync terminal; do + cp "$WORK/mosh-build-$arch/src/$d"/*.a "$moshp/lib/" 2>/dev/null || true + done + cp -r "$WORK/mosh-build-$arch/src" "$moshp/src" 2>/dev/null || true + echo " mosh: $(ls "$moshp/lib"/*.a 2>/dev/null | wc -l) archives in $((SECONDS-t0))s" +} + +# ------------------------------------------------------------------ library + +# The JNI surface. Phase 1's whole job is a library that loads and works on a device; +# a .so with no exported symbols links fine and proves nothing, which is exactly what +# an earlier probe of this build did: 5,216 bytes and one dynamic symbol. +build_library() { + local arch="$1" stage="$2" pblite="$3" nettle="$4" moshp="$5" out="$6" + echo "-- linking libredtermmosh.so for $arch" + + # The stand-ins for what bionic lacks at API 24. + "$stage/cc" -Os -fPIC -c -o "$WORK/mosh_compat-$arch.o" \ + "$SCRIPT_DIR/mosh_compat.c" -include "$SCRIPT_DIR/mosh_compat.h" \ + 2> "$WORK/logs/compat-$arch.log" || { + echo "!! mosh_compat.c failed" >&2 + cat "$WORK/logs/compat-$arch.log" >&2 + return 1 + } + + "$stage/cxx" -std=gnu++11 -Os -fPIC -shared -o "$out" \ + -I "$moshp/src" -I "$SYSROOT/usr/include" -I "$pblite/include" -I "$nettle/include" \ + -I "$moshp/src/include" -I "$moshp/src/statesync" -I "$moshp/src/terminal" \ + -I "$moshp/src/network" -I "$moshp/src/crypto" -I "$moshp/src/protobufs" \ + -I "$moshp/src/util" \ + -I "$SCRIPT_DIR/mosh_curses_stub" \ + -include "$SCRIPT_DIR/mosh_compat.h" \ + "$SCRIPT_DIR/redterm_mosh.c" \ + "$SCRIPT_DIR/redterm_mosh_client.cpp" \ + "$SCRIPT_DIR/mosh_display_compat.cpp" \ + "$moshp/lib/libmoshcrypto.a" "$moshp/lib/libmoshutil.a" \ + "$moshp/lib/libmoshprotos.a" "$moshp/lib/libmoshnetwork.a" \ + "$moshp/lib/libmoshstatesync.a" "$moshp/lib/libmoshterminal.a" \ + "$nettle/lib/libnettle.a" "$pblite/lib/libprotobuf-lite.a" \ + "$WORK/mosh_compat-$arch.o" \ + -lz -llog -ldl -lm -lc++_static \ + -Wl,--no-undefined -Wl,-z,max-page-size=$PAGE_SIZE \ + 2> "$WORK/logs/link-$arch.log" + local rc=$? + if [ $rc -ne 0 ]; then + echo "!! link failed for $arch" >&2 + tail -30 "$WORK/logs/link-$arch.log" >&2 + return 1 + fi + + # --no-undefined is the check that matters: it is what turned a 5 KB empty shell + # into a failure the moment a real symbol was referenced. + local undef + undef="$("$TOOLCHAIN/bin/llvm-readelf" -d "$out" | grep NEEDED | sed 's/.*\[\(.*\)\]/\1/' | tr '\n' ' ')" + echo " NEEDED: $undef" + + "$TOOLCHAIN/bin/llvm-strip" "$out" + # 16 KB page alignment is the point of a jniLibs .so; say so if it is missing + # rather than leaving a lint warning that names something else. + local align + align="$("$TOOLCHAIN/bin/llvm-readelf" -l "$out" | awk '/LOAD/{getline; print $NF; exit}' | tr -d '\r')" + echo " $(stat -c%s "$out") bytes, LOAD align ${align:-?}" + return 0 +} + +# ------------------------------------------------------------------ main + +build_arch() { + local arch="$1" + local abi; abi="$(abi_for "$arch")" + local stage="$WORK/stage-$arch" + local nettle="$WORK/nettle-$arch" + local pblite="$WORK/pb-lite-$arch" + local moshp="$WORK/mosh-$arch" + + mkdir -p "$stage" "$OUTPUT_DIR/$abi" + write_wrapper "$arch" "$stage" "$(triple_for "$arch")" "$(apidir_for "$arch")" + + build_nettle "$arch" "$stage" "$nettle" || return 1 + build_protobuf_lite "$arch" "$stage" "$pblite" || return 1 + build_mosh "$arch" "$stage" "" "$pblite" "$nettle" "$moshp" || return 1 + + # Built into a temporary name and moved into place, so an interrupted link cannot + # leave a truncated library in the APK where the loader would find it and die. + local tmp="$WORK/libredtermmosh-$arch.so" + build_library "$arch" "$stage" "$pblite" "$nettle" "$moshp" "$tmp" || return 1 + mv "$tmp" "$OUTPUT_DIR/$abi/libredtermmosh.so" + echo " installed $OUTPUT_DIR/$abi/libredtermmosh.so" + + local bin_tmp="$WORK/redterm-mosh-$arch" + mkdir -p "$BINARY_OUTPUT_DIR/$(asset_dir_for "$arch")" + build_binary "$arch" "$stage" "$pblite" "$nettle" "$moshp" "$bin_tmp" || return 1 + mv "$bin_tmp" "$BINARY_OUTPUT_DIR/$(asset_dir_for "$arch")/redterm-mosh" + chmod +x "$BINARY_OUTPUT_DIR/$(asset_dir_for "$arch")/redterm-mosh" + echo " installed $BINARY_OUTPUT_DIR/$(asset_dir_for "$arch")/redterm-mosh" +} + +# The client executable. +# +# This exists because TerminalSession is final and its constructor takes a shell *path*: +# it builds the subprocess itself through JNI.createSubprocess, so a mosh session that is +# not a process cannot be a session at all. The app already ships real executables as +# per-ABI assets and runs them through proot's loader, so mosh takes that same route -- +# which means the session machinery, the emulator, split panes and resize need no change +# at all. The sources are identical to the library's; only main() is added, and it is the +# one that reads the endpoint from the environment rather than from argv. +build_binary() { + local arch="$1" stage="$2" pblite="$3" nettle="$4" moshp="$5" out="$6" + echo "-- linking redterm-mosh executable for $arch" + + "$stage/cxx" -std=gnu++11 -Os -fPIC -o "$out" \ + -I "$moshp/src" -I "$SYSROOT/usr/include" -I "$pblite/include" -I "$nettle/include" \ + -I "$moshp/src/include" -I "$moshp/src/statesync" -I "$moshp/src/terminal" \ + -I "$moshp/src/network" -I "$moshp/src/crypto" -I "$moshp/src/protobufs" \ + -I "$moshp/src/util" \ + -I "$SCRIPT_DIR/mosh_curses_stub" \ + -include "$SCRIPT_DIR/mosh_compat.h" \ + "$SCRIPT_DIR/redterm_mosh_main.cpp" \ + "$SCRIPT_DIR/redterm_mosh_client.cpp" \ + "$SCRIPT_DIR/mosh_display_compat.cpp" \ + "$moshp/lib/libmoshcrypto.a" "$moshp/lib/libmoshutil.a" \ + "$moshp/lib/libmoshprotos.a" "$moshp/lib/libmoshnetwork.a" \ + "$moshp/lib/libmoshstatesync.a" "$moshp/lib/libmoshterminal.a" \ + "$nettle/lib/libnettle.a" "$pblite/lib/libprotobuf-lite.a" \ + "$WORK/mosh_compat-$arch.o" \ + -lz -llog -ldl -lm \ + -static-libstdc++ \ + -Wl,-z,max-page-size=$BINARY_PAGE_SIZE \ + 2> "$WORK/logs/binary-$arch.log" + local rc=$? + if [ $rc -ne 0 ]; then + echo "!! executable link failed for $arch" >&2 + tail -30 "$WORK/logs/binary-$arch.log" >&2 + return 1 + fi + + "$TOOLCHAIN/bin/llvm-strip" "$out" + + local align + align="$("$TOOLCHAIN/bin/llvm-readelf" -l "$out" | awk '/LOAD/{getline; print $NF; exit}' | tr -d '\r')" + if [ "$align" != "0x$(printf '%x' $BINARY_PAGE_SIZE)" ]; then + echo "!! $arch executable is LOAD align ${align:-?}, proot's loader needs $(printf '0x%x' $BINARY_PAGE_SIZE)" >&2 + return 1 + fi + echo " $(stat -c%s "$out") bytes, LOAD align $align (proot loader)" + return 0 +} + +T0=$SECONDS +command -v protoc >/dev/null || { echo "!! protoc not found (apt install protobuf-compiler)" >&2; exit 1; } +command -v pkg-config >/dev/null || { echo "!! pkg-config not found" >&2; exit 1; } + +fetch_sources || exit 1 + +for arch in "${ARCHES[@]}"; do + say "$arch ($(abi_for "$arch"))" + build_arch "$arch" || { echo "!! FAILED: $arch" >&2; exit 1; } +done + +say "done in $((SECONDS-T0))s" +for arch in "${ARCHES[@]}"; do + abi="$(abi_for "$arch")" + f="$OUTPUT_DIR/$abi/libredtermmosh.so" + [ -f "$f" ] && printf ' %-14s %-14s %10d bytes\n' "$arch" "$abi" "$(stat -c%s "$f")" +done \ No newline at end of file diff --git a/native/mosh_compat.c b/native/mosh_compat.c new file mode 100644 index 0000000..8e5526f --- /dev/null +++ b/native/mosh_compat.c @@ -0,0 +1,30 @@ +/* + * Android stand-ins for glibc functions mosh calls that bionic does not have at this + * app's minimum API. + */ +#include + +/* + * nl_langinfo is declared in bionic only from API 26: + * + * #if __ANDROID_API__ >= 26 + * char* _Nonnull nl_langinfo(nl_item __item) __INTRODUCED_IN(26); + * #endif + * + * and the symbol genuinely does not exist on an API 24 device, so this is not only a + * declaration problem -- RedTerm supports API 24. + * + * mosh asks for exactly one item. src/util/locale_utils.cc's locale_charset() calls + * nl_langinfo(CODESET) for the sole purpose of rewriting glibc's "ANSI_X3.4-1968" + * into something readable, and nothing else in the tree uses it. So answering + * CODESET is the entire requirement, and answering it with UTF-8 is accurate here: + * ProotLaunch exports LANG/LC_ALL=C.UTF-8 when the rootfs offers it, so a session is + * not in the C locale by the time this runs. + */ +char *nl_langinfo(int item) +{ + if (item == CODESET) { + return (char *)"UTF-8"; + } + return (char *)""; +} diff --git a/native/mosh_compat.h b/native/mosh_compat.h new file mode 100644 index 0000000..6980491 --- /dev/null +++ b/native/mosh_compat.h @@ -0,0 +1,17 @@ +/* + * Declarations bionic does not provide at this app's minimum API. + * + * Force-included with -include so mosh's own sources compile unchanged; the + * definitions are in mosh_compat.c and are linked into libredtermmosh.so. + * + * This is the arrangement native/ssh_compat.c already uses for OpenSSH, and for the + * same reason: mosh is an upstream tree that assumes a glibc feature level, and + * patching it in place would mean re-applying the patch on every version bump. + */ +#ifndef REDTERM_MOSH_COMPAT_H +#define REDTERM_MOSH_COMPAT_H + +/* Declared by bionic only under __ANDROID_API__ >= 26. See mosh_compat.c. */ +char *nl_langinfo(int item); + +#endif /* REDTERM_MOSH_COMPAT_H */ diff --git a/native/mosh_curses_stub/curses.h b/native/mosh_curses_stub/curses.h new file mode 100644 index 0000000..c18e0bb --- /dev/null +++ b/native/mosh_curses_stub/curses.h @@ -0,0 +1,39 @@ +/* + * Declaration-only curses.h, for cross-compiling mosh. + * + * src/terminal/terminaldisplayinit.cc includes and calls setupterm, + * tigetflag and tigetstr for terminfo capability lookups, and hard-errors without a + * header: + * + * #error "SysV or X/Open-compatible Curses header file required" + * + * That is the only reason ncurses would be needed for the protocol libraries. + * Nothing in the tree references TerminalDisplayInit -- it exists for the curses + * frontend, and --disable-client does not build that -- so the object is never pulled + * into libredtermmosh.so and these three symbols are never linked. + * + * Declaring them is therefore enough to compile the object and nothing more. If a + * future change does reference it, the link fails with an undefined setupterm rather + * than silently binding to a terminfo database we do not ship. + */ +#ifndef REDTERM_STUB_CURSES_H +#define REDTERM_STUB_CURSES_H + +/* The two result macros ncurses defines; terminaldisplayinit.cc compares setupterm's + * return against OK. */ +#define OK 0 +#define ERR (-1) + +#ifdef __cplusplus +extern "C" { +#endif + +int setupterm(const char *type, int filedes, int *errret); +int tigetflag(const char *capname); +char *tigetstr(const char *capname); + +#ifdef __cplusplus +} +#endif + +#endif /* REDTERM_STUB_CURSES_H */ diff --git a/native/mosh_curses_stub/term.h b/native/mosh_curses_stub/term.h new file mode 100644 index 0000000..09c49cb --- /dev/null +++ b/native/mosh_curses_stub/term.h @@ -0,0 +1,5 @@ +/* See curses.h. ncurses declares tigetstr/tigetflag here; the stub puts them in + * curses.h, and this only has to exist because mosh includes both. */ +#ifndef REDTERM_STUB_TERM_H +#define REDTERM_STUB_TERM_H +#endif diff --git a/native/mosh_display_compat.cpp b/native/mosh_display_compat.cpp new file mode 100644 index 0000000..e35907d --- /dev/null +++ b/native/mosh_display_compat.cpp @@ -0,0 +1,68 @@ +/* + * Terminal::Display's constructor, for the build that ships without curses. + * + * mosh puts this constructor in terminaldisplayinit.cc, together with + * TerminalDisplayInit -- the one file in src/terminal that calls setupterm, + * tigetflag and tigetstr, and which therefore cannot compile without a curses + * header. build-mosh.sh deletes that object from libmoshterminal.a and relies on + * the declaration-only curses.h stub, which was sound while nothing referenced + * anything in it. + * + * It is not sound any more. Display is not optional for us: new_frame() is what + * turns a Framebuffer diff into the escape sequences the Android terminal + * emulator understands, and reimplementing it would mean reimplementing wrapping, + * scrolling regions, wide characters and SGR bookkeeping -- all of which mosh has + * already got right, and all of which are the sort of thing that is subtly wrong + * in a way only a real terminal session would ever show. + * + * The other half of that file was thrown away for one reason, and it is worth being + * precise about it: it is thrown away because there is no terminfo database on an + * Android device. So this constructor is the part that does not need one. + * + * Look at what it actually does. Given use_environment == false -- which is what + * RedTerm passes, because the Android terminal already knows its own capabilities + * and the device has no $TERM worth reading -- the entire body is the member + * initialiser list: has_ech, has_bce and has_title true, smcup and rmcup null. + * Not one terminfo lookup happens. The other five members, and the 4 KB of + * capability discovery around them, are all inside `if ( use_environment )`. + * + * Those defaults are also the right answer for us, not merely the only one + * available: + * + * smcup / rmcup NULL, so no alternate-screen sequences. A phone has no + * alternate screen, and emitting smcup would switch the + * emulator into a mode nothing ever leaves. + * has_bce true. An erase is sent with the background colour to match, so + * a cleared region repaints correctly against whatever was + * underneath it. + * has_ech true. ECH is universally supported and produces shorter erases. + * has_title true. A window-title escape written into a phone terminal's + * byte stream is ignored by the emulator, which is the correct + * outcome. + * + * use_environment == true throws rather than guessing. We cannot honour it on + * this platform -- there is no database to read -- and upstream throws in exactly + * the same circumstance ("Terminfo database could not be found."). Silently + * behaving as though a capability existed would be worse than refusing: an + * invisible terminfo assumption is how a terminal ends up subtly wrong on one + * device model and fine on another. Nothing in RedTerm asks for it, so this path + * only exists to be a loud failure rather than a quiet one. + */ + +#include "terminal/terminaldisplay.h" + +#include + +namespace Terminal { + +Display::Display( bool use_environment ) + : has_ech( true ), has_bce( true ), has_title( true ), smcup( NULL ), rmcup( NULL ) +{ + if ( use_environment ) { + throw std::runtime_error( + "Terminfo database could not be found: RedTerm's terminal reads no $TERM, " + "because an Android device ships no terminfo database to read it from." ); + } +} + +} /* namespace Terminal */ \ No newline at end of file diff --git a/native/redterm_mosh.c b/native/redterm_mosh.c new file mode 100644 index 0000000..588507c --- /dev/null +++ b/native/redterm_mosh.c @@ -0,0 +1,306 @@ +/* + * The JNI surface of libredtermmosh.so. + * + * Phase 1's whole job is a library that loads and demonstrably works on a device, so + * this is deliberately a real surface and not an empty one. An earlier probe of this + * build linked a 5,216-byte object with a single dynamic symbol -- it proved the + * archives were on disk and nothing else, because nothing referenced anything. The + * link now runs with --no-undefined, so a reference that cannot be satisfied is a + * build failure rather than a library that quietly does nothing. + * + * This file is where the rules from AGENTS.md about secrets are load-bearing. The + * session key is a secret: it must never reach a command line, a launcher script or + * AppLog. It is passed as bytes and lives only in this library's memory for the life + * of the session. + */ + +#include + +#include +#include + +#include + +#include "crypto/crypto.h" +#include "redterm_mosh_client.h" + +#define REDTERM_MOSH_VERSION "1.4.0" + +extern "C" { + +/* + * Round-trips a message through mosh's authenticated encryption and checks the + * plaintext comes back, including the nonce. + * + * This is the smallest slice of mosh that touches every dependency the JNI layer will + * rely on: mosh's internal OCB, nettle's AES, base64, and the Nonce/Message value + * types. If this returns 1 on a device then the native side is sound end to end. + * + * 1 round trip correct + * 0 round trip wrong + * -1 an exception escaped + */ +JNIEXPORT jint JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeSelfTest(JNIEnv *, jclass) +{ + try { + Crypto::Base64Key key; + Crypto::Session session(key); + + Crypto::Nonce nonce(static_cast(0x0102030405060708ULL)); + const std::string plaintext("redterm-mosh-probe"); + + const std::string ciphertext = session.encrypt(Crypto::Message(nonce, plaintext)); + + /* Encrypting to nothing, or echoing the input back, would both satisfy a + * length check, so they are rejected before the round trip is even tried. */ + if (ciphertext.empty() || ciphertext == plaintext) { + return 0; + } + + const Crypto::Message back = session.decrypt(ciphertext.data(), ciphertext.size()); + if (back.text != plaintext) { + return 0; + } + + /* The nonce has to survive too, or two messages are interchangeable and the + * replay protection the server relies on is gone. */ + if (back.nonce.val() != nonce.val()) { + return 0; + } + return 1; + } catch (...) { + /* mosh throws std::exception subclasses (CryptoException). A JNI boundary that + * lets one escape terminates the process, and a crashed app is how the first + * version of this reported its own failures. */ + return -1; + } +} + +JNIEXPORT jstring JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeVersion(JNIEnv *env, jclass) +{ + return env->NewStringUTF(REDTERM_MOSH_VERSION); +} + +/* + * A session handle, kept as a raw pointer in a jlong. + * + * The alternative is a global table keyed by an int, and the table needs a lock and a + * leak story for no benefit: the Java side owns the handle's lifetime, hands it back to + * every call, and calls nativeFree when it is done. nativeStop joins the worker thread + * before the object is destroyed, so no call can ever reach a freed MoshSession. + */ +static MoshSession *as_session(jlong handle) +{ + return reinterpret_cast(handle); +} + +/* + * Starts a session against an already-running mosh-server. + * + * `key` is the secret half of MOSH_KEY. It arrives from bundled ssh's output, so it + * never appears in a command line or a launcher script on the device, and it is never + * logged here -- returning an error string that describes the failure is enough, and the + * string never contains the key. + * + * Returns a non-null handle, or 0 with `outError` set to something readable. A failure + * the caller cannot read is a failure nobody can fix, so the message is what the remote + * actually said wherever that is available. + */ +JNIEXPORT jlong JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeStart(JNIEnv *env, jclass, jstring key, jstring ip, + jint port, jint cols, jint rows, jobject outError) +{ + const char *keyChars = key ? env->GetStringUTFChars(key, NULL) : NULL; + const char *ipChars = ip ? env->GetStringUTFChars(ip, NULL) : NULL; + jlong result = 0; + + if (!keyChars || !ipChars) { + if (keyChars) { + env->ReleaseStringUTFChars(key, keyChars); + } + if (ipChars) { + env->ReleaseStringUTFChars(ip, ipChars); + } + return 0; + } + + char errbuf[512]; + errbuf[0] = '\0'; + + /* The C API takes the port as a string, because that is how mosh's own client gets + * it -- straight out of the bootstrap output's third field. Rendering it here keeps + * that a detail of this file rather than something every caller has to remember. */ + char portbuf[16]; + snprintf(portbuf, sizeof(portbuf), "%d", static_cast(port)); + + try { + MoshSession *s = mosh_session_start(keyChars, ipChars, portbuf, + static_cast(cols), static_cast(rows), errbuf, + sizeof(errbuf)); + if (s) { + /* The key was copied into the session; this is the last chance to let the + * JVM's copy of the secret go. */ + memset((void *)keyChars, 0, strlen(keyChars)); + result = reinterpret_cast(s); + } + } catch (...) { + errbuf[0] = '\0'; + snprintf(errbuf, sizeof(errbuf), "could not start: unknown error"); + } + + if (keyChars) { + memset((void *)keyChars, 0, strlen(keyChars)); + env->ReleaseStringUTFChars(key, keyChars); + } + if (ipChars) { + env->ReleaseStringUTFChars(ip, ipChars); + } + + if (result == 0 && outError != NULL) { + jstring msg = env->NewStringUTF(errbuf[0] ? errbuf : "could not start"); + if (msg) { + /* outError is a StringBuilder, so the method is append(String). It used to + * look up set(String), which no StringBuilder has: GetMethodID left a + * pending NoSuchMethodError behind, and that escaped the UnsatisfiedLinkError + * Kotlin was prepared for -- the failure report crashed rather than read. */ + jclass cls = env->GetObjectClass(outError); + jmethodID append = cls ? + env->GetMethodID(cls, "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;") + : NULL; + if (append) { + env->CallObjectMethod(outError, append, msg); + } + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + env->DeleteLocalRef(msg); + } + } + return result; +} + +JNIEXPORT void JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeStop(JNIEnv *, jclass, jlong handle) +{ + MoshSession *s = as_session(handle); + if (!s) { + return; + } + /* stop() joins the worker thread, so nothing is using `s` after this returns. */ + mosh_session_stop(s); +} + +JNIEXPORT void JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeFree(JNIEnv *, jclass, jlong handle) +{ + MoshSession *s = as_session(handle); + if (s) { + mosh_session_free(s); + } +} + +/* Keystrokes in, remote bytes out. Byte-at-a-time in both directions is the terminal's + * own model, and mosh's diffing is what makes it cheap on the wire. */ +JNIEXPORT void JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeInput(JNIEnv *env, jclass, jlong handle, jbyteArray data) +{ + MoshSession *s = as_session(handle); + if (!s || !data) { + return; + } + const jsize n = env->GetArrayLength(data); + if (n <= 0) { + return; + } + jbyte *bytes = env->GetByteArrayElements(data, NULL); + if (bytes) { + mosh_session_input(s, reinterpret_cast(bytes), static_cast(n)); + env->ReleaseByteArrayElements(data, bytes, JNI_ABORT); + } +} + +/* Rows and columns, not width and height: mosh's Resize takes rows first, and the two + * swapped is a terminal that is 80x24 in a portrait phone rather than an error. */ +JNIEXPORT void JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeResize(JNIEnv *, jclass, jlong handle, jint cols, jint rows) +{ + MoshSession *s = as_session(handle); + if (!s) { + return; + } + mosh_session_resize(s, static_cast(cols), static_cast(rows)); +} + +/* + * Returns however many bytes the remote has waiting, which may be 0. + * + * "0" and "the socket closed" are not the same thing, and a caller cannot tell them + * apart without this: a mosh server that has gone away leaves the UDP socket readable + * with nothing in it, which looks exactly like an idle connection. mosh's own + * loop answers that question; this only reports what is already in the model. + */ +JNIEXPORT jint JNICALL +Java_com_redtermapp_mosh_MoshNative_nativePoll(JNIEnv *env, jclass, jlong handle, jbyteArray out) +{ + MoshSession *s = as_session(handle); + if (!s || !out) { + return 0; + } + const jsize capacity = env->GetArrayLength(out); + if (capacity <= 0) { + return 0; + } + jbyte *bytes = env->GetByteArrayElements(out, NULL); + if (!bytes) { + return 0; + } + const size_t n = mosh_session_poll(s, reinterpret_cast(bytes), static_cast(capacity)); + if (n > 0) { + env->SetByteArrayRegion(out, 0, static_cast(n), bytes); + } + env->ReleaseByteArrayElements(out, bytes, 0); + return static_cast(n); +} + +JNIEXPORT jboolean JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeIsConnected(JNIEnv *, jclass, jlong handle) +{ + MoshSession *s = as_session(handle); + return (s && mosh_session_is_connected(s)) ? JNI_TRUE : JNI_FALSE; +} + +/* + * Milliseconds until mosh's own transport wants attention again, already clamped by the + * session to a sane maximum. + * + * Java should not busy-wait on this being small: mosh's timers are in milliseconds and a + * transport with nothing to do reports a long wait, which is the normal case. + */ +JNIEXPORT jint JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeWaitTime(JNIEnv *, jclass, jlong handle) +{ + MoshSession *s = as_session(handle); + if (!s) { + return -1; + } + const int wait = mosh_session_wait_time(s); + return static_cast(wait < 0 ? 0 : wait); +} + +JNIEXPORT jstring JNICALL +Java_com_redtermapp_mosh_MoshNative_nativeLastError(JNIEnv *env, jclass, jlong handle) +{ + MoshSession *s = as_session(handle); + char buf[ 512 ]; + mosh_session_last_error( s, buf, sizeof( buf ) ); + return env->NewStringUTF( buf ); +} + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *, void *) +{ + return JNI_VERSION_1_6; +} + +} /* extern "C" */ \ No newline at end of file diff --git a/native/redterm_mosh_client.cpp b/native/redterm_mosh_client.cpp new file mode 100644 index 0000000..bc79cd2 --- /dev/null +++ b/native/redterm_mosh_client.cpp @@ -0,0 +1,484 @@ +/* + * mosh client for RedTerm: the State Synchronization Protocol. + * + * This is mosh's own C++ -- Network::Transport, StateSync::UserStream, + * StateSync::Complete, Terminal::Framebuffer -- wrapped in a small C API so both the + * JNI layer and a host test harness can drive it. Nothing here reimplements the + * protocol: the wire format, the encryption and the terminal model are upstream's, + * because the SSP is fiddly enough that a reimplementation is only plausible-looking. + * + * The wiring follows src/frontend/stmclient.cc, which is mosh's own client: + * + * typedef Network::Transport< Network::UserStream, Terminal::Complete > MoshTransport; + * + * user_state; local_terminal; + * network = MoshTransport( user_state, local_terminal, key, ip, port ); + * network->get_current_state().push_back( Parser::Resize( cols, rows ) ); + * ... network->recv(); ... + * new_state = network->get_latest_remote_state().state.get_fb(); + * diff = display.new_frame( false, local_fb, new_state ); + * + * Two deliberate differences from stmclient: + * + * - It uses mosh's own `Select` wrapper around the transport's fds. `Select` lives in + * src/network and is not built under --disable-client, so this polls directly. The + * fds are still mosh's: network->fds(). + * - It owns a thread and hands finished frames out as a queue, because Kotlin drives + * the terminal through a pipe and a blocking native call would stall it. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Included bare, with per-directory -I, the way src/frontend/stmclient.cc does it. + * The Transport template itself is in the -impl.h; networktransport.h is only the + * declaration, and including that alone gives a class with no members defined. */ +/* networktransport-impl.h calls fatal_assert(), which lives in this header. Without + * the include the template does not compile, and the error names a missing method on + * a template parameter rather than a missing header. */ +#include "fatal_assert.h" +#include "timestamp.h" +#include "networktransport-impl.h" +#include "completeterminal.h" +#include "user.h" +#include "parser.h" +#include "terminaldisplay.h" +#include "terminalframebuffer.h" + +typedef Network::Transport< Network::UserStream, Terminal::Complete > MoshTransport; + +/* mosh caps its own wait; so do we, or stop() takes up to this long to be noticed. */ +static const int MAX_WAIT_MS = 25; + +struct MoshSession { + /* These two are held by reference by MoshTransport, so they must outlive it -- + * mosh's own client keeps them as locals in main_init for the same reason. */ + Network::UserStream user_state; + Terminal::Complete local_terminal; + + std::shared_ptr< MoshTransport > network; + Terminal::Display display; + Terminal::Framebuffer local_fb; + + std::deque< std::string > outbox; + std::mutex outbox_lock; + + /* Input from the caller's thread, applied to the transport only on the worker. + * + * mosh's transport is not thread-safe: tick() diffs the user stream to build its + * outgoing packet while a push_back from another thread is changing the very deque + * it iterates. mosh's own client is single-threaded and never has this problem; + * ours does, because the terminal sits behind a pipe and the caller cannot wait. + * So the caller queues, and the worker applies what it queued between renders -- + * the same shape the outbox uses in the other direction. */ + std::mutex input_lock; + std::string pending_bytes; + std::vector< std::pair< int, int > > pending_resizes; + + std::atomic< bool > running; + std::thread worker; + + int cols; + int rows; + /* Atomic: the worker writes it while mosh_session_is_connected() reads it + * from the caller's thread, and a plain bool is a data race there. */ + std::atomic< bool > connected; + + /* mosh's own goodbye. When the remote shell's pty closes, mosh-server sends + * shutdown packets (new_num = -1) exactly like ordinary SSP state, resent with + * backoff like ordinary SSP state -- so they arrive wherever the session's + * packets do, ICMP-filtering NAT and all. The real mosh client closes on this; + * ignoring it was why a session sat at `logout` for ever: everything acked, no + * more keystrokes, no packet either way, and the receive-error detector waiting + * for errors that the silent network never raises. Sampled on the worker because + * the sender state it reads is only safe to touch there. */ + std::atomic< bool > shutdown_requested; + + /* Consecutive network failures, reset by any successful receive. + * + * mosh tolerates loss without complaint -- that is most of what the protocol is for + * -- so an exception here means the socket itself failed. The one that matters is + * what happens when the remote shell exits: mosh-server closes its UDP port, the + * next packet draws ICMP port-unreachable, and recv() throws. That is not a fault to + * recover from, it is the far end saying the session is over. */ + std::atomic< int > consecutive_errors; + + /* The last failure seen on the worker thread, for mosh_session_last_error(). + * + * Deliberately kept rather than written anywhere: this library has no context and + * no log, and AGENTS.md's rule is that a failure nobody can read is a failure + * nobody can fix -- so the caller decides whether it is worth reporting. */ + std::string last_error; + std::mutex error_lock; + + void note_error( const std::string &why ) + { + std::lock_guard< std::mutex > lock( error_lock ); + last_error = why; + } + + /* + * Display( use_environment = false ): mosh's Display reads $TERM through setupterm + * when that is true, which needs a terminfo database. There is none in an APK, and + * the Android terminal emulator already knows its own capabilities, so the display + * only has to emit the frame diff. + */ + MoshSession( int c, int r ) + : local_terminal( c, r ), display( false ), local_fb( c, r ), running( false ), + cols( c ), rows( r ), connected( false ), shutdown_requested( false ), + consecutive_errors( 0 ) + { + } + ~MoshSession() { stop(); } + + void stop() + { + bool was = running.exchange( false ); + if ( was && worker.joinable() ) { + worker.join(); + } + } + + /* Host output as a terminal would produce it: the minimal ANSI diff from the + * previous frame, which is what Terminal::Display computes. */ + void render() + { + try { + const Terminal::Framebuffer new_state + = network->get_latest_remote_state().state.get_fb(); + if ( new_state == local_fb ) { + return; /* nothing changed; do not emit an empty diff */ + } + const std::string diff = display.new_frame( false, local_fb, new_state ); + local_fb = new_state; + if ( !diff.empty() ) { + std::lock_guard< std::mutex > lock( outbox_lock ); + outbox.push_back( diff ); + } + } catch ( const std::exception &e ) { + note_error( e.what() ); + } catch ( ... ) { + note_error( "unknown render error" ); + } + } + + void run() + { + while ( running.load() ) { + render(); + + int wait = network->wait_time(); + if ( wait < 0 ) { + wait = 0; + } + if ( wait > MAX_WAIT_MS ) { + wait = MAX_WAIT_MS; + } + + /* mosh's fds, polled directly. See the note at the top about Select. */ + std::vector< int > fds; + try { + fds = network->fds(); + } catch ( ... ) { + break; + } + if ( fds.empty() ) { + break; /* no sockets to wait on, so nothing more can happen */ + } + + std::vector< struct pollfd > pfds; + pfds.reserve( fds.size() ); + for ( std::vector< int >::const_iterator it = fds.begin(); it != fds.end(); ++it ) { + struct pollfd p; + p.fd = *it; + p.events = POLLIN; + p.revents = 0; + pfds.push_back( p ); + } + + const int n = poll( &pfds[0], pfds.size(), wait ); + if ( n > 0 ) { + try { + network->recv(); + connected = true; + consecutive_errors.store( 0 ); + } catch ( const std::exception &e ) { + consecutive_errors.fetch_add( 1 ); + note_error( e.what() ); + } catch ( ... ) { + note_error( "unknown receive error" ); + consecutive_errors.fetch_add( 1 ); + } + } + + /* Advance mosh's clock. This is the single most surprising thing about + * mosh's internals and it was the entire send path. + * + * Network::timestamp() returns frozen_timestamp(), which reads a + * millisecond cache exactly once and then returns that same value for + * ever. It only advances when something calls freeze_timestamp(), and in + * the whole tree the callers are mosh's own frontends and its Select + * wrapper: + * + * frontend/stmclient.cc:567 freeze_timestamp(); + * frontend/mosh-server.cc:141 freeze_timestamp(); + * util/select.h:187 freeze_timestamp(); + * + * Replicate the loop without that call and the clock stops at the first + * timestamp the client ever read. Every timer comparison stays + * "now < next_send_time", so the initial state goes out and nothing ever + * does again: keystrokes queue, tick() is asked and declines every time, + * and the remote shell never sees them. No error, no exception, no + * output -- the connection looks alive and is one-way. */ + freeze_timestamp(); + + /* Apply what the caller queued, on the thread that owns the transport. + * Anything queued on the caller's thread waited for exactly this point; + * without it, a keystroke typed between renders would sit until the next + * one for no better reason than where it was queued. */ + { + std::lock_guard< std::mutex > lock( input_lock ); + if ( !pending_bytes.empty() || !pending_resizes.empty() ) { + try { + Network::UserStream &state = network->get_current_state(); + for ( size_t i = 0; i < pending_bytes.size(); i++ ) { + state.push_back( Parser::UserByte( pending_bytes[ i ] ) ); + } + for ( size_t i = 0; i < pending_resizes.size(); i++ ) { + state.push_back( + Parser::Resize( pending_resizes[ i ].first, pending_resizes[ i ].second ) + ); + } + } catch ( ... ) { + note_error( "could not queue input" ); + } + pending_bytes.clear(); + pending_resizes.clear(); + } + } + + try { + network->tick(); + } catch ( const std::exception &e ) { + note_error( e.what() ); + } catch ( ... ) { + note_error( "unknown send error" ); + } + + /* The goodbye arrives as ordinary packets, so both reads come off the + * same transport the tick just advanced -- never from another thread. + * The two conditions mirror the real client's loop: the counterparty + * asked and our ack has gone out, or our own shutdown (which this + * client never starts, but the check costs nothing) is acked. Once + * true there is no un-true: ack_num only ever moves to -1 and stays. */ + try { + if ( network->counterparty_shutdown_ack_sent() || + network->shutdown_in_progress() ) { + shutdown_requested.store( true ); + } + } catch ( ... ) { + /* getters are reads; nothing to do here */ + } + } + } +}; + +extern "C" { + +/* + * Starts a session against a mosh-server that has already printed its port and key. + * + * The key is a secret: it is passed as a string here but never logged, never written + * to disk and never placed in a command line. Kotlin holds it in memory only, and the + * same rule the passphrase helper follows for ssh. + * + * Returns NULL and fills errbuf on failure rather than throwing, because every caller + * is a Kotlin boundary where an exception would take the process down. + */ +MoshSession *mosh_session_start( + const char *key, const char *ip, const char *port, int cols, int rows, + char *errbuf, size_t errlen ) +{ + if ( key == NULL || ip == NULL || port == NULL ) { + snprintf( errbuf, errlen, "key, address and port are all required" ); + return NULL; + } + if ( cols <= 0 || rows <= 0 ) { + snprintf( errbuf, errlen, "terminal size %dx%d is not usable", cols, rows ); + return NULL; + } + + MoshSession *s = new MoshSession( cols, rows ); + try { + s->network = std::shared_ptr< MoshTransport >( + new MoshTransport( s->user_state, s->local_terminal, key, ip, port ) ); + s->network->set_send_delay( 1 ); /* minimal delay on outgoing keystrokes */ + /* REDTERM_MOSH_VERBOSE=1 turns on mosh's own tracing. Off by default: it + * writes to stderr per packet, which is a firehose on a live session. */ + { + const char *v = getenv( "REDUCTERM_MOSH_VERBOSE" ); + s->network->set_verbose( ( v != NULL && atoi( v ) > 0 ) ? 2 : 0 ); + } + /* The server needs our size before it will draw anything sensible. */ + s->network->get_current_state().push_back( Parser::Resize( cols, rows ) ); + } catch ( const std::exception &e ) { + snprintf( errbuf, errlen, "could not start: %s", e.what() ); + delete s; + return NULL; + } catch ( ... ) { + snprintf( errbuf, errlen, "could not start: unknown error" ); + delete s; + return NULL; + } + + s->running.store( true ); + try { + s->worker = std::thread( &MoshSession::run, s ); + } catch ( const std::exception &e ) { + snprintf( errbuf, errlen, "could not start the session thread: %s", e.what() ); + delete s; + return NULL; + } + return s; +} + +void mosh_session_stop( MoshSession *s ) +{ + if ( s ) { + s->stop(); + } +} + +void mosh_session_free( MoshSession *s ) { delete s; } + +/* + * Queues keystrokes. The worker applies them to the user-state stream, which SSP + * diffs and transmits; the server applies them and its frame comes back on the next + * render. Queued rather than applied here because the transport lives on the worker + * thread and mosh's own client never mutates it from anywhere else. + */ +void mosh_session_input( MoshSession *s, const uint8_t *bytes, size_t n ) +{ + if ( !s || !bytes || n == 0 ) { + return; + } + try { + std::lock_guard< std::mutex > lock( s->input_lock ); + s->pending_bytes.append( reinterpret_cast< const char * >( bytes ), n ); + } catch ( ... ) { + } +} + +/* + * Queues a resize. Applied by the worker like the keystrokes, and it re-sends the + * whole screen, so the host output after this is expected to be large. + */ +void mosh_session_resize( MoshSession *s, int cols, int rows ) +{ + if ( !s || cols <= 0 || rows <= 0 ) { + return; + } + try { + std::lock_guard< std::mutex > lock( s->input_lock ); + s->pending_resizes.push_back( std::make_pair( cols, rows ) ); + } catch ( ... ) { + } +} + +/* + * Takes up to maxlen bytes of host output. Returns how many were written, 0 when + * there is nothing yet. + * + * A single call drains the whole queue rather than one frame, so Kotlin's poll loop + * does not turn into one JNI crossing per rendered frame. + */ +size_t mosh_session_poll( MoshSession *s, uint8_t *out, size_t maxlen ) +{ + if ( !s || !out || maxlen == 0 ) { + return 0; + } + std::lock_guard< std::mutex > lock( s->outbox_lock ); + size_t written = 0; + while ( written < maxlen && !s->outbox.empty() ) { + const std::string &frame = s->outbox.front(); + const size_t room = maxlen - written; + if ( frame.size() <= room ) { + memcpy( out + written, frame.data(), frame.size() ); + written += frame.size(); + s->outbox.pop_front(); + } else { + /* Partially take a frame, and put the rest back at the front. */ + memcpy( out + written, frame.data(), room ); + written += room; + s->outbox.front() = frame.substr( room ); + break; + } + } + return written; +} + +/* 1 once a frame has come back from the server, 0 before that. */ +int mosh_session_is_connected( MoshSession *s ) { return ( s && s->connected ) ? 1 : 0; } + +/* 1 once the server's shutdown has been seen and answered. The main loop ends the + * session on it; see the comment on shutdown_requested and the exit-code protocol + * in redterm_mosh_main.cpp. */ +int mosh_session_shutdown_requested( MoshSession *s ) +{ + return ( s && s->shutdown_requested.load() ) ? 1 : 0; +} + +/* The last failure the worker saw, or "" if none. For Diagnostics, not for stdout. + * + * The text is copied out under the lock rather than referenced: the worker can + * reassign `last_error` at any moment, and a caller reading a pointer into a + * string another thread keeps mutating reads something between versions -- + * typically on the remote-exit path, where every recv() throws and calls + * note_error again while the caller is still formatting the previous one. */ +size_t mosh_session_last_error( MoshSession *s, char *out, size_t outlen ) +{ + if ( !out || outlen == 0 ) { + return 0; + } + out[ 0 ] = '\0'; + if ( !s ) { + return 0; + } + std::lock_guard< std::mutex > lock( s->error_lock ); + snprintf( out, outlen, "%s", s->last_error.c_str() ); + return strlen( out ); +} + +/* How many network failures in a row, with no successful receive in between. + * + * Zero once the link is healthy. The far end closing its socket is the signal that a + * session is genuinely over rather than merely quiet. */ +int mosh_session_network_errors( MoshSession *s ) +{ + return s ? s->consecutive_errors.load() : 0; +} + +/* Milliseconds mosh's own transport wants to wait; -1 when there is no transport. */ +int mosh_session_wait_time( MoshSession *s ) +{ + if ( !s || !s->network ) { + return -1; + } + return s->network->wait_time(); +} + +} /* extern "C" */ \ No newline at end of file diff --git a/native/redterm_mosh_client.h b/native/redterm_mosh_client.h new file mode 100644 index 0000000..d814907 --- /dev/null +++ b/native/redterm_mosh_client.h @@ -0,0 +1,37 @@ +/* + * The C API of the mosh client. See redterm_mosh_client.cpp. + * + * Deliberately C so the JNI layer and the host harness in + * /home/ubuntu/.cache/redterm-build/host_harness.c can both use it without a C++ + * toolchain of their own. + */ +#ifndef REDTERM_MOSH_CLIENT_H +#define REDTERM_MOSH_CLIENT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct MoshSession MoshSession; + +MoshSession *mosh_session_start( const char *key, const char *ip, const char *port, + int cols, int rows, char *errbuf, size_t errlen ); +void mosh_session_stop( MoshSession *s ); +void mosh_session_free( MoshSession *s ); +void mosh_session_input( MoshSession *s, const uint8_t *bytes, size_t n ); +void mosh_session_resize( MoshSession *s, int cols, int rows ); +size_t mosh_session_poll( MoshSession *s, uint8_t *out, size_t maxlen ); +int mosh_session_is_connected( MoshSession *s ); +int mosh_session_shutdown_requested( MoshSession *s ); +int mosh_session_wait_time( MoshSession *s ); +int mosh_session_network_errors( MoshSession *s ); +size_t mosh_session_last_error( MoshSession *s, char *out, size_t outlen ); + +#ifdef __cplusplus +} +#endif + +#endif /* REDTERM_MOSH_CLIENT_H */ diff --git a/native/redterm_mosh_main.cpp b/native/redterm_mosh_main.cpp new file mode 100644 index 0000000..9e6253c --- /dev/null +++ b/native/redterm_mosh_main.cpp @@ -0,0 +1,491 @@ +/* + * redterm-mosh: the native client a mosh terminal session runs. + * + * This is an executable rather than a library call because TerminalSession is final and + * its constructor takes a shell *path*: it builds the subprocess itself through + * JNI.createSubprocess, so a session that is not a process cannot be a session. The app + * already ships real executables as per-ABI assets (`assets/ssh//`) extracted into + * the rootfs and run through proot's loader, and mosh takes exactly that path — which + * means TerminalSession, the emulator, split panes, resize and the session list need no + * change at all. + * + * It is mosh's client without curses: mosh's own SSP client, its terminal state machine + * and its rendering, with stdio in place of a terminal. What it deliberately does not + * have is a display. Display::new_frame() is what turns a framebuffer diff into escape + * sequences, and that is kept; the curses capability discovery in Display's constructor + * is what cannot be kept, and mosh_display_compat.cpp supplies it. + * + * The endpoint arrives in the environment, never on the command line: + * + * MOSH_KEY the secret half of MOSH_KEY -- the key ssh's bootstrap printed + * MOSH_IP the address to send UDP to, the far end's own $SSH_CONNECTION field + * MOSH_PORT the UDP port mosh-server chose + * MOSH_COLS, MOSH_ROWS initial size + * + * The launcher script that runs this is written to app storage and logged on the way + * past, so an argument would put the session key on disk in the one place it was promised + * not to be. It is read once, handed to the session, and unset straight away. + * + * Exit codes are a protocol, not a log format: the app reads them to decide what to tell + * the user, so 4 and 5 are kept distinct. + * + * 0 the session ended normally: stdin closed, or mosh-server said goodbye + * (the remote shell exited) and was acknowledged on its own protocol + * 2 a required variable was missing or malformed + * 3 the session would not start + * 4 nothing ever came back — mosh-server answered but the UDP path does not + * + * There is deliberately no "went quiet afterwards" code: a session that has spoken once is + * left alone, because silence is what a terminal is most of the time. + * + * 4 is the one that matters and is worth separating from 5: it means the session never + * functioned, so there is nothing for the user to be doing and the right answer is to + * stop. 5 means a session that was real, which is a different thing to report. + * + * 1 is left alone so a shell reporting an ordinary failure is never confused with ours. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "redterm_mosh_client.h" + +/* Set from a signal handler, so it must be the type that is written atomically. */ +/* How long to block on stdin between passes, at minimum and at most. The minimum is + * what yields the core to the session's worker thread; see the note at the call site. */ +static const int IDLE_POLL_MS = 20; +static const int MAX_POLL_MS = 100; + +static volatile sig_atomic_t window_changed = 0; + +/* Writes one line and ends it. + * + * Used only for the failure report, never for progress. + * + * Three versions of a progress line got this wrong. Rewriting one line in place with + * `\r\033[2K`, which is what mosh's own client does, orphans it the moment the far end + * scrolls -- that escape clears *the current* line, so the old one is stranded at the top + * and a second copy appears at the bottom with the shell printing under it. Printing the + * status inside the terminal at all was the deeper mistake: it competes with the shell's + * output for the same screen, and the user has to read a connection state in the same + * place they would be reading a command they just typed. + * + * Connecting is the app's business, and the app has a proper place to say so. */ +static void say( const char *text ) +{ + fprintf( stdout, "%s\r\n", text ); + fflush( stdout ); +} + +/* Waits for a line on stdin, so a failure stays on screen until it is acknowledged. + * + * Bounded, because an unbounded wait on a session nobody is watching is a session that + * never closes and never opens anything else. Returns at once if stdin has already gone, + * since there is nobody left to press a key. */ +static void wait_for_ack( void ) +{ + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + pfd.revents = 0; + for ( int i = 0; i < 60; i++ ) { + const int rv = poll( &pfd, 1, 2000 ); + if ( rv < 0 ) { + if ( errno == EINTR ) { + continue; + } + return; + } + if ( rv == 0 ) { + continue; + } + char drain[ 256 ]; + if ( read( STDIN_FILENO, drain, sizeof( drain ) ) <= 0 ) { + return; + } + return; + } +} + +/* Milliseconds on a monotonic clock. + * + * Counting loop iterations instead of measuring elapsed time is the obvious way to write + * a give-up timer and it is wrong: this loop's iteration time is whatever mosh's transport + * happens to want, and that is frequently zero -- poll is then called with a zero timeout + * and returns immediately, so fifty "rounds" can be a few milliseconds. The client then + * gives up on a session that was never given a chance to answer, which is exactly what + * happened here: the shim exited with "the connection went away" before the first UDP + * packet had been read. */ +static long long monotonic_ms(void) +{ + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { + return 0; + } + return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static void on_sigwinch(int) +{ + window_changed = 1; +} + +static void usage(void) +{ + fputs( + "redterm-mosh: internal client for RedTerm; not meant to be run by hand.\n" + "\n" + "It reads MOSH_KEY, MOSH_IP, MOSH_PORT, MOSH_COLS and MOSH_ROWS from the\n" + "environment. Start it through the app, or through a launcher script it writes.\n", + stderr); +} + +/* Reads a required variable, or returns NULL having said why on stderr. */ +static const char *need_env(const char *name) +{ + const char *value = getenv(name); + if (value == NULL || *value == '\0') { + fprintf(stderr, "redterm-mosh: %s is not set\n", name); + return NULL; + } + return value; +} + +static int env_int(const char *name, int fallback) +{ + const char *value = getenv(name); + if (value == NULL || *value == '\0') { + return fallback; + } + char *end = NULL; + long parsed = strtol(value, &end, 10); + if (end == value || parsed <= 0 || parsed > 65535) { + return fallback; + } + return (int)parsed; +} + +/* Writes everything or reports how much is left, because a short write to a terminal + * is normal and treating it as an error would drop output. */ +static int write_all(int fd, const unsigned char *data, size_t len) +{ + size_t done = 0; + while (done < len) { + const ssize_t n = write(fd, data + done, len - done); + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -1; + } + if (n == 0) { + break; + } + done += (size_t)n; + } + return (int)done; +} + +/* The remote end's idea of our size, so a resize reaches mosh rather than being + * guessed at. Zero rows and columns is a pty that has not been sized yet, which is + * what a very early SIGWINCH looks like and is not worth acting on. */ +static void apply_size(MoshSession *s) +{ + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0) { + return; + } + if (ws.ws_col == 0 || ws.ws_row == 0) { + return; + } + mosh_session_resize(s, ws.ws_col, ws.ws_row); +} + +int main(void) +{ + const char *key = need_env("MOSH_KEY"); + const char *ip = need_env("MOSH_IP"); + const char *port_env = need_env("MOSH_PORT"); + if (key == NULL || ip == NULL || port_env == NULL) { + usage(); + return 2; + } + + const char *host = ip; + const char *port = port_env; + + int cols = env_int("MOSH_COLS", 80); + int rows = env_int("MOSH_ROWS", 24); + + /* Raw mode. Without it the pty line-disciplines every byte we forward: a + * keystroke would not reach the remote shell until Enter, and Ctrl-C would be + * swallowed by the local tty instead of interrupting the remote process. + * A failure here is not fatal -- a session with no pty is still usable -- so it + * is reported and the session is attempted anyway. */ + struct termios saved; + const bool has_tty = isatty(STDIN_FILENO) && tcgetattr(STDIN_FILENO, &saved) == 0; + if (has_tty) { + struct termios raw = saved; + raw.c_iflag &= (unsigned)~(IXON | ICRNL | INLCR | IGNCR); + raw.c_lflag &= (unsigned)~(ECHO | ICANON | ISIG | IEXTEN); + raw.c_oflag &= (unsigned)~(OPOST); + raw.c_cc[VMIN] = 0; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) { + fprintf(stderr, "redterm-mosh: could not set raw mode: %s\n", strerror(errno)); + } + } + + char errbuf[512]; + errbuf[0] = '\0'; + MoshSession *session = mosh_session_start(key, ip, port_env, cols, rows, errbuf, sizeof(errbuf)); + + /* The secret has been handed over; leave nothing behind for a later /proc read + * or a core dump. */ + unsetenv("MOSH_KEY"); + memset((void *)key, 0, strlen(key)); + + if (session == NULL) { + fprintf(stderr, "redterm-mosh: %s\n", errbuf[0] ? errbuf : "could not start"); + if (has_tty) { + tcsetattr(STDIN_FILENO, TCSANOW, &saved); + } + return 3; + } + + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = on_sigwinch; + sigaction(SIGWINCH, &action, NULL); + + int exit_code = 0; + /* mosh tolerates loss, so silence is normal and one quiet second means nothing. + * These are two separate waits because they are two different questions: how long + * to wait for the first byte at all, and how long a connection that has spoken once + * may then go quiet. Both are measured in milliseconds, never in iterations. */ + const long long connect_grace_ms = 20000; + const long long start_ms = monotonic_ms(); + /* Whether anything has arrived from the network *yet*. + * + * Not "whether we have rendered anything": the very first render differs from the + * initial framebuffer because the remote's terminal size is not the one we were + * seeded with, so mosh emits a diff, and that diff is ours rather than the server's. + * Timing on that set gave up the moment anything was drawn at all — including the + * one thing that happens before a single packet has arrived. */ + bool heard_from_server = false; + + /* No progress line here. Connecting is the app's business and the app has a proper + * place to say so — TerminalActivity's connecting overlay covers the attach and + * ends on the session's first drawn output. A status printed into the terminal + * competes with the shell's output for the same screen, and the in-place rewrite + * the erase relied on strands itself the moment the far end scrolls; say() is for + * the failure report only, and the history of the three wrong versions is written + * into its comment above. */ + + for (;;) { + unsigned char out[4096]; + size_t got = 0; + while (got < sizeof(out)) { + const size_t n = mosh_session_poll(session, out + got, sizeof(out) - got); + if (n == 0) { + break; + } + got += n; + } + if (got > 0) { + if (write_all(STDOUT_FILENO, out, got) < 0) { + break; + } + } + + if (window_changed) { + window_changed = 0; + apply_size(session); + } + + /* The remote shell has exited, part one: mosh-server says so itself. + * + * `exit` at the far end closes the shell's pty, and mosh-server reacts by + * putting the connection into its shutdown sequence: ordinary encrypted + * packets whose state number is -1, resent with the same backoff as any + * other state — so they arrive wherever the session's packets arrive, + * including through a NAT that drops every ICMP error. The client answers + * with its own ack and the two sides are done: no error, no guesswork. + * + * Ignoring this signal is what left a session sitting at `logout` for ever: + * by the time the server is gone every state is acknowledged, so SSP sends + * nothing more, the network is silent in both directions, and the + * receive-error detector below waits for errors that the silent network + * never raises. Measured on this host before the fix: killed after 50 + * seconds, still running. */ + if ( mosh_session_shutdown_requested( session ) ) { + exit_code = 0; + break; + } + + /* The remote shell has exited, part two: only when the server had no chance + * to say goodbye — a crash on the far end rather than a shell exiting — does + * the receive side carry the news. + * + * Counted rather than taken from the first failure because one refused packet is + * not a verdict -- mosh itself tolerates loss silently, and a stray ICMP should + * not end a live session. Several in a row, with no successful receive in + * between, means the port is closed rather than unlucky. */ + if (heard_from_server && mosh_session_network_errors( session ) >= 5 ) { + break; + } + + if (!heard_from_server && mosh_session_is_connected(session)) { + heard_from_server = true; + /* Two stages rather than one: "connected" and "you have a shell" are + * different moments, and the gap between them is exactly when a user decides + * the app has hung. */ + } + + { + /* Two separate waits, compared against two separate baselines. + * + * Writing this as `now - last_rx_ms > limit` with last_rx_ms starting at + * zero looks correct and is not: the clock here is CLOCK_MONOTONIC, so "now" + * is the milliseconds since boot -- millions of them -- and zero is not "now, + * a moment ago" but "before the machine started". The comparison is + * therefore true on the very first iteration and the client gives up + * instantly. + * + * Nothing caught it on loopback, where the handshake comes back inside the + * same iteration. Against a real server the round trip is a hundred + * milliseconds and upwards, so the check ran first and every session exited + * 4 immediately -- which read as "the server refused to speak mosh" and was + * the entire mosh feature failing for two reasons stacked. */ + /* Only the *first* frame is waited for. + * + * There is deliberately no silence timeout after that. A mosh session sits idle + * for minutes while somebody reads a man page, and killing it for being quiet + * would break the one thing mosh is for — surviving a network that would have + * dropped a TCP flow long before. mosh's own client has no such timer either. + * + * A previous version had one, at 60s, and it made the failure worse rather + * than better: the first bytes to arrive switch the timer from "waiting for + * the session" to "waiting for a shell", so a handshake that completed but + * produced nothing usable left the user staring at a blank terminal for a full + * minute. One timer, about one thing, is easier to reason about than two. */ + const long long now = monotonic_ms(); + if (!heard_from_server && now - start_ms > connect_grace_ms) { + /* This is the case a firewall produces, and it is worth saying so. + * + * The bootstrap succeeded, so mosh-server is installed, running and holding + * a UDP port — and the usual reason nothing comes back is that nothing can + * reach that port. The range is named here because the single most useful + * thing to tell someone staring at an empty terminal is which firewall rule + * is missing. */ + /* Both causes, because measuring one of them is not enough. + * + * The obvious answer is the server's firewall, and it is the one worth + * checking first -- but on a cloud instance it is genuinely possible for + * the server to be configured correctly and for the packets still never + * to arrive, because the phone's own network can refuse to carry them + * and its NAT can leave no way for a reply to come back. Both were + * measured here: the instance firewall was opened, persisted, and its + * packet counter stayed at zero across four separate attempts. Naming only + * the server would send someone to fix something that was already right. */ + /* \r\n rather than \n throughout: raw mode is on by this point, so a bare + * newline moves down a line without returning the carriage, and the + * explanation lands as a staircase down the left edge. */ + { + char heading[ 256 ]; + snprintf( heading, sizeof( heading ), + "mosh: mosh-server started, but nothing at all arrived from " + "%s in %llds.", + host, (long long)(connect_grace_ms / 1000) ); + say( heading ); + } + { + char body[ 700 ]; + snprintf( body, sizeof( body ), + " Not one UDP packet reached the server, which is one of two " + "things:\r\n" + " 1. UDP %s is not open on the server, all the way through " + "the cloud's security list and the host's own firewall; or\r\n" + " 2. this device\u2019s network does not carry UDP to that " + "host, which is common on mobile networks behind carrier " + "NAT.\r\n" + " SSH works in both cases; only mosh needs the UDP.\r\n" + "\r\n" + "Press Enter to close this session and open one over SSH.", + port ); + say( body ); + } + wait_for_ack(); + exit_code = 4; + break; + } + } + + /* mosh's own idea of how long it has nothing to do, bounded so a long wait + * still notices a keystroke promptly. + * + * The floor is not a nicety. The session pumps its own network on a worker + * thread, and when mosh's timers are due its wait time is zero -- so polling + * stdin with that value returns immediately and this loop spins. On a + * single-core device the spinner and the worker thread then divide one core + * between them and the worker, which is the only thing that can bring data in, + * gets almost none of it: the session sits there with no output and no error + * until the give-up timer fires. Yielding for a moment each pass costs at most + * a frame of keystroke latency and is what makes the two halves able to run at + * all. */ + int wait = mosh_session_wait_time(session); + if (wait < IDLE_POLL_MS) { + wait = IDLE_POLL_MS; + } else if (wait > MAX_POLL_MS) { + wait = MAX_POLL_MS; + } + + struct pollfd pfd; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + pfd.revents = 0; + const int rv = poll(&pfd, 1, wait); + if (rv < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (rv > 0 && (pfd.revents & (POLLIN | POLLHUP)) != 0) { + unsigned char in[1024]; + const ssize_t n = read(STDIN_FILENO, in, sizeof(in)); + if (n > 0) { + mosh_session_input(session, in, (size_t)n); + } else if (n == 0) { + /* stdin closed: the terminal went away, which is the one exit + * that is not a failure worth a message. */ + break; + } + } + } + + char why[ 512 ]; + /* A session the server said goodbye to is not a failure, and an error that was + * noted earlier in its life is not the last thing the user should read under + * `logout`: the server hangs up its own way, whatever else happened before. */ + const bool clean_goodbye = mosh_session_shutdown_requested( session ) != 0 && + mosh_session_network_errors( session ) < 5; + if ( !clean_goodbye && mosh_session_last_error( session, why, sizeof( why ) ) > 0 ) { + fprintf(stderr, "redterm-mosh: %s\n", why); + } + + if (has_tty) { + tcsetattr(STDIN_FILENO, TCSANOW, &saved); + } + mosh_session_free(session); + return exit_code; +} \ No newline at end of file