Introduction
Zuko
Private remote shells for machines you own—without inbound ports or a VPN.
zuko’s supported core is a Linux/macOS host and Rust CLI. Iroh handles dial-by-key reachability, relay fallback, NAT traversal, and encrypted transport; zuko handles a real PTY, one-time-code pairing, client authorization, and short reconnects.
Start here
- Install and connect on Linux or macOS.
- Run the Linux host under WSL2 on Windows, with the documented lifecycle limits.
- Download a client or build one from a fresh clone.
Use tmux, zellij, or screen for durable work. zuko deliberately does not
store detached output or promise that PTYs survive a host restart.
Useful commands:
zuko ls
zuko rm <name>
zuko reset
zuko doctor
zuko upgrade --check
zuko app --doctor
Read next
- Host operations
zuko app(Labs)- Wire protocol for client authors
- Direction and roadmap
- Releasing
- Security
The shared Flutter client is the sole graphical client implementation. Android,
iOS/iPadOS, macOS, and Linux are Beta; the
web client and Windows bundle remain Labs because
their browser and installer/upgrade gates are incomplete. zuko app is also
Labs. See Clients for current delivery channels and the
roadmap for remaining promotion gates.
Source: github.com/adonm/zuko.
Install and connect
Zuko’s host and reference CLI are the same binary. Host releases support glibc Linux and macOS on x86_64 and ARM64.
Install the CLI
The installer detects or bootstraps mise, configures activation for Bash, Zsh, or Fish, and installs Zuko as a global mise tool:
curl --proto '=https' --tlsv1.2 -LsSf https://zuko.adonm.dev/install.sh | sh
# Relaunch your shell here if the installer asks.
zuko --version
If the installer adds mise activation to your shell profile, exit and relaunch
the shell before running zuko. Its Zuko tool entry sets mise’s minimum release
age to 0s so a newly published Zuko release is immediately available; this
does not change the global policy for other tools. Re-running the installer
upgrades an existing mise-managed Zuko installation. To inspect the script
before running it:
curl --proto '=https' --tlsv1.2 -fsSLo /tmp/zuko-install.sh \
https://zuko.adonm.dev/install.sh
less /tmp/zuko-install.sh
sh /tmp/zuko-install.sh
Optional settings:
# Install one release rather than latest.
curl --proto '=https' --tlsv1.2 -LsSf https://zuko.adonm.dev/install.sh |
ZUKO_VERSION=0.10.13 sh
Update with zuko upgrade or mise upgrade github:adonm/zuko. Restarting the
host service ends its in-memory PTYs, so zuko upgrade shows the plan before it
does so.
Start the host
Install and start the per-user service:
zuko install
zuko doctor
Linux uses a systemd user unit. A server that must continue after logout also needs lingering:
sudo loginctl enable-linger "$USER"
journalctl --user -u zuko-host -f
macOS uses a LaunchAgent. Follow its log with:
tail -f "${XDG_CONFIG_HOME:-$HOME/.config}/zuko/zuko-host.err.log"
You can avoid service installation and keep the host in the foreground:
zuko host
Windows does not have a native host service. See Windows host through WSL2 for the Linux-host workaround and its limitations.
Pair a client
Install Zuko on a second machine or choose another client. Then:
# Host: print a one-time two-word code.
zuko share
# Client: claim the code, save the host, and connect.
zuko iridescent-hilton
The host must be running while you pair. zuko share authorizes that client;
the code is not a reusable password. Future connections use the saved host
name:
zuko ls
zuko home
Use zuko rm <name> to revoke a client or forget a host. Continue with
host operations for service control, state, reset, diagnostics, and
session behavior.
Host operations
zuko is one binary: host daemon, CLI client, pairing helper, temporary TCP
tunnel and file-share helper, service installer, upgrader, and Linux zuko app
launcher.
For binary installation, first service setup, and pairing, start with Install and connect.
Commands
zuko host # foreground host
zuko install # install/start user service
zuko uninstall # remove service; keep user state
zuko upgrade # mise-managed binary upgrade
zuko doctor # service, ticket, state, network diagnostics
zuko share # authorise a client with a one-time code
zuko <code> # claim, save, connect
zuko claim <code> # flags: --as, --no-connect, --timeout
zuko <name> # connect to saved host
zuko connect <name>
zuko # TTY picker / non-TTY list
zuko ls # saved hosts + authorised clients
zuko rm <name> # remove saved host and/or authorised client
zuko reset # rotate host key; clear authorised clients
zuko tunnel <port> # inside hosted shell: client → host loopback TCP
zuko files # current directory via foreground dufs + tunnel
zuko app <command> # Linux GUI app over Kitty graphics
State
| Path | Role |
|---|---|
$ZUKO_CONFIG/key | host identity |
$ZUKO_CONFIG/current_ticket | live dial ticket, refreshed by host |
$ZUKO_CONFIG/authorized_clients | host allow-list |
$ZUKO_CONFIG/hosts | client-side saved hosts |
$ZUKO_CONFIG/client_key | CLI client token seed |
Here $ZUKO_CONFIG means ${XDG_CONFIG_HOME:-$HOME/.config}/zuko. All secret
state is user-local and written 0600 where Unix permissions apply.
Service control
zuko install writes ~/.local/bin/zuko-host-run, installs the platform user
service, and enables and starts it. Rerunning it updates that configuration.
Linux:
systemctl --user status zuko-host
systemctl --user restart zuko-host
journalctl --user -u zuko-host -f
sudo loginctl enable-linger "$USER" # servers that must run without login
macOS:
tail -f "${XDG_CONFIG_HOME:-$HOME/.config}/zuko/zuko-host.err.log"
Install flags:
| Flag | Default |
|---|---|
--prefix | ~/.local |
--key | $ZUKO_CONFIG/key |
--shell | $SHELL |
--no-start | disabled |
Foreground host:
zuko host --shell /bin/bash --cwd "$HOME"
zuko host --detached-ttl 0 # detached sessions stay resumable until shell exit/restart
Pair and connect
# host
zuko share
# client
zuko <code>
claim saves the ticket under the host label unless --as <name> is set.
On an interactive terminal, share also renders a QR containing only the
one-time code for graphical clients with camera support. The long-lived ticket
is never in the QR, and stdout remains the plain code so scripts can continue
to pipe it.
After pairing:
zuko ls
zuko <name>
The host admits only tokens in authorized_clients. Pairing writes that list.
Trust management
zuko ls
zuko rm ipad
zuko reset
zuko reset --yes
reset removes key, removes current_ticket, and writes an empty
authorized_clients. Restart the host, then re-pair each client.
Session behavior
- Host runs a real PTY with
TERM=xterm-256color. - Shell exit ends the session and kills the PTY.
- Network/client drop detaches the PTY for up to 6 hours by default
(
zuko host --detached-ttl <seconds>;0keeps detached sessions until the shell exits or the host restarts). - Detached output is discarded.
- CLI reconnects while the process is alive; Flutter redials while its screen is active; Flutter clients use bounded reconnect while their session is open.
- Use
tmux,zellij, orscreenfor durable work.
Force-exit a stuck CLI: Ctrl-C three times within ~1s with no remote output.
Pairing internals
share derives a throwaway Iroh key from the code, serves
<label>\n<ticket> over ALPN zuko/handoff/1, then receives the client’s
AUTHORIZE frame. claim retries the handoff dial for --timeout seconds
(default 60). share reads current_ticket; interactively it offers to start
the service when that file is unavailable, while non-interactive use fails.
zuko share flags:
| Flag | Default | Notes |
|---|---|---|
--ticket | current_ticket | advanced override; argv may expose the ticket, so prefer the file |
--label | hostname | default save name on client |
--count | 1 | accepted claims before exit |
--timeout | 300 | seconds; 0 waits forever |
Upgrade
Mise-managed install:
zuko upgrade --check
zuko upgrade
zuko upgrade --version 0.10.13
zuko upgrade --no-restart
The curl installer creates this mise-managed installation, so the same upgrade commands apply. Restarting the service kills in-memory PTYs.
Debug
RUST_LOG=iroh=info zuko home
RUST_LOG=iroh=debug zuko home
Host logs are stderr in foreground, systemd journal on Linux, and
$ZUKO_CONFIG/zuko-host.err.log on macOS.
Troubleshooting
Start with the read-only diagnostic report:
zuko doctor
It checks whether the platform user service is installed and active, validates
the host key and fresh ticket without printing either, summarizes saved hosts
and authorized clients, and performs a 10-second Iroh relay-registration probe.
Warnings include the next command to run; a client-only installation may
legitimately warn that no local host is installed. Pass --key <path> if the
host service was installed with a non-default key path.
zuko share reports a missing or stale ticket
The host service must be running and refreshing current_ticket. Check its log,
then restart it with the platform service manager or run zuko host in the
foreground. Do not copy a raw ticket around as a workaround.
The host rejects authorization
The client’s token is no longer in authorized_clients, usually after
zuko rm or zuko reset. Run zuko share on the host and claim the new code
from that client. Repeated dialing cannot repair an authorization failure.
A reconnect opens a fresh shell
The detached lease lasts 6 hours by default (zuko host --detached-ttl <seconds>) and exists only in the host process. Expired leases, host restarts,
and upgrades create a fresh PTY. Use a terminal multiplexer for durable work.
A connected full-screen app looks stale
Resize the local terminal to trigger a repaint. On iOS, use the Refresh action. If the link is wedged, use the CLI force-exit sequence and reconnect.
Build/test
mise bootstrap
just test
just test-e2e
The e2e test uses a real PTY and the live Iroh network. See Contributing for the full check graph.
Windows host through WSL2
Zuko has a native Windows client, but no native Windows host or Windows service. WSL2 can run the glibc Linux host and expose a WSL Linux shell. It does not expose PowerShell or a native Windows desktop session.
This is a practical, best-effort setup rather than a Core always-on host. WSL shutdown, Windows restart or sleep, and host-process restart end every in-memory PTY. Prefer a Linux or macOS host when unattended availability matters.
1. Install WSL2 with systemd
In an Administrator PowerShell window:
wsl --install -d Ubuntu
wsl --update
Restart Windows if requested, open Ubuntu, and verify systemd:
ps -p 1 -o comm=
systemctl status
Current Ubuntu WSL installations enable systemd by default. If PID 1 is not
systemd, add this to /etc/wsl.conf inside Ubuntu:
[boot]
systemd=true
Then apply it from PowerShell:
wsl --shutdown
Open Ubuntu again and rerun systemctl status. See Microsoft’s
WSL systemd guide for distro
requirements and troubleshooting.
2. Install and start Zuko inside WSL
Run these commands in Ubuntu, not PowerShell:
curl --proto '=https' --tlsv1.2 -LsSf https://zuko.adonm.dev/install.sh | sh
# Exit and reopen Ubuntu here if the installer requests it.
zuko install
zuko doctor
Inspect the user service with:
systemctl --user status zuko-host
journalctl --user -u zuko-host -f
Pair normally with zuko share. The remote session starts the Linux shell
inside this WSL distribution.
Service lifetime
Microsoft explicitly notes that systemd services do not keep a WSL instance
alive. zuko install keeps the host supervised while the distribution is
running, but it does not turn WSL into an always-on VM.
For the most predictable interactive use, keep an Ubuntu window open and run:
zuko host
After wsl --shutdown, Windows restart, or a stopped distribution, open the
distribution again and check zuko doctor. A Windows Scheduled Task can launch
the distribution at sign-in, but Zuko does not test or install such a task.
Networking and firewall
Zuko/Iroh initiates outbound connections and does not expose a stable inbound
application port. Do not create a netsh interface portproxy rule for Zuko.
WSL2’s default NAT normally works through Iroh’s relay fallback; mirrored mode
can improve compatibility with VPNs and IPv6.
If zuko doctor cannot register with a relay:
- verify DNS and outbound HTTPS/QUIC inside WSL;
- review Windows Firewall and, on current Windows 11, Hyper-V firewall policy;
- check VPN or enterprise egress rules rather than disabling the firewall.
Microsoft references:
Clients
| Client | Status | Get it |
|---|---|---|
| Rust CLI | Core | curl installer or Linux/macOS release tarball |
| Android | Beta | Signed APK attached to tagged GitHub Releases |
| iOS/iPadOS | Beta | Internal TestFlight build produced from each release tag |
| macOS | Beta | CI application artifact; protected Mac App Store package workflow |
| Web | Labs | zuko.adonm.dev/web/ |
| Linux desktop | Beta | FlatPark |
| Windows desktop | Labs | Versioned x86_64 ZIP on GitHub Releases |
GitHub Release downloads are at
github.com/adonm/zuko/releases/latest.
Every package attached there has a .sha256 sidecar. The web deployment,
TestFlight build, and transient CI artifacts are separate delivery channels.
Fully signed public-store releases for every graphical target are still being worked on. The checksummed GitHub Release packages are the best source for testing current builds; they are not a claim that each platform’s store listing, review, installer, upgrade, and signing path is complete. iOS/iPadOS testing continues through the separate internal TestFlight channel.
- Android: install the signed APK; the AAB is for store upload.
- Linux: install the signed FlatPark package; credentials use the host Secret Service.
- Windows: extract the complete ZIP and run
zuko.exe; do not move the EXE away from its DLL and data files.
FlatPark is an independent community Flatpak hub. Add its signed remote and Flathub’s Freedesktop runtime source once, then install Zuko:
flatpak --user remote-add --if-not-exists flatpark \
https://dl.flatpark.org/flatpark.flatpakrepo
flatpak --user remote-add --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak --user install flatpark dev.adonm.zuko
flatpak run dev.adonm.zuko
The package downloads Zuko’s versioned Linux archive from the official GitHub Release and pins its SHA-256 and size; FlatPark signs the resulting package repository. It is not affiliated with Flathub. The release archive and checksum remain the upstream payload and provenance record; FlatPark owns the Flatpak wrapper and update channel. See Linux delivery through FlatPark.
The Windows ZIP attached to GitHub Releases is not an installer and does not provide automatic updates. A separate protected workflow can build and sign MSIX/MSIXBundle packages for Partner Center, but that path remains manual. For toolchains, fresh-clone commands, signing behavior, and exact output paths, see Building clients.
Implemented shared behavior
The current Flutter client provides the same application behavior on all six targets unless a platform note below says otherwise:
- pair by scanning the host’s one-time QR code on camera-capable targets or by entering its two-word code;
- preserve a stable client identity and saved hosts in protected platform storage, with invalid-state recovery;
- suggest and persist an editable device name for recognizable host-side authorization labels while retaining an identity-derived collision suffix;
- connect only after validating the saved endpoint ticket, host identity, and
ATTACHEDtoken; - expose connecting, attached, retrying, ended, rejected, and failed states, with bounded reconnect for transient failures;
- rename, inspect, and forget saved hosts, including the host-side revocation command when its authorized-client label is known;
- render a resizable
flterm/libghosttyterminal with scrollback, selection, copy, guarded multi-line paste, desktop keyboard/IME input, and mobile accessory keys, plus a screen-reader-readable visible viewport and terminal focus action; - persist system/light/dark theme and terminal font-size preferences.
The Linux shell uses Yaru’s Adwaita-red theme and an integrated draggable title bar with native window controls. Other desktop targets retain their platform window chrome.
This is a remote shell client, not a durable session manager: it has no output replay, and forgetting a host locally does not revoke that client on the host. Complete accessibility coverage and representative camera, physical-device, and browser testing remain promotion gates rather than advertised capabilities.
The Rust CLI and shared Flutter client are the behavior references. Former Compose, TypeScript, Relm4, and Swift UI implementations were removed.
The Flutter client shares:
- pairing and saved-host behavior;
- wire framing and bounded reconnect;
flterm/libghosttyterminal rendering;- secure-storage model and platform-neutral UI;
- Dart unit and widget tests.
Native targets use iroh_flutter. Browser Iroh remains relay-only and uses the
Rust/WASM bridge in flutter/rust/web_transport/. Platform-specific code is
reserved for credential storage, camera permissions, lifecycle, and packaging.
Apple builds use the same Flutter implementation as every other graphical target. A release tag automatically builds and uploads the protected iOS IPA to internal TestFlight. macOS store packaging and upload remain manual protected jobs; neither Apple package is attached to the GitHub Release.
Implementing or reviewing a client
For Flutter interaction and accessibility decisions, first read the
human-centered design guide. For transport behavior, read
protocol.md. A client must:
- claim through
zuko/handoff/1, derive the canonical Argon2 key, read the endpoint ticket, persist a stable client identity, and sendAUTHORIZE; - dial the saved endpoint ticket with ALPN
zuko/2; - open a bidirectional stream and send
ATTACHfirst; - reject terminal data until the host echoes the expected token in
ATTACHED; - serialize writes, chunk
DATAat 65,535 bytes, and sendRESIZEchanges; - treat host
ERRORand clean shell exit as permanent, while reconnecting only transient failures with bounded backoff; - cancel readers, writers, and pending retries on disconnect or host switch.
Reference implementations and fixtures:
- Rust:
src/wire.rs,src/client.rs,src/handoff.rs - Flutter:
flutter/lib/src/,flutter/test/ - Browser bridge:
flutter/rust/web_transport/
Flutter platform support
Zuko’s pre-1.0 support statement distinguishes an enforced package floor from runtime validation. A successful cross-platform compile is not a claim that every operating-system or browser generation has completed physical testing.
Current build floors and validation:
| Target | Enforced package/build floor | Automated validation |
|---|---|---|
| Android | API 35 minimum; SDK/build-tools 36; platform-tools 37.0; NDK 29.0 | shared tests plus ARM64 debug and signed release builds |
| iOS/iPadOS | 18.0 deployment target | ARM64 Simulator build, Appetize preview, and signed device IPA validation |
| macOS | 15.0 deployment target | release app build and protected Mac App Store package validation |
| Windows | Windows 10 package target, x86_64 build | release bundle build; protected MSIX/MSIXBundle validation is manual |
| Linux | x86_64 Wayland FlatPark package, Freedesktop 25.08 | release archive reproducibility and linkage checks; FlatPark package build/install/launch checks |
| Web | /web/ deployment on current browsers | shared tests and a release WASM build; no automated browser matrix yet |
Android 15+, iOS/iPadOS 18+, macOS 15+, Windows 10/11, the packaged Linux runtime, and current Chrome/Firefox/Edge/Safari are the intended test range. Only the floors and CI jobs above are mechanically enforced today.
Temporary zuko tunnel forwarding is supported by the native Android, iOS,
macOS, Windows, and Linux Flutter clients. Flutter Web cannot bind a local TCP
listener and ignores tunnel offers.
The apparent Apple version gap reflects Apple’s 2025 platform-version naming change. There was no public iOS/iPadOS 19 or macOS 16 release.
Floors are reviewed with each Flutter toolchain update. Raising a floor requires release notes and package metadata changes; broadening a support claim requires CI or recorded physical-device/browser coverage rather than only a successful compile.
Pairing and connection flow
This page walks through the end-to-end pairing experience in the Zuko clients: installing the host, claiming a one-time share code, and opening the terminal session.
The screenshots are rendered deterministically from the real widget tree
(Yaru themes, bundled fonts, and the terminal widget) by
flutter/test/screenshot_flow_test.dart. Regenerate them with
just screenshots whenever the pairing or connection UI changes.
1. First run
The welcome screen gives the two host-side commands: install Zuko, then
run zuko share to mint a one-time share code.
2. Claim the share code
On a phone, point the camera at the QR code that zuko share prints. The
animated viewfinder brackets the code; a failed claim offers both a retry
and the typed fallback.
Entering the code by hand accepts the canonical two-word form. Pasting
works with the full zuko share output — the code is extracted from
merged stdout/stderr, zuko claim lines, or zuko://pair URIs.
A successful claim confirms the host name before the connection opens.
3. Connect to a saved host
Selecting a saved host opens a terminal tab. While the peer connection is established, the session overlay reports progress and the tab shows the busy state.
If the link drops, the overlay counts down to the automatic reconnect attempt; Retry now reconnects immediately.
An attached session brings up the terminal with the accessory bar for touch devices and extended keys.
Re-pairing and revocation
A host that rejects the saved ticket shows a Pair again action. Pairing
again records the fresh ticket and the host-side client label, which the
host details dialog exposes as a zuko rm <label> revocation command.
Temporary TCP tunnels
zuko tunnel <port> forwards an ephemeral port on the attached client to
127.0.0.1:<port> on the host:
# Start a service in the background on host loopback, then tunnel it.
python3 -m http.server 8000 --bind 127.0.0.1 &
zuko tunnel 8000
The native client prints a line like:
zuko tunnel: client 127.0.0.1:49152 -> host 127.0.0.1:8000
It also opens http://127.0.0.1:49152/ for the common web-server case. The
port is not HTTP-specific: for a TLS service, open
https://127.0.0.1:49152/; for another TCP service, point its normal client at
127.0.0.1:49152. CLI users can set ZUKO_NO_BROWSER=1 before connecting to
suppress automatic browser launch.
Share files from the current directory
Inside a shell opened through Zuko, run:
cd /path/to/share
zuko files
The command uses dufs from PATH. If it is missing, Zuko checks for mise,
installs the pinned github:sigoden/dufs@0.46.0 tool, and runs it through
mise exec without modifying the user’s global mise configuration. It selects
an unused host port, passes the current directory explicitly, ignores inherited
DUFS_* overrides, starts dufs on 127.0.0.1, waits for it to listen, and then
opens the normal authenticated Zuko tunnel. Dufs stdout and stderr remain
attached, so startup and request logs appear beside tunnel statistics.
zuko files deliberately runs dufs -A. Anyone able to reach the temporary
client-loopback URL can upload, delete, search, create archives, calculate
hashes, and follow symlinks outside the shared directory under dufs’s
allow-all policy. Use it only for a directory and client machine you trust,
and press Ctrl-C immediately when finished.
Lifecycle and statistics
The host-side command stays in the foreground. It prints connection-open and connection-close events plus uploaded/downloaded byte totals. Zuko cannot print HTTP access logs because it deliberately does not inspect application traffic.
Press Ctrl-C to stop. Command exit closes its control lease; the host removes
the tunnel, closes active Iroh streams, and tells the native client to close
its loopback listener. For zuko tunnel, the target service is independent and
is not started or stopped by Zuko. zuko files is the explicit exception: it
supervises dufs and stops both dufs and the tunnel when either ends.
Security boundary
- The destination is fixed to host
127.0.0.1and the requested non-zero TCP port. A client cannot select another host or turn the tunnel into a LAN or Internet proxy. - The client listener binds only to
127.0.0.1on an ephemeral port. - Tunnel negotiation uses the separate
zuko/tunnel/1Iroh ALPN and requires both the existing authorized-client token and a random tunnel ID delivered over the authenticated terminal connection. - The hosted PTY receives a random per-session control capability. Keeping the control connection open owns the tunnel lease.
- Each local TCP connection maps to one Iroh bidirectional stream. A session may own at most 64 active tunnels, and each tunnel shares one 64-connection host concurrency limit across all authenticated Iroh connections.
Any process on the client machine that can reach the ephemeral loopback port can use it while the tunnel is active, matching normal local port-forwarding semantics. Stop the foreground command when the tunnel is no longer needed.
Native Rust CLI and native Flutter clients support tunnels. Flutter Web cannot bind a local TCP listener and therefore ignores optional tunnel offers.
zuko app (Labs)
Linux-only GUI app streaming. Run inside an existing zuko <host> shell.
This is an opt-in experiment, not part of the supported remote-shell core or a promise to become a full remote desktop. Expect terminal-compatibility, performance, and runtime-dependency gaps. See the roadmap.
Implementation: host spawns headless cage/wlroots, captures frames, writes Kitty graphics to stdout, and injects keyboard/mouse input back into cage.
Quick use
zuko app --list
zuko app text-editor
zuko app firefox
zuko app --fps 5 -- firefox --new-window
Flags go before the child command. Use -- before child flags.
Diagnostics
Run in this order:
zuko app --test-pattern
zuko app --doctor
zuko app --dry-run firefox
zuko app --debug-child firefox
If --test-pattern fails, fix terminal Kitty graphics or the zuko PTY path
before debugging cage/app launch.
Flags
| Flag | Default | Notes |
|---|---|---|
--list | — | list aliases |
--dry-run | — | print launch command/env |
--test-pattern | — | draw Kitty test image; no cage |
--doctor | — | check cage/protocol/geometry |
--debug-child | — | let child stdout/stderr through |
--no-sandbox | — | browser/container escape hatch |
--no-cursor | — | hide crosshair cursor overlay |
--fps | 30 | max frame rate |
--max-mbps | 80 | approximate graphics bandwidth cap; 0 disables |
--graphics-codec | auto | auto, png, rgb |
--scale | 1.0 | render below/above terminal pixel size |
--software | — | force software GL/WebRender in child |
Flatpak
Flatpak launches are Wayland-only cage children with --die-with-parent. zuko
detects exported Flatpaks and simple Exec=flatpak run <app-id> ... desktop
files.
Portal-heavy/full-desktop flows: run an RDP client inside zuko app and connect
to GNOME/KDE RDP on the host.
Runtime deps
x86_64 Linux release tarballs bundle cage plus uncommon wlroots libs next to the
zuko binary. Lookup order:
<exe_dir>/cage/~/.local/share/zuko/cagePATH$ZUKO_CAGE
Not bundled: libwayland, libxkbcommon, libdrm, libxcb, libinput,
libudev, mesa libEGL/libGLESv2.
aarch64 Linux currently needs cage on PATH.
Building clients
This page starts from a fresh clone and names each build’s output. The shared Flutter client targets Android, iOS, macOS, web, Linux, and Windows. Run commands from the repository root unless noted.
Recommended Ubuntu 24.04 Distrobox
The primary x86_64 Linux development environment is a version-pinned Ubuntu 24.04 Distrobox. Create it on the host, then run repository commands inside it:
distrobox create \
--name flutter-dev \
--image quay.io/toolbx/ubuntu-toolbox:24.04
distrobox enter flutter-dev
Do not use an unversioned latest image. Distrobox shares the host checkout,
display, GPU, devices, network, and home by default; it is a convenient mutable
development environment rather than a security boundary. Ubuntu 26.04 and
Fedora are useful additional compatibility checks, but Ubuntu 24.04 remains the
local and CI baseline.
Inside the box, install mise if it is not already available through the shared home, then bootstrap the native toolchain and activate Mise for the current shell:
mise trust
mise bootstrap
eval "$(mise activate bash)"
just check
Activation is explicit because Distrobox shares the host’s shell startup files
by default. Run the eval once in each plain shell. When Zuko is checked out
through the flutter-dev workspace, its just devbox-enter command starts
with Mise already active.
Hermetic Flutter compile recipes
The current full Linux-hostable compile matrix still uses the repository’s pinned Ubuntu 24.04 builder image. These recipes require a healthy rootless Docker or Podman engine reachable from the development box. The image contains the checksum-pinned Flutter beta SDK, Rust, JDK 17, Android SDK/NDK/CMake, GTK3, and web Wasm tools. Source is copied from a read-only mount into an ephemeral workspace; only artifact and cache directories are written back.
mise install just
mise exec -- just container-ci # Dart + web + Android + Linux
mise exec -- just container-all # preflight + quality + all Linux builds
Focused recipes avoid rebuilding unrelated targets:
just container-preflight # Rust + Flutter application tests
just container-web
just container-android # ARM64 debug APK compile gate
just container-android-release # unsigned release APK and AAB
just container-linux-build
just container-linux-bundle
just container-quality # actionlint + mdBook
just container-links # network link check; honors GITHUB_TOKEN
just container-e2e # live relay/PTY test; requires network access
The scripts use a healthy Docker engine by default, fall back to Podman, and
honor CONTAINER_ENGINE=docker or CONTAINER_ENGINE=podman when an explicit
choice is needed. The image is checksum/digest pinned in
containers/flutter-ci.Containerfile. Container layer caching plus named
Cargo, Dart, Pub, and Gradle volumes make
subsequent runs incremental without leaking container-generated platform files
or package paths into the host checkout. Normal checks/builds do not use a
privileged container;
Flatpak assembly remains the explicit exception because flatpak-builder needs
additional sandbox privileges.
Linux containers cannot faithfully build Windows, iOS, or macOS runners. GitHub Actions builds those targets on native Windows and macOS hosts; Codemagic is used only for signed iOS candidates and uploads.
Native toolchain setup
Install mise, bootstrap the repository-managed tools and Linux OS packages, then activate the current shell:
mise bootstrap
eval "$(mise activate bash)"
just flutter-check
Use this path for quick native iteration inside Ubuntu 24.04 and for native Apple/Windows work on those operating systems. On Linux, a missing CMake or Android SDK is a signal to finish provisioning the Distrobox or use the container recipes, not a reason to skip the corresponding compile gate.
The shared client pins flterm and libghostty to the same immutable commit of
the adonm/libghostty monorepo. flutter pub get resolves both package paths
from one Git checkout.
Every platform installs the official Flutter beta archive through Mise’s
http:flutter backend at framework revision
ceb9a865625239789d86b31be0a8d04e4c5a5084 (version 3.48.0-0.1.pre, Dart
3.14.0-95.1.beta). Linux builds use the stock GTK3 embedder with Impeller;
no build job clones, deepens, patches, or precaches Flutter.
Android
Requirements:
- Android SDK platforms 34–36, build-tools 36.0.0, and platform-tools 37.0.0;
- Android NDK 29.0.14206865 and CMake 3.22.1;
- JDK 17 and accepted Android licenses;
- Android 15/API 35 or newer to run the app.
Preferred Linux container build:
just container-android
adb install -r flutter/build/app/outputs/flutter-apk/app-debug.apk # ARM64 device
The focused container compile gate intentionally emits ARM64 native libraries. Use an ARM64 physical device/emulator for that APK. For an x86_64 emulator, use the host-native unrestricted debug build below.
For direct Android development inside Ubuntu 24.04, after installing the requirements above:
mise exec -C flutter -- flutter build apk --debug
adb install -r flutter/build/app/outputs/flutter-apk/app-debug.apk
Unsigned release outputs can also be compiled in the container:
just container-android-release
flutter/build/app/outputs/flutter-apk/app-release.apk
flutter/build/app/outputs/bundle/release/app-release.aab
The container deliberately does not forward signing credentials. Host-native
release files are signed only when ANDROID_KEYSTORE_PATH,
ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD are
all set. The AAB is a store upload, not a directly installable package. Tagged
release CI requires all signing secrets and verifies both signatures.
Web
Preferred Linux build:
just container-web
For direct Distrobox iteration, Linux needs clang; the script installs the Wasm
Rust target and the exact wasm-bindgen-cli version used by the bridge:
sudo apt-get install clang # Debian/Ubuntu
just flutter-check
just build-web
Output: target/book/web/. The build uses base path /web/ for deployment at
zuko.adonm.dev/web/; it is not a root-path static
bundle without changing scripts/build-web.sh. Browser transport is relay-only,
while terminal payloads remain end-to-end encrypted. Production web builds
currently retain JavaScript/Wasm source maps and Wasm symbol names so browser
failures can be symbolized during the active null-exception investigation.
Linux desktop
The pinned Ubuntu 24.04 container is the release-compatible default:
just container-linux-build
For direct Ubuntu 24.04 Distrobox iteration, mise bootstrap installs the
configured dependencies. The equivalent APT command is:
sudo apt-get update
sudo apt-get install -y \
clang cmake libgtk-4-dev libsecret-1-dev ninja-build pkg-config
just build-flutter-linux
Output and run command:
flutter/build/linux/x64/release/bundle/zuko
Keep the complete bundle/ directory together. The supported packaged target
is Wayland with Impeller/OpenGL; runtime machines also need GTK 3, libsecret,
and an active Secret Service provider such as GNOME Keyring. Tagged releases
package this directory for FlatPark. See the Linux runtime
notes.
To build an unsigned, self-contained Flatpak for local testing, including the current Linux payload under FlatPark’s production app ID and permissions:
mise exec -- just build-flatpark-test-bundle
flatpak --user install dist/flatpak/zuko-linux-vX.Y.Z-x86_64-test.flatpak
flatpak run dev.adonm.zuko//test-vX.Y.Z
The test branch can coexist with FlatPark’s stable branch. The recipe consumes
an immutable, inspected revision of FlatPark’s registry packaging and embeds the
local release archive so installation does not depend on an already-published
GitHub Release. It is for local validation only and is not signed by FlatPark.
Windows desktop
Build on Windows with Python 3 and Visual Studio 2022’s Desktop development
with C++ workload. Confirm flutter doctor -v passes. The repository Justfile
uses Bash, so native Windows CI uses this PowerShell sequence instead:
mise install
flutter --version
$rustBin = Join-Path (mise where rust) "bin"
$env:Path = "$rustBin;$env:Path"
Push-Location flutter
flutter pub get
Pop-Location
python scripts/patch-flutter-plugins.py flutter
Push-Location flutter
flutter build windows --release
Pop-Location
Output: flutter/build/windows/x64/runner/Release/. Run zuko.exe from that
directory and keep its DLLs and data beside it. Tagged releases zip this whole
directory; there is not yet a signed Windows installer.
Flutter iOS/iPadOS and macOS
Apple builds require macOS, Xcode, CocoaPods, Flutter, and Rust. The CocoaPods
build compiles iroh_flutter’s Rust library and packages libghostty for the
selected Apple platform. The generated runners target iOS 18 and macOS 15.
mise bootstrap
eval "$(mise activate bash)"
flutter --version
just build-flutter-ios
just build-flutter-macos
Outputs:
flutter/build/ios/iphonesimulator/Runner.app
flutter/build/macos/Build/Products/Release/Zuko.app
GitHub runs fast shared Flutter checks on pull requests. Two parallel macOS
jobs compile and package the iOS Simulator and macOS targets for each main
candidate, which becomes the GitHub Release asset without rebuilding. After
tagging, ios-testflight-release builds and uploads the signed device IPA.
Apple builds use bundle ID dev.adonm.zuko and all signing credentials remain
in Codemagic.
Matching CI
The source of truth for build environments is:
.github/workflows/ci.ymlfor fast pull-request checks;.github/workflows/build.ymlfor nativemainbuilds and the aggregate build-once candidate;codemagic.yamlfor signed iOS and upload-only mobile publication;.github/workflows/release.ymlfor protected tagging and core publication;.github/workflows/publish-*.ymlfor independently rerunnable channels;Justfilefor supported local recipes.
just container-ci invokes the same flutter-linux-ci recipe as GitHub’s
Linux jobs. It covers every target that can be built faithfully on a
Linux host: shared Dart, web, Android, and Linux desktop. just container-all
adds the exhaustive local preflight, workflow lint, and documentation build
without rerunning the lean app tests. Network-dependent link and end-to-end
tests remain explicit focused recipes. Native Windows and Apple compilation
cannot be replaced by a Linux container and remains hosted on those operating
systems.
Current automation coverage is:
| Target | Pull request | main candidate | Release-tag delivery |
|---|---|---|---|
| Shared Dart + web | Analyze, test, and compile web | Recheck shared client; Pages builds web | No release asset |
| Android | Shared Flutter checks | Unsigned APK/AAB | Same candidate signed once, published, and promoted to Appetize/Google Play |
| Linux | Shared Flutter checks | GTK3 release bundle and smoke | Same checksummed archive consumed by FlatPark |
| Windows | Shared Flutter checks | x86_64 portable build | Same ZIP published; protected Store package remains manual |
| iOS/iPadOS | Shared Flutter checks | Simulator build | Same Simulator ZIP to Appetize; exact-tag signed IPA to TestFlight |
| macOS | Rust and shared Flutter checks | Release application build | Same development ZIP published; Mac App Store is not automated |
Compilation in this matrix does not imply store publication or the physical-device/browser coverage listed in Flutter platform support and the roadmap.
Flutter human-centered design
This document defines the product and interaction goals for Zuko’s shared Flutter client. It complements the repository-wide design principles and roadmap: those documents define product and trust boundaries; this one explains how the graphical client should feel and how contributors should evaluate changes.
The client serves phones, tablets, browsers, and desktop windows from one implementation. Shared code should preserve one mental model without forcing every platform into the same physical layout or input pattern.
Human outcomes
A successful client lets someone:
- pair a machine without understanding Iroh, tickets, tokens, or node IDs;
- recognize the intended host and this client device later;
- start and control a real terminal with the input method they have;
- understand whether a connection is waiting, retrying, attached, ended, or rejected, and know what to do next;
- distinguish local organization from host-side authorization and revocation;
- recover from ordinary mistakes, denied permissions, network changes, and application lifecycle changes without losing identity unexpectedly.
Optimize for confidence and recoverability, not feature count. A remote shell is a high-consequence tool: ambiguity about which host is active or whether access was revoked is more harmful than an extra step.
Interaction principles
Prefer recognition over recall
- Pair from the QR code the host already displays, with the two-word code as a complete fallback.
- Use host-provided labels initially; do not ask the user to invent a host name during pairing.
- Let people rename saved hosts later and search by friendly name, original host label, or node ID.
- Give this client a recognizable, editable device name while retaining a short identity-derived suffix to avoid collisions.
Names are aids for people, not security identities. Never weaken endpoint, token, or node-ID validation because a friendly label matches.
Make one next action obvious
Empty, loading, connected, recoverable-error, and terminal-ended states should each have one visually primary next action. Secondary choices remain available without competing with it. Do not put onboarding instructions in a narrow sidebar when the otherwise-empty main pane can explain them clearly.
Keep fallback paths complete
QR scanning is convenient, not required. Camera denial, unsupported desktop platforms, malformed QR content, and scanner failure must still leave typed entry available. The same rule applies to touch, mouse, keyboard, clipboard, and accessibility input: platform enhancement must not become the only path.
State the consequence before destructive actions
Use precise verbs:
- Rename changes a local display name.
- Forget removes a saved host from this client but does not revoke access.
- Revoke removes host-side authorization.
- Reset rotates trust state and requires re-pairing.
Confirmation and detail text must say which side changes. Do not imply that forgetting locally has secured a lost or compromised device.
Make status actionable
Status text should answer three questions where possible:
- What is happening?
- Does the user need to act?
- What action can resolve it?
Prefer “Host rejected this client. Pair it again.” over a transport exception or protocol code. Keep the underlying distinction in logs and tests, but do not require it to understand the screen. Preserve input when retry is useful.
Be compact, not cramped
Terminal work benefits from density, especially on small screens. Density is acceptable when labels remain legible, state remains distinguishable, and the same action is available through a larger menu, keyboard, or system affordance. Do not reduce spacing merely to show more inactive chrome.
Adapt layout and input, not product meaning
- Narrow layouts use a drawer and the main pane for the current task.
- Wide layouts keep a persistent, collapsible connection sidebar.
- Phones may use bottom sheets where desktops use anchored menus or popovers.
- Touch dragging scrolls by default. Touch text selection must be enabled from the terminal accessory row and then starts with long press. Mouse and keyboard selection retain desktop conventions.
- Terminal keys must go through
flterm’s typedKeyAPI, not handwritten escape sequences.
The action and result should remain equivalent across these presentations.
Protect terminal correctness and trust boundaries
UI convenience must not bypass pairing-code parsing, endpoint validation,
ATTACHED validation, guarded multiline paste, supported-link filtering, or
secure storage. Validate untrusted input near its boundary and fail closed.
Avoid collecting identifiers or adding file, network, or background capability
only to improve presentation.
Current interaction model
This section records intentional behavior that already exists. Update it when the implementation changes.
First run and pairing
- The empty main pane welcomes the user and offers QR scanning when supported plus typed-code entry everywhere.
- The QR payload is the raw one-time pairing code and is accepted only through
the same
PairingCode.parsevalidation as typed input. - Pairing asks only for the code. The saved host starts with the host-provided label; a local rename is available afterward.
- Invalid scans are ignored with inline guidance. Failed claims retain retry and manual fallback instead of dismissing the flow.
- Camera access exists only on supported targets. There is no operating-system deep-link registration for pairing. Browser builds use validated typed-code entry and do not register or ship the QR scanner runtime.
Saved hosts and client identity
- Saved hosts are bounded and displayed in compact rows. Duplicate subtitles are suppressed.
- Local search is immediate, case-insensitive, and matches all query terms across the friendly name, original label, and node ID.
- The selected host uses both icon treatment and row state; selection must not rely on color alone.
- Opening a saved host creates a closable Yaru tab with its own terminal, transport session, geometry, status, and reconnect lifecycle. Other host tabs remain connected when selection changes. Selecting an already-open host focuses its existing tab, matching the protocol’s single resumable PTY per client/host identity.
- The tab strip scrolls horizontally and shows Yaru undershoot indicators when all open hosts do not fit.
- This device name is suggested from a non-secret descriptive property,
can be edited, and is persisted in protected client state. New host labels
use
zuko-<device-name>-<identity-suffix>. - Changing the device name affects new pairings. Re-pairing an existing host safely replaces its old authorization label for the same token.
Terminal surface
fltermandlibghosttyown terminal parsing, rendering, selection, and key encoding. Zuko should not create a parallel terminal behavior layer.- The accessory row is currently 24 logical pixels high, with width-aware 28–36 pixel slots. These are tested compact-mode constraints, not a general recommendation for all controls.
- Copy and paste are contextual; less common actions live in overflow.
- The overflow opens Home, End, Page Up, Page Down, Insert, Delete, and F1–F12 in a phone bottom sheet or compact desktop popover. Arrow buttons repeat after a deliberate hold delay instead of requiring rapid tapping, and use the predictable Up, Down, Left, Right order.
- Multiline paste remains guarded. Supported terminal links are limited to absolute HTTP and HTTPS URLs.
- Remote OSC 52 output may write UTF-8 text to the system clipboard only from the visible terminal while the app is foregrounded. Clipboard reads, non-default selectors, malformed base64 or UTF-8, and decoded payloads over 1 MiB are ignored.
- Touch and stylus positions follow alternate-screen scroll conversion so mouse-aware programs receive wheel events at the intended terminal cell. Touch selection is off by default; while enabled, long press gives platform feedback when selection becomes armed.
- Terminal output exposes a readable visible viewport and focus action to assistive technology, but continuous output is not a live region because announcement spam would make the client unusable.
Responsive behavior
760logical pixels is the current wide-layout breakpoint.- Until the user chooses a terminal size, the tested defaults are 7 logical pixels on narrow layouts and 10 on wide layouts. User customization then takes precedence within the supported range.
- These constants protect known small-screen layouts. Change them only with narrow, wide, text-scale, and physical-device evidence.
Accessibility and inclusive input
Accessibility is behavior, not a final semantics pass. Every interactive change should consider:
- meaningful labels, roles, values, selected state, and focus order;
- keyboard-only activation, dismissal, and traversal;
- TalkBack, VoiceOver, and desktop screen-reader output;
- text scaling without clipped actions or unreachable content;
- contrast in light and dark themes and state cues beyond color;
- reduced-motion preferences for nonessential transitions;
- touch, stylus, mouse, trackpad, hardware keyboard, and software keyboard;
- errors that remain understandable without seeing an icon or color.
Platform target-size guidance is the default. The compact terminal accessory row is an intentional exception because terminal viewport height is scarce; important actions therefore also need keyboard or menu access. A future comfortable-control mode should improve this trade-off without silently changing the established compact layout.
Writing style
- Use sentence case and familiar words.
- Name the object: “Pair host”, “Forget office workstation”, “Edit device name”.
- Keep primary buttons verb-first and specific; avoid generic “OK” when the action can be named.
- Do not expose raw exceptions, ALPN names, token language, or package details as the only explanation.
- Use inline validation for fixable input. Use dialogs for decisions and snackbars for brief confirmation, not for essential instructions.
- Never claim success before protected state has been saved.
Contributor workflow
Before implementing a Flutter interaction, write down:
- the human problem and the critical journey it belongs to;
- the default, empty, busy, success, recoverable-error, permanent-error, and cancellation states that apply;
- what happens on narrow and wide layouts;
- how touch, mouse, keyboard, and assistive technology reach the action;
- whether permission, storage, network, clipboard, process, or trust boundaries change;
- how the person recovers and whether their input or identity is preserved;
- the smallest automated and physical-device evidence that demonstrates the result.
Prefer an existing Flutter, Material, Yaru, or flterm affordance over a new
dependency. A package is justified by a required capability and supported
target matrix, not by a single convenient widget.
Code map
flutter/lib/src/app.dart: responsive shell, welcome state, saved hosts, connection settings, and terminal accessory UI.flutter/lib/src/pairing_screen.dart: scanner and typed pairing journey.flutter/lib/src/app_controller.dart: persisted actions and user-visible operation status.flutter/lib/src/model.dartandstorage.dart: migration-safe preferences, identity, and saved-host state.flutter/lib/src/client_name.dart: safe friendly client labels.adonm/libghosttypackages/flterm/: terminal rendering and cross-input behavior, consumed at the commit pinned influtter/pubspec.yaml.
Expected evidence
Automated checks should cover the smallest stable boundary:
- pure tests for parsing, normalization, sizing, state migration, and action selection;
- widget tests for validation, progress, retry, focus, semantics, and layout;
fltermregression tests for terminal input, rendering, scrolling, and selection behavior;- web builds for conditional imports, CSP, and WASM compatibility;
- native builds and representative physical-device checks before promotion.
Exercise at least a small phone width, the wide-layout boundary, a desktop window, light and dark themes, system text scaling, keyboard traversal, and the relevant touch or pointer interaction. Passing analysis alone is not UX evidence.
Run just flutter-check for shared changes and the relevant platform build from
Building clients. Record environmental build blockers
rather than treating an unattempted build as success.
High-value follow-up work
This is an evaluation order, not a release promise. The roadmap remains the source of product commitments.
The automated baseline now drives first pairing, host search, connection recovery, terminal focus, and forget-versus-revoke guidance through keyboard and semantics journeys. Recovery actions receive focus when terminal input is no longer available, while pairing, search-result, and recovery status changes are live regions.
Next
- Verify QR scanning, lifecycle changes, client-state migration, and terminal touch behavior on representative physical devices before target promotion.
- Record representative VoiceOver, TalkBack, and desktop screen-reader runs, then reduce any platform-specific failures into automated regressions where possible.
After those foundations
- Improve denied-camera and restricted-permission recovery with a clear typed fallback and platform-settings action where the platform supports one.
- Offer a comfortable terminal-control density without changing the tested compact default or responsive terminal font behavior.
- Map connection failures to a small, tested set of plain-language causes and next actions while retaining safe diagnostic detail.
- Add an undo window for local host forgetting where state can be restored without implying host-side revocation.
- Expand resize and text-scale tests around the sidebar breakpoint, pairing flow, dialogs, and terminal overlays.
- Prepare strings for localization once there is a concrete translation and maintenance plan; avoid constructing user-visible sentences from fragments in the meantime.
- Use structured physical-device test scripts and issue feedback rather than adding behavioral analytics by default.
When choosing among these, fix blocked recovery, inaccessible operation, and misleading trust state before adding visual polish.
Wire protocol
Transport
- Iroh QUIC endpoint ticket (
endpointa…). - Session ALPN:
zuko/2. - Raw tunnel ALPN:
zuko/tunnel/1. - Handoff ALPN:
zuko/handoff/1. - On
zuko/2, the first bidi stream is data. An optional second bidi stream is control.
Frame format
[type: u8][len: u16 BE][payload: len bytes]
len excludes the 3-byte header. Max payload: 65535 bytes. Receivers must
accumulate and parse greedily; frames can split/coalesce across QUIC reads.
Frame types
| Type | Name | Direction | Payload |
|---|---|---|---|
0x00 | DATA | both | terminal bytes |
0x01 | RESIZE | client → host | cols:u16 rows:u16 pixel_width:u16 pixel_height:u16 |
0x04 | PING | both | nonce:u64 |
0x05 | PONG | both | nonce:u64 |
0x06 | ATTACH | client → host | token:16 bytes + resize payload |
0x07 | ATTACHED | host → client | token:16 bytes |
0x08 | AUTHORIZE | client → handoff host | token:16 bytes + UTF-8 label |
0x09 | ERROR | host → client | code:u8 + UTF-8 message |
0x0a | TUNNEL_OFFER | host → terminal client | id:16 bytes port:u16 |
0x0b | TUNNEL_CLOSE | host → terminal client | id:16 bytes |
0x0c | TUNNEL_ATTACH | client → tunnel host | token:16 bytes id:16 bytes |
0x0d | TUNNEL_ATTACHED | tunnel host → client | id:16 bytes |
Unknown types are ignored.
ERROR is fatal. A client must show the message and stop reconnecting. Defined
codes are 0x01 (authorization failure; pair again) and 0x02 (protocol
violation).
Session handshake
- Client dials host ticket on
zuko/2. - Client opens data bidi stream.
- First frame must be
ATTACHwith a non-zero host-scoped token and current terminal size. - Host checks
authorized_clientsfor the token. - Host creates or reattaches the PTY keyed by that token.
- Host sends
ATTACHED(token).
The token identifies both an authorized client and that client’s in-memory PTY lease. A second connection with the same token takes over the same PTY.
RESIZE is valid only after ATTACH. Cell dimensions are clamped to at least
1x1. Pixel dimensions may be zero.
Pump
- Keystrokes/stdin:
DATAclient → host. - PTY output:
DATAhost → client. - Size changes:
RESIZEon control stream when available, otherwise data stream. PINGreplies withPONGcarrying the same nonce.
Shell EOF closes the stream and kills the PTY. Network/client drop detaches the
PTY for the host’s detached-session lease (6 hours by default,
zuko host --detached-ttl); output while detached is discarded.
Raw TCP tunnel
zuko tunnel <port> runs inside the hosted PTY and registers host
127.0.0.1:<port> with the parent host over a random, per-PTY loopback control
capability. The registration control connection is the tunnel’s lifetime
lease. The host sends TUNNEL_OFFER(id, port) on the authenticated terminal
stream and replays active offers after terminal reattachment. A session may
register at most 64 active tunnels.
A native client then:
- Dials the same endpoint on
zuko/tunnel/1. - Opens a handshake bidi stream and sends
TUNNEL_ATTACH(token, id). - Requires
TUNNEL_ATTACHED(id)before binding a local listener. - Binds an ephemeral port on client
127.0.0.1. - Maps each accepted local TCP connection to one additional Iroh bidi stream.
The host validates the normal authorized-client token and random tunnel ID, then maps each post-handshake bidi stream to a fresh TCP connection to the registered host-loopback port. Bytes after the handshake are opaque. Zuko does not parse HTTP, terminate TLS, rewrite traffic, or infer application protocol.
Control EOF removes the registration and emits TUNNEL_CLOSE(id). Command
exit, PTY exit, explicit close, or Iroh connection closure tears down listeners
and active streams. Completed streams report byte counts to the foreground
command over the private control connection.
Compatibility
- The ALPN is the incompatible-version boundary. There is no version negotiation or v1 fallback.
- New optional frame types may be added to
zuko/2; receivers ignore unknown types. - Existing frame meanings and required handshake order must not change within
zuko/2. - A deliberate host rejection uses
ERRORand must not enter a retry loop. - Malformed required frames or an unexpected stream close are fatal to that connection.
Ticket handoff
Purpose: let a client learn the host ticket and register its future ATTACH
token without putting the ticket on argv/stdin/stdout.
Possession of the short code while zuko share is active grants enrollment. A
share accepts one claim by default; --count can explicitly allow more. Treat
the code as temporary sensitive data and do not leave an unlimited share
(--timeout 0) unattended.
Host (zuko share):
- Generate memorable code.
- Derive throwaway Iroh secret:
Argon2id(normalized_code, salt="zuko-share-handoff-v1") -> 32-byte seed. - Bind endpoint on
zuko/handoff/1. - Open uni stream and write:
<label>\n<ticket> - Wait briefly for client
AUTHORIZEuni stream. - Save token + label to
authorized_clients.
Client (zuko claim / zuko <code>):
- Derive same throwaway endpoint id from code.
- Dial
zuko/handoff/1, retrying until timeout. - Read label + ticket.
- Parse ticket host id.
- Derive stable host-scoped token from local client secret + host id.
- Open uni stream, send
AUTHORIZE(token, client_label). - Save ticket locally and optionally connect.
Token derivation
Rust CLI:
SHA256("zuko-session-token-v1" || client_key_bytes || host_id_bytes)[0..16]
The Flutter client uses the same derivation with its protected client key on Android, iOS, macOS, web, Linux, and Windows.
iOS:
SHA256("zuko-ios-session-token-v1" || keychain_seed || host_id_string)[0..16]
Tokens must be non-zero.
Security notes
- A shell connection requires host dial information plus a token in the host’s authorized-client list.
- Host key stays on host at
${XDG_CONFIG_HOME:-$HOME/.config}/zuko/key. - Host admits only authorised client tokens.
- Iroh provides transport encryption; relays see encrypted traffic.
- Rotate host trust with
zuko reset, restart, then re-pair clients.
Reference implementations: src/wire.rs, src/client.rs, src/host.rs,
src/handoff.rs, flutter/lib/src/, and
flutter/rust/web_transport/src/lib.rs.
Direction and roadmap
North star
Zuko provides private remote shells for machines you own without opening an inbound port or operating a VPN:
- install a per-user host service;
- pair once with a short code;
- reconnect by name;
- survive ordinary network and application lifecycle changes;
- inspect or revoke access locally.
Iroh owns encrypted reachability. Zuko owns authorization, PTY behavior, the terminal experience, recovery, packaging, and clear operator feedback.
Supported products
| Tier | Product | Surfaces |
|---|---|---|
| Core | Host and reference client | Linux/macOS host and Rust CLI |
| Beta | Packaged shared Flutter client | Android, iOS/iPadOS, macOS, and Linux |
| Labs | Early delivery channels and application streaming | Flutter web/Windows and Linux zuko app |
The former Compose Android client, TypeScript web client, and Relm4 Flatpak client and native Swift client were removed. They will not receive parallel feature work.
Current priority: steadily improve one credible Flutter client
All Android, iOS, macOS, web, Linux, and Windows client work now lands in
flutter/.
Flutter shares navigation, saved-host behavior, pairing, framing, reconnect,
terminal integration, and tests. Platform code is limited to transport,
credential storage, camera access, lifecycle, and packaging where the operating
system genuinely differs.
The Flutter human-centered design guide records the shared interaction goals, current responsive behavior, accessibility expectations, and evidence required for client-facing changes.
Chosen foundations:
- Terminal: pinned
fltermandlibghostty; do not build another renderer. - Native transport: pinned
iroh_flutteron Android, iOS, macOS, Linux, and Windows. - Web transport: Zuko’s relay-only Rust/Iroh WASM bridge behind Dart JS
interop until
iroh_flutterhas a production browser backend. - Storage:
flutter_secure_storage, backed by Android Keystore, Apple Keychain, Linux Secret Service, Windows protected storage, and browser-origin storage. - Versioning: Cargo remains canonical; Flutter uses the same semantic version and the existing monotonic Android version-code formula.
The old Labs clients do not have an in-place state migration guarantee. The
Flutter Android package retains dev.adonm.zuko and the signing identity, but
users should expect to pair again after this cutover. The web app remains at
/web/, but old IndexedDB records are not imported automatically.
The Flutter iOS replacement likewise retains dev.adonm.zuko but intentionally
starts with new Keychain state and session-token derivation; pre-1.0 testers
must pair again and revoke the old native client authorization when finished.
Continuous quality program
Shipping the shared client is not the end state. Quality work should land in
small, measured increments across flterm, every Flutter target, and the
host/client boundary.
flterm is a long-lived product dependency and will receive substantial
ongoing work rather than only compatibility patches needed by Zuko. Priorities
include:
- expand terminal conformance coverage for escape sequences, Unicode grapheme and cell-width behavior, cursor/style state, scrollback, alternate screen, selection, clipboard, links, and Kitty graphics;
- make keyboard, IME, mouse, wheel, touch, and DEC input modes consistent on phones, tablets, browsers, and desktop systems;
- add deterministic renderer goldens, fuzz/property tests at parser and input boundaries, long-session tests, and measured performance/memory baselines;
- improve accessibility semantics, API documentation, diagnostics, examples, and release hygiene so downstream Flutter clients can depend on behavior rather than implementation details;
- upstream generally useful fixes in
fltermfirst and pin Zuko to reviewed, tested commits instead of carrying hidden application-only forks.
The first focused terminal-experience increment is clickable links. flterm
detects OSC 8 hyperlinks and plain-text URLs, and Zuko wires supported web
links to the existing platform URL launcher while rejecting unsupported
schemes. This small cross-platform improvement exercises an existing reviewed
flterm capability.
The accessibility baseline now exposes the visible, non-concealed terminal
viewport and a terminal-focus action through flterm semantics. Zuko supplies
the remote-terminal label and hint. Output is deliberately not a live region,
so continuous command output does not create announcement spam. Structured
cursor/selection navigation and representative VoiceOver, TalkBack, and
desktop screen-reader testing remain follow-up work.
Shared Flutter quality work should continuously exercise real small phones, tablets, desktop windows, narrow browsers, lifecycle transitions, credential storage, reconnect, upgrade, and uninstall behavior. A successful compile is not sufficient evidence of client quality.
Host/client user experience is part of the same program: pairing and revocation should be understandable, connection and retry states actionable, diagnostics safe to share, errors specific about the next step, and host install/upgrade/reset behavior predictable from every supported client.
Binary size remains a release constraint. CI should record compressed and installed sizes per target, compare them with the previous release, and make large regressions explicit. Prefer shared assets, targeted font subsets, tree-shaking, symbol stripping, and one native implementation per capability; do not add parallel frameworks or broad asset bundles when a measured smaller choice meets the same user need. Size work must preserve terminal correctness, accessibility, security, and offline fallback behavior.
Delivery plan
1. Make the shared session trustworthy
Required before any Flutter target is promoted:
- validate endpoint tickets and host identity before dialing;
- keep the Argon2 handoff KDF and host-scoped token fixtures identical to Rust across Rust and Dart fixtures;
- require
ATTACHEDbefore accepting terminal data or user input; - serialize writes and split data at the 65,535-byte frame limit;
- reconnect transient failures with bounded 1/2/4/8/15-second backoff;
- stop on authorization errors, protocol errors, clean shell exit, explicit disconnect, or host switch;
- bound terminal output and outbound work so a slow renderer cannot consume unbounded memory;
- add integration coverage for pairing, reconnect, revocation, malformed frames, and persisted identity on native and browser transports.
The shared Dart framing, pairing parser, KDF fixture, native transport, browser
bridge, and reconnect loops now exist. A shared attachment gate used by native
and browser sessions rejects output and input before ATTACHED and fails closed
on a mismatched identity. Their outbound paths use one bounded, serialized
writer, while deterministic integration tests cover revocation/protocol failure,
stale-session suppression, reconnect, persisted identity, 2,000 bidirectional
terminal exchanges, and a 10,000-write queue soak. The live Rust Iroh test still
covers pairing, PTY traffic, revocation, and tunnel teardown. Representative
native-device and browser relay soaks remain target-promotion evidence rather
than an unmeasured shared-client claim.
2. Reach terminal and lifecycle parity
The Flutter client must provide:
- correct styles, cursor, alternate screen, selection, clipboard, scrollback, resize, keyboard/IME, mouse reporting, and supported Kitty graphics;
- usable phone, tablet, desktop, and narrow-browser layouts;
- in-app QR pairing with typed-code fallback;
- explicit connecting, attached, retrying, rejected, ended, and disconnected states;
- foreground/background and network-change recovery on mobile targets;
- screen-reader semantics and keyboard-only operation;
- visible destructive reset behavior that rotates the client identity and explains host-side revocation.
flterm supplies the shared terminal surface. Typed recovery states, foreground
redial, mobile shortcut controls, host management, themes, font sizing, and
baseline terminal viewport semantics now exist. Automated keyboard and semantics
journeys cover first pairing, host search, recovery-action focus, terminal focus,
and explicit forget-versus-revoke guidance; changing pairing, search, and
recovery status is exposed as a live region without turning terminal output into
one. Representative VoiceOver, TalkBack, and desktop screen-reader runs, QR
scanner lifecycle tests, and physical-device coverage remain open.
3. Ship each target through its normal channel
| Target | Release gate |
|---|---|
| Android | Signed APK/AAB, Appetize preview, upgrade test, physical phone/tablet tests, Play-ready metadata |
| Web | Chrome/Firefox/Safari tests, strict CSP, origin review, deployed /web/ smoke test |
| Linux | Reproducible Wayland-only release archive and FlatPark package, Impeller rendering, Secret Service behavior, install/uninstall documentation |
| Windows | Promote the protected MSIX/MSIXBundle path, verify protected-storage behavior, URI registration, and upgrade/uninstall tests |
| iOS/iPadOS | Signed TestFlight build, physical-device Iroh/terminal/lifecycle tests, replacement migration decision |
| macOS | Mac App Store package/upload validation, Keychain behavior, keyboard/accessibility and upgrade tests |
CI now analyzes and tests the shared client and builds all six target families. Tagged releases produce Android, Linux, and Windows GitHub assets, Android/iOS Appetize previews, and an internal TestFlight upload. Web remains part of the Pages deployment; macOS store packaging/upload and Windows Store publication remain protected manual workflows. Promotion waits for package-level smoke tests and target-specific gates, not merely a successful compile.
Core and shared-client policy
Flutter work must not regress host authorization, revocation, PTY correctness, protocol compatibility, service recovery, or secret handling. Before 1.0, keep one maintained cross-platform client rather than parallel implementations.
Host and CLI reach a 1.0 stability promise when install, upgrade, reset, uninstall, compatibility, authorization/reconnect, security review, and state migration are release-gated. There is no calendar promise for 1.0.
Explicitly out of scope
- restoring the removed Compose, TypeScript, or Relm4 clients;
- another terminal renderer or a local-PTY terminal dependency;
- durable PTY output replay; use
tmux,zellij, orscreen; - full desktop streaming, centralized accounts, RBAC, or fleet management;
- broad plugin or protocol frameworks without a concrete client need.
Decision order
When work competes, choose in this order:
- prevent unauthorized shell access, identity loss, or weaker secret storage;
- preserve framing, terminal correctness, reconnect, and recovery;
- close shared Flutter terminal, accessibility, and lifecycle gaps;
- improve pairing, diagnostics, recovery, and host/client operational UX;
- make signed packages, upgrades, releases, and size reporting repeatable;
- add new features or platforms.
Design principles
The product direction lives in the roadmap. These principles turn it into engineering constraints.
The shared graphical client’s interaction, accessibility, responsive-layout, and contributor-testing goals live in the Flutter human-centered design guide.
Product boundary
zuko is a terminal-first, per-user remote shell for machines the user owns. The Core product is the Linux/macOS host and Rust CLI. Mobile, browser, and GUI app streaming must not make that path harder to install, secure, debug, or maintain.
zuko does not own network reachability, durable terminal sessions, or fleet identity. Iroh provides reachability and encrypted transport; terminal multiplexers provide durable work.
Priorities
When a design trades one property for another, prefer this order:
- explicit authorization and safe local secret storage;
- correct PTY behavior and bounded failure modes;
- actionable operator feedback and recovery;
- protocol simplicity and compatibility;
- optional client or streaming features.
Constraints
- The base experience is a real PTY over Iroh.
- A stock terminal and one binary must remain a useful client.
- Pairing is explicit, short-lived, and separate from ordinary connections.
- Host state remains inspectable under
${XDG_CONFIG_HOME:-$HOME/.config}/zuko. - Handshakes, queues, retries, and detached leases are bounded.
- A brief disconnect may reattach; detached output is not replayed.
- Long-running work belongs in
tmux,zellij, orscreen. - New background services and trust surfaces require a concrete Core use case.
Protocol shape
Session ALPN: zuko/2.
- data stream:
ATTACH,DATA,ATTACHED,ERROR; - control frames:
RESIZE,PING,PONGon an optional control stream, with data-stream fallback; - handoff ALPN:
zuko/handoff/1; - authorization:
AUTHORIZEduring handoff, enforced onATTACH.
Prefer additive frame types over negotiation layers until a concrete client needs more. Unknown frame types are ignored; a new incompatible handshake gets a new ALPN.
Labs: zuko app
The current GUI path runs cage/wlroots on the host, sends Kitty graphics over the existing PTY, and maps terminal input back into cage. Reusing the shell path avoids a second listener, client, and pairing surface.
This remains an experiment, not the start of a remote-desktop stack. A native video protocol should be considered only after a demonstrated use case and an explicit maintenance/security plan.
Releasing
Every pushed main commit produces one build-once release candidate in
.github/workflows/build.yml. The aggregate candidate binds the source commit,
Flutter/Dart contract, file sizes, and SHA-256 digests for:
- Rust CLI/host tarballs for Linux and macOS, x86_64 and aarch64;
- unsigned Flutter Android APK and AAB;
- Flutter Linux x86_64 archive consumed by FlatPark;
- Flutter Windows x86_64 bundle;
- iOS Simulator and macOS development archives.
GitHub Actions performs all ordinary tests and platform builds. Codemagic is
reserved for signed iOS construction, TestFlight upload, and upload-only
Appetize publication. Flutter web remains deployed by the Pages workflow after
changes reach main.
Published assets follow these names (TAG includes the leading v):
| Surface | Asset |
|---|---|
| CLI/host | zuko-<rust-target>.tar.gz |
| Android | zuko-android-TAG-signed.apk and .aab |
| Linux client | zuko-linux-TAG-x86_64.tar.gz |
| Windows client | zuko-windows-TAG-x86_64.zip |
| Apple previews | Zuko-Flutter-ios-simulator.zip, Zuko-Flutter-macOS.zip |
| Provenance | release-candidate.json |
Each installable payload has a matching .sha256 sidecar. End-user notes are
in Clients; source build outputs are in
Building clients.
Cut a release
Run Linux-side release checks and dispatch from the version-pinned Ubuntu 24.04 Distrobox documented in Building clients. Hosted jobs remain authoritative for their native and hermetic build environments.
mise trust
mise bootstrap
eval "$(mise activate bash)"
just check
just test-e2e
just release
just release is intentionally non-blocking. It requires a clean main
exactly matching origin/main, validates the committed package versions, and
dispatches release.yml for that exact commit. No local polling process needs
to remain running.
The protected workflow then:
- resolves the one successful exact-commit GitHub candidate and its aggregate artifact ID and digest;
- enters the protected
releaseenvironment, rechecksorigin/main, and creates the annotatedvX.Y.Ztag; - downloads the aggregate candidate, verifies
release-candidate.json, signs Android once, and publishes the immutable GitHub Release; and - dispatches independent idempotent crate, TestFlight, and Appetize channels.
No tag is created if candidate or source identity validation fails. External channels do not gate the core GitHub Release and can be rerun independently. TestFlight builds and validates one signed IPA from the immutable tag before uploading it; the Appetize channel only promotes published release bytes.
A release tag is permanent. Retry only transient runner, network, upload, or
approval failures. If source, packaging, or workflow code changes, increment
the patch version and produce a new candidate. Never rebuild an old version
from current main.
Cargo workspace.package.version is canonical. scripts/release_metadata.py
validates the complete release contract. Flutter uses the same semantic version
plus:
1,800,000,000 + major * 1,000,000 + minor * 1,000 + patch
Run just check-release-metadata after every version change.
crates.io
Crate publication requires crossterm-zuko 0.29.0-zuko.1 on crates.io.
Development resolves immutable tag crossterm-zuko-v0.29.0-zuko.1 at
cc3e2009082bb6b4dec31a42f1b11ff0e2a004a6; packaging resolves the exact
registry fallback =0.29.0-zuko.1.
publish-crate.yml verifies the tag and packaged dependency graph, then uses
crates.io trusted publishing through the crates-io GitHub environment. No
registry token is stored in the repository.
Mobile previews and stores
GitHub signs the candidate APK and AAB with repository-scoped secrets and publishes their checksums. Appetize’s Codemagic workflow only downloads the published signed APK and iOS Simulator ZIP and uploads those exact bytes; it no longer compiles either client.
The Google Play workflow similarly downloads and validates the already signed release AAB instead of rebuilding Flutter. Microsoft Store MSIX packaging remains a separate protected build because it is a different package format. See Appetize previews, Android publishing, and Windows publishing.
Linux and Windows
GitHub’s Ubuntu 24.04 job builds, normalizes, linkage-checks, reproduces, and smokes the Linux archive. FlatPark consumes that immutable URL and owns Flatpak wrapping and repository publication. GitHub’s Windows runner produces the portable x86_64 ZIP and checksum. Neither platform is rebuilt after the release tag.
Apple distribution
GitHub’s macOS job owns the unsigned iOS Simulator and macOS compile gate.
Codemagic’s ios-testflight-release workflow builds, validates, and uploads one
signed IPA from the immutable tag. Apple signing and App Store Connect
credentials never enter GitHub. A TestFlight outage can leave a valid tag and
GitHub Release awaiting an independently rerunnable store upload.
The Apple bundle ID is dev.adonm.zuko; Android and Apple share the deterministic
build number above. Mac App Store publication is not currently automated.
Provider responsibilities
GitHub Actions owns tests, Flutter compile gates, all unsigned/portable release artifacts, candidate provenance, Android signing, immutable tags and Releases, crates.io, Google Play, and Microsoft Store orchestration. Codemagic owns only signed iOS construction, TestFlight upload, and the temporarily isolated Appetize credentials. Each publication channel validates source identity and artifact hashes and can be retried without changing the core release.
Linux zuko app support
The x86_64 Linux CLI tarball includes cage/ and required wlroots libraries for
host-side zuko app. This is independent of the Flutter Linux client. aarch64
users still need cage on PATH.
Distribution setup checklist
This is the single operator checklist for external identities, GitHub environments, variables, secrets, and first uploads. Platform-specific details remain in the linked guides. Never commit credentials or pass them as workflow inputs.
Common release controls
- Keep Cargo and Flutter versions aligned (
0.10.13and0.10.13+1800010013at the time of writing); runjust check-release-metadata. - Use application/package/bundle ID
dev.adonm.zukoeverywhere except the Partner Center-assigned Microsoft package identity. - Keep
https://adonm.devreachable and visibly associated with Zuko so the reverse-DNS ID remains supportable and can be verified by stores. - Require reviews on every publishing environment and prevent self-review where practical.
- Run
just check,just test-e2e, and the platform package check before creating an annotatedvX.Y.Ztag.
GitHub configuration matrix
| Scope | Name | Required values |
|---|---|---|
| Repository secrets | coordinated Flutter release | CODEMAGIC_API_TOKEN with access to Codemagic app 6a52dc14add8531e99f88b8a; ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD for signing the exact GitHub Android candidate |
release environment | immutable tag | approval policy for the final exact-candidate tag job; no secret required |
testflight environment | signed iOS publication | approval policy; CODEMAGIC_API_TOKEN remains a repository secret |
appetize environment | preview publication | approval policy; CODEMAGIC_API_TOKEN remains a repository secret while Appetize credentials stay in Codemagic |
google-play environment | Play publication | GOOGLE_PLAY_SERVICE_ACCOUNT_JSON |
microsoft-store-package environment | package/sign | variables MSSTORE_PRODUCT_ID, MSSTORE_PACKAGE_IDENTITY_NAME, MSSTORE_PACKAGE_PUBLISHER, MSSTORE_PACKAGE_FAMILY_NAME, MSSTORE_PACKAGE_DISPLAY_NAME, MSSTORE_PUBLISHER_DISPLAY_NAME; secrets MSSTORE_SIGNING_PFX_BASE64, MSSTORE_SIGNING_PFX_PASSWORD |
microsoft-store-draft environment | draft upload | the six package variables above plus MSSTORE_TENANT_ID, MSSTORE_SELLER_ID, MSSTORE_CLIENT_ID; secret MSSTORE_CLIENT_SECRET |
microsoft-store-submit environment | final submission | the same values as microsoft-store-draft, with a separate final approval |
crates-io environment | trusted publish | no long-lived secret; allow OIDC and configure the crates.io trusted publisher after the initial publication |
Environment values are not shared between GitHub environments. Repeat the
Microsoft identity variables in every environment that consumes them, and
repeat MSSTORE_CLIENT_SECRET in both draft and submit.
Codemagic configuration matrix
| Scope | Name | Required values |
|---|---|---|
| Developer Portal integration | zuko-app-store | App Store Connect App Manager issuer ID, key ID, and .p8 key |
| iOS signing identity | dev.adonm.zuko | matching Apple Distribution certificate and App Store provisioning profile |
| Variable group | appetize_credentials | APPETIZE_API_TOKEN, APPETIZE_ANDROID_PUBLIC_KEY, APPETIZE_IOS_PUBLIC_KEY |
Codemagic’s YAML contains one signed-iOS/TestFlight workflow and one upload-only Appetize workflow. All ordinary compile gates and portable artifacts run in GitHub. The coordinated release passes no GitHub credential into Codemagic; Apple signing material remains there, and Appetize downloads public checksummed release assets without a signing identity.
First-time portal work
Google Play
- Create
dev.adonm.zuko, complete Play policy/listing declarations, and enroll in Play App Signing. - Preserve the existing keystore as the upload key.
- Make the first Console upload if required, then grant a dedicated service account least-privilege access to Zuko and test the internal track.
- Dispatch
publish-flutter-android.ymlwithdrafton the internal track before any production release.
Details: Android store publishing.
Apple
- Create the explicit App ID and iOS App Store Connect record for
dev.adonm.zuko. - Create an Apple Distribution certificate and matching iOS App Store profile.
- Create a dedicated App Store Connect App Manager API key and retain its
issuer ID, key ID, and one-time
.p8securely. - Dispatch
publish-testflight.ymlfor an immutable test tag when changing signing configuration. GitHub requires no Apple signing secrets.
Details: Apple store publishing.
Microsoft Store
- Reserve the app and copy every identity value exactly from Product
management > Product identity; do not derive these values from
dev.adonm.zuko. - Associate a least-privilege Entra application, create the code-signing PFX, and ensure the certificate subject exactly equals the assigned publisher.
- Complete the initial Partner Center submission, run WACK locally, then
dispatch
lane=draft. Approvelane=submitonly after reviewing the draft.
Details: Microsoft Store publishing and the package identity reference.
crates.io
- Publish
crossterm-zuko 0.29.0-zuko.1first from immutable fork tagcrossterm-zuko-v0.29.0-zuko.1using a short-lived crates.io token. The tagged source and commit areadonm/crossterm@cc3e2009082bb6b4dec31a42f1b11ff0e2a004a6. - Run
scripts/check-crate-package.sh; it must resolve that exact registry package and verify the underflow fix before Zuko is publishable. - Publish Zuko’s first verified crate version with a short-lived token.
- Configure crates.io trusted publishing with environment
crates-ioforadonm/zukoworkflowpublish-crate.ymlandadonm/crosstermworkflowpublish-zuko.yml, then require trusted publishing for both crates. - Revoke the bootstrap token in crates.io account settings. It has been removed from local Cargo storage, but its endpoint scope did not permit API self-revocation.
Linux and FlatPark
- Publish the versioned x86_64 Flutter Linux
bundle/archive and SHA-256 sidecar on every immutable GitHub Release. - Keep the FlatPark manifest on Freedesktop 25.08 while it is the catalog’s current runtime, with only network, IPC, Wayland, DRI, and Secret Service access.
- Test the FlatPark package build, installation, launch, locked/unlocked keyring behavior, and a real Iroh connection before submitting it.
- Keep FlatPark’s update resolver restricted to the exact versioned archive
on the official
adonm/zukoGitHub Release. - Review automated FlatPark checksum/update pull requests; do not modify or replace immutable release assets.
Zuko stores no Flatpak repository signing credential. FlatPark owns package signing and repository hosting. Details: Linux delivery through FlatPark.
Release order
- Complete the portal records, Codemagic identities/groups, and protected GitHub environments.
- Publish the
crossterm-zukobootstrap dependency and verify Zuko packaging. - Run
just release. GitHub verifies the existing build-once candidate, pushes the protected tag, signs Android, and promotes the exact bytes. The Linux archive then becomes FlatPark’s immutable input. - Confirm the independently dispatched TestFlight and Appetize channels, then publish Google Play internal, Mac App Store, and Microsoft draft builds through their protected workflows.
- Review each portal’s retained artifact, metadata, policy answers, and human approval before production submission.
Tags are immutable release source identities. Re-run failed jobs only when the
source and workflow are unchanged. If automation or packaging needs a code
change, increment the patch version and cut a new tag; never publish current
main under an older tag.
Linux delivery through FlatPark
Zuko’s graphical Linux client is available through FlatPark, an independent community repository that is not affiliated with Flathub. FlatPark distributes the official Zuko Linux release payload as a signed, sandboxed Flatpak.
Zuko does not build, sign, or host a Flatpak repository. Each immutable GitHub Release instead contains the official x86_64 Flutter Linux payload:
zuko-linux-vX.Y.Z-x86_64.tar.gz
zuko-linux-vX.Y.Z-x86_64.tar.gz.sha256
The archive contains one top-level bundle/ directory with the executable,
Flutter data, and adjacent libraries. GitHub builds it on Ubuntu 24.04 with the
checksum-pinned Mise Flutter beta SDK. scripts/package-linux-release.sh normalizes the archive,
rejects links, privileged files, and non-relocatable runtime paths, checks
native linkage before and after extraction, and emits its checksum.
The separate FlatPark registry manifest downloads that official release asset
as Flatpak extra-data, pins its SHA-256 and byte size, and unpacks it
without modifying the application payload. FlatPark owns the wrapper,
AppStream data, repository signing, hosting, and package-update automation.
Users therefore trust both the official Zuko release bytes and FlatPark’s
packaging and signing infrastructure; published registry packages are
reviewable in the
FlatPark registry.
Install
Add FlatPark and Flathub at the same user scope, then install Zuko:
flatpak --user remote-add --if-not-exists flatpark \
https://dl.flatpark.org/flatpark.flatpakrepo
flatpak --user remote-add --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak --user install flatpark dev.adonm.zuko
flatpak run dev.adonm.zuko
The package grants only the capabilities required by the client:
- network access for Iroh;
- IPC, Wayland, and DRI for Flutter rendering;
- access to
org.freedesktop.secretsfor encrypted client state.
It grants no X11 socket and no host or home-directory filesystem access. A Secret Service provider such as GNOME Keyring or KWallet must be running. If the login keyring is locked, Zuko leaves encrypted state unchanged and displays an unlock-and-retry screen without requesting an unlock itself. If no provider is available, it reports secure storage as unavailable instead of creating an unprotected fallback.
Release and update maintenance
The Zuko release workflow publishes the raw archive and checksum. It does not
publish a .flatpak, .flatpakref, OSTree repository, or repository signing
key. scripts/release_candidate.py binds the archive bytes to the source
commit, and scripts/publish-github-release.sh fails closed unless the expected
archive and checksum are present exactly once.
The package’s resolve-update.sh selects the exact versioned Linux archive from
the latest GitHub Release. FlatPark’s update automation computes a new size and
checksum and opens a reviewed registry update. Changes to
the FlatPark wrapper, permissions, or metadata belong in that registry rather
than this repository.
For pre-publication testing, just build-flatpark-test-bundle builds the
versioned Linux payload in the pinned Ubuntu container, applies an
immutable revision of the registry’s Zuko wrapper and permissions, and emits an
unsigned local test branch under dist/flatpak/. It embeds the payload only to
make local install testing self-contained; official FlatPark builds continue to
use reviewed extra-data pins and FlatPark’s signing infrastructure.
Apple publishing with Codemagic
GitHub Actions builds unsigned iOS Simulator and macOS candidates. Codemagic is used only where Apple trust material is required; GitHub stores no Apple certificate, provisioning profile, or App Store Connect key.
codemagic.yaml defines one ios-testflight-release workflow. It checks out an
immutable annotated tag, builds and validates the signed IPA, and uploads it to
TestFlight. It uses an M2 runner, Xcode 26.3, CocoaPods 1.16.2, and the same
checksum-pinned Mise Flutter SDK used by GitHub. Codemagic CLI Tools 0.68.0 come
from the hash-locked scripts/codemagic-requirements.txt closure.
One-time setup
- Add
adonm/zukowithcodemagic.yamlat the repository root. - Add App Store Connect App Manager integration
zuko-app-store. - Add an Apple Distribution certificate and matching App Store profile for
dev.adonm.zuko, teamR8PN382RC4. - Store a Codemagic API token as GitHub secret
CODEMAGIC_API_TOKEN. - Protect the GitHub
testflightenvironment.
Release behavior
After the core GitHub Release is published, publish-testflight.yml resolves
the annotated tag and asks Codemagic to:
- require its checkout and built-in tag/commit identity to match;
- build the signed device IPA with the protected Apple identity;
- verify bundle ID, version, build, team, signature, profile, ARM64 architecture, Ghostty framework, and iOS 18 deployment floor;
- retain a SHA-256 sidecar; and
- upload through
zuko-app-storefor TestFlight processing.
Rerunning publish-testflight.yml reuses a successful exact-tag Codemagic build
and resumes an active one. A corrected App Store binary requires a new source
version; immutable tags and accepted store build numbers are never replaced.
TestFlight upload does not submit for review. Tester groups, screenshots, privacy declarations, export compliance, pricing, and review remain App Store Connect operations. Mac App Store packaging is not automated.
Android store publishing
Android compilation and upload-key signing happen once in the coordinated
GitHub Release. The manual publish-flutter-android.yml workflow uses pinned
Codemagic CLI Tools only for bundle validation and the Google Play API boundary.
Dispatch the workflow definition from main and supply an existing immutable
vX.Y.Z release tag.
The workflow checks out the selected immutable tag, downloads its signed AAB and SHA-256 sidecar, validates those exact bytes, and uploads them. It does not install Flutter, run Gradle, or sign a second package.
The workflow verifies all of the following before upload:
- the requested version matches the checked-out Cargo and Flutter metadata;
- the Google Play version code is deterministically derived from the semantic
version as
1,800,000,000 + major * 1,000,000 + minor * 1,000 + patch; - the package and namespace are
dev.adonm.zuko; - Bundletool accepts the AAB and its manifest reports the expected package, version name, and version code;
- the JAR signature is valid and its certificate is the configured Google Play upload key;
- the SHA-256 sidecar matches, and the file is unchanged immediately before the Codemagic Google Play upload.
Google Play setup
External setup cannot be created safely by this repository:
- Register
dev.adonm.zukoin Google Play Console. Complete the developer account, agreements, payments profile, app access, ads, content rating, target audience, data safety, privacy policy, store listing, and any required testing declarations. - Enroll the app in Play App Signing. Preserve the existing keystore as the upload key; it is not the Google-managed app-signing key. Existing users can only upgrade when the package name and signing lineage remain valid.
- Make the first Play Console upload manually if the application has never had an artifact. The Android Publisher API cannot create the app record or bootstrap every first-release state.
- In a dedicated Google Cloud project, enable the Google Play Android Developer API and create a dedicated service account with a JSON key.
- In Play Console, invite or link that service account under Users and permissions. Restrict it to Zuko and grant only the app/release permissions needed to inspect tracks and publish releases. Confirm API access with the internal track before allowing production.
- Create a GitHub Actions environment named
google-play. Add required reviewers, prevent self-review where practical, and restrict deployment tomain. Store the secrets below on that environment, not as plaintext files or workflow inputs.
Protected secrets
| Environment secret | Value |
|---|---|
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON | Complete service-account JSON key |
The Android upload-key secrets remain repository-scoped to the coordinated
release signing job. The google-play environment needs only the service
account JSON. Do not enable shell tracing or print it; the uploader passes the
credential through an ephemeral file rather than command-line plaintext.
Dispatch and release
Run publish-flutter-android manually, select the immutable release tag, Play track,
and mode. draft uploads a draft release; release makes it
available according to the selected track and Google Play review state. Use the
internal track first. A production release is a full production release, so
the google-play environment approval is the final human control.
Google Play upload does not complete the store listing, policy declarations, managed publishing choice, country/device availability, staged production rollout, release review, or final publication. Complete and audit those records in Play Console.
Microsoft Store publishing
The manual publish-flutter-windows.yml workflow builds the Flutter Windows
client from an immutable vX.Y.Z release tag, packages and signs
MSIX/MSIXBundle artifacts, uploads a Partner Center draft, and separates final
submission behind a second protected approval.
Partner Center assigns the package identity. Configure the exact values and
signing credentials documented in
flutter/windows/store/README.md; never
invent or commit placeholders for them. The workflow validates the live Partner
Center identity, selected source commit, and package metadata before upload.
Use lane=draft first. Run WACK against the retained signed bundle, review the
draft in Partner Center, then dispatch lane=submit through the protected
microsoft-store-submit environment. Store listing metadata, age ratings,
privacy declarations, agreements, and the initial Partner Center submission
remain maintainer-owned portal work.
Repeated runs use the same tagged source and package version before certification. After Partner Center accepts a package version, increment the Zuko release version for the next submission; Microsoft does not accept a second package with the published version.
Appetize mobile previews
The independent Appetize publication workflow updates two existing apps from the same immutable annotated release tag used by GitHub Releases and TestFlight:
- Android receives the checksummed, signed APK already published in the GitHub Release.
- iOS receives an unsigned ARM Flutter Simulator
.appzip built from that same tag. Appetize cannot run the signed device IPA.
Appetize is a preview channel, not a source of release artifacts or credentials.
One-time setup
-
In Appetize API Tokens, create a least-privilege Developer token named
zuko-codemagic. -
At Appetize Upload, create separate apps from a signed Android APK and an ARM iOS Simulator
.appzip. -
Copy each app’s
publicKeyfrom its share URL or settings. -
In Codemagic application settings, create the variable group
appetize_credentialswith these values:Variable Value APPETIZE_API_TOKENOrganization API token APPETIZE_ANDROID_PUBLIC_KEYAndroid app public key APPETIZE_IOS_PUBLIC_KEYiOS Simulator app public key Mark the API token secret. The public keys are identifiers rather than credentials, but may also be marked secret to keep all three values scoped to the release workflow.
After GitHub publishes all assets for a tag, it dispatches
publish-appetize.yml without waiting for that channel. The channel starts
Codemagic’s mobile-appetize-release workflow for the exact tag and waits for
both uploads. Codemagic verifies the immutable release identity, downloads and
validates the published APK and iOS Simulator ZIP, and uploads those exact
bytes. It installs no Flutter SDK and performs no compile. The Android signing
key remains only in GitHub. The channel is independently rerunnable and reuses a successful exact-tag Codemagic build.
Verify credentials
Download an existing package and run the matching command:
read -r -s APPETIZE_API_TOKEN
export APPETIZE_API_TOKEN
sh scripts/upload-appetize.sh android ./zuko-android-vX.Y.Z-signed.apk \
YOUR_ANDROID_PUBLIC_KEY "manual credential check"
sh scripts/upload-appetize.sh ios ./Zuko-Flutter-ios-simulator.zip \
YOUR_IOS_PUBLIC_KEY "manual credential check"
unset APPETIZE_API_TOKEN
Confirm both dashboard entries report the expected version and launch. The
Android package must have the same application ID and signing certificate as
the GitHub Release APK; the iOS entry must report an ARM iPhoneSimulator
build.
Rotation
- Rotate the organization token in Appetize, then replace
APPETIZE_API_TOKENin Codemagic’sappetize_credentialsgroup. - If an app is recreated, replace its platform public-key secret.
- Keep preview access authenticated unless a public demo is intentional.
- Revoke temporary Appetize client authorization on the host after testing.
Implementation: scripts/upload-appetize.sh,
scripts/publish-appetize-release.py, codemagic.yaml, and
.github/workflows/publish-appetize.yml.
Security
Model
- Host identity:
${XDG_CONFIG_HOME:-$HOME/.config}/zuko/key. - Host ticket:
endpointa…, sensitive dial information containing the host public key and current addresses. - Client allow-list:
${XDG_CONFIG_HOME:-$HOME/.config}/zuko/authorized_clients. - Client identity: a private local key used to derive a host-scoped token.
- Saved connection state:
${XDG_CONFIG_HOME:-$HOME/.config}/zuko/hosts, iOS Keychain, or the Flutter target’s protected storage.
Connections are Iroh QUIC and end-to-end encrypted. Public relays see encrypted payloads but can observe connection metadata and traffic volume.
Current shell access requires both:
- enough ticket information to dial the host; and
- a client token present in the host’s allow-list.
The ticket alone does not authorize a shell. Keep it private anyway: it exposes reachability metadata, and storing all connection material defensively limits the effect of future protocol changes.
Trust boundaries
zuko shareis the enrollment boundary. Anyone who obtains its short code while it is active can receive connection information and register a token.- The pairing code is memorable rather than high-entropy. Argon2id slows
guessing, and the short timeout/count bound exposure; do not use
--timeout 0unattended. - The host’s config directory controls identity and authorization. Local access to those files is outside zuko’s remote threat boundary.
- Losing a paired client exposes that client’s access until it is removed from the host allow-list.
- Browser state is available to scripts on the same origin. The Labs web client should not be treated as a hardened client until it has a dedicated origin.
Rules
- Protect saved connection state and client identity together.
- Host tickets are handed out through
zuko share/claimonly. zuko hostnever prints the raw ticket.zuko sharerejects stalecurrent_ticket.zuko hostadmits only tokens inauthorized_clients.- Remove a lost client with
zuko rm <name>; usezuko resetif trust cannot be narrowed safely.
Manage trust:
zuko ls
zuko rm <name>
zuko reset # remove key/current_ticket, clear authorised clients
zuko reset --yes
After reset, restart host and re-pair clients.
Report vulnerabilities
Use GitHub Security Advisories: adonm/zuko/security/advisories/new.
Use private advisories for vulnerabilities.
Scope: src/, the shared Flutter client and platform runners, wire protocol,
handoff, service installer, and release packaging.
Contributing
Use mise for pinned tools, environment, and bootstrap dependencies. Use
just for every human-facing and CI operation. CI installs mise.toml directly
and invokes the same Justfile recipes through mise exec -- just <recipe>.
On Linux, contribute from an x86_64 Ubuntu 24.04 Distrobox created from
quay.io/toolbx/ubuntu-toolbox:24.04. This is the local baseline used to match
the repository’s explicit Ubuntu 24.04 Linux jobs. Enter the box and activate
Mise before running checks; Ubuntu 26.04 and Fedora are
optional compatibility environments, not replacements for this gate. See
Building clients for creation and package setup.
mise trust
mise bootstrap # OS packages and pinned tools, including Flutter
eval "$(mise activate bash)"
hk install --mise # local format and full pre-push gates
just # grouped recipe list
just check # Rust + Flutter + release metadata
just test # Rust clippy + unit tests
just test-e2e # live Iroh network + PTY
just preflight # full source, analysis, and test preflight
just container-ci # web + Android + Linux compile gate on x86_64 Linux
just container-all # preflight + quality + Linux-hostable Flutter builds
just build
The Ubuntu 24.04 Distrobox is the normal Rust, Dart, Flutter-test, and direct
Linux iteration environment. The container-* recipes remain the full Flutter
compile gate because they pin the Android SDK/NDK and other build-only inputs.
They prevent silently skipping Android or Linux because CMake, GTK, Java, or
the Android SDK is absent. Use focused container-web, container-android, and
container-linux-build recipes during iteration; use container-all before
requesting review when Flutter or its build configuration changed. See
Building clients for the exact coverage and the
Apple/Windows boundary.
Flutter terminal and Dart binding changes live in the adonm/libghostty
monorepo. Submit reusable changes upstream, then update both immutable Git
package refs in flutter/pubspec.yaml to the same tested commit; do not vendor
package source or generated binary test fixtures into Zuko.
Justfile contains commands and dependencies between recipes. mise.toml
contains only tool versions, OS packages, and environment. Put multi-step
platform logic in scripts/ rather than inline workflow YAML. Workflows retain
only GitHub orchestration: runners, permissions, protected environments,
secrets, caches, matrices, approvals, and artifact transfer.
Each CI job lets jdx/mise-action install the repository configuration as-is;
do not duplicate tool lists in workflow YAML. Mise’s cache covers pinned tool
downloads. Keep additional caching narrow and use the official actions/cache
only for expensive immutable inputs such as the Flutter package cache or fixed
test fixtures. Cargo target directories are intentionally rebuilt rather than
restored through a third-party cache action.
Apple targets (macOS/Xcode):
just build-flutter-ios
just build-flutter-macos
Platform prerequisites, output paths, and the native Windows PowerShell build are in Building clients. The Justfile uses the Git Bash already present on supported Windows development and CI environments; Windows packaging details remain in focused PowerShell scripts called by recipes.
Before PR:
just checkis green.- If Flutter changed, keep shared logic in
flutter/lib/src/and runjust flutter-check; on x86_64 Linux also runjust container-ciso web, Android, and Linux compile. Do not create a target-specific second implementation. - For Flutter UI or input changes, follow the human-centered design guide and test the relevant narrow, wide, keyboard, touch, and accessibility paths.
- Keep commits terse and imperative.
- Update
docs/protocol.mdfor wire changes. - Update
docs/host.mdfor CLI/state changes. - Update
docs/roadmap.mdwhen a support tier, priority, or product boundary changes. - Run
zuko doctorafter service/ticket changes; it must remain read-only and avoid printing keys, tickets, or client tokens.
Scope new work
Read the roadmap and design principles first. Core reliability, recovery, diagnostics, and trust management take priority over new clients and streaming modes.
For a new platform, protocol, or background service, describe:
- the Core user problem it solves;
- its intended product tier;
- its trust and resource boundaries;
- how failure and recovery work;
- the tests and ongoing maintenance it requires.
Client authors: start with clients.md, then read the
Flutter human-centered design guide for graphical-client
work and protocol.md for transport work.
Security reports: use GitHub Security Advisories.
Local hooks and CI scope
The committed hk.pkl keeps deterministic checks close to development:
- pre-commit checks Rust and Dart formatting plus staged whitespace;
- pre-push runs
just preflightwhen code, Flutter, build, or tool configuration changed, including Rust tests, Flutter application tests, and the complete vendoredfltermanalysis and test suite. Documentation-only and GitHub Actions-only changes use their smaller dedicated checks.
Install the repository hooks with hk install --mise; HK=0 is the explicit
one-command escape hatch when a broken local environment must be bypassed.
Hosted Flutter CI intentionally runs just flutter-ci-check, which retains
configuration checks, formatting, application analysis, and application tests
but does not repeat all vendored flterm tests. Cross-platform client builds
still compile the pinned package. Release readiness continues to require the
full local preflight rather than treating the lean hosted check as sufficient.
The local container’s ci mode and GitHub both call just flutter-linux-ci
for the Linux-hostable compile matrix.