Add iOS platform support to BlurtEngine - #145
Conversation
Declare .iOS(.v18) beside .macOS(.v15) in Package.swift (the engine's Synchronization imports set the iOS floor) and fence the mac-only files behind #if os(macOS) so the package compiles for both platforms: - Whole-file fences: AudioRouteMonitor, FocusCapture+Editability, KeyInjector (+Separator, +SystemActions), SystemClipboard, PermissionsChecker, SigningIdentity — CoreAudio HAL listeners, AX reads, NSPasteboard/CGEvent paste injection, and SecCode inspection have no iOS counterpart in this engine. - AudioRoute keeps InputSnapshot portable and fences the HAL reads; off macOS, currentInput() answers nil (the conservative unknown-route answer its consumers already take), so MicCapture compiles unchanged. - AudioTransport.isBluetooth answers false off macOS — the transport constants are HAL symbols and iOS never produces a transport type. - FocusCapture keeps CapturedFocus and FocusedFieldContext portable and fences the AppKit/AX capture; DictationSession's seam defaults and the injector's setTargetApp requirement (an NSRunningApplication — AppKit in the protocol signature) are fenced with it. - Test suites and stubs that exercise fenced symbols carry the same fences; on macOS every suite compiles and runs exactly as before. CI gains a non-required ios-build job on macos-26 that builds the BlurtEngine scheme for 'generic/platform=iOS Simulator' against the runner's latest simulator SDK — build only, no iOS test run. Fenced blocks are re-indented per .swift-format's indentConditionalCompilationBlocks; a whitespace-ignoring diff shows fences only, so macOS behavior is unchanged.
SetupReadiness.isReady(permissions:hasAPIKey:) is portable, but its PermissionStatus parameter type was declared inside PermissionsChecker.swift's whole-file #if os(macOS) fence, so the ios-build job failed with "cannot find type 'PermissionStatus' in scope". The struct is a pure value type (two Bools and their derivations, no framework imports), so move it outside the fence — the same member-level treatment InputSnapshot and FocusedFieldContext already get. PermissionsChecker, which does need the mac-only TCC probes, stays fenced; the macOS token stream is unchanged (git diff -w shows only the fence lines and a new doc comment).
The #if os(macOS) fencing removed the blank line between currentInput()'s closing brace and defaultOutputDeviceID()'s doc comment. Restores main's shape. NOTE: this is NOT confirmed to be the pending swift-format reformat that fails check -- see the commit trailer discussion. swift-format is not installable in this sandbox, so this was not verified locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015D99NhTVKymfq6pVp485Bk
`ios-build` builds the BlurtEngine *library* for the iOS SDK, which proves the source is iOS-clean. It does not prove an iOS consumer can resolve the symbols it reaches for — a library slice can compile while an app that links it does not. PR #145 left "an iOS app target" as an explicit follow-up; this is it. App/BlurtiOSShell is the smallest honest version: one XcodeGen spec, one Swift file, one scene, one view. No microphone, no permission prompts, no UIBackgroundModes, no entitlements, no signing identity — none of those make the link claim stronger, and each is a capability the probe would have to justify. The view renders three engine values so `DEAD_CODE_STRIPPING` (left switched on, deliberately) cannot drop the dependency: a shell that imports the module and touches nothing links even with the engine stripped, and would prove less than it appears. The probes are pure value-type logic — no device, no keychain, no defaults: - `SetupReadiness.isReady(permissions:hasAPIKey:)` over a `PermissionStatus`. That pair is what the iOS build already tripped over once (213a936), so it doubles as a regression probe on the fencing. - `TriggerKey.fromPersisted(_:)` and its `label`. - `SyncSTTLimits.autoReleaseSeconds`. The .xcodeproj is generated by CI and git-ignored rather than committed. Unlike App/Blurt/Blurt.xcodeproj it has no human user, so generating it a second before the build beats a checked-in copy plus a drift check to keep it honest. `ios-build` gains three steps (install xcodegen, generate, build for 'generic/platform=iOS Simulator' with CODE_SIGNING_ALLOWED=NO). The job stays non-required and `gate` still ignores it, unchanged. Two guards widen to cover the second app rather than staying pinned to the first: check.sh's no-external-dependencies guard now reads every App/*/project.yml, and check-invariants.sh's app scope is `App/` instead of `App/Blurt/`. SwiftLint's `included:` gains the new sources, so the shell isn't the only Swift in the repo without correctness lint. Verified on Linux: check.sh --portable is green (actionlint, zizmor, prettier, markdownlint, shellcheck, shfmt, xmllint, the dependency/ignore/site/ portability/invariant guards, ruff, pytest). swift-format and swiftlint have no Linux build here and xcodebuild/xcodegen need a Mac, so the Swift formatting, Swift lint, and every build claim above are CI's to confirm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015D99NhTVKymfq6pVp485Bk
Alex approved making the iOS build a merge blocker, so gate now needs ios-build alongside check and applies the same skip-is-not-a-pass logic to it: success passes, skipped passes only on a docs-only change, and anything else fails. The macOS arm is unchanged. Drops the now-false line in the ios-build comment saying it is deliberately not required and gate ignores it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015D99NhTVKymfq6pVp485Bk
| # stops covering the next one. | ||
| ENGINE="Sources/*.swift" | ||
| APP="App/Blurt/*.swift App/Blurt/*.yml App/Blurt/*.plist :!App/Blurt/Blurt.xcodeproj" | ||
| APP="App/*.swift App/*.yml App/*.plist :!App/Blurt/Blurt.xcodeproj" |
There was a problem hiding this comment.
APP scope uses top-level globs (App/*.swift, etc.), so nested app files are not scanned. This contradicts the stated all-app coverage and can let invariant violations in app subdirectories pass undetected.
| APP="App/*.swift App/*.yml App/*.plist :!App/Blurt/Blurt.xcodeproj" | |
| APP="App/**/*.swift App/**/*.yml App/**/*.plist :!App/Blurt/Blurt.xcodeproj" |
Details
✨ AI Reasoning
1) The change is trying to broaden invariant scanning from one app to all apps.
2) The new scope pattern uses a top-level-only wildcard for file extensions.
3) App files are organized in subdirectories, so this pattern cannot include the intended files.
4) That creates a direct contradiction: the guard claims broader protection while actually skipping app code.
5) This is a concrete control-flow/data-selection bug in the guard logic, not a style issue.
Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
These aren't shell globs — they're git pathspecs. The line is:
APP="App/*.swift App/*.yml App/*.plist :!App/Blurt/Blurt.xcodeproj"and it's consumed at line 304 as files=$(git ls-files -- $scope). The :! exclude in the same string is git-pathspec-only syntax, and git's default pathspec matching doesn't set FNM_PATHNAME, so * crosses / and already recurses.
Empirically, App/*.swift App/*.yml App/*.plist matches 45 files including App/Blurt/Blurt/Hotkey/DictationKeyTap.swift and App/BlurtiOSShell/Sources/BlurtiOSShellApp.swift. Planting a temporary AVAudioEngine() violation at App/BlurtiOSShell/Sources/__tmp_probe.swift is caught:
error: settled decision reintroduced — MicCapture uses a fresh AVAudioRecorder per session …
App/BlurtiOSShell/Sources/__tmp_probe.swift:2:let probe = AVAudioEngine()
The suggested App/**/*.swift variant selects the same 45 files, so it's a no-op here. Not applying it.
Generated by Claude Code
|
Closing this PR at the requester's direction — it was not merged. The branch Generated by Claude Code |
Requested by Alex Kroman · Slack thread
What & why
Before: BlurtEngine declares
platforms: [.macOS(.v15)]only, and won't compile for iOS — AppKit, Accessibility, CoreAudio HAL, and SecCode symbols sit unguarded across the capture, focus-capture, injection, and permissions files.After: the package builds for iOS.
Package.swiftdeclares.iOS(.v18)beside.macOS(.v15)(the engine'sSynchronization/Muteximports set the iOS floor), the portable core — theDictationSessionpipeline, the whole STT client, the settings/Keychain stores,MicCapture— compiles as-is, and the mac-only capture/injection/AX code is fenced behind#if os(macOS). CI builds the iOS slice on every PR.How: whole-file fences on the files that are entirely mac-bound (
AudioRouteMonitor,FocusCapture+Editability,KeyInjector+ its extensions,SystemClipboard,PermissionsChecker,SigningIdentity) and member-level fences where a portable file touches a mac-only symbol:AudioRoutekeepsInputSnapshotportable and answerscurrentInput()with nil off macOS (the conservative unknown-route answer its consumers already take, which letsMicCapturecompile verbatim),AudioTransport.isBluetoothanswers false where the HAL transport constants don't exist,FocusCapturekeeps its data types portable while the AX machinery is fenced, andInjectorProtocol'ssetTargetApp(NSRunningApplication?)requirement — AppKit in the protocol signature — is macOS-only, along with theDictationSessionseam defaults and call site that reach it. Test suites and stubs exercising fenced symbols carry the same fences, so the macOS test run is unchanged. The newios-buildjob (non-required, likecompile) runsxcodebuild -scheme BlurtEngine -destination 'generic/platform=iOS Simulator' buildonmacos-26against the runner's latest simulator SDK — build only, no version pin, no iOS test run.The fenced blocks are re-indented per
.swift-format'sindentConditionalCompilationBlocks, which is most of the diff's bulk; a whitespace-ignoring diff (git diff -w) is ~165 added lines, all fences. macOS behavior is byte-for-byte unchanged.Out of scope (follow-ups): an iOS app and keyboard-extension target; iOS runtime wiring (AVAudioSession activation for
MicCapture, an iOS injector overtextDocumentProxy, App Group defaults + Keychain access group for app↔extension sharing).How it was tested
scripts/check.shpasses (or CI will, if I'm not on a Mac) —--portableis green locally (Linux); CI onmacos-26is the authority on the Swift build, and the newios-buildjob proves the iOS sliceplatforms:mention is left for a follow-up alongside the iOS targetsGenerated by Claude Code