Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,84 @@ jobs:
# job is meant to save.
run: swift build --build-tests -Xswiftc -warnings-as-errors

# The engine's iOS slice. `check` and `compile` build BlurtEngine for macOS
# only, so without this nothing would catch a change that reaches for an
# unfenced AppKit/CoreAudio/AX symbol and silently breaks the package's
# declared iOS platform.
#
# Two steps, answering two different questions:
#
# 1. The library on its own, straight from the package — the narrow, fast
# signal, and the one that needs no project and no extra tooling.
# 2. App/BlurtiOSShell, a one-view SwiftUI app that imports BlurtEngine and
# calls into it. Compiling the library says the source is iOS-clean;
# linking it into an application bundle is what says an iOS *consumer*
# can resolve the symbols it reaches for. Those are different failures —
# a library slice can compile while an app that links it does not — and
# the second one is the reason the shell exists at all.
#
# Build-only on purpose: the engine's tests run under `check` on macOS, and
# the shell has no behaviour to test. It is a required check: `gate` treats an
# iOS failure exactly like a macOS one, so a break in an iOS consumer of the
# package blocks the merge.
ios-build:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: macos-26
timeout-minutes: 15

steps:
- name: Checkout blurt
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: blurt
persist-credentials: false

- name: Build BlurtEngine for iOS
working-directory: blurt
# xcodebuild rather than `swift build`: SwiftPM builds for the host, and
# cross-compiling to iOS needs the SDK/destination handling xcodebuild
# owns (it resolves the package straight from the checkout — no project
# needed). `generic/platform=iOS Simulator` builds against the runner's
# default — i.e. latest installed — simulator SDK without naming a
# device or pinning an iOS version. The scheme is the package's own
# `BlurtEngine` product scheme, so only the library builds; the test
# target stays a macOS (`swift test`) concern.
run: |
xcodebuild -scheme BlurtEngine \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/BlurtEngine-iOS" \
build

- name: Install XcodeGen
# Just the one formula, not `brew bundle --file=Brewfile`: the shell's
# project is the only thing this job generates, and installing the other
# eleven tools would cost more than the build it precedes. xcodegen is in
# the Brewfile too, so the version here is the version check.sh uses.
run: brew install xcodegen

- name: Generate the iOS shell project
working-directory: blurt/App/BlurtiOSShell
# Generated here rather than committed, unlike App/Blurt/Blurt.xcodeproj.
# That project is committed because people open it; this one has no human
# user, so generating it a second before the build is strictly better than
# a checked-in copy plus a drift check to keep it honest.
run: xcodegen generate --quiet

- name: Build the iOS shell app
working-directory: blurt
# The same destination as the library step above, so the two steps differ
# only in what is being built. CODE_SIGNING_ALLOWED=NO because a hosted
# runner has no signing identity and this build needs none — nothing is
# installed, run, or distributed; the link is the whole result.
run: |
xcodebuild -project App/BlurtiOSShell/BlurtiOSShell.xcodeproj \
-scheme BlurtiOSShell \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/BlurtiOSShell" \
CODE_SIGNING_ALLOWED=NO \
build

# What swift-format would change, as an applicable patch. Formatting is the
# other half of what CI is sole authority over here, and `check` can only say
# *that* a file is misformatted (`swift-format lint --strict`) — leaving the
Expand Down Expand Up @@ -223,17 +301,18 @@ jobs:
# NOTE: this only protects the repo once branch protection requires `gate`
# instead of (or as well as) `check` — that's a repo settings change.
gate:
needs: [changes, check]
needs: [changes, check, ios-build]
if: always()
runs-on: ubuntu-latest
steps:
- name: Assert the macOS gate ran or was intentionally skipped
- name: Assert the macOS and iOS gates ran or were intentionally skipped
env:
CHECK_RESULT: ${{ needs.check.result }}
IOS_RESULT: ${{ needs.ios-build.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_CHANGED: ${{ needs.changes.outputs.code }}
run: |
echo "changes=$CHANGES_RESULT code=$CODE_CHANGED check=$CHECK_RESULT"
echo "changes=$CHANGES_RESULT code=$CODE_CHANGED check=$CHECK_RESULT ios-build=$IOS_RESULT"
if [ "$CHANGES_RESULT" != "success" ]; then
echo "error: the changes filter did not succeed, so the gate cannot be trusted" >&2
exit 1
Expand All @@ -250,3 +329,15 @@ jobs:
;;
*) echo "error: macOS gate result was '$CHECK_RESULT'" >&2; exit 1 ;;
esac
case "$IOS_RESULT" in
success) echo "iOS gate passed" ;;
skipped)
if [ "$CODE_CHANGED" = "false" ]; then
echo "docs-only change; iOS gate intentionally skipped"
else
echo "error: ios-build was skipped but code changed" >&2
exit 1
fi
;;
*) echo "error: iOS gate result was '$IOS_RESULT'" >&2; exit 1 ;;
esac
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
DerivedData/
*.xcuserdatad/
xcuserdata/
# XcodeGen output for the iOS link probe. App/Blurt/Blurt.xcodeproj is committed
# (people open it) and drift-checked against its project.yml; this one has no
# human user — CI generates it from App/BlurtiOSShell/project.yml right before it
# builds — so there is nothing to commit and nothing to drift. Named exactly, not
# as a `*.xcodeproj/` glob, which would shadow the tracked mac project.
App/BlurtiOSShell/BlurtiOSShell.xcodeproj/
.DS_Store
.claude/*
!.claude/settings.json
Expand Down
4 changes: 4 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ included:
- Tests
- App/Blurt/Blurt
- App/Blurt/BlurtUITests
# The iOS link probe. Tiny, but swift-format already lints every tracked
# .swift file by construction (git ls-files), so leaving this one out of
# SwiftLint would make it the only Swift in the repo with no correctness lint.
- App/BlurtiOSShell/Sources

excluded:
- .build
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ App/Blurt/
MenuBar/, Hotkey/DictationKeyTap, Update/, CueSoundPlayer
Shared/ UITestIdentifiers.swift — compiled into BOTH app and UI-test targets
BlurtUITests/ XCUITest bundle (see Tests)
App/BlurtiOSShell/ the iOS link probe, not a product: a one-view SwiftUI app
that imports BlurtEngine and calls into it, so check.yml's
ios-build job proves the engine LINKS on iOS and not merely
that it compiles. project.yml is the source of truth; the
.xcodeproj is generated by CI and git-ignored, since nobody
opens it. No mic, no permissions, no signing identity —
keep it that way.
Tests/BlurtEngineTests/ Swift Testing suites; Stubs/ holds the seam doubles
scripts/ check.sh, check-site.sh, check-portability.sh, check-invariants.sh,
bootstrap.sh, dev-build.sh, uitest.sh, leaks.sh, release*.sh
Expand Down
71 changes: 71 additions & 0 deletions App/BlurtiOSShell/Sources/BlurtiOSShellApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import BlurtEngine
import SwiftUI

/// The whole iOS shell: one scene, one view, no behaviour.
///
/// It is not a product. It exists so CI has something that must *link*
/// `BlurtEngine` into an iOS application bundle. `ios-build`'s first step
/// compiles the library for the iOS SDK, which proves the source is iOS-clean;
/// it does not prove that an iOS consumer can resolve the symbols it reaches
/// for. That is a different failure, and only an app catches it.
///
/// Deliberately absent, and to stay that way: microphone capture, permission
/// prompts, `UIBackgroundModes`, entitlements, a signing identity, an Info.plist
/// of its own. Every one of those is a capability this probe would have to
/// justify, and none of them make the link claim any stronger.
@main
struct BlurtiOSShellApp: App {
var body: some Scene {
WindowGroup {
EngineProbeView()
}
}
}

/// Renders values the engine computes, which is the part that does the work.
///
/// A shell that imports `BlurtEngine` and then touches nothing links cleanly
/// even when the linker drops the engine entirely — `DEAD_CODE_STRIPPING` is on
/// (see project.yml), so an unreferenced dependency proves only that the module
/// interface parsed. Each property below is therefore a real cross-module call
/// whose result reaches the view body, so it survives to the linked binary:
///
/// - `SetupReadiness.isReady(permissions:hasAPIKey:)` over a `PermissionStatus`.
/// That pair is what the iOS build already tripped over once, when
/// `PermissionStatus` was still inside `PermissionsChecker.swift`'s
/// `#if os(macOS)` fence — so it doubles as a regression probe on the fencing.
/// - `TriggerKey.fromPersisted(_:)` and its `label`, from the hotkey layer.
/// - `SyncSTTLimits.autoReleaseSeconds`, from the STT layer.
///
/// All three are pure value-type logic: no device, no keychain, no defaults,
/// nothing that needs a grant or a running service. Pick replacements with the
/// same property if these ever move.
struct EngineProbeView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("BlurtEngine linked")
Text("setup ready: \(isConfigured)")
Text("trigger key: \(triggerLabel)")
Text("auto-release: \(autoReleaseSeconds) s")
}
.padding()
}

/// The engine's "fully configured" rule, run over a synthetic all-granted
/// reading rather than a real one — iOS has neither of these grants to read.
private var isConfigured: Bool {
let permissions = PermissionStatus(microphone: true, accessibility: true)
return SetupReadiness.isReady(permissions: permissions, hasAPIKey: true)
}

/// The engine's decode-with-default for a persisted trigger keycode.
private var triggerLabel: String {
TriggerKey.fromPersisted(TriggerKey.rightCommand.rawValue).label
}

/// When a held trigger auto-releases, derived by the engine from the Sync STT
/// model's own cap.
private var autoReleaseSeconds: Double {
SyncSTTLimits.autoReleaseSeconds
}
}
62 changes: 62 additions & 0 deletions App/BlurtiOSShell/project.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# XcodeGen spec for the iOS link probe — the smallest real iOS application that
# consumes BlurtEngine. It exists for one job: give CI something that has to
# *link* the engine, not merely compile it. See check.yml's `ios-build`.
#
# Unlike App/Blurt, the generated project is NOT committed (it's in .gitignore)
# and there is no drift check for it. Nobody opens this project in Xcode; CI
# runs `xcodegen generate` here immediately before it builds, so the .pbxproj
# has no chance to go stale against this file.
name: BlurtiOSShell
options:
bundleIdPrefix: dev.alex
deploymentTarget:
# Matches Package.swift's `.iOS(.v18)`. An app deployment target below the
# package's floor fails to resolve the dependency at all, which would make
# this job red for a reason that has nothing to do with the engine's source.
iOS: "18.0"
developmentLanguage: en
settings:
base:
SWIFT_VERSION: "6.0"
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
packages:
# The same local package the mac app carries, reached from App/BlurtiOSShell.
# No remote packages here either — check.sh's dependency guard reads every
# App/*/project.yml, this one included.
BlurtEngine:
path: ../..
targets:
BlurtiOSShell:
type: application
platform: iOS
sources:
- path: Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.iosshell
# No Info.plist of its own: the shell declares no permissions, no
# background modes, and no document types, so the keys Xcode synthesizes
# are the entire correct set. Adding a plist would be somewhere for a
# capability to accrete that this probe has no business asking for.
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
# Warnings are failures for the shell's own code, the same as the mac app
# target. Scoped to the target rather than the project base for the reason
# App/Blurt/project.yml gives: at base it collides with the
# -suppress-warnings Xcode applies to SPM dependency packages.
SWIFT_TREAT_WARNINGS_AS_ERRORS: YES
# Load-bearing, not inherited boilerplate. The probe's whole claim is that
# the linker keeps the engine, so leave the stripper switched on and make
# the app reference symbols it cannot remove (see BlurtiOSShellApp.swift).
DEAD_CODE_STRIPPING: YES
dependencies:
- package: BlurtEngine
product: BlurtEngine
# XcodeGen emits a scheme only where one is declared, and `xcodebuild -scheme`
# needs a shared one. Build only — there is nothing here to run or test.
schemes:
BlurtiOSShell:
build:
targets:
BlurtiOSShell: all
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import PackageDescription

let package = Package(
name: "BlurtEngine",
platforms: [.macOS(.v15)],
// iOS 18 is the era-matching floor for macOS 15: the engine's `Synchronization`
// imports (Mutex) need it, and nothing portable here wants anything newer. The
// mac-only capture/injection/AX files are fenced behind `#if os(macOS)`; the
// pipeline, STT client, and settings stores compile for both platforms.
platforms: [.macOS(.v15), .iOS(.v18)],
products: [
.library(name: "BlurtEngine", targets: ["BlurtEngine"])
],
Expand Down
Loading
Loading