From 119dcca3e20a0ae2553cdcf7aeb467405dc6644b Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 01:03:38 +0100 Subject: [PATCH 1/5] Add mediated automation surface --- docs/NATIVE_ONLY_REDESIGN.md | 7 ++ docs/SECURITY_POSTURE_AND_TESTING.md | 4 + native-app/Resources/APW.sdef | 14 +++ .../Sources/APW/APWAutomationIntents.swift | 85 ++++++++++++++ .../Sources/NativeAppLib/BrokerCore.swift | 104 +++++++++++++++++- .../NativeAppTests/BrokerCoreTests.swift | 82 ++++++++++++++ scripts/build-native-app.sh | 5 + 7 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 native-app/Resources/APW.sdef create mode 100644 native-app/Sources/APW/APWAutomationIntents.swift diff --git a/docs/NATIVE_ONLY_REDESIGN.md b/docs/NATIVE_ONLY_REDESIGN.md index 070fb33..e8129d6 100644 --- a/docs/NATIVE_ONLY_REDESIGN.md +++ b/docs/NATIVE_ONLY_REDESIGN.md @@ -222,6 +222,9 @@ Deliverables: - `BrokerCoreTests` exercises the broker outcome paths via `StubCredentialBroker` for `success` / `denied` / `canceled` / `invalidResponse`, and asserts the broker error code mapping. +- `BrokerAutomation` exposes a narrow Shortcuts / AppleScript request surface + for `login` and `fill`. Automation builds the same broker envelope as the CLI + path and still routes credential material through the user-mediated broker. **Phase 3 exit blockers still open**: @@ -239,12 +242,16 @@ Deliverables: - Map `pw get ` to the new login flow where appropriate - Remove unsupported commands from primary docs - Preserve a migration guide for operators moving from parity APW to native-only +- Expose only mediated automation entrypoints for `login` and `fill` so + scripts can initiate broker requests without bypassing credential approval Deliverables: - clear CLI help text - explicit migration notices - browser/runtime code marked legacy +- Shortcuts actions and an AppleScript dictionary for the supported broker + operations ### Phase 5: packaging and release diff --git a/docs/SECURITY_POSTURE_AND_TESTING.md b/docs/SECURITY_POSTURE_AND_TESTING.md index 5c3c4cb..9b6b460 100644 --- a/docs/SECURITY_POSTURE_AND_TESTING.md +++ b/docs/SECURITY_POSTURE_AND_TESTING.md @@ -40,6 +40,9 @@ Release reference version: `v2.0.0` - the v2 app broker uses a same-user local UNIX socket under `~/.apw/native-app/` - `status --json` exposes app/broker readiness while retaining legacy daemon diagnostics - requests and responses use typed JSON envelopes with bounded payload sizes +- Shortcuts and AppleScript automation entrypoints build the same broker + request envelope as CLI `apw login` / `apw fill`; they do not read credential + material directly or bypass the broker's user-mediated response path - bootstrap credentials are read from a local runtime file for the supported demo domain only; the app does not create that plaintext file on default launch @@ -124,6 +127,7 @@ The Rust test suite covers: - native app socket timeout handling - native app diagnostics and `APW_DEMO=1` bootstrap credential file initialization - end-to-end v2 app install, launch, status, doctor, and login flows +- Shortcuts / AppleScript automation envelope parity for `login` and `fill` - direct-exec fallback, unsupported-domain handling, denial handling, and malformed broker response mapping - a manual notarized-hardware validation contract for the Phase 3 AuthenticationServices broker flow diff --git a/native-app/Resources/APW.sdef b/native-app/Resources/APW.sdef new file mode 100644 index 0000000..6514b69 --- /dev/null +++ b/native-app/Resources/APW.sdef @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/native-app/Sources/APW/APWAutomationIntents.swift b/native-app/Sources/APW/APWAutomationIntents.swift new file mode 100644 index 0000000..edcdbbb --- /dev/null +++ b/native-app/Sources/APW/APWAutomationIntents.swift @@ -0,0 +1,85 @@ +#if canImport(AppIntents) + import AppIntents + import Foundation + import NativeAppLib + + @available(macOS 13.0, *) + struct APWLoginIntent: AppIntent { + static var title: LocalizedStringResource = "Request APW Login" + static var description = IntentDescription( + "Requests a login credential through the APW broker. APW still requires user mediation before returning credential material." + ) + static var openAppWhenRun = true + + @Parameter(title: "URL") + var url: String + + init() {} + + init(url: String) { + self.url = url + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let data = try BrokerAutomation.performResponseData( + operation: .login, + url: url, + requestId: "shortcuts-login" + ) + return .result(value: String(decoding: data, as: UTF8.self)) + } + } + + @available(macOS 13.0, *) + struct APWFillIntent: AppIntent { + static var title: LocalizedStringResource = "Request APW Fill" + static var description = IntentDescription( + "Requests a fill credential through the APW broker. APW still requires user mediation before returning credential material." + ) + static var openAppWhenRun = true + + @Parameter(title: "URL") + var url: String + + init() {} + + init(url: String) { + self.url = url + } + + @MainActor + func perform() async throws -> some IntentResult & ReturnsValue { + let data = try BrokerAutomation.performResponseData( + operation: .fill, + url: url, + requestId: "shortcuts-fill" + ) + return .result(value: String(decoding: data, as: UTF8.self)) + } + } + + @available(macOS 13.0, *) + struct APWShortcutsProvider: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: APWLoginIntent(), + phrases: [ + "Request APW login with \(.applicationName)", + "Get APW login credential with \(.applicationName)", + ], + shortTitle: "APW Login", + systemImageName: "key.fill" + ) + AppShortcut( + intent: APWFillIntent(), + phrases: [ + "Request APW fill with \(.applicationName)", + "Fill with APW using \(.applicationName)", + ], + shortTitle: "APW Fill", + systemImageName: "text.cursor" + ) + } + } +#endif diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index d04e461..e2388df 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -200,6 +200,91 @@ struct ResponseEnvelope: Codable { let requestId: String? } +public enum BrokerAutomationOperation: String, CaseIterable { + case login + case fill +} + +public enum BrokerAutomationError: Error, CustomStringConvertible { + case invalidURL(String) + + public var description: String { + switch self { + case .invalidURL(let value): + return "Invalid APW automation URL: \(value)" + } + } +} + +public struct BrokerAutomation { + public static func requestEnvelopeData( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil + ) throws -> Data { + try validateURL(url) + return try JSONEncoder().encode(requestEnvelope( + operation: operation, + url: url, + requestId: requestId + )) + } + + public static func performResponseData( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil + ) throws -> Data { + let server = BrokerServer(paths: AppPaths.resolve()) + return try responseData( + operation: operation, + url: url, + requestId: requestId, + server: server + ) + } + + static func responseData( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil, + server: BrokerServer + ) throws -> Data { + let response = try server.dispatch(request: requestEnvelope( + operation: operation, + url: url, + requestId: requestId + )) + return try JSONEncoder().encode(response) + } + + private static func requestEnvelope( + operation: BrokerAutomationOperation, + url: String, + requestId: String? + ) throws -> RequestEnvelope { + try validateURL(url) + return RequestEnvelope( + requestId: requestId, + command: operation.rawValue, + payload: [ + "url": url, + "intent": operation.rawValue, + "automation": "true", + ] + ) + } + + private static func validateURL(_ rawURL: String) throws { + guard let parsed = URL(string: rawURL), + parsed.scheme == "https", + parsed.host?.isEmpty == false + else { + throw BrokerAutomationError.invalidURL(rawURL) + } + } +} + struct AppPaths { let runtimeRoot: URL let socketPath: URL @@ -372,9 +457,14 @@ final class BrokerServer { error: nil, requestId: request.requestId ) - case "login": + case "login", "fill": let url = request.payload?["url"] ?? "" - return try loginResponse(for: url, requestId: request.requestId) + let operation = BrokerAutomationOperation(rawValue: request.command) ?? .login + return try credentialResponse( + for: url, + operation: operation, + requestId: request.requestId + ) default: return ResponseEnvelope( ok: false, @@ -413,13 +503,17 @@ final class BrokerServer { ] } - private func loginResponse(for rawURL: String, requestId: String?) throws -> ResponseEnvelope { + private func credentialResponse( + for rawURL: String, + operation: BrokerAutomationOperation, + requestId: String? + ) throws -> ResponseEnvelope { guard let url = URL(string: rawURL), let host = url.host?.lowercased(), !host.isEmpty else { return ResponseEnvelope( ok: false, code: 1, payload: nil, - error: "Invalid URL for native app login.", + error: "Invalid URL for native app \(operation.rawValue).", requestId: requestId ) } @@ -448,6 +542,7 @@ final class BrokerServer { "status": AnyCodable("approved"), "url": AnyCodable(credential.url), "domain": AnyCodable(credential.domain), + "intent": AnyCodable(operation.rawValue), "username": AnyCodable(credential.username), "password": AnyCodable(credential.password), "transport": AnyCodable("authentication_services"), @@ -515,6 +610,7 @@ final class BrokerServer { "status": AnyCodable("approved"), "url": AnyCodable(credential.url), "domain": AnyCodable(credential.domain), + "intent": AnyCodable(operation.rawValue), "username": AnyCodable(credential.username), "password": AnyCodable(credential.password), "transport": AnyCodable("unix_socket"), diff --git a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift index ad42d85..3f2a160 100644 --- a/native-app/Tests/NativeAppTests/BrokerCoreTests.swift +++ b/native-app/Tests/NativeAppTests/BrokerCoreTests.swift @@ -272,9 +272,91 @@ final class BrokerCoreTests: XCTestCase { XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") XCTAssertEqual(response.payload?["username"]?.value as? String, "alice@example.com") XCTAssertEqual(response.payload?["domain"]?.value as? String, "vault.example.com") + XCTAssertEqual(response.payload?["intent"]?.value as? String, "login") XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) } + func testFillRoutesThroughSameBrokerEnvelopeOnSuccess() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let response = try server.dispatch( + request: RequestEnvelope( + requestId: "fill", + command: "fill", + payload: ["url": "https://vault.example.com", "intent": "fill"] + )) + + XCTAssertEqual(response.ok, true) + XCTAssertEqual(response.code, 0) + XCTAssertEqual(response.payload?["transport"]?.value as? String, "authentication_services") + XCTAssertEqual(response.payload?["intent"]?.value as? String, "fill") + XCTAssertEqual(response.payload?["userMediated"]?.value as? Bool, true) + } + + func testAutomationEnvelopeMatchesBrokerRequestContract() throws { + let data = try BrokerAutomation.requestEnvelopeData( + operation: .fill, + url: "https://vault.example.com", + requestId: "automation-fill" + ) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let payload = json?["payload"] as? [String: String] + + XCTAssertEqual(json?["requestId"] as? String, "automation-fill") + XCTAssertEqual(json?["command"] as? String, "fill") + XCTAssertEqual(payload?["url"], "https://vault.example.com") + XCTAssertEqual(payload?["intent"], "fill") + XCTAssertEqual(payload?["automation"], "true") + } + + func testAutomationResponseUsesInjectedBrokerServer() throws { + unsetenv("APW_DEMO") + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let broker = StubCredentialBroker( + outcome: .success( + BrokerCredential( + domain: "vault.example.com", + url: "https://vault.example.com", + username: "alice@example.com", + password: "real-keychain-password" + ))) + let server = makeServer(root: root, credentialBroker: broker) + + let data = try BrokerAutomation.responseData( + operation: .login, + url: "https://vault.example.com", + requestId: "automation-login", + server: server + ) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let payload = json?["payload"] as? [String: Any] + + XCTAssertEqual(json?["ok"] as? Bool, true) + XCTAssertEqual(json?["requestId"] as? String, "automation-login") + XCTAssertEqual(payload?["intent"] as? String, "login") + XCTAssertEqual(payload?["transport"] as? String, "authentication_services") + } + + func testAutomationRejectsNonHTTPSURLsBeforeBrokerDispatch() { + XCTAssertThrowsError(try BrokerAutomation.requestEnvelopeData( + operation: .login, + url: "http://vault.example.com", + requestId: "bad" + )) + } + func testLoginRoutesToCredentialBrokerOnDeny() throws { unsetenv("APW_DEMO") let root = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/scripts/build-native-app.sh b/scripts/build-native-app.sh index a91bf6d..d90aa3a 100755 --- a/scripts/build-native-app.sh +++ b/scripts/build-native-app.sh @@ -55,6 +55,7 @@ rm -rf "$APP_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" cp "$EXECUTABLE_PATH" "$MACOS_DIR/$EXECUTABLE_NAME" chmod 0755 "$MACOS_DIR/$EXECUTABLE_NAME" +cp "$PACKAGE_DIR/Resources/APW.sdef" "$RESOURCES_DIR/APW.sdef" RESOURCE_BUNDLE="$(find "$PACKAGE_DIR/.build" \( -path '*/release/*.bundle' -o -path '*/Release/*.bundle' \) -type d -name '*NativeAppLib*.bundle' | head -n 1 || true)" if [[ -n "$RESOURCE_BUNDLE" ]]; then @@ -84,6 +85,10 @@ cat >"$PLIST_PATH" <$VERSION LSUIElement + NSAppleScriptEnabled + + OSAScriptingDefinition + APW.sdef EOF From 6131a4c70f12d17eb630335b00523c514bad652c Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:05:15 +0100 Subject: [PATCH 2/5] Guard native automation packaging --- scripts/ci/run-fast-checks.sh | 1 + scripts/test-native-automation-config.sh | 48 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100755 scripts/test-native-automation-config.sh diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 564b845..915adb0 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -44,5 +44,6 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-extended-validation-config.sh ./scripts/test-verify-universal-binaries.sh ./scripts/test-universal-release-config.sh +./scripts/test-native-automation-config.sh echo "APW fast checks passed." diff --git a/scripts/test-native-automation-config.sh b/scripts/test-native-automation-config.sh new file mode 100755 index 0000000..109f763 --- /dev/null +++ b/scripts/test-native-automation-config.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +SDEF="native-app/Resources/APW.sdef" +APP_INTENTS="native-app/Sources/APW/APWAutomationIntents.swift" +BROKER_CORE_TESTS="native-app/Tests/NativeAppTests/BrokerCoreTests.swift" +BUILD_SCRIPT="scripts/build-native-app.sh" + +require_line() { + local file="$1" + local pattern="$2" + local message="$3" + if ! grep -Fq "$pattern" "$file"; then + echo "$message" >&2 + exit 1 + fi +} + +if [ ! -f "$SDEF" ]; then + echo "Missing AppleScript dictionary: $SDEF" >&2 + exit 1 +fi + +python3 - "$SDEF" <<'PY' +import sys +import xml.etree.ElementTree as ET + +ET.parse(sys.argv[1]) +PY + +require_line "$SDEF" 'command name="request login"' "AppleScript dictionary must expose request login." +require_line "$SDEF" 'command name="request fill"' "AppleScript dictionary must expose request fill." +require_line "$SDEF" "user still approves" "AppleScript dictionary must document user mediation." + +require_line "$APP_INTENTS" "struct APWLoginIntent" "Shortcuts login intent is missing." +require_line "$APP_INTENTS" "struct APWFillIntent" "Shortcuts fill intent is missing." +require_line "$APP_INTENTS" "AppShortcutsProvider" "Shortcuts provider is missing." +require_line "$APP_INTENTS" "BrokerAutomation.performResponseData" "Shortcuts intents must route through BrokerAutomation." + +require_line "$BUILD_SCRIPT" 'cp "$PACKAGE_DIR/Resources/APW.sdef" "$RESOURCES_DIR/APW.sdef"' "APW.sdef must be copied into the app bundle." +require_line "$BUILD_SCRIPT" "NSAppleScriptEnabled" "App bundle must enable AppleScript." +require_line "$BUILD_SCRIPT" "OSAScriptingDefinition" "App bundle must publish the scripting definition." + +require_line "$BROKER_CORE_TESTS" "testAutomationEnvelopeMatchesBrokerRequestContract" "Automation envelope parity test is missing." +require_line "$BROKER_CORE_TESTS" "testAutomationResponseUsesInjectedBrokerServer" "Automation broker dispatch test is missing." +require_line "$BROKER_CORE_TESTS" "testAutomationRejectsNonHTTPSURLsBeforeBrokerDispatch" "Automation URL rejection test is missing." + +echo "Native automation configuration test passed." From 0d80e306196cb2cbb94b4f1d841ab2e8f392fe9a Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:45:40 +0100 Subject: [PATCH 3/5] Wire native automation command handlers --- native-app/Resources/APW.sdef | 2 + .../Sources/APW/APWAppleScriptCommands.swift | 43 +++++++++++++++++++ .../Sources/APW/APWAutomationIntents.swift | 6 +-- .../AuthenticationServicesBroker.swift | 22 +++++++--- .../Sources/NativeAppLib/BrokerCore.swift | 16 ++++++- scripts/test-native-automation-config.sh | 13 +++++- 6 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 native-app/Sources/APW/APWAppleScriptCommands.swift diff --git a/native-app/Resources/APW.sdef b/native-app/Resources/APW.sdef index 6514b69..34e6aa7 100644 --- a/native-app/Resources/APW.sdef +++ b/native-app/Resources/APW.sdef @@ -3,10 +3,12 @@ + + diff --git a/native-app/Sources/APW/APWAppleScriptCommands.swift b/native-app/Sources/APW/APWAppleScriptCommands.swift new file mode 100644 index 0000000..87d1d5f --- /dev/null +++ b/native-app/Sources/APW/APWAppleScriptCommands.swift @@ -0,0 +1,43 @@ +import AppKit +import Foundation +import NativeAppLib + +enum APWAppleScriptCommandBridge { + static func perform( + operation: BrokerAutomationOperation, + command: NSScriptCommand + ) -> Any? { + guard let url = command.directParameter as? String, !url.isEmpty else { + command.scriptErrorNumber = 1 + command.scriptErrorString = "APW \(operation.rawValue) requires an HTTPS URL direct parameter." + return nil + } + + do { + let data = try BrokerAutomation.performResponseData( + operation: operation, + url: url, + requestId: "applescript-\(operation.rawValue)" + ) + return String(decoding: data, as: UTF8.self) + } catch { + command.scriptErrorNumber = 1 + command.scriptErrorString = "\(error)" + return nil + } + } +} + +@objc(APWRequestLoginCommand) +final class APWRequestLoginCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + APWAppleScriptCommandBridge.perform(operation: .login, command: self) + } +} + +@objc(APWRequestFillCommand) +final class APWRequestFillCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + APWAppleScriptCommandBridge.perform(operation: .fill, command: self) + } +} diff --git a/native-app/Sources/APW/APWAutomationIntents.swift b/native-app/Sources/APW/APWAutomationIntents.swift index edcdbbb..5c05bed 100644 --- a/native-app/Sources/APW/APWAutomationIntents.swift +++ b/native-app/Sources/APW/APWAutomationIntents.swift @@ -20,9 +20,8 @@ self.url = url } - @MainActor func perform() async throws -> some IntentResult & ReturnsValue { - let data = try BrokerAutomation.performResponseData( + let data = try await BrokerAutomation.performResponseDataAsync( operation: .login, url: url, requestId: "shortcuts-login" @@ -48,9 +47,8 @@ self.url = url } - @MainActor func perform() async throws -> some IntentResult & ReturnsValue { - let data = try BrokerAutomation.performResponseData( + let data = try await BrokerAutomation.performResponseDataAsync( operation: .fill, url: url, requestId: "shortcuts-fill" diff --git a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift index 76f28fe..405ce6f 100644 --- a/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift +++ b/native-app/Sources/NativeAppLib/AuthenticationServicesBroker.swift @@ -81,10 +81,7 @@ public protocol CredentialBroker { let semaphore = DispatchSemaphore(value: 0) var captured: CredentialBrokerResult = .failure(.unknown, "broker did not complete") - // ASAuthorizationController must be driven on the main thread. The - // broker server runs accept() on a worker thread, so we hop to - // main and block on a semaphore until the delegate fires. - DispatchQueue.main.async { + let startRequest = { let request = ASAuthorizationPasswordProvider().createRequest() let controller = ASAuthorizationController(authorizationRequests: [request]) @@ -111,9 +108,22 @@ public protocol CredentialBroker { // Bound the sync wait so a hung credential picker cannot hold the // broker forever. `brokerRequestTimeoutMs` is the same constant // used for IPC. (issue #2) - let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs) - if semaphore.wait(timeout: timeout) == .timedOut { + if Thread.isMainThread { + startRequest() + let deadline = Date().addingTimeInterval(TimeInterval(brokerRequestTimeoutMs) / 1000.0) + while Date() < deadline { + if semaphore.wait(timeout: .now()) == .success { + return captured + } + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) + } return .failure(.failed, "AuthenticationServices request timed out.") + } else { + DispatchQueue.main.async(execute: startRequest) + let timeout = DispatchTime.now() + .milliseconds(brokerRequestTimeoutMs) + if semaphore.wait(timeout: timeout) == .timedOut { + return .failure(.failed, "AuthenticationServices request timed out.") + } } return captured } diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index e2388df..ba0d1d7 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -200,7 +200,7 @@ struct ResponseEnvelope: Codable { let requestId: String? } -public enum BrokerAutomationOperation: String, CaseIterable { +public enum BrokerAutomationOperation: String, CaseIterable, Sendable { case login case fill } @@ -244,6 +244,20 @@ public struct BrokerAutomation { ) } + public static func performResponseDataAsync( + operation: BrokerAutomationOperation, + url: String, + requestId: String? = nil + ) async throws -> Data { + try await Task.detached(priority: .userInitiated) { + try performResponseData( + operation: operation, + url: url, + requestId: requestId + ) + }.value + } + static func responseData( operation: BrokerAutomationOperation, url: String, diff --git a/scripts/test-native-automation-config.sh b/scripts/test-native-automation-config.sh index 109f763..b31c1ad 100755 --- a/scripts/test-native-automation-config.sh +++ b/scripts/test-native-automation-config.sh @@ -3,6 +3,7 @@ set -euo pipefail SDEF="native-app/Resources/APW.sdef" APP_INTENTS="native-app/Sources/APW/APWAutomationIntents.swift" +APPLE_SCRIPT_COMMANDS="native-app/Sources/APW/APWAppleScriptCommands.swift" BROKER_CORE_TESTS="native-app/Tests/NativeAppTests/BrokerCoreTests.swift" BUILD_SCRIPT="scripts/build-native-app.sh" @@ -30,12 +31,22 @@ PY require_line "$SDEF" 'command name="request login"' "AppleScript dictionary must expose request login." require_line "$SDEF" 'command name="request fill"' "AppleScript dictionary must expose request fill." +require_line "$SDEF" '' "AppleScript login command must be wired to a Cocoa command class." +require_line "$SDEF" '' "AppleScript fill command must be wired to a Cocoa command class." require_line "$SDEF" "user still approves" "AppleScript dictionary must document user mediation." require_line "$APP_INTENTS" "struct APWLoginIntent" "Shortcuts login intent is missing." require_line "$APP_INTENTS" "struct APWFillIntent" "Shortcuts fill intent is missing." require_line "$APP_INTENTS" "AppShortcutsProvider" "Shortcuts provider is missing." -require_line "$APP_INTENTS" "BrokerAutomation.performResponseData" "Shortcuts intents must route through BrokerAutomation." +require_line "$APP_INTENTS" "BrokerAutomation.performResponseDataAsync" "Shortcuts intents must route through asynchronous BrokerAutomation." +if grep -Fq "@MainActor" "$APP_INTENTS"; then + echo "Shortcuts intents must not run broker requests synchronously on MainActor." >&2 + exit 1 +fi + +require_line "$APPLE_SCRIPT_COMMANDS" "final class APWRequestLoginCommand: NSScriptCommand" "AppleScript login command implementation is missing." +require_line "$APPLE_SCRIPT_COMMANDS" "final class APWRequestFillCommand: NSScriptCommand" "AppleScript fill command implementation is missing." +require_line "$APPLE_SCRIPT_COMMANDS" "BrokerAutomation.performResponseData" "AppleScript commands must route through BrokerAutomation." require_line "$BUILD_SCRIPT" 'cp "$PACKAGE_DIR/Resources/APW.sdef" "$RESOURCES_DIR/APW.sdef"' "APW.sdef must be copied into the app bundle." require_line "$BUILD_SCRIPT" "NSAppleScriptEnabled" "App bundle must enable AppleScript." From 26ec63fa0f032e6dda843ba8aaab4c7c122481c7 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 06:56:29 +0100 Subject: [PATCH 4/5] Run automation app as scriptable bundle Start the broker from an AppKit-backed bundled launch so AppleScript command classes can be dispatched from the app's scripting definition. Keep AppleScript broker requests off the main thread while pumping the run loop, preserving credential UI dispatch for non-demo requests. --- .../Sources/APW/APWAppleScriptCommands.swift | 46 ++++++++++++++++++- .../Sources/NativeAppLib/BrokerCore.swift | 35 ++++++++++++++ scripts/test-native-automation-config.sh | 3 ++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/native-app/Sources/APW/APWAppleScriptCommands.swift b/native-app/Sources/APW/APWAppleScriptCommands.swift index 87d1d5f..5790d8d 100644 --- a/native-app/Sources/APW/APWAppleScriptCommands.swift +++ b/native-app/Sources/APW/APWAppleScriptCommands.swift @@ -13,12 +13,21 @@ enum APWAppleScriptCommandBridge { return nil } - do { - let data = try BrokerAutomation.performResponseData( + let request = { + try BrokerAutomation.performResponseData( operation: operation, url: url, requestId: "applescript-\(operation.rawValue)" ) + } + + do { + let data: Data + if Thread.isMainThread { + data = try performOffMainThreadWhilePumpingRunLoop(request) + } else { + data = try request() + } return String(decoding: data, as: UTF8.self) } catch { command.scriptErrorNumber = 1 @@ -26,6 +35,39 @@ enum APWAppleScriptCommandBridge { return nil } } + + private static func performOffMainThreadWhilePumpingRunLoop( + _ request: @escaping () throws -> Data + ) throws -> Data { + let result = AppleScriptCommandResultBox() + + DispatchQueue.global(qos: .userInitiated).async { + result.set(Result(catching: request)) + } + + while result.value == nil { + RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + } + + return try result.value!.get() + } +} + +private final class AppleScriptCommandResultBox { + private let lock = NSLock() + private var storage: Result? + + var value: Result? { + lock.lock() + defer { lock.unlock() } + return storage + } + + func set(_ value: Result) { + lock.lock() + storage = value + lock.unlock() + } } @objc(APWRequestLoginCommand) diff --git a/native-app/Sources/NativeAppLib/BrokerCore.swift b/native-app/Sources/NativeAppLib/BrokerCore.swift index ba0d1d7..3ab40cf 100644 --- a/native-app/Sources/NativeAppLib/BrokerCore.swift +++ b/native-app/Sources/NativeAppLib/BrokerCore.swift @@ -754,6 +754,38 @@ final class BrokerServer { } } +private final class BrokerApplicationDelegate: NSObject, NSApplicationDelegate { + private let server: BrokerServer + + init(server: BrokerServer) { + self.server = server + } + + func applicationDidFinishLaunching(_ notification: Notification) { + Thread.detachNewThread { [server] in + do { + try server.run() + } catch { + fputs("APW app failed: \(error)\n", stderr) + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + } + } + } +} + +private var brokerApplicationDelegate: BrokerApplicationDelegate? + +private func runScriptableBrokerApp(server: BrokerServer) -> Never { + let application = NSApplication.shared + application.setActivationPolicy(.accessory) + brokerApplicationDelegate = BrokerApplicationDelegate(server: server) + application.delegate = brokerApplicationDelegate + application.run() + exit(0) +} + func bundleVersion() -> String { if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, @@ -772,6 +804,9 @@ public func runBrokerAppMain() -> Never { do { switch command { case "serve": + if Bundle.main.object(forInfoDictionaryKey: "NSAppleScriptEnabled") as? Bool == true { + runScriptableBrokerApp(server: server) + } try server.run() case "doctor": let payload = server.doctorPayload() diff --git a/scripts/test-native-automation-config.sh b/scripts/test-native-automation-config.sh index b31c1ad..81f227b 100755 --- a/scripts/test-native-automation-config.sh +++ b/scripts/test-native-automation-config.sh @@ -47,10 +47,13 @@ fi require_line "$APPLE_SCRIPT_COMMANDS" "final class APWRequestLoginCommand: NSScriptCommand" "AppleScript login command implementation is missing." require_line "$APPLE_SCRIPT_COMMANDS" "final class APWRequestFillCommand: NSScriptCommand" "AppleScript fill command implementation is missing." require_line "$APPLE_SCRIPT_COMMANDS" "BrokerAutomation.performResponseData" "AppleScript commands must route through BrokerAutomation." +require_line "$APPLE_SCRIPT_COMMANDS" "performOffMainThreadWhilePumpingRunLoop" "AppleScript commands must avoid blocking the main AppKit event loop." require_line "$BUILD_SCRIPT" 'cp "$PACKAGE_DIR/Resources/APW.sdef" "$RESOURCES_DIR/APW.sdef"' "APW.sdef must be copied into the app bundle." require_line "$BUILD_SCRIPT" "NSAppleScriptEnabled" "App bundle must enable AppleScript." require_line "$BUILD_SCRIPT" "OSAScriptingDefinition" "App bundle must publish the scripting definition." +require_line "native-app/Sources/NativeAppLib/BrokerCore.swift" "runScriptableBrokerApp" "Scriptable app launches must run an AppKit event loop." +require_line "native-app/Sources/NativeAppLib/BrokerCore.swift" "NSApplication.shared" "Scriptable app launches must initialize NSApplication." require_line "$BROKER_CORE_TESTS" "testAutomationEnvelopeMatchesBrokerRequestContract" "Automation envelope parity test is missing." require_line "$BROKER_CORE_TESTS" "testAutomationResponseUsesInjectedBrokerServer" "Automation broker dispatch test is missing." From 2e6d6dce91ae03ec3b6217534ee4b90bbe52aa7e Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sun, 24 May 2026 07:09:02 +0100 Subject: [PATCH 5/5] Clarify AppleScript command parameters Document that the AppleScript direct parameters are routed to the concrete NSScriptCommand bridge classes added for broker automation. --- native-app/Resources/APW.sdef | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native-app/Resources/APW.sdef b/native-app/Resources/APW.sdef index 34e6aa7..80c1c7e 100644 --- a/native-app/Resources/APW.sdef +++ b/native-app/Resources/APW.sdef @@ -4,12 +4,12 @@ - + - +