The Intent companion app is a native SwiftUI client for iPhone, iPad, and Apple Vision Pro that connects to the intentd daemon over WSS/TLS with certificate fingerprint pinning. It provides on-the-go access to workspaces, notes, tasks, and agent conversations over a direct network connection or, on iPhone and iPad, a Tailcat tunnel.
- Native SwiftUI. Built for iOS 18.0+ / visionOS 2.0+ with async/await, SwiftUI lifecycle, and
@MainActorisolation. Supports iPhone, iPad, and Apple Vision Pro form factors — visionOS is a destination of the singleIntenttarget, not a separate target. - Secure pairing. Pair with
intentdvia QR code (iPhone/iPad),intent://pairdeep link, or manual entry, verify TLS fingerprints (pin-on-first-use or QR code), and authenticate with bearer tokens. Connections without a trusted fingerprint are rejected, including legacy fingerprint-less pairings. Plainws://is available only through an explicit Debug-only loopback test override, never as an app fallback. - Local-first mirror. The app maintains a local cache of workspaces, notes, and agent sessions for offline read access, synced via WebSocket JSON-RPC push notifications.
- JSON-RPC client. Talks to
intentd's WebSocket transport using the same JSON-RPC 2.0 protocol the desktop client uses. - Workspace tools. Create and transfer workspaces, review staged and unstaged changes and commit diffs, browse workspace files, run and monitor workspace scripts, use workspace terminals, and open browser previews served through the daemon.
- Pull requests. Switch the workspace list to pull requests from its overflow menu (iPad: the Pull Requests sidebar section), filter and search them, and open a detail page with checks, reviews, and changed files.
- Rich content. Render Markdown, tables, attachments, and offline Mermaid diagrams in notes and conversations. Messages, question answers, and new workspaces accept up to 10 images.
An Xcode project with a SwiftUI app target plus unit and UI test targets, structured as:
Intent/
├── App/ App lifecycle, root/navigation, and
│ backend session registry
├── Models/ Domain and wire models
├── Services/ JSON-RPC transport, live subscriptions,
│ feature stores, and attachment handling
├── Demo/ `--demo-data` fixtures, demo mode, and
│ parsed launch options
├── ViewModels/ UI state and feature orchestration
├── Presentation/ View-agnostic presentation logic
│ (scroll policy, partitioning, classifiers)
├── Views/ Pairing, workspace, note, and settings UI
│ └── Conversation/ Conversation presentation, transcript,
│ input, and content rendering
├── DesignSystem/ Colors, theme, layout, and avatar design
├── Utils/ Shared formatting, links, haptics, and notifications
├── AppIcon.icon Icon Composer app icon
├── Assets.xcassets App icon sets, colors, and Intent icons
├── Resources/ Inter fonts, Lottie animations, and offline
│ Mermaid and syntax-highlighting assets
├── Constants.swift, Models.swift
├── Info.plist, Intent.entitlements
├── PrivacyInfo.xcprivacy Privacy manifest
├── ConnectionRegistryReadResult.swift
├── PersistenceKeychainStorage.swift
└── PersistenceManager*.swift Keychain credentials, cached state, drafts, and preferences
Info-visionOS.plist visionOS Info.plist
IntentTests/ Unit and integration tests
IntentUITests/ UI and screenshot tests
TailcatKit/ Go bridge for iOS Tailcat tunnels
scripts/ Build/test/lint wrappers, CI helpers, and
Mermaid/highlighting vendoring tools
.github/ CI workflows, runner selection, and actions
ci_scripts/ Xcode Cloud dependency preparation
The app uses Network.framework NWConnection for TCP/TLS (certificate pinning and path observation) and performs the RFC 6455 WebSocket upgrade and framing itself, negotiating permessage-deflate compression (NWProtocolWebSocket cannot accept server extensions). It uses Textual for Markdown, SwiftTerm for terminal emulation, and Lottie for bundled animations. Mermaid diagrams render through bundled Pele JavaScript in a WKWebView, without a CDN or runtime Node dependency.
On first launch, the app shows a pairing flow:
- QR, deep link, or manual entry: the user scans a QR code from the desktop app containing the host, port, token, and cert fingerprint, opens an
intent://pairdeep link, or enters them manually. On visionOS the app offers deep link or manual entry only, not QR scanning. - TLS fingerprint verification: the app computes the SHA-256 fingerprint of the server's self-signed certificate and compares it to the expected fingerprint (from QR or prompt). On mismatch, connection is rejected.
- Bearer auth: the token is sent in the
Authorization: Bearer <token>header. - Persist credentials: the token, server info, and paired connections are stored in the Keychain; non-secret UI state (active connection id, drafts, last-screen navigation) is kept in
UserDefaults.
On iOS, pairing information can also include a Tailcat address. The connection manager races available direct hosts and the tunnel; the winning route still uses the pinned TLS certificate and bearer authentication.
Apple Vision Pro support is a destination of the single Intent target: SUPPORTED_PLATFORMS includes xros/xrsimulator, TARGETED_DEVICE_FAMILY includes 7, and XROS_DEPLOYMENT_TARGET is 2.0. Every view, store, and service compiles for visionOS by default; platform differences live behind #if os(visionOS) guards. CI keeps the destination green with a dedicated visionos-build job (build only, no tests).
Platform limitations on visionOS:
- No tunnel pairing.
TailcatKit.xcframeworkhas no xros slice (gomobile has no visionOS target), so the framework is linked for iOS only and the tunnel path reports an explicit "unavailable" error on visionOS. Connections need a directly reachable daemon, paired viaintent://pairdeep link or manual entry. - No QR scanning. The app's camera-based QR pairing path is hidden on visionOS.
TailcatKit/build_xcframework.sh builds the (gitignored) xcframework for the iOS slices. Use Go compatible with TailcatKit/go.mod (currently Go 1.27.0 or newer); if needed, select a full toolchain version with GOTOOLCHAIN, for example GOTOOLCHAIN=go1.27.0 ./TailcatKit/build_xcframework.sh. visionOS builds do not need this framework or Go.
The app uses the daemon's namespaced JSON-RPC API for these capability groups:
- workspace discovery, creation, lifecycle actions, and live state;
- note and comment reading and live state, plus task listing, status changes, and live state;
- agent discovery and lifecycle plus conversation history, sending, queue management, questions, models, and streaming controls;
- attachment upload, placement, and download, plus workspace file listing and reading;
- repository, branch, provider, model, specialist, and workspace skill discovery;
- Git status, diffs, and commit history across workspace repositories;
- GitHub authentication status and pull request search, detail, checks, reviews, and files;
- workspace transfer and mobile terminal lifecycle, input, output, and resizing;
- workspace script creation, start/stop, status, and removal; and
- background-hook, script-monitor, and pull-request-monitor status and controls.
Live updates combine domain subscriptions for workspaces, notes, tasks, comments, and chats—delivered as subscription.push notifications—with filtered events.subscribe streams for agent, hook, and pull-request-monitor events.
Browser previews use an auxiliary authenticated WebSocket tunnel rather than
passing daemon credentials to web content.
The workspace browser also opens HTTP and HTTPS addresses reachable from the device, including LAN IP addresses, local hostnames, and Tailscale hostnames. Allow Intent's local network access when prompted. The app declares an App Transport Security exception for web content so user-entered HTTP sites can load; daemon connections still require pinned WSS/TLS.
In an agent conversation, type / in the prompt to browse available workspace
skills. Matching skills appear as a horizontally scrolling row of chips above the
composer; keep typing to filter by name or description, then tap a chip to insert
its slash command. Hardware keyboards can use Up/Down to move the highlight, Tab
to insert, and Escape to dismiss the chips. Each agent's skill list is cached for
the rest of the app session.
A fresh checkout does not contain the gitignored TailcatKit.xcframework required
by iOS builds. On macOS with Xcode and the version of Go declared in
TailcatKit/go.mod, build it before opening the project:
TailcatKit/build_xcframework.sh
open Intent.xcodeprojXcode resolves the Swift package dependencies. Select an iPhone, iPad, or Apple Vision Pro destination and use Cmd+R. The app opens to the pairing screen. GUI builds run through Xcode itself; command-line builds and tests instead use the repository wrapper documented below.
Run an intentd instance with its TCP WebSocket listener enabled, then scan its QR
code or enter the pairing details manually:
intentd serve --listen tcpDirect dependencies managed via Swift Package Manager (integrated in Xcode):
| Package | Project requirement | Purpose |
|---|---|---|
| Textual | 0.5.0-intent.4 (exact) |
Markdown rendering for notes and messages |
| SwiftTerm | 1.18.0 (exact) |
Workspace terminal emulation |
| Lottie | 4.6.1..<5.0.0 (currently resolved to 4.6.1) |
Bundled animations |
Intent.xcodeproj/project.pbxproj declares these requirements;
Intent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved records
the resolved direct and transitive packages. The separate Go bridge pins its
dependencies in TailcatKit/go.mod and TailcatKit/go.sum.
Intent/Resources/Mermaid/ contains the checked-in Pele 1.8.0 engine in
pele.bundle.js, the renderer, licenses, and vendor.json provenance. Pele
replaces the Mermaid/ELK engine while retaining Mermaid source syntax and the
existing resource and vendoring directories. Normal Xcode builds use those
assets directly; Node/npm is only needed to regenerate them or run the
renderer's tests.
The engine exposes synchronous window.intentPele.render(source, options).
The app's window.renderMermaid bridge still prepares local Inter fonts,
discards stale revisions, and reports height or errors to Swift. Diagrams keep
their natural size and source direction, with horizontal scrolling and pinch
zoom for wide content. Light and dark themes use Intent brand colors through
Pele CSS variables, using the palette and radii from the
Intent Brand System.
Links and images are disabled; source is limited to 50,000 characters and
flowchart/agentflow diagrams to 500 edges. Source directives and frontmatter
cannot relax those restrictions. The page's CSP and native navigation policy
continue to prevent external content access.
Pele uses its own layout and SVG structure; Mermaid layout engines and
themeVariables do not carry over. Labels support text formatting rather than
arbitrary HTML. TeX labels show their source because no math typesetter is bundled.
To regenerate the bundle from its locked dependencies:
cd scripts/vendor-mermaid
npm ci
npm run buildReview the lockfile, generated bundle, license notices, and vendor.json together. To test
the shipped renderer from the same directory, run npx playwright install chromium webkit
then npm test. The browser suite covers flowchart, sequence, state, class, ER,
pie, XY chart, Gantt, Git graph, and frontmatter fixtures in both themes, plus
security, source and edge limits, stale requests during font loading, source directions,
natural-size scrolling, fullscreen sizing, and completion without animation
frames. Set MERMAID_BROWSERS=chromium or MERMAID_BROWSERS=webkit for a focused
run. To capture all fixtures in both themes, set MERMAID_SCREENSHOT_DIR to a
directory outside the checkout, for example:
MERMAID_SCREENSHOT_DIR=/tmp/intent-pele-screenshots npm testScreenshot names include the engine, browser, fixture, and appearance. Review
the images before claiming visual coverage; browser screenshots do not replace
native device or simulator validation. Native coverage lives in MermaidMarkdownContentTests,
MermaidWebViewTests, and ConversationMermaidUITests.
The native Mermaid probes have a five-second JavaScript deadline and require a
successful render before inspecting the page. MermaidJavaScriptProbeTests
checks missing and late callbacks, error propagation, and cancellation without
depending on a healthy WebKit process. CI retains unit results and console logs
on cancellation as well as failure so interrupted renderer runs can be inspected.
File previews and workspace diffs use @pierre/highlights 0.3.0, bundled in
Intent/Resources/Highlights/ with its WebAssembly engine, Pierre Light/Dark
themes, full license notices, and vendor.json checksums. The published package
contains 120 syntax lexers plus plain text. Normal app builds use the checked-in
assets; no Node installation, CDN, or grammar downloads are needed at runtime.
The local page exposes asynchronous
window.intentHighlights.highlight({ source, path, dark }), returning
{ language, runs: [{ location, length, color, fontStyle }] }. Ranges use UTF-16
code units in the unchanged source, colors are six-digit hex RGB, and font styles
use Shiki flags. ready resolves to a boolean; status is loading, ready, or
failed. Source text is never inserted into the page. CSP blocks network access
and JavaScript evaluation while permitting the bundled WebAssembly engine.
Unknown filenames, engine failures, source above 512 KiB UTF-8, lines above
10,000 UTF-16 code units, or output above 60,000 runs return empty runs for native
plain-text fallback. These are prototype highlighting limits, independent of
the existing 5 MiB file-opening limit. A shared, nonpersistent WKWebView
returns token ranges to the native TextKit 2 viewer. Only foreground colors
change, preserving font metrics, text selection, scrolling, and original-file
contents. Requests cancel on file/theme changes or dismissal and fall back after
five seconds; JavaScript cannot interrupt synchronous WebAssembly tokenization.
File previews include a native line-number gutter, 8-point horizontal padding, and 16-point vertical padding. The header uses the shared sidebar tint, a flat Back action, and a file-options menu with Copy contents, Copy path, Wrap lines, and Show line numbers. Copy uses the original source text or workspace file path. Both display toggles are on by default and remembered across previews; turning wrapping off enables horizontal scrolling with a pinned line-number gutter. Hiding numbers reclaims the gutter's width while preserving the reading position and selection. Numbers follow source lines, including blank lines and a final empty line; wrapped continuations do not repeat numbers. The gutter uses visible TextKit 2 layout fragments and stays outside the selectable/shared text. Source line offsets are indexed during background file loading.
Diffs retain native old/new numbers, markers and change backgrounds. Commit diffs tokenize the complete old and new sources separately. Working and staged diffs only receive hunks from the daemon, so each contiguous side starts with fresh lexer state; a comment or string that begins in omitted context can have incomplete colors. Unknown or over-budget diffs remain plain. Theme and file changes cancel highlighting and discard stale results.
Commit viewing uses one vertically scrolling Changes page with collapsible file sections and independently scrolling horizontal diffs. Each filename stays pinned above its diff until the next file header takes its place. A floating Jump to file control appears only while a file section is not fully visible, and opens a searchable half sheet (expandable to full height; full height at accessibility text sizes) and expands the chosen section. Changes and Details use the same switcher as workspace content. The Details tab exposes the available message, author, date, copyable full hash, and known push status; switching tabs preserves the Changes scroll position. Associated PRs and parent links are not supplied by the current commit model. On iPad, commits and staged/unstaged file diffs open in the main content pane from either the overview or workspace sidebar, using the shell’s background colors. Back preserves the previous page and repository selection; changing workspaces or repositories closes the viewer.
Regenerate and verify the assets with Node 24 or newer:
cd scripts/highlights
npm ci
npm run build
npx playwright install chromium webkit
npm testThe suite checks the shipped bundle, filename resolution, Unicode/CRLF offsets,
multiline syntax, both themes, budgets, WebAssembly failure, and offline CSP in
Chromium and WebKit. HIGHLIGHTS_BROWSERS=webkit npm test selects one browser.
Review generated assets, notices, provenance and the lockfile together. Browser
tests validate the tokenizer contract; native screenshots and device timings
remain separate evidence for the prototype.
- For app builds: macOS with Xcode and its command-line tools. Tests require an available iPhone
simulator by default; use
SIM_UDIDto select an iPad. Every CI Mac selects its newest installed GA Xcode 26 (betas and release candidates are skipped); on GitHub-hostedmacos-26-xlargethat is Xcode 26.6 (the local iPhone pool runs 26.3). - Network access for the first Swift package resolution and pinned Go tool download.
- A Go toolchain compatible with the version in
TailcatKit/go.mod. iOS commands automatically runTailcatKit/build_xcframework.shwhen the gitignored framework is missing and Go is onPATH; otherwise they stop with the manual command to run. SetTAILCATKIT_AUTOBUILD=0to require a prebuilt framework. - The visionOS platform component for visionOS builds. Install it with
xcodebuild -downloadPlatform visionOSif the wrapper reports that it is missing.
Use scripts/xcodebuild.sh for every command-line build and test. It fixes the
project, scheme, signing flags, and destinations, and deliberately rejects extra
arguments such as -sdk.
# Resolve Swift package dependencies
scripts/xcodebuild.sh resolve
# Build the app
scripts/xcodebuild.sh build-ios
scripts/xcodebuild.sh build-visionos
# Run unit tests while iterating, or the complete pre-PR test gate
scripts/xcodebuild.sh test-ios-unit
scripts/xcodebuild.sh test-ios
# Lint (requires SwiftLint 0.65.1; the wrapper enforces the version).
# CI runs this on every PR as the merge-blocking SwiftLint job;
# SWIFTLINT_REPORTER=<name> overrides the config's reporter.
scripts/swiftlint.sh
# Smoke-test the lint wrapper
scripts/test-swiftlint.shThe no-argument scripts/swiftlint.sh gate uses SwiftLint 0.65.1 with the same
strict rules on both hosts. Linux also needs a loadable SourceKit runtime; a
successful swiftlint version does not prove that prerequisite is met.
| Host | Supported route |
|---|---|
| Ubuntu 24.04, x86_64 (development and Linux CI) | Native setup below; lint only. |
| macOS with Xcode | Existing SwiftLint 0.65.1 on PATH and Xcode toolchain; unchanged invocation. |
| Other Linux distributions / architectures | Automatic setup is unsupported; use one of the hosts above. |
From this repository's root:
# One-time system prerequisites, if the setup command reports them missing.
sudo apt-get update
sudo apt-get install -y ca-certificates coreutils curl gnupg libc-bin libxml2 tar python3
# Install or reuse SwiftLint and a working SourceKit runtime.
scripts/setup-swiftlint.sh
scripts/setup-swiftlint.sh --check # read-only prerequisite check
scripts/test-swiftlint-runtime.sh # real clean and violating fixtures
scripts/swiftlint.sh # full strict gate; never installs tools
# Offline wrapper, prerequisite, and setup regressions on Linux.
scripts/test-swiftlint.sh
scripts/test-swiftlint-prerequisites.sh
scripts/test-setup-swiftlint.shOn macOS, use scripts/test-swiftlint.sh for wrapper smoke tests. The Linux
prerequisite/setup fixtures need Linux host tools; the prerequisite suite currently
depends on ldd even though it stubs the reported operating system, a
documented Linux-only limitation (ios#733, closed).
Setup reuses SwiftLint 0.65.1 from PATH and an existing usable runtime. If tools
are missing, it installs the checksum-pinned official SwiftLint release
and Swiftly 1.1.2 into
${XDG_DATA_HOME:-$HOME/.local/share}/intent/swiftlint (override with the absolute
INTENT_SWIFTLINT_HOME path). Swiftly's bootstrap has a pinned SHA256, verified
against its official signature; Swiftly verifies the Swift 6.3.2 toolchain's
PGP signature before extraction. This runtime matches
SwiftLint 0.65.1's upstream image
and is exercised by the real fixture check. A fresh runtime download is about
1 GiB and extracts to 3.3 GiB; allow room for both (set TMPDIR if needed).
No shell profiles or existing toolchains are changed. The gate finds the managed
installation automatically, while a swiftlint already on PATH keeps priority
and must match the pin. Remove a mismatched binary from PATH before re-running
setup. To select another installed runtime, set LINUX_SOURCEKIT_LIB_PATH to the
directory containing libsourcekitdInProc.so; an invalid or unloadable override
fails even if a system runtime could otherwise be used. Unset it to return to
automatic selection. The preflight loads an explicit library and runs a small
real lint probe, with crashes and timeouts reported as prerequisite errors.
SwiftLint's executable libraries (including libxml2) are checked with ldd
before running its version command; missing libraries report the prerequisite
command above. Additional Swift runtime libraries are reported with Swiftly's
package-install command; setup never runs sudo for you. Linux support does not
include SwiftUI builds.
To remove the managed tools, delete only the installation directory (by default
rm -rf ~/.local/share/intent/swiftlint); if XDG_DATA_HOME or
INTENT_SWIFTLINT_HOME was set, use that installation's path instead.
The Xcode wrapper supports these subcommands:
| Subcommand | Purpose |
|---|---|
resolve |
Resolve Swift package dependencies. |
build-ios |
Build for the generic iOS Simulator destination. |
build-ios-device |
Build for physical DEVICE_UDID using project signing; does not install, upload, or change provisioning. |
build-visionos |
Build for the generic visionOS Simulator destination. |
build-for-testing |
Build the iOS test bundles for a later test-only run. |
build-for-testing-ui |
Build the app and UI tests using the IntentUI scheme, without compiling the unit-test bundle. |
test-without-building |
Run previously built unit and UI tests. |
test-without-building-ui |
Run UI tests built with build-for-testing-ui; exclude unit tests from enumeration and execution. Defaults ONLY_TESTING to IntentUITests and verifies the selected results. |
test-without-building-unit |
Run previously built tests without IntentUITests. |
test-ios |
Build and run the complete unit and UI test gate. |
test-ios-unit |
Build and run tests without IntentUITests. |
pick-simulator |
Print the automatically selected iPhone simulator UDID: an iPhone on the newest iOS runtime the selected Xcode can run (an iPhone 16 there when available). The device name and runtime go to stderr. Set SIM_UDID to choose another device or runtime. |
For a focused run, set ONLY_TESTING to one or more whitespace-separated
Target[/Suite[/test]] selectors. Selectors are test identifiers, not Swift file
names. The wrapper validates each selector before the run, then reports resolved,
executed, skipped, and failed counts per selector:
ONLY_TESTING='IntentTests/DemoFixturesTests' scripts/xcodebuild.sh test-ios-unitA selector whose tests were all skipped at runtime exits 0 with a notice. Inspect the per-selector summary and confirm a nonzero executed count before claiming that a green focused run executed the intended tests.
Besides xcodebuild's own status (65 for a build or test failure), the wrapper exits
80 when the test process crashed and its one re-run did not recover, 81 when the
simulator lock timed out, 82 when a selector matched or ran no test, 83 when the
selection could not be verified, and 84 when the app could not launch on the
simulator. For 84, a watchdog spots dyld aborting the app before main (for example,
a library the simulator runtime lacks) within seconds. It stops the run instead of
letting XCUITest wait until the job times out, prints the missing library and the
simulator runtime, and does not re-run. Try a simulator on another runtime with
SIM_UDID. The wrapper header has the details and the
LAUNCH_CRASH_WATCH=0 opt-out.
For repeated focused runs, build once and reuse that build:
scripts/xcodebuild.sh build-for-testing
ONLY_TESTING='IntentTests/DemoFixturesTests' scripts/xcodebuild.sh test-without-building-unitFor UI-only iteration, avoid compiling the unit-test bundle by using the paired
IntentUI scheme commands:
scripts/xcodebuild.sh build-for-testing-ui
ONLY_TESTING='IntentUITests/WorkspaceChangesUITests' scripts/xcodebuild.sh test-without-building-uiUse the regular build-for-testing / test-without-building pair for mixed unit
and UI selectors. SIM_UDID selects a specific simulator; otherwise the wrapper
chooses an iPhone and serializes test runs on that device. Keep SIM_UDID and
DERIVED_DATA_DIR consistent between a split build and run, and rebuild after
source changes. Use separate derived-data directories for concurrent builds.
For Release verification, set BUILD_CONFIGURATION=Release on both build and
test commands (or use test-ios-unit to do both). A focused example is
BUILD_CONFIGURATION=Release ONLY_TESTING='IntentTests/ReleaseConfigurationTests' scripts/xcodebuild.sh test-ios-unit.
Do not reuse a Debug build as Release evidence: ReleaseConfigurationTests is
absent in Debug, and some other suites require Debug-only plaintext fixtures.
To test one commit on a Mac, for example for an agent working on Linux, use
scripts/run_commit_tests.sh. It checks the commit out into a clean temporary
directory (fetching it from origin if needed), runs the selected tests through
the wrapper, and writes a summary to
~/Library/Caches/intent-ios-test-run/summary.md. The summary gives the verdict,
per-suite executed, passed, failed and skipped counts, each failing test with its
first failure message, the log path, and a command that repeats the run. Any
IntentUITests selector runs test-ios; otherwise it runs test-ios-unit:
scripts/run_commit_tests.sh <full-sha> 'IntentTests/AgentListPagingTests'The exit status is the wrapper's. Run it in the foreground. Without
DEVELOPER_DIR it uses the Xcode that commit's CI selects (the newest non-beta
match of the Select Xcode pattern), not the Mac's default Xcode, which can lack
components such as the Metal toolchain; the summary names the Xcode and why. See
the script header for the files it keeps and the environment it passes through.
The build-visionos command always uses the generic visionOS Simulator destination,
never -sdk: an -sdk xros... invocation can silently fall back to iphoneos when
the visionOS platform is absent and produce a bogus successful build.
Tests run serially by default — the shared Intent and IntentUI schemes pin parallelizable = "NO", so no extra flags are needed. Parallel simulator clone runners mass-flake the suite locally (see intent-hq/intent#759).
.github/workflows/ci.yml runs unit tests, the UI smoke selectors in
scripts/ui-smoke-tests.txt, and a visionOS build for pull requests and the merge
queue. Smoke coverage uses two balanced iPhone shards and an independent job for
all eleven focused iPad regressions: sidebar and rail navigation, search, the
Pull Requests section, the empty detail-pane placeholder, and commit and file
diff viewing in the main pane. Each lane owns its build directory,
disposable simulator, results, and evidence. All lanes prefer an idle
self-hosted Mac; GitHub-hosted Macs handle overflow when the local pool is busy.
Matrix fail-fast is
disabled, and the existing iOS UI Tests check requires every lane to succeed.
Go tests, wrapper self-tests, and the strict SwiftLint gate run on Linux.
The full UI bundle runs across two isolated iPhone shards, alongside the iPad regressions,
on the default branch at 09:00 and 21:00 UTC or on manual dispatch:
gh workflow run ci.yml --repo intent-hq/ios --ref your-branch -f ui_suite=full
gh workflow run ci.yml --repo intent-hq/ios --ref your-branch -f pre_pr_gate=true
gh workflow run ci.yml --repo intent-hq/ios --ref your-branch -f only_testing='IntentTests/DemoFixturesTests'Replace your-branch with the branch to test. Filtered dispatches are
non-gating and do not replace the normal PR checks.
Both workspace creation entry tests exercise light and dark appearances on every run, including ordinary PRs.
Each selector in scripts/ui-smoke-tests.txt has a # shard: 1 or # shard: 2
annotation. Assign new selectors to a shard and keep suite members together when
practical. python3 scripts/ui_smoke_tests.py --shard 1 prints that shard's
selection. Invalid annotations, duplicate selectors, or empty shards fail before
native tests start. Run python3 scripts/test_ui_smoke_tests.py after changing
selection logic and python3 scripts/test_ci_simulator.py after changing the
workflow. Screenshot artifact names use -iphone-1, -iphone-2, and -ipad
suffixes for smoke and full runs. Simulator and failure
artifacts also include the lane name.
Full shards partition the built runnable inventory through scripts/ui_full_tests.py.
Measured suite durations in scripts/ui-full-test-durations.json guide balancing;
new suites use a per-test estimate and are always included. Each test belongs to
exactly one shard, and each suite stays together. The aggregate job compares both
lanes' inventories, plans, tested revisions, and resolved identities before passing.
Scheme exclusions and runtime quarantines remain in force. Only the first iPhone
shard owns the screenshot lifecycle contract. Logs and selection plans are retained
in ios-iphone-1-ui-selection and ios-iphone-2-ui-selection; failed or cancelled
lanes retain their available result bundles. An interrupted bundle can remain unreadable.
After building with build-for-testing-ui, select a full shard locally with
UI_TEST_SHARD=1 scripts/xcodebuild.sh test-without-building-ui (or 2). It cannot
be combined with a narrower ONLY_TESTING. Use UI_TEST_SHARD_PLAN_PATH and
ONLY_TESTING_IDENTITIES_PATH to retain selection evidence. Run
python3 scripts/test_ui_full_tests.py after changing the partitioner or its timing data.
pre_pr_gate=true runs the literal scripts/xcodebuild.sh test-ios gate,
building once and running unit and UI tests together on an owned iPhone simulator.
It replaces the separate build/unit/UI steps for that dispatch, keeps the visionOS
build, and retains the xcresult, retry results, full gate log and allowlisted fixture
screenshots. It rejects only_testing and iPad selection; ui_suite is ignored.
For an iPad proof run, add -f simulator_family=ipad with 1–20
Target/Suite[/test] selectors in only_testing; whole-bundle selectors are not
accepted, and each selection must actually execute. This route creates and
cleans up its own simulator and uploads ios-ipad-execution-evidence. Like the
iPhone route, it selects an available runtime matching the selected Xcode SDK's
major version and fails before creation if no compatible iPad is available.
The full UI bundle still excludes AppStoreScreenshotTests and honors runtime
skips. Quarantined transcript cases in
ConversationTranscriptFullGateUITests.swift
extend ConversationTranscriptScrollUITests. Select
ONLY_TESTING='IntentUITests/ConversationTranscriptScrollUITests' with
TEST_RUNNER_INTENT_UITEST_FULL_GATE_REPRO=1 on a local wrapper run;
ui_suite=full alone does not opt into them.
IntentUITests/ConversationMermaidUITests/testFullscreenButtonEdgeAndInlinePinchAndPan
is quarantined for intermittent inline and fullscreen render failures first reported in
ios#735. That issue was closed as stale
after repeated passing filtered runs, but the guard remains; lifting it is tracked in
ios#940. The root cause is unconfirmed.
It is absent from PR/merge-queue smoke selection and skips before app launch in
scheduled full runs, manual full runs, and focused runs unless explicitly enabled.
Neither ui_suite=full nor only_testing alone enables it.
This intentionally leaves fullscreen button-edge activation, inline pinch zoom,
and two-axis pan without default UI coverage. The assertions, diagram feature,
MermaidMarkdownContentTests, and MermaidWebViewTests remain intact. For local
diagnosis on a Mac, run from the repository root:
scripts/xcodebuild.sh build-for-testing-ui
TEST_RUNNER_INTENT_UITEST_MERMAID_REPRO=1 \
ONLY_TESTING='IntentUITests/ConversationMermaidUITests/testFullscreenButtonEdgeAndInlinePinchAndPan' \
scripts/xcodebuild.sh test-without-building-uiOnly the exact value 1 opts in; the transcript quarantine flag is independent.
Check the wrapper's executed/skipped counts: a skip is not runtime coverage.
Remove the guard and restore the smoke selector together (ios#940) after a
validated fix and repeated unchanged runs pass on the affected CI simulator/Xcode
configurations with the original assertions and timeouts.
CI uploads ios-ui-review-evidence for 14 days when allowlisted captures exist,
on success or failure and for filtered UI runs. Open its PNGs directly;
manifest.json records the tested revision, suite/test, source bundle, and retry
origin. UI lane exports append the lane name (-iphone-1, -iphone-2, or -ipad),
for example ios-ui-review-evidence-ipad, to avoid collisions; filtered and
pre_pr_gate dispatches upload the unsuffixed ios-ui-review-evidence. Captures
include workspace creation, iPad sidebars and pairing, menus, changes, and connection-loss
flows; scripts/export_ui_evidence.py is the complete allowlist. Missing images
are not proof that a view was captured. ios-agent-group-evidence and
ios-transcript-recovery-evidence remain separate artifacts. Run
python3 scripts/test_export_ui_evidence.py to check the export contract.
On macOS, use TMPDIR=/private/tmp python3 scripts/test_export_ui_evidence.py
to avoid the fixture's symlinked temporary-path mismatch
(ios#671).
Final diagnostic attachments use XCTest teardown, since an assertion with
continueAfterFailure = false can bypass Swift defer. The Linux CI job runs
python3 scripts/check_ui_attachment_lifecycle.py and its mutation tests
(scripts/test_check_ui_attachment_lifecycle.py) to
reject attachment-producing defer blocks (including named helper calls);
ordinary cleanup remains allowed. The source check is conservative by function
name, not a Swift type checker: it includes parameter defaults but does not
resolve dynamic dispatch, aliases, computed properties, or constructors.
After scripts/xcodebuild.sh build-for-testing-ui, run
python3 scripts/verify_fixture_capture_lifecycle.py --output-root build/capture-contract
with the same simulator and derived-data settings. The output directory must be
new. The first iPhone shard in both smoke and full runs owns this contract
separately from the smoke selection and uploads ios-fixture-capture-lifecycle,
including the original failing xcresults,
result JSON, logs, and compact PNG manifests. The isolated abort fixture opts in
only for this verifier; ordinary test runs skip that intentional failure.
The verifier requires exit 65, the original marked assertion recorded as Failed,
a passing companion fixture, and every exact named final PNG under its owning
test in each result bundle. The local verifier defaults to two result bundles.
Ordinary PRs use --repetitions 1; capture fixture/exporter/tooling changes,
scheduled runs, manual runs, and the merge queue retain two. If the changed-file
lookup fails, CI keeps two. The selector checks the PR head, base, and file count
before and after fetching files; stale reruns and changing PRs also keep two.
.github/scripts/select-fixture-capture-repetitions.sh
defines the relevant paths; its offline regression is
python3 scripts/test_select_fixture_capture_repetitions.py. One repetition
still requires repeated passing captures to remain distinct; the second also
checks separation across result bundles. Changing the abort fixture's capture
teardown back to defer must fail
both the source check and this native contract after rebuilding; restore and
rebuild afterward. XCTest teardown does not guarantee evidence after a process crash.
Other repository checks: scripts/test-row-provider-isolation.sh compiles positive
and negative SwiftUI fixtures against the deferred row-provider boundary on the macOS
CI job. On Linux, python3 scripts/protocol_fixture_version.py lint keeps test
client.hello fixtures on the current intentd protocol version (check warns when
IntentTests/CurrentProtocolVersion.swift is behind; sync updates it), with
python3 scripts/test_protocol_fixture_version.py as its contract test, and
python3 scripts/test_fixture_capture_lifecycle.py rejects false greens in the
capture-lifecycle verifier. scripts/generate-inter-instances.py rebuilds the bundled
static Inter faces from the pinned Inter release (see its header for the command).
The Linux job also runs the offline tooling self-tests scripts/test_xcodebuild.sh,
scripts/test_run_commit_tests.sh,
scripts/test-ci-post-clone.sh, scripts/test-check-swiftlint-inputs.sh,
scripts/test-update-source-lists.sh, scripts/test-generate-test-inputs.sh, and python3 scripts/test_select_macos_runner.py;
scripts/check-swiftlint-inputs.sh keeps scripts/swiftlint-xcode-inputs.xcfilelist
in sync with the Swift sources; scripts/update-source-lists.sh regenerates it and
scripts/test-source-inputs.xcfilelist after Swift files are added, split, moved, or removed.
After building, the macOS job runs scripts/check-webkit-overlay-linkage.sh, which
fails if Intent.app or a test bundle built next to it links
/usr/lib/swift/libswiftWebKit.dylib (which cannot load on iOS 18.4–18.5 simulators); scripts/test-check-webkit-overlay-linkage.sh is
its macOS self-test.
Two auxiliary workflows run on pushes to main: warm-cache.yml (also weekly on
Mondays at 06:17 UTC and on manual dispatch) seeds the Go, TailcatKit, and SPM caches
that ci.yml restores, and notify-monorepo.yml sends a fail-soft
repository_dispatch so intent-hq/intent can advance its submodule pin.
Xcode Cloud uses ci_scripts/ci_post_clone.sh to prepare TailcatKit for iOS with
a checksum-pinned Go toolchain; it skips that setup for visionOS. This is build
preparation, not release automation. See AGENTS.md for
version/build-number checks and the human-controlled upload and release policy.
Script and Go jobs use GitHub-hosted ubuntu-24.04. All macOS jobs prefer
self-hosted Macs: iOS builds and every iPhone/iPad UI lane use the intent-ios
pool; visionOS builds and cache warming prefer intent-ios-build, then
intent-ios. Each pool selects its installed Xcode independently.
The 18 October 2026 validation used Xcode 26.5 on a Depot Mac (the hosted pool
before the move to GitHub-hosted runners) for iPad and 26.3 on the local iPhone Mac.
The pools do not guarantee the same toolchain or simulator runtime. Record the
runner, Xcode, and runtime when comparing timings or investigating failures.
Selectors (.github/scripts/select-macos-runner.sh) use RUNNER_STATUS_TOKEN to inspect the pools and fall back to
GitHub-hosted macos-26-xlarge (Apple Silicon) when every matching local runner is
busy or offline, or runner status cannot be read. Selection uses a snapshot of
runner availability. CI allocates each idle service once across builds and UI
lanes, then sends remaining jobs to hosted Macs. Independent workflows can still
race for the same capacity before GitHub assigns them.
Each local registration must carry intent-ios-runner-<id>, using its GitHub
runner ID. The selector verifies that label is unique and includes it in the
job's routing labels, so GitHub schedules the service counted by the allocation.
Missing or duplicate routing labels make that service ineligible for selection.
The selected image needs Xcode 26;
runtime and Metal provisioning run as needed. Go and Swift package caches and
Xcode Cloud distribution remain unchanged. Swift builds do not use sccache.
Three checks keep implicit state machines from growing back:
| Check | Runs | What it enforces |
|---|---|---|
IntentTests/StateFlagBudgetPolicyTests |
unit tests | Stored Bool flags plus optional Task handles per type under Intent/: four unless baselined in the test; baselines only go down. |
state_phase_flag (.swiftlint.yml) |
scripts/swiftlint.sh |
No Bool named after a step (…Scheduled, …Queued, …Pending, …Awaiting…, …Performed, …InProgress, …InFlight) without a same-line suppression. |
scripts/periphery-assign-only.sh |
manual, macOS | New assign-only properties against scripts/periphery-baseline.json; needs brew install periphery and a wrapper build in DERIVED_DATA_DIR. --write-baseline refreshes the baseline. |
Prefer one enum per lifecycle, CoalescedRead for single-flight reads, and
WorkspaceAuthorityGate for callers that wait on a resolved value.
Conventional commits are required (feat, fix, chore, docs, refactor, test, ci, perf); PR titles are validated in CI.
See AGENTS.md for layout, state-modeling and platform-guard policies, source-input
packaging, focused validation, and issue-reporting requirements. Keep this README
and those instructions aligned when changing build scripts, dependencies, or CI.
- intent-hq/intent — engineering monorepo that mounts this repo at
packages/iosand holds cross-cutting docs and tooling. - intent-hq/intentd — Rust backend daemon this app connects to.
Report bugs and limitations in the iOS app to the intent-hq/ios issue tracker, not the monorepo issue tracker.
This project is licensed under the Apache License 2.0.