Skiff is a small SwiftUI terminal for driving a long-running tmux session on a machine you
own — the desktop at home doing the heavy lifting — from your phone. It is built for the case
where the computer runs the work and the phone is the remote control.
It is deliberately not a general-purpose SSH client. It does one route well: phone → (optional
single jump box) → your host → attach tmux.
Note: the in-app UI text is currently Chinese only. Everything else — code, comments, docs — is English. Localization is welcome (see Contributing).
These are the constraints the implementation is held to. They are the reason the app is this small:
- Pure client, no relay. No third-party cloud, no daemon to install on the host. Just SSH.
tmuxowns the session. Skiff never takes over the session lifecycle — a laptop canssh + tmux attachinto the exact same session at any time, with zero config changes.- One jump hop, max. Phone → your public jump box → the host behind NAT. Arbitrary hop chains are out of scope.
- Agent-agnostic. Whatever CLI you run is just a process inside
tmux. The app is a dumb PTY forwarder and stays blind to it. - Baseline usability is non-negotiable. Smooth scrollback, correct CJK/wide-character rendering, and a real Ctrl/Esc/Tab/arrow key row.
- Notifications are optional and orthogonal. They ride on the terminal bell, not on a push service you have to sign up for.
| SSH | Pure-Swift SwiftNIO SSH. PTY + tmux new -A, correct UTF-8/CJK. |
| Auth | Device ed25519 key held in the Keychain, offered first; password fallback only if the target rejects the key and advertises password auth. The jump hop is key-only — there is no jump password field. The public key is shown on the connect screen with a copy button. |
| Host keys | Trust-on-first-use pinning, keyed by host:port — so two reverse tunnels on the same jump box don't share a pin. Mismatch aborts the connection. |
| ProxyJump | Single hop, implemented as a direct-tcpip channel on the jump connection carrying a second, independent SSH handshake — the jump box never sees the inner session's plaintext. |
| Session picker | Lists real tmux sessions via tmux ls; expand one to see its windows and attach straight into a specific window. Or create a new session by name. |
| Structure save / rebuild | Save a named snapshot of a host's session+window names, and rebuild that skeleton with one tap after the host reboots. Rebuild recreates names only — it does not restore process state, by design. |
| Terminal UX | Touch-drag scrollback (mapped to mouse wheel, needs tmux mouse mode), pinch-to-zoom font size (9–28pt, persisted), one-finger horizontal swipe to switch tmux windows and two-finger swipe to switch tmux sessions, tappable window tab strip. |
| Key row | Custom accessory row replacing SwiftTerm's default: esc, ⌃B (tmux prefix), tab, ` |
| Command panel | Editable queues of one-tap command chips (each optionally auto-pressing Return). Fully user-configurable in-app; JSON-persisted. |
| Notifications | A remote bell (\a) becomes a local notification — no APNs, no paid account, no third-party push service. Have your long job ring the bell when it finishes. |
| Background | Silent-audio keepalive holds the SSH connection while the app is backgrounded. It engages automatically whenever an SSH terminal is live and stops on return to the foreground — there is no user-facing toggle yet. Mixes with other audio, so it won't interrupt your music. |
- Xcode 16 or newer, iOS 16+ target (developed and tested against Xcode 26.5)
- XcodeGen (
brew install xcodegen) —project.ymlis the source of truth;Skiff.xcodeprojis generated and not committed - Dependencies are declared as
from:version floors inproject.yml, so a fresh checkout resolves to current upstream releases. Pin them there if you need reproducible builds. - A host running
sshdandtmux
git clone https://github.com/DKmiyan/skiff-ios.git
cd skiff-ios
xcodegen generate # project.yml -> Skiff.xcodeproj
open Skiff.xcodeprojIn Xcode, set your own signing team on both targets, then Run. Swift Package dependencies resolve on first build.
To build and test from the command line:
xcodebuild -project Skiff.xcodeproj -scheme Skiff \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
xcodebuild -project Skiff.xcodeproj -scheme Skiff \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' testSubstitute any simulator you actually have installed for iPhone 17 Pro.
Signing. project.yml ships with the placeholder bundle id com.example.skiff and no
development team. Set DEVELOPMENT_TEAM and your own reverse-DNS PRODUCT_BUNDLE_IDENTIFIER
in project.yml (then re-run xcodegen generate), or just pick your team in Xcode's Signing
pane. A free Apple ID works for device installs, but the certificate expires after 7 days and
needs re-signing.
Xcode 26 downloads the iOS platform and the Metal toolchain separately —
xcodebuild -downloadPlatform iOSandxcodebuild -downloadComponent MetalToolchain. SwiftTerm has Metal shaders and will not build without the latter.
1. Authorize the app's key. Launch Skiff, copy the public key shown at the bottom of the
connect screen, and append it to ~/.ssh/authorized_keys on the host (and on the jump box, if
you use one). The key is generated on first launch when the Keychain has none, and reused after
that. Simulator and device have separate keychains, so each needs its own authorized_keys
entry.
2. Direct connection. Fill in host, port, user. Leave the password blank to force key auth.
3. Via a jump box. Turn on ProxyJump and fill in the jump host/port/user. The target host field is evaluated from the jump box's point of view. So for a host behind NAT that maintains a reverse tunnel to the jump box:
# On the host behind NAT — expose its port 22 as port 2201 on the jump box:
ssh -N -R 2201:localhost:22 jumpuser@jump.example.comthen in Skiff set jump = jumpuser@jump.example.com:22 and target = youruser@127.0.0.1:2201.
Because host-key pins are keyed by host:port, a second tunnel on 127.0.0.1:2202 is correctly
treated as a different machine.
4. Optional — finish notifications. Make whatever long-running job you care about ring the
terminal bell on completion, e.g. printf '\a' in a completion hook. Skiff turns that into a
local notification (suppressed while in the foreground, shown when backgrounded).
5. Recommended — enable tmux mouse mode so touch-drag scrollback works:
echo 'set -g mouse on' >> ~/.tmux.confSources/
├── SkiffApp.swift app entry
├── ContentView.swift stage machine: connect -> session picker -> terminal
├── ConnectView.swift connection form, recent servers, public-key display
├── SessionPickerView.swift tmux session/window list, save & rebuild structure
├── Theme.swift dark IDE-ish palette
├── WindowTabBar.swift tmux window tab strip
├── CommandStore.swift command queue model + persistence
├── CommandPanelView.swift command panel sheet + editor
├── SessionHistory.swift recent session names
├── SessionStructureStore.swift named session/window skeletons
├── LastConnectionStore.swift last + recent connections (never passwords)
├── SkiffNotifications.swift bell -> local notification, de-duplicated
├── BackgroundKeepAlive.swift silent-audio background keepalive
├── SSH/
│ ├── SSHConnector.swift transport: direct or ProxyJump (nested SSH over direct-tcpip)
│ ├── SSHSession.swift interactive half: PTY + tmux, streams to the terminal
│ ├── SSHSessionLister.swift one-shot `tmux ls` / `list-windows` query
│ ├── SSHAuth.swift key-first, password-fallback auth delegate
│ ├── SSHKeyStore.swift Keychain-backed ed25519 identity
│ └── HostKeyStore.swift TOFU host-key pinning, keyed by host:port
└── Terminal/
├── TerminalSession.swift the transport seam the terminal talks to
├── TerminalScreen.swift SwiftTerm bridge
├── WheelTerminalView.swift touch scroll -> wheel, pinch zoom, swipe windows
├── SkiffKeyBar.swift custom key accessory row
└── EchoSession.swift offline local-echo session for self-check
SSHConnector is shared by the interactive session and the one-shot tmux ls query, so both
take the exact same connect path (including the jump hop and host-key checks).
- Authentication prefers the Keychain
ed25519key. A password is only offered if the server rejects the key and advertises password auth; it is never written to disk. - Saved connections store host/port/user only. Passwords are never persisted.
- Host keys are pinned on first use and verified on every later connect; a mismatch fails the connection rather than prompting. To re-pin a genuinely changed host key you currently have to clear the app's stored pin (delete and reinstall the app).
- TOFU means the first connection is trusted blindly. Over an untrusted network, make that first connection somewhere you trust, or verify the fingerprint out of band.
- The jump hop carries a second, independent SSH session inside a
direct-tcpipchannel, so the jump box cannot read the inner session.
Working and used daily: SSH direct + ProxyJump, key auth with host-key pinning, session/window picker, structure save & rebuild, scrollback, swipe/pinch gestures, key row, command panel.
Rough edges, honestly:
- Bell notifications and background keepalive are lightly tested on physical devices.
- No paste button yet; no in-app UI to reset a host-key pin.
- One jump hop only, by design — no multi-hop chains.
- No
mosh; a dropped connection means reconnecting (thoughtmuxmeans you lose nothing). - UI strings are Chinese-only.
Issues and PRs are welcome — especially English/other localization, device-testing reports for
the notification and keepalive paths, and a host-key re-pin UI. Please keep the design rules
above intact: no relay component, and tmux keeps owning the session.
Built on SwiftTerm (terminal emulation) and swift-nio-ssh (SSH). Prior art worth reading: SwiftTermApp, Citadel.


