Files
tty7/mobile
l0ng-ai 0c2033ab07 feat(mobile): run on Android, and release a signed APK (#1044)
* feat(mobile): run on Android

The app builds and runs on Android, and fits there.

- Back button and gesture close what is open over the screen, then go
  back a screen, and from the first one send the app to the background.
  Left to the WebView they closed the app, since it has no history.
- The system bars: the page asks their size of the app (`insets`, over
  JNI), since WebViews before 140 report the safe areas as 0 even edge
  to edge. The CSS reads them as `--inset-*`, which iOS still fills
  from `env()`.
- The keyboard: edge to edge, the WebView is not resized for it and its
  visual viewport stays whole, so the keyboard covered the message box.
  MainActivity hands its height to the page, which lays out as on iOS.
- The title folds into the bar from the scroll position on every screen.
  The observer it used reported a title in plain view as out of sight on
  Android, so the bar started folded.
- src-tauri/gen/android is kept in the repo for that MainActivity.

* ci(mobile): release a signed Android APK

A `mobile-v<x.y.z>` tag builds the app for arm64 at that version, signs
it with the release key and attaches it to a draft release. The mobile
app is versioned apart from the desktop's `v*` tags.

- The release is never marked latest: the desktop updater reads
  /releases/latest and would take it for a desktop release.
- The APK's certificate is checked against the release key's, since one
  signed with another key could not be installed over earlier ones.
- The NDK is pinned, and the version must be x.y.z: Tauri derives the
  versionCode from it, and Android installs over a build only when that
  is higher.
- Gradle signs a release build when keystore.properties names a key;
  CI writes it from secrets.
- The release library is stripped: 30 MB to 20, the APK 32 to 23.

* ci: tell people how to install the phone app

The nightly release notes and each mobile-v release now say how to get
the app on a phone: the TestFlight link for iPhone, and the newest
mobile-v release's APK for Android. Both read .github/mobile-install.md.
2026-09-30 17:17:06 +08:00
..

tty7 mobile

Watch and drive your desktop's tty7 panes and agents from a phone.

phone (Tauri: WebView + Rust)  ──iroh──▶  tty7-gateway  ──local sockets──▶  tty7 server
   xterm.js ◀─ raw bytes ─ tty7-mobile-client          (crates/tty7-gateway)
  • Transport: iroh. The phone dials the desktop by public key. Connections hole-punch to a direct path when the network allows and fall back to a relay when it doesn't. Either way they're end-to-end encrypted. No port forwarding, no VPN.
  • Protocol: crates/tty7-mobile-proto. The phone never speaks the daemon's own protocols. The gateway exposes a small vocabulary: pair, a live tree of workspaces, tabs, panes and agent status, and one stream per open pane.
  • Panes are observed, not attached. A phone never takes a pane away from the desktop window showing it. Keystrokes go in beside the observer (SendInput). The terminal keeps the desktop's size, and the app shrinks the font to fit the width.
  • Take over. The phone button runs the pane at the phone's grid instead: a size lease the daemon holds for the observer (ClientMsg::Lease, feature size-lease). The desktop window keeps its grid, shows "In use on " with Take Back, and a resize there is remembered for when the lease ends. It ends when the phone lets go, when the desktop takes it back, or when the phone's stream closes — a phone that just drops off gives the pane back once the connection times out.
  • Auth: pairing with a one-time code, valid 10 minutes, single use. After that the gateway admits only the phone keys on its device list (<config dir>/mobile/devices.json).

Run it

On the desktop: Settings → Mobile → Allow phone access. The local daemon runs the gateway from then on, with every window closed too. Show code there gives a QR code and a tty7pair: code, and the phones it paired are listed below it, each with an Unpair button.

Whichever daemon is running starts the gateway as its own child process and stops it with the switch. tty7-app --daemon runs itself as tty7-app --mobile-gateway. The lean tty7-server runs a tty7-gateway from beside it or on PATH, and without one it says so in Settings. The gateway exits when its daemon does, so an update never leaves an old one running.

Without the GUI, the same gateway runs from the command line. It shares the state in <config dir>/mobile/, so the two never run at once:

cargo run -p tty7-gateway -- serve     # keep this running
cargo run -p tty7-gateway -- pair      # prints a QR code + a tty7pair:… code
cargo run -p tty7-gateway -- devices   # paired phones; `revoke <name>` removes one

The app on the desktop (fastest loop)

Tauri builds the same app for macOS, which is the quickest way to work on the UI:

cd mobile
npm install
npm run tauri dev

Paste the tty7pair: code and tap Pair.

iOS

Needs the full Xcode, not just the Command Line Tools, plus an Apple developer account to run on a device.

rustup target add aarch64-apple-ios aarch64-apple-ios-sim
cd mobile
npm run tauri ios init       # once: generates src-tauri/gen/apple
npm run tauri ios dev        # simulator, or pick a connected device

To send a build to TestFlight (Xcode signed in to an account on the team):

scripts/testflight.sh              # archive, sign for the App Store, upload
scripts/testflight.sh --no-upload  # a signed .ipa in build/testflight/ instead

The build is <version>.<n>; n defaults to the time, so each upload is higher than the last. --build-number 7 picks it.

Android

Needs the Android SDK and NDK, with ANDROID_HOME and NDK_HOME set, and a JDK 17 to 21 as JAVA_HOME.

rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
cd mobile
npm run tauri android dev                                        # emulator or a connected phone
npm run tauri android build -- --debug --apk --target aarch64   # an installable .apk

src-tauri/gen/android is kept in the repo, unlike gen/apple: its MainActivity hands the keyboard's height to the page, which the WebView does not report edge to edge. Don't re-run android init over it.

To release an APK, push a mobile-v<x.y.z> tag, higher than the last. .github/workflows/mobile.yml builds it at that version, signs it with the release key and attaches it to a draft release, which is never marked latest, so the desktop updater doesn't see it. People download the APK on the phone and open it. A later one installs over it only if it's signed with the same key and its version is higher.

The release key is in the ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_ALIAS secrets, with a copy kept outside GitHub. A local release build signs with it when src-tauri/gen/android/keystore.properties (ignored by git) names it:

storeFile=/path/to/tty7-release.jks
storePassword=…
keyAlias=tty7
keyPassword=…

Without a phone

crates/tty7-mobile-client/examples/probe.rs is a phone in a shell. It pairs, prints the tree, and times keystroke echo on a pane:

cargo run -p tty7-mobile-client --example probe -- pair '<code>'
cargo run -p tty7-mobile-client --example probe -- tree
cargo run -p tty7-mobile-client --example probe -- type 1 'echo hi'

Finding the computer again

A pairing code carries the gateway's addresses at the time it was made. Those stay good across restarts: serve listens on the same UDP port every time (<config dir>/mobile/port, a fresh one only if it is taken). When they go stale anyway — a new DHCP lease, a new IPv6 prefix — the phone looks the gateway up by key: through n0's relay and DNS where those are reachable, and by mDNS (_tty7._udp) on the same local network where they are not.

mDNS needs the OS's local-network permission on both ends. On macOS that belongs to whatever launched the gateway (your terminal), and it answers "Allow … to find devices on local networks" once. On iOS the app declares it in src-tauri/Info.ios.plist. Without it, known addresses and the relay still work.

Not done yet

  • QR scanning. The app takes a pasted code for now. Next step is tauri-plugin-barcode-scanner on mobile.
  • Push notifications when an agent starts waiting. The gateway sees the status change, but APNs/FCM delivery needs a small native plugin and a push relay.
  • Keychain / Keystore for the phone's key. It lives in the app's private data directory today.
  • A self-hosted iroh relay for production, instead of n0's public ones.