iPhone and iPad
One pane per page on iPhone, the Mac layout on iPad, over the same machines
Not built. The project has no iOS target today: Ovvi is a macOS app. This page is the design of an iPhone and iPad app that reaches the same machines over SSH.
Layout
- Tab pages swipe with one finger (a choice for the phone: the Mac switches tabs at once, with no swipe).
- iPhone shows one pane per page, in reading order through each tab's split tree. This is a drawing choice. Nothing changes in herdr.
- A row of chips over the page has one chip per pane, with the tabs split by
|. A tap on a chip goes to that pane. - iPad in regular width uses the Mac layout. In compact width (Slide Over, narrow Split View) it uses the iPhone layout. iPad keeps one scene.
- iPhone uses its own
NavigationStacklayout instead of a collapsed split view. - Agent panes open in chat on iPhone (Chat view). Chat needs no terminal stream, so the Mac keeps control of the terminal.
- Terminals get a key bar over the keyboard (libghostty-spm's
inputAccessoryItems), for terminals only: Esc, Tab, Shift-Tab, a Ctrl latch, arrows and Enter, as in every iOS herdr client. Check first that keyboard mode switches do not tear it down, as they did in Heeler (patterns across clients). - Card buttons always show; there is no hover.
- Tapping the title opens the machine, session and space switcher. The agents that need you are a bar under the navigation bar.
- The app's chrome and the chat view follow Dynamic Type.
- Drag and drop has no touch path: Move Pane to Tab in the card's menu is the way to move a pane.
Watching and control
- iPhone and iPad open a terminal as
observe, a watcher (One controller per pane). Looking never resizes the PTY. - The first key press, paste or tap in the card, or Take Back, takes
control (
--takeover). A watcher stays a watcher until its user acts. - herdr does not report a pane's PTY columns, and
observeclips or pads to the watcher's size. An iPhone watches at a fixed 160×50, with the font scaled to fit and pinch to zoom. An iPad watches at its card size. observecannot resize, so a size change restarts the stream.
Background
- When the app goes to the background, it closes its terminal streams. iOS
then gives about 30 s. The app holds its other connections with
beginBackgroundTask, closes them when that time ends, and reconnects and reads the snapshot again on return. - Cards still in memory keep their state; only cards the system discarded are built again (State).
Connections
- One Citadel SSH connection per host, with exec channels on it.
Citadel is a pure Swift SSH
client, pinned exactly (0.12.1 was checked); it depends on a swift-nio-ssh
fork, so updates are reviewed. The iOS
ExecisCitadelExec. - The machine list is the same as on the Mac: name, SSH target, icon and id
in
UserDefaults(The machine list). - The key is a Secure Enclave P-256 key (
ecdsa-sha2-nistp256). It cannot leave the device. Settings ▸ Machines' add form shows its public line to copy intoauthorized_keys. - The first connect shows the host key's SHA256 fingerprint and asks. After that the key is pinned, and a changed key blocks the connection.
- Pairing can follow the common pattern of iOS herdr clients: a QR code on the machine with a one-time bootstrap key that enrolls the device's key (patterns across clients).
- The phone reaches a Mac like any SSH machine: turn on System Settings › General › Sharing › Remote Login, then add the Mac by its Tailscale or LAN name.
- Reachability is the user's network (LAN or Tailscale), with no server in
between. Jump hosts come later (Citadel has
jump(to:)). - Citadel has no keepalive; the health check finds dead links. A failed check runs over a new Citadel connection.
- Runs go through the same relay as on the Mac, on one Citadel exec channel
(The SSH connection), so a busy tab
never reaches the server's
MaxSessions(its limit on sessions per connection). A call that times out closes its run. - The browser's remote pages use Citadel
direct-tcpipchannels in place of ssh's-D(Remote localhost).
Checks before building
A small test build checks Citadel with the relay before any iOS work:
- One long-lived Citadel exec channel runs the relay: lines both ways, with stdin open for the life of the connection, and binary data as base64 lines.
- Every run of the app (calls, the event stream,
terminal session control) goes through that channel, and closing a run's stdin ends that run alone. - Closing the channel ends the relay and every run on the machine, and the server frees the session.
- A Secure Enclave P-256 key authenticates.
- A
direct-tcpipchannel carries a web page: an in-app listener on127.0.0.1:<P>pipes each connection into its own channel, and a dev server loads in a web view at full speed. - After the background time ends, the app reconnects and reads a fresh snapshot.
Notifications
- The same notifications as on the Mac, while the app is open or within its ~30 s background time (Notifications).
- No push at first: the phone shows status while the app is open. Alerts
while the app is closed need a push relay: a small stateless server that
forwards encrypted payloads to Apple's push service. It must exist because
Apple's push service needs the developer's
.p8key, which cannot ship in the app. Follow Heeler's design: a stateless Worker as the relay, a herdr plugin[[events]]hook on the machine as the trigger, and end-to-end encryption with a key the app writes to the machine over SSH (patterns across clients). - Faking the
audioorvoipbackground modes to stay connected risks App Store rejection, so the app does not.
Plugins
- Plugins are web pages, and WebKit is on iOS, so the same plugin runs on the
iPhone and iPad (Plugins). It
draws on the device. Its files, git and
runcalls go to the pane's machine, as on the Mac. - The first version ships only the built-in pane kinds and the core plugins (Terminal, Agent, Browser, File Explorer, Git), inside the app. Downloaded code is an App Store review risk, so your own plugins on iOS come later.
- The App Store rules (guidelines 2.5.2 and 4.7, the license agreement's
§3.3.1(B)) allow plugins as web pages under conditions; the evidence is in
plugins.md. - The later path: a setting "Load plugins from my Mac", off by default. The
phone reads each plugin's built folder on the Mac (the entry's folder in
~/.config/ovvi/plugins/<id>/, for exampledist/) over the SSH connection it already has, with onetarrun and a safe tar reader (no.., no links, a size cap), and asks to trust each again. iOS cannot run a package's build script. There is never an in-app plugin catalog on iOS. - A label for any other plugin shows the missing-plugin card with Show placeholder (switch the card to the pane's terminal) (A missing plugin).
- At most 2 live web views on iPhone, and 6 on iPad.
- A browser card reaches a remote
localhostthrough an in-app listener (Remote localhost).
Platform
- libghostty-spm supports iOS 15 and later.
- One multiplatform SwiftUI app target (macOS 26, iPadOS 26 and iOS 26) and the same
HerdrKitpackage. One minimum OS means no fallbacks. - iOS only:
CitadelExec, the UIKit terminal subclass (it yields its pan gesture when the motion is mostly horizontal, so the tab pages swipe), the compact layout. macOS only:ProcessExec,SSHExec, the askpass script, the pill panel, the Dock badge, scroll routing withNSEvent, theWindowGroupscene. - The iOS app ships on the App Store. Citadel is linked for iOS only.
Choices
- Citadel with a Secure Enclave key; the Mac through Remote Login. Pure Swift; the key cannot leave the device; no new mechanism. Rejected: libssh2 and OpenSSL; a server of our own between phone and machine.
- One pane per page on iPhone, agents in chat. Splits do not fit, and chat needs no PTY. Rejected: shrinking splits onto a phone.
- Core plugins only on iOS at first, because downloaded code is an App Store review risk.