Skip to content

Add iOS platform support to BlurtEngine - #145

Closed
claude[bot] wants to merge 5 commits into
mainfrom
feat/blurtengine-ios
Closed

Add iOS platform support to BlurtEngine#145
claude[bot] wants to merge 5 commits into
mainfrom
feat/blurtengine-ios

Conversation

@claude

@claude claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.swift declares .iOS(.v18) beside .macOS(.v15) (the engine's Synchronization/Mutex imports set the iOS floor), the portable core — the DictationSession pipeline, 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: AudioRoute keeps InputSnapshot portable and answers currentInput() with nil off macOS (the conservative unknown-route answer its consumers already take, which lets MicCapture compile verbatim), AudioTransport.isBluetooth answers false where the HAL transport constants don't exist, FocusCapture keeps its data types portable while the AX machinery is fenced, and InjectorProtocol's setTargetApp(NSRunningApplication?) requirement — AppKit in the protocol signature — is macOS-only, along with the DictationSession seam 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 new ios-build job (non-required, like compile) runs xcodebuild -scheme BlurtEngine -destination 'generic/platform=iOS Simulator' build on macos-26 against the runner's latest simulator SDK — build only, no version pin, no iOS test run.

The fenced blocks are re-indented per .swift-format's indentConditionalCompilationBlocks, 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 over textDocumentProxy, App Group defaults + Keychain access group for app↔extension sharing).

How it was tested

  • scripts/check.sh passes (or CI will, if I'm not on a Mac) — --portable is green locally (Linux); CI on macos-26 is the authority on the Swift build, and the new ios-build job proves the iOS slice
  • I read AGENTS.md and this doesn't reintroduce anything deliberately removed
  • Docs updated if behavior changed — no behavior change on macOS; AGENTS.md's platforms: mention is left for a follow-up alongside the iOS targets

Generated by Claude Code

claude added 5 commits August 14, 2026 22:52
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"

@aikido-pr-checks aikido-pr-checks Bot Aug 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@claude

claude Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Closing this PR at the requester's direction — it was not merged. The branch feat/blurtengine-ios is left in place.


Generated by Claude Code

@claude claude Bot closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant