Building DeviceTerm
August 10, 2026 · View on GitHub
Prerequisites
- Apple Silicon Mac running macOS 14 or later for building and running DeviceTerm.
- Xcode 26.4 or 26.6. These are the toolchains currently verified to build DeviceTerm. Other Xcode releases are unverified; Xcode 27 beta does not currently build the project.
- Swift 6 toolchain, included with supported Xcode releases.
- SwiftLint:
brew install swiftlint. Required formake lint/make verify. - Apple Developer ID Application certificate: required for a
fully-launchable bundle. The daemon registers via
SMAppService.agent(...)and launchd's Launch Constraints reject ad-hoc-signed agents on macOS 26, so without Developer ID the GUI starts but the daemon can't demand-launch. Configure via.env.release'sCODESIGN_IDENTITY. A free Apple Account tier is insufficient: the Developer ID cert family ships only with paid Developer Program membership ($99/year). Without credentials,make bundleandmake verifystill complete (ad-hoc-signed bundles assemble + pass smoke checks) but the bundle isn't launchable end-to-end. The hermeticBundleCodesignTestsexercises a separate--ephemeralad-hoc path into a per-run temp dir.
Select the active Xcode
DeviceTerm, xcrun, SwiftPM, and the compatibility probe use the active
developer directory. To make an installed Xcode the command-line default:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
xcode-select --print-path
xcodebuild -version
Use the actual app name when it differs, for example:
sudo xcode-select --switch /Applications/Xcode-26.4.app/Contents/Developer
sudo xcode-select --switch /Applications/Xcode-26.6.app/Contents/Developer
For a one-command test without changing the system default, set
DEVELOPER_DIR for that invocation:
DEVELOPER_DIR=/Applications/Xcode-26.4.app/Contents/Developer make build
This compiler/toolchain support is separate from the Simulator services and
physical-device stream protocols DeviceTerm talks to at runtime. Those
compatibility boundaries are documented in USAGE.md.
First-time setup
git clone <…>
cd deviceterm
make hooks # one-time: activate .githooks/commit-msg
make verify # confirm the tree is green
Day-to-day
make build # build in debug
make run # build + open DeviceTerm.app
make test # unit tests (Swift Testing)
make test-gui # GUI smoke (scripts/gui-smoke.sh; --smoke-driven)
make lint # swiftlint --strict
make verify # the single-command gate
verify self-skips checks whose backing source/script doesn't exist yet
and runs the rest. Green tree always exits 0. See the verify shape note in
../AGENTS.md.
GUI smoke. make test-gui runs scripts/gui-smoke.sh, which launches
the bundled DeviceTerm.app --smoke. The --smoke handler drives Router
dispatches headlessly (first window+tab, daemon round-trip, newTab,
closeTab, second window open, selectWindow, closeWindow) and asserts nav
state, exiting 0 on success. The same script runs inside make verify, so
the default gate catches dispatch/reconcile regressions. There is no
XCTest UI target: by project tenet (no .xcodeproj) this script is the
only GUI gate; modal prompts, real-sim flows, the status item, and ⌘Q live
in Tests/Manual.
Configuration
DeviceTerm preferences live in ~/.config/deviceterm/config (or
$XDG_CONFIG_HOME/deviceterm/config when that variable is set),
Ghostty-style key = value with # comments. Preferences live in
this one file, never in UserDefaults or other scattered app state.
The file is hand-editable; DeviceTerm rewrites only the specific key when a
preference changes, preserving every comment, blank line, and unknown key
verbatim.
Terminal appearance and terminal-local key bindings are a separate domain.
libghostty loads the user's normal Ghostty configuration, including
~/.config/ghostty/config. Neither configuration overrides the other because
their recognized keys are disjoint. The app ignores unknown DeviceTerm keys;
deviceterm dump-config reports them as warnings.
| Key | Values | Default | Behavior |
|---|---|---|---|
tab-close-default | detach, shutdown | detach | Suppresses the Close Tab prompt and picks this action. detach closes the tab but keeps any sims it booted running; shutdown also stops them. |
quit-with-sims-default | keep, shutdown | keep | Suppresses the Quit prompt when DeviceTerm-owned sims are booted. keep quits leaving them running; shutdown stops every owned booted sim first. Has no effect when no owned sims are booted. |
simulator-app-advisory | show, suppress | show | Whether to show the Simulator.app coexistence advisory when a sim is attached while Apple's Simulator.app is also running. suppress hides it. |
auto-update | off, check, download | check | How the app handles updates via Sparkle. check checks automatically and notifies when an update is available; download also installs it on relaunch; off disables automatic checks (the Check for Updates… menu item still works). |
These keys are written when the user ticks "Don't ask again" / "Don't show again" on the matching prompt; deleting one from the file restores the prompt. When DeviceTerm writes the file it makes it self-documenting: each key the app sets is preceded by a doc comment (summary, allowed values, default), and every recognized key the user hasn't set is appended as a commented-out example, so the file lists every available option. Hand-edited lines, comments, and unknown keys are preserved verbatim.
Saved locations
A second, separate file, ~/.config/deviceterm/locations (or
$XDG_CONFIG_HOME/deviceterm/locations), holds the places listed under
Device ▸ Location. It is not key = value: each entry is either
<latitude>,<longitude> [name] or <path>.gpx [name], the name running
to the end of the line. A path containing spaces is double-quoted. Blank
lines and full-line # comments are allowed; a # later in a line is
part of the name. Coordinates parse as POSIX
regardless of the user's locale, so the file means the same thing to
everyone who opens it; the Custom Coordinates sheet is the opposite,
accepting a decimal comma from whoever is typing.
Semantics are append-only: DeviceTerm adds an entry when the user
sets a custom coordinate (deduplicated by position, so re-picking one
adds nothing and never overwrites the name the user gave it), and does
nothing else. It never reorders, caps, or evicts, because file order is
menu order and a capped MRU would eventually delete a line somebody
typed by hand. As with config, comments, blanks, and lines this
version doesn't recognize survive a write byte-for-byte, so a line the
current parser can't read is preserved rather than dropped.
Shell completions + man page
Shell completions install via the CLI itself: deviceterm completions install <zsh|bash|fish> writes the per-shell script to the
conventional autoload path (honoring XDG_DATA_HOME /
XDG_CONFIG_HOME when set) and prints the install path + a one-line
hint covering the rc-file change that enables it (zsh fpath, bash
source, fish autoload).
The man page is hand-authored at share/man/man1/deviceterm.1. From a
checkout, browse the canonical copy with
man -l share/man/man1/deviceterm.1.
libghostty
The terminal pane is libghostty-backed (Ghostty's renderer + input +
parser, no app shell). libghostty isn't built here; it's a
prebuilt SwiftPM binary package, libghostty-spm, pinned in
Package.swift:
.package(
url: "https://github.com/sethdeckard/libghostty-spm.git",
exact: "0.1.0"
)
SwiftPM fetches a checksummed GhosttyKit.xcframework from the
package's GitHub release, and the GhosttyKitResources module ships
libghostty's runtime resource tree (the terminfo sentinel +
ghostty/shell-integration + ghostty/themes) in-package via
Bundle.module. Nothing is vendored in DeviceTerm (no zig
toolchain, no Ghostty source, no Vendor/ tree), so a fresh clone just builds.
LibghosttyBridge is the only target that imports GhosttyKit / sees
ghostty.h. LibghosttyHarness additionally depends on
GhosttyKitResources and points libghostty at
GhosttyKitResources.directoryURL (overridable for local Ghostty
checkouts via DEVICETERM_LIBGHOSTTY_RESOURCES).
Bumping libghostty
The libghostty C API is not versioned by upstream, so treat every bump
as an API audit. The Ghostty pin, the fragile zig/Metal build, and the
embedding-symbol release gate all live in libghostty-spm. There is
nothing to build here. Bumping is: raise the exact: version in
Package.swift to a newer libghostty-spm tag, then make verify to
confirm LibghosttyBridge still compiles against any ghostty.h
changes.
Code signing & release
DeviceTerm is distributed through a Homebrew Cask and a direct DMG download. Both use the same Developer ID-signed and notarized app. Private CoreSimulator APIs preclude the App Store sandbox.
Required environment
CODESIGN_IDENTITY: Developer ID Application certificate identity, for example"Developer ID Application: Your Name (TEAM123ABC)".- Notarization credentials, either
NOTARY_PROFILEfor a storednotarytoolkeychain profile orAPPLE_ID,APPLE_TEAM_ID, andAPPLE_APP_PASSWORD.
Copy .env.release.example to .env.release; the release scripts load it
automatically. Publishing also needs the Sparkle private key in the login
Keychain.
Building a release
make release
make release runs scripts/build-release.sh --dmg:
swift build --configuration release.- Bundle into
DeviceTerm.app(make-app-bundle.sh), embeddingdeviceterm-daemon.appatContents/Library/LoginItems/andSparkle.frameworkatContents/Frameworks/. - Confirm the embedded daemon exists at the expected path.
codesigninner to outer (hardened runtime + secure timestamp): helpers, the Sparkle framework's nested XPC services / Autoupdate / Updater.app, then the framework bundle, the daemon, then the outer app.codesign --verify --deep --strict DeviceTerm.app.- Zip + submit to
notarytool --wait, staple; build the DMG, sign + notarize + staple it too. - Gatekeeper assessment (
spctl).
Publishing (make publish, after a release build) is a separate local
step: it refreshes the Homebrew cask, generates the appcast, and creates
the GitHub release. See RELEASING.md.
Sparkle update signing
Sparkle verifies updates with an EdDSA (ed25519) key pair that is independent of your Apple Developer ID:
- Generate the key once with Sparkle's
generate_keys(ships in the Sparkle distribution). The private key is stored in your login Keychain; the printed public key goes intoSUPublicEDKeyinSources/App/Resources/Info.plist(a placeholder ships in the repo). - At publish time,
generate_appcastsigns each release with the private key and writesappcast.xml;make publishuploads it as a release asset so theSUFeedURLpermalink serves the newest feed. - Back up the private key: it can't be re-issued, and losing it means
existing installs can't verify future updates. See
RELEASING.md.
Location permission
Device ▸ Location ▸ Use My Location reads this Mac's position through
CoreLocation, which macOS gates behind a TCC prompt. The prompt appears
only when NSLocationWhenInUseUsageDescription is present in the running
bundle's Info.plist. It ships in Sources/App/Resources/Info.plist, and
scripts/make-app-bundle.sh copies that file into DeviceTerm.app.
No entitlement is involved. As with the tunnel sockets below,
com.apple.security.personal-information.location is an App Sandbox
key and would be a no-op in a non-sandboxed, hardened-runtime build. The
usage string is the whole requirement, and only the GUI links
CoreLocation: the daemon never touches TCC.
The consequence for development is that Use My Location needs make run,
not swift run. A bare binary has no bundle, so it has no usage string,
so macOS never prompts and CoreLocation never answers. DeviceTerm detects
that case and says so in an alert rather than waiting on a prompt that
will not arrive. Everything else in the submenu works either way.
Physical-device panes: daemon scope & entitlements
Mirroring a physically-connected iPhone/iPad runs entirely inside the
deviceterm-daemon helper: it directly links the physical-device targets
(DeviceReachability, ChannelBootstrap, InteractionRelay, and
MirrorPipeline). MirrorPipeline supplies the VideoToolbox, CoreMedia,
CoreVideo, and IOSurface framework links. The daemon talks to the device over
the OS CoreDevice tunnel (a utun ULA-IPv6 interface) with plain BSD sockets
and decodes the device's video stream to IOSurface in-process.
Two deliberate properties keep this shippable without new signing surface:
- User-scope, no root: DeviceTerm holds the tunnel itself. The daemon is a
lazy-spawned, idle-exiting
LSUIElementhelper bundle: no privileged helper, noSMJobBless, no setuid. The OS tunnel is created and held by Apple's root daemons (remoted/remotepairingd), which a Developer-ID app cannot drive directly (those entry points are gated by privatecom.apple.private.RemoteServiceDiscovery.*entitlements). So instead of reusing a tunnel some other app (Xcode/Device Hub) happens to hold up, the daemon borrows Apple's own signeddevicectl: a benign blockingdevicectl device notification observe --device <udid>subprocess (TunnelKeepalive) parks a trusted CoreDevice session, which keeps theutunup; it's SIGINT'd when the last pane mirroring that device closes. Enumeration isdevicectl list devices(usbmux/lockdown, works with the tunnel down). Both arexcrun devicectlinvocations: Apple's binary makes the privileged ask; the daemon stays user-scope. - No daemon-specific entitlement needed. The app and its helper run
without the App Sandbox (private CoreSimulator APIs preclude it), so
the hardened runtime is the only constraint at notarization. Hardened
runtime doesn't gate BSD-socket networking or spawning
xcrun:getifaddrs/getaddrinfo/connect()over the tunnel andProcess-spawningdevicectlwork without any entitlement. Thecom.apple.security.network.*keys are App Sandbox entitlements and would be no-ops here; we deliberately don't add a separate daemon entitlements plist. The existing release flow already signs the daemon binary + bundle with--options runtime+ timestamp.
On a clean daemon exit TunnelKeepalive.shutdownAll() SIGINTs every
borrowed devicectl; a crashed daemon's orphans self-exit at their
--session-timeout and are reaped (TunnelKeepalive.reapOrphans(), keyed
on a unique observe notification name) the next time the daemon launches.
Captured personal-device frames stay in-process (IOSurface streamed to the GUI for rendering) and are never persisted. The daemon persists no session/ownership/pane state at all: a restart drops every pane and ownership record, and mirrors recreate through the normal attach path (device panes are explicit-attach-only and never auto-resurrected).
UI-test harness (dev/test only)
deviceterm-uitest is an out-of-process instrument that screenshots and
drives DeviceTerm's GUI, so an agent running inside a DeviceTerm tab can
verify what the CLI's --json state claims. It is never part of a
release: scripts/build-release.sh doesn't bundle it, and nothing in
DeviceTerm.app depends on it.
It exists as a separate app for one reason. macOS attributes Screen
Recording and Accessibility to the process that calls the API, resolved
through that process's responsible process. A bare binary run from your
shell attributes to your terminal, so granting it would hand your
terminal broad capture and input rights, and capturing from inside
DeviceTerm would hand them to DeviceTerm. Launching a signed, faceless
.app through LaunchServices gives it its own identity, so the grants
land on the harness and nowhere else.
make uitest-run # build + bundle + launch the resident, then report grants
make uitest-stop # stop it
One-time grants
make uitest-run bundles the harness to
~/Applications/DeviceTermUITestHarness.app, a visible, stable
location (not the hidden .build tree, which the Privacy "+" picker can't
reach and make clean wipes), launches it, and prints a doctor report.
If either grant is missing it then reveals the app in Finder and opens
the two Privacy panes for you, so the one-time setup is drag-and-toggle
rather than a hunt:
- In each pane that
make uitest-runopened, Screen Recording and Accessibility, dragDeviceTermUITestHarness.appfrom the revealed Finder window onto the list (or click+). - Toggle it on in both.
- Re-run
make uitest-run;doctornow exits 0 with both grants.
The two permissions:
- Screen Recording: ScreenCaptureKit reads the composited window server, so Metal-rendered simulator/device panes appear exactly as a human sees them. An in-app self-render would miss those layers.
- Accessibility: reading DeviceTerm's AppKit accessibility tree and
posting the GUI-only gestures (menu clicks, drag, keyboard shortcuts)
that have no CLI equivalent. It is also the only way to dismiss an
app-modal
NSAlert, which blocks DeviceTerm's own main run loop.
With CODESIGN_IDENTITY set in .env.release the harness is signed with a
stable Developer-ID identity, so TCC keys on the signature (not a
per-build cdhash) and (combined with the fixed ~/Applications path) the
grant is genuinely one-time, surviving rebuilds and make clean. Without
it the bundle is ad-hoc signed and its code signature changes on every
rebuild, so macOS may silently drop the grant; if capture starts failing
after a rebuild, toggle the harness off and on in System Settings.
Talking to it
The resident serves a private UDS socket
(~/Library/Caches/deviceterm/uitest.sock, overridable with
DEVICETERM_UITEST_SOCK). Every other verb is a short-lived client that
forwards one request, so the capture APIs always execute inside the
resident: the client never touches them, and nothing rolls attribution
back to your shell.
deviceterm-uitest ping
deviceterm-uitest doctor
deviceterm-uitest capture window --out /tmp/win.png # DeviceTerm's frontmost window (incl. a modal alert)
deviceterm-uitest capture status-item --out /tmp/badge.png # just the daemon's menu bar badge window
The harness only ever captures DeviceTerm's own windows, never a whole
display: it can't screenshot other apps or the desktop. The status
item belongs to the daemon, not the app, and is a menu-bar-layer window of
its own, so it is captured via capture status-item (not a window capture of
the app, and not a display capture). When no owned sim is booted the badge is
hidden and capture status-item reports present:false with no PNG.
Smoke track and the E2E skill
make test-uitest is a deliberate, non-hermetic track (like make test-live): it builds the harness + DeviceTerm.app, ensures the resident
and both grants are present, launches DeviceTerm, and runs a sim-free
smoke subset end to end: a real capture, a well-formed AX dump, and the
direct evidence that a harness-driven "New Tab" gesture changes the tab
count the CLI reports. It needs the TCC grants and an unlocked display, so
it stays out of make verify / make test and self-skips when the harness
source is absent. It never boots or shuts down a simulator.
The richer, sim-touching scenarios (pending-pane lifecycle, status-item
count, close/quit modals, the device picker) are driven interactively by an
agent through the deviceterm-e2e skill: one neutral playbook at
.agents/skills/deviceterm-e2e/PLAYBOOK.md, with Claude and Codex
frontmatter shims under .claude/ and .codex/. Those require a
user-nominated throwaway sim and are not automated here.