From 6525c1d18aad602896f4e659dc7df1bc60c8b35b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 22:52:36 +0000 Subject: [PATCH 1/5] Add iOS platform support to BlurtEngine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/check.yml | 36 ++ Package.swift | 6 +- Sources/BlurtEngine/Audio/AudioRoute.swift | 139 +++-- .../BlurtEngine/Audio/AudioRouteMonitor.swift | 276 ++++----- .../BlurtEngine/Audio/AudioTransport.swift | 17 +- .../FocusCapture+Editability.swift | 282 ++++----- .../FocusCapture/FocusCapture.swift | 477 +++++++-------- .../Injection/InjectorProtocol.swift | 11 +- .../Injection/KeyInjector+Separator.swift | 94 +-- .../Injection/KeyInjector+SystemActions.swift | 148 ++--- .../BlurtEngine/Injection/KeyInjector.swift | 546 +++++++++--------- .../Injection/SystemClipboard.swift | 268 ++++----- .../Permissions/PermissionsChecker.swift | 204 +++---- .../Permissions/SigningIdentity.swift | 218 +++---- .../Pipeline/DictationSession+Press.swift | 4 +- .../Pipeline/DictationSession+Seams.swift | 16 +- Tests/BlurtEngineTests/AXDowncastTests.swift | 76 +-- .../BrowserBundleIDTests.swift | 256 ++++---- Tests/BlurtEngineTests/CancelRaceTests.swift | 9 +- .../BlurtEngineTests/DictationLogTests.swift | 6 +- .../EditableTargetTests.swift | 130 +++-- .../KeyInjectorFallbackTests.swift | 170 +++--- .../KeyInjectorInsertTests.swift | 506 ++++++++-------- .../KeyInjectorLeadingSeparatorTests.swift | 326 +++++------ .../KeyInjectorSystemActionsTests.swift | 158 ++--- Tests/BlurtEngineTests/MemoryLeakTests.swift | 12 +- Tests/BlurtEngineTests/MicLivenessTests.swift | 120 ++-- .../PermissionsCheckerTests.swift | 140 ++--- .../SigningIdentityMigrationTests.swift | 194 ++++--- .../SigningIdentityTests.swift | 104 ++-- .../Stubs/FakeClipboard.swift | 94 +-- .../Stubs/InjectorTestSupport.swift | 202 +++---- .../BlurtEngineTests/Stubs/StubInjector.swift | 9 +- .../SystemClipboardTests.swift | 364 ++++++------ 34 files changed, 2887 insertions(+), 2731 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e1dc4d3d..326c57ed 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -144,6 +144,42 @@ 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. Build-only on purpose: the engine's tests run under + # `check` on macOS, and there is no iOS app target to exercise. Like + # `compile`, it is deliberately NOT required and `gate` ignores it — it can + # only go red where an iOS consumer of the package would too. + 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 + # 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 diff --git a/Package.swift b/Package.swift index badb5a39..8ad27db0 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]) ], diff --git a/Sources/BlurtEngine/Audio/AudioRoute.swift b/Sources/BlurtEngine/Audio/AudioRoute.swift index bf7e8e98..387dcd26 100644 --- a/Sources/BlurtEngine/Audio/AudioRoute.swift +++ b/Sources/BlurtEngine/Audio/AudioRoute.swift @@ -1,4 +1,11 @@ -import CoreAudio +#if os(macOS) + import CoreAudio +#else + /// CoreAudio's HAL — and with it `AudioDeviceID` — is macOS-only. Restating the + /// underlying type (`AudioObjectID` is a `UInt32`) keeps `InputSnapshot` one + /// shape on every platform, so `MicCapture` compiles unchanged. + typealias AudioDeviceID = UInt32 +#endif /// Read-only queries against the system's current audio routing — the two facts /// about the mic that `AVFoundation` doesn't expose but the capture path needs: @@ -49,73 +56,81 @@ enum AudioRoute { let transportType: UInt32? } - /// The default input device as an `InputSnapshot`, or nil when there is no - /// input device (all of them unplugged or asleep) or CoreAudio refused the - /// read. Nil is the conservative answer everywhere it's consumed: an unknown - /// input invalidates a warm recorder rather than silently keeping one bound to - /// a device that may have gone away. - static func currentInput() -> InputSnapshot? { - guard let deviceID = defaultDeviceID(for: kAudioHardwarePropertyDefaultInputDevice) else { - return nil + #if os(macOS) + /// The default input device as an `InputSnapshot`, or nil when there is no + /// input device (all of them unplugged or asleep) or CoreAudio refused the + /// read. Nil is the conservative answer everywhere it's consumed: an unknown + /// input invalidates a warm recorder rather than silently keeping one bound to + /// a device that may have gone away. + static func currentInput() -> InputSnapshot? { + guard let deviceID = defaultDeviceID(for: kAudioHardwarePropertyDefaultInputDevice) else { + return nil + } + return InputSnapshot(deviceID: deviceID, transportType: transportType(of: deviceID)) + } + /// The system's current default *output* device — what `AudioRouteMonitor` + /// hangs its format listener on. Nil when there is none, or the read failed. + static func defaultOutputDeviceID() -> AudioDeviceID? { + defaultDeviceID(for: kAudioHardwarePropertyDefaultOutputDevice) } - return InputSnapshot(deviceID: deviceID, transportType: transportType(of: deviceID)) - } - - /// The system's current default *output* device — what `AudioRouteMonitor` - /// hangs its format listener on. Nil when there is none, or the read failed. - static func defaultOutputDeviceID() -> AudioDeviceID? { - defaultDeviceID(for: kAudioHardwarePropertyDefaultOutputDevice) - } - // MARK: - CoreAudio addressing + // MARK: - CoreAudio addressing - /// The system-wide audio object, which owns the default-device properties. - static var systemObject: AudioObjectID { AudioObjectID(kAudioObjectSystemObject) } + /// The system-wide audio object, which owns the default-device properties. + static var systemObject: AudioObjectID { AudioObjectID(kAudioObjectSystemObject) } - /// A global-scope address for `selector`. Returned fresh per call rather than - /// stored, because every caller needs its own copy to pass `inout` to - /// CoreAudio. Shared with `AudioRouteMonitor`, which addresses the same object - /// graph and would otherwise restate this three-field literal. - static func globalAddress(_ selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress { - AudioObjectPropertyAddress( - mSelector: selector, - mScope: kAudioObjectPropertyScopeGlobal, - mElement: kAudioObjectPropertyElementMain) - } + /// A global-scope address for `selector`. Returned fresh per call rather than + /// stored, because every caller needs its own copy to pass `inout` to + /// CoreAudio. Shared with `AudioRouteMonitor`, which addresses the same object + /// graph and would otherwise restate this three-field literal. + static func globalAddress(_ selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress { + AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + } - // MARK: - CoreAudio reads + // MARK: - CoreAudio reads - // Spelled out per property rather than shared behind a generic - // `read(_:from:initial:)`. That reads better but doesn't compile: `&value` - // on an unconstrained `T` is "forming 'UnsafeMutableRawPointer' to a variable - // of type 'T'; this is likely incorrect because 'T' may contain an object - // reference". Making it work means constraining to `BitwiseCopyable` and going - // through `withUnsafeMutableBytes` — more machinery than two five-line reads - // are worth, in a file the coverage gate can't check anyway. + // Spelled out per property rather than shared behind a generic + // `read(_:from:initial:)`. That reads better but doesn't compile: `&value` + // on an unconstrained `T` is "forming 'UnsafeMutableRawPointer' to a variable + // of type 'T'; this is likely incorrect because 'T' may contain an object + // reference". Making it work means constraining to `BitwiseCopyable` and going + // through `withUnsafeMutableBytes` — more machinery than two five-line reads + // are worth, in a file the coverage gate can't check anyway. - /// The device the system object reports for `selector` (a default-device - /// property). Nil covers both a failed read and the "no such device" sentinel, - /// which callers treat identically. - private static func defaultDeviceID(for selector: AudioObjectPropertySelector) -> AudioDeviceID? { - var address = globalAddress(selector) - var deviceID = AudioDeviceID(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceID) - // 0 is `kAudioObjectUnknown` — "there is no such device" — spelled as the - // literal so this doesn't depend on how the constant imports. - guard status == noErr, deviceID != 0 else { return nil } - return deviceID - } + /// The device the system object reports for `selector` (a default-device + /// property). Nil covers both a failed read and the "no such device" sentinel, + /// which callers treat identically. + private static func defaultDeviceID(for selector: AudioObjectPropertySelector) -> AudioDeviceID? { + var address = globalAddress(selector) + var deviceID = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(systemObject, &address, 0, nil, &size, &deviceID) + // 0 is `kAudioObjectUnknown` — "there is no such device" — spelled as the + // literal so this doesn't depend on how the constant imports. + guard status == noErr, deviceID != 0 else { return nil } + return deviceID + } - /// The device's transport type, or nil when the read failed — - /// `AudioTransport` and `MicLiveness` both treat nil as "not Bluetooth", which - /// is the conservative direction for each. - private static func transportType(of deviceID: AudioDeviceID) -> UInt32? { - var address = globalAddress(kAudioDevicePropertyTransportType) - var transport = UInt32(0) - var size = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &transport) - guard status == noErr else { return nil } - return transport - } + /// The device's transport type, or nil when the read failed — + /// `AudioTransport` and `MicLiveness` both treat nil as "not Bluetooth", which + /// is the conservative direction for each. + private static func transportType(of deviceID: AudioDeviceID) -> UInt32? { + var address = globalAddress(kAudioDevicePropertyTransportType) + var transport = UInt32(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &transport) + guard status == noErr else { return nil } + return transport + } + #else + /// iOS has no HAL to ask; route identity there is an `AVAudioSession` question + /// this engine doesn't wire up yet. Always-nil is the conservative answer the + /// consumers already take for an unreadable route: `MicCapture` discards warm + /// recorders rather than trusting one, and `AudioTransport`/`MicLiveness` + /// treat the missing transport as not-Bluetooth (short wait cap, no linger). + static func currentInput() -> InputSnapshot? { nil } + #endif } diff --git a/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift b/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift index 4d17d1d9..84886a53 100644 --- a/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift +++ b/Sources/BlurtEngine/Audio/AudioRouteMonitor.swift @@ -1,152 +1,154 @@ -import CoreAudio -import Dispatch -import os +#if os(macOS) + import CoreAudio + import Dispatch + import os -/// Ticks whenever the system's audio **output** route changes in a way that -/// invalidates an already-pre-rolled `AVAudioPlayer`. -/// -/// This exists for the record cue chimes. `CueSoundPlayer` decodes and -/// `prepareToPlay()`s them once at launch so the first chime never stalls the -/// pill — but that pre-roll is bound to the output route it was made against, -/// and Blurt's own capture is what invalidates it: opening the mic flips AirPods -/// out of their output-only profile into the bidirectional one, which drops the -/// output format underneath the primed players. The first chime after such a -/// flip is exactly the one that stalls, which is the chime at the start of a -/// dictation. -/// -/// Two properties are watched, because "the route changed" has two shapes: -/// -/// - `kAudioHardwarePropertyDefaultOutputDevice` on the system object — the user -/// switched output devices (built-in speakers → AirPods). -/// - `kAudioDevicePropertyNominalSampleRate` on whichever device is *currently* -/// default — the same device renegotiated its format, which is what the -/// profile flip looks like from CoreAudio. That listener is re-targeted -/// whenever the first one fires, so it always tracks the live device. -/// -/// Public because the app owns the cue players; the engine's own use of -/// CoreAudio routing (`AudioRoute`) stays internal. -/// -/// `@unchecked Sendable` because the listener registrations below are confined -/// to `queue` rather than protected by a lock — see their declarations. -public final class AudioRouteMonitor: @unchecked Sendable { - private static let logger = Logger(subsystem: BlurtIdentity.subsystem, category: "AudioRoute") + /// Ticks whenever the system's audio **output** route changes in a way that + /// invalidates an already-pre-rolled `AVAudioPlayer`. + /// + /// This exists for the record cue chimes. `CueSoundPlayer` decodes and + /// `prepareToPlay()`s them once at launch so the first chime never stalls the + /// pill — but that pre-roll is bound to the output route it was made against, + /// and Blurt's own capture is what invalidates it: opening the mic flips AirPods + /// out of their output-only profile into the bidirectional one, which drops the + /// output format underneath the primed players. The first chime after such a + /// flip is exactly the one that stalls, which is the chime at the start of a + /// dictation. + /// + /// Two properties are watched, because "the route changed" has two shapes: + /// + /// - `kAudioHardwarePropertyDefaultOutputDevice` on the system object — the user + /// switched output devices (built-in speakers → AirPods). + /// - `kAudioDevicePropertyNominalSampleRate` on whichever device is *currently* + /// default — the same device renegotiated its format, which is what the + /// profile flip looks like from CoreAudio. That listener is re-targeted + /// whenever the first one fires, so it always tracks the live device. + /// + /// Public because the app owns the cue players; the engine's own use of + /// CoreAudio routing (`AudioRoute`) stays internal. + /// + /// `@unchecked Sendable` because the listener registrations below are confined + /// to `queue` rather than protected by a lock — see their declarations. + public final class AudioRouteMonitor: @unchecked Sendable { + private static let logger = Logger(subsystem: BlurtIdentity.subsystem, category: "AudioRoute") - /// Fires once per observed route change. `.bufferingNewest(1)` because this is - /// an invalidation signal, not a log: a consumer that was busy through three - /// changes needs to re-prime once, not three times. - public let outputRouteChanges: AsyncStream - private let continuation: AsyncStream.Continuation + /// Fires once per observed route change. `.bufferingNewest(1)` because this is + /// an invalidation signal, not a log: a consumer that was busy through three + /// changes needs to re-prime once, not three times. + public let outputRouteChanges: AsyncStream + private let continuation: AsyncStream.Continuation - /// The queue CoreAudio delivers every listener callback on, and the one place - /// the registrations below are touched. Serial, so a re-target triggered by a - /// default-device change can't interleave with itself. - private let queue: DispatchQueue + /// The queue CoreAudio delivers every listener callback on, and the one place + /// the registrations below are touched. Serial, so a re-target triggered by a + /// default-device change can't interleave with itself. + private let queue: DispatchQueue - /// The registered listener blocks, kept so they can be handed back to - /// CoreAudio — removal matches on block identity, so a re-created block would - /// deregister nothing. - /// - /// `nonisolated(unsafe)` rather than lock-guarded: every read and write happens - /// inside a `queue` block, including the initial registration (`init` wraps it - /// in `queue.sync` precisely so a listener can't fire before the property - /// recording it has been written). Dispatch's serial ordering supplies both the - /// exclusion and the memory barriers a lock would. - private nonisolated(unsafe) var systemListener: AudioObjectPropertyListenerBlock? - private nonisolated(unsafe) var deviceListener: (id: AudioDeviceID, block: AudioObjectPropertyListenerBlock)? + /// The registered listener blocks, kept so they can be handed back to + /// CoreAudio — removal matches on block identity, so a re-created block would + /// deregister nothing. + /// + /// `nonisolated(unsafe)` rather than lock-guarded: every read and write happens + /// inside a `queue` block, including the initial registration (`init` wraps it + /// in `queue.sync` precisely so a listener can't fire before the property + /// recording it has been written). Dispatch's serial ordering supplies both the + /// exclusion and the memory barriers a lock would. + private nonisolated(unsafe) var systemListener: AudioObjectPropertyListenerBlock? + private nonisolated(unsafe) var deviceListener: (id: AudioDeviceID, block: AudioObjectPropertyListenerBlock)? - public init() { - let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - self.outputRouteChanges = stream - self.continuation = continuation - self.queue = DispatchQueue(label: "\(BlurtIdentity.subsystem).AudioRoute") - queue.sync { - installDefaultDeviceListener() - retargetFormatListener() + public init() { + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + self.outputRouteChanges = stream + self.continuation = continuation + self.queue = DispatchQueue(label: "\(BlurtIdentity.subsystem).AudioRoute") + queue.sync { + installDefaultDeviceListener() + retargetFormatListener() + } } - } - /// The monitor is owned for the app's lifetime, so this never runs in - /// practice — but deregistering mirrors the `[weak self]` care below and - /// documents that the CoreAudio registrations are owned rather than leaked: a - /// listener left behind outlives the monitor, since CoreAudio retains the block - /// and nothing else would ever hand it back. - /// - /// Removal happens **inline, with no hop onto `queue`** — deliberately. - /// - /// A `queue.sync` here can self-deadlock. The listener blocks capture `self` - /// weakly, but `guard let self` upgrades that to a strong reference for the - /// body's duration, so while a block is running `queue` *is* an owner. If the - /// last other reference is dropped in that window, the block's release is the - /// final one and this `deinit` runs **on `queue`** — where `queue.sync` - /// deadlocks against itself. - /// - /// Inline removal is also race-free without the hop. `deinit` only runs once - /// the last reference is gone, so no block can be *inside* its `guard let self` - /// concurrently with this — a block that starts now fails the upgrade and - /// touches nothing. That leaves these reads of the queue-confined - /// registrations unopposed. (`queue` is still passed to CoreAudio, because - /// removal matches on the queue the listener was added with; that's an argument, - /// not an execution context.) - deinit { - continuation.finish() - if let systemListener { - var address = AudioRoute.globalAddress(kAudioHardwarePropertyDefaultOutputDevice) - _ = AudioObjectRemovePropertyListenerBlock( - AudioRoute.systemObject, &address, queue, systemListener) - } - if let deviceListener { - var address = AudioRoute.globalAddress(kAudioDevicePropertyNominalSampleRate) - _ = AudioObjectRemovePropertyListenerBlock( - deviceListener.id, &address, queue, deviceListener.block) + /// The monitor is owned for the app's lifetime, so this never runs in + /// practice — but deregistering mirrors the `[weak self]` care below and + /// documents that the CoreAudio registrations are owned rather than leaked: a + /// listener left behind outlives the monitor, since CoreAudio retains the block + /// and nothing else would ever hand it back. + /// + /// Removal happens **inline, with no hop onto `queue`** — deliberately. + /// + /// A `queue.sync` here can self-deadlock. The listener blocks capture `self` + /// weakly, but `guard let self` upgrades that to a strong reference for the + /// body's duration, so while a block is running `queue` *is* an owner. If the + /// last other reference is dropped in that window, the block's release is the + /// final one and this `deinit` runs **on `queue`** — where `queue.sync` + /// deadlocks against itself. + /// + /// Inline removal is also race-free without the hop. `deinit` only runs once + /// the last reference is gone, so no block can be *inside* its `guard let self` + /// concurrently with this — a block that starts now fails the upgrade and + /// touches nothing. That leaves these reads of the queue-confined + /// registrations unopposed. (`queue` is still passed to CoreAudio, because + /// removal matches on the queue the listener was added with; that's an argument, + /// not an execution context.) + deinit { + continuation.finish() + if let systemListener { + var address = AudioRoute.globalAddress(kAudioHardwarePropertyDefaultOutputDevice) + _ = AudioObjectRemovePropertyListenerBlock( + AudioRoute.systemObject, &address, queue, systemListener) + } + if let deviceListener { + var address = AudioRoute.globalAddress(kAudioDevicePropertyNominalSampleRate) + _ = AudioObjectRemovePropertyListenerBlock( + deviceListener.id, &address, queue, deviceListener.block) + } } - } - // MARK: - Registration (queue-confined) + // MARK: - Registration (queue-confined) - /// Watches for the default output device itself changing. Registered once and - /// never re-targeted — the system object is always there. - private func installDefaultDeviceListener() { - var address = AudioRoute.globalAddress(kAudioHardwarePropertyDefaultOutputDevice) - // `[weak self]`, so CoreAudio's strong hold on the block doesn't keep the - // monitor alive forever — and so a callback landing during teardown finds - // nil rather than a half-destroyed object. - let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in - guard let self else { return } - // Re-target first, then publish: a consumer that re-primes on this tick - // should already be behind a listener pointed at the new device. - self.retargetFormatListener() - self.continuation.yield() - } - let status = AudioObjectAddPropertyListenerBlock(AudioRoute.systemObject, &address, queue, block) - guard status == noErr else { - Self.logger.error("default-output listener failed: \(status)") - return + /// Watches for the default output device itself changing. Registered once and + /// never re-targeted — the system object is always there. + private func installDefaultDeviceListener() { + var address = AudioRoute.globalAddress(kAudioHardwarePropertyDefaultOutputDevice) + // `[weak self]`, so CoreAudio's strong hold on the block doesn't keep the + // monitor alive forever — and so a callback landing during teardown finds + // nil rather than a half-destroyed object. + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + guard let self else { return } + // Re-target first, then publish: a consumer that re-primes on this tick + // should already be behind a listener pointed at the new device. + self.retargetFormatListener() + self.continuation.yield() + } + let status = AudioObjectAddPropertyListenerBlock(AudioRoute.systemObject, &address, queue, block) + guard status == noErr else { + Self.logger.error("default-output listener failed: \(status)") + return + } + systemListener = block } - systemListener = block - } - /// Points the format listener at the current default output device, removing - /// the one on the previous device. A no-op when the device hasn't actually - /// changed, so a notification that resolves to the same device doesn't churn - /// the registration. - private func retargetFormatListener() { - let device = AudioRoute.defaultOutputDeviceID() - if let existing = deviceListener { - guard existing.id != device else { return } + /// Points the format listener at the current default output device, removing + /// the one on the previous device. A no-op when the device hasn't actually + /// changed, so a notification that resolves to the same device doesn't churn + /// the registration. + private func retargetFormatListener() { + let device = AudioRoute.defaultOutputDeviceID() + if let existing = deviceListener { + guard existing.id != device else { return } + var address = AudioRoute.globalAddress(kAudioDevicePropertyNominalSampleRate) + _ = AudioObjectRemovePropertyListenerBlock(existing.id, &address, queue, existing.block) + deviceListener = nil + } + guard let device else { return } + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.continuation.yield() + } var address = AudioRoute.globalAddress(kAudioDevicePropertyNominalSampleRate) - _ = AudioObjectRemovePropertyListenerBlock(existing.id, &address, queue, existing.block) - deviceListener = nil - } - guard let device else { return } - let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in - self?.continuation.yield() - } - var address = AudioRoute.globalAddress(kAudioDevicePropertyNominalSampleRate) - let status = AudioObjectAddPropertyListenerBlock(device, &address, queue, block) - guard status == noErr else { - Self.logger.error("output-format listener failed: \(status)") - return + let status = AudioObjectAddPropertyListenerBlock(device, &address, queue, block) + guard status == noErr else { + Self.logger.error("output-format listener failed: \(status)") + return + } + deviceListener = (id: device, block: block) } - deviceListener = (id: device, block: block) } -} +#endif diff --git a/Sources/BlurtEngine/Audio/AudioTransport.swift b/Sources/BlurtEngine/Audio/AudioTransport.swift index d1540cc7..b79d065e 100644 --- a/Sources/BlurtEngine/Audio/AudioTransport.swift +++ b/Sources/BlurtEngine/Audio/AudioTransport.swift @@ -1,4 +1,6 @@ -import CoreAudio +#if os(macOS) + import CoreAudio +#endif /// Classification of a CoreAudio device's transport type /// (`kAudioDevicePropertyTransportType`). @@ -23,9 +25,16 @@ enum AudioTransport { /// and no linger. Padding every wired capture with a delay would be a worse /// regression than losing the tail on a device we couldn't classify. static func isBluetooth(_ transportType: UInt32?) -> Bool { - guard let transportType else { return false } - return transportType == kAudioDeviceTransportTypeBluetooth - || transportType == kAudioDeviceTransportTypeBluetoothLE + #if os(macOS) + guard let transportType else { return false } + return transportType == kAudioDeviceTransportTypeBluetooth + || transportType == kAudioDeviceTransportTypeBluetoothLE + #else + // The transport constants are HAL (macOS-only) symbols, and iOS never + // produces a transport type anyway — `AudioRoute.currentInput()` is nil + // there — so nothing classifies as Bluetooth. + return false + #endif } /// How much longer capture runs past the key-up that ends it, for a device of diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift index e8749888..cb463370 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture+Editability.swift @@ -1,155 +1,157 @@ -import AppKit -import ApplicationServices +#if os(macOS) + import AppKit + import ApplicationServices -// The "can this target accept a paste?" half of `FocusCapture`, split out of -// `FocusCapture.swift` to stay within the lint file-length budget — the same -// reason `DictationSession` is split across `+Commands`/`+Observation`/`+Pipeline`. -// -// Cohesive as its own file: everything here serves `KeyInjector`'s pre-paste -// "no beep" guard, whereas `FocusCapture.swift` proper serves the press-time -// context capture that primes the STT request. -extension FocusCapture { - /// AX roles a focused element reports when it accepts typed/pasted text. - /// Includes `secureFieldRole`: a password field is a valid *paste* target even - /// though its contents are never read (see `isSecureField`). - private static let editableRoles: Set = [ - "AXTextField", "AXTextArea", "AXComboBox", secureFieldRole, "AXSearchField", - ] + // The "can this target accept a paste?" half of `FocusCapture`, split out of + // `FocusCapture.swift` to stay within the lint file-length budget — the same + // reason `DictationSession` is split across `+Commands`/`+Observation`/`+Pipeline`. + // + // Cohesive as its own file: everything here serves `KeyInjector`'s pre-paste + // "no beep" guard, whereas `FocusCapture.swift` proper serves the press-time + // context capture that primes the STT request. + extension FocusCapture { + /// AX roles a focused element reports when it accepts typed/pasted text. + /// Includes `secureFieldRole`: a password field is a valid *paste* target even + /// though its contents are never read (see `isSecureField`). + private static let editableRoles: Set = [ + "AXTextField", "AXTextArea", "AXComboBox", secureFieldRole, "AXSearchField", + ] - /// Pure decision: do these signals, read off a focused element, mean it accepts - /// pasted text? The injector calls this just before a synthesized ⌘V — if it - /// returns false the paste is skipped (so macOS doesn't beep into a non-editable - /// target) and the transcript is left on the clipboard with a quiet "Copied" - /// notice. The "is anything focused at all?" question is answered by the caller, - /// which never gets this far without an element (see `hasEditableFocusedElement`). - /// - /// Requires a *positive* editability signal: a known text role, a settable value, - /// or an insertion point. Anything else — a non-text control, an unknown role, or - /// no readable role — is treated as not editable, so we copy rather than beep a - /// ⌘V into a target that can't take it. - /// - /// AX-opaque apps — Electron editors (VS Code, Slack) and web browsers — can - /// expose *none* of these signals even for a genuine text field, so this - /// returns false for them too. The injector still pastes into those via a - /// separate app-identity check (see `isAXOpaqueApp` / `KeyInjector.insert`), - /// so the user's words aren't dropped to copy-only there. - static func isEditableTarget(role: String?, valueSettable: Bool, hasInsertionPoint: Bool) -> Bool { - if let role, editableRoles.contains(role) { return true } - return valueSettable || hasInsertionPoint - } + /// Pure decision: do these signals, read off a focused element, mean it accepts + /// pasted text? The injector calls this just before a synthesized ⌘V — if it + /// returns false the paste is skipped (so macOS doesn't beep into a non-editable + /// target) and the transcript is left on the clipboard with a quiet "Copied" + /// notice. The "is anything focused at all?" question is answered by the caller, + /// which never gets this far without an element (see `hasEditableFocusedElement`). + /// + /// Requires a *positive* editability signal: a known text role, a settable value, + /// or an insertion point. Anything else — a non-text control, an unknown role, or + /// no readable role — is treated as not editable, so we copy rather than beep a + /// ⌘V into a target that can't take it. + /// + /// AX-opaque apps — Electron editors (VS Code, Slack) and web browsers — can + /// expose *none* of these signals even for a genuine text field, so this + /// returns false for them too. The injector still pastes into those via a + /// separate app-identity check (see `isAXOpaqueApp` / `KeyInjector.insert`), + /// so the user's words aren't dropped to copy-only there. + static func isEditableTarget(role: String?, valueSettable: Bool, hasInsertionPoint: Bool) -> Bool { + if let role, editableRoles.contains(role) { return true } + return valueSettable || hasInsertionPoint + } - /// Whether `app` is an Electron/Chromium-based app, detected by the bundled - /// Electron framework. Such apps ship with their accessibility tree off, so even - /// a focused text field exposes no editable AX signal and - /// `hasEditableFocusedElement` reads them as non-editable. A native app with - /// genuinely no editable focus bundles no such framework and correctly falls - /// back to copy. - static func isElectronApp(_ app: NSRunningApplication?) -> Bool { - isElectronBundle(app?.bundleURL) - } + /// Whether `app` is an Electron/Chromium-based app, detected by the bundled + /// Electron framework. Such apps ship with their accessibility tree off, so even + /// a focused text field exposes no editable AX signal and + /// `hasEditableFocusedElement` reads them as non-editable. A native app with + /// genuinely no editable focus bundles no such framework and correctly falls + /// back to copy. + static func isElectronApp(_ app: NSRunningApplication?) -> Bool { + isElectronBundle(app?.bundleURL) + } - /// Pure decision behind `isElectronApp`: does the bundle at `bundleURL` ship the - /// Electron framework? Split from the `NSRunningApplication` wrapper for the same - /// reason as `isBrowserBundleID` — the detection is then unit-testable against a - /// fixture bundle, instead of requiring an Electron app to be installed *and* - /// running on the machine under test. - static func isElectronBundle(_ bundleURL: URL?) -> Bool { - guard let bundleURL else { return false } - let electronFramework = bundleURL.appendingPathComponent( - "Contents/Frameworks/Electron Framework.framework") - return FileManager.default.fileExists(atPath: electronFramework.path) - } + /// Pure decision behind `isElectronApp`: does the bundle at `bundleURL` ship the + /// Electron framework? Split from the `NSRunningApplication` wrapper for the same + /// reason as `isBrowserBundleID` — the detection is then unit-testable against a + /// fixture bundle, instead of requiring an Electron app to be installed *and* + /// running on the machine under test. + static func isElectronBundle(_ bundleURL: URL?) -> Bool { + guard let bundleURL else { return false } + let electronFramework = bundleURL.appendingPathComponent( + "Contents/Frameworks/Electron Framework.framework") + return FileManager.default.fileExists(atPath: electronFramework.path) + } - /// Bundle-identifier prefixes of known web browsers. Prefix-matched so channel - /// variants classify with their stable siblings (`com.google.Chrome.beta`, - /// `com.apple.SafariTechnologyPreview`). - private static let browserBundleIDPrefixes: [String] = [ - "com.apple.Safari", // Safari + Safari Technology Preview - "com.google.Chrome", // Chrome + Beta/Dev/Canary - "org.chromium.Chromium", - "com.microsoft.edgemac", // Edge + Beta/Dev/Canary - "com.brave.Browser", // Brave + Beta/Nightly - "com.operasoftware.Opera", - "com.vivaldi.Vivaldi", - "company.thebrowser.Browser", // Arc - "org.mozilla.firefox", - "com.duckduckgo.macos.browser", - "com.kagi.kagimacOS", // Orion - ] + /// Bundle-identifier prefixes of known web browsers. Prefix-matched so channel + /// variants classify with their stable siblings (`com.google.Chrome.beta`, + /// `com.apple.SafariTechnologyPreview`). + private static let browserBundleIDPrefixes: [String] = [ + "com.apple.Safari", // Safari + Safari Technology Preview + "com.google.Chrome", // Chrome + Beta/Dev/Canary + "org.chromium.Chromium", + "com.microsoft.edgemac", // Edge + Beta/Dev/Canary + "com.brave.Browser", // Brave + Beta/Nightly + "com.operasoftware.Opera", + "com.vivaldi.Vivaldi", + "company.thebrowser.Browser", // Arc + "org.mozilla.firefox", + "com.duckduckgo.macos.browser", + "com.kagi.kagimacOS", // Orion + ] - /// Pure decision behind `isBrowserApp`: does this bundle identifier belong to a - /// known browser? Split from the `NSRunningApplication` wrapper so the - /// classification is unit-testable without live running apps. - static func isBrowserBundleID(_ bundleID: String?) -> Bool { - guard let bundleID else { return false } - return browserBundleIDPrefixes.contains { bundleID.hasPrefix($0) } - } + /// Pure decision behind `isBrowserApp`: does this bundle identifier belong to a + /// known browser? Split from the `NSRunningApplication` wrapper so the + /// classification is unit-testable without live running apps. + static func isBrowserBundleID(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return browserBundleIDPrefixes.contains { bundleID.hasPrefix($0) } + } - /// Whether `app` is a known web browser. Web content is AX-opaque in practice: - /// Chromium builds its accessibility tree lazily (the first query after launch - /// resolves only a bare `AXWebArea` with no editable signal), and even with the - /// tree live, a `contenteditable` composer (ChatGPT's ProseMirror field) can - /// surface as a generic group with no settable value. So "no editable signal" - /// in a browser usually means "AX can't see the field," not "no field." - static func isBrowserApp(_ app: NSRunningApplication?) -> Bool { - isBrowserBundleID(app?.bundleIdentifier) - } + /// Whether `app` is a known web browser. Web content is AX-opaque in practice: + /// Chromium builds its accessibility tree lazily (the first query after launch + /// resolves only a bare `AXWebArea` with no editable signal), and even with the + /// tree live, a `contenteditable` composer (ChatGPT's ProseMirror field) can + /// surface as a generic group with no settable value. So "no editable signal" + /// in a browser usually means "AX can't see the field," not "no field." + static func isBrowserApp(_ app: NSRunningApplication?) -> Bool { + isBrowserBundleID(app?.bundleIdentifier) + } - /// Whether `app` is AX-opaque — an Electron editor or a web browser — where a - /// focused text field can expose no editable AX signal at all. These are the - /// one case the injector still pastes into on no signal: dropping the user's - /// words into a copy-only fallback there would be the worse mistake. The - /// accepted trade-off is a rare ⌘V beep when such an app truly has nothing - /// editable focused. - static func isAXOpaqueApp(_ app: NSRunningApplication?) -> Bool { - // Browser first: it's a string prefix check, whereas isElectronApp probes - // the disk (FileManager.fileExists) — skip that I/O for the common case. - isBrowserApp(app) || isElectronApp(app) - } + /// Whether `app` is AX-opaque — an Electron editor or a web browser — where a + /// focused text field can expose no editable AX signal at all. These are the + /// one case the injector still pastes into on no signal: dropping the user's + /// words into a copy-only fallback there would be the worse mistake. The + /// accepted trade-off is a rare ⌘V beep when such an app truly has nothing + /// editable focused. + static func isAXOpaqueApp(_ app: NSRunningApplication?) -> Bool { + // Browser first: it's a string prefix check, whereas isElectronApp probes + // the disk (FileManager.fileExists) — skip that I/O for the common case. + isBrowserApp(app) || isElectronApp(app) + } - /// Whether the system-wide focused element can accept pasted text right now. - /// Read by `KeyInjector` (off the main actor, after it has activated the target - /// app) just before pasting — the Accessibility *client* read APIs are - /// thread-safe. Returns `true` whenever AX can't be consulted (process not - /// trusted) or can't resolve a focused element, so an unknowable state still - /// attempts the paste — the injector's own trust check then handles the - /// missing-permission case. - nonisolated static func hasEditableFocusedElement() -> Bool { - guard AXIsProcessTrusted() else { return true } + /// Whether the system-wide focused element can accept pasted text right now. + /// Read by `KeyInjector` (off the main actor, after it has activated the target + /// app) just before pasting — the Accessibility *client* read APIs are + /// thread-safe. Returns `true` whenever AX can't be consulted (process not + /// trusted) or can't resolve a focused element, so an unknowable state still + /// attempts the paste — the injector's own trust check then handles the + /// missing-permission case. + nonisolated static func hasEditableFocusedElement() -> Bool { + guard AXIsProcessTrusted() else { return true } - guard let element = systemFocusedElement() else { - // AX is trusted but reports no focused element — e.g. a native app frontmost - // with nothing editable focused (Finder, the desktop, a button-only window). - // Posting ⌘V there only beeps, so treat it as non-editable and copy instead. - // AX-opaque apps (Electron editors like VS Code/Slack, and browsers before - // Chromium's lazy accessibility tree is built) also expose no focused - // element here, but the injector's app-identity check still pastes into - // those (see `KeyInjector.insert` / `isAXOpaqueApp`). - return false - } + guard let element = systemFocusedElement() else { + // AX is trusted but reports no focused element — e.g. a native app frontmost + // with nothing editable focused (Finder, the desktop, a button-only window). + // Posting ⌘V there only beeps, so treat it as non-editable and copy instead. + // AX-opaque apps (Electron editors like VS Code/Slack, and browsers before + // Chromium's lazy accessibility tree is built) also expose no focused + // element here, but the injector's app-identity check still pastes into + // those (see `KeyInjector.insert` / `isAXOpaqueApp`). + return false + } - // Same checked reader `captureFieldContext` uses for this attribute, so the - // editability path and the secure-field path can't disagree about what a - // misbehaving app's role reads as. - let role = stringValue(element, kAXRoleAttribute) + // Same checked reader `captureFieldContext` uses for this attribute, so the + // editability path and the secure-field path can't disagree about what a + // misbehaving app's role reads as. + let role = stringValue(element, kAXRoleAttribute) - var settable = DarwinBoolean(false) - let valueSettable = - AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &settable) == .success - && settable.boolValue + var settable = DarwinBoolean(false) + let valueSettable = + AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &settable) == .success + && settable.boolValue - // A readable selected-text *range* means the element has an insertion point — - // the hallmark of a text input even when its value isn't reported settable. - // Require the range to actually decode, not merely that the read succeeded: a - // `.success` carrying a non-CFRange payload is not an insertion point, and this - // signal is one of the two that can green-light a ⌘V on an unknown role. - var rangeRef: CFTypeRef? - let hasInsertionPoint = - AXUIElementCopyAttributeValue( - element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success - && rangeRef.flatMap(axRange) != nil + // A readable selected-text *range* means the element has an insertion point — + // the hallmark of a text input even when its value isn't reported settable. + // Require the range to actually decode, not merely that the read succeeded: a + // `.success` carrying a non-CFRange payload is not an insertion point, and this + // signal is one of the two that can green-light a ⌘V on an unknown role. + var rangeRef: CFTypeRef? + let hasInsertionPoint = + AXUIElementCopyAttributeValue( + element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success + && rangeRef.flatMap(axRange) != nil - return isEditableTarget( - role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) + return isEditableTarget( + role: role, valueSettable: valueSettable, hasInsertionPoint: hasInsertionPoint) + } } -} +#endif diff --git a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift index 80befb67..bb5b94b9 100644 --- a/Sources/BlurtEngine/FocusCapture/FocusCapture.swift +++ b/Sources/BlurtEngine/FocusCapture/FocusCapture.swift @@ -1,5 +1,10 @@ -import AppKit -import ApplicationServices +#if os(macOS) + import AppKit + import ApplicationServices +#else + // For `pid_t` — AppKit exports it on macOS. + import Foundation +#endif struct CapturedFocus: Sendable { let pid: pid_t @@ -7,18 +12,20 @@ struct CapturedFocus: Sendable { } enum FocusCapture { - @MainActor - static func captureFrontmost() -> CapturedFocus? { - guard let app = NSWorkspace.shared.frontmostApplication else { return nil } - return CapturedFocus( - pid: app.processIdentifier, - processName: app.localizedName - ) - } + #if os(macOS) + @MainActor + static func captureFrontmost() -> CapturedFocus? { + guard let app = NSWorkspace.shared.frontmostApplication else { return nil } + return CapturedFocus( + pid: app.processIdentifier, + processName: app.localizedName + ) + } - static func runningApp(for captured: CapturedFocus) -> NSRunningApplication? { - NSRunningApplication(processIdentifier: captured.pid) - } + static func runningApp(for captured: CapturedFocus) -> NSRunningApplication? { + NSRunningApplication(processIdentifier: captured.pid) + } + #endif /// Accessibility-derived priming read from the system-wide focused UI element /// at dictation start (see `TranscriptionContext`). Every field is @@ -50,246 +57,248 @@ enum FocusCapture { priorText: nil, selectedText: nil, windowTitle: nil, fieldLabel: nil) } - /// Reads window title, field label, and up to `maxPriorChars` of text before - /// the cursor from the focused UI element in a single Accessibility traversal. - /// - /// Returns `.empty` whenever nothing can be read — the process lacks - /// Accessibility trust or no element is focused. Each field is independently - /// best-effort. Requires the same Accessibility permission the app already - /// holds for paste injection, so it adds no new prompt. - /// - /// Secure text fields (password inputs) are detected by role **or** subrole and - /// never have their contents read. This guard is what keeps a typed password out - /// of the developer-mode log and — since the text before the cursor is sent as - /// the request's context turns (`ConversationContext`) — off the wire - /// entirely. The check - /// fails closed: an unreadable role is treated as secure, since it can't be - /// shown not to be. - /// - /// Deliberately `nonisolated`: each read below is a synchronous cross-process - /// IPC round trip into the frontmost app, and an unresponsive app blocks the - /// calling thread until the AX messaging timeout. On the main actor that froze - /// the overlay and menu bar right at hotkey press; callers run this off-main - /// (the AX *client* read APIs are thread-safe — see `systemFocusedElement`). - nonisolated static func captureFieldContext(maxPriorChars: Int = 320, maxSelectedChars: Int = 320) - -> FocusedFieldContext - { - guard AXIsProcessTrusted() else { return .empty } - guard let element = systemFocusedElement() else { return .empty } + #if os(macOS) + /// Reads window title, field label, and up to `maxPriorChars` of text before + /// the cursor from the focused UI element in a single Accessibility traversal. + /// + /// Returns `.empty` whenever nothing can be read — the process lacks + /// Accessibility trust or no element is focused. Each field is independently + /// best-effort. Requires the same Accessibility permission the app already + /// holds for paste injection, so it adds no new prompt. + /// + /// Secure text fields (password inputs) are detected by role **or** subrole and + /// never have their contents read. This guard is what keeps a typed password out + /// of the developer-mode log and — since the text before the cursor is sent as + /// the request's context turns (`ConversationContext`) — off the wire + /// entirely. The check + /// fails closed: an unreadable role is treated as secure, since it can't be + /// shown not to be. + /// + /// Deliberately `nonisolated`: each read below is a synchronous cross-process + /// IPC round trip into the frontmost app, and an unresponsive app blocks the + /// calling thread until the AX messaging timeout. On the main actor that froze + /// the overlay and menu bar right at hotkey press; callers run this off-main + /// (the AX *client* read APIs are thread-safe — see `systemFocusedElement`). + nonisolated static func captureFieldContext(maxPriorChars: Int = 320, maxSelectedChars: Int = 320) + -> FocusedFieldContext + { + guard AXIsProcessTrusted() else { return .empty } + guard let element = systemFocusedElement() else { return .empty } - // Don't read the value of a password field into the request. The whole - // decision — including the fail-closed arm — lives in `mustRedactContents`, - // where it is unit-tested; this function needs a live AX element, so anything - // decided inline here would be covered by nothing. - let isSecure = mustRedactContents( - role: stringValue(element, kAXRoleAttribute), - subrole: stringValue(element, kAXSubroleAttribute)) - // Both text slices stay nil for a secure field — the whole point of the guard - // above — so the reads that feed them are skipped wholesale rather than each - // being individually conditional. - var prior: String? - var selected: String? - if !isSecure { - // One read of the selected range serves both slices: its location *is* the - // insertion point the prior-text slice ends at, and its extent is what the - // selected-text slice asks for. Reading it here rather than inside each slice - // costs the capture one cross-process round trip for it instead of two. - let selection = selectedTextRange(of: element) - // `visibleTextOrNil` collapses an all-invisible read (e.g. Google Docs' lone - // U+200B before the caret) to nil so it can't masquerade as real prior text. - prior = visibleTextOrNil(priorText(of: element, selection: selection, maxChars: maxPriorChars)) - // The selected range's text (empty when there's no selection). Capped like - // prior text so a huge highlight can't dominate the context budget. - selected = visibleTextOrNil(selectedText(of: element, selection: selection, maxChars: maxSelectedChars)) + // Don't read the value of a password field into the request. The whole + // decision — including the fail-closed arm — lives in `mustRedactContents`, + // where it is unit-tested; this function needs a live AX element, so anything + // decided inline here would be covered by nothing. + let isSecure = mustRedactContents( + role: stringValue(element, kAXRoleAttribute), + subrole: stringValue(element, kAXSubroleAttribute)) + // Both text slices stay nil for a secure field — the whole point of the guard + // above — so the reads that feed them are skipped wholesale rather than each + // being individually conditional. + var prior: String? + var selected: String? + if !isSecure { + // One read of the selected range serves both slices: its location *is* the + // insertion point the prior-text slice ends at, and its extent is what the + // selected-text slice asks for. Reading it here rather than inside each slice + // costs the capture one cross-process round trip for it instead of two. + let selection = selectedTextRange(of: element) + // `visibleTextOrNil` collapses an all-invisible read (e.g. Google Docs' lone + // U+200B before the caret) to nil so it can't masquerade as real prior text. + prior = visibleTextOrNil(priorText(of: element, selection: selection, maxChars: maxPriorChars)) + // The selected range's text (empty when there's no selection). Capped like + // prior text so a huge highlight can't dominate the context budget. + selected = visibleTextOrNil(selectedText(of: element, selection: selection, maxChars: maxSelectedChars)) + } + return FocusedFieldContext( + priorText: prior, + selectedText: selected, + windowTitle: clip(windowTitle(of: element), to: 120), + fieldLabel: clip(fieldLabel(of: element), to: 80), + isSecure: isSecure) } - return FocusedFieldContext( - priorText: prior, - selectedText: selected, - windowTitle: clip(windowTitle(of: element), to: 120), - fieldLabel: clip(fieldLabel(of: element), to: 80), - isSecure: isSecure) - } - /// Cap on each cross-process AX round trip this process makes. An unresponsive - /// frontmost app costs a read this long, not the ~6 s system default; the - /// context capture is best-effort priming, so partial answers beat waiting. - private static let axMessagingTimeoutSeconds: Float = 1 + /// Cap on each cross-process AX round trip this process makes. An unresponsive + /// frontmost app costs a read this long, not the ~6 s system default; the + /// context capture is best-effort priming, so partial answers beat waiting. + private static let axMessagingTimeoutSeconds: Float = 1 - // Internal, not private: `hasEditableFocusedElement` in - // FocusCapture+Editability.swift calls it from another file. - /// The system-wide focused UI element, or `nil` when none is resolvable - /// (process not trusted, or nothing focused). The Accessibility *client* read - /// APIs are thread-safe, so this serves both the off-main context capture - /// and the injector's off-main editability check. - nonisolated static func systemFocusedElement() -> AXUIElement? { - let system = AXUIElementCreateSystemWide() - // Setting the timeout on the system-wide element applies it process-wide - // (per AXUIElement.h), bounding this focused-element lookup AND every later - // read — including ones on *other* element refs a per-element timeout would - // miss (the window element behind `windowTitle`, the editability probes). - AXUIElementSetMessagingTimeout(system, axMessagingTimeoutSeconds) - var focusedRef: CFTypeRef? - guard - AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) - == .success, - let focusedRef - else { return nil } - return axElement(focusedRef) - } + // Internal, not private: `hasEditableFocusedElement` in + // FocusCapture+Editability.swift calls it from another file. + /// The system-wide focused UI element, or `nil` when none is resolvable + /// (process not trusted, or nothing focused). The Accessibility *client* read + /// APIs are thread-safe, so this serves both the off-main context capture + /// and the injector's off-main editability check. + nonisolated static func systemFocusedElement() -> AXUIElement? { + let system = AXUIElementCreateSystemWide() + // Setting the timeout on the system-wide element applies it process-wide + // (per AXUIElement.h), bounding this focused-element lookup AND every later + // read — including ones on *other* element refs a per-element timeout would + // miss (the window element behind `windowTitle`, the editability probes). + AXUIElementSetMessagingTimeout(system, axMessagingTimeoutSeconds) + var focusedRef: CFTypeRef? + guard + AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &focusedRef) + == .success, + let focusedRef + else { return nil } + return axElement(focusedRef) + } - // MARK: - Checked CF downcasts - // - // AX attribute values arrive as CFTypeRef from *other apps'* accessibility - // implementations, so a misbehaving app returning the wrong CF type must read - // as "nothing readable" (nil), never flow onward mistyped. CF bridging makes - // `as?` a compile-time "always succeeds" warning (an error under - // -warnings-as-errors), so the runtime check is CFGetTypeID; after it the - // force-cast below each guard is provably safe — these two helpers are the - // only force_cast sites in the repo. Internal (not private) so the unit tests - // can exercise both arms without Accessibility trust. + // MARK: - Checked CF downcasts + // + // AX attribute values arrive as CFTypeRef from *other apps'* accessibility + // implementations, so a misbehaving app returning the wrong CF type must read + // as "nothing readable" (nil), never flow onward mistyped. CF bridging makes + // `as?` a compile-time "always succeeds" warning (an error under + // -warnings-as-errors), so the runtime check is CFGetTypeID; after it the + // force-cast below each guard is provably safe — these two helpers are the + // only force_cast sites in the repo. Internal (not private) so the unit tests + // can exercise both arms without Accessibility trust. - /// `ref` as an `AXUIElement`, or `nil` when it's some other CF type. - nonisolated static func axElement(_ ref: CFTypeRef) -> AXUIElement? { - guard CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil } - // swiftlint:disable:next force_cast - return (ref as! AXUIElement) - } + /// `ref` as an `AXUIElement`, or `nil` when it's some other CF type. + nonisolated static func axElement(_ ref: CFTypeRef) -> AXUIElement? { + guard CFGetTypeID(ref) == AXUIElementGetTypeID() else { return nil } + // swiftlint:disable:next force_cast + return (ref as! AXUIElement) + } - /// `ref` decoded as an `AXValue`-wrapped `CFRange` (the selected-text-range - /// payload), or `nil` when it's some other CF type or a non-range `AXValue` - /// (`AXValueGetValue` checks the payload type and refuses a mismatch). - nonisolated static func axRange(_ ref: CFTypeRef) -> CFRange? { - guard CFGetTypeID(ref) == AXValueGetTypeID() else { return nil } - // swiftlint:disable:next force_cast - let value = ref as! AXValue - var range = CFRange() - guard AXValueGetValue(value, .cfRange, &range) else { return nil } - return range - } + /// `ref` decoded as an `AXValue`-wrapped `CFRange` (the selected-text-range + /// payload), or `nil` when it's some other CF type or a non-range `AXValue` + /// (`AXValueGetValue` checks the payload type and refuses a mismatch). + nonisolated static func axRange(_ ref: CFTypeRef) -> CFRange? { + guard CFGetTypeID(ref) == AXValueGetTypeID() else { return nil } + // swiftlint:disable:next force_cast + let value = ref as! AXValue + var range = CFRange() + guard AXValueGetValue(value, .cfRange, &range) else { return nil } + return range + } - /// The element's selected text range — a zero-length range when there's just a - /// caret — or `nil` when it exposes no readable selection. Its `location` is the - /// insertion point. - private nonisolated static func selectedTextRange(of element: AXUIElement) -> CFRange? { - var rangeRef: CFTypeRef? - guard - AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) - == .success, - let rangeRef - else { return nil } - return axRange(rangeRef) - } + /// The element's selected text range — a zero-length range when there's just a + /// caret — or `nil` when it exposes no readable selection. Its `location` is the + /// insertion point. + private nonisolated static func selectedTextRange(of element: AXUIElement) -> CFRange? { + var rangeRef: CFTypeRef? + guard + AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) + == .success, + let rangeRef + else { return nil } + return axRange(rangeRef) + } - /// The element's text for `range` via the parameterized "string for range" - /// attribute, so only the slice asked for crosses Accessibility IPC rather than - /// the whole field value. Shared by the prior-text and selected-text reads, - /// which differ only in the range they request and how they post-process it. - private nonisolated static func string(of element: AXUIElement, in range: CFRange) -> String? { - var range = range - guard let axRange = AXValueCreate(.cfRange, &range) else { return nil } - var sliceRef: CFTypeRef? - guard - AXUIElementCopyParameterizedAttributeValue( - element, kAXStringForRangeParameterizedAttribute as CFString, axRange, &sliceRef) - == .success, - let slice = sliceRef as? String - else { return nil } - return slice - } + /// The element's text for `range` via the parameterized "string for range" + /// attribute, so only the slice asked for crosses Accessibility IPC rather than + /// the whole field value. Shared by the prior-text and selected-text reads, + /// which differ only in the range they request and how they post-process it. + private nonisolated static func string(of element: AXUIElement, in range: CFRange) -> String? { + var range = range + guard let axRange = AXValueCreate(.cfRange, &range) else { return nil } + var sliceRef: CFTypeRef? + guard + AXUIElementCopyParameterizedAttributeValue( + element, kAXStringForRangeParameterizedAttribute as CFString, axRange, &sliceRef) + == .success, + let slice = sliceRef as? String + else { return nil } + return slice + } - /// Up to `maxChars` of text immediately preceding the insertion point, or - /// `nil` when the element exposes no readable text before the cursor. - /// `selection` is the already-read selected range (see `selectedTextRange`). - private nonisolated static func priorText( - of element: AXUIElement, selection: CFRange?, maxChars: Int - ) -> String? { - // Insertion point = the location of the (possibly empty) selected range. - let caret = selection?.location ?? -1 + /// Up to `maxChars` of text immediately preceding the insertion point, or + /// `nil` when the element exposes no readable text before the cursor. + /// `selection` is the already-read selected range (see `selectedTextRange`). + private nonisolated static func priorText( + of element: AXUIElement, selection: CFRange?, maxChars: Int + ) -> String? { + // Insertion point = the location of the (possibly empty) selected range. + let caret = selection?.location ?? -1 - // Prefer the parameterized "string for range" so we read only the slice we - // need (cheap even in huge documents) rather than the whole field value. - if caret > 0 { - let start = max(0, caret - maxChars) - if let slice = string(of: element, in: CFRange(location: start, length: caret - start)), - !slice.isEmpty - { - return slice + // Prefer the parameterized "string for range" so we read only the slice we + // need (cheap even in huge documents) rather than the whole field value. + if caret > 0 { + let start = max(0, caret - maxChars) + if let slice = string(of: element, in: CFRange(location: start, length: caret - start)), + !slice.isEmpty + { + return slice + } } - } - // Fallback: read the full value and clip to the caret (or the tail). Read it - // RAW — `caret` is a UTF-16 offset into the untrimmed value, so a trimmed - // string would be indexed with offsets that no longer refer to it. - return priorSlice(full: rawStringValue(element, kAXValueAttribute) ?? "", caret: caret, maxChars: maxChars) - } + // Fallback: read the full value and clip to the caret (or the tail). Read it + // RAW — `caret` is a UTF-16 offset into the untrimmed value, so a trimmed + // string would be indexed with offsets that no longer refer to it. + return priorSlice(full: rawStringValue(element, kAXValueAttribute) ?? "", caret: caret, maxChars: maxChars) + } - /// Up to `maxChars` of selected text, or `nil` when the element exposes no - /// readable selection. Slices `selection` through the parameterized - /// string-for-range attribute first so a huge highlight does not copy the full - /// selection across Accessibility IPC before being clipped locally; falls back - /// to `kAXSelectedText` for elements that expose no string-for-range. - private nonisolated static func selectedText( - of element: AXUIElement, selection: CFRange?, maxChars: Int - ) -> String? { - if var selectedRange = selection, selectedRange.length > 0 { - selectedRange.length = min(selectedRange.length, maxChars) - if let slice = string(of: element, in: selectedRange) { - return clip(slice.trimmedNonEmpty(), to: maxChars) + /// Up to `maxChars` of selected text, or `nil` when the element exposes no + /// readable selection. Slices `selection` through the parameterized + /// string-for-range attribute first so a huge highlight does not copy the full + /// selection across Accessibility IPC before being clipped locally; falls back + /// to `kAXSelectedText` for elements that expose no string-for-range. + private nonisolated static func selectedText( + of element: AXUIElement, selection: CFRange?, maxChars: Int + ) -> String? { + if var selectedRange = selection, selectedRange.length > 0 { + selectedRange.length = min(selectedRange.length, maxChars) + if let slice = string(of: element, in: selectedRange) { + return clip(slice.trimmedNonEmpty(), to: maxChars) + } } - } - return clip(stringValue(element, kAXSelectedTextAttribute), to: maxChars) - } + return clip(stringValue(element, kAXSelectedTextAttribute), to: maxChars) + } - /// The title of the window containing the focused element, if exposed. - private nonisolated static func windowTitle(of element: AXUIElement) -> String? { - guard let window = elementValue(element, kAXWindowAttribute) else { return nil } - return stringValue(window, kAXTitleAttribute) - } + /// The title of the window containing the focused element, if exposed. + private nonisolated static func windowTitle(of element: AXUIElement) -> String? { + guard let window = elementValue(element, kAXWindowAttribute) else { return nil } + return stringValue(window, kAXTitleAttribute) + } - /// A short, human-meaningful label for the field, chosen by priority from the - /// attributes the focused element exposes. - private nonisolated static func fieldLabel(of element: AXUIElement) -> String? { - selectLabel( - placeholder: stringValue(element, kAXPlaceholderValueAttribute), - description: stringValue(element, kAXDescriptionAttribute), - title: stringValue(element, kAXTitleAttribute), - roleDescription: stringValue(element, kAXRoleDescriptionAttribute)) - } + /// A short, human-meaningful label for the field, chosen by priority from the + /// attributes the focused element exposes. + private nonisolated static func fieldLabel(of element: AXUIElement) -> String? { + selectLabel( + placeholder: stringValue(element, kAXPlaceholderValueAttribute), + description: stringValue(element, kAXDescriptionAttribute), + title: stringValue(element, kAXTitleAttribute), + roleDescription: stringValue(element, kAXRoleDescriptionAttribute)) + } - // Internal for the same reason as `systemFocusedElement`: the editability path in - // FocusCapture+Editability.swift reads the role through it. - /// Reads a `String`-valued AX attribute, returning `nil` for missing, - /// non-string, or blank values. Trims, which is what the *label-ish* attributes - /// want (role, title, placeholder, description). Do **not** use it for - /// `kAXValueAttribute` when a caret offset will index the result — see - /// `rawStringValue`. - nonisolated static func stringValue(_ element: AXUIElement, _ attribute: String) -> String? { - rawStringValue(element, attribute)?.trimmedNonEmpty() - } + // Internal for the same reason as `systemFocusedElement`: the editability path in + // FocusCapture+Editability.swift reads the role through it. + /// Reads a `String`-valued AX attribute, returning `nil` for missing, + /// non-string, or blank values. Trims, which is what the *label-ish* attributes + /// want (role, title, placeholder, description). Do **not** use it for + /// `kAXValueAttribute` when a caret offset will index the result — see + /// `rawStringValue`. + nonisolated static func stringValue(_ element: AXUIElement, _ attribute: String) -> String? { + rawStringValue(element, attribute)?.trimmedNonEmpty() + } - /// Reads a `String`-valued AX attribute **verbatim** — no trimming, so a - /// caret offset taken from the same element still indexes it correctly. - /// - /// `kAXSelectedTextRange` locations are UTF-16 offsets into the element's - /// *original* value. Trimming shifts every offset past a leading whitespace run - /// and shortens the string, so slicing a trimmed value with an untrimmed caret - /// silently returns the wrong text (or falls back to the whole tail, which - /// destroys the trailing-whitespace signal `withLeadingSeparator` reads). - private nonisolated static func rawStringValue(_ element: AXUIElement, _ attribute: String) -> String? { - var ref: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success, - let value = ref as? String - else { return nil } - return value - } + /// Reads a `String`-valued AX attribute **verbatim** — no trimming, so a + /// caret offset taken from the same element still indexes it correctly. + /// + /// `kAXSelectedTextRange` locations are UTF-16 offsets into the element's + /// *original* value. Trimming shifts every offset past a leading whitespace run + /// and shortens the string, so slicing a trimmed value with an untrimmed caret + /// silently returns the wrong text (or falls back to the whole tail, which + /// destroys the trailing-whitespace signal `withLeadingSeparator` reads). + private nonisolated static func rawStringValue(_ element: AXUIElement, _ attribute: String) -> String? { + var ref: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success, + let value = ref as? String + else { return nil } + return value + } - /// Reads an `AXUIElement`-valued AX attribute (e.g. the containing window). - private nonisolated static func elementValue(_ element: AXUIElement, _ attribute: String) -> AXUIElement? { - var ref: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success, - let ref - else { return nil } - return axElement(ref) - } + /// Reads an `AXUIElement`-valued AX attribute (e.g. the containing window). + private nonisolated static func elementValue(_ element: AXUIElement, _ attribute: String) -> AXUIElement? { + var ref: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success, + let ref + else { return nil } + return axElement(ref) + } + #endif } diff --git a/Sources/BlurtEngine/Injection/InjectorProtocol.swift b/Sources/BlurtEngine/Injection/InjectorProtocol.swift index 79d7eda4..11c65a53 100644 --- a/Sources/BlurtEngine/Injection/InjectorProtocol.swift +++ b/Sources/BlurtEngine/Injection/InjectorProtocol.swift @@ -1,7 +1,14 @@ -import AppKit +#if os(macOS) + import AppKit +#endif public protocol InjectorProtocol: Sendable { - func setTargetApp(_ app: NSRunningApplication?) async + #if os(macOS) + /// Pins the paste target captured at press time. macOS-only because the + /// target's identity is an AppKit process handle; on other platforms the + /// host's injector owns its own notion of where text lands. + func setTargetApp(_ app: NSRunningApplication?) async + #endif /// Insert text into whatever the OS currently treats as the focus target. /// `priorText` is the text immediately before the caret (captured at press time), /// used to decide whether a separating space is needed so consecutive dictations diff --git a/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift b/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift index 79aec915..c064e25c 100644 --- a/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift +++ b/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift @@ -1,48 +1,50 @@ -// `KeyInjector`'s separator decision: the pure text rules for joining a new -// dictation onto whatever already sits before the caret. Split out of -// `KeyInjector.swift` to stay within the lint file-length budget, on the seam its -// tests already use — `KeyInjectorLeadingSeparatorTests` and the -// `KeyInjector.separatorBasis` suite cover exactly these two functions, and -// neither touches the pasteboard, the event system, or the actor's state. -// `resolveInsert`, which composes them with the window-identity decision, stays -// beside that state in `KeyInjector.swift` (it needs `pid_t`, and this file -// deliberately imports nothing). -extension KeyInjector { - /// Joins `text` to whatever precedes the caret with exactly one separating space, - /// so consecutive dictations don't run together. Prepends a *leading* space only - /// when there's preceding text (`priorText`) that doesn't already end in - /// whitespace; leaves `text` untouched for an empty/unknown field or when the - /// caret already follows whitespace. - /// - /// A leading separator beats a trailing one: a trailing space dangles at the end - /// of a paste where many text engines trim or collapse it (so the next paste - /// abuts the previous text), whereas a leading space lands *between* the two - /// chunks where nothing strips it. `priorText` is nil for empty fields and for - /// secure/Accessibility-opaque fields — there we can't tell what precedes the - /// caret, so we add no separator rather than risk a stray leading space. - public static func withLeadingSeparator(_ text: String, after priorText: String?) -> String { - guard !text.isEmpty else { return text } - guard let priorText, let last = priorText.last, !last.isWhitespace else { return text } - guard let first = text.first, !first.isWhitespace else { return text } - return " " + text - } +#if os(macOS) + // `KeyInjector`'s separator decision: the pure text rules for joining a new + // dictation onto whatever already sits before the caret. Split out of + // `KeyInjector.swift` to stay within the lint file-length budget, on the seam its + // tests already use — `KeyInjectorLeadingSeparatorTests` and the + // `KeyInjector.separatorBasis` suite cover exactly these two functions, and + // neither touches the pasteboard, the event system, or the actor's state. + // `resolveInsert`, which composes them with the window-identity decision, stays + // beside that state in `KeyInjector.swift` (it needs `pid_t`, and this file + // deliberately imports nothing). + extension KeyInjector { + /// Joins `text` to whatever precedes the caret with exactly one separating space, + /// so consecutive dictations don't run together. Prepends a *leading* space only + /// when there's preceding text (`priorText`) that doesn't already end in + /// whitespace; leaves `text` untouched for an empty/unknown field or when the + /// caret already follows whitespace. + /// + /// A leading separator beats a trailing one: a trailing space dangles at the end + /// of a paste where many text engines trim or collapse it (so the next paste + /// abuts the previous text), whereas a leading space lands *between* the two + /// chunks where nothing strips it. `priorText` is nil for empty fields and for + /// secure/Accessibility-opaque fields — there we can't tell what precedes the + /// caret, so we add no separator rather than risk a stray leading space. + public static func withLeadingSeparator(_ text: String, after priorText: String?) -> String { + guard !text.isEmpty else { return text } + guard let priorText, let last = priorText.last, !last.isWhitespace else { return text } + guard let first = text.first, !first.isWhitespace else { return text } + return " " + text + } - /// Chooses what text the separator decision should treat as preceding the - /// caret. AX-read `priorText` is authoritative whenever we have it. When it's - /// nil — the field is empty *or* Accessibility-opaque (Electron/Monaco, e.g. VS - /// Code — or a browser tab like Google Docs, whose canvas-rendered body is just - /// as opaque) — we can't tell those apart from AX alone, so we fall back to the - /// text we last pasted, but only when this dictation targets the *same window* - /// as last time (see `WindowIdentity`): that's the in-progress-run case where - /// our own paste is what now sits before the caret. This is deliberately - /// app-agnostic rather than an allowlist of "known opaque editors": a window - /// match is a reasonable proxy for "still the same document" across *any* app, - /// opaque or not, whereas a shared process id alone isn't (one browser process - /// hosts many unrelated tabs/documents). Otherwise (a different window or - /// nothing pasted yet) we return nil rather than risk a stray leading space - /// into what may be a genuinely fresh field. - static func separatorBasis(priorText: String?, lastInserted: String?, sameWindow: Bool) -> String? { - if priorText != nil { return priorText } - return sameWindow ? lastInserted : nil + /// Chooses what text the separator decision should treat as preceding the + /// caret. AX-read `priorText` is authoritative whenever we have it. When it's + /// nil — the field is empty *or* Accessibility-opaque (Electron/Monaco, e.g. VS + /// Code — or a browser tab like Google Docs, whose canvas-rendered body is just + /// as opaque) — we can't tell those apart from AX alone, so we fall back to the + /// text we last pasted, but only when this dictation targets the *same window* + /// as last time (see `WindowIdentity`): that's the in-progress-run case where + /// our own paste is what now sits before the caret. This is deliberately + /// app-agnostic rather than an allowlist of "known opaque editors": a window + /// match is a reasonable proxy for "still the same document" across *any* app, + /// opaque or not, whereas a shared process id alone isn't (one browser process + /// hosts many unrelated tabs/documents). Otherwise (a different window or + /// nothing pasted yet) we return nil rather than risk a stray leading space + /// into what may be a genuinely fresh field. + static func separatorBasis(priorText: String?, lastInserted: String?, sameWindow: Bool) -> String? { + if priorText != nil { return priorText } + return sameWindow ? lastInserted : nil + } } -} +#endif diff --git a/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift b/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift index 92ba8e65..b06192ac 100644 --- a/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift +++ b/Sources/BlurtEngine/Injection/KeyInjector+SystemActions.swift @@ -1,82 +1,84 @@ -import AppKit -import ApplicationServices -import CoreGraphics +#if os(macOS) + import AppKit + import ApplicationServices + import CoreGraphics -// The system side effects behind `KeyInjector`'s injectable seams: the default -// implementations of app activation, the frontmost-wait, the Accessibility-trust -// check, and the synthesized ⌘V. Split out from the actor's paste orchestration -// (`KeyInjector.swift`) because these are pure AppKit/CoreGraphics glue with no -// actor state — the only place `KeyInjector` needs CoreGraphics or -// ApplicationServices at all. `static`, not `private`, so the initializers in -// `KeyInjector.swift` can wire them as the defaults across the file boundary. -extension KeyInjector { - static func activate(_ app: NSRunningApplication) -> Bool { - app.activate() - } + // The system side effects behind `KeyInjector`'s injectable seams: the default + // implementations of app activation, the frontmost-wait, the Accessibility-trust + // check, and the synthesized ⌘V. Split out from the actor's paste orchestration + // (`KeyInjector.swift`) because these are pure AppKit/CoreGraphics glue with no + // actor state — the only place `KeyInjector` needs CoreGraphics or + // ApplicationServices at all. `static`, not `private`, so the initializers in + // `KeyInjector.swift` can wire them as the defaults across the file boundary. + extension KeyInjector { + static func activate(_ app: NSRunningApplication) -> Bool { + app.activate() + } - static func waitUntilFrontmost(_ app: NSRunningApplication) async -> Bool { - let pid = app.processIdentifier - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: .milliseconds(350)) - while clock.now < deadline { - if await isFrontmost(pid) { return true } - try? await Task.sleep(for: .milliseconds(10)) + static func waitUntilFrontmost(_ app: NSRunningApplication) async -> Bool { + let pid = app.processIdentifier + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .milliseconds(350)) + while clock.now < deadline { + if await isFrontmost(pid) { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + // One last read: the final sleep may have carried us past the deadline just + // before the activation landed. + return await isFrontmost(pid) } - // One last read: the final sleep may have carried us past the deadline just - // before the activation landed. - return await isFrontmost(pid) - } - /// Whether `pid` owns the frontmost application right now. Named rather than - /// inlined so the poll and the post-deadline check share one expression — and so - /// neither needs `MainActor.run`'s explicit `body:` label, which a trailing - /// closure in an `if` condition can't use. - private static func isFrontmost(_ pid: pid_t) async -> Bool { - await MainActor.run { - NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + /// Whether `pid` owns the frontmost application right now. Named rather than + /// inlined so the poll and the post-deadline check share one expression — and so + /// neither needs `MainActor.run`'s explicit `body:` label, which a trailing + /// closure in an `if` condition can't use. + private static func isFrontmost(_ pid: pid_t) async -> Bool { + await MainActor.run { + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + } } - } - static func accessibilityTrusted() -> Bool { - AXIsProcessTrusted() - } + static func accessibilityTrusted() -> Bool { + AXIsProcessTrusted() + } - /// The Cmd-V key-down/key-up pair, or `nil` when CoreGraphics refuses to build - /// them. Split from `postCmdV` because only the *posting* is untestable: building - /// an event needs no Accessibility trust, while posting one sends a live - /// keystroke into whatever app has focus — not something `swift test` may do to - /// the machine it runs on. So the part carrying an actual invariant (the ⌘ flag - /// on both events and the `kVK_ANSI_V` keycode, which is what makes the paste a - /// paste) is asserted in `KeyInjectorSystemActionsTests`, and only the two - /// `.post` calls below stay covered by running the app. - static func cmdVEvents() -> (down: CGEvent, up: CGEvent)? { - let vKey: CGKeyCode = 0x09 // kVK_ANSI_V - guard let source = CGEventSource(stateID: .combinedSessionState), - let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), - let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) - else { return nil } - // Set on both: a key-up carrying no ⌘ reads as the modifier having been - // released mid-chord, which some apps treat as cancelling the shortcut. - down.flags = .maskCommand - up.flags = .maskCommand - return (down, up) - } + /// The Cmd-V key-down/key-up pair, or `nil` when CoreGraphics refuses to build + /// them. Split from `postCmdV` because only the *posting* is untestable: building + /// an event needs no Accessibility trust, while posting one sends a live + /// keystroke into whatever app has focus — not something `swift test` may do to + /// the machine it runs on. So the part carrying an actual invariant (the ⌘ flag + /// on both events and the `kVK_ANSI_V` keycode, which is what makes the paste a + /// paste) is asserted in `KeyInjectorSystemActionsTests`, and only the two + /// `.post` calls below stay covered by running the app. + static func cmdVEvents() -> (down: CGEvent, up: CGEvent)? { + let vKey: CGKeyCode = 0x09 // kVK_ANSI_V + guard let source = CGEventSource(stateID: .combinedSessionState), + let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false) + else { return nil } + // Set on both: a key-up carrying no ⌘ reads as the modifier having been + // released mid-chord, which some apps treat as cancelling the shortcut. + down.flags = .maskCommand + up.flags = .maskCommand + return (down, up) + } - /// Posts Cmd-V. Returns `false` if the events couldn't be built. The real side - /// effect (a keystroke into the focused app) is why this is the injectable seam - /// tests replace. - static func postCmdV() -> Bool { - guard let (down, up) = cmdVEvents() else { return false } - // Post to the annotated session tap rather than the HID tap: the session tap - // honors exactly the flags set above instead of OR-ing in the live hardware - // modifier state, so a still-held hotkey modifier can't corrupt Cmd-V into a - // combo the target app ignores. (We deliberately don't suppress local events - // during the post: `setLocalEventsFilterDuringSuppressionState` lingers for - // the source's ~0.25s suppression interval and would swallow the user's next - // dictation keypress right after a paste — the annotated tap already prevents - // the modifier merge that suppression was guarding against.) - down.post(tap: .cgAnnotatedSessionEventTap) - up.post(tap: .cgAnnotatedSessionEventTap) - return true + /// Posts Cmd-V. Returns `false` if the events couldn't be built. The real side + /// effect (a keystroke into the focused app) is why this is the injectable seam + /// tests replace. + static func postCmdV() -> Bool { + guard let (down, up) = cmdVEvents() else { return false } + // Post to the annotated session tap rather than the HID tap: the session tap + // honors exactly the flags set above instead of OR-ing in the live hardware + // modifier state, so a still-held hotkey modifier can't corrupt Cmd-V into a + // combo the target app ignores. (We deliberately don't suppress local events + // during the post: `setLocalEventsFilterDuringSuppressionState` lingers for + // the source's ~0.25s suppression interval and would swallow the user's next + // dictation keypress right after a paste — the annotated tap already prevents + // the modifier merge that suppression was guarding against.) + down.post(tap: .cgAnnotatedSessionEventTap) + up.post(tap: .cgAnnotatedSessionEventTap) + return true + } } -} +#endif diff --git a/Sources/BlurtEngine/Injection/KeyInjector.swift b/Sources/BlurtEngine/Injection/KeyInjector.swift index c59802fd..771bfee3 100644 --- a/Sources/BlurtEngine/Injection/KeyInjector.swift +++ b/Sources/BlurtEngine/Injection/KeyInjector.swift @@ -1,298 +1,300 @@ -import AppKit +#if os(macOS) + import AppKit -public actor KeyInjector: InjectorProtocol { - private var targetApp: NSRunningApplication? + public actor KeyInjector: InjectorProtocol { + private var targetApp: NSRunningApplication? - /// How long to wait after posting Cmd-V before clearing and restoring the - /// pasteboard. The paste is asynchronous: clear too soon and a slow target - /// (VNC/Remote Desktop, heavy Electron apps) reads an already-cleared - /// clipboard. 400 ms is a pragmatic margin over local apps' near-instant read. - /// This wait runs in a background settle task (see `pendingSettle`), *not* on - /// `insert`'s caller, so it no longer delays the pipeline re-arming. - private let pasteSettleDuration: Duration + /// How long to wait after posting Cmd-V before clearing and restoring the + /// pasteboard. The paste is asynchronous: clear too soon and a slow target + /// (VNC/Remote Desktop, heavy Electron apps) reads an already-cleared + /// clipboard. 400 ms is a pragmatic margin over local apps' near-instant read. + /// This wait runs in a background settle task (see `pendingSettle`), *not* on + /// `insert`'s caller, so it no longer delays the pipeline re-arming. + private let pasteSettleDuration: Duration - /// Synthesizes the paste keystroke. Returns `false` if the event subsystem - /// couldn't build the events. Injectable so tests exercise the clipboard - /// save/restore logic without posting a real Cmd-V into the focused app. - private let postPaste: @Sendable () -> Bool + /// Synthesizes the paste keystroke. Returns `false` if the event subsystem + /// couldn't build the events. Injectable so tests exercise the clipboard + /// save/restore logic without posting a real Cmd-V into the focused app. + private let postPaste: @Sendable () -> Bool - /// Identifies a window by its app's pid plus its title — a pid alone isn't - /// enough (one browser process hosts many unrelated tabs/documents), and a - /// title alone isn't stable across apps, so both travel together as one - /// value rather than two optionals that would have to stay in sync by hand. - /// Title equality is itself a heuristic, not a strict identity check (two - /// tabs can coincidentally share a title, or a title can change mid-session - /// for reasons unrelated to the document, e.g. an unsaved-changes marker); - /// accepted here because the safe failure mode is just a missing separator. - /// - /// Internal rather than private so `resolveInsert` — the pure form of the - /// decision it feeds — can be driven from tests with plain pids and titles. - struct WindowIdentity: Equatable { - let pid: pid_t - let title: String + /// Identifies a window by its app's pid plus its title — a pid alone isn't + /// enough (one browser process hosts many unrelated tabs/documents), and a + /// title alone isn't stable across apps, so both travel together as one + /// value rather than two optionals that would have to stay in sync by hand. + /// Title equality is itself a heuristic, not a strict identity check (two + /// tabs can coincidentally share a title, or a title can change mid-session + /// for reasons unrelated to the document, e.g. an unsaved-changes marker); + /// accepted here because the safe failure mode is just a missing separator. + /// + /// Internal rather than private so `resolveInsert` — the pure form of the + /// decision it feeds — can be driven from tests with plain pids and titles. + struct WindowIdentity: Equatable { + let pid: pid_t + let title: String - // Hand-written rather than synthesized: Periphery's static analysis can't - // see through a compiler-synthesized `==`, so it flags `pid`/`title` as - // assigned-but-unused. Spelling out the comparison gives it a real usage - // site to index. - static func == (lhs: WindowIdentity, rhs: WindowIdentity) -> Bool { - lhs.pid == rhs.pid && lhs.title == rhs.title + // Hand-written rather than synthesized: Periphery's static analysis can't + // see through a compiler-synthesized `==`, so it flags `pid`/`title` as + // assigned-but-unused. Spelling out the comparison gives it a real usage + // site to index. + static func == (lhs: WindowIdentity, rhs: WindowIdentity) -> Bool { + lhs.pid == rhs.pid && lhs.title == rhs.title + } } - } - - /// The previous insert's resolution: the exact text it pasted (including any - /// leading separator it added) and the window it landed in. One value rather - /// than two optionals kept in sync by hand — the same reasoning as - /// `WindowIdentity` above, and they are only ever written together. - /// - /// Lets `insert` recover spacing in Accessibility-opaque editors - /// (Electron/Monaco, e.g. VS Code — and, just as opaque, a browser tab like - /// Google Docs) where no prior text can be read: if the next dictation targets - /// the same window, the text we just pasted is what now precedes the caret, so - /// it drives the separator decision (see `separatorBasis`). A different window - /// — a different tab, a different file, or an unreadable title — means a - /// different field, so the fallback doesn't fire. - private var lastInserted: ResolvedInsert? - /// Tail of the paste chain: each insert links behind the previous insert's - /// ENTIRE critical section — paste *plus* its backgrounded settle/restore — so - /// two pastes can never interleave on the global `NSPasteboard`. `insert` - /// itself returns as soon as its paste is posted (the settle trails inside the - /// chain link), so the pipeline re-arms immediately while the next paste still - /// waits out the restore window. Chaining replaces a hand-rolled continuation - /// mutex whose lock had to be handed off to the settle task. Exposed - /// (internal get) so tests can await the deferred restore before asserting - /// clipboard state; production never reads it. - private(set) var pendingSettle: Task? + /// The previous insert's resolution: the exact text it pasted (including any + /// leading separator it added) and the window it landed in. One value rather + /// than two optionals kept in sync by hand — the same reasoning as + /// `WindowIdentity` above, and they are only ever written together. + /// + /// Lets `insert` recover spacing in Accessibility-opaque editors + /// (Electron/Monaco, e.g. VS Code — and, just as opaque, a browser tab like + /// Google Docs) where no prior text can be read: if the next dictation targets + /// the same window, the text we just pasted is what now precedes the caret, so + /// it drives the separator decision (see `separatorBasis`). A different window + /// — a different tab, a different file, or an unreadable title — means a + /// different field, so the fallback doesn't fire. + private var lastInserted: ResolvedInsert? - /// Brings the captured target app back to the foreground before pasting. - /// Injectable so tests can cover activation failure without depending on - /// another live application. - private let activateTarget: @Sendable (NSRunningApplication) -> Bool + /// Tail of the paste chain: each insert links behind the previous insert's + /// ENTIRE critical section — paste *plus* its backgrounded settle/restore — so + /// two pastes can never interleave on the global `NSPasteboard`. `insert` + /// itself returns as soon as its paste is posted (the settle trails inside the + /// chain link), so the pipeline re-arms immediately while the next paste still + /// waits out the restore window. Chaining replaces a hand-rolled continuation + /// mutex whose lock had to be handed off to the settle task. Exposed + /// (internal get) so tests can await the deferred restore before asserting + /// clipboard state; production never reads it. + private(set) var pendingSettle: Task? - /// Waits until the app activation has actually become visible to - /// `NSWorkspace` before editability is checked and Cmd-V is posted. Kept - /// injectable so tests that stub activation don't depend on the host's live - /// foreground app. - private let waitForTargetActivation: @Sendable (NSRunningApplication) async -> Bool + /// Brings the captured target app back to the foreground before pasting. + /// Injectable so tests can cover activation failure without depending on + /// another live application. + private let activateTarget: @Sendable (NSRunningApplication) -> Bool - /// Whether the process is trusted for Accessibility. Posting the synthesized - /// Cmd-V requires it (macOS 10.14+); without it `CGEvent.post` is silently - /// dropped, so we check first and fail loudly instead of reporting a paste that - /// never happened. Injectable so tests don't depend on the host's AX state - /// (defaults to trusted there). - private let isAccessibilityTrusted: @Sendable () -> Bool + /// Waits until the app activation has actually become visible to + /// `NSWorkspace` before editability is checked and Cmd-V is posted. Kept + /// injectable so tests that stub activation don't depend on the host's live + /// foreground app. + private let waitForTargetActivation: @Sendable (NSRunningApplication) async -> Bool - /// Whether something editable is focused to receive the paste. Checked right - /// before the synthesized ⌘V (after the target app is activated): when false, - /// the paste is skipped and the transcript is left on the clipboard instead of - /// beeping into a non-editable target. Injectable so tests don't depend on the - /// host's live focus (defaults to "editable" there). - private let hasEditableTarget: @Sendable () -> Bool + /// Whether the process is trusted for Accessibility. Posting the synthesized + /// Cmd-V requires it (macOS 10.14+); without it `CGEvent.post` is silently + /// dropped, so we check first and fail loudly instead of reporting a paste that + /// never happened. Injectable so tests don't depend on the host's AX state + /// (defaults to trusted there). + private let isAccessibilityTrusted: @Sendable () -> Bool - /// Whether the captured target app is AX-opaque — an Electron/Chromium editor - /// (VS Code, Slack) or a web browser — which can expose no editable AX signal - /// even for a real text field (Chromium builds its accessibility tree lazily, - /// and `contenteditable` composers like ChatGPT's surface no editable role). - /// When `hasEditableTarget` reads false but this is true, we still paste - /// rather than copy — dropping the user's words into a field they're clearly - /// typing in would be the worse mistake. Injectable so tests don't depend on - /// which apps are installed (defaults to "not opaque"). - private let isAXOpaqueApp: @Sendable (NSRunningApplication?) -> Bool + /// Whether something editable is focused to receive the paste. Checked right + /// before the synthesized ⌘V (after the target app is activated): when false, + /// the paste is skipped and the transcript is left on the clipboard instead of + /// beeping into a non-editable target. Injectable so tests don't depend on the + /// host's live focus (defaults to "editable" there). + private let hasEditableTarget: @Sendable () -> Bool - /// The pasteboard the paste reads, writes, and restores. Behind a seam so - /// tests exercise the save/restore + changeCount logic against an in-memory - /// fake instead of the real system pasteboard — whose contents another process - /// can change mid-test, which (correctly) suppresses the restore and would - /// otherwise flake. - private let clipboard: any ClipboardAccess + /// Whether the captured target app is AX-opaque — an Electron/Chromium editor + /// (VS Code, Slack) or a web browser — which can expose no editable AX signal + /// even for a real text field (Chromium builds its accessibility tree lazily, + /// and `contenteditable` composers like ChatGPT's surface no editable role). + /// When `hasEditableTarget` reads false but this is true, we still paste + /// rather than copy — dropping the user's words into a field they're clearly + /// typing in would be the worse mistake. Injectable so tests don't depend on + /// which apps are installed (defaults to "not opaque"). + private let isAXOpaqueApp: @Sendable (NSRunningApplication?) -> Bool - public init(pasteSettleDuration: Duration = .milliseconds(400)) { - self.init( - pasteSettleDuration: pasteSettleDuration, - postPaste: KeyInjector.postCmdV, - activateTarget: KeyInjector.activate, - waitForTargetActivation: KeyInjector.waitUntilFrontmost, - isAccessibilityTrusted: KeyInjector.accessibilityTrusted, - hasEditableTarget: FocusCapture.hasEditableFocusedElement, - isAXOpaqueApp: FocusCapture.isAXOpaqueApp) - } - - init( - pasteSettleDuration: Duration, - postPaste: @escaping @Sendable () -> Bool, - activateTarget: @escaping @Sendable (NSRunningApplication) -> Bool = KeyInjector.activate, - waitForTargetActivation: @escaping @Sendable (NSRunningApplication) async -> Bool = { _ in true }, - isAccessibilityTrusted: @escaping @Sendable () -> Bool = { true }, - hasEditableTarget: @escaping @Sendable () -> Bool = { true }, - isAXOpaqueApp: @escaping @Sendable (NSRunningApplication?) -> Bool = { _ in false }, - clipboard: any ClipboardAccess = SystemClipboard() - ) { - self.pasteSettleDuration = pasteSettleDuration - self.postPaste = postPaste - self.activateTarget = activateTarget - self.waitForTargetActivation = waitForTargetActivation - self.isAccessibilityTrusted = isAccessibilityTrusted - self.hasEditableTarget = hasEditableTarget - self.isAXOpaqueApp = isAXOpaqueApp - self.clipboard = clipboard - } - - public func setTargetApp(_ app: NSRunningApplication?) async { - targetApp = app - } - - /// Brings the captured target app frontmost before a paste, then waits a beat - /// for the activation to settle. No-op when no target was captured. Throws - /// `.targetAppLost` if the app quit between capture and now, or if activation - /// fails — pasting into whatever currently has focus would land the - /// keystrokes in the wrong place. (`performInsert` puts the transcript on the - /// clipboard before letting this error propagate, so the words survive.) - /// Takes the target as a parameter (the caller's snapshot) rather than - /// re-reading `targetApp`, so a `setTargetApp` racing in across the settle - /// sleep can't swap it mid-paste. - private func activateTargetApp(_ target: NSRunningApplication?) async throws(BlurtError) { - guard let target else { return } - guard !target.isTerminated else { throw BlurtError.targetAppLost } - guard activateTarget(target) else { throw BlurtError.targetAppLost } - guard await waitForTargetActivation(target) else { throw BlurtError.targetAppLost } - } + /// The pasteboard the paste reads, writes, and restores. Behind a seam so + /// tests exercise the save/restore + changeCount logic against an in-memory + /// fake instead of the real system pasteboard — whose contents another process + /// can change mid-test, which (correctly) suppresses the restore and would + /// otherwise flake. + private let clipboard: any ClipboardAccess - public func insert(_ text: String, after priorText: String? = nil, windowTitle: String? = nil) async throws { - guard !text.isEmpty else { return } - // Serialize the whole paste critical section by chaining behind the previous - // insert's link (which includes its settle/restore — see `pendingSettle`). - // The actor is reentrant across the `await`s in `performInsert`, so an - // unserialized second insert would snapshot the pasteboard while it still - // holds this insert's transcript and later restore that instead of the - // user's original clipboard. - let previous = pendingSettle - let timeout = pasteSettleDuration - let paste = Task<(@Sendable () -> Void)?, any Error> { - await previous?.value - return try await self.performInsert(text, after: priorText, windowTitle: windowTitle) + public init(pasteSettleDuration: Duration = .milliseconds(400)) { + self.init( + pasteSettleDuration: pasteSettleDuration, + postPaste: KeyInjector.postCmdV, + activateTarget: KeyInjector.activate, + waitForTargetActivation: KeyInjector.waitUntilFrontmost, + isAccessibilityTrusted: KeyInjector.accessibilityTrusted, + hasEditableTarget: FocusCapture.hasEditableFocusedElement, + isAXOpaqueApp: FocusCapture.isAXOpaqueApp) } - // Strong `self` captures, deliberately: each link is bounded (one paste, one - // settle sleep — no cycle), and a weak capture would let an injector torn - // down mid-window skip the paste. The restore closure captures the clipboard - // (not `self`), so the user's contents come back even if the injector is - // gone by the time the settle fires. - pendingSettle = Task { - guard let restore = try? await paste.value else { return } - try? await Task.sleep(for: timeout) - restore() + + init( + pasteSettleDuration: Duration, + postPaste: @escaping @Sendable () -> Bool, + activateTarget: @escaping @Sendable (NSRunningApplication) -> Bool = KeyInjector.activate, + waitForTargetActivation: @escaping @Sendable (NSRunningApplication) async -> Bool = { _ in true }, + isAccessibilityTrusted: @escaping @Sendable () -> Bool = { true }, + hasEditableTarget: @escaping @Sendable () -> Bool = { true }, + isAXOpaqueApp: @escaping @Sendable (NSRunningApplication?) -> Bool = { _ in false }, + clipboard: any ClipboardAccess = SystemClipboard() + ) { + self.pasteSettleDuration = pasteSettleDuration + self.postPaste = postPaste + self.activateTarget = activateTarget + self.waitForTargetActivation = waitForTargetActivation + self.isAccessibilityTrusted = isAccessibilityTrusted + self.hasEditableTarget = hasEditableTarget + self.isAXOpaqueApp = isAXOpaqueApp + self.clipboard = clipboard } - // Forward the caller's cancellation into the chain link: the paste task is - // unstructured, so `pipelineTask.cancel()` in the session wouldn't otherwise - // reach `performInsert`'s cancellation gates. - try await withTaskCancellationHandler { - _ = try await paste.value - } onCancel: { - paste.cancel() + + public func setTargetApp(_ app: NSRunningApplication?) async { + targetApp = app } - } - /// The paste critical section: runs with the chain's guarantee that no other - /// insert (or its settle) is mid-flight. Returns the deferred clipboard-restore - /// action for the settle link to run once the paste has landed. - private func performInsert( - _ text: String, after priorText: String?, windowTitle: String? - ) async throws -> @Sendable () -> Void { - try Task.checkCancellation() - // Snapshot the target at entry and use only the local below: this method - // suspends (activation settle), the actor is reentrant, and a - // setTargetApp() interleaving mid-insert must not make us activate one app - // while judging editability and recording `lastInserted` for another. - let target = targetApp - let resolved = KeyInjector.resolveInsert( - text: text, priorText: priorText, windowTitle: windowTitle, - targetPID: target?.processIdentifier, - lastInserted: lastInserted) - let finalText = resolved.text - do { - try await activateTargetApp(target) - } catch { - // The target app quit or refused activation between capture and paste. - // Transcription already succeeded, so leave the words on the clipboard — - // the pipeline degrades this to the quiet "copied" notice instead of a - // hard failure that would lose the dictation. - clipboard.write(finalText) - throw error + /// Brings the captured target app frontmost before a paste, then waits a beat + /// for the activation to settle. No-op when no target was captured. Throws + /// `.targetAppLost` if the app quit between capture and now, or if activation + /// fails — pasting into whatever currently has focus would land the + /// keystrokes in the wrong place. (`performInsert` puts the transcript on the + /// clipboard before letting this error propagate, so the words survive.) + /// Takes the target as a parameter (the caller's snapshot) rather than + /// re-reading `targetApp`, so a `setTargetApp` racing in across the settle + /// sleep can't swap it mid-paste. + private func activateTargetApp(_ target: NSRunningApplication?) async throws(BlurtError) { + guard let target else { return } + guard !target.isTerminated else { throw BlurtError.targetAppLost } + guard activateTarget(target) else { throw BlurtError.targetAppLost } + guard await waitForTargetActivation(target) else { throw BlurtError.targetAppLost } } - // Final cancellation gate before the irreversible paste: a cancel() that - // landed during activation must not type into the focused app. - try Task.checkCancellation() - // Nothing editable is focused (checked now that the target app is frontmost): - // a synthesized ⌘V would just make macOS beep. Leave the transcript on the - // clipboard so the user can paste it by hand, and signal the pipeline to show - // a quiet "copied" notice instead of typing. The exception is an AX-opaque - // app — an Electron editor (VS Code, Slack) or a web browser — which can - // report no editable signal even for a real text field; there we still paste - // rather than drop the user's words. - guard hasEditableTarget() || isAXOpaqueApp(target) else { - clipboard.write(finalText) - throw BlurtError.noEditableTarget + + public func insert(_ text: String, after priorText: String? = nil, windowTitle: String? = nil) async throws { + guard !text.isEmpty else { return } + // Serialize the whole paste critical section by chaining behind the previous + // insert's link (which includes its settle/restore — see `pendingSettle`). + // The actor is reentrant across the `await`s in `performInsert`, so an + // unserialized second insert would snapshot the pasteboard while it still + // holds this insert's transcript and later restore that instead of the + // user's original clipboard. + let previous = pendingSettle + let timeout = pasteSettleDuration + let paste = Task<(@Sendable () -> Void)?, any Error> { + await previous?.value + return try await self.performInsert(text, after: priorText, windowTitle: windowTitle) + } + // Strong `self` captures, deliberately: each link is bounded (one paste, one + // settle sleep — no cycle), and a weak capture would let an injector torn + // down mid-window skip the paste. The restore closure captures the clipboard + // (not `self`), so the user's contents come back even if the injector is + // gone by the time the settle fires. + pendingSettle = Task { + guard let restore = try? await paste.value else { return } + try? await Task.sleep(for: timeout) + restore() + } + // Forward the caller's cancellation into the chain link: the paste task is + // unstructured, so `pipelineTask.cancel()` in the session wouldn't otherwise + // reach `performInsert`'s cancellation gates. + try await withTaskCancellationHandler { + _ = try await paste.value + } onCancel: { + paste.cancel() + } } - // Bail before touching the pasteboard if we can't actually paste: without - // Accessibility trust the Cmd-V post below is silently dropped, which would - // otherwise clobber-and-restore the clipboard for a paste that never lands. - guard isAccessibilityTrusted() else { throw BlurtError.accessibilityPermissionMissing } - // Put the transcript on the clipboard and keep the deferred restore that - // brings the user's contents back once the paste settles. - let restore = clipboard.writeAndPrepareRestore(finalText) - // If the event subsystem won't synthesize the keystroke, the paste can't - // happen. The transcript is already on the pasteboard — leave it there (the - // user's words beat the stale pre-paste snapshot) so the failure degrades - // to the "copied" notice, matching the lost-target path above. - guard postPaste() else { throw BlurtError.targetAppLost } - // The paste is posted and the text is visible. Record what landed (including - // any leading separator) and which window it landed in so a following - // dictation into the same window can recover its spacing, then hand the - // deferred restore back to the chain link (see `insert`). `insert` returns - // now — so the pipeline reaches `.idle` and re-arms without waiting out the - // restore window — while the next paste still serializes behind the settle. - lastInserted = resolved - return restore - } + /// The paste critical section: runs with the chain's guarantee that no other + /// insert (or its settle) is mid-flight. Returns the deferred clipboard-restore + /// action for the settle link to run once the paste has landed. + private func performInsert( + _ text: String, after priorText: String?, windowTitle: String? + ) async throws -> @Sendable () -> Void { + try Task.checkCancellation() + // Snapshot the target at entry and use only the local below: this method + // suspends (activation settle), the actor is reentrant, and a + // setTargetApp() interleaving mid-insert must not make us activate one app + // while judging editability and recording `lastInserted` for another. + let target = targetApp + let resolved = KeyInjector.resolveInsert( + text: text, priorText: priorText, windowTitle: windowTitle, + targetPID: target?.processIdentifier, + lastInserted: lastInserted) + let finalText = resolved.text + do { + try await activateTargetApp(target) + } catch { + // The target app quit or refused activation between capture and paste. + // Transcription already succeeded, so leave the words on the clipboard — + // the pipeline degrades this to the quiet "copied" notice instead of a + // hard failure that would lose the dictation. + clipboard.write(finalText) + throw error + } + // Final cancellation gate before the irreversible paste: a cancel() that + // landed during activation must not type into the focused app. + try Task.checkCancellation() + // Nothing editable is focused (checked now that the target app is frontmost): + // a synthesized ⌘V would just make macOS beep. Leave the transcript on the + // clipboard so the user can paste it by hand, and signal the pipeline to show + // a quiet "copied" notice instead of typing. The exception is an AX-opaque + // app — an Electron editor (VS Code, Slack) or a web browser — which can + // report no editable signal even for a real text field; there we still paste + // rather than drop the user's words. + guard hasEditableTarget() || isAXOpaqueApp(target) else { + clipboard.write(finalText) + throw BlurtError.noEditableTarget + } + // Bail before touching the pasteboard if we can't actually paste: without + // Accessibility trust the Cmd-V post below is silently dropped, which would + // otherwise clobber-and-restore the clipboard for a paste that never lands. + guard isAccessibilityTrusted() else { throw BlurtError.accessibilityPermissionMissing } - /// What one insert resolves to before anything is activated or pasted: the - /// exact text to write (any leading separator included) and the window - /// identity to remember it against. Fed straight back in as the next insert's - /// `lastInserted` — a resolution and the memory it becomes are the same pair, - /// so there is one type for both. - struct ResolvedInsert: Equatable { - let text: String - let window: WindowIdentity? - } + // Put the transcript on the clipboard and keep the deferred restore that + // brings the user's contents back once the paste settles. + let restore = clipboard.writeAndPrepareRestore(finalText) + // If the event subsystem won't synthesize the keystroke, the paste can't + // happen. The transcript is already on the pasteboard — leave it there (the + // user's words beat the stale pre-paste snapshot) so the failure degrades + // to the "copied" notice, matching the lost-target path above. + guard postPaste() else { throw BlurtError.targetAppLost } + // The paste is posted and the text is visible. Record what landed (including + // any leading separator) and which window it landed in so a following + // dictation into the same window can recover its spacing, then hand the + // deferred restore back to the chain link (see `insert`). `insert` returns + // now — so the pipeline reaches `.idle` and re-arms without waiting out the + // restore window — while the next paste still serializes behind the settle. + lastInserted = resolved + return restore + } + + /// What one insert resolves to before anything is activated or pasted: the + /// exact text to write (any leading separator included) and the window + /// identity to remember it against. Fed straight back in as the next insert's + /// `lastInserted` — a resolution and the memory it becomes are the same pair, + /// so there is one type for both. + struct ResolvedInsert: Equatable { + let text: String + let window: WindowIdentity? + } - /// Both of `performInsert`'s continuity decisions, as one pure function taking - /// the paste target as a plain pid rather than an `NSRunningApplication`: - /// which text to write, and which window to remember writing it into. - /// - /// A pid is all the decision ever needed. Computed inline in `performInsert`, - /// it could only be reached through a full `insert`, so its four cases were - /// covered by scraping `NSWorkspace` for live processes — one case required - /// the host to be running two — which activated the developer's foreground app - /// and failed whenever the unordered pick landed on a background-only process. - /// Here the same cases are plain values, and `insert` keeps one end-to-end test - /// for the wiring. - static func resolveInsert( - text: String, - priorText: String?, - windowTitle: String?, - targetPID: pid_t?, - lastInserted: ResolvedInsert? - ) -> ResolvedInsert { - let currentWindow = targetPID.flatMap { pid in - windowTitle.map { WindowIdentity(pid: pid, title: $0) } + /// Both of `performInsert`'s continuity decisions, as one pure function taking + /// the paste target as a plain pid rather than an `NSRunningApplication`: + /// which text to write, and which window to remember writing it into. + /// + /// A pid is all the decision ever needed. Computed inline in `performInsert`, + /// it could only be reached through a full `insert`, so its four cases were + /// covered by scraping `NSWorkspace` for live processes — one case required + /// the host to be running two — which activated the developer's foreground app + /// and failed whenever the unordered pick landed on a background-only process. + /// Here the same cases are plain values, and `insert` keeps one end-to-end test + /// for the wiring. + static func resolveInsert( + text: String, + priorText: String?, + windowTitle: String?, + targetPID: pid_t?, + lastInserted: ResolvedInsert? + ) -> ResolvedInsert { + let currentWindow = targetPID.flatMap { pid in + windowTitle.map { WindowIdentity(pid: pid, title: $0) } + } + // `currentWindow.map { ... } ?? false` rather than `currentWindow == lastInserted?.window`: + // both sides being nil (nothing readable this time, nothing pasted last time) + // must not count as a match. + let sameWindow = currentWindow.map { $0 == lastInserted?.window } ?? false + let basis = separatorBasis( + priorText: priorText, lastInserted: lastInserted?.text, sameWindow: sameWindow) + return ResolvedInsert(text: withLeadingSeparator(text, after: basis), window: currentWindow) } - // `currentWindow.map { ... } ?? false` rather than `currentWindow == lastInserted?.window`: - // both sides being nil (nothing readable this time, nothing pasted last time) - // must not count as a match. - let sameWindow = currentWindow.map { $0 == lastInserted?.window } ?? false - let basis = separatorBasis( - priorText: priorText, lastInserted: lastInserted?.text, sameWindow: sameWindow) - return ResolvedInsert(text: withLeadingSeparator(text, after: basis), window: currentWindow) } -} +#endif diff --git a/Sources/BlurtEngine/Injection/SystemClipboard.swift b/Sources/BlurtEngine/Injection/SystemClipboard.swift index 1c3c8b1d..e6102c39 100644 --- a/Sources/BlurtEngine/Injection/SystemClipboard.swift +++ b/Sources/BlurtEngine/Injection/SystemClipboard.swift @@ -1,154 +1,156 @@ -import AppKit -import Foundation +#if os(macOS) + import AppKit + import Foundation -/// A thread-safe, value-type representation of a pasteboard item containing its data -/// keyed by pasteboard types, allowing it to cross concurrency boundaries safely. -struct SendablePasteboardItem: Sendable { - let dataMap: [NSPasteboard.PasteboardType: Data] -} + /// A thread-safe, value-type representation of a pasteboard item containing its data + /// keyed by pasteboard types, allowing it to cross concurrency boundaries safely. + struct SendablePasteboardItem: Sendable { + let dataMap: [NSPasteboard.PasteboardType: Data] + } -/// A best-effort snapshot of the whole pasteboard, taken before a paste so the -/// user's contents can be put back afterwards. -/// -/// The distinction that matters: this type is only ever produced when the -/// pasteboard was *readable*. `SystemClipboard.snapshot()` returns `nil` when the -/// read itself failed, so a failure can never be mistaken for "the clipboard was -/// empty" — conflating those two is what made the restore clear the user's -/// clipboard instead of restoring it. -/// -/// Known, inherent limitation: pasteboard data can be *promised* (provided lazily -/// by the owning app on demand). A promise cannot be copied, only materialized, and -/// an app may decline. Types that decline are absent from `dataMap`, so a restore -/// of promise-backed contents is a best-effort downgrade, not a byte-faithful -/// round trip — `plainText` exists to keep at least the text when that happens. -struct PasteboardSnapshot: Sendable { - /// One entry per item the pasteboard held, in order. An entry with an empty - /// `dataMap` means the item existed but none of its representations could be - /// materialized — distinct from the pasteboard having held no items at all, - /// which is `items.isEmpty`. - let items: [SendablePasteboardItem] - /// The pasteboard's plain-string flavor, kept separately as a restore floor for - /// when no item's representations could be materialized. - let plainText: String? -} + /// A best-effort snapshot of the whole pasteboard, taken before a paste so the + /// user's contents can be put back afterwards. + /// + /// The distinction that matters: this type is only ever produced when the + /// pasteboard was *readable*. `SystemClipboard.snapshot()` returns `nil` when the + /// read itself failed, so a failure can never be mistaken for "the clipboard was + /// empty" — conflating those two is what made the restore clear the user's + /// clipboard instead of restoring it. + /// + /// Known, inherent limitation: pasteboard data can be *promised* (provided lazily + /// by the owning app on demand). A promise cannot be copied, only materialized, and + /// an app may decline. Types that decline are absent from `dataMap`, so a restore + /// of promise-backed contents is a best-effort downgrade, not a byte-faithful + /// round trip — `plainText` exists to keep at least the text when that happens. + struct PasteboardSnapshot: Sendable { + /// One entry per item the pasteboard held, in order. An entry with an empty + /// `dataMap` means the item existed but none of its representations could be + /// materialized — distinct from the pasteboard having held no items at all, + /// which is `items.isEmpty`. + let items: [SendablePasteboardItem] + /// The pasteboard's plain-string flavor, kept separately as a restore floor for + /// when no item's representations could be materialized. + let plainText: String? + } -/// The two clipboard operations `KeyInjector` actually performs around a paste — -/// a plain overwrite, or an overwrite that can later restore what it displaced — -/// rather than exposing NSPasteboard's raw change-count/multi-item bookkeeping. -/// A seam so tests substitute a trivial in-memory fake that never has to -/// re-derive the pasteboard's change-count semantics to stay faithful. -protocol ClipboardAccess: Sendable { - /// Overwrite the clipboard with a single plain-string item, discarding the - /// previous contents. The degraded paste paths call this to leave the - /// transcript on the clipboard for a manual paste. - func write(_ text: String) - /// Overwrite the clipboard with `text`, returning an action that restores the - /// previous contents — but only if nothing else has written to the clipboard - /// in the meantime (so a user copy during the paste-settle window survives). - /// Call the returned action once the paste has settled. - func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void -} + /// The two clipboard operations `KeyInjector` actually performs around a paste — + /// a plain overwrite, or an overwrite that can later restore what it displaced — + /// rather than exposing NSPasteboard's raw change-count/multi-item bookkeeping. + /// A seam so tests substitute a trivial in-memory fake that never has to + /// re-derive the pasteboard's change-count semantics to stay faithful. + protocol ClipboardAccess: Sendable { + /// Overwrite the clipboard with a single plain-string item, discarding the + /// previous contents. The degraded paste paths call this to leave the + /// transcript on the clipboard for a manual paste. + func write(_ text: String) + /// Overwrite the clipboard with `text`, returning an action that restores the + /// previous contents — but only if nothing else has written to the clipboard + /// in the meantime (so a user copy during the paste-settle window survives). + /// Call the returned action once the paste has settled. + func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void + } -/// `ClipboardAccess` backed by the real `NSPasteboard.general`. The change-count -/// comparison that gates the deferred restore lives here, behind the seam, so a -/// fake never re-implements it. -struct SystemClipboard: ClipboardAccess { - func write(_ text: String) { setString(text) } + /// `ClipboardAccess` backed by the real `NSPasteboard.general`. The change-count + /// comparison that gates the deferred restore lives here, behind the seam, so a + /// fake never re-implements it. + struct SystemClipboard: ClipboardAccess { + func write(_ text: String) { setString(text) } - func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void { - let saved = snapshot() - setString(text) - // Snapshot the change count our own write produced. If anything else writes - // to the pasteboard before the restore fires (e.g. the user copies - // something), the count moves and the restore leaves their newer contents - // alone rather than clobbering them with the stale pre-paste snapshot. - let ourChangeCount = changeCount - return { [self] in - guard changeCount == ourChangeCount else { return } - // A nil snapshot means the pasteboard could not be read at all. There is - // nothing to put back, so leave the transcript on the clipboard (the same - // degraded-but-recoverable outcome as the `.noTarget` path) rather than - // clearing the user's clipboard to nothing. - guard let saved else { return } - restore(saved) + func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void { + let saved = snapshot() + setString(text) + // Snapshot the change count our own write produced. If anything else writes + // to the pasteboard before the restore fires (e.g. the user copies + // something), the count moves and the restore leaves their newer contents + // alone rather than clobbering them with the stale pre-paste snapshot. + let ourChangeCount = changeCount + return { [self] in + guard changeCount == ourChangeCount else { return } + // A nil snapshot means the pasteboard could not be read at all. There is + // nothing to put back, so leave the transcript on the clipboard (the same + // degraded-but-recoverable outcome as the `.noTarget` path) rather than + // clearing the user's clipboard to nothing. + guard let saved else { return } + restore(saved) + } } - } - // MARK: - NSPasteboard building blocks (also exercised directly by SystemClipboardTests) + // MARK: - NSPasteboard building blocks (also exercised directly by SystemClipboardTests) - var changeCount: Int { NSPasteboard.general.changeCount } + var changeCount: Int { NSPasteboard.general.changeCount } - /// Snapshots the pasteboard, or `nil` when it can't be read at all - /// (`pasteboardItems` is documented to return nil on error). Callers must treat - /// nil as "don't restore" — never as an empty clipboard. - func snapshot() -> PasteboardSnapshot? { - let pasteboard = NSPasteboard.general - guard let items = pasteboard.pasteboardItems else { return nil } - let captured = items.map { item in - var dataMap: [NSPasteboard.PasteboardType: Data] = [:] - for type in item.types { - // A nil read is a promised representation the owning app declined to - // materialize; record what we did get and let `plainText` be the floor. - if let data = item.data(forType: type) { - dataMap[type] = data + /// Snapshots the pasteboard, or `nil` when it can't be read at all + /// (`pasteboardItems` is documented to return nil on error). Callers must treat + /// nil as "don't restore" — never as an empty clipboard. + func snapshot() -> PasteboardSnapshot? { + let pasteboard = NSPasteboard.general + guard let items = pasteboard.pasteboardItems else { return nil } + let captured = items.map { item in + var dataMap: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + // A nil read is a promised representation the owning app declined to + // materialize; record what we did get and let `plainText` be the floor. + if let data = item.data(forType: type) { + dataMap[type] = data + } } + return SendablePasteboardItem(dataMap: dataMap) } - return SendablePasteboardItem(dataMap: dataMap) + return PasteboardSnapshot(items: captured, plainText: pasteboard.string(forType: .string)) } - return PasteboardSnapshot(items: captured, plainText: pasteboard.string(forType: .string)) - } - - func setString(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - func restore(_ saved: PasteboardSnapshot) { - let pasteboard = NSPasteboard.general - - // The pasteboard genuinely held nothing, so restoring it means emptying it. - // Safe to clear because the snapshot succeeded — a *failed* read is nil and - // never reaches here. - guard !saved.items.isEmpty else { + func setString(_ text: String) { + let pasteboard = NSPasteboard.general pasteboard.clearContents() - return + pasteboard.setString(text, forType: .string) } - // Build the items BEFORE clearing. Clearing first and then discovering there - // is nothing to write is how the user's clipboard got destroyed whenever a - // snapshot came back degraded. - let rebuilt = saved.items.compactMap { item -> NSPasteboardItem? in - guard !item.dataMap.isEmpty else { return nil } - let pasteboardItem = NSPasteboardItem() - for (type, data) in item.dataMap { - pasteboardItem.setData(data, forType: type) + func restore(_ saved: PasteboardSnapshot) { + let pasteboard = NSPasteboard.general + + // The pasteboard genuinely held nothing, so restoring it means emptying it. + // Safe to clear because the snapshot succeeded — a *failed* read is nil and + // never reaches here. + guard !saved.items.isEmpty else { + pasteboard.clearContents() + return } - return pasteboardItem - } - if !rebuilt.isEmpty { - pasteboard.clearContents() - // `writeObjects` can refuse the batch; fall through to the text floor - // rather than leaving the pasteboard empty. - if pasteboard.writeObjects(rebuilt) { return } - } + // Build the items BEFORE clearing. Clearing first and then discovering there + // is nothing to write is how the user's clipboard got destroyed whenever a + // snapshot came back degraded. + let rebuilt = saved.items.compactMap { item -> NSPasteboardItem? in + guard !item.dataMap.isEmpty else { return nil } + let pasteboardItem = NSPasteboardItem() + for (type, data) in item.dataMap { + pasteboardItem.setData(data, forType: type) + } + return pasteboardItem + } - // Either nothing was materializable, or the write above was refused. Put the - // text back if we have it. - // - // Precise about the one case this does NOT recover: if items DID materialize, - // `writeObjects` refused them, and there was no plain-string flavor, the - // pasteboard has already been cleared and stays empty. That is not gated on - // `plainText` being non-nil on purpose — skipping the item write whenever - // there's no text flavor would refuse to restore an image-only or - // file-only clipboard, which is a far more common clipboard than a refused - // batch write. `writeObjects` failing on a freshly-cleared pasteboard holding - // valid `NSPasteboardItem`s is a programming error, not a runtime condition, - // and NSPasteboard offers no way to test a write before clearing. - if let plainText = saved.plainText { - pasteboard.clearContents() - pasteboard.setString(plainText, forType: .string) + if !rebuilt.isEmpty { + pasteboard.clearContents() + // `writeObjects` can refuse the batch; fall through to the text floor + // rather than leaving the pasteboard empty. + if pasteboard.writeObjects(rebuilt) { return } + } + + // Either nothing was materializable, or the write above was refused. Put the + // text back if we have it. + // + // Precise about the one case this does NOT recover: if items DID materialize, + // `writeObjects` refused them, and there was no plain-string flavor, the + // pasteboard has already been cleared and stays empty. That is not gated on + // `plainText` being non-nil on purpose — skipping the item write whenever + // there's no text flavor would refuse to restore an image-only or + // file-only clipboard, which is a far more common clipboard than a refused + // batch write. `writeObjects` failing on a freshly-cleared pasteboard holding + // valid `NSPasteboardItem`s is a programming error, not a runtime condition, + // and NSPasteboard offers no way to test a write before clearing. + if let plainText = saved.plainText { + pasteboard.clearContents() + pasteboard.setString(plainText, forType: .string) + } } } -} +#endif diff --git a/Sources/BlurtEngine/Permissions/PermissionsChecker.swift b/Sources/BlurtEngine/Permissions/PermissionsChecker.swift index 190cb6ff..1d3a80bb 100644 --- a/Sources/BlurtEngine/Permissions/PermissionsChecker.swift +++ b/Sources/BlurtEngine/Permissions/PermissionsChecker.swift @@ -1,115 +1,117 @@ -import AVFoundation -import AppKit -import ApplicationServices -import Foundation +#if os(macOS) + import AVFoundation + import AppKit + import ApplicationServices + import Foundation -public struct PermissionStatus: Equatable, Sendable { - public let microphone: Bool - public let accessibility: Bool + public struct PermissionStatus: Equatable, Sendable { + public let microphone: Bool + public let accessibility: Bool - public init(microphone: Bool, accessibility: Bool) { - self.microphone = microphone - self.accessibility = accessibility - } + public init(microphone: Bool, accessibility: Bool) { + self.microphone = microphone + self.accessibility = accessibility + } - public var allGranted: Bool { microphone && accessibility } + public var allGranted: Bool { microphone && accessibility } - /// True when `previous` had every permission and this reading no longer does — - /// i.e. the user revoked one in System Settings, possibly while no window was - /// open. The shell reacts by pulling them back into onboarding rather than - /// leaving a dead overlay, so this is a behavioural edge worth a test; it lives - /// next to `allGranted`, the derivation it's built from, rather than being - /// spelled out at the one call site that watches for it. - public func lostGrant(since previous: PermissionStatus) -> Bool { - previous.allGranted && !allGranted + /// True when `previous` had every permission and this reading no longer does — + /// i.e. the user revoked one in System Settings, possibly while no window was + /// open. The shell reacts by pulling them back into onboarding rather than + /// leaving a dead overlay, so this is a behavioural edge worth a test; it lives + /// next to `allGranted`, the derivation it's built from, rather than being + /// spelled out at the one call site that watches for it. + public func lostGrant(since previous: PermissionStatus) -> Bool { + previous.allGranted && !allGranted + } } -} -public enum PermissionsChecker { - /// The current grant state, read without prompting. - public static func check() -> PermissionStatus { - check(micGranted: { micGranted() }, axTrusted: { AXIsProcessTrusted() }) - } + public enum PermissionsChecker { + /// The current grant state, read without prompting. + public static func check() -> PermissionStatus { + check(micGranted: { micGranted() }, axTrusted: { AXIsProcessTrusted() }) + } - /// The composition `check()` performs, with both probes injected. The probes - /// themselves read process-global TCC state the test host can't set, so with - /// them hard-coded the only assertable claim about `check()` was that it - /// doesn't crash — and the test that made it ended up restating `allGranted`'s - /// own definition instead. Injected, the wiring is assertable: each probe's - /// answer has to land in its own field, so a swapped pair fails. - static func check(micGranted: () -> Bool, axTrusted: () -> Bool) -> PermissionStatus { - PermissionStatus( - microphone: micGranted(), - accessibility: axTrusted() - ) - } + /// The composition `check()` performs, with both probes injected. The probes + /// themselves read process-global TCC state the test host can't set, so with + /// them hard-coded the only assertable claim about `check()` was that it + /// doesn't crash — and the test that made it ended up restating `allGranted`'s + /// own definition instead. Injected, the wiring is assertable: each probe's + /// answer has to land in its own field, so a swapped pair fails. + static func check(micGranted: () -> Bool, axTrusted: () -> Bool) -> PermissionStatus { + PermissionStatus( + microphone: micGranted(), + accessibility: axTrusted() + ) + } - private static func micGranted() -> Bool { - AVAudioApplication.shared.recordPermission == .granted - } + private static func micGranted() -> Bool { + AVAudioApplication.shared.recordPermission == .granted + } - public static func requestMicrophone() async -> Bool { - await AVAudioApplication.requestRecordPermission() - } + public static func requestMicrophone() async -> Bool { + await AVAudioApplication.requestRecordPermission() + } - /// Opens System Settings to Privacy › Microphone. The fallback when the in-app - /// `requestMicrophone()` prompt can't grant access — the user declined it, or - /// the system won't re-present it once the status is determined — so the - /// Microphone row still has a way forward, mirroring the Accessibility row's - /// "open Settings" flow rather than being a dead-end button. - @MainActor - public static func openMicrophoneSettings() { - guard - let url = URL( - string: - "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone") - else { return } - NSWorkspace.shared.open(url) - } + /// Opens System Settings to Privacy › Microphone. The fallback when the in-app + /// `requestMicrophone()` prompt can't grant access — the user declined it, or + /// the system won't re-present it once the status is determined — so the + /// Microphone row still has a way forward, mirroring the Accessibility row's + /// "open Settings" flow rather than being a dead-end button. + @MainActor + public static func openMicrophoneSettings() { + guard + let url = URL( + string: + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone") + else { return } + NSWorkspace.shared.open(url) + } - /// Trigger the Accessibility permission flow: - /// 1. Make a *real* AX-protected call against another app's UI tree — - /// this is what reliably registers Blurt in TCC on macOS 26. - /// `AXIsProcessTrustedWithOptions` and the system-wide query don't - /// appear to count as "activity" for registration purposes. - /// 2. Show the trust prompt with an "Open System Settings" button. - @MainActor - public static func openAccessibilitySettings() { - forceAccessibilityActivity() - // The literal spells out `kAXTrustedCheckOptionPrompt`'s value: the SDK - // header declares the constant as a non-const `extern CFStringRef`, so it - // imports into Swift as a global `var` that strict concurrency refuses to - // reference ("shared mutable state") — the framework constant cannot be - // used here. - let prompt: NSDictionary = ["AXTrustedCheckOptionPrompt": true] - _ = AXIsProcessTrustedWithOptions(prompt) - } + /// Trigger the Accessibility permission flow: + /// 1. Make a *real* AX-protected call against another app's UI tree — + /// this is what reliably registers Blurt in TCC on macOS 26. + /// `AXIsProcessTrustedWithOptions` and the system-wide query don't + /// appear to count as "activity" for registration purposes. + /// 2. Show the trust prompt with an "Open System Settings" button. + @MainActor + public static func openAccessibilitySettings() { + forceAccessibilityActivity() + // The literal spells out `kAXTrustedCheckOptionPrompt`'s value: the SDK + // header declares the constant as a non-const `extern CFStringRef`, so it + // imports into Swift as a global `var` that strict concurrency refuses to + // reference ("shared mutable state") — the framework constant cannot be + // used here. + let prompt: NSDictionary = ["AXTrustedCheckOptionPrompt": true] + _ = AXIsProcessTrustedWithOptions(prompt) + } - /// Makes a *real* AX-protected call against another app's UI tree — what - /// reliably registers Blurt in TCC on macOS 26, and the no-prompt first step - /// of `openAccessibilitySettings`. Internal rather than private so the engine - /// tests can exercise it directly, without the trust prompt that - /// `openAccessibilitySettings` adds on top. - @MainActor - static func forceAccessibilityActivity() { - // AX reads against our own pid are NOT TCC-protected — apps can always - // read their own UI. We must target a different process so tccd sees - // a denied request and registers Blurt in the Accessibility list. - // `frontmostApplication` is Blurt itself when this runs from a - // button in our own window, which is why prior versions never worked. - let myPid = ProcessInfo.processInfo.processIdentifier - let others = NSWorkspace.shared.runningApplications.filter { - $0.processIdentifier > 0 && $0.processIdentifier != myPid + /// Makes a *real* AX-protected call against another app's UI tree — what + /// reliably registers Blurt in TCC on macOS 26, and the no-prompt first step + /// of `openAccessibilitySettings`. Internal rather than private so the engine + /// tests can exercise it directly, without the trust prompt that + /// `openAccessibilitySettings` adds on top. + @MainActor + static func forceAccessibilityActivity() { + // AX reads against our own pid are NOT TCC-protected — apps can always + // read their own UI. We must target a different process so tccd sees + // a denied request and registers Blurt in the Accessibility list. + // `frontmostApplication` is Blurt itself when this runs from a + // button in our own window, which is why prior versions never worked. + let myPid = ProcessInfo.processInfo.processIdentifier + let others = NSWorkspace.shared.runningApplications.filter { + $0.processIdentifier > 0 && $0.processIdentifier != myPid + } + let target = + others.first(where: { $0.bundleIdentifier == "com.apple.finder" }) + ?? others.first(where: { $0.activationPolicy == .regular }) + ?? others.first + guard let pid = target?.processIdentifier else { return } + let element = AXUIElementCreateApplication(pid) + var value: CFTypeRef? + _ = AXUIElementCopyAttributeValue( + element, kAXFocusedUIElementAttribute as CFString, &value) } - let target = - others.first(where: { $0.bundleIdentifier == "com.apple.finder" }) - ?? others.first(where: { $0.activationPolicy == .regular }) - ?? others.first - guard let pid = target?.processIdentifier else { return } - let element = AXUIElementCreateApplication(pid) - var value: CFTypeRef? - _ = AXUIElementCopyAttributeValue( - element, kAXFocusedUIElementAttribute as CFString, &value) - } -} + } +#endif diff --git a/Sources/BlurtEngine/Permissions/SigningIdentity.swift b/Sources/BlurtEngine/Permissions/SigningIdentity.swift index 0dcb6b26..150638d3 100644 --- a/Sources/BlurtEngine/Permissions/SigningIdentity.swift +++ b/Sources/BlurtEngine/Permissions/SigningIdentity.swift @@ -1,117 +1,119 @@ -import Foundation -import Security -import os +#if os(macOS) + import Foundation + import Security + import os -/// Integration adapters for the signing-identity migration: read the identity -/// this process's signature pins into its designated requirement, and clear its -/// Accessibility grant. Kept separate from the pure `SigningIdentityMigration` -/// so the decision logic stays testable and these system calls stay thin. -public enum SigningIdentity { - private static let log = Logger(subsystem: BlurtIdentity.subsystem, category: "SigningIdentity") + /// Integration adapters for the signing-identity migration: read the identity + /// this process's signature pins into its designated requirement, and clear its + /// Accessibility grant. Kept separate from the pure `SigningIdentityMigration` + /// so the decision logic stays testable and these system calls stay thin. + public enum SigningIdentity { + private static let log = Logger(subsystem: BlurtIdentity.subsystem, category: "SigningIdentity") - /// Namespace marker on a recorded identity. Every build before this one recorded - /// a bare Team ID (10 alphanumerics, no colon), so the prefix keeps the two - /// shapes disjoint — a marker left by an older build can never accidentally - /// compare equal to a requirement — and makes a dumped `defaults read` value - /// self-describing. - static let requirementPrefix = "dr:" + /// Namespace marker on a recorded identity. Every build before this one recorded + /// a bare Team ID (10 alphanumerics, no colon), so the prefix keeps the two + /// shapes disjoint — a marker left by an older build can never accidentally + /// compare equal to a requirement — and makes a dumped `defaults read` value + /// self-describing. + static let requirementPrefix = "dr:" - /// Whatever an Accessibility grant taken *right now* would be pinned to: this - /// binary's **designated requirement**, serialized. - /// - /// The DR is what `tccd` stores alongside a grant and re-checks the running - /// binary against, so it is the identity — not a proxy for it. That distinction - /// is the whole bug this used to have: it recorded the *Team ID*, and the two - /// ways Blurt is team-signed carry the same team while pinning different - /// requirements. - /// - /// - **Dev builds** (`Apple Development`, re-signed by the `project.yml` - /// post-build install) pin an explicit team-based requirement: - /// `identifier … and anchor apple generic and certificate leaf[subject.OU] = `. - /// Cert rotation inside the team leaves that string byte-identical, which is - /// why the explicit requirement is stamped in the first place. - /// - **Releases** (`Developer ID`, `scripts/release-build.sh`) carry codesign's - /// *default* requirement, which additionally pins the leaf cert's Common Name - /// and the Developer ID marker OIDs — so re-issuing that certificate moves the - /// requirement and orphans every installed user's grant. - /// - /// `nil` for **unsigned or ad-hoc code**, which the migration reads as "no - /// action". Ad-hoc signatures pin a cdhash, so honouring them would mean every - /// `uitest.sh` / `check.sh` run — each one a throwaway ad-hoc binary under the - /// debug bundle id — resetting the developer's own Blurt Dev grant. Refusing - /// them here makes that structural rather than a flag the call site has to - /// remember to pass. The cost is a contributor who builds ad-hoc *and* copies - /// the result to /Applications by hand: they re-grant per rebuild, and - /// `tccutil reset Accessibility dev.alex.blurt.dev` is the way out. - public static func current() -> String? { - guard let code = staticCodeForSelf() else { return nil } - // A team identifier is present exactly when the signature isn't ad-hoc, so it - // is the probe for that carve-out — never the recorded value. - guard teamIdentifier(of: code) != nil else { return nil } - guard let requirement = designatedRequirement(of: code) else { return nil } - return requirementPrefix + requirement - } + /// Whatever an Accessibility grant taken *right now* would be pinned to: this + /// binary's **designated requirement**, serialized. + /// + /// The DR is what `tccd` stores alongside a grant and re-checks the running + /// binary against, so it is the identity — not a proxy for it. That distinction + /// is the whole bug this used to have: it recorded the *Team ID*, and the two + /// ways Blurt is team-signed carry the same team while pinning different + /// requirements. + /// + /// - **Dev builds** (`Apple Development`, re-signed by the `project.yml` + /// post-build install) pin an explicit team-based requirement: + /// `identifier … and anchor apple generic and certificate leaf[subject.OU] = `. + /// Cert rotation inside the team leaves that string byte-identical, which is + /// why the explicit requirement is stamped in the first place. + /// - **Releases** (`Developer ID`, `scripts/release-build.sh`) carry codesign's + /// *default* requirement, which additionally pins the leaf cert's Common Name + /// and the Developer ID marker OIDs — so re-issuing that certificate moves the + /// requirement and orphans every installed user's grant. + /// + /// `nil` for **unsigned or ad-hoc code**, which the migration reads as "no + /// action". Ad-hoc signatures pin a cdhash, so honouring them would mean every + /// `uitest.sh` / `check.sh` run — each one a throwaway ad-hoc binary under the + /// debug bundle id — resetting the developer's own Blurt Dev grant. Refusing + /// them here makes that structural rather than a flag the call site has to + /// remember to pass. The cost is a contributor who builds ad-hoc *and* copies + /// the result to /Applications by hand: they re-grant per rebuild, and + /// `tccutil reset Accessibility dev.alex.blurt.dev` is the way out. + public static func current() -> String? { + guard let code = staticCodeForSelf() else { return nil } + // A team identifier is present exactly when the signature isn't ad-hoc, so it + // is the probe for that carve-out — never the recorded value. + guard teamIdentifier(of: code) != nil else { return nil } + guard let requirement = designatedRequirement(of: code) else { return nil } + return requirementPrefix + requirement + } - /// The on-disk code object backing this process, or `nil` when any step of the - /// `Security` handshake fails. - static func staticCodeForSelf() -> SecStaticCode? { - var code: SecCode? - guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, let code else { return nil } - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, let staticCode - else { return nil } - return staticCode - } + /// The on-disk code object backing this process, or `nil` when any step of the + /// `Security` handshake fails. + static func staticCodeForSelf() -> SecStaticCode? { + var code: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, let code else { return nil } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, let staticCode + else { return nil } + return staticCode + } - /// The signature's team identifier — `nil` for unsigned or ad-hoc code. - private static func teamIdentifier(of code: SecStaticCode) -> String? { - var info: CFDictionary? - let flags = SecCSFlags(rawValue: kSecCSSigningInformation) - guard SecCodeCopySigningInformation(code, flags, &info) == errSecSuccess, - let dict = info as? [String: Any] - else { return nil } - return dict[kSecCodeInfoTeamIdentifier as String] as? String - } + /// The signature's team identifier — `nil` for unsigned or ad-hoc code. + private static func teamIdentifier(of code: SecStaticCode) -> String? { + var info: CFDictionary? + let flags = SecCSFlags(rawValue: kSecCSSigningInformation) + guard SecCodeCopySigningInformation(code, flags, &info) == errSecSuccess, + let dict = info as? [String: Any] + else { return nil } + return dict[kSecCodeInfoTeamIdentifier as String] as? String + } - /// The designated requirement, serialized to the same text `codesign -d -r-` - /// prints. Read through `SecCodeCopyDesignatedRequirement` rather than the - /// `kSecCodeInfoDesignatedRequirement` key of the signing-information - /// dictionary: that dictionary is `[String: Any]`, and narrowing an `Any` back - /// to a CoreFoundation type needs a cast the compiler flags as always-succeeding - /// (or a banned force cast). This spelling is typed end to end. - /// - /// Takes the code object rather than reading `self` so the tests can point it at - /// a binary whose requirement is known — the whole `Security` handshake is the - /// one part of this file that isn't pure logic, and the test host's own - /// signature varies by how the suite was launched. - static func designatedRequirement(of code: SecStaticCode) -> String? { - var requirement: SecRequirement? - guard SecCodeCopyDesignatedRequirement(code, SecCSFlags(), &requirement) == errSecSuccess, - let requirement - else { return nil } - var text: CFString? - guard SecRequirementCopyString(requirement, SecCSFlags(), &text) == errSecSuccess, let text - else { return nil } - return text as String - } + /// The designated requirement, serialized to the same text `codesign -d -r-` + /// prints. Read through `SecCodeCopyDesignatedRequirement` rather than the + /// `kSecCodeInfoDesignatedRequirement` key of the signing-information + /// dictionary: that dictionary is `[String: Any]`, and narrowing an `Any` back + /// to a CoreFoundation type needs a cast the compiler flags as always-succeeding + /// (or a banned force cast). This spelling is typed end to end. + /// + /// Takes the code object rather than reading `self` so the tests can point it at + /// a binary whose requirement is known — the whole `Security` handshake is the + /// one part of this file that isn't pure logic, and the test host's own + /// signature varies by how the suite was launched. + static func designatedRequirement(of code: SecStaticCode) -> String? { + var requirement: SecRequirement? + guard SecCodeCopyDesignatedRequirement(code, SecCSFlags(), &requirement) == errSecSuccess, + let requirement + else { return nil } + var text: CFString? + guard SecRequirementCopyString(requirement, SecCSFlags(), &text) == errSecSuccess, let text + else { return nil } + return text as String + } - /// Clears Blurt's Accessibility TCC grant so the next authorization recaptures a - /// code requirement matching the current signature. Resetting a bundle's own - /// grant needs no admin rights. Returns whether `tccutil` exited 0. - @discardableResult - public static func resetAccessibilityGrant(bundleID: String) -> Bool { - let proc = Process() - proc.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") - proc.arguments = ["reset", "Accessibility", bundleID] - do { - try proc.run() - proc.waitUntilExit() - let ok = proc.terminationStatus == 0 - if !ok { log.warning("tccutil reset Accessibility exited \(proc.terminationStatus)") } - return ok - } catch { - log.error("tccutil reset Accessibility failed to launch: \(error.localizedDescription)") - return false + /// Clears Blurt's Accessibility TCC grant so the next authorization recaptures a + /// code requirement matching the current signature. Resetting a bundle's own + /// grant needs no admin rights. Returns whether `tccutil` exited 0. + @discardableResult + public static func resetAccessibilityGrant(bundleID: String) -> Bool { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + proc.arguments = ["reset", "Accessibility", bundleID] + do { + try proc.run() + proc.waitUntilExit() + let ok = proc.terminationStatus == 0 + if !ok { log.warning("tccutil reset Accessibility exited \(proc.terminationStatus)") } + return ok + } catch { + log.error("tccutil reset Accessibility failed to launch: \(error.localizedDescription)") + return false + } } } -} +#endif diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift index 3f9d68ec..f3a929b4 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Press.swift @@ -130,7 +130,9 @@ extension DictationSession { // above) so the call is a Sendable closure rather than isolated state. let captureFrontmost = seams.captureFrontmost let captured = await captureFrontmost() - await injector.setTargetApp(captured.flatMap { FocusCapture.runningApp(for: $0) }) + #if os(macOS) + await injector.setTargetApp(captured.flatMap { FocusCapture.runningApp(for: $0) }) + #endif // Key terms are read synchronously at press (cheap UserDefaults read), so // each dictation observably re-reads Settings edits at press time. let keyTerms = keyTermsProvider() diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Seams.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Seams.swift index 460eb9a0..c2579921 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Seams.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Seams.swift @@ -22,9 +22,15 @@ extension DictationSession { /// session stores one and never writes to it. struct Seams: Sendable { /// Captures the frontmost application — the paste target, and the context's - /// app name. On the main actor because it's an AppKit read. + /// app name. On the main actor because it's an AppKit read. Off macOS there + /// is no AX capture to run, so the default reports nothing and a host that + /// has real context supplies its own seam. var captureFrontmost: @Sendable () async -> CapturedFocus? = { - await MainActor.run { FocusCapture.captureFrontmost() } + #if os(macOS) + return await MainActor.run { FocusCapture.captureFrontmost() } + #else + return nil + #endif } /// Reads the focused field's Accessibility context. Deliberately @@ -32,7 +38,11 @@ extension DictationSession { /// it blocks a thread against an unresponsive app (see its call site), and /// that stays true of the production capture behind this seam. var captureFieldContext: @Sendable () -> FocusCapture.FocusedFieldContext = { - FocusCapture.captureFieldContext() + #if os(macOS) + return FocusCapture.captureFieldContext() + #else + return .empty + #endif } /// Records a completed dictation in the developer-mode log. Gated on the diff --git a/Tests/BlurtEngineTests/AXDowncastTests.swift b/Tests/BlurtEngineTests/AXDowncastTests.swift index ba50daca..07f80bf4 100644 --- a/Tests/BlurtEngineTests/AXDowncastTests.swift +++ b/Tests/BlurtEngineTests/AXDowncastTests.swift @@ -1,44 +1,46 @@ -import ApplicationServices -import Testing +#if os(macOS) + import ApplicationServices + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -/// The checked CFTypeRef downcasts behind the Accessibility reads -/// (`FocusCapture.axElement` / `.axRange`). Attribute values arrive from -/// *other apps'* AX implementations, so a wrong CF type must decode to `nil`, -/// never flow onward mistyped. Pure type checks — no Accessibility trust, no -/// focused element, and no cross-process IPC needed. -@Suite("FocusCapture checked AX downcasts") -struct AXDowncastTests { - @Test("axElement passes a real AXUIElement through") - func elementAccepted() { - // The system-wide element is just a local ref — creating it needs no trust. - #expect(FocusCapture.axElement(AXUIElementCreateSystemWide()) != nil) - } + /// The checked CFTypeRef downcasts behind the Accessibility reads + /// (`FocusCapture.axElement` / `.axRange`). Attribute values arrive from + /// *other apps'* AX implementations, so a wrong CF type must decode to `nil`, + /// never flow onward mistyped. Pure type checks — no Accessibility trust, no + /// focused element, and no cross-process IPC needed. + @Suite("FocusCapture checked AX downcasts") + struct AXDowncastTests { + @Test("axElement passes a real AXUIElement through") + func elementAccepted() { + // The system-wide element is just a local ref — creating it needs no trust. + #expect(FocusCapture.axElement(AXUIElementCreateSystemWide()) != nil) + } - @Test("axElement rejects a non-element CF value") - func elementWrongTypeRejected() { - #expect(FocusCapture.axElement("not an element" as CFString) == nil) - } + @Test("axElement rejects a non-element CF value") + func elementWrongTypeRejected() { + #expect(FocusCapture.axElement("not an element" as CFString) == nil) + } - @Test("axRange decodes an AXValue-wrapped CFRange") - func rangeDecoded() throws { - var range = CFRange(location: 4, length: 2) - let wrapped = try #require(AXValueCreate(.cfRange, &range)) - let decoded = try #require(FocusCapture.axRange(wrapped)) - #expect(decoded.location == 4) - #expect(decoded.length == 2) - } + @Test("axRange decodes an AXValue-wrapped CFRange") + func rangeDecoded() throws { + var range = CFRange(location: 4, length: 2) + let wrapped = try #require(AXValueCreate(.cfRange, &range)) + let decoded = try #require(FocusCapture.axRange(wrapped)) + #expect(decoded.location == 4) + #expect(decoded.length == 2) + } - @Test("axRange rejects a non-AXValue CF value") - func rangeWrongTypeRejected() { - #expect(FocusCapture.axRange("not a range" as CFString) == nil) - } + @Test("axRange rejects a non-AXValue CF value") + func rangeWrongTypeRejected() { + #expect(FocusCapture.axRange("not a range" as CFString) == nil) + } - @Test("axRange rejects an AXValue holding a non-range payload") - func rangeWrongPayloadRejected() throws { - var point = CGPoint(x: 1, y: 2) - let wrapped = try #require(AXValueCreate(.cgPoint, &point)) - #expect(FocusCapture.axRange(wrapped) == nil) + @Test("axRange rejects an AXValue holding a non-range payload") + func rangeWrongPayloadRejected() throws { + var point = CGPoint(x: 1, y: 2) + let wrapped = try #require(AXValueCreate(.cgPoint, &point)) + #expect(FocusCapture.axRange(wrapped) == nil) + } } -} +#endif diff --git a/Tests/BlurtEngineTests/BrowserBundleIDTests.swift b/Tests/BlurtEngineTests/BrowserBundleIDTests.swift index 967ad2fa..c1f64609 100644 --- a/Tests/BlurtEngineTests/BrowserBundleIDTests.swift +++ b/Tests/BlurtEngineTests/BrowserBundleIDTests.swift @@ -1,144 +1,146 @@ -import AppKit -import Foundation -import Testing +#if os(macOS) + import AppKit + import Foundation + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -/// Pins the browser classification behind the injector's AX-opaque exemption -/// (see `FocusCapture.isAXOpaqueApp`): a known browser pastes even when the -/// focused element exposes no editable AX signal, because web content is -/// routinely opaque (Chromium's lazy accessibility tree, `contenteditable` -/// composers like ChatGPT's) — "no signal" there means "AX can't see the -/// field," not "no field." -@Suite("FocusCapture.isBrowserBundleID") -struct BrowserBundleIDTests { - @Test( - "known browser bundle IDs classify as browsers", - arguments: [ - "com.apple.Safari", - "com.google.Chrome", - "org.chromium.Chromium", - "com.microsoft.edgemac", - "com.brave.Browser", - "com.operasoftware.Opera", - "com.vivaldi.Vivaldi", - "company.thebrowser.Browser", - "org.mozilla.firefox", - "com.duckduckgo.macos.browser", - "com.kagi.kagimacOS", - ]) - func knownBrowsers(bundleID: String) { - #expect(FocusCapture.isBrowserBundleID(bundleID)) - } + /// Pins the browser classification behind the injector's AX-opaque exemption + /// (see `FocusCapture.isAXOpaqueApp`): a known browser pastes even when the + /// focused element exposes no editable AX signal, because web content is + /// routinely opaque (Chromium's lazy accessibility tree, `contenteditable` + /// composers like ChatGPT's) — "no signal" there means "AX can't see the + /// field," not "no field." + @Suite("FocusCapture.isBrowserBundleID") + struct BrowserBundleIDTests { + @Test( + "known browser bundle IDs classify as browsers", + arguments: [ + "com.apple.Safari", + "com.google.Chrome", + "org.chromium.Chromium", + "com.microsoft.edgemac", + "com.brave.Browser", + "com.operasoftware.Opera", + "com.vivaldi.Vivaldi", + "company.thebrowser.Browser", + "org.mozilla.firefox", + "com.duckduckgo.macos.browser", + "com.kagi.kagimacOS", + ]) + func knownBrowsers(bundleID: String) { + #expect(FocusCapture.isBrowserBundleID(bundleID)) + } - @Test( - "channel variants classify with their stable siblings (prefix match)", - arguments: [ - "com.apple.SafariTechnologyPreview", - "com.google.Chrome.beta", - "com.google.Chrome.canary", - "com.microsoft.edgemac.Dev", - "com.brave.Browser.nightly", - ]) - func channelVariants(bundleID: String) { - #expect(FocusCapture.isBrowserBundleID(bundleID)) - } + @Test( + "channel variants classify with their stable siblings (prefix match)", + arguments: [ + "com.apple.SafariTechnologyPreview", + "com.google.Chrome.beta", + "com.google.Chrome.canary", + "com.microsoft.edgemac.Dev", + "com.brave.Browser.nightly", + ]) + func channelVariants(bundleID: String) { + #expect(FocusCapture.isBrowserBundleID(bundleID)) + } - @Test( - "non-browser apps are not browsers — they keep the copy-don't-beep fallback", - arguments: [ - "com.apple.finder", - "com.apple.TextEdit", - "com.apple.dt.Xcode", - "com.microsoft.VSCode", // Electron: exempted by isElectronApp, not here - "com.googlecode.iterm2", // "com.google" lookalike must not prefix-match - ]) - func nonBrowsers(bundleID: String) { - #expect(!FocusCapture.isBrowserBundleID(bundleID)) - } + @Test( + "non-browser apps are not browsers — they keep the copy-don't-beep fallback", + arguments: [ + "com.apple.finder", + "com.apple.TextEdit", + "com.apple.dt.Xcode", + "com.microsoft.VSCode", // Electron: exempted by isElectronApp, not here + "com.googlecode.iterm2", // "com.google" lookalike must not prefix-match + ]) + func nonBrowsers(bundleID: String) { + #expect(!FocusCapture.isBrowserBundleID(bundleID)) + } - @Test("a nil bundle ID is not a browser") - func nilBundleID() { - #expect(!FocusCapture.isBrowserBundleID(nil)) + @Test("a nil bundle ID is not a browser") + func nilBundleID() { + #expect(!FocusCapture.isBrowserBundleID(nil)) + } } -} -/// The other half of the AX-opaque exemption: Electron detection, and the -/// `isAXOpaqueApp` disjunction the injector actually calls. -/// -/// Electron apps are classified by the framework they bundle rather than by -/// bundle ID, because the set is open-ended (every Electron app ever shipped), -/// so the fixtures here are directory trees rather than identifier strings — -/// `isElectronBundle` is split out of the `NSRunningApplication` wrapper for -/// exactly that reason. -@Suite("FocusCapture AX-opaque app classification") -struct AXOpaqueAppTests { + /// The other half of the AX-opaque exemption: Electron detection, and the + /// `isAXOpaqueApp` disjunction the injector actually calls. + /// + /// Electron apps are classified by the framework they bundle rather than by + /// bundle ID, because the set is open-ended (every Electron app ever shipped), + /// so the fixtures here are directory trees rather than identifier strings — + /// `isElectronBundle` is split out of the `NSRunningApplication` wrapper for + /// exactly that reason. + @Suite("FocusCapture AX-opaque app classification") + struct AXOpaqueAppTests { - /// An app bundle skeleton in a temp directory, with the Electron framework - /// present or absent. Only the *path* matters to the check — nothing is loaded — - /// so an empty directory at the framework's location is a faithful fixture. - private func makeBundle(withElectron: Bool) throws -> URL { - let bundle = URL.temporaryDirectory.appending(path: "Blurt-\(UUID().uuidString).app") - let contents = - withElectron - ? bundle.appending(path: "Contents/Frameworks/Electron Framework.framework") - : bundle.appending(path: "Contents/Frameworks") - try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) - return bundle - } + /// An app bundle skeleton in a temp directory, with the Electron framework + /// present or absent. Only the *path* matters to the check — nothing is loaded — + /// so an empty directory at the framework's location is a faithful fixture. + private func makeBundle(withElectron: Bool) throws -> URL { + let bundle = URL.temporaryDirectory.appending(path: "Blurt-\(UUID().uuidString).app") + let contents = + withElectron + ? bundle.appending(path: "Contents/Frameworks/Electron Framework.framework") + : bundle.appending(path: "Contents/Frameworks") + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + return bundle + } - // MARK: isElectronBundle + // MARK: isElectronBundle - @Test("a bundle shipping the Electron framework is Electron") - func electronBundleDetected() throws { - let bundle = try makeBundle(withElectron: true) - defer { try? FileManager.default.removeItem(at: bundle) } - // The true arm is what keeps VS Code and Slack on the paste path: their focused - // text fields expose no editable AX signal, so without this they'd fall back to - // copy-only and the user's words would never land. - #expect(FocusCapture.isElectronBundle(bundle)) - } + @Test("a bundle shipping the Electron framework is Electron") + func electronBundleDetected() throws { + let bundle = try makeBundle(withElectron: true) + defer { try? FileManager.default.removeItem(at: bundle) } + // The true arm is what keeps VS Code and Slack on the paste path: their focused + // text fields expose no editable AX signal, so without this they'd fall back to + // copy-only and the user's words would never land. + #expect(FocusCapture.isElectronBundle(bundle)) + } - @Test("a native bundle with no Electron framework is not Electron") - func nativeBundleRejected() throws { - let bundle = try makeBundle(withElectron: false) - defer { try? FileManager.default.removeItem(at: bundle) } - // The false arm matters just as much: a native app with genuinely nothing - // editable focused must fall back to copy rather than beep a ⌘V. - #expect(!FocusCapture.isElectronBundle(bundle)) - } + @Test("a native bundle with no Electron framework is not Electron") + func nativeBundleRejected() throws { + let bundle = try makeBundle(withElectron: false) + defer { try? FileManager.default.removeItem(at: bundle) } + // The false arm matters just as much: a native app with genuinely nothing + // editable focused must fall back to copy rather than beep a ⌘V. + #expect(!FocusCapture.isElectronBundle(bundle)) + } - @Test("a bundle URL that doesn't exist is not Electron") - func missingBundleRejected() { - #expect(!FocusCapture.isElectronBundle(URL(filePath: "/nonexistent/Ghost.app"))) - } + @Test("a bundle URL that doesn't exist is not Electron") + func missingBundleRejected() { + #expect(!FocusCapture.isElectronBundle(URL(filePath: "/nonexistent/Ghost.app"))) + } - @Test("a nil bundle URL is not Electron") - func nilBundleURLRejected() { - #expect(!FocusCapture.isElectronBundle(nil)) - } + @Test("a nil bundle URL is not Electron") + func nilBundleURLRejected() { + #expect(!FocusCapture.isElectronBundle(nil)) + } - // MARK: NSRunningApplication wrappers + // MARK: NSRunningApplication wrappers - @Test("the test host is neither a browser nor Electron") - func testHostIsNotOpaque() { - // The one live `NSRunningApplication` a unit test can count on. Weak as an - // assertion about *this* process, but it pins the wrappers as pass-throughs to - // the two pure checks rather than, say, defaulting to opaque — which would make - // the injector paste into every non-editable target and beep. - let current = NSRunningApplication.current - #expect(!FocusCapture.isBrowserApp(current)) - #expect(!FocusCapture.isElectronApp(current)) - #expect(!FocusCapture.isAXOpaqueApp(current)) - } + @Test("the test host is neither a browser nor Electron") + func testHostIsNotOpaque() { + // The one live `NSRunningApplication` a unit test can count on. Weak as an + // assertion about *this* process, but it pins the wrappers as pass-throughs to + // the two pure checks rather than, say, defaulting to opaque — which would make + // the injector paste into every non-editable target and beep. + let current = NSRunningApplication.current + #expect(!FocusCapture.isBrowserApp(current)) + #expect(!FocusCapture.isElectronApp(current)) + #expect(!FocusCapture.isAXOpaqueApp(current)) + } - @Test("no app at all is not AX-opaque") - func nilAppIsNotOpaque() { - // `KeyInjector` passes its captured target, which is nil when nothing was - // captured — that must not be treated as opaque, or a paste with no known - // target would be attempted anyway. - #expect(!FocusCapture.isBrowserApp(nil)) - #expect(!FocusCapture.isElectronApp(nil)) - #expect(!FocusCapture.isAXOpaqueApp(nil)) + @Test("no app at all is not AX-opaque") + func nilAppIsNotOpaque() { + // `KeyInjector` passes its captured target, which is nil when nothing was + // captured — that must not be treated as opaque, or a paste with no known + // target would be attempted anyway. + #expect(!FocusCapture.isBrowserApp(nil)) + #expect(!FocusCapture.isElectronApp(nil)) + #expect(!FocusCapture.isAXOpaqueApp(nil)) + } } -} +#endif diff --git a/Tests/BlurtEngineTests/CancelRaceTests.swift b/Tests/BlurtEngineTests/CancelRaceTests.swift index 2ed81335..6d2e4330 100644 --- a/Tests/BlurtEngineTests/CancelRaceTests.swift +++ b/Tests/BlurtEngineTests/CancelRaceTests.swift @@ -1,9 +1,12 @@ -import AppKit import Foundation import Testing @testable import BlurtEngine +#if os(macOS) + import AppKit +#endif + /// Cancel arriving after recording has already stopped — i.e. while the pipeline /// is in `.transcribing` or `.injecting`. The transcribe→inject work runs in a /// detached task spawned by `release()`; a `cancel()` (DictationKeyGate can emit @@ -324,7 +327,9 @@ private actor GatedInjector: InjectorProtocol { self.onRecord = onRecord } - func setTargetApp(_ app: NSRunningApplication?) async {} + #if os(macOS) + func setTargetApp(_ app: NSRunningApplication?) async {} + #endif func insert(_ text: String, after priorText: String?, windowTitle: String?) async throws { await gate.enter() diff --git a/Tests/BlurtEngineTests/DictationLogTests.swift b/Tests/BlurtEngineTests/DictationLogTests.swift index 8f553ac3..1b7fd16a 100644 --- a/Tests/BlurtEngineTests/DictationLogTests.swift +++ b/Tests/BlurtEngineTests/DictationLogTests.swift @@ -121,8 +121,10 @@ struct DictationLogEntryTests { now: Date()) #expect(entry.prior == "Hi Sam, ") #expect(entry.turns == ["Hi Sam,"]) - #expect(KeyInjector.withLeadingSeparator("p", after: entry.prior) == "p") - #expect(KeyInjector.withLeadingSeparator("p", after: entry.turns.last) == " p") + #if os(macOS) + #expect(KeyInjector.withLeadingSeparator("p", after: entry.prior) == "p") + #expect(KeyInjector.withLeadingSeparator("p", after: entry.turns.last) == " p") + #endif } @Test("a nil prior tells a history-only turn list apart from a prior chunk") diff --git a/Tests/BlurtEngineTests/EditableTargetTests.swift b/Tests/BlurtEngineTests/EditableTargetTests.swift index 27e45b7d..48297d38 100644 --- a/Tests/BlurtEngineTests/EditableTargetTests.swift +++ b/Tests/BlurtEngineTests/EditableTargetTests.swift @@ -1,76 +1,78 @@ -import Testing +#if os(macOS) + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -@Suite("FocusCapture.isEditableTarget") -struct EditableTargetTests { - @Test("a known text role is editable") - func textRole() { - #expect( - FocusCapture.isEditableTarget( - role: "AXTextArea", valueSettable: false, hasInsertionPoint: false)) - } + @Suite("FocusCapture.isEditableTarget") + struct EditableTargetTests { + @Test("a known text role is editable") + func textRole() { + #expect( + FocusCapture.isEditableTarget( + role: "AXTextArea", valueSettable: false, hasInsertionPoint: false)) + } - @Test("a secure (password) field is still a paste target despite prompt redaction") - func secureFieldIsEditable() { - // Redaction (never read a password into the STT prompt) and editability - // (the paste may land there) are independent: dictating into a password - // field must type, even though its contents are never captured. - #expect( - FocusCapture.isEditableTarget( - role: FocusCapture.secureFieldRole, valueSettable: false, hasInsertionPoint: false)) - } + @Test("a secure (password) field is still a paste target despite prompt redaction") + func secureFieldIsEditable() { + // Redaction (never read a password into the STT prompt) and editability + // (the paste may land there) are independent: dictating into a password + // field must type, even though its contents are never captured. + #expect( + FocusCapture.isEditableTarget( + role: FocusCapture.secureFieldRole, valueSettable: false, hasInsertionPoint: false)) + } - @Test("a settable value is editable even with an unknown role") - func settableValue() { - #expect( - FocusCapture.isEditableTarget( - role: "AXUnknown", valueSettable: true, hasInsertionPoint: false)) - } + @Test("a settable value is editable even with an unknown role") + func settableValue() { + #expect( + FocusCapture.isEditableTarget( + role: "AXUnknown", valueSettable: true, hasInsertionPoint: false)) + } - @Test("an insertion point is editable even with an unknown role") - func insertionPoint() { - #expect( - FocusCapture.isEditableTarget( - role: nil, valueSettable: false, hasInsertionPoint: true)) - } + @Test("an insertion point is editable even with an unknown role") + func insertionPoint() { + #expect( + FocusCapture.isEditableTarget( + role: nil, valueSettable: false, hasInsertionPoint: true)) + } - @Test("a non-text control with no editable signal is not editable") - func nonEditableControl() { - #expect( - !FocusCapture.isEditableTarget( - role: "AXButton", valueSettable: false, hasInsertionPoint: false)) - } + @Test("a non-text control with no editable signal is not editable") + func nonEditableControl() { + #expect( + !FocusCapture.isEditableTarget( + role: "AXButton", valueSettable: false, hasInsertionPoint: false)) + } - @Test("an unknown role with no editable signal is not editable (copy, don't beep)") - func unknownRoleWithoutSignalCopies() { - // A focused element that reports an unrecognized role and exposes no settable - // value or insertion point isn't a text target — copy rather than beep a ⌘V - // into it. (AX-opaque apps — Electron editors and browsers — also land here, - // but are pasted into via the injector's separate app-identity check, not - // this signal test.) - #expect( - !FocusCapture.isEditableTarget( - role: "AXWebArea", valueSettable: false, hasInsertionPoint: false)) - } + @Test("an unknown role with no editable signal is not editable (copy, don't beep)") + func unknownRoleWithoutSignalCopies() { + // A focused element that reports an unrecognized role and exposes no settable + // value or insertion point isn't a text target — copy rather than beep a ⌘V + // into it. (AX-opaque apps — Electron editors and browsers — also land here, + // but are pasted into via the injector's separate app-identity check, not + // this signal test.) + #expect( + !FocusCapture.isEditableTarget( + role: "AXWebArea", valueSettable: false, hasInsertionPoint: false)) + } - @Test("a focused element with an unreadable role is not editable (copy, don't beep)") - func nilRoleWithoutSignalCopies() { - #expect( - !FocusCapture.isEditableTarget( - role: nil, valueSettable: false, hasInsertionPoint: false)) + @Test("a focused element with an unreadable role is not editable (copy, don't beep)") + func nilRoleWithoutSignalCopies() { + #expect( + !FocusCapture.isEditableTarget( + role: nil, valueSettable: false, hasInsertionPoint: false)) + } } -} -@Suite("noTarget phase + overlay mapping") -struct NoTargetPhaseTests { - @Test("noTarget is terminal") - func terminal() { - #expect(PipelinePhase.noTarget.isTerminal) - } + @Suite("noTarget phase + overlay mapping") + struct NoTargetPhaseTests { + @Test("noTarget is terminal") + func terminal() { + #expect(PipelinePhase.noTarget.isTerminal) + } - @Test("noTarget maps to the quiet overlay state, not an error") - func overlayMapping() { - #expect(PipelinePhase.noTarget.overlayState == .noTarget) + @Test("noTarget maps to the quiet overlay state, not an error") + func overlayMapping() { + #expect(PipelinePhase.noTarget.overlayState == .noTarget) + } } -} +#endif diff --git a/Tests/BlurtEngineTests/KeyInjectorFallbackTests.swift b/Tests/BlurtEngineTests/KeyInjectorFallbackTests.swift index f6978ec7..ee3ff46c 100644 --- a/Tests/BlurtEngineTests/KeyInjectorFallbackTests.swift +++ b/Tests/BlurtEngineTests/KeyInjectorFallbackTests.swift @@ -1,97 +1,99 @@ -import AppKit -import Foundation -import Testing +#if os(macOS) + import AppKit + import Foundation + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -/// Exercises `KeyInjector.insert`'s abort paths: the mid-activation cancel gate -/// and the copy-to-clipboard fallbacks. Split from `KeyInjectorInsertTests` -/// (same seams, same shared fixtures in `Stubs/InjectorTestSupport.swift`) to -/// stay within the lint file-length budget. -@Suite("KeyInjector.insert fallback & cancel") -struct KeyInjectorFallbackTests { + /// Exercises `KeyInjector.insert`'s abort paths: the mid-activation cancel gate + /// and the copy-to-clipboard fallbacks. Split from `KeyInjectorInsertTests` + /// (same seams, same shared fixtures in `Stubs/InjectorTestSupport.swift`) to + /// stay within the lint file-length budget. + @Suite("KeyInjector.insert fallback & cancel") + struct KeyInjectorFallbackTests { - @Test("a cancel landing during target activation aborts before the paste") - func cancelDuringActivationSkipsPaste() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let posted = ValueBox(false) - let insertTask = ValueBox?>(nil) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }, - activateTarget: { _ in - // The cancel lands while activation is in flight; activation itself - // still succeeds, so only the post-activation cancellation gate stands - // between the cancel and the irreversible ⌘V. - insertTask.value?.cancel() - return true - }, - clipboard: clip) - await injector.setTargetApp(try liveTargetApp()) + @Test("a cancel landing during target activation aborts before the paste") + func cancelDuringActivationSkipsPaste() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let posted = ValueBox(false) + let insertTask = ValueBox?>(nil) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }, + activateTarget: { _ in + // The cancel lands while activation is in flight; activation itself + // still succeeds, so only the post-activation cancellation gate stands + // between the cancel and the irreversible ⌘V. + insertTask.value?.cancel() + return true + }, + clipboard: clip) + await injector.setTargetApp(try liveTargetApp()) - // Park the insert behind a gate until the task handle is stored, so the - // activation closure deterministically has something to cancel. - let gate = AsyncGate() - let task = Task { - await gate.wait() - try await injector.insert("text") - } - insertTask.value = task - gate.open() + // Park the insert behind a gate until the task handle is stored, so the + // activation closure deterministically has something to cancel. + let gate = AsyncGate() + let task = Task { + await gate.wait() + try await injector.insert("text") + } + insertTask.value = task + gate.open() - await #expect(throws: CancellationError.self) { - try await task.value + await #expect(throws: CancellationError.self) { + try await task.value + } + // No ⌘V was posted and the user's clipboard was never touched — the abort + // happened before the save/overwrite stage. + #expect(posted.value == false) + #expect(clip.string == "user-clipboard") } - // No ⌘V was posted and the user's clipboard was never touched — the abort - // happened before the save/overwrite stage. - #expect(posted.value == false) - #expect(clip.string == "user-clipboard") - } - @Test("the clipboard fallback carries the leading separator, not the raw text") - func fallbackKeepsLeadingSeparator() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { true }, - activateTarget: { _ in false }, - clipboard: clip) - await injector.setTargetApp(try liveTargetApp()) + @Test("the clipboard fallback carries the leading separator, not the raw text") + func fallbackKeepsLeadingSeparator() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { true }, + activateTarget: { _ in false }, + clipboard: clip) + await injector.setTargetApp(try liveTargetApp()) - await #expect(throws: BlurtError.targetAppLost) { - try await injector.insert("Second.", after: "First.") + await #expect(throws: BlurtError.targetAppLost) { + try await injector.insert("Second.", after: "First.") + } + // What lands on the clipboard is the final text a paste would have typed — + // separator included — so a manual ⌘V still joins the prior text correctly. + #expect(clip.string == " Second.") } - // What lands on the clipboard is the final text a paste would have typed — - // separator included — so a manual ⌘V still joins the prior text correctly. - #expect(clip.string == " Second.") - } - @Test("a thrown insert releases the paste lock for the next dictation", .timeLimit(.minutes(1))) - func failedInsertReleasesLock() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let editable = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { true }, - hasEditableTarget: { editable.value }, - clipboard: clip) + @Test("a thrown insert releases the paste lock for the next dictation", .timeLimit(.minutes(1))) + func failedInsertReleasesLock() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let editable = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { true }, + hasEditableTarget: { editable.value }, + clipboard: clip) - // First insert: nothing editable focused → copy fallback throws. - await #expect(throws: BlurtError.noEditableTarget) { - try await injector.insert("first") - } + // First insert: nothing editable focused → copy fallback throws. + await #expect(throws: BlurtError.noEditableTarget) { + try await injector.insert("first") + } - // The throw path must have released the paste lock: a following insert - // proceeds (rather than deadlocking behind a leaked lock, which the time - // limit would surface) and completes a normal paste + deferred restore. - editable.value = true - try await injector.insert("second") - await injector.pendingSettle?.value - // The restore brings back the pre-paste contents — which the failed insert - // deliberately left as its copied transcript. - #expect(clip.string == "first") + // The throw path must have released the paste lock: a following insert + // proceeds (rather than deadlocking behind a leaked lock, which the time + // limit would surface) and completes a normal paste + deferred restore. + editable.value = true + try await injector.insert("second") + await injector.pendingSettle?.value + // The restore brings back the pre-paste contents — which the failed insert + // deliberately left as its copied transcript. + #expect(clip.string == "first") + } } -} +#endif diff --git a/Tests/BlurtEngineTests/KeyInjectorInsertTests.swift b/Tests/BlurtEngineTests/KeyInjectorInsertTests.swift index 2e973308..e365e5aa 100644 --- a/Tests/BlurtEngineTests/KeyInjectorInsertTests.swift +++ b/Tests/BlurtEngineTests/KeyInjectorInsertTests.swift @@ -1,289 +1,291 @@ -import AppKit -import Foundation -import Testing +#if os(macOS) + import AppKit + import Foundation + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -/// Exercises `KeyInjector.insert` and its clipboard save/restore. The real Cmd-V -/// poster is replaced with an injected closure so no keystroke is sent to the -/// focused app, and an in-memory `FakeClipboard` stands in for the system -/// pasteboard so the save/restore + changeCount logic is tested in full -/// isolation — no dependency on (or races with) the host's real clipboard. -@Suite("KeyInjector.insert") -struct KeyInjectorInsertTests { + /// Exercises `KeyInjector.insert` and its clipboard save/restore. The real Cmd-V + /// poster is replaced with an injected closure so no keystroke is sent to the + /// focused app, and an in-memory `FakeClipboard` stands in for the system + /// pasteboard so the save/restore + changeCount logic is tested in full + /// isolation — no dependency on (or races with) the host's real clipboard. + @Suite("KeyInjector.insert") + struct KeyInjectorInsertTests { - @Test("restores the prior pasteboard contents after pasting") - func restoresClipboard() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let injector = KeyInjector(pasteSettleDuration: .zero, postPaste: { true }, clipboard: clip) - try await injector.insert("dictated text") - // The restore is deferred to the background settle task; await it before - // asserting the user's clipboard came back. - await injector.pendingSettle?.value + @Test("restores the prior pasteboard contents after pasting") + func restoresClipboard() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let injector = KeyInjector(pasteSettleDuration: .zero, postPaste: { true }, clipboard: clip) + try await injector.insert("dictated text") + // The restore is deferred to the background settle task; await it before + // asserting the user's clipboard came back. + await injector.pendingSettle?.value - #expect(clip.string == "user-clipboard") - } + #expect(clip.string == "user-clipboard") + } - @Test("insert returns before the deferred clipboard restore runs") - func deferRestoreReArmsEarly() async throws { - let clip = FakeClipboard(string: "user-clipboard") - // A 1s settle: long enough that the assertions below run well before the - // background restore fires, so the "still pending" checks are deterministic. - let injector = KeyInjector( - pasteSettleDuration: .seconds(1), postPaste: { true }, clipboard: clip) + @Test("insert returns before the deferred clipboard restore runs") + func deferRestoreReArmsEarly() async throws { + let clip = FakeClipboard(string: "user-clipboard") + // A 1s settle: long enough that the assertions below run well before the + // background restore fires, so the "still pending" checks are deterministic. + let injector = KeyInjector( + pasteSettleDuration: .seconds(1), postPaste: { true }, clipboard: clip) - try await injector.insert("dictated text") + try await injector.insert("dictated text") - // insert() has returned, but the restore is deferred: the pasted text is - // still on the clipboard and a settle task is in flight. This is what lets - // the pipeline re-arm immediately instead of waiting out the settle. - let settle = await injector.pendingSettle - #expect(clip.string == "dictated text") - #expect(settle != nil) + // insert() has returned, but the restore is deferred: the pasted text is + // still on the clipboard and a settle task is in flight. This is what lets + // the pipeline re-arm immediately instead of waiting out the settle. + let settle = await injector.pendingSettle + #expect(clip.string == "dictated text") + #expect(settle != nil) - // The backgrounded settle eventually restores the user's clipboard. - await settle?.value - #expect(clip.string == "user-clipboard") - } + // The backgrounded settle eventually restores the user's clipboard. + await settle?.value + #expect(clip.string == "user-clipboard") + } - @Test("activates a live target app before pasting") - func activatesTargetApp() async throws { - let activated = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { true }, - activateTarget: { _ in - activated.value = true - return true - }, - clipboard: FakeClipboard(string: nil)) - await injector.setTargetApp(try liveTargetApp()) - try await injector.insert("dictated text") // must not throw - #expect(activated.value) - } + @Test("activates a live target app before pasting") + func activatesTargetApp() async throws { + let activated = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { true }, + activateTarget: { _ in + activated.value = true + return true + }, + clipboard: FakeClipboard(string: nil)) + await injector.setTargetApp(try liveTargetApp()) + try await injector.insert("dictated text") // must not throw + #expect(activated.value) + } - @Test("activation failure: skips the paste but leaves the transcript on the clipboard") - func activationFailureSkipsPaste() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let posted = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }, - activateTarget: { _ in false }, - clipboard: clip) - await injector.setTargetApp(try liveTargetApp()) + @Test("activation failure: skips the paste but leaves the transcript on the clipboard") + func activationFailureSkipsPaste() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let posted = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }, + activateTarget: { _ in false }, + clipboard: clip) + await injector.setTargetApp(try liveTargetApp()) - await #expect(throws: BlurtError.targetAppLost) { - try await injector.insert("text") + await #expect(throws: BlurtError.targetAppLost) { + try await injector.insert("text") + } + // No ⌘V was posted into whatever now has focus, but the transcript survives + // on the clipboard so the failure degrades to a "copied" notice. + #expect(posted.value == false) + #expect(clip.string == "text") } - // No ⌘V was posted into whatever now has focus, but the transcript survives - // on the clipboard so the failure degrades to a "copied" notice. - #expect(posted.value == false) - #expect(clip.string == "text") - } - @Test("empty text is a no-op (no paste posted)") - func emptyTextNoOp() async throws { - let posted = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }) - try await injector.insert("") - #expect(posted.value == false) - } + @Test("empty text is a no-op (no paste posted)") + func emptyTextNoOp() async throws { + let posted = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }) + try await injector.insert("") + #expect(posted.value == false) + } - @Test("paste synthesis failure: throws .targetAppLost, transcript stays on the clipboard") - func pasteSynthesisFailureThrows() async { - let clip = FakeClipboard(string: "user-clipboard") - let injector = KeyInjector(pasteSettleDuration: .zero, postPaste: { false }, clipboard: clip) - await #expect(throws: BlurtError.targetAppLost) { - try await injector.insert("text") + @Test("paste synthesis failure: throws .targetAppLost, transcript stays on the clipboard") + func pasteSynthesisFailureThrows() async { + let clip = FakeClipboard(string: "user-clipboard") + let injector = KeyInjector(pasteSettleDuration: .zero, postPaste: { false }, clipboard: clip) + await #expect(throws: BlurtError.targetAppLost) { + try await injector.insert("text") + } + // The paste never happened, so the transcript is deliberately left on the + // clipboard (not restored away) — the user's words beat the stale snapshot. + #expect(clip.string == "text") } - // The paste never happened, so the transcript is deliberately left on the - // clipboard (not restored away) — the user's words beat the stale snapshot. - #expect(clip.string == "text") - } - @Test("throws .accessibilityPermissionMissing and leaves the clipboard untouched") - func notAccessibilityTrustedThrows() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let posted = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }, - isAccessibilityTrusted: { false }, - clipboard: clip) + @Test("throws .accessibilityPermissionMissing and leaves the clipboard untouched") + func notAccessibilityTrustedThrows() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let posted = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }, + isAccessibilityTrusted: { false }, + clipboard: clip) - await #expect(throws: BlurtError.accessibilityPermissionMissing) { - try await injector.insert("text") + await #expect(throws: BlurtError.accessibilityPermissionMissing) { + try await injector.insert("text") + } + // No paste posted, and the user's clipboard is left exactly as it was. + #expect(posted.value == false) + #expect(clip.string == "user-clipboard") } - // No paste posted, and the user's clipboard is left exactly as it was. - #expect(posted.value == false) - #expect(clip.string == "user-clipboard") - } - @Test("no editable target: skips the paste and leaves the transcript on the clipboard") - func noEditableTargetKeepsClipboard() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let posted = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }, - hasEditableTarget: { false }, - clipboard: clip) + @Test("no editable target: skips the paste and leaves the transcript on the clipboard") + func noEditableTargetKeepsClipboard() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let posted = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }, + hasEditableTarget: { false }, + clipboard: clip) - await #expect(throws: BlurtError.noEditableTarget) { - try await injector.insert("dictated text") + await #expect(throws: BlurtError.noEditableTarget) { + try await injector.insert("dictated text") + } + // No ⌘V was posted (so macOS never beeps), and the transcript is left on the + // clipboard for a manual paste rather than being restored away. + #expect(posted.value == false) + #expect(clip.string == "dictated text") } - // No ⌘V was posted (so macOS never beeps), and the transcript is left on the - // clipboard for a manual paste rather than being restored away. - #expect(posted.value == false) - #expect(clip.string == "dictated text") - } - @Test("AX-opaque Electron editor still pastes despite no editable signal") - func electronEditorPastesWithoutSignal() async throws { - let clip = FakeClipboard(string: "user-clipboard") - let posted = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }, - hasEditableTarget: { false }, // Electron/Chromium exposes no editable AX signal - isAXOpaqueApp: { _ in true }, // …but it *is* an AX-opaque app (Electron editor) - clipboard: clip) + @Test("AX-opaque Electron editor still pastes despite no editable signal") + func electronEditorPastesWithoutSignal() async throws { + let clip = FakeClipboard(string: "user-clipboard") + let posted = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }, + hasEditableTarget: { false }, // Electron/Chromium exposes no editable AX signal + isAXOpaqueApp: { _ in true }, // …but it *is* an AX-opaque app (Electron editor) + clipboard: clip) - // Must not throw noEditableTarget: the Electron exception keeps the paste. - try await injector.insert("dictated text") - await injector.pendingSettle?.value + // Must not throw noEditableTarget: the Electron exception keeps the paste. + try await injector.insert("dictated text") + await injector.pendingSettle?.value - // The ⌘V was posted (words pasted, not copy-only), and the user's clipboard - // is restored after the settle. - #expect(posted.value == true) - #expect(clip.string == "user-clipboard") - } + // The ⌘V was posted (words pasted, not copy-only), and the user's clipboard + // is restored after the settle. + #expect(posted.value == true) + #expect(clip.string == "user-clipboard") + } - @Test("opaque editor: a second dictation into the same window gets a separating space") - func opaqueEditorSameWindowInsertsSeparated() async throws { - let (injector, pasted) = makeRecordingInjector() - // Same target app AND window title for both dictations — e.g. two - // back-to-back dictations into the same VS Code file, or the same Google - // Docs tab. Prior text is nil because both surfaces are Accessibility-opaque. - await injector.setTargetApp(try liveTargetApp()) + @Test("opaque editor: a second dictation into the same window gets a separating space") + func opaqueEditorSameWindowInsertsSeparated() async throws { + let (injector, pasted) = makeRecordingInjector() + // Same target app AND window title for both dictations — e.g. two + // back-to-back dictations into the same VS Code file, or the same Google + // Docs tab. Prior text is nil because both surfaces are Accessibility-opaque. + await injector.setTargetApp(try liveTargetApp()) - try await injector.insert("First.", after: nil, windowTitle: "notes.txt — Editor") - try await injector.insert("Second.", after: nil, windowTitle: "notes.txt — Editor") + try await injector.insert("First.", after: nil, windowTitle: "notes.txt — Editor") + try await injector.insert("Second.", after: nil, windowTitle: "notes.txt — Editor") - // First paste lands as-is; the second is separated from it even though AX - // gave us no prior text — the injector remembers what it just pasted into - // this same window. - // - // The one end-to-end case: it pins that `insert` actually threads its - // resolution through (feeding the target's pid in, recording the resolved - // window after the paste). The rules themselves — a changed title, a missing - // title, a changed target — are cases of `resolveInsert` and live in - // `KeyInjectorResolveInsertTests`, which needs no live process at all. - #expect(pasted.values == ["First.", " Second."]) - } + // First paste lands as-is; the second is separated from it even though AX + // gave us no prior text — the injector remembers what it just pasted into + // this same window. + // + // The one end-to-end case: it pins that `insert` actually threads its + // resolution through (feeding the target's pid in, recording the resolved + // window after the paste). The rules themselves — a changed title, a missing + // title, a changed target — are cases of `resolveInsert` and live in + // `KeyInjectorResolveInsertTests`, which needs no live process at all. + #expect(pasted.values == ["First.", " Second."]) + } - @Test("overlapping inserts still restore the user's original clipboard") - func overlappingInsertsRestoreOriginal() async throws { - let clip = FakeClipboard(string: "user-clipboard") - // A non-zero settle keeps insert #1's backgrounded settle task holding the - // paste lock while a second insert arrives. Without serialization, insert #2 - // would snapshot the clipboard while it still holds insert #1's "first" text - // and later restore *that* instead of the user's original. `postPaste` opens - // the gate while #1 still holds the lock (insert #1 then hands the lock to - // its settle task and returns), so when `wait()` returns insert #2 - // deterministically blocks on the lock until #1's settle restores and - // releases — rather than interleaving. No timing margin needed. - let firstParked = AsyncGate() - let injector = KeyInjector( - pasteSettleDuration: .milliseconds(100), - postPaste: { - firstParked.open() - return true - }, - clipboard: clip) + @Test("overlapping inserts still restore the user's original clipboard") + func overlappingInsertsRestoreOriginal() async throws { + let clip = FakeClipboard(string: "user-clipboard") + // A non-zero settle keeps insert #1's backgrounded settle task holding the + // paste lock while a second insert arrives. Without serialization, insert #2 + // would snapshot the clipboard while it still holds insert #1's "first" text + // and later restore *that* instead of the user's original. `postPaste` opens + // the gate while #1 still holds the lock (insert #1 then hands the lock to + // its settle task and returns), so when `wait()` returns insert #2 + // deterministically blocks on the lock until #1's settle restores and + // releases — rather than interleaving. No timing margin needed. + let firstParked = AsyncGate() + let injector = KeyInjector( + pasteSettleDuration: .milliseconds(100), + postPaste: { + firstParked.open() + return true + }, + clipboard: clip) - let first = Task { try await injector.insert("first") } - await firstParked.wait() - try await injector.insert("second") - try await first.value - // Await the second insert's deferred settle (insert returns before it runs). - await injector.pendingSettle?.value + let first = Task { try await injector.insert("first") } + await firstParked.wait() + try await injector.insert("second") + try await first.value + // Await the second insert's deferred settle (insert returns before it runs). + await injector.pendingSettle?.value - #expect(clip.string == "user-clipboard") - } - - @Test("a copy during the settle window is preserved, not clobbered by restore") - func copyDuringSettleIsPreserved() async throws { - let clip = FakeClipboard(string: "user-clipboard") - // The paste posts, then the deferred settle waits. During that wait the user - // copies something new. The restore must not blow that away with the stale - // pre-paste snapshot — once the pasteboard changed under us, the right move - // is to leave the newer contents alone. - let pasted = AsyncGate() - let injector = KeyInjector( - pasteSettleDuration: .milliseconds(100), - postPaste: { - pasted.open() - return true - }, - clipboard: clip) + #expect(clip.string == "user-clipboard") + } - let task = Task { try await injector.insert("dictated") } - await pasted.wait() - clip.externalWrite("copied-mid-paste") - try await task.value - // Await the deferred settle: it must see the changed changeCount and skip the - // restore, leaving the user's mid-paste copy intact. - await injector.pendingSettle?.value + @Test("a copy during the settle window is preserved, not clobbered by restore") + func copyDuringSettleIsPreserved() async throws { + let clip = FakeClipboard(string: "user-clipboard") + // The paste posts, then the deferred settle waits. During that wait the user + // copies something new. The restore must not blow that away with the stale + // pre-paste snapshot — once the pasteboard changed under us, the right move + // is to leave the newer contents alone. + let pasted = AsyncGate() + let injector = KeyInjector( + pasteSettleDuration: .milliseconds(100), + postPaste: { + pasted.open() + return true + }, + clipboard: clip) - #expect(clip.string == "copied-mid-paste") - } + let task = Task { try await injector.insert("dictated") } + await pasted.wait() + clip.externalWrite("copied-mid-paste") + try await task.value + // Await the deferred settle: it must see the changed changeCount and skip the + // restore, leaving the user's mid-paste copy intact. + await injector.pendingSettle?.value - @Test("a cancelled task throws before posting any paste") - func cancelledBeforePaste() async throws { - let posted = ValueBox(false) - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - posted.value = true - return true - }) - // Gate the body so `cancel()` is guaranteed to land before `insert` reaches - // its first cancellation check. Without this, a busy machine can run the - // task past that check before `cancel()` arrives, posting the paste and - // flaking the assertion. The gate opens only after the cancel. - let gate = AsyncGate() - let task = Task { - await gate.wait() - try await injector.insert("text") + #expect(clip.string == "copied-mid-paste") } - task.cancel() - gate.open() - await #expect(throws: CancellationError.self) { - try await task.value + + @Test("a cancelled task throws before posting any paste") + func cancelledBeforePaste() async throws { + let posted = ValueBox(false) + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + posted.value = true + return true + }) + // Gate the body so `cancel()` is guaranteed to land before `insert` reaches + // its first cancellation check. Without this, a busy machine can run the + // task past that check before `cancel()` arrives, posting the paste and + // flaking the assertion. The gate opens only after the cancel. + let gate = AsyncGate() + let task = Task { + await gate.wait() + try await injector.insert("text") + } + task.cancel() + gate.open() + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(posted.value == false) } - #expect(posted.value == false) } -} -// The shared fixtures these tests drive the injector with — `liveTargetApp`, -// `AsyncGate`, `StringListBox`, `ValueBox` — live in `Stubs/InjectorTestSupport.swift` -// so the fallback/cancel suite (a separate file) can reuse them. + // The shared fixtures these tests drive the injector with — `liveTargetApp`, + // `AsyncGate`, `StringListBox`, `ValueBox` — live in `Stubs/InjectorTestSupport.swift` + // so the fallback/cancel suite (a separate file) can reuse them. +#endif diff --git a/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift b/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift index 311b9ab5..58e9a582 100644 --- a/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift +++ b/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift @@ -1,162 +1,164 @@ -// Foundation for `pid_t`: the resolve-insert suite below names it, and the two -// pure text-rule suites this file started as needed no imports at all. -import Foundation -import Testing - -@testable import BlurtEngine - -@Suite("KeyInjector.withLeadingSeparator") -struct KeyInjectorLeadingSeparatorTests { - @Test("prepends a space when prior text doesn't end in whitespace") - func prependsSpace() { - #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.") == " Second.") - } - - @Test("no separator when prior text already ends in a space") - func priorEndsInSpace() { - #expect(KeyInjector.withLeadingSeparator("Second.", after: "First. ") == "Second.") - } - - @Test("no separator when prior text ends in a newline") - func priorEndsInNewline() { - #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\n") == "Second.") - } - - @Test("no leading space into an empty field (nil prior text)") - func nilPrior() { - #expect(KeyInjector.withLeadingSeparator("Second.", after: nil) == "Second.") - } - - @Test("no leading space when prior text is empty") - func emptyPrior() { - #expect(KeyInjector.withLeadingSeparator("Second.", after: "") == "Second.") - } - - @Test("doesn't double up when the new text already starts with whitespace") - func textStartsWithSpace() { - #expect(KeyInjector.withLeadingSeparator(" Second.", after: "First.") == " Second.") - } - - @Test("returns empty text unchanged") - func emptyText() { - #expect(KeyInjector.withLeadingSeparator("", after: "First.") == "") - } - - @Test("every whitespace class counts, not just space and newline") - func otherWhitespaceClasses() { - // The rules key on Character.isWhitespace: a trailing tab, carriage return, - // or non-breaking space suppresses the separator like a plain space does… - #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\t") == "Second.") - #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\r") == "Second.") - #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\u{00A0}") == "Second.") - // …and text already leading with a tab or non-breaking space isn't doubled. - #expect(KeyInjector.withLeadingSeparator("\tSecond.", after: "First.") == "\tSecond.") - #expect(KeyInjector.withLeadingSeparator("\u{00A0}Second.", after: "First.") == "\u{00A0}Second.") - } -} - -@Suite("KeyInjector.separatorBasis") -struct KeyInjectorSeparatorBasisTests { - @Test("AX-read prior text wins even when a prior paste is on record") - func priorTextWins() { - #expect( - KeyInjector.separatorBasis(priorText: "AX.", lastInserted: "Old.", sameWindow: false) == "AX.") - } - - @Test("falls back to the last paste when AX is opaque and the window is unchanged") - func opaqueSameWindowFallsBack() { - #expect( - KeyInjector.separatorBasis(priorText: nil, lastInserted: "First.", sameWindow: true) - == "First.") - } - - @Test("does not carry a prior paste across a different window") - func opaqueDifferentWindowNoFallback() { - #expect( - KeyInjector.separatorBasis(priorText: nil, lastInserted: "First.", sameWindow: false) == nil) - } - - @Test("no basis when AX is opaque and nothing was pasted yet") - func opaqueNoPriorPaste() { - #expect(KeyInjector.separatorBasis(priorText: nil, lastInserted: nil, sameWindow: true) == nil) - } -} - -/// `resolveInsert` — everything `performInsert` decides before it activates -/// anything: the text to write and the window to remember writing it into. The -/// same-window derivation used to be inline in `performInsert`, so these cases -/// could only be driven by scraping `NSWorkspace` for live applications (and one -/// of them by requiring two). Here the target is just a pid. -@Suite("KeyInjector.resolveInsert") -struct KeyInjectorResolveInsertTests { - private let editor = KeyInjector.WindowIdentity(pid: 501, title: "notes.txt — Editor") - - /// A second dictation with no AX prior text, resolved against `editor`. - private func secondInsert( - targetPID: pid_t?, windowTitle: String?, priorText: String? = nil - ) -> KeyInjector.ResolvedInsert { - KeyInjector.resolveInsert( - text: "Second.", priorText: priorText, windowTitle: windowTitle, targetPID: targetPID, - lastInserted: KeyInjector.ResolvedInsert(text: "First.", window: editor)) - } - - @Test("opaque editor: the same pid and title recovers the spacing from the last paste") - func sameWindowSeparates() { - // Two back-to-back dictations into the same VS Code file or Google Docs tab: - // AX gives no prior text, so what we pasted a moment ago is what precedes - // the caret. - #expect( - secondInsert(targetPID: editor.pid, windowTitle: editor.title) - == KeyInjector.ResolvedInsert(text: " Second.", window: editor)) - } - - @Test("opaque editor: a changed title is a different field, so no phantom space") - func changedTitleDoesNotSeparate() { - // One browser process (or one Electron window) but a different tab or file — - // a shared pid alone must not carry spacing into an unrelated document. The - // new window is still remembered, so a *third* dictation into it separates. - let resolved = secondInsert(targetPID: editor.pid, windowTitle: "Untitled document — Docs") - #expect(resolved.text == "Second.") - #expect(resolved.window == KeyInjector.WindowIdentity(pid: editor.pid, title: "Untitled document — Docs")) - } - - @Test("no readable window title means no match and nothing to remember") - func missingTitleDoesNotSeparate() { - // Can't confirm it's the same window, so the fallback stays off rather than - // guessing — and with no identity to record, the next dictation can't match - // this one either. - #expect( - secondInsert(targetPID: editor.pid, windowTitle: nil) - == KeyInjector.ResolvedInsert(text: "Second.", window: nil)) - } - - @Test("a different target app never inherits the previous paste's spacing") - func changedTargetDoesNotSeparate() { - let resolved = secondInsert(targetPID: 777, windowTitle: editor.title) - #expect(resolved.text == "Second.") - #expect(resolved.window == KeyInjector.WindowIdentity(pid: 777, title: editor.title)) - } - - @Test("no captured target at all: nothing to match, nothing to remember") - func noTargetDoesNotSeparate() { - #expect( - secondInsert(targetPID: nil, windowTitle: editor.title) - == KeyInjector.ResolvedInsert(text: "Second.", window: nil)) - } - - @Test("AX prior text wins over the remembered paste, in the same window or not") - func priorTextWinsOverMemory() { - // The field is readable, so the caret's real neighbour decides — here it - // already ends in whitespace, which the remembered "First." would not have. - #expect(secondInsert(targetPID: editor.pid, windowTitle: editor.title, priorText: "Hello ").text == "Second.") - #expect(secondInsert(targetPID: 777, windowTitle: "Other", priorText: "Hello").text == " Second.") - } - - @Test("a first-ever insert has no memory to fall back on") - func firstInsertHasNoBasis() { - let resolved = KeyInjector.resolveInsert( - text: "First.", priorText: nil, windowTitle: editor.title, targetPID: editor.pid, - lastInserted: nil) - #expect(resolved == KeyInjector.ResolvedInsert(text: "First.", window: editor)) - } -} +#if os(macOS) + // Foundation for `pid_t`: the resolve-insert suite below names it, and the two + // pure text-rule suites this file started as needed no imports at all. + import Foundation + import Testing + + @testable import BlurtEngine + + @Suite("KeyInjector.withLeadingSeparator") + struct KeyInjectorLeadingSeparatorTests { + @Test("prepends a space when prior text doesn't end in whitespace") + func prependsSpace() { + #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.") == " Second.") + } + + @Test("no separator when prior text already ends in a space") + func priorEndsInSpace() { + #expect(KeyInjector.withLeadingSeparator("Second.", after: "First. ") == "Second.") + } + + @Test("no separator when prior text ends in a newline") + func priorEndsInNewline() { + #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\n") == "Second.") + } + + @Test("no leading space into an empty field (nil prior text)") + func nilPrior() { + #expect(KeyInjector.withLeadingSeparator("Second.", after: nil) == "Second.") + } + + @Test("no leading space when prior text is empty") + func emptyPrior() { + #expect(KeyInjector.withLeadingSeparator("Second.", after: "") == "Second.") + } + + @Test("doesn't double up when the new text already starts with whitespace") + func textStartsWithSpace() { + #expect(KeyInjector.withLeadingSeparator(" Second.", after: "First.") == " Second.") + } + + @Test("returns empty text unchanged") + func emptyText() { + #expect(KeyInjector.withLeadingSeparator("", after: "First.") == "") + } + + @Test("every whitespace class counts, not just space and newline") + func otherWhitespaceClasses() { + // The rules key on Character.isWhitespace: a trailing tab, carriage return, + // or non-breaking space suppresses the separator like a plain space does… + #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\t") == "Second.") + #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\r") == "Second.") + #expect(KeyInjector.withLeadingSeparator("Second.", after: "First.\u{00A0}") == "Second.") + // …and text already leading with a tab or non-breaking space isn't doubled. + #expect(KeyInjector.withLeadingSeparator("\tSecond.", after: "First.") == "\tSecond.") + #expect(KeyInjector.withLeadingSeparator("\u{00A0}Second.", after: "First.") == "\u{00A0}Second.") + } + } + + @Suite("KeyInjector.separatorBasis") + struct KeyInjectorSeparatorBasisTests { + @Test("AX-read prior text wins even when a prior paste is on record") + func priorTextWins() { + #expect( + KeyInjector.separatorBasis(priorText: "AX.", lastInserted: "Old.", sameWindow: false) == "AX.") + } + + @Test("falls back to the last paste when AX is opaque and the window is unchanged") + func opaqueSameWindowFallsBack() { + #expect( + KeyInjector.separatorBasis(priorText: nil, lastInserted: "First.", sameWindow: true) + == "First.") + } + + @Test("does not carry a prior paste across a different window") + func opaqueDifferentWindowNoFallback() { + #expect( + KeyInjector.separatorBasis(priorText: nil, lastInserted: "First.", sameWindow: false) == nil) + } + + @Test("no basis when AX is opaque and nothing was pasted yet") + func opaqueNoPriorPaste() { + #expect(KeyInjector.separatorBasis(priorText: nil, lastInserted: nil, sameWindow: true) == nil) + } + } + + /// `resolveInsert` — everything `performInsert` decides before it activates + /// anything: the text to write and the window to remember writing it into. The + /// same-window derivation used to be inline in `performInsert`, so these cases + /// could only be driven by scraping `NSWorkspace` for live applications (and one + /// of them by requiring two). Here the target is just a pid. + @Suite("KeyInjector.resolveInsert") + struct KeyInjectorResolveInsertTests { + private let editor = KeyInjector.WindowIdentity(pid: 501, title: "notes.txt — Editor") + + /// A second dictation with no AX prior text, resolved against `editor`. + private func secondInsert( + targetPID: pid_t?, windowTitle: String?, priorText: String? = nil + ) -> KeyInjector.ResolvedInsert { + KeyInjector.resolveInsert( + text: "Second.", priorText: priorText, windowTitle: windowTitle, targetPID: targetPID, + lastInserted: KeyInjector.ResolvedInsert(text: "First.", window: editor)) + } + + @Test("opaque editor: the same pid and title recovers the spacing from the last paste") + func sameWindowSeparates() { + // Two back-to-back dictations into the same VS Code file or Google Docs tab: + // AX gives no prior text, so what we pasted a moment ago is what precedes + // the caret. + #expect( + secondInsert(targetPID: editor.pid, windowTitle: editor.title) + == KeyInjector.ResolvedInsert(text: " Second.", window: editor)) + } + + @Test("opaque editor: a changed title is a different field, so no phantom space") + func changedTitleDoesNotSeparate() { + // One browser process (or one Electron window) but a different tab or file — + // a shared pid alone must not carry spacing into an unrelated document. The + // new window is still remembered, so a *third* dictation into it separates. + let resolved = secondInsert(targetPID: editor.pid, windowTitle: "Untitled document — Docs") + #expect(resolved.text == "Second.") + #expect(resolved.window == KeyInjector.WindowIdentity(pid: editor.pid, title: "Untitled document — Docs")) + } + + @Test("no readable window title means no match and nothing to remember") + func missingTitleDoesNotSeparate() { + // Can't confirm it's the same window, so the fallback stays off rather than + // guessing — and with no identity to record, the next dictation can't match + // this one either. + #expect( + secondInsert(targetPID: editor.pid, windowTitle: nil) + == KeyInjector.ResolvedInsert(text: "Second.", window: nil)) + } + + @Test("a different target app never inherits the previous paste's spacing") + func changedTargetDoesNotSeparate() { + let resolved = secondInsert(targetPID: 777, windowTitle: editor.title) + #expect(resolved.text == "Second.") + #expect(resolved.window == KeyInjector.WindowIdentity(pid: 777, title: editor.title)) + } + + @Test("no captured target at all: nothing to match, nothing to remember") + func noTargetDoesNotSeparate() { + #expect( + secondInsert(targetPID: nil, windowTitle: editor.title) + == KeyInjector.ResolvedInsert(text: "Second.", window: nil)) + } + + @Test("AX prior text wins over the remembered paste, in the same window or not") + func priorTextWinsOverMemory() { + // The field is readable, so the caret's real neighbour decides — here it + // already ends in whitespace, which the remembered "First." would not have. + #expect(secondInsert(targetPID: editor.pid, windowTitle: editor.title, priorText: "Hello ").text == "Second.") + #expect(secondInsert(targetPID: 777, windowTitle: "Other", priorText: "Hello").text == " Second.") + } + + @Test("a first-ever insert has no memory to fall back on") + func firstInsertHasNoBasis() { + let resolved = KeyInjector.resolveInsert( + text: "First.", priorText: nil, windowTitle: editor.title, targetPID: editor.pid, + lastInserted: nil) + #expect(resolved == KeyInjector.ResolvedInsert(text: "First.", window: editor)) + } + } +#endif diff --git a/Tests/BlurtEngineTests/KeyInjectorSystemActionsTests.swift b/Tests/BlurtEngineTests/KeyInjectorSystemActionsTests.swift index e683c745..de66ea96 100644 --- a/Tests/BlurtEngineTests/KeyInjectorSystemActionsTests.swift +++ b/Tests/BlurtEngineTests/KeyInjectorSystemActionsTests.swift @@ -1,95 +1,97 @@ -import AppKit -import CoreGraphics -import Testing +#if os(macOS) + import AppKit + import CoreGraphics + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -/// The system side of `KeyInjector`'s seams (`KeyInjector+SystemActions.swift`), -/// covering the parts that can be asserted without changing the state of the -/// machine running the suite. -/// -/// The line this suite draws: *reads* of process-global state are fair game -/// (`accessibilityTrusted`, the frontmost-app poll), and so is *building* a -/// CGEvent. What is deliberately left to running the real app is anything that -/// mutates the session — `activate` steals focus, and `postCmdV`'s two `.post` -/// calls would fire a live ⌘V into whatever the developer had open. That is the -/// same reason `check.sh` runs the XCUITest suite on CI only: it commandeers the -/// GUI session. -@Suite("KeyInjector system actions") -struct KeyInjectorSystemActionsTests { + /// The system side of `KeyInjector`'s seams (`KeyInjector+SystemActions.swift`), + /// covering the parts that can be asserted without changing the state of the + /// machine running the suite. + /// + /// The line this suite draws: *reads* of process-global state are fair game + /// (`accessibilityTrusted`, the frontmost-app poll), and so is *building* a + /// CGEvent. What is deliberately left to running the real app is anything that + /// mutates the session — `activate` steals focus, and `postCmdV`'s two `.post` + /// calls would fire a live ⌘V into whatever the developer had open. That is the + /// same reason `check.sh` runs the XCUITest suite on CI only: it commandeers the + /// GUI session. + @Suite("KeyInjector system actions") + struct KeyInjectorSystemActionsTests { - // MARK: - Cmd-V event construction + // MARK: - Cmd-V event construction - @Test("cmdVEvents builds a V key-down/key-up pair") - func cmdVEventsBuildsPair() throws { - let events = try #require(KeyInjector.cmdVEvents()) + @Test("cmdVEvents builds a V key-down/key-up pair") + func cmdVEventsBuildsPair() throws { + let events = try #require(KeyInjector.cmdVEvents()) - #expect(events.down.type == .keyDown) - #expect(events.up.type == .keyUp) - // 0x09 is kVK_ANSI_V. Asserted numerically because the Carbon constant isn't - // importable here, which is why the source spells it as a literal too — this - // is the check that keeps that literal honest. - #expect(events.down.getIntegerValueField(.keyboardEventKeycode) == 0x09) - #expect(events.up.getIntegerValueField(.keyboardEventKeycode) == 0x09) - } + #expect(events.down.type == .keyDown) + #expect(events.up.type == .keyUp) + // 0x09 is kVK_ANSI_V. Asserted numerically because the Carbon constant isn't + // importable here, which is why the source spells it as a literal too — this + // is the check that keeps that literal honest. + #expect(events.down.getIntegerValueField(.keyboardEventKeycode) == 0x09) + #expect(events.up.getIntegerValueField(.keyboardEventKeycode) == 0x09) + } - @Test("both Cmd-V events carry the command flag") - func cmdVEventsCarryCommand() throws { - let events = try #require(KeyInjector.cmdVEvents()) + @Test("both Cmd-V events carry the command flag") + func cmdVEventsCarryCommand() throws { + let events = try #require(KeyInjector.cmdVEvents()) - // A ⌘-less key-down is a plain "v" — it types a character into the target - // instead of pasting, which is the visible failure this pins. - #expect(events.down.flags.contains(.maskCommand)) - // And a ⌘-less key-up reads as the modifier having been released mid-chord. - #expect(events.up.flags.contains(.maskCommand)) - } + // A ⌘-less key-down is a plain "v" — it types a character into the target + // instead of pasting, which is the visible failure this pins. + #expect(events.down.flags.contains(.maskCommand)) + // And a ⌘-less key-up reads as the modifier having been released mid-chord. + #expect(events.up.flags.contains(.maskCommand)) + } - @Test("Cmd-V events carry no modifier beyond command") - func cmdVEventsCarryNoOtherModifier() throws { - let events = try #require(KeyInjector.cmdVEvents()) + @Test("Cmd-V events carry no modifier beyond command") + func cmdVEventsCarryNoOtherModifier() throws { + let events = try #require(KeyInjector.cmdVEvents()) - // ⌘⌥V and ⌘⇧V are "paste and match style" in most apps, and ⌃⌘V is bound - // elsewhere again — so a stray extra modifier doesn't fail loudly, it pastes - // the wrong way. `flags` is assigned (not OR-ed) in `cmdVEvents`, and this is - // what keeps it that way. - for flags in [events.down.flags, events.up.flags] { - #expect(!flags.contains(.maskAlternate)) - #expect(!flags.contains(.maskShift)) - #expect(!flags.contains(.maskControl)) - #expect(!flags.contains(.maskSecondaryFn)) + // ⌘⌥V and ⌘⇧V are "paste and match style" in most apps, and ⌃⌘V is bound + // elsewhere again — so a stray extra modifier doesn't fail loudly, it pastes + // the wrong way. `flags` is assigned (not OR-ed) in `cmdVEvents`, and this is + // what keeps it that way. + for flags in [events.down.flags, events.up.flags] { + #expect(!flags.contains(.maskAlternate)) + #expect(!flags.contains(.maskShift)) + #expect(!flags.contains(.maskControl)) + #expect(!flags.contains(.maskSecondaryFn)) + } } - } - // MARK: - Accessibility trust probe + // MARK: - Accessibility trust probe - @Test("accessibilityTrusted reports the process-wide AX trust state") - func accessibilityTrustedMatchesSystem() { - // The test host's trust state isn't ours to set, so the assertable claim is - // that the seam is a pass-through and not, say, a hard-coded `true` that would - // make `KeyInjector` skip its permission check in production. - #expect(KeyInjector.accessibilityTrusted() == AXIsProcessTrusted()) - } + @Test("accessibilityTrusted reports the process-wide AX trust state") + func accessibilityTrustedMatchesSystem() { + // The test host's trust state isn't ours to set, so the assertable claim is + // that the seam is a pass-through and not, say, a hard-coded `true` that would + // make `KeyInjector` skip its permission check in production. + #expect(KeyInjector.accessibilityTrusted() == AXIsProcessTrusted()) + } - // MARK: - Frontmost wait + // MARK: - Frontmost wait - @Test("waitUntilFrontmost reports failure for an app that never comes frontmost") - func waitUntilFrontmostGivesUp() async { - // The test host is a command-line process with no windows, so the window - // server never reports it frontmost — the deterministic "activation didn't - // land" case. `KeyInjector.activateTargetApp` turns this `false` into - // `.targetAppLost` rather than pasting into the wrong app. - #expect(await KeyInjector.waitUntilFrontmost(.current) == false) - } + @Test("waitUntilFrontmost reports failure for an app that never comes frontmost") + func waitUntilFrontmostGivesUp() async { + // The test host is a command-line process with no windows, so the window + // server never reports it frontmost — the deterministic "activation didn't + // land" case. `KeyInjector.activateTargetApp` turns this `false` into + // `.targetAppLost` rather than pasting into the wrong app. + #expect(await KeyInjector.waitUntilFrontmost(.current) == false) + } - @Test("waitUntilFrontmost gives up on a bounded deadline instead of hanging") - func waitUntilFrontmostIsBounded() async { - // Sits on the press→paste path, so an unbounded wait would freeze the paste, - // not just slow it. 350 ms budget; the ceiling leaves room for a loaded CI - // box's scheduling without being loose enough to pass an unbounded loop. - let clock = ContinuousClock() - let elapsed = await clock.measure { - _ = await KeyInjector.waitUntilFrontmost(.current) + @Test("waitUntilFrontmost gives up on a bounded deadline instead of hanging") + func waitUntilFrontmostIsBounded() async { + // Sits on the press→paste path, so an unbounded wait would freeze the paste, + // not just slow it. 350 ms budget; the ceiling leaves room for a loaded CI + // box's scheduling without being loose enough to pass an unbounded loop. + let clock = ContinuousClock() + let elapsed = await clock.measure { + _ = await KeyInjector.waitUntilFrontmost(.current) + } + #expect(elapsed < .seconds(3)) } - #expect(elapsed < .seconds(3)) } -} +#endif diff --git a/Tests/BlurtEngineTests/MemoryLeakTests.swift b/Tests/BlurtEngineTests/MemoryLeakTests.swift index d5d988e1..90617fa4 100644 --- a/Tests/BlurtEngineTests/MemoryLeakTests.swift +++ b/Tests/BlurtEngineTests/MemoryLeakTests.swift @@ -44,10 +44,12 @@ struct MemoryLeakTests { } } - @Test("KeyInjector deallocates") - func keyInjectorNoLeak() async { - await expectNoLeak("KeyInjector") { - KeyInjector() + #if os(macOS) + @Test("KeyInjector deallocates") + func keyInjectorNoLeak() async { + await expectNoLeak("KeyInjector") { + KeyInjector() + } } - } + #endif } diff --git a/Tests/BlurtEngineTests/MicLivenessTests.swift b/Tests/BlurtEngineTests/MicLivenessTests.swift index 4c0f7c95..6cc8f95a 100644 --- a/Tests/BlurtEngineTests/MicLivenessTests.swift +++ b/Tests/BlurtEngineTests/MicLivenessTests.swift @@ -1,37 +1,43 @@ -import CoreAudio import Foundation import Synchronization import Testing @testable import BlurtEngine +#if os(macOS) + import CoreAudio +#endif + /// The pure half of `MicCapture.start()`'s liveness gate: the transport-aware /// wait cap and the poll-until-the-recorder's-clock-advances loop. Driven /// against `TestClock` so the Bluetooth cap is exercised without waiting real /// seconds. @Suite("MicLiveness", .timeLimit(.minutes(1))) struct MicLivenessTests { - @Test("Bluetooth transports get the long cap; everything else the short one") - func transportTimeouts() { - // The profile switch is the whole reason the gate exists — both Bluetooth - // transport types must get the multi-second budget. - #expect( - MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBluetooth) - == MicLiveness.bluetoothTimeout) - #expect( - MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBluetoothLE) - == MicLiveness.bluetoothTimeout) - // Wired/built-in inputs deliver frames near-instantly; a long cap there - // would make a genuinely broken mic feel like a hang. - #expect( - MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBuiltIn) - == MicLiveness.defaultTimeout) - #expect( - MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeUSB) - == MicLiveness.defaultTimeout) - // An unreadable transport must not be treated as Bluetooth. - #expect(MicLiveness.timeout(forTransportType: nil) == MicLiveness.defaultTimeout) - } + #if os(macOS) + @Test("Bluetooth transports get the long cap; everything else the short one") + func transportTimeouts() { + // The profile switch is the whole reason the gate exists — both Bluetooth + // transport types must get the multi-second budget. (macOS-only: the + // transport constants are HAL symbols, and iOS never reads a transport.) + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBluetooth) + == MicLiveness.bluetoothTimeout) + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBluetoothLE) + == MicLiveness.bluetoothTimeout) + // Wired/built-in inputs deliver frames near-instantly; a long cap there + // would make a genuinely broken mic feel like a hang. + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeBuiltIn) + == MicLiveness.defaultTimeout) + #expect( + MicLiveness.timeout(forTransportType: kAudioDeviceTransportTypeUSB) + == MicLiveness.defaultTimeout) + // An unreadable transport must not be treated as Bluetooth. + #expect(MicLiveness.timeout(forTransportType: nil) == MicLiveness.defaultTimeout) + } + #endif @Test("already-advancing recorder clock confirms immediately, without sleeping") func immediateLiveness() async { @@ -108,43 +114,45 @@ struct MicLivenessTests { } } -/// The transport classification both the liveness cap and `MicCapture`'s tail -/// linger hang off. Pure and pinned here because `AudioRoute`, which reads the -/// raw value, needs real hardware and is excluded from the coverage gate — so -/// this is the only place the decision can be tested. -@Suite("AudioTransport") -struct AudioTransportTests { - @Test("both Bluetooth transport types count") - func bluetoothTransports() { - #expect(AudioTransport.isBluetooth(kAudioDeviceTransportTypeBluetooth)) - #expect(AudioTransport.isBluetooth(kAudioDeviceTransportTypeBluetoothLE)) - } +#if os(macOS) + /// The transport classification both the liveness cap and `MicCapture`'s tail + /// linger hang off. Pure and pinned here because `AudioRoute`, which reads the + /// raw value, needs real hardware and is excluded from the coverage gate — so + /// this is the only place the decision can be tested. + @Suite("AudioTransport") + struct AudioTransportTests { + @Test("both Bluetooth transport types count") + func bluetoothTransports() { + #expect(AudioTransport.isBluetooth(kAudioDeviceTransportTypeBluetooth)) + #expect(AudioTransport.isBluetooth(kAudioDeviceTransportTypeBluetoothLE)) + } - @Test("the tail linger is Bluetooth-only") - func tailLinger() { - // The other transport-conditional policy, here rather than in `MicCapture` - // so it is reachable by `swift test` at all — the capture actor needs real - // hardware and is excluded from the coverage gate. - #expect( - AudioTransport.tailLinger(forTransportType: kAudioDeviceTransportTypeBluetooth) - == AudioTransport.bluetoothTailLinger) - // `.zero`, not a small duration: `stop()` skips the sleep entirely on a - // wired input rather than awaiting a nominal one. - #expect(AudioTransport.tailLinger(forTransportType: kAudioDeviceTransportTypeBuiltIn) == .zero) - #expect(AudioTransport.tailLinger(forTransportType: nil) == .zero) - } + @Test("the tail linger is Bluetooth-only") + func tailLinger() { + // The other transport-conditional policy, here rather than in `MicCapture` + // so it is reachable by `swift test` at all — the capture actor needs real + // hardware and is excluded from the coverage gate. + #expect( + AudioTransport.tailLinger(forTransportType: kAudioDeviceTransportTypeBluetooth) + == AudioTransport.bluetoothTailLinger) + // `.zero`, not a small duration: `stop()` skips the sleep entirely on a + // wired input rather than awaiting a nominal one. + #expect(AudioTransport.tailLinger(forTransportType: kAudioDeviceTransportTypeBuiltIn) == .zero) + #expect(AudioTransport.tailLinger(forTransportType: nil) == .zero) + } - @Test("wired, built-in, and unreadable transports do not") - func nonBluetoothTransports() { - #expect(!AudioTransport.isBluetooth(kAudioDeviceTransportTypeBuiltIn)) - #expect(!AudioTransport.isBluetooth(kAudioDeviceTransportTypeUSB)) - #expect(!AudioTransport.isBluetooth(kAudioDeviceTransportTypeAggregate)) - // nil is the conservative answer for both consumers: the short wait cap and - // no tail linger. Padding every wired capture with a delay would be a worse - // regression than losing the tail on a device we couldn't classify. - #expect(!AudioTransport.isBluetooth(nil)) + @Test("wired, built-in, and unreadable transports do not") + func nonBluetoothTransports() { + #expect(!AudioTransport.isBluetooth(kAudioDeviceTransportTypeBuiltIn)) + #expect(!AudioTransport.isBluetooth(kAudioDeviceTransportTypeUSB)) + #expect(!AudioTransport.isBluetooth(kAudioDeviceTransportTypeAggregate)) + // nil is the conservative answer for both consumers: the short wait cap and + // no tail linger. Padding every wired capture with a delay would be a worse + // regression than losing the tail on a device we couldn't classify. + #expect(!AudioTransport.isBluetooth(nil)) + } } -} +#endif /// `Duration.milliseconds` backs the latency lines `MicCapture` logs for the /// liveness gap — the field evidence for whether the gate is doing anything — diff --git a/Tests/BlurtEngineTests/PermissionsCheckerTests.swift b/Tests/BlurtEngineTests/PermissionsCheckerTests.swift index 02d5c50f..9ba23e5c 100644 --- a/Tests/BlurtEngineTests/PermissionsCheckerTests.swift +++ b/Tests/BlurtEngineTests/PermissionsCheckerTests.swift @@ -1,80 +1,82 @@ -import Foundation -import Testing +#if os(macOS) + import Foundation + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -@Suite("PermissionsChecker") -struct PermissionsCheckerTests { + @Suite("PermissionsChecker") + struct PermissionsCheckerTests { - @Test("allGranted requires both microphone and accessibility") - func allGrantedLogic() { - #expect(PermissionStatus(microphone: true, accessibility: true).allGranted) - #expect(!PermissionStatus(microphone: true, accessibility: false).allGranted) - #expect(!PermissionStatus(microphone: false, accessibility: true).allGranted) - #expect(!PermissionStatus(microphone: false, accessibility: false).allGranted) - } + @Test("allGranted requires both microphone and accessibility") + func allGrantedLogic() { + #expect(PermissionStatus(microphone: true, accessibility: true).allGranted) + #expect(!PermissionStatus(microphone: true, accessibility: false).allGranted) + #expect(!PermissionStatus(microphone: false, accessibility: true).allGranted) + #expect(!PermissionStatus(microphone: false, accessibility: false).allGranted) + } - @Test("PermissionStatus is value-equatable") - func equatable() { - #expect( - PermissionStatus(microphone: true, accessibility: false) - == PermissionStatus(microphone: true, accessibility: false)) - #expect( - PermissionStatus(microphone: true, accessibility: false) - != PermissionStatus(microphone: false, accessibility: false)) - } + @Test("PermissionStatus is value-equatable") + func equatable() { + #expect( + PermissionStatus(microphone: true, accessibility: false) + == PermissionStatus(microphone: true, accessibility: false)) + #expect( + PermissionStatus(microphone: true, accessibility: false) + != PermissionStatus(microphone: false, accessibility: false)) + } - /// Every combination of the two probes, so the field each one feeds is pinned. - static let probeCases: [(mic: Bool, accessibility: Bool)] = [ - (true, true), (true, false), (false, true), (false, false), - ] + /// Every combination of the two probes, so the field each one feeds is pinned. + static let probeCases: [(mic: Bool, accessibility: Bool)] = [ + (true, true), (true, false), (false, true), (false, false), + ] - @Test("check reports each probe in its own field", arguments: probeCases) - func checkWiresProbesToFields(mic: Bool, accessibility: Bool) { - // The one thing `check()` does that can be wrong: which probe feeds which - // field. Driving both probes pins it — swap them and half these rows fail. - #expect( - PermissionsChecker.check(micGranted: { mic }, axTrusted: { accessibility }) - == PermissionStatus(microphone: mic, accessibility: accessibility)) - } - - @Test("check consults both probes exactly once") - func checkReadsEachProbeOnce() { - // Each real probe is a TCC read on the permission-poll timer; re-reading one - // per call would double that traffic for a struct with two fields. - let micReads = Counter() - let axReads = Counter() - _ = PermissionsChecker.check( - micGranted: { - _ = micReads.next() - return true - }, - axTrusted: { - _ = axReads.next() - return true - }) - #expect(micReads.value == 1) - #expect(axReads.value == 1) - } + @Test("check reports each probe in its own field", arguments: probeCases) + func checkWiresProbesToFields(mic: Bool, accessibility: Bool) { + // The one thing `check()` does that can be wrong: which probe feeds which + // field. Driving both probes pins it — swap them and half these rows fail. + #expect( + PermissionsChecker.check(micGranted: { mic }, axTrusted: { accessibility }) + == PermissionStatus(microphone: mic, accessibility: accessibility)) + } - /// Smoke tests, deliberately assertion-free: both entry points read - /// process-global TCC state this host can't set, so all they can establish is - /// that the real probes run without throwing or prompting. The behaviour they - /// compose is covered above, against injected probes. - @Suite("PermissionsChecker smoke") - struct SmokeTests { - @Test("the production check() runs against the real probes without prompting") - func productionCheckRuns() { - _ = PermissionsChecker.check() + @Test("check consults both probes exactly once") + func checkReadsEachProbeOnce() { + // Each real probe is a TCC read on the permission-poll timer; re-reading one + // per call would double that traffic for a struct with two fields. + let micReads = Counter() + let axReads = Counter() + _ = PermissionsChecker.check( + micGranted: { + _ = micReads.next() + return true + }, + axTrusted: { + _ = axReads.next() + return true + }) + #expect(micReads.value == 1) + #expect(axReads.value == 1) } - @Test("forceAccessibilityActivity runs without prompting") - @MainActor - func forceAccessibilityActivityRuns() { - // Best-effort, side-effect-light (a read-only AX query against another - // process). This is the no-prompt half of the Accessibility flow — - // `openAccessibilitySettings` adds the trust prompt. - PermissionsChecker.forceAccessibilityActivity() + /// Smoke tests, deliberately assertion-free: both entry points read + /// process-global TCC state this host can't set, so all they can establish is + /// that the real probes run without throwing or prompting. The behaviour they + /// compose is covered above, against injected probes. + @Suite("PermissionsChecker smoke") + struct SmokeTests { + @Test("the production check() runs against the real probes without prompting") + func productionCheckRuns() { + _ = PermissionsChecker.check() + } + + @Test("forceAccessibilityActivity runs without prompting") + @MainActor + func forceAccessibilityActivityRuns() { + // Best-effort, side-effect-light (a read-only AX query against another + // process). This is the no-prompt half of the Accessibility flow — + // `openAccessibilitySettings` adds the trust prompt. + PermissionsChecker.forceAccessibilityActivity() + } } } -} +#endif diff --git a/Tests/BlurtEngineTests/SigningIdentityMigrationTests.swift b/Tests/BlurtEngineTests/SigningIdentityMigrationTests.swift index b50fa30a..2b913895 100644 --- a/Tests/BlurtEngineTests/SigningIdentityMigrationTests.swift +++ b/Tests/BlurtEngineTests/SigningIdentityMigrationTests.swift @@ -1,111 +1,113 @@ -import Testing +#if os(macOS) + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -@Suite struct SigningIdentityMigrationTests { - private typealias Migration = SigningIdentityMigration + @Suite struct SigningIdentityMigrationTests { + private typealias Migration = SigningIdentityMigration - // The identities `SigningIdentity.current()` produces, one per way Blurt is - // signed. The migration treats them alike — it only ever compares strings — but - // which pairs of them differ is the whole reason the cases below exist, so they - // are spelled out rather than left implied. - // - // A dev build's requirement is explicit and team-based (stamped by the - // `project.yml` post-build install, stable across cert rotation); a release - // carries codesign's default, which names the leaf certificate — so re-issuing - // that certificate produces `reissuedRelease` and orphans the grant. - private static let prefix = SigningIdentity.requirementPrefix - private static let anchor = "identifier \"dev.alex.blurt\" and anchor apple generic" - private static let devBuild = "\(prefix)\(anchor) and certificate leaf[subject.OU] = \"B2VQF7Q2QY\"" - private static let release = "\(prefix)\(anchor) and certificate leaf[subject.CN] = \"Dev ID 2024\"" - private static let reissuedRelease = - "\(prefix)\(anchor) and certificate leaf[subject.CN] = \"Dev ID 2029\"" + // The identities `SigningIdentity.current()` produces, one per way Blurt is + // signed. The migration treats them alike — it only ever compares strings — but + // which pairs of them differ is the whole reason the cases below exist, so they + // are spelled out rather than left implied. + // + // A dev build's requirement is explicit and team-based (stamped by the + // `project.yml` post-build install, stable across cert rotation); a release + // carries codesign's default, which names the leaf certificate — so re-issuing + // that certificate produces `reissuedRelease` and orphans the grant. + private static let prefix = SigningIdentity.requirementPrefix + private static let anchor = "identifier \"dev.alex.blurt\" and anchor apple generic" + private static let devBuild = "\(prefix)\(anchor) and certificate leaf[subject.OU] = \"B2VQF7Q2QY\"" + private static let release = "\(prefix)\(anchor) and certificate leaf[subject.CN] = \"Dev ID 2024\"" + private static let reissuedRelease = + "\(prefix)\(anchor) and certificate leaf[subject.CN] = \"Dev ID 2029\"" - // decide(): unsigned builds never act. - @Test func unsignedNeverActs() { - #expect(Migration.decide(lastIdentity: nil, currentIdentity: nil, isTrusted: false) == .noAction) - #expect(Migration.decide(lastIdentity: "OLD", currentIdentity: nil, isTrusted: true) == .noAction) - } + // decide(): unsigned builds never act. + @Test func unsignedNeverActs() { + #expect(Migration.decide(lastIdentity: nil, currentIdentity: nil, isTrusted: false) == .noAction) + #expect(Migration.decide(lastIdentity: "OLD", currentIdentity: nil, isTrusted: true) == .noAction) + } - // decide(): same identity as last launch is steady state, regardless of trust. - @Test func steadyStateIsNoAction() { - #expect(Migration.decide(lastIdentity: "NEW", currentIdentity: "NEW", isTrusted: false) == .noAction) - #expect(Migration.decide(lastIdentity: "NEW", currentIdentity: "NEW", isTrusted: true) == .noAction) - } + // decide(): same identity as last launch is steady state, regardless of trust. + @Test func steadyStateIsNoAction() { + #expect(Migration.decide(lastIdentity: "NEW", currentIdentity: "NEW", isTrusted: false) == .noAction) + #expect(Migration.decide(lastIdentity: "NEW", currentIdentity: "NEW", isTrusted: true) == .noAction) + } - // decide(): no marker (every build predating it) is treated as changed. - @Test func firstSeenUntrustedResets() { - let decision = Migration.decide(lastIdentity: nil, currentIdentity: "NEW", isTrusted: false) - #expect(decision == .resetThenRecord("NEW")) - } - @Test func firstSeenTrustedRecordsOnly() { - let decision = Migration.decide(lastIdentity: nil, currentIdentity: "NEW", isTrusted: true) - #expect(decision == .record("NEW")) - } + // decide(): no marker (every build predating it) is treated as changed. + @Test func firstSeenUntrustedResets() { + let decision = Migration.decide(lastIdentity: nil, currentIdentity: "NEW", isTrusted: false) + #expect(decision == .resetThenRecord("NEW")) + } + @Test func firstSeenTrustedRecordsOnly() { + let decision = Migration.decide(lastIdentity: nil, currentIdentity: "NEW", isTrusted: true) + #expect(decision == .record("NEW")) + } - // decide(): any identity change at all. - @Test func identityChangedUntrustedResets() { - let decision = Migration.decide(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: false) - #expect(decision == .resetThenRecord("NEW")) - } - @Test func identityChangedTrustedRecordsOnly() { - let decision = Migration.decide(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: true) - #expect(decision == .record("NEW")) - } + // decide(): any identity change at all. + @Test func identityChangedUntrustedResets() { + let decision = Migration.decide(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: false) + #expect(decision == .resetThenRecord("NEW")) + } + @Test func identityChangedTrustedRecordsOnly() { + let decision = Migration.decide(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: true) + #expect(decision == .record("NEW")) + } - // decide(): the case this exists for now that debug builds carry their own - // bundle id. Re-issuing the Developer ID certificate (RELEASE.md's rotation - // procedure) changes the leaf its default requirement names, so every installed - // user's grant is suddenly pinned to a requirement the update cannot satisfy — - // switched on in System Settings, denied by `AXIsProcessTrusted()`, with no way - // past the wizard. Nothing at signing time can pre-empt it: the requirement was - // handed to `tccd` before the rotation existed. - @Test func reissuedReleaseCertificateResets() { - let decision = Migration.decide( - lastIdentity: Self.release, currentIdentity: Self.reissuedRelease, isTrusted: false) - #expect(decision == .resetThenRecord(Self.reissuedRelease)) - } + // decide(): the case this exists for now that debug builds carry their own + // bundle id. Re-issuing the Developer ID certificate (RELEASE.md's rotation + // procedure) changes the leaf its default requirement names, so every installed + // user's grant is suddenly pinned to a requirement the update cannot satisfy — + // switched on in System Settings, denied by `AXIsProcessTrusted()`, with no way + // past the wizard. Nothing at signing time can pre-empt it: the requirement was + // handed to `tccd` before the rotation existed. + @Test func reissuedReleaseCertificateResets() { + let decision = Migration.decide( + lastIdentity: Self.release, currentIdentity: Self.reissuedRelease, isTrusted: false) + #expect(decision == .resetThenRecord(Self.reissuedRelease)) + } - // decide(): the flip side — the identity must be *stable* for the loop that - // rebuilds the same way over and over. Two `dev-build.sh` runs pin the same - // team-based requirement, so a working grant survives a rebuild; nothing here - // may reset on every launch. - @Test func rebuildingTheSameWayIsNoAction() { - #expect( - Migration.decide(lastIdentity: Self.devBuild, currentIdentity: Self.devBuild, isTrusted: true) - == .noAction) - #expect( - Migration.decide(lastIdentity: Self.release, currentIdentity: Self.release, isTrusted: false) - == .noAction) - } + // decide(): the flip side — the identity must be *stable* for the loop that + // rebuilds the same way over and over. Two `dev-build.sh` runs pin the same + // team-based requirement, so a working grant survives a rebuild; nothing here + // may reset on every launch. + @Test func rebuildingTheSameWayIsNoAction() { + #expect( + Migration.decide(lastIdentity: Self.devBuild, currentIdentity: Self.devBuild, isTrusted: true) + == .noAction) + #expect( + Migration.decide(lastIdentity: Self.release, currentIdentity: Self.release, isTrusted: false) + == .noAction) + } - // run(): steady state / unsigned persist nothing and never reset. - @Test func runNoActionPersistsNothingAndSkipsReset() { - let out = Migration.run(lastIdentity: "NEW", currentIdentity: "NEW", isTrusted: false) { - Issue.record("reset must not run for .noAction") - return true + // run(): steady state / unsigned persist nothing and never reset. + @Test func runNoActionPersistsNothingAndSkipsReset() { + let out = Migration.run(lastIdentity: "NEW", currentIdentity: "NEW", isTrusted: false) { + Issue.record("reset must not run for .noAction") + return true + } + #expect(out == nil) } - #expect(out == nil) - } - // run(): a trusted identity change records without resetting. - @Test func runRecordsWithoutResetting() { - var didReset = false - let out = Migration.run(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: true) { - didReset = true - return true + // run(): a trusted identity change records without resetting. + @Test func runRecordsWithoutResetting() { + var didReset = false + let out = Migration.run(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: true) { + didReset = true + return true + } + #expect(out == "NEW") + #expect(didReset == false) } - #expect(out == "NEW") - #expect(didReset == false) - } - // run(): an untrusted identity change resets, then persists only on success. - @Test func runResetsThenPersistsOnSuccess() { - let out = Migration.run(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: false) { true } - #expect(out == "NEW") - } - @Test func runDoesNotPersistWhenResetFails() { - let out = Migration.run(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: false) { false } - #expect(out == nil) + // run(): an untrusted identity change resets, then persists only on success. + @Test func runResetsThenPersistsOnSuccess() { + let out = Migration.run(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: false) { true } + #expect(out == "NEW") + } + @Test func runDoesNotPersistWhenResetFails() { + let out = Migration.run(lastIdentity: "OLD", currentIdentity: "NEW", isTrusted: false) { false } + #expect(out == nil) + } } -} +#endif diff --git a/Tests/BlurtEngineTests/SigningIdentityTests.swift b/Tests/BlurtEngineTests/SigningIdentityTests.swift index d93d58a5..f7b41bb3 100644 --- a/Tests/BlurtEngineTests/SigningIdentityTests.swift +++ b/Tests/BlurtEngineTests/SigningIdentityTests.swift @@ -1,59 +1,61 @@ -import Foundation -import Security -import Testing +#if os(macOS) + import Foundation + import Security + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -@Suite("SigningIdentity") -struct SigningIdentityTests { + @Suite("SigningIdentity") + struct SigningIdentityTests { - // The value the migration compares must be a property of the *binary*, not of - // the moment it was read — an identity that varied per call would reset the - // Accessibility grant on every launch. - @Test("current() is stable across reads") - func currentIsStable() { - #expect(SigningIdentity.current() == SigningIdentity.current()) - } + // The value the migration compares must be a property of the *binary*, not of + // the moment it was read — an identity that varied per call would reset the + // Accessibility grant on every launch. + @Test("current() is stable across reads") + func currentIsStable() { + #expect(SigningIdentity.current() == SigningIdentity.current()) + } - // What the test host reports depends on how it was signed (ad-hoc under - // `swift test`, team-signed under Xcode), so this asserts the shape of whatever - // this host gives rather than a literal — including the shape of "nothing", - // which is the answer for the ad-hoc host and the one the migration reads as - // "no action". - @Test("current() is either absent or a namespaced requirement") - func currentIsNamespacedWhenPresent() { - guard let identity = SigningIdentity.current() else { - return // ad-hoc or unsigned host: refusing to answer is the contract + // What the test host reports depends on how it was signed (ad-hoc under + // `swift test`, team-signed under Xcode), so this asserts the shape of whatever + // this host gives rather than a literal — including the shape of "nothing", + // which is the answer for the ad-hoc host and the one the migration reads as + // "no action". + @Test("current() is either absent or a namespaced requirement") + func currentIsNamespacedWhenPresent() { + guard let identity = SigningIdentity.current() else { + return // ad-hoc or unsigned host: refusing to answer is the contract + } + #expect(identity.hasPrefix(SigningIdentity.requirementPrefix)) + // Never a bare Team ID (10 alphanumerics, no colon): the marker recorded by + // builds that predate this shape has to stay distinguishable from one + // recorded now, or an upgrade would read as steady state. + #expect(identity.contains(":")) + #expect(identity.count > SigningIdentity.requirementPrefix.count) } - #expect(identity.hasPrefix(SigningIdentity.requirementPrefix)) - // Never a bare Team ID (10 alphanumerics, no colon): the marker recorded by - // builds that predate this shape has to stay distinguishable from one - // recorded now, or an upgrade would read as steady state. - #expect(identity.contains(":")) - #expect(identity.count > SigningIdentity.requirementPrefix.count) - } - // The `Security` handshake is the one part of this file that isn't pure logic, - // and the host's own signature can't exercise it (ad-hoc under `swift test`). - // `/bin/ls` can: it is present on every Mac and Apple-signed, so its designated - // requirement is both readable and known. If this returns nil the migration - // silently degrades to "never act" — a grant orphaned by a re-issued release - // certificate would then strand every user with no way past the wizard. - @Test("a designated requirement can actually be read") - func designatedRequirementIsReadable() throws { - var code: SecStaticCode? - let status = SecStaticCodeCreateWithPath( - URL(fileURLWithPath: "/bin/ls") as CFURL, SecCSFlags(), &code) - #expect(status == errSecSuccess) - let staticCode = try #require(code) - let requirement = try #require(SigningIdentity.designatedRequirement(of: staticCode)) - #expect(requirement.contains("anchor apple")) - } + // The `Security` handshake is the one part of this file that isn't pure logic, + // and the host's own signature can't exercise it (ad-hoc under `swift test`). + // `/bin/ls` can: it is present on every Mac and Apple-signed, so its designated + // requirement is both readable and known. If this returns nil the migration + // silently degrades to "never act" — a grant orphaned by a re-issued release + // certificate would then strand every user with no way past the wizard. + @Test("a designated requirement can actually be read") + func designatedRequirementIsReadable() throws { + var code: SecStaticCode? + let status = SecStaticCodeCreateWithPath( + URL(fileURLWithPath: "/bin/ls") as CFURL, SecCSFlags(), &code) + #expect(status == errSecSuccess) + let staticCode = try #require(code) + let requirement = try #require(SigningIdentity.designatedRequirement(of: staticCode)) + #expect(requirement.contains("anchor apple")) + } - // The process's own code object has to resolve, or `current()` can only ever - // answer nil and the migration is dead weight in every build. - @Test("this process's code object resolves") - func selfStaticCodeResolves() throws { - _ = try #require(SigningIdentity.staticCodeForSelf()) + // The process's own code object has to resolve, or `current()` can only ever + // answer nil and the migration is dead weight in every build. + @Test("this process's code object resolves") + func selfStaticCodeResolves() throws { + _ = try #require(SigningIdentity.staticCodeForSelf()) + } } -} +#endif diff --git a/Tests/BlurtEngineTests/Stubs/FakeClipboard.swift b/Tests/BlurtEngineTests/Stubs/FakeClipboard.swift index de0b9617..563feffd 100644 --- a/Tests/BlurtEngineTests/Stubs/FakeClipboard.swift +++ b/Tests/BlurtEngineTests/Stubs/FakeClipboard.swift @@ -1,58 +1,60 @@ -import Foundation -import Synchronization +#if os(macOS) + import Foundation + import Synchronization -@testable import BlurtEngine + @testable import BlurtEngine -/// In-memory `ClipboardAccess` for tests: holds a single plain string without -/// touching the system pasteboard, and lets a test simulate another process -/// overwriting the clipboard via `externalWrite`. Shared by the KeyInjector -/// insert and delete suites. A `Mutex` guards the state, making the `Sendable` -/// conformance compiler-checked. -/// -/// The narrowed `ClipboardAccess` seam keeps the pasteboard's change-count and -/// restore-if-unchanged policy inside `SystemClipboard`, so this fake only has -/// to hold a string and a write counter — it never re-derives that policy. -final class FakeClipboard: ClipboardAccess, Sendable { - private struct State { - var text: String? - var count = 0 - } - private let state: Mutex - - init(string: String?) { - state = Mutex(State(text: string)) - } + /// In-memory `ClipboardAccess` for tests: holds a single plain string without + /// touching the system pasteboard, and lets a test simulate another process + /// overwriting the clipboard via `externalWrite`. Shared by the KeyInjector + /// insert and delete suites. A `Mutex` guards the state, making the `Sendable` + /// conformance compiler-checked. + /// + /// The narrowed `ClipboardAccess` seam keeps the pasteboard's change-count and + /// restore-if-unchanged policy inside `SystemClipboard`, so this fake only has + /// to hold a string and a write counter — it never re-derives that policy. + final class FakeClipboard: ClipboardAccess, Sendable { + private struct State { + var text: String? + var count = 0 + } + private let state: Mutex - func write(_ text: String) { - state.withLock { - $0.text = text - $0.count += 1 + init(string: String?) { + state = Mutex(State(text: string)) } - } - func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void { - let (saved, mark) = state.withLock { s -> (String?, Int) in - let previous = s.text - s.text = text - s.count += 1 - return (previous, s.count) + func write(_ text: String) { + state.withLock { + $0.text = text + $0.count += 1 + } } - return { [self] in - state.withLock { s in - // Another writer bumped the count during the settle window — leave their - // newer contents alone (mirrors SystemClipboard's changeCount guard). - guard s.count == mark else { return } - s.text = saved + + func writeAndPrepareRestore(_ text: String) -> @Sendable () -> Void { + let (saved, mark) = state.withLock { s -> (String?, Int) in + let previous = s.text + s.text = text s.count += 1 + return (previous, s.count) + } + return { [self] in + state.withLock { s in + // Another writer bumped the count during the settle window — leave their + // newer contents alone (mirrors SystemClipboard's changeCount guard). + guard s.count == mark else { return } + s.text = saved + s.count += 1 + } } } - } - /// Simulate another process writing the clipboard mid-paste. - func externalWrite(_ text: String) { write(text) } + /// Simulate another process writing the clipboard mid-paste. + func externalWrite(_ text: String) { write(text) } - /// Current plain-string content, for assertions. - var string: String? { - state.withLock { $0.text } + /// Current plain-string content, for assertions. + var string: String? { + state.withLock { $0.text } + } } -} +#endif diff --git a/Tests/BlurtEngineTests/Stubs/InjectorTestSupport.swift b/Tests/BlurtEngineTests/Stubs/InjectorTestSupport.swift index 181b892f..ebcf44de 100644 --- a/Tests/BlurtEngineTests/Stubs/InjectorTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/InjectorTestSupport.swift @@ -1,115 +1,117 @@ -import AppKit -import Synchronization -import Testing +#if os(macOS) + import AppKit + import Synchronization + import Testing -@testable import BlurtEngine + @testable import BlurtEngine -/// Shared fixtures for the `KeyInjector.insert` suites (the main insert suite -/// and the fallback/cancel suite live in separate files to stay within the -/// lint file-length budget, but drive the injector the same way). -/// -/// The boxes are classes over `Mutex` (not actors) because they're poked from -/// synchronous `@Sendable` seams like `postPaste`; the `Mutex` makes each -/// `Sendable` conformance compiler-checked instead of `@unchecked`-asserted. + /// Shared fixtures for the `KeyInjector.insert` suites (the main insert suite + /// and the fallback/cancel suite live in separate files to stay within the + /// lint file-length budget, but drive the injector the same way). + /// + /// The boxes are classes over `Mutex` (not actors) because they're poked from + /// synchronous `@Sendable` seams like `postPaste`; the `Mutex` makes each + /// `Sendable` conformance compiler-checked instead of `@unchecked`-asserted. -/// Some live application to stand in as the captured paste target. -func liveTargetApp() throws -> NSRunningApplication { - try #require( - NSWorkspace.shared.runningApplications.first { - $0.processIdentifier > 0 && !$0.isTerminated - }) -} - -/// A `KeyInjector` wired to an in-memory clipboard, with `postPaste` recording -/// each pasted string (captured after `setString`, before the deferred -/// restore) into the returned box — for the end-to-end case that pins `insert` -/// threading its resolution through to the paste. (The continuity rules -/// themselves are cases of `resolveInsert` and need no injector at all.) -func makeRecordingInjector() -> (injector: KeyInjector, pasted: StringListBox) { - let clip = FakeClipboard(string: nil) - let pasted = StringListBox() - let injector = KeyInjector( - pasteSettleDuration: .zero, - postPaste: { - pasted.append(clip.string) - return true - }, - // Stub the activation. Omitting this defaulted to `KeyInjector.activate`, a - // REAL `NSRunningApplication.activate()` — so this test yanked the - // developer's foreground app, and failed spuriously whenever - // `liveTargetApp()`'s unordered pick landed on a background-only process - // whose activate() returns false (the injector then throws `.targetAppLost` - // for reasons unrelated to separator logic). It needs a stable non-nil app - // identity, not activation. - activateTarget: { _ in true }, - clipboard: clip) - return (injector, pasted) -} + /// Some live application to stand in as the captured paste target. + func liveTargetApp() throws -> NSRunningApplication { + try #require( + NSWorkspace.shared.runningApplications.first { + $0.processIdentifier > 0 && !$0.isTerminated + }) + } -/// One-shot async gate: `wait()` suspends until `open()` is called. Tolerates -/// `open()` racing ahead of `wait()` (the waiter then returns immediately), and -/// any number of concurrent waiters — `Gate`, which is built from a pair of -/// these, needs that for the stubs whose blocked method is called twice by the -/// regression under test. -/// -/// `open()` is synchronous (a `Mutex`-guarded class, not an actor) because the -/// seams that trip these gates are synchronous `@Sendable` closures like -/// `KeyInjector.postPaste`. -final class AsyncGate: Sendable { - private struct State { - var waiters: [CheckedContinuation] = [] - var opened = false + /// A `KeyInjector` wired to an in-memory clipboard, with `postPaste` recording + /// each pasted string (captured after `setString`, before the deferred + /// restore) into the returned box — for the end-to-end case that pins `insert` + /// threading its resolution through to the paste. (The continuity rules + /// themselves are cases of `resolveInsert` and need no injector at all.) + func makeRecordingInjector() -> (injector: KeyInjector, pasted: StringListBox) { + let clip = FakeClipboard(string: nil) + let pasted = StringListBox() + let injector = KeyInjector( + pasteSettleDuration: .zero, + postPaste: { + pasted.append(clip.string) + return true + }, + // Stub the activation. Omitting this defaulted to `KeyInjector.activate`, a + // REAL `NSRunningApplication.activate()` — so this test yanked the + // developer's foreground app, and failed spuriously whenever + // `liveTargetApp()`'s unordered pick landed on a background-only process + // whose activate() returns false (the injector then throws `.targetAppLost` + // for reasons unrelated to separator logic). It needs a stable non-nil app + // identity, not activation. + activateTarget: { _ in true }, + clipboard: clip) + return (injector, pasted) } - private let state = Mutex(State()) - func wait() async { - await withCheckedContinuation { cont in - let openedAlready = state.withLock { s -> Bool in - if s.opened { return true } - s.waiters.append(cont) - return false + /// One-shot async gate: `wait()` suspends until `open()` is called. Tolerates + /// `open()` racing ahead of `wait()` (the waiter then returns immediately), and + /// any number of concurrent waiters — `Gate`, which is built from a pair of + /// these, needs that for the stubs whose blocked method is called twice by the + /// regression under test. + /// + /// `open()` is synchronous (a `Mutex`-guarded class, not an actor) because the + /// seams that trip these gates are synchronous `@Sendable` closures like + /// `KeyInjector.postPaste`. + final class AsyncGate: Sendable { + private struct State { + var waiters: [CheckedContinuation] = [] + var opened = false + } + private let state = Mutex(State()) + + func wait() async { + await withCheckedContinuation { cont in + let openedAlready = state.withLock { s -> Bool in + if s.opened { return true } + s.waiters.append(cont) + return false + } + if openedAlready { cont.resume() } } - if openedAlready { cont.resume() } } - } - func open() { - let waiters = state.withLock { s -> [CheckedContinuation] in - s.opened = true - let pending = s.waiters - s.waiters.removeAll() - return pending + func open() { + let waiters = state.withLock { s -> [CheckedContinuation] in + s.opened = true + let pending = s.waiters + s.waiters.removeAll() + return pending + } + for waiter in waiters { waiter.resume() } } - for waiter in waiters { waiter.resume() } } -} -/// Thread-safe ordered list of strings recorded inside a `@Sendable` closure, -/// for asserting the sequence of texts a test observed being pasted. Not a -/// `ValueBox<[String]>`: the atomic append is the point — appending through a -/// get-then-set property would race two recorders against each other. -final class StringListBox: Sendable { - private let items = Mutex<[String]>([]) - func append(_ value: String?) { - items.withLock { $0.append(value ?? "") } - } - var values: [String] { - items.withLock { $0 } + /// Thread-safe ordered list of strings recorded inside a `@Sendable` closure, + /// for asserting the sequence of texts a test observed being pasted. Not a + /// `ValueBox<[String]>`: the atomic append is the point — appending through a + /// get-then-set property would race two recorders against each other. + final class StringListBox: Sendable { + private let items = Mutex<[String]>([]) + func append(_ value: String?) { + items.withLock { $0.append(value ?? "") } + } + var values: [String] { + items.withLock { $0 } + } } -} -/// Thread-safe single-value cell for capturing an arbitrary value written inside -/// a `@Sendable` closure and reading it back after the awaited call returns — -/// also how a closure holds the handle of the very task executing it, so it can -/// cancel it (a `Task` is `Sendable`, so that needs no separate box). -final class ValueBox: Sendable { - private let stored: Mutex - init(_ initial: T) { stored = Mutex(initial) } - /// One settable property rather than a `value` getter beside a `set(_:)` — the - /// `Mutex` is what makes the class `Sendable`, so both accessors can go through - /// it and callers read as ordinary assignment. - var value: T { - get { stored.withLock { $0 } } - set { stored.withLock { $0 = newValue } } + /// Thread-safe single-value cell for capturing an arbitrary value written inside + /// a `@Sendable` closure and reading it back after the awaited call returns — + /// also how a closure holds the handle of the very task executing it, so it can + /// cancel it (a `Task` is `Sendable`, so that needs no separate box). + final class ValueBox: Sendable { + private let stored: Mutex + init(_ initial: T) { stored = Mutex(initial) } + /// One settable property rather than a `value` getter beside a `set(_:)` — the + /// `Mutex` is what makes the class `Sendable`, so both accessors can go through + /// it and callers read as ordinary assignment. + var value: T { + get { stored.withLock { $0 } } + set { stored.withLock { $0 = newValue } } + } } -} +#endif diff --git a/Tests/BlurtEngineTests/Stubs/StubInjector.swift b/Tests/BlurtEngineTests/Stubs/StubInjector.swift index 0c5f2ef6..2ccdc1ba 100644 --- a/Tests/BlurtEngineTests/Stubs/StubInjector.swift +++ b/Tests/BlurtEngineTests/Stubs/StubInjector.swift @@ -1,8 +1,11 @@ -import AppKit import Foundation @testable import BlurtEngine +#if os(macOS) + import AppKit +#endif + actor StubInjector: InjectorProtocol { var inserted: [String] = [] /// The `priorText` and `windowTitle` passed alongside each `insert`: the two @@ -20,6 +23,8 @@ actor StubInjector: InjectorProtocol { insertedPrior.append(priorText) insertedWindowTitles.append(windowTitle) } - func setTargetApp(_ app: NSRunningApplication?) async {} + #if os(macOS) + func setTargetApp(_ app: NSRunningApplication?) async {} + #endif func setError(_ error: (any Error & Sendable)?) { self.error = error } } diff --git a/Tests/BlurtEngineTests/SystemClipboardTests.swift b/Tests/BlurtEngineTests/SystemClipboardTests.swift index 1564d87a..fa46eee3 100644 --- a/Tests/BlurtEngineTests/SystemClipboardTests.swift +++ b/Tests/BlurtEngineTests/SystemClipboardTests.swift @@ -1,211 +1,213 @@ -import AppKit -import Foundation -import Testing - -@testable import BlurtEngine - -/// Covers `SystemClipboard`, the real-`NSPasteboard` implementation of the -/// `ClipboardAccess` seam (`KeyInjector` uses a fake in its own tests). These -/// run synchronously with no settle window, so they don't race other processes' -/// clipboard activity; `.serialized` because they touch `NSPasteboard.general`. -@Suite("SystemClipboard", .serialized) -struct SystemClipboardTests { - - /// Snapshot/restore the user's clipboard around a test body so the suite leaves - /// the real pasteboard as it found it. - /// - /// Uses the full `snapshot()`/`restore()` primitives rather than just the string - /// flavor: snapshotting only `.string` and then clearing unconditionally meant - /// running these tests wiped a developer's real clipboard whenever it held an - /// image, a file, or styled text. - private func withClipboardRestored(_ body: () throws -> Void) rethrows { - let clip = SystemClipboard() - let saved = clip.snapshot() - defer { - if let saved { clip.restore(saved) } +#if os(macOS) + import AppKit + import Foundation + import Testing + + @testable import BlurtEngine + + /// Covers `SystemClipboard`, the real-`NSPasteboard` implementation of the + /// `ClipboardAccess` seam (`KeyInjector` uses a fake in its own tests). These + /// run synchronously with no settle window, so they don't race other processes' + /// clipboard activity; `.serialized` because they touch `NSPasteboard.general`. + @Suite("SystemClipboard", .serialized) + struct SystemClipboardTests { + + /// Snapshot/restore the user's clipboard around a test body so the suite leaves + /// the real pasteboard as it found it. + /// + /// Uses the full `snapshot()`/`restore()` primitives rather than just the string + /// flavor: snapshotting only `.string` and then clearing unconditionally meant + /// running these tests wiped a developer's real clipboard whenever it held an + /// image, a file, or styled text. + private func withClipboardRestored(_ body: () throws -> Void) rethrows { + let clip = SystemClipboard() + let saved = clip.snapshot() + defer { + if let saved { clip.restore(saved) } + } + try body() } - try body() - } - @Test("setString writes the string and advances changeCount") - func setStringWrites() { - withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() + @Test("setString writes the string and advances changeCount") + func setStringWrites() { + withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() - let before = clip.changeCount - clip.setString("written") + let before = clip.changeCount + clip.setString("written") - #expect(pb.string(forType: .string) == "written") - #expect(clip.changeCount > before) + #expect(pb.string(forType: .string) == "written") + #expect(clip.changeCount > before) + } } - } - @Test("snapshot captures contents that restore brings back") - func snapshotRestoreRoundTrips() throws { - try withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() + @Test("snapshot captures contents that restore brings back") + func snapshotRestoreRoundTrips() throws { + try withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() - pb.clearContents() - pb.setString("original", forType: .string) - let snapshot = try #require(clip.snapshot()) + pb.clearContents() + pb.setString("original", forType: .string) + let snapshot = try #require(clip.snapshot()) - clip.setString("overwritten") - #expect(pb.string(forType: .string) == "overwritten") + clip.setString("overwritten") + #expect(pb.string(forType: .string) == "overwritten") - clip.restore(snapshot) - #expect(pb.string(forType: .string) == "original") + clip.restore(snapshot) + #expect(pb.string(forType: .string) == "original") + } } - } - - @Test("snapshot/restore preserves every representation of multi-type, multi-item contents") - func multiTypeSnapshotRoundTrips() throws { - // The reason `SendablePasteboardItem` keys data by pasteboard *type*: a copy - // of styled text carries several representations (plain string + RTF), and - // the restore must bring all of them back — a string-only round trip would - // silently downgrade the user's clipboard to plain text. - try withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() - let styled = NSPasteboardItem() - styled.setString("styled", forType: .string) - styled.setData(Data("{\\rtf1 styled}".utf8), forType: .rtf) - let plain = NSPasteboardItem() - plain.setString("second item", forType: .string) - pb.clearContents() - pb.writeObjects([styled, plain]) - let snapshot = try #require(clip.snapshot()) - - clip.setString("overwritten") - clip.restore(snapshot) - - let restored = pb.pasteboardItems ?? [] - #expect(restored.count == 2) - #expect(restored.first?.string(forType: .string) == "styled") - #expect(restored.first?.data(forType: .rtf) == Data("{\\rtf1 styled}".utf8)) - #expect(restored.last?.string(forType: .string) == "second item") + @Test("snapshot/restore preserves every representation of multi-type, multi-item contents") + func multiTypeSnapshotRoundTrips() throws { + // The reason `SendablePasteboardItem` keys data by pasteboard *type*: a copy + // of styled text carries several representations (plain string + RTF), and + // the restore must bring all of them back — a string-only round trip would + // silently downgrade the user's clipboard to plain text. + try withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() + + let styled = NSPasteboardItem() + styled.setString("styled", forType: .string) + styled.setData(Data("{\\rtf1 styled}".utf8), forType: .rtf) + let plain = NSPasteboardItem() + plain.setString("second item", forType: .string) + pb.clearContents() + pb.writeObjects([styled, plain]) + let snapshot = try #require(clip.snapshot()) + + clip.setString("overwritten") + clip.restore(snapshot) + + let restored = pb.pasteboardItems ?? [] + #expect(restored.count == 2) + #expect(restored.first?.string(forType: .string) == "styled") + #expect(restored.first?.data(forType: .rtf) == Data("{\\rtf1 styled}".utf8)) + #expect(restored.last?.string(forType: .string) == "second item") + } } - } - - @Test("a degraded snapshot never clears the clipboard it cannot replace") - func degradedSnapshotDoesNotDestroy() { - // The failure this guards: an item that exists but whose representations can't - // be materialized (a promise the owning app declines) used to produce an - // all-empty snapshot, and `restore` cleared first and wrote nothing — so the - // user's clipboard came back EMPTY instead of unchanged. - withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() - - // An item with a declared type but no retrievable data models the promise. - let degraded = PasteboardSnapshot( - items: [SendablePasteboardItem(dataMap: [:])], plainText: nil) - clip.setString("transcript") - clip.restore(degraded) - - // Left alone rather than emptied — the transcript is still recoverable. - #expect(pb.string(forType: .string) == "transcript") + @Test("a degraded snapshot never clears the clipboard it cannot replace") + func degradedSnapshotDoesNotDestroy() { + // The failure this guards: an item that exists but whose representations can't + // be materialized (a promise the owning app declines) used to produce an + // all-empty snapshot, and `restore` cleared first and wrote nothing — so the + // user's clipboard came back EMPTY instead of unchanged. + withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() + + // An item with a declared type but no retrievable data models the promise. + let degraded = PasteboardSnapshot( + items: [SendablePasteboardItem(dataMap: [:])], plainText: nil) + + clip.setString("transcript") + clip.restore(degraded) + + // Left alone rather than emptied — the transcript is still recoverable. + #expect(pb.string(forType: .string) == "transcript") + } } - } - @Test("a partly-readable snapshot falls back to the plain-text floor") - func degradedSnapshotUsesTextFloor() { - withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() + @Test("a partly-readable snapshot falls back to the plain-text floor") + func degradedSnapshotUsesTextFloor() { + withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() - let degraded = PasteboardSnapshot( - items: [SendablePasteboardItem(dataMap: [:])], plainText: "original text") + let degraded = PasteboardSnapshot( + items: [SendablePasteboardItem(dataMap: [:])], plainText: "original text") - clip.setString("transcript") - clip.restore(degraded) + clip.setString("transcript") + clip.restore(degraded) - // Not byte-faithful (the richer flavors are unrecoverable), but the user's - // text survives instead of their clipboard being emptied. - #expect(pb.string(forType: .string) == "original text") + // Not byte-faithful (the richer flavors are unrecoverable), but the user's + // text survives instead of their clipboard being emptied. + #expect(pb.string(forType: .string) == "original text") + } } - } - @Test("writeAndPrepareRestore writes the text and its restore puts the saved contents back") - func writeAndPrepareRestoreRoundTrips() { - // The entry point `KeyInjector` actually pastes through, and the one primitive - // in this type that no test reached: the injector suites drive `FakeClipboard`, - // which holds a string and a counter and deliberately does *not* re-derive the - // save-write-restore policy — the whole point of narrowing the - // `ClipboardAccess` seam. So the policy only exists here, and only here can it - // be checked. - withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() - - pb.clearContents() - pb.setString("original", forType: .string) - - let restore = clip.writeAndPrepareRestore("transcript") - // The transcript is on the clipboard for the target app's ⌘V to read. - #expect(pb.string(forType: .string) == "transcript") - - restore() - #expect(pb.string(forType: .string) == "original") + @Test("writeAndPrepareRestore writes the text and its restore puts the saved contents back") + func writeAndPrepareRestoreRoundTrips() { + // The entry point `KeyInjector` actually pastes through, and the one primitive + // in this type that no test reached: the injector suites drive `FakeClipboard`, + // which holds a string and a counter and deliberately does *not* re-derive the + // save-write-restore policy — the whole point of narrowing the + // `ClipboardAccess` seam. So the policy only exists here, and only here can it + // be checked. + withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() + + pb.clearContents() + pb.setString("original", forType: .string) + + let restore = clip.writeAndPrepareRestore("transcript") + // The transcript is on the clipboard for the target app's ⌘V to read. + #expect(pb.string(forType: .string) == "transcript") + + restore() + #expect(pb.string(forType: .string) == "original") + } } - } - - @Test("a clipboard changed after our paste is left alone rather than restored over") - func restoreSkipsAfterAnExternalWrite() { - // The change-count guard. The restore is deferred by `pasteSettleDuration`, and - // during that window the user may copy something new — restoring then would - // silently destroy what they just copied, which is a worse outcome than leaving - // the transcript behind. So a pasteboard that has moved on since our write is - // not touched. - withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() - - pb.clearContents() - pb.setString("original", forType: .string) - let restore = clip.writeAndPrepareRestore("transcript") - // Someone else copies inside the settle window. - pb.clearContents() - pb.setString("user copied this", forType: .string) - - restore() - #expect(pb.string(forType: .string) == "user copied this") + @Test("a clipboard changed after our paste is left alone rather than restored over") + func restoreSkipsAfterAnExternalWrite() { + // The change-count guard. The restore is deferred by `pasteSettleDuration`, and + // during that window the user may copy something new — restoring then would + // silently destroy what they just copied, which is a worse outcome than leaving + // the transcript behind. So a pasteboard that has moved on since our write is + // not touched. + withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() + + pb.clearContents() + pb.setString("original", forType: .string) + let restore = clip.writeAndPrepareRestore("transcript") + + // Someone else copies inside the settle window. + pb.clearContents() + pb.setString("user copied this", forType: .string) + + restore() + #expect(pb.string(forType: .string) == "user copied this") + } } - } - - @Test("write overwrites the clipboard with just the text") - func writeOverwrites() { - withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() - pb.clearContents() - pb.setString("previous", forType: .string) - // The `ClipboardAccess` half the degraded paste paths use: no restore is - // prepared, because the transcript is meant to *stay* on the clipboard for - // the user to paste by hand. So the previous contents must be gone. - clip.write("transcript") - - #expect(pb.string(forType: .string) == "transcript") + @Test("write overwrites the clipboard with just the text") + func writeOverwrites() { + withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() + + pb.clearContents() + pb.setString("previous", forType: .string) + // The `ClipboardAccess` half the degraded paste paths use: no restore is + // prepared, because the transcript is meant to *stay* on the clipboard for + // the user to paste by hand. So the previous contents must be gone. + clip.write("transcript") + + #expect(pb.string(forType: .string) == "transcript") + } } - } - @Test("restore of an empty snapshot leaves the cleared pasteboard empty") - func restoreEmptyIsNoOp() throws { - try withClipboardRestored { - let pb = NSPasteboard.general - let clip = SystemClipboard() + @Test("restore of an empty snapshot leaves the cleared pasteboard empty") + func restoreEmptyIsNoOp() throws { + try withClipboardRestored { + let pb = NSPasteboard.general + let clip = SystemClipboard() - pb.clearContents() // an empty pasteboard has no items to snapshot - let empty = try #require(clip.snapshot()) - clip.setString("temp") - clip.restore(empty) + pb.clearContents() // an empty pasteboard has no items to snapshot + let empty = try #require(clip.snapshot()) + clip.setString("temp") + clip.restore(empty) - #expect(pb.string(forType: .string) == nil) + #expect(pb.string(forType: .string) == nil) + } } } -} +#endif From 213a936d8255f174c910627041ebf3807dc973b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 23:01:49 +0000 Subject: [PATCH 2/5] Hoist PermissionStatus out of the macOS fence to fix the iOS build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../Permissions/PermissionsChecker.swift | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/Sources/BlurtEngine/Permissions/PermissionsChecker.swift b/Sources/BlurtEngine/Permissions/PermissionsChecker.swift index 1d3a80bb..49154363 100644 --- a/Sources/BlurtEngine/Permissions/PermissionsChecker.swift +++ b/Sources/BlurtEngine/Permissions/PermissionsChecker.swift @@ -3,29 +3,35 @@ import AppKit import ApplicationServices import Foundation +#endif - public struct PermissionStatus: Equatable, Sendable { - public let microphone: Bool - public let accessibility: Bool +/// Outside the `os(macOS)` fence on purpose: a pure value type (two Bools and +/// their derivations) with no framework dependency, consumed by the portable +/// `SetupReadiness` policy. Only *reading* the grants (`PermissionsChecker`, +/// below) needs the mac-only TCC probes. +public struct PermissionStatus: Equatable, Sendable { + public let microphone: Bool + public let accessibility: Bool - public init(microphone: Bool, accessibility: Bool) { - self.microphone = microphone - self.accessibility = accessibility - } + public init(microphone: Bool, accessibility: Bool) { + self.microphone = microphone + self.accessibility = accessibility + } - public var allGranted: Bool { microphone && accessibility } + public var allGranted: Bool { microphone && accessibility } - /// True when `previous` had every permission and this reading no longer does — - /// i.e. the user revoked one in System Settings, possibly while no window was - /// open. The shell reacts by pulling them back into onboarding rather than - /// leaving a dead overlay, so this is a behavioural edge worth a test; it lives - /// next to `allGranted`, the derivation it's built from, rather than being - /// spelled out at the one call site that watches for it. - public func lostGrant(since previous: PermissionStatus) -> Bool { - previous.allGranted && !allGranted - } + /// True when `previous` had every permission and this reading no longer does — + /// i.e. the user revoked one in System Settings, possibly while no window was + /// open. The shell reacts by pulling them back into onboarding rather than + /// leaving a dead overlay, so this is a behavioural edge worth a test; it lives + /// next to `allGranted`, the derivation it's built from, rather than being + /// spelled out at the one call site that watches for it. + public func lostGrant(since previous: PermissionStatus) -> Bool { + previous.allGranted && !allGranted } +} +#if os(macOS) public enum PermissionsChecker { /// The current grant state, read without prompting. public static func check() -> PermissionStatus { From eefa5db2f67ca941dcfdcab2946f60521f4fa110 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 23:53:41 +0000 Subject: [PATCH 3/5] Restore a blank line the iOS fencing dropped in AudioRoute 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) Claude-Session: https://claude.ai/code/session_015D99NhTVKymfq6pVp485Bk --- Sources/BlurtEngine/Audio/AudioRoute.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/BlurtEngine/Audio/AudioRoute.swift b/Sources/BlurtEngine/Audio/AudioRoute.swift index 387dcd26..f72a3c3f 100644 --- a/Sources/BlurtEngine/Audio/AudioRoute.swift +++ b/Sources/BlurtEngine/Audio/AudioRoute.swift @@ -68,6 +68,7 @@ enum AudioRoute { } return InputSnapshot(deviceID: deviceID, transportType: transportType(of: deviceID)) } + /// The system's current default *output* device — what `AudioRouteMonitor` /// hangs its format listener on. Nil when there is none, or the read failed. static func defaultOutputDeviceID() -> AudioDeviceID? { From 9f43d96d281b02e792123d4f121b457ff2056d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 23:42:50 +0000 Subject: [PATCH 4/5] Add an iOS shell app so CI proves BlurtEngine links, not just compiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_015D99NhTVKymfq6pVp485Bk --- .github/workflows/check.yml | 50 +++++++++++-- .gitignore | 6 ++ .swiftlint.yml | 4 ++ AGENTS.md | 7 ++ .../Sources/BlurtiOSShellApp.swift | 71 +++++++++++++++++++ App/BlurtiOSShell/project.yml | 62 ++++++++++++++++ scripts/check-invariants.sh | 8 ++- scripts/check.sh | 23 +++--- 8 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 App/BlurtiOSShell/Sources/BlurtiOSShellApp.swift create mode 100644 App/BlurtiOSShell/project.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 326c57ed..51683e5e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -147,10 +147,23 @@ jobs: # 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. Build-only on purpose: the engine's tests run under - # `check` on macOS, and there is no iOS app target to exercise. Like - # `compile`, it is deliberately NOT required and `gate` ignores it — it can - # only go red where an iOS consumer of the package would too. + # 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. Like `compile`, it is deliberately NOT + # required and `gate` ignores it — it can only go red where an iOS consumer + # of the package would too. ios-build: needs: changes if: needs.changes.outputs.code == 'true' @@ -180,6 +193,35 @@ jobs: -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 diff --git a/.gitignore b/.gitignore index fa6e3894..6fdf30a3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.swiftlint.yml b/.swiftlint.yml index 7d9848e0..20ecdfb6 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index f42505a2..5aaed33a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/App/BlurtiOSShell/Sources/BlurtiOSShellApp.swift b/App/BlurtiOSShell/Sources/BlurtiOSShellApp.swift new file mode 100644 index 00000000..9569eac0 --- /dev/null +++ b/App/BlurtiOSShell/Sources/BlurtiOSShellApp.swift @@ -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 + } +} diff --git a/App/BlurtiOSShell/project.yml b/App/BlurtiOSShell/project.yml new file mode 100644 index 00000000..d60197f5 --- /dev/null +++ b/App/BlurtiOSShell/project.yml @@ -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 diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index 92886b7e..72e0e335 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -73,8 +73,12 @@ GUARDRAILS=".claude/skills/project-guardrails/SKILL.md" # Markdown paragraph isn't a comment in any language grep knows. Restricting to # code extensions also keeps the app scope off the .png and .m4a resources it # was otherwise grepping byte by byte. +# +# The app scope is `App/`, not `App/Blurt/`: there is a second app spec now +# (App/BlurtiOSShell, the iOS link probe), and a scope pinned to one app silently +# 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" TESTS="Tests/*.swift App/Blurt/BlurtUITests/*.swift" # Four parallel arrays rather than one delimited list, for the reason @@ -280,7 +284,7 @@ fi # here until it is staged — and a scan that skips the file you just wrote reads # exactly like a scan that approved it. Warn rather than fail, because an # untracked file is a normal state mid-edit; `git add -N` brings it into scope. -UNTRACKED=$(git ls-files --others --exclude-standard -- Sources Tests App/Blurt) +UNTRACKED=$(git ls-files --others --exclude-standard -- Sources Tests App) if [ -n "$UNTRACKED" ]; then echo "note: untracked files are NOT scanned (git add -N to include them):" printf '%s\n' "$UNTRACKED" | sed 's/^/ /' diff --git a/scripts/check.sh b/scripts/check.sh index a4575b55..fc02f107 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -236,16 +236,23 @@ check_no_external_deps() { violation=1 fi - # App: only the local BlurtEngine (path:) package is allowed. A remote package + # Apps: only the local BlurtEngine (path:) package is allowed. A remote package # is declared with a url:/github: key inside project.yml's `packages:` block, so # extract that block and reject any such key. - local app_packages - app_packages="$(awk '/^packages:/{f=1;next} /^[^[:space:]]/{f=0} f' "$APP_DIR/project.yml")" - if printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' >/dev/null 2>&1; then - echo "error: App/Blurt/project.yml declares a remote SPM package — the app must carry only the local BlurtEngine:" >&2 - printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' >&2 - violation=1 - fi + # + # Every App/*/project.yml, not just the mac app's: App/BlurtiOSShell carries its + # own spec with its own `packages:` block, and a guard that names one file is a + # guard the next app walks around without anyone noticing. + local spec spec_path app_packages + for spec in "$REPO_ROOT"/App/*/project.yml; do + spec_path="${spec#"$REPO_ROOT"/}" + app_packages="$(awk '/^packages:/{f=1;next} /^[^[:space:]]/{f=0} f' "$spec")" + if printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' >/dev/null 2>&1; then + echo "error: $spec_path declares a remote SPM package — the app must carry only the local BlurtEngine:" >&2 + printf '%s\n' "$app_packages" | grep -nE '(^|[[:space:]])(url|github):' >&2 + violation=1 + fi + done [ "$violation" -eq 0 ] || return 1 echo "no external dependencies (engine dependency-free; app carries only local BlurtEngine)" From 5d508e1e1e64d66d68efad925dcc56a943f7b704 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 23:54:59 +0000 Subject: [PATCH 5/5] Make ios-build a required check that gate enforces 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) Claude-Session: https://claude.ai/code/session_015D99NhTVKymfq6pVp485Bk --- .github/workflows/check.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 51683e5e..c99845d7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -161,9 +161,9 @@ jobs: # 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. Like `compile`, it is deliberately NOT - # required and `gate` ignores it — it can only go red where an iOS consumer - # of the package would too. + # 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' @@ -301,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 @@ -328,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