Building from source
Requirements
Section titled “Requirements”- A current Xcode with Swift 6 and the macOS 15 / iOS 18 SDKs or later.
- The Xcode license accepted:
sudo xcodebuild -license accept.
The SwiftUI macros only ship inside Xcode.app, so the Command Line Tools alone won’t build the UI.
Background transcript fills establish a complete cache fingerprint baseline before saving so the search index can update only the changed tail. Visible chat opens keep their nonblocking cache path. The TranscriptCachePrefillTests repro and offline prefill indexing checks measure the number of built search documents and verify that old and appended messages remain searchable; token or schema mismatches still require a full recovery pass.
Composer and Quick Capture attachment thumbnails decode away from the main thread and downsample to their displayed pixel size, capped at 512 pixels. Decoded previews use a shared 16 MiB LRU; each visible thumbnail keeps only its own current small bitmap while mounted.
Quick build with SwiftPM
Section titled “Quick build with SwiftPM”swift build # PincerKit, PincerUI, dev app, checksscripts/bundle-mac.sh release # -> build/Pincer.app (ad-hoc signed)open build/Pincer.appSigned builds with Xcode
Section titled “Signed builds with Xcode”Generate the Xcode project with XcodeGen:
xcodegen generateopen Pincer.xcodeprojSet your team, then run Pincer-macOS or Pincer-iOS. The Xcode-built macOS app is sandboxed. Xcode builds also include the Share extensions and, on iOS, the notification service extension. The SwiftPM bundle doesn’t.
Localization
Section titled “Localization”UI strings live in Sources/PincerUI/Resources/Localizable.xcstrings and are looked up with bundle: .module. See Localization.
Unit tests
Section titled “Unit tests”Tests/PincerKitTests is a Swift Testing suite for PincerKit:
- device signing and the connect handshake
- TLS pinning, URL policy and failure classification
- reconnect backoff and protocol frame decoding
- the transcript cache, message search matching and composer drafts
- sidebar grouping, slash commands and approvals
swift test # run the suiteswift test --parallel # run tests in parallel worker processes, as CI doesswift test --filter "Device identity" # one suite or test by nameThe tests are hermetic:
- They open no sockets and never touch the real Keychain,
UserDefaults.standardor Application Support. - Each test uses its own temporary folder and scratch defaults suite, and cleans them up afterwards.
- Secrets always use an in-memory store:
Keychaindetects the test runner, so noPINCER_KEYCHAIN=memoryis needed, and a test fails if any real Keychain call happens. - They can run alongside
PincerCheckswithout either run affecting the other.
Tests/PincerUITests covers the shared transcript UI on macOS: Markdown caching, how a streaming reply is split and drawn, and a streaming performance probe. The probe streams a synthetic reply to 2, 10 and 25 KB and prints the main-thread milliseconds per token and the slowest update as a table:
swift test --filter StreamingProbeText-growth transcript publishes are capped at 60 Hz; status and terminal updates remain immediate. A paired 25 KB streaming probe measured similar per-publish p95 main-actor elapsed time at 30 and 60 Hz (about 2.1 ms), while the summed publish time was about 87% higher at 60 Hz. The cap is fixed and does not track the display refresh rate; a 120 Hz display does not imply 120 Hz transcript updates.
Live automatic Read Aloud checks speakability on one shared background worker, with up to 32 pending items and 8 MiB of logical retained text and identifiers. Each item is limited to 64 text blocks and 256 KiB of joined text. Revisions of the same queued item keep their FIFO position; an active item retains one bounded latest revision, and an authoritative history refresh rechecks a completed candidate at its original position before the success barrier resolves. The byte budget counts logical UTF-8 bytes, not additional storage retained through Swift string copy-on-write. If admission exceeds a limit, automatic reading is suppressed for that run; transcript delivery and manual Listen remain available.
Composer and Quick Capture share one attachment-preparation FIFO: one item is active, with up to 32 pending descriptors and 32 MiB of pending in-memory data. Image codec work runs off-main; the UIKit fallback that turns a pasteboard UIImage into PNG data still runs before that queue. AttachmentPreparationTests checks the real ingest path and codec executor, while the demo check exercises queue budgets and a small PNG round trip.
Composer autosizing measures the draft on the main thread, in the same layout pass as the edit or width change, so a newline, a wrap, a paste, a restored draft, a window resize or a rotation never shows a clipped or one-line frame first. macOS lays the text out with a TextKit 1 layout manager configured like the field; iOS asks the live UITextView for its fitting size. Heights are cached per width for the current text, so SwiftUI’s repeated size proposals cost a lookup. Drafts over 20,000 UTF-16 units skip layout and take the full 12-line height, which is exact at any real composer width. The hosted tests check the native editor height after one layout pass on macOS and iOS, plus a main-thread budget for a 4,000-character draft:
swift test --filter 'ComposerSizing|ComposerSynchronousSizing'CI also runs the transcript suites on an iPhone simulator, including live-versus-committed row layout, off-main inline math, and SVG rasterization. The hosted UIKit suite also resizes a native transcript from 390 to 600 points and back, checking measured row widths, row tops, collection content geometry, and the reader’s anchored row and screen position. Synthetic and seeded Demo transcripts verify that status-bar scroll-to-top is disabled and its delegate rejects the request without changing position, while manual browsing still moves the reader’s anchor. These native checks do not simulate a physical status-bar tap. To run those rendering suites locally:
scripts/ios-test-scheme.shxcodebuild test -scheme PincerUITests-iOS \ -destination 'platform=iOS Simulator,name=iPhone 18 Pro' \ -derivedDataPath .build/xcode-ios \ -only-testing:PincerUITests/StreamingRenderingTests \ -only-testing:PincerUITests/InlineMathOffMainTests \ -only-testing:PincerUITests/SVGRasterizerTestsUse an available iPhone simulator name from xcrun simctl list devices available. SVG rendering can produce WebKit process logs in the simulator; the test assertions (size and rendered pixels) determine whether rasterization succeeded. CI runs SVGRasterizerTests in its own step after the transcript suites, because WebKit rendering alongside the hosted UIKit suites starved for minutes. Cancelling an in-flight iOS render (a row scrolled away) stops the web view load and returns no image.
The iPad sidebar geometry probe hosts a 1,200-row transcript in NavigationSplitView, drives the public native UISplitViewController hide/show transition, and reports detail-width changes and row builds per display frame. It checks that the native column actually changes and that layouts stay bounded while the sidebar animates. This measures native geometry; it does not test the SwiftUI sidebar button binding. Run it on a regular-width iPad simulator (the default iPhone CI lane does not run this probe):
scripts/ios-test-scheme.shxcodebuild test -scheme PincerUITests-iOS \ -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M5)' \ -derivedDataPath .build/xcode-ipad \ -only-testing:PincerUITests/TranscriptSidebarSlideProbeUse an available iPad simulator name from xcrun simctl list devices available.
The iOS StreamingRenderingTests suite also prints a UIKit streaming baseline for 2, 10, and 25 KB markdown replies. It sends prepared events through ChatStore, flushes every two simulated tokens, and updates an actual hosted collection view. CPU windows cover event handling and synchronous native publishing; fixture setup and the run-loop yield between publishes are excluded. It checks the growing body, visible cell, row geometry, and a separate committed-row off-main premeasure warmup. There is no timing cutoff yet.
On an iPhone 18 Pro / iOS 27 simulator in a Debug build, the baseline was:
| Reply | Mean token CPU | Max token CPU | Max publish CPU |
|---|---|---|---|
| 2 KB | 1.289 ms | 4.498 ms | 8.490 ms |
| 10 KB | 1.485 ms | 3.463 ms | 6.623 ms |
| 25 KB | 2.020 ms | 4.128 ms | 7.629 ms |
These are simulated event/render costs, not physical-device timings or a measured network cadence. Run the StreamingRenderingTests command above to collect the table on another simulator.
While a reply streams, text-growth updates are coalesced to the configured frame interval. If the wall clock moves backward, negative elapsed time is treated as zero so a coalesced update waits no longer than one frame; normal bursts and already-due updates keep their existing timing. Status and terminal updates remain immediate. Finished paragraphs are laid out once and only the paragraph being written is measured again, so the cost of each update stays flat as the reply grows.
Self-checks
Section titled “Self-checks”While scripts/run-checks.sh waits for its parallel lanes, it prints a bounded progress snapshot about every 30 seconds. The snapshot names the pending lane, process, log filename, and last recognized unfinished unit-test identifier; it never prints raw log tails or test arguments. Started and finished counts cover only the bounded portion observed, with skipped=1 when earlier output could not be retained. Final logs and exit status are unchanged. The progress reader stops before the solo performance lanes, which keep the CPU to themselves.
The live core paging check uses a separate uncached, headless chat store, so the newest-page and older-page assertions remain independent of background prefetch and already-open transcripts.
PincerChecks is an executable harness that exercises the stores end to end. It complements the unit tests:
swift run PincerChecks # offline (unit) checksswift run PincerChecks --demo # the built-in demo gateway (--demo-core + --demo-extras)swift run PincerChecks --live-core ws://127.0.0.1:18789 dev-tokenswift run PincerChecks --live-extras ws://127.0.0.1:18789 dev-tokenswift run -c release PincerChecks --perf # message search at scaleswift run PincerChecks --live-no-usage ws://127.0.0.1:18790 dev-token # mock started with MOCK_NO_USAGE=1 PORT=18790swift run PincerChecks --live-no-reply-to ws://127.0.0.1:18791 dev-token # mock started with MOCK_NO_REPLY_TO=1 PORT=18791A mode flag picks the mode, and each mode runs only its own suite: a mode flag doesn’t also run the offline checks. Which sections belong to which suite is listed in Sources/PincerChecks/Registry.swift (see Contributing). scripts/run-checks.sh runs every mode side by side.
| Mode | What it checks |
|---|---|
| no flag | Offline checks: identity, protocol models, stores, the transcript cache, message search and composer drafts. |
--demo |
Both halves of the demo run, --demo-core then --demo-extras. |
--demo-core / --demo-extras |
The two halves of --demo, so they can run side by side. A full run against the in-process demo gateway, including sidebar navigation and message search. |
--live <url> <token> |
Both halves of the end-to-end run, --live-core then --live-extras, against a real or mock gateway. |
--live-core <url> <token> / --live-extras <url> <token> |
The two halves of --live, so they can run side by side against separate, fresh mocks. --live-core is the main end-to-end run; --live-extras covers Quick Capture, replies and reactions, the transcript cache, the setup wizard, deep links, tool diffs and avatars. |
--perf |
Builds a message search index over 20 synthetic chats of 20,000 messages each and checks build time, query time, memory and index size. Build it in release (-c release), since its time targets assume an optimized build. |
--live-no-usage <url> <token> |
A run against a gateway without the usage methods (the mock with MOCK_NO_USAGE=1), checking that Usage reports them as unsupported. |
--live-no-reply-to <url> <token> |
A run against a gateway that rejects chat.send’s replyToId (the mock with MOCK_NO_REPLY_TO=1), checking that replies fall back to quoting the original. |
Add --skip-intent-checks to the plain run to leave out the Shortcuts & Siri offline checks, which wait on real reply timeouts (about 15 seconds).
Add --skip-perf-budgets to any mode to report the offline perf smoke timings (message index build, query and append, and one sidebar sessions-row event at 300 sessions) without enforcing their budgets. Only clearly broken timings, such as a selective query over 1 second, still fail. Use it when other work shares the CPU. --perf-smoke runs only the perf smoke, with its budgets enforced.
Debug checks also enforce a deterministic sidebar work budget: a section build derives each eligible row’s parent candidates at most once. This covers all organization modes and the built-in demo without relying on wall-clock timing or a persistent section cache.
Each run sets its own PINCER_DRAFTS_DIR, PINCER_CACHE_DIR and scratch defaults suite, so concurrent runs don’t share storage. It also keeps every secret in memory, so it never touches or prompts for your real Keychain (no PINCER_KEYCHAIN=memory needed), and it fails if any real Keychain call happens.
Launch CPU check
Section titled “Launch CPU check”scripts/check-launch-cpu.sh launches a built Mac app, waits for it to settle, samples its CPU and fails if it isn’t idle. It catches launch loops like the menu bar freeze in #119:
scripts/bundle-mac.sh releasescripts/check-launch-cpu.sh --menu-bar on --demo--menu-bar on|off sets the menu bar item for the run, and --demo saves only the built-in demo gateway, so it connects at launch. By default the script runs a copy of the app under its own bundle id with an in-memory Keychain, and it restores that bundle’s defaults afterwards, so your own settings and gateways aren’t touched. Run scripts/check-launch-cpu.sh --help for the other options.
Every pull request runs it in the Launch CPU step of tests.yml, against the debug PincerMacDev that job already built, with the menu bar off, on, and on with the demo gateway. That step fails only above 50% average CPU, which catches runaway update loops like #119 (near 100%) without flaking on shared runners. CI also runs it every night (.github/workflows/launch-cpu.yml) at the default 5% threshold, with the menu bar on and the demo gateway and again with the menu bar off, against a release build of main. The nightly run is skipped when main has no commits from the last day. The workflow also runs on pull requests that change the check or scripts/bundle-mac.sh, and from Actions → Launch CPU → Run workflow.
Continuous integration
Section titled “Continuous integration”.github/workflows/tests.yml runs on every pull request and every push to main:
- Detect changes (Ubuntu): lists the changed files. If they’re all docs (
website/,docs/, top-level Markdown files orLICENSE), the next two jobs are skipped, which counts as passing. Anything else, including workflows,Package.swift,project.yml,scripts/,mock-gateway/and the sources, runs them. If the list isn’t available (a new branch or a force push), they run too. - Mock gateway selftest (Ubuntu):
npm ci && npm run selftestinmock-gateway/. - Swift build and checks (macOS,
PINCER_KEYCHAIN=memory):-
Restores the cached
.buildfolder -
swift build --build-tests -
scripts/run-checks.sh, which starts five mocks, waits until they all listen (it checks every 50 ms and fails with a mock’s log if it exits or isn’t up within 30 seconds), and then runs these at the same time:swift test --skip-build --parallel, running at most 4 tests at once (PINCER_TEST_WIDTHchanges this), so tests with timeouts don’t starve each other on a small runnerPincerChecksPincerChecks --demo-coreandPincerChecks --demo-extras(withPINCER_DEMO_DELAY_SCALE=0.2)PincerChecks --live-coreandPincerChecks --live-extras(withPINCER_DEMO_DELAY_SCALE=0.2), each against its own mockPincerChecks --live-no-usageagainst a mock started withMOCK_NO_USAGE=1PincerChecks --live-no-reply-toagainst a mock started withMOCK_NO_REPLY_TO=1PincerChecks --live-reconnect(bootstrap races, overlapping reconnects and per-launch RPC counts) against its own mock
Only the plain
PincerChecksrun does the Shortcuts & Siri offline checks; the others pass--skip-intent-checks. Because they share the CPU (CI runners have 3 cores), they all pass--skip-perf-budgets. After they finish,PincerChecks --perf-smokeruns alone and enforces the perf smoke budgets. Then the unit tests with wall-clock budgets run alone withPINCER_STRICT_PERF=1; in the parallelswift testlane they’re only held to five times their budget. Bothswift testruns fail if any Swift Testing run they started exits without printing its final summary, so a test that stops the main run loop (for example withNSApp.postEvent) can’t end the suite early and still pass. A test helper that crashes fails the run; it is never retried. A test whose fake parks a thread on a semaphore (so the code under test stays in flight) belongs in a suite marked.parksCooperativeThread: such tests take turns, because a few of them at once fill the small runners’ cooperative thread pool, and then noTask.sleepwakes up and the run hangs. The script prints each run’s log, then a summary with each run’s time. If a run fails, CI uploads the logs.
-
.github/workflows/docs.yml builds the website (npm ci && npm run build in website/) on pull requests and pushes to main that change it.
CI passes -Xswiftc -enable-incremental-file-hashing to every swift command. Checkout gives every file a new modification time, so without it the restored build would recompile everything.
To reproduce CI locally, run npm ci in mock-gateway/ and swift build --build-tests, then scripts/run-checks.sh. It starts its own mocks on ports 18801–18805 (set CHECKS_PORT_BASE to use others) and writes logs to a temporary folder (or CHECKS_LOG_DIR). Each check run keeps its drafts, transcript cache and saved gateways in its own scratch folders and defaults suites, so the demo and live runs can safely run at the same time.
Environment variables
Section titled “Environment variables”| Variable | Effect |
|---|---|
PINCER_KEYCHAIN=memory |
Keep identities and secrets in memory, so dev runs never touch your real Keychain. PincerChecks and swift test always do this. |
PINCER_CACHE_DIR |
off disables the transcript cache and message search. A path moves both. |
PINCER_DRAFTS_DIR |
off disables saved composer drafts. A path moves them. |
PINCER_REQUEST_LOG |
A file path to log every request and the gateway’s reply. |
PINCER_DEMO_DELAY_SCALE |
Multiplies the demo gateway’s simulated streaming and tool delays. 0.2 runs the demo five times faster. 0 removes them entirely, but then the demo checks can’t see the streaming phases. |
They work for the app too:
open --env PINCER_KEYCHAIN=memory build/Pincer.appopen --env PINCER_REQUEST_LOG=/tmp/pincer.log build/Pincer.appProject layout
Section titled “Project layout”| Path | Contents |
|---|---|
Sources/PincerKit |
Protocol client (handshake, signing, reconnect, TLS pinning), models, and the observable stores. No UI. |
Sources/PincerUI |
Shared UI for macOS and iOS. The shell is SwiftUI. The transcript and sidebar are native for performance: NSTableView/NSOutlineView on macOS and UICollectionView on iOS, with Markdown laid out once with TextKit. |
Apps/macOS, Apps/iOS |
@main app shells used by the Xcode project. |
Apps/Shared |
Resources shared by both apps, including the layered app icon (AppIcon.icon) and the Info.plist keys that name the App Group and Keychain group. |
Apps/ShareExtension |
Share extensions for iOS and macOS: a view controller per platform plus the shared SwiftUI sheet. The logic lives in PincerKit. |
Apps/iOSNotificationService |
iOS notification service extension that decrypts relayed pushes. |
Design/AppIcon |
Flattened reference artwork for the app icon. |
Sources/PincerMacDev |
Dev entry point so SwiftPM alone can produce the macOS app. |
Sources/PincerChecks |
Self-checks: per-domain *Checks.swift files, and Registry.swift listing which run in each mode. |
Sources/PincerPush |
Web Push decryption (RFC 8291), per-gateway push keys and payload parsing, shared by the app and its notification service extension. |
push-relay/ |
Zero-dependency Node relay from Gateway Web Push to APNs. See Push notifications. |
Tests/PincerKitTests |
Unit tests for PincerKit (swift test). |
Tests/PincerUITests |
Unit tests and the streaming performance probe for the shared transcript UI (swift test). |
mock-gateway/ |
Node mock of the Gateway protocol for offline development. |
website/ |
This documentation site. |
Known gaps
Section titled “Known gaps”- iOS runs in the simulator (connect, sidebar, history) but hasn’t been tried on a real device yet.
- Gateway Settings has been tested against the mock only.
Parallel dev worktrees
Section titled “Parallel dev worktrees”Builds from different git worktrees would otherwise share profiles, Keychain items, drafts, outboxes and caches. Give each worktree a development namespace; production builds have none and keep their exact identifiers.
PINCER_DEV_NAMESPACE=feature-x swift run PincerMacDev # own Keychain service, UserDefaults suite, Drafts/Outbox foldersPINCER_DEV_NAMESPACE=feature-x scripts/bundle-mac.sh release # embeds the namespace in build/Pincer.appxcodebuild -scheme Pincer-macOS PINCER_DEV_SUFFIX=.dev-feature-x # bundle ids, App Group and Keychain group get the suffixThe SwiftPM Mac bundler sanitizes the namespace to lowercase letters, digits, and dashes, capped at 24 characters. It writes the bundle ID suffix and PincerDevSuffix into Info.plist, so launching the bundle later keeps its isolated storage without needing the environment variable. Empty or unusable names keep the production identity. Run python3 scripts/test_bundle_mac_namespace.py to check the generated metadata with isolated build-tool fixtures.
Xcode builds validate PINCER_DEV_SUFFIX before compiling each app or extension. Leave it empty for production identity, or use .dev- followed by 1–24 lowercase letters and digits separated by single hyphens (for example, .dev-feature-x). Xcode suffixes must already be canonical; invalid values fail the build rather than being normalized. GitHub Actions checks the generated pre-build phases for all five targets. Run python3 scripts/test_xcode_dev_suffix.py --direct for validator cases, or python3 scripts/test_xcode_dev_suffix.py --generated to generate and exercise the phases locally (requires XcodeGen).
With the suffix, chat.pincer.mac becomes chat.pincer.mac.dev-feature-x, the Keychain service chat.pincer.gateway.dev-feature-x, and storage folders Pincer-feature-x. Use the same name for both variables. On iOS the suffixed App Group and bundle ids need provisioning, so set the suffix there only when you want an isolated install. The push relay rejects the suffixed iOS topic (chat.pincer.ios.dev-x) unless you add it to its APNS_TOPICS, and it needs the aps capability provisioned. Both builds register the same pincer:// URL scheme and Handoff type, so links and Handoff may open the other build. PINCER_CACHE_DIR, PINCER_DRAFTS_DIR and PINCER_OUTBOX_DIR still override folders explicitly.
Cold transcript premeasurement prepares exact message sources on one shared background worker. Up to 64 waiting descriptors retain weak list owners and short native row IDs, with no queued message bodies. One active COW row snapshot and its exact joined text can cost the size of that message; editing can fork shared buffers. This transient memory is not covered by the 4 MiB retained source-memo budget. Finished text and geometry use the existing caches and revision checks. Width prewarm takes the same single lease before each wait, adopts one result before acquiring the next, and yields to queued work when busy. Denied lists each keep one weak, removable capacity observation and at most one queued retry callback. Their metadata fanout scales with the number of currently denied lists; it is not a globally fixed-size observer pool. Metadata warm shortcuts remain; a cold body already warmed by another row may be measured once again in the background. Existing visible layout and unsupported-content paths are unchanged.
Debug reconnect checks wait for the current connection’s actual bootstrap and preference/reconciliation tasks to finish before comparing RPC counts. The reconnect history-count check also requires successfully persisted launch caches; a completed prefetch alone does not prove a cache write succeeded. Release checks retain the legacy readiness fallback.
Cache-reconciliation checks observe the actual directory inventory on the cache writer’s background actor. They verify that an authoritative complete session list removes orphan manifests while current chats and retained outbox entries keep theirs, including owners created while inventory is in flight. Session-key hashing and live-owner bookkeeping remain separate main-actor work; these checks do not establish a physical interaction-latency improvement.
UI readiness polling stops when its test task is canceled, including during the final readiness grace period. This keeps canceled tests from continuing to evaluate UI predicates; readiness deadlines and test time limits are unchanged.
To verify the actual iOS Quick Look presentation lifecycle, use a selected simulator:
python3 scripts/quicklook-presentation-check.py --destination 'platform=iOS Simulator,id=YOUR-SIMULATOR-UUID'This generates an inert, uniquely named temporary app host and runs exactly one enabled test, with no SVG tests. It requires real presentation, backing-file survival while open, and cleanup after programmatic dismissal. It does not prove a physical Done-button action or navigation away while a preview is open. The package test is disabled without the explicit app-host flag; a skipped package test is not presentation evidence. The script retains its temporary result bundle and rejects skipped or failed execution.