From 369bff9fd1b573e900eb310c89f5c4dad342d134 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 29 Apr 2026 22:47:59 -0700 Subject: [PATCH 1/5] Add system UI presenter coverage for iOS and visionOS --- README.md | 7 + Sources/CodeMode/API/BridgeErrors.swift | 5 + Sources/CodeMode/API/BridgeModels.swift | 8 +- Sources/CodeMode/API/SystemUIPresenter.swift | 29 ++ .../Bridges/DefaultCapabilityLoader.swift | 80 ++++ Sources/CodeMode/Bridges/SystemUIBridge.swift | 77 +++ .../Catalog/JavaScriptBindingCatalog.swift | 3 + .../Host/CodeModeAgentToolDescriptions.swift | 2 +- .../Host/HostConfigurationValidator.swift | 2 +- .../Registry/CapabilityRegistry.swift | 4 + .../Runtime/BridgeInvocationContext.swift | 3 + Sources/CodeMode/Runtime/BridgeRuntime.swift | 3 + .../CodeMode/Runtime/RuntimeJavaScript.swift | 9 +- .../Support/CapabilityPlatformSupport.swift | 11 +- Sources/CodeMode/Support/HostPlatform.swift | 3 + .../Support/UIKitSystemUIPresenter.swift | 440 ++++++++++++++++++ .../CodeModeEvaluation/EvalScenarios.swift | 24 + .../CapabilityRegistryTests.swift | 31 ++ .../HostConfigurationValidatorTests.swift | 17 + Tests/CodeModeTests/HostRuntimeTests.swift | 25 + Tests/CodeModeTests/SystemUIBridgeTests.swift | 222 +++++++++ Tests/CodeModeTests/TestSupport.swift | 10 +- 22 files changed, 1005 insertions(+), 10 deletions(-) create mode 100644 Sources/CodeMode/API/SystemUIPresenter.swift create mode 100644 Sources/CodeMode/Bridges/SystemUIBridge.swift create mode 100644 Sources/CodeMode/Support/UIKitSystemUIPresenter.swift create mode 100644 Tests/CodeModeTests/SystemUIBridgeTests.swift diff --git a/README.md b/README.md index 9e11f03..40ca3be 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ GitHub: [velos/CodeMode.swift](https://github.com/velos/CodeMode.swift) ## Highlights - Platforms: `iOS 18+`, `macOS 15+`, `visionOS 2+` +- watchOS is audited but not a runtime target in this package because the current runtime depends on JavaScriptCore. - Typed Swift host API through `CodeModeAgentTools` - Streaming execution via `JavaScriptExecutionCall` - Structured failures via `CodeModeToolError` @@ -17,6 +18,7 @@ GitHub: [velos/CodeMode.swift](https://github.com/velos/CodeMode.swift) - web-style globals: `fetch`, `URL`, `URLSearchParams`, `setTimeout`, `console` - cross-platform Apple namespaces: `apple.keychain`, `apple.location`, `apple.weather`, `apple.calendar`, `apple.reminders`, `apple.contacts`, `apple.photos`, `apple.vision`, `apple.notifications`, `apple.health`, `apple.home`, `apple.media`, `apple.fs` - platform-specific namespaces when needed: `ios.alarm` +- iOS/visionOS system UI helpers through an injected presenter: `apple.calendar.presentNewEvent`, `apple.photos.pick`, `apple.contacts.pick` - Node-style aliases for file operations through `globalThis.fs.promises` - Sandboxed filesystem policy with allowed roots: `tmp`, `caches`, `documents` - Search and execution only expose helpers supported on the current host platform @@ -50,6 +52,8 @@ Then add the product to your target: - `CodeModeFileSystem` - `LocalCodeModeFileSystem` - `CodeModeAgentToolDescriptions` +- `SystemUIPresenter` +- `UIKitSystemUIPresenter` on iOS/visionOS ## Quick Start @@ -189,6 +193,8 @@ Cross-platform privileged helpers are installed under `apple.*`. Platform-specif `apple.location.requestPermission()` is currently exposed only on iOS hosts. Other Apple platforms can expose `apple.location.*` helpers when supported, but the explicit permission-request helper is intentionally hidden outside iOS for now. +System UI helpers are installed only on iOS and visionOS. Host apps must provide a `SystemUIPresenter`; otherwise UI-presenting helpers fail with `UI_PRESENTER_UNAVAILABLE`. + `call.events` is a non-throwing `AsyncStream` that can emit: - `.log(ExecutionLog)` @@ -231,6 +237,7 @@ Required Info.plist keys by capability: - Contacts (`contacts.read`, `contacts.search`): `NSContactsUsageDescription` - Calendar read (`calendar.read`): `NSCalendarsFullAccessUsageDescription` - Calendar write-only (`calendar.write`): `NSCalendarsWriteOnlyAccessUsageDescription` +- Calendar event editor UI (`calendar.ui.presentNewEvent`): `NSCalendarsWriteOnlyAccessUsageDescription` - Reminders (`reminders.read`, `reminders.write`): `NSRemindersFullAccessUsageDescription` - Photos (`photos.read`, `photos.export`): `NSPhotoLibraryUsageDescription` - AlarmKit (`alarm.permission.request`, `alarm.read`, `alarm.schedule`, `alarm.cancel`): `NSAlarmKitUsageDescription` diff --git a/Sources/CodeMode/API/BridgeErrors.swift b/Sources/CodeMode/API/BridgeErrors.swift index 9c256f4..dbcd0c6 100644 --- a/Sources/CodeMode/API/BridgeErrors.swift +++ b/Sources/CodeMode/API/BridgeErrors.swift @@ -7,6 +7,7 @@ enum BridgeError: Error, Sendable { case capabilityNotFound(String) case permissionDenied(PermissionKind) case unsupportedPlatform(String) + case uiPresenterUnavailable case timeout(milliseconds: Int) case cancelled case pathViolation(String) @@ -29,6 +30,8 @@ extension BridgeError: LocalizedError { return "Permission denied: \(permission.rawValue)" case let .unsupportedPlatform(feature): return "Unsupported platform for \(feature)" + case .uiPresenterUnavailable: + return "System UI presenter is unavailable. Configure CodeModeConfiguration.systemUIPresenter before using UI-presenting helpers." case let .timeout(milliseconds): return "Execution timed out after \(milliseconds)ms" case .cancelled: @@ -56,6 +59,8 @@ extension BridgeError: LocalizedError { return "PERMISSION_DENIED" case .unsupportedPlatform: return "UNSUPPORTED_PLATFORM" + case .uiPresenterUnavailable: + return "UI_PRESENTER_UNAVAILABLE" case .timeout: return "EXECUTION_TIMEOUT" case .cancelled: diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index b26e9e1..8277fc7 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -6,19 +6,22 @@ public struct CodeModeConfiguration: Sendable { public var artifactStore: any ArtifactStore public var permissionBroker: any PermissionBroker public var auditLogger: any AuditLogger + public var systemUIPresenter: any SystemUIPresenter public init( pathPolicy: any PathPolicy = DefaultPathPolicy(), fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), artifactStore: any ArtifactStore = InMemoryArtifactStore(), permissionBroker: any PermissionBroker = SystemPermissionBroker(), - auditLogger: any AuditLogger = SyncAuditLogger() + auditLogger: any AuditLogger = SyncAuditLogger(), + systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter() ) { self.pathPolicy = pathPolicy self.fileSystem = fileSystem self.artifactStore = artifactStore self.permissionBroker = permissionBroker self.auditLogger = auditLogger + self.systemUIPresenter = systemUIPresenter } } @@ -318,15 +321,18 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case calendarRead = "calendar.read" case calendarWrite = "calendar.write" + case calendarUIPresentNewEvent = "calendar.ui.presentNewEvent" case remindersRead = "reminders.read" case remindersWrite = "reminders.write" case contactsRead = "contacts.read" case contactsSearch = "contacts.search" + case contactsUIPick = "contacts.ui.pick" case photosRead = "photos.read" case photosExport = "photos.export" + case photosUIPick = "photos.ui.pick" case visionImageAnalyze = "vision.image.analyze" diff --git a/Sources/CodeMode/API/SystemUIPresenter.swift b/Sources/CodeMode/API/SystemUIPresenter.swift new file mode 100644 index 0000000..24d7e64 --- /dev/null +++ b/Sources/CodeMode/API/SystemUIPresenter.swift @@ -0,0 +1,29 @@ +import Foundation + +public protocol SystemUIPresenter: Sendable { + func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue +} + +public struct UnavailableSystemUIPresenter: SystemUIPresenter { + public init() {} + + public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + public func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } +} diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index f77d9e5..ebafc97 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -18,6 +18,7 @@ public enum DefaultCapabilityLoader { let health = HealthBridge() let home = HomeBridge() let media = MediaBridge() + let systemUI = SystemUIBridge() return [ CapabilityRegistration( @@ -185,6 +186,35 @@ public enum DefaultCapabilityLoader { try eventKit.writeEvent(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .calendarUIPresentNewEvent, + title: "Present calendar event editor", + summary: "Present system UI to let the user create or edit a new calendar event draft.", + tags: ["calendar", "eventkit", "system-ui", "picker"], + example: "await apple.calendar.presentNewEvent({ title: 'Standup', start: '2026-02-22T16:00:00Z', end: '2026-02-22T16:15:00Z' })", + requiredPermissions: [.calendarWriteOnly], + optionalArguments: ["title", "start", "end", "notes", "location"], + argumentTypes: [ + "title": .string, + "start": .string, + "end": .string, + "notes": .string, + "location": .string, + ], + argumentHints: [ + "title": "Optional event title shown in the editor.", + "start": "Optional ISO8601 start timestamp.", + "end": "Optional ISO8601 end timestamp.", + "notes": "Optional event notes/body text.", + "location": "Optional location string.", + ], + resultSummary: "Object with action plus identifier/title when the user saves." + ), + handler: { args, context in + try systemUI.presentNewCalendarEvent(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .remindersRead, @@ -262,6 +292,30 @@ public enum DefaultCapabilityLoader { try contacts.search(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .contactsUIPick, + title: "Pick contacts with system UI", + summary: "Present system contact picker UI and return selected contacts without requiring full Contacts permission.", + tags: ["contacts", "people", "system-ui", "picker"], + example: "await apple.contacts.pick({ mode: 'single', displayedPropertyKeys: ['phoneNumbers', 'emailAddresses'] })", + optionalArguments: ["mode", "displayedPropertyKeys", "timeoutMs"], + argumentTypes: [ + "mode": .string, + "displayedPropertyKeys": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "mode": "single (default) or multiple.", + "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Array of selected contacts with identifier/name/organization/phones/emails." + ), + handler: { args, context in + try systemUI.pickContacts(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .photosRead, @@ -301,6 +355,32 @@ public enum DefaultCapabilityLoader { try photos.export(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .photosUIPick, + title: "Pick photos with system UI", + summary: "Present system photo picker UI and export selected assets into the sandbox artifact store.", + tags: ["photos", "photo-library", "system-ui", "picker", "artifact"], + example: "await apple.photos.pick({ mediaType: 'image', limit: 3, outputDirectory: 'tmp:picks' })", + optionalArguments: ["mediaType", "limit", "outputDirectory", "timeoutMs"], + argumentTypes: [ + "mediaType": .string, + "limit": .number, + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "mediaType": "any (default), image/photo, or video.", + "limit": "Maximum number of selectable items; default 1.", + "outputDirectory": "Optional sandbox directory for exported picker files; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on user selection/export.", + ], + resultSummary: "Array of selected assets with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + ), + handler: { args, context in + try systemUI.pickPhotos(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .visionImageAnalyze, diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift new file mode 100644 index 0000000..aad7c6a --- /dev/null +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -0,0 +1,77 @@ +import Foundation + +public final class SystemUIBridge: @unchecked Sendable { + public init() {} + + public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateCalendarEventArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.presentNewCalendarEvent(arguments: arguments, context: context) + } + + public func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validatePhotoPickerArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.pickPhotos(arguments: arguments, context: context) + } + + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateContactPickerArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.pickContacts(arguments: arguments, context: context) + } + + private func validateCalendarEventArguments(_ arguments: [String: JSONValue]) throws { + let formatter = ISO8601DateFormatter() + for key in ["start", "end"] { + guard let value = arguments.string(key) else { + continue + } + guard formatter.date(from: value) != nil else { + throw BridgeError.invalidArguments("calendar.ui.presentNewEvent requires \(key) to be an ISO8601 timestamp when provided") + } + } + } + + private func validatePhotoPickerArguments(_ arguments: [String: JSONValue]) throws { + if let mediaType = arguments.string("mediaType")?.lowercased(), + ["any", "image", "photo", "video"].contains(mediaType) == false + { + throw BridgeError.invalidArguments("photos.ui.pick mediaType must be any, image, photo, or video") + } + + if let limit = arguments.int("limit"), limit <= 0 { + throw BridgeError.invalidArguments("photos.ui.pick limit must be greater than 0") + } + + if let outputDirectory = arguments.string("outputDirectory"), + outputDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + throw BridgeError.invalidArguments("photos.ui.pick outputDirectory cannot be empty") + } + + try validateTimeoutMs(arguments, capability: "photos.ui.pick") + } + + private func validateContactPickerArguments(_ arguments: [String: JSONValue]) throws { + if let mode = arguments.string("mode")?.lowercased(), + ["single", "multiple"].contains(mode) == false + { + throw BridgeError.invalidArguments("contacts.ui.pick mode must be single or multiple") + } + + if let keys = arguments.array("displayedPropertyKeys"), + keys.contains(where: { $0.stringValue == nil }) + { + throw BridgeError.invalidArguments("contacts.ui.pick displayedPropertyKeys must contain only strings") + } + + try validateTimeoutMs(arguments, capability: "contacts.ui.pick") + } + + private func validateTimeoutMs(_ arguments: [String: JSONValue], capability: String) throws { + if let timeoutMs = arguments.int("timeoutMs"), timeoutMs <= 0 { + throw BridgeError.invalidArguments("\(capability) timeoutMs must be greater than 0") + } + } +} diff --git a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift index 2bef5ce..71b6ef3 100644 --- a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift +++ b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift @@ -55,12 +55,15 @@ enum JavaScriptBindingCatalog { .weatherRead: ["apple.weather.getCurrentWeather"], .calendarRead: ["apple.calendar.listEvents"], .calendarWrite: ["apple.calendar.createEvent"], + .calendarUIPresentNewEvent: ["apple.calendar.presentNewEvent"], .remindersRead: ["apple.reminders.listReminders"], .remindersWrite: ["apple.reminders.createReminder"], .contactsRead: ["apple.contacts.list"], .contactsSearch: ["apple.contacts.search"], + .contactsUIPick: ["apple.contacts.pick"], .photosRead: ["apple.photos.list"], .photosExport: ["apple.photos.export"], + .photosUIPick: ["apple.photos.pick"], .visionImageAnalyze: ["apple.vision.analyzeImage"], .notificationsPermissionRequest: ["apple.notifications.requestPermission"], .notificationsSchedule: ["apple.notifications.schedule"], diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index ed01b37..de76454 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -63,7 +63,7 @@ public enum CodeModeAgentToolDescriptions { public static let executeJavaScript = CodeModeAgentToolDescription( name: "executeJavaScript", description: """ - Execute JavaScript against the CodeMode runtime. Prefer searchJavaScriptAPI first when choosing helpers or arguments. Cross-platform helpers live under apple.* and platform-specific helpers live under platform namespaces such as ios.alarm.*. Only helpers supported on the current host platform are installed. The runtime already wraps your code in an async function, so use top-level await directly and return the final value with an explicit top-level return statement; do not use an unreturned async IIFE as the final expression. Include only the required capabilities in allowedCapabilities. Execution streams logs and diagnostics and returns structured CodeModeToolError failures for syntax errors, missing JS helpers, runtime throws, validation failures, permission denials, timeouts, cancellation, and internal errors. + Execute JavaScript against the CodeMode runtime. Prefer searchJavaScriptAPI first when choosing helpers or arguments. Cross-platform helpers live under apple.* and platform-specific helpers live under platform namespaces such as ios.alarm.*. System UI helpers such as apple.calendar.presentNewEvent, apple.photos.pick, and apple.contacts.pick are installed only on iOS/visionOS hosts that provide a SystemUIPresenter. Only helpers supported on the current host platform are installed. The runtime already wraps your code in an async function, so use top-level await directly and return the final value with an explicit top-level return statement; do not use an unreturned async IIFE as the final expression. Include only the required capabilities in allowedCapabilities. Execution streams logs and diagnostics and returns structured CodeModeToolError failures for syntax errors, missing JS helpers, runtime throws, validation failures, permission denials, timeouts, cancellation, and internal errors. """ ) diff --git a/Sources/CodeMode/Host/HostConfigurationValidator.swift b/Sources/CodeMode/Host/HostConfigurationValidator.swift index 60df6f2..8606e0c 100644 --- a/Sources/CodeMode/Host/HostConfigurationValidator.swift +++ b/Sources/CodeMode/Host/HostConfigurationValidator.swift @@ -29,7 +29,7 @@ public enum HostConfigurationValidator { keys.insert("NSContactsUsageDescription") } - if capabilities.contains(.calendarWrite) { + if capabilities.contains(.calendarWrite) || capabilities.contains(.calendarUIPresentNewEvent) { keys.insert("NSCalendarsWriteOnlyAccessUsageDescription") } diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 0cdf603..9d0170e 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -92,12 +92,16 @@ public struct CapabilityDescriptor: Sendable, Equatable { "end": .string, "title": .string, "notes": .string, + "location": .string, "dueDate": .string, "query": .string, "limit": .number, "identifiers": .array, "localIdentifier": .string, "mediaType": .string, + "outputDirectory": .string, + "timeoutMs": .number, + "displayedPropertyKeys": .array, "features": .array, "maxResults": .number, "identifier": .string, diff --git a/Sources/CodeMode/Runtime/BridgeInvocationContext.swift b/Sources/CodeMode/Runtime/BridgeInvocationContext.swift index 53bcdc9..f963b1d 100644 --- a/Sources/CodeMode/Runtime/BridgeInvocationContext.swift +++ b/Sources/CodeMode/Runtime/BridgeInvocationContext.swift @@ -7,6 +7,7 @@ public final class BridgeInvocationContext: @unchecked Sendable { public let artifactStore: any ArtifactStore public let permissionBroker: any PermissionBroker public let auditLogger: any AuditLogger + public let systemUIPresenter: any SystemUIPresenter private let lock = NSLock() private var validatedPermissions: Set = [] @@ -20,6 +21,7 @@ public final class BridgeInvocationContext: @unchecked Sendable { artifactStore: any ArtifactStore, permissionBroker: any PermissionBroker, auditLogger: any AuditLogger, + systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter(), transcript: ExecutionTranscript, cancellationController: ExecutionCancellationController ) { @@ -29,6 +31,7 @@ public final class BridgeInvocationContext: @unchecked Sendable { self.artifactStore = artifactStore self.permissionBroker = permissionBroker self.auditLogger = auditLogger + self.systemUIPresenter = systemUIPresenter self.transcript = transcript self.cancellationController = cancellationController } diff --git a/Sources/CodeMode/Runtime/BridgeRuntime.swift b/Sources/CodeMode/Runtime/BridgeRuntime.swift index a23c1b4..17c8454 100644 --- a/Sources/CodeMode/Runtime/BridgeRuntime.swift +++ b/Sources/CodeMode/Runtime/BridgeRuntime.swift @@ -44,6 +44,7 @@ final class BridgeRuntime: @unchecked Sendable { artifactStore: config.artifactStore, permissionBroker: config.permissionBroker, auditLogger: config.auditLogger, + systemUIPresenter: config.systemUIPresenter, transcript: transcript, cancellationController: cancellationController ) @@ -151,6 +152,7 @@ final class BridgeRuntime: @unchecked Sendable { artifactStore: config.artifactStore, permissionBroker: config.permissionBroker, auditLogger: config.auditLogger, + systemUIPresenter: config.systemUIPresenter, transcript: transcript, cancellationController: cancellationController ) @@ -720,6 +722,7 @@ final class BridgeRuntime: @unchecked Sendable { "CAPABILITY_NOT_FOUND", "PERMISSION_DENIED", "UNSUPPORTED_PLATFORM", + "UI_PRESENTER_UNAVAILABLE", "EXECUTION_TIMEOUT", "PATH_POLICY_VIOLATION", "JAVASCRIPT_ERROR", diff --git a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift index 61b1992..738ab01 100644 --- a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift +++ b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift @@ -111,7 +111,8 @@ enum RuntimeJavaScript { globalThis.apple.calendar = { listEvents: function(args) { return __invokeAsync('calendar.read', args || {}); }, - createEvent: function(args) { return __invokeAsync('calendar.write', args || {}); } + createEvent: function(args) { return __invokeAsync('calendar.write', args || {}); }, + presentNewEvent: function(args) { return __invokeAsync('calendar.ui.presentNewEvent', args || {}); } }; globalThis.apple.reminders = { @@ -121,12 +122,14 @@ enum RuntimeJavaScript { globalThis.apple.contacts = { list: function(args) { return __invokeAsync('contacts.read', args || {}); }, - search: function(args) { return __invokeAsync('contacts.search', args || {}); } + search: function(args) { return __invokeAsync('contacts.search', args || {}); }, + pick: function(args) { return __invokeAsync('contacts.ui.pick', args || {}); } }; globalThis.apple.photos = { list: function(args) { return __invokeAsync('photos.read', args || {}); }, - export: function(args) { return __invokeAsync('photos.export', args || {}); } + export: function(args) { return __invokeAsync('photos.export', args || {}); }, + pick: function(args) { return __invokeAsync('photos.ui.pick', args || {}); } }; globalThis.apple.vision = { diff --git a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift index db7db36..72069b8 100644 --- a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift +++ b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift @@ -24,6 +24,8 @@ enum CapabilityPlatformSupport { return common.union(iOSCapabilities) case .macOS: return common.union(macOSCapabilities) + case .watchOS: + return [] case .visionOS: return common.union(visionOSCapabilities) } @@ -65,6 +67,9 @@ enum CapabilityPlatformSupport { ] private static let iOSCapabilities = crossAppleCapabilities.union([ + .calendarUIPresentNewEvent, + .contactsUIPick, + .photosUIPick, .locationPermissionRequest, .alarmPermissionRequest, .alarmRead, @@ -74,6 +79,10 @@ enum CapabilityPlatformSupport { private static let macOSCapabilities = crossAppleCapabilities - private static let visionOSCapabilities = crossAppleCapabilities + private static let visionOSCapabilities = crossAppleCapabilities.union([ + .calendarUIPresentNewEvent, + .contactsUIPick, + .photosUIPick, + ]) } diff --git a/Sources/CodeMode/Support/HostPlatform.swift b/Sources/CodeMode/Support/HostPlatform.swift index 2f028d6..3b2cdfc 100644 --- a/Sources/CodeMode/Support/HostPlatform.swift +++ b/Sources/CodeMode/Support/HostPlatform.swift @@ -3,6 +3,7 @@ import Foundation enum HostPlatform: String, Sendable { case iOS case macOS + case watchOS case visionOS static let current: HostPlatform = { @@ -10,6 +11,8 @@ enum HostPlatform: String, Sendable { return .iOS #elseif os(macOS) return .macOS + #elseif os(watchOS) + return .watchOS #elseif os(visionOS) return .visionOS #else diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift new file mode 100644 index 0000000..251542f --- /dev/null +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift @@ -0,0 +1,440 @@ +import Foundation + +#if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import Contacts +@preconcurrency import ContactsUI +@preconcurrency import EventKit +@preconcurrency import EventKitUI +@preconcurrency import PhotosUI +@preconcurrency import UIKit +@preconcurrency import UniformTypeIdentifiers + +public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendable { + private static let defaultTimeoutMs = 300_000 + + private let presentingViewControllerProvider: @Sendable () -> UIViewController? + private let coordinatorLock = NSLock() + private var retainedCoordinators: [UUID: AnyObject] = [:] + + public init(presentingViewController: @escaping @Sendable () -> UIViewController?) { + self.presentingViewControllerProvider = presentingViewController + } + + public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = Self.defaultTimeoutMs + let formatter = ISO8601DateFormatter() + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let store = EKEventStore() + let event = EKEvent(eventStore: store) + event.title = arguments.string("title") ?? "" + event.notes = arguments.string("notes") + event.location = arguments.string("location") + + let startDate = arguments.string("start").flatMap { formatter.date(from: $0) } ?? Date() + event.startDate = startDate + event.endDate = arguments.string("end").flatMap { formatter.date(from: $0) } + ?? Calendar.current.date(byAdding: .hour, value: 1, to: startDate) + ?? startDate + event.calendar = store.defaultCalendarForNewEvents + + let controller = EKEventEditViewController() + controller.eventStore = store + controller.event = event + + let coordinator = CalendarEventEditCoordinator { [weak self] action, event in + var object: [String: JSONValue] = [ + "action": .string(self?.actionString(action) ?? "unknown"), + ] + if action == .saved, let event { + object["identifier"] = .string(event.eventIdentifier ?? "") + object["title"] = .string(event.title ?? "") + } + self?.releaseCoordinator(token) + complete(.success(.object(object))) + } + self.retainCoordinator(coordinator, token: token) + controller.editViewDelegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mediaType = arguments.string("mediaType")?.lowercased() ?? "any" + let limit = max(1, arguments.int("limit") ?? 1) + let outputDirectory = arguments.string("outputDirectory") ?? "tmp:" + let outputURL = try context.pathPolicy.resolve(path: outputDirectory) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + let token = UUID() + + let results: [PhotoPickerSelection] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + var configuration = PHPickerConfiguration() + configuration.selectionLimit = limit + switch mediaType { + case "image", "photo": + configuration.filter = .images + case "video": + configuration.filter = .videos + default: + configuration.filter = nil + } + + let controller = PHPickerViewController(configuration: configuration) + let coordinator = PhotoPickerCoordinator { [weak self] results in + self?.releaseCoordinator(token) + complete(.success(results.map { PhotoPickerSelection(result: $0) })) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + var exported: [JSONValue] = [] + exported.reserveCapacity(results.count) + for (index, result) in results.enumerated() { + try context.checkCancellation() + exported.append( + try exportPickerResult( + result, + index: index, + mediaType: mediaType, + outputDirectory: outputURL, + timeoutMs: timeoutMs, + context: context + ) + ) + } + return .array(exported) + } + + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mode = arguments.string("mode")?.lowercased() ?? "single" + let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = CNContactPickerViewController() + controller.displayedPropertyKeys = displayedPropertyKeys + controller.predicateForSelectionOfContact = NSPredicate(value: true) + + if mode == "multiple" { + let coordinator = ContactMultiplePickerCoordinator { [weak self] contacts in + self?.releaseCoordinator(token) + complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + } else { + let coordinator = ContactSinglePickerCoordinator { [weak self] contacts in + self?.releaseCoordinator(token) + complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + } + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + private func runUIOperation( + timeoutMs: Int, + onTimeout: (@Sendable () -> Void)? = nil, + start: @escaping @MainActor @Sendable (@escaping @Sendable (Result) -> Void) -> Void + ) throws -> T { + if Thread.isMainThread { + throw BridgeError.nativeFailure("System UI helpers cannot block the main thread") + } + + let semaphore = DispatchSemaphore(value: 0) + let resultBox = LockedBox?>(nil) + + DispatchQueue.main.async { + start { result in + resultBox.set(result) + semaphore.signal() + } + } + + if semaphore.wait(timeout: .now() + .milliseconds(timeoutMs)) == .timedOut { + onTimeout?() + throw BridgeError.timeout(milliseconds: timeoutMs) + } + + guard let result = resultBox.get() else { + throw BridgeError.nativeFailure("System UI operation produced no result") + } + return try result.get() + } + + @MainActor private func requirePresenter() throws -> UIViewController { + guard let presenter = presentingViewControllerProvider() else { + throw BridgeError.uiPresenterUnavailable + } + return topMostPresenter(from: presenter) + } + + @MainActor private func topMostPresenter(from root: UIViewController) -> UIViewController { + var current = root + while let presented = current.presentedViewController { + current = presented + } + return current + } + + private func retainCoordinator(_ coordinator: AnyObject, token: UUID) { + coordinatorLock.lock() + retainedCoordinators[token] = coordinator + coordinatorLock.unlock() + } + + private func releaseCoordinator(_ token: UUID) { + coordinatorLock.lock() + retainedCoordinators[token] = nil + coordinatorLock.unlock() + } + + private func actionString(_ action: EKEventEditViewAction) -> String { + switch action { + case .canceled: + return "cancelled" + case .saved: + return "saved" + case .deleted: + return "deleted" + @unknown default: + return "unknown" + } + } + + private func exportPickerResult( + _ result: PhotoPickerSelection, + index: Int, + mediaType: String, + outputDirectory: URL, + timeoutMs: Int, + context: BridgeInvocationContext + ) throws -> JSONValue { + guard let typeIdentifier = preferredTypeIdentifier(for: result.itemProvider, mediaType: mediaType) else { + throw BridgeError.nativeFailure("photos.ui.pick could not find an exportable type for selected item \(index)") + } + + let semaphore = DispatchSemaphore(value: 0) + let resultBox = LockedBox?>(nil) + result.itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { sourceURL, error in + if let error { + resultBox.set(.failure(error)) + semaphore.signal() + return + } + + guard let sourceURL else { + resultBox.set(.failure(BridgeError.nativeFailure("photos.ui.pick returned no file for selected item \(index)"))) + semaphore.signal() + return + } + + do { + let outputURL = outputDirectory.appendingPathComponent(self.outputFileName(for: typeIdentifier)) + if FileManager.default.fileExists(atPath: outputURL.path) { + try FileManager.default.removeItem(at: outputURL) + } + try FileManager.default.copyItem(at: sourceURL, to: outputURL) + resultBox.set(.success(outputURL)) + } catch { + resultBox.set(.failure(error)) + } + semaphore.signal() + } + + if semaphore.wait(timeout: .now() + .milliseconds(timeoutMs)) == .timedOut { + throw BridgeError.timeout(milliseconds: timeoutMs) + } + + guard let outputURL = try resultBox.get()?.get() else { + throw BridgeError.nativeFailure("photos.ui.pick export produced no result") + } + + let bytes = (try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? NSNumber)?.doubleValue ?? 0 + let mimeType = UTType(typeIdentifier)?.preferredMIMEType + let artifact = try context.artifactStore.register(url: outputURL, mimeType: mimeType) + + var object: [String: JSONValue] = [ + "path": .string(outputURL.path), + "artifactID": .string(artifact.id), + "mediaType": .string(mediaTypeString(for: typeIdentifier)), + "uniformTypeIdentifier": .string(typeIdentifier), + "bytes": .number(bytes), + ] + if let assetIdentifier = result.assetIdentifier { + object["assetIdentifier"] = .string(assetIdentifier) + } + return .object(object) + } + + private func preferredTypeIdentifier(for itemProvider: NSItemProvider, mediaType: String) -> String? { + let identifiers = itemProvider.registeredTypeIdentifiers + let preferredTypes: [UTType] + switch mediaType { + case "image", "photo": + preferredTypes = [.image] + case "video": + preferredTypes = [.movie, .video] + default: + preferredTypes = [.image, .movie, .video] + } + + for preferredType in preferredTypes { + if let identifier = identifiers.first(where: { UTType($0)?.conforms(to: preferredType) == true }) { + return identifier + } + } + + return identifiers.first + } + + private func outputFileName(for typeIdentifier: String) -> String { + let ext = UTType(typeIdentifier)?.preferredFilenameExtension ?? "bin" + return "picked-\(UUID().uuidString).\(ext)" + } + + private func mediaTypeString(for typeIdentifier: String) -> String { + guard let type = UTType(typeIdentifier) else { + return "unknown" + } + if type.conforms(to: .image) { + return "image" + } + if type.conforms(to: .movie) || type.conforms(to: .video) { + return "video" + } + return "unknown" + } + + private func mapContact(_ contact: CNContact) -> JSONValue { + .object([ + "identifier": .string(contact.identifier), + "givenName": .string(availableString(CNContactGivenNameKey, contact: contact) { $0.givenName }), + "familyName": .string(availableString(CNContactFamilyNameKey, contact: contact) { $0.familyName }), + "organization": .string(availableString(CNContactOrganizationNameKey, contact: contact) { $0.organizationName }), + "phones": .array(availablePhoneNumbers(contact)), + "emails": .array(availableEmailAddresses(contact)), + ]) + } + + private func availableString(_ key: String, contact: CNContact, read: (CNContact) -> String) -> String { + contact.isKeyAvailable(key) ? read(contact) : "" + } + + private func availablePhoneNumbers(_ contact: CNContact) -> [JSONValue] { + guard contact.isKeyAvailable(CNContactPhoneNumbersKey) else { + return [] + } + return contact.phoneNumbers.map { .string($0.value.stringValue) } + } + + private func availableEmailAddresses(_ contact: CNContact) -> [JSONValue] { + guard contact.isKeyAvailable(CNContactEmailAddressesKey) else { + return [] + } + return contact.emailAddresses.map { .string(String($0.value)) } + } +} + +private struct PhotoPickerSelection: @unchecked Sendable { + var assetIdentifier: String? + var itemProvider: NSItemProvider + + init(result: PHPickerResult) { + self.assetIdentifier = result.assetIdentifier + self.itemProvider = result.itemProvider + } +} + +@MainActor private final class CalendarEventEditCoordinator: NSObject, @preconcurrency EKEventEditViewDelegate { + private let onComplete: (EKEventEditViewAction, EKEvent?) -> Void + + init(onComplete: @escaping (EKEventEditViewAction, EKEvent?) -> Void) { + self.onComplete = onComplete + } + + func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { + let event = controller.event + controller.dismiss(animated: true) + onComplete(action, event) + } +} + +@MainActor private final class PhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { + private let onComplete: ([PHPickerResult]) -> Void + + init(onComplete: @escaping ([PHPickerResult]) -> Void) { + self.onComplete = onComplete + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + onComplete(results) + } +} + +@MainActor private final class ContactSinglePickerCoordinator: NSObject, @preconcurrency CNContactPickerDelegate { + private let onComplete: ([CNContact]) -> Void + + init(onComplete: @escaping ([CNContact]) -> Void) { + self.onComplete = onComplete + } + + func contactPickerDidCancel(_ picker: CNContactPickerViewController) { + picker.dismiss(animated: true) + onComplete([]) + } + + func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { + picker.dismiss(animated: true) + onComplete([contact]) + } +} + +@MainActor private final class ContactMultiplePickerCoordinator: NSObject, @preconcurrency CNContactPickerDelegate { + private let onComplete: ([CNContact]) -> Void + + init(onComplete: @escaping ([CNContact]) -> Void) { + self.onComplete = onComplete + } + + func contactPickerDidCancel(_ picker: CNContactPickerViewController) { + picker.dismiss(animated: true) + onComplete([]) + } + + func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) { + picker.dismiss(animated: true) + onComplete(contacts) + } +} +#endif diff --git a/Sources/CodeModeEvaluation/EvalScenarios.swift b/Sources/CodeModeEvaluation/EvalScenarios.swift index 3281ea7..5ce70b7 100644 --- a/Sources/CodeModeEvaluation/EvalScenarios.swift +++ b/Sources/CodeModeEvaluation/EvalScenarios.swift @@ -21,6 +21,7 @@ public enum CodeModeEvalScenarios { catalogConsoleDiagnostics, searchRejectsNonFunctionProgram, catalogAliasAndPlatformPruning, + catalogSystemUIPlatformPruning, contactsPermissionDenied, weatherArgumentValidation, badFileSystemHelperSuggestion, @@ -602,6 +603,29 @@ public enum CodeModeEvalScenarios { ) ) + public static let catalogSystemUIPlatformPruning = CodeModeEvalScenario( + id: "catalog.system-ui-platform-pruning", + title: "Catalog hides system UI helpers on unsupported hosts", + task: "Search for the system UI helpers apple.calendar.presentNewEvent, apple.photos.pick, and apple.contacts.pick. On this macOS host these iOS/visionOS helpers should be hidden, so return null for each missing helper.", + searchCode: """ + async () => { + return { + calendarUI: api.byJSName["apple.calendar.presentNewEvent"] ?? null, + photosUI: api.byJSName["apple.photos.pick"] ?? null, + contactsUI: api.byJSName["apple.contacts.pick"] ?? null + }; + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "\"calendarUI\":null", + "\"contactsUI\":null", + "\"photosUI\":null", + ] + ) + ) + public static let contactsPermissionDenied = CodeModeEvalScenario( id: "contacts.permission-denied", title: "Permission denial stays structured", diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 4907cb3..37ec240 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -19,6 +19,37 @@ import Testing #expect(loaded == expected) } +@Test func systemUICapabilitiesArePlatformScoped() { + let uiCapabilities: Set = [ + .calendarUIPresentNewEvent, + .contactsUIPick, + .photosUIPick, + ] + + #expect(uiCapabilities.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .iOS))) + #expect(uiCapabilities.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .visionOS))) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .macOS).isDisjoint(with: uiCapabilities)) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .watchOS).isDisjoint(with: uiCapabilities)) +} + +@Test func systemUIDescriptorsExposeExpectedJavaScriptNames() throws { + let descriptors = Dictionary( + uniqueKeysWithValues: DefaultCapabilityLoader.loadAllRegistrations().map { ($0.descriptor.id, $0.descriptor) } + ) + + let calendar = try #require(descriptors[.calendarUIPresentNewEvent]) + #expect(calendar.requiredPermissions == [.calendarWriteOnly]) + #expect(JavaScriptBindingCatalog.names(for: .calendarUIPresentNewEvent) == ["apple.calendar.presentNewEvent"]) + + let contacts = try #require(descriptors[.contactsUIPick]) + #expect(contacts.requiredPermissions.isEmpty) + #expect(JavaScriptBindingCatalog.names(for: .contactsUIPick) == ["apple.contacts.pick"]) + + let photos = try #require(descriptors[.photosUIPick]) + #expect(photos.requiredPermissions.isEmpty) + #expect(JavaScriptBindingCatalog.names(for: .photosUIPick) == ["apple.photos.pick"]) +} + @Test func filesystemListDescriptorDocumentsEntryObjects() throws { let descriptor = try #require( DefaultCapabilityLoader.loadAllRegistrations() diff --git a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift index 1fba7ad..7555121 100644 --- a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift +++ b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift @@ -62,6 +62,23 @@ import Testing #expect(issues.contains(where: { $0.key == "NSCalendarsFullAccessUsageDescription" }) == false) } +@Test func validatorRequiresWriteOnlyCalendarKeyForCalendarUIPresentationOnly() { + let issues = HostConfigurationValidator.validate( + requiredCapabilities: [.calendarUIPresentNewEvent], + infoPlist: [:] + ) + + #expect(issues.contains(where: { $0.key == "NSCalendarsWriteOnlyAccessUsageDescription" && $0.severity == .error })) + #expect(issues.contains(where: { $0.key == "NSCalendarsFullAccessUsageDescription" }) == false) +} + +@Test func validatorDoesNotRequireFullLibraryKeysForSystemPickers() { + let keys = HostConfigurationValidator.requiredInfoPlistKeys(for: [.photosUIPick, .contactsUIPick]) + + #expect(keys.contains("NSPhotoLibraryUsageDescription") == false) + #expect(keys.contains("NSContactsUsageDescription") == false) +} + @Test func validatorReportsPhotosAndHomeKitMissingUsageDescriptions() { let issues = HostConfigurationValidator.validate( requiredCapabilities: [.photosRead, .homeRead], diff --git a/Tests/CodeModeTests/HostRuntimeTests.swift b/Tests/CodeModeTests/HostRuntimeTests.swift index dd475c0..1682a70 100644 --- a/Tests/CodeModeTests/HostRuntimeTests.swift +++ b/Tests/CodeModeTests/HostRuntimeTests.swift @@ -98,6 +98,31 @@ import Testing } } +@Test func searchHidesSystemUICapabilitiesOnUnsupportedHostPlatform() async throws { + let (tools, sandbox) = try makeTools() + defer { cleanup(sandbox) } + + let response = try await tools.searchJavaScriptAPI( + JavaScriptAPISearchRequest( + code: """ + async () => { + return { + calendarUI: api.byJSName["apple.calendar.presentNewEvent"] ?? null, + contactsUI: api.byJSName["apple.contacts.pick"] ?? null, + photosUI: api.byJSName["apple.photos.pick"] ?? null + }; + } + """ + ) + ) + + let result = try #require(response.result?.objectValue) + let shouldExpose = CapabilityPlatformSupport.isSupported(.calendarUIPresentNewEvent, for: .current) + #expect((result["calendarUI"] != .null) == shouldExpose) + #expect((result["contactsUI"] != .null) == shouldExpose) + #expect((result["photosUI"] != .null) == shouldExpose) +} + @Test func searchSupportsDirectCapabilityLookup() async throws { let (tools, sandbox) = try makeTools() defer { cleanup(sandbox) } diff --git a/Tests/CodeModeTests/SystemUIBridgeTests.swift b/Tests/CodeModeTests/SystemUIBridgeTests.swift new file mode 100644 index 0000000..87d9193 --- /dev/null +++ b/Tests/CodeModeTests/SystemUIBridgeTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing +@testable import CodeMode + +@Test func systemUIBridgeUsesInjectedPresenterForSuccesses() throws { + let bridge = SystemUIBridge() + let presenter = FakeSystemUIPresenter( + calendarResult: .object([ + "action": .string("saved"), + "identifier": .string("event-1"), + "title": .string("Standup"), + ]), + photosResult: .array([ + .object([ + "path": .string("/tmp/picked.jpg"), + "artifactID": .string("artifact-1"), + "mediaType": .string("image"), + "uniformTypeIdentifier": .string("public.jpeg"), + "bytes": .number(128), + ]), + ]), + contactsResult: .array([ + .object([ + "identifier": .string("contact-1"), + "givenName": .string("Alex"), + "familyName": .string("Lee"), + "organization": .string(""), + "phones": .array([]), + "emails": .array([.string("alex@example.com")]), + ]), + ]) + ) + + let (context, sandbox) = try makeInvocationContext(systemUIPresenter: presenter) + defer { cleanup(sandbox) } + + let calendar = try bridge.presentNewCalendarEvent( + arguments: [ + "title": .string("Standup"), + "start": .string("2026-02-22T16:00:00Z"), + "end": .string("2026-02-22T16:15:00Z"), + ], + context: context + ) + #expect(calendar.objectValue?.string("identifier") == "event-1") + + let photos = try requireArray( + bridge.pickPhotos(arguments: ["mediaType": .string("image"), "limit": .number(1)], context: context) + ) + #expect(photos.first?.objectValue?.string("artifactID") == "artifact-1") + + let contacts = try requireArray( + bridge.pickContacts(arguments: ["mode": .string("single")], context: context) + ) + #expect(contacts.first?.objectValue?.string("givenName") == "Alex") +} + +@Test func systemUIBridgeDefaultPresenterIsStructuredFailure() throws { + let bridge = SystemUIBridge() + let (context, sandbox) = try makeInvocationContext() + defer { cleanup(sandbox) } + + do { + _ = try bridge.pickContacts(arguments: [:], context: context) + Issue.record("Expected missing presenter to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "UI_PRESENTER_UNAVAILABLE") + } +} + +@Test func systemUICapabilitiesRespectRegistryAllowlist() throws { + let registration = try #require( + DefaultCapabilityLoader.loadAllRegistrations() + .first { $0.descriptor.id == .photosUIPick } + ) + let registry = CapabilityRegistry(registrations: [registration]) + let (context, sandbox) = try makeInvocationContext(allowedCapabilities: []) + defer { cleanup(sandbox) } + + do { + _ = try registry.invoke(CapabilityID.photosUIPick.rawValue, arguments: [:], context: context) + Issue.record("Expected photos.ui.pick to require explicit allowlisting") + } catch { + #expect(requireBridgeErrorCode(error) == "CAPABILITY_DENIED") + } +} + +@Test func systemUIBridgeValidatesArgumentsBeforePresenter() throws { + let bridge = SystemUIBridge() + let presenter = FakeSystemUIPresenter() + let (context, sandbox) = try makeInvocationContext(systemUIPresenter: presenter) + defer { cleanup(sandbox) } + + do { + _ = try bridge.presentNewCalendarEvent(arguments: ["start": .string("soon")], context: context) + Issue.record("Expected invalid calendar start to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.pickPhotos(arguments: ["mediaType": .string("audio")], context: context) + Issue.record("Expected invalid mediaType to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.pickContacts(arguments: ["displayedPropertyKeys": .array([.number(1)])], context: context) + Issue.record("Expected invalid displayedPropertyKeys to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } +} + +@Test func systemUIBridgeHonorsCancellationBeforePresentation() throws { + let sandbox = try makeTestSandbox() + defer { cleanup(sandbox) } + + let cancellationController = ExecutionCancellationController() + cancellationController.cancel() + let context = BridgeInvocationContext( + executionContext: .init(userID: "test-user", sessionID: "test-session"), + allowedCapabilities: Set(CapabilityID.allCases), + pathPolicy: DefaultPathPolicy( + config: PathPolicyConfig(tmpRoot: sandbox.tmp, cachesRoot: sandbox.caches, documentsRoot: sandbox.documents) + ), + artifactStore: InMemoryArtifactStore(), + permissionBroker: NoopPermissionBroker(), + auditLogger: SyncAuditLogger(), + systemUIPresenter: FakeSystemUIPresenter(), + transcript: ExecutionTranscript(), + cancellationController: cancellationController + ) + + do { + _ = try SystemUIBridge().pickPhotos(arguments: [:], context: context) + Issue.record("Expected cancellation to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "CANCELLED") + } +} + +@Test func systemUIBridgePropagatesPresenterTimeout() throws { + let bridge = SystemUIBridge() + let presenter = FakeSystemUIPresenter(error: BridgeError.timeout(milliseconds: 10)) + let (context, sandbox) = try makeInvocationContext(systemUIPresenter: presenter) + defer { cleanup(sandbox) } + + do { + _ = try bridge.pickContacts(arguments: [:], context: context) + Issue.record("Expected timeout to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "EXECUTION_TIMEOUT") + } +} + +@Test func calendarUIPresentationRequiresWriteOnlyCalendarPermission() throws { + let registration = try #require( + DefaultCapabilityLoader.loadAllRegistrations() + .first { $0.descriptor.id == .calendarUIPresentNewEvent } + ) + let registry = CapabilityRegistry(registrations: [registration]) + let broker = FixedPermissionBroker(statuses: [.calendarWriteOnly: .denied]) + let (context, sandbox) = try makeInvocationContext( + permissionBroker: broker, + allowedCapabilities: [.calendarUIPresentNewEvent], + systemUIPresenter: FakeSystemUIPresenter() + ) + defer { cleanup(sandbox) } + + do { + _ = try registry.invoke( + CapabilityID.calendarUIPresentNewEvent.rawValue, + arguments: [:], + context: context + ) + Issue.record("Expected calendar write-only permission denial") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } +} + +private struct FakeSystemUIPresenter: SystemUIPresenter { + var calendarResult: JSONValue + var photosResult: JSONValue + var contactsResult: JSONValue + var error: BridgeError? + + init( + calendarResult: JSONValue = .object(["action": .string("cancelled")]), + photosResult: JSONValue = .array([]), + contactsResult: JSONValue = .array([]), + error: BridgeError? = nil + ) { + self.calendarResult = calendarResult + self.photosResult = photosResult + self.contactsResult = contactsResult + self.error = error + } + + func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return calendarResult + } + + func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return photosResult + } + + func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return contactsResult + } +} diff --git a/Tests/CodeModeTests/TestSupport.swift b/Tests/CodeModeTests/TestSupport.swift index ae4d32e..9cf2c4d 100644 --- a/Tests/CodeModeTests/TestSupport.swift +++ b/Tests/CodeModeTests/TestSupport.swift @@ -35,7 +35,8 @@ func cleanup(_ sandbox: TestSandbox) { func makeTools( permissionBroker: any PermissionBroker = NoopPermissionBroker(), - fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem() + fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), + systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter() ) throws -> (CodeModeAgentTools, TestSandbox) { let sandbox = try makeTestSandbox() @@ -48,7 +49,8 @@ func makeTools( fileSystem: fileSystem, artifactStore: InMemoryArtifactStore(), permissionBroker: permissionBroker, - auditLogger: SyncAuditLogger() + auditLogger: SyncAuditLogger(), + systemUIPresenter: systemUIPresenter ) let tools = CodeModeAgentTools(config: configuration) @@ -57,7 +59,8 @@ func makeTools( func makeInvocationContext( permissionBroker: any PermissionBroker = NoopPermissionBroker(), - allowedCapabilities: Set = Set(CapabilityID.allCases) + allowedCapabilities: Set = Set(CapabilityID.allCases), + systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter() ) throws -> (BridgeInvocationContext, TestSandbox) { let sandbox = try makeTestSandbox() let pathPolicy = DefaultPathPolicy( @@ -71,6 +74,7 @@ func makeInvocationContext( artifactStore: InMemoryArtifactStore(), permissionBroker: permissionBroker, auditLogger: SyncAuditLogger(), + systemUIPresenter: systemUIPresenter, transcript: ExecutionTranscript(), cancellationController: ExecutionCancellationController() ) From a6a4402affd47072ff38fb151f6781b2de2dacfc Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 29 Apr 2026 23:13:35 -0700 Subject: [PATCH 2/5] Refactor codemode iOS workflow and update related flows --- README.md | 13 +- Sources/CodeMode/API/BridgeModels.swift | 15 + Sources/CodeMode/API/SystemUIPresenter.swift | 110 +- .../Bridges/DefaultCapabilityLoader.swift | 385 ++++++ Sources/CodeMode/Bridges/SystemUIBridge.swift | 266 +++- .../Catalog/JavaScriptBindingCatalog.swift | 14 + .../Host/CodeModeAgentToolDescriptions.swift | 2 +- .../Host/HostConfigurationValidator.swift | 21 +- .../Registry/CapabilityRegistry.swift | 26 + .../CodeMode/Runtime/RuntimeJavaScript.swift | 43 +- .../Support/CapabilityPlatformSupport.swift | 24 + .../Support/UIKitSystemUIPresenter.swift | 1184 ++++++++++++++++- .../CodeModeEvaluation/EvalScenarios.swift | 47 +- .../CapabilityRegistryTests.swift | 50 +- .../HostConfigurationValidatorTests.swift | 16 +- Tests/CodeModeTests/HostRuntimeTests.swift | 51 +- Tests/CodeModeTests/SystemUIBridgeTests.swift | 214 ++- 17 files changed, 2406 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 40ca3be..5c48bfe 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ GitHub: [velos/CodeMode.swift](https://github.com/velos/CodeMode.swift) - web-style globals: `fetch`, `URL`, `URLSearchParams`, `setTimeout`, `console` - cross-platform Apple namespaces: `apple.keychain`, `apple.location`, `apple.weather`, `apple.calendar`, `apple.reminders`, `apple.contacts`, `apple.photos`, `apple.vision`, `apple.notifications`, `apple.health`, `apple.home`, `apple.media`, `apple.fs` - platform-specific namespaces when needed: `ios.alarm` -- iOS/visionOS system UI helpers through an injected presenter: `apple.calendar.presentNewEvent`, `apple.photos.pick`, `apple.contacts.pick` +- iOS/visionOS system UI helpers through an injected presenter, including alerts, calendar editors, photo/contact/document pickers, share sheets, Quick Look previews, web authentication, and iOS-only camera/scan/mail/message compose flows - Node-style aliases for file operations through `globalThis.fs.promises` - Sandboxed filesystem policy with allowed roots: `tmp`, `caches`, `documents` - Search and execution only expose helpers supported on the current host platform @@ -193,7 +193,7 @@ Cross-platform privileged helpers are installed under `apple.*`. Platform-specif `apple.location.requestPermission()` is currently exposed only on iOS hosts. Other Apple platforms can expose `apple.location.*` helpers when supported, but the explicit permission-request helper is intentionally hidden outside iOS for now. -System UI helpers are installed only on iOS and visionOS. Host apps must provide a `SystemUIPresenter`; otherwise UI-presenting helpers fail with `UI_PRESENTER_UNAVAILABLE`. +System UI helpers are installed only on supported UI platforms. Shared iOS/visionOS helpers include `apple.ui.presentAlert`, `apple.calendar.pickCalendar`, `apple.calendar.presentEvent`, `apple.calendar.presentNewEvent`, `apple.contacts.pick`, `apple.contacts.presentContact`, `apple.contacts.presentNewContact`, `apple.photos.pick`, `apple.documents.pick`, `apple.share.present`, `apple.quicklook.preview`, `apple.web.present`, and `apple.auth.webAuthenticate`. iOS-only helpers include `apple.camera.capture`, `apple.documents.scan`, `apple.mail.compose`, and `apple.messages.compose`. Host apps must provide a `SystemUIPresenter`; otherwise UI-presenting helpers fail with `UI_PRESENTER_UNAVAILABLE`. `call.events` is a non-throwing `AsyncStream` that can emit: @@ -234,12 +234,15 @@ Required Info.plist keys by capability: - Location read (`location.read`): `NSLocationWhenInUseUsageDescription` - Location permission request (`location.permission.request`, iOS-only): `NSLocationWhenInUseUsageDescription` -- Contacts (`contacts.read`, `contacts.search`): `NSContactsUsageDescription` +- Contacts (`contacts.read`, `contacts.search`, `contacts.ui.presentContact`, `contacts.ui.presentNewContact`): `NSContactsUsageDescription` - Calendar read (`calendar.read`): `NSCalendarsFullAccessUsageDescription` +- Calendar event detail UI (`calendar.ui.presentEvent`): `NSCalendarsFullAccessUsageDescription` - Calendar write-only (`calendar.write`): `NSCalendarsWriteOnlyAccessUsageDescription` -- Calendar event editor UI (`calendar.ui.presentNewEvent`): `NSCalendarsWriteOnlyAccessUsageDescription` +- Calendar event editor/chooser UI (`calendar.ui.presentNewEvent`, `calendar.ui.pickCalendar`): `NSCalendarsWriteOnlyAccessUsageDescription` - Reminders (`reminders.read`, `reminders.write`): `NSRemindersFullAccessUsageDescription` - Photos (`photos.read`, `photos.export`): `NSPhotoLibraryUsageDescription` +- Camera UI (`camera.ui.capture`, `documents.ui.scan`): `NSCameraUsageDescription` +- Camera video capture (`camera.ui.capture`): `NSMicrophoneUsageDescription` - AlarmKit (`alarm.permission.request`, `alarm.read`, `alarm.schedule`, `alarm.cancel`): `NSAlarmKitUsageDescription` - HealthKit read (`health.permission.request`, `health.read`): `NSHealthShareUsageDescription` - HealthKit write (`health.permission.request`, `health.write`): `NSHealthUpdateUsageDescription` @@ -291,7 +294,7 @@ swift run --package-path Tools/CodeModeEval codemode-eval run fs.round-trip --sh swift run --package-path Tools/CodeModeEval codemode-eval run --json ``` -The eval harness runs 21 built-in user-style scenarios through the same +The eval harness runs 22 built-in user-style scenarios through the same `searchJavaScriptAPI` and `executeJavaScript` APIs that host apps expose to agents. It validates tool order, discovered catalog output, generated JavaScript fragments, exact `allowedCapabilities`, structured errors, repair suggestions, diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index 8277fc7..d7367d0 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -367,4 +367,19 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case fsMkdir = "fs.mkdir" case fsExists = "fs.exists" case fsAccess = "fs.access" + + case calendarUIPickCalendar = "calendar.ui.pickCalendar" + case calendarUIPresentEvent = "calendar.ui.presentEvent" + case contactsUIPresentContact = "contacts.ui.presentContact" + case contactsUIPresentNewContact = "contacts.ui.presentNewContact" + case documentsUIPick = "documents.ui.pick" + case documentsUIScan = "documents.ui.scan" + case shareUIPresent = "share.ui.present" + case quickLookUIPreview = "quicklook.ui.preview" + case cameraUICapture = "camera.ui.capture" + case mailUICompose = "mail.ui.compose" + case messagesUICompose = "messages.ui.compose" + case webUIPresent = "web.ui.present" + case authUIWebAuthenticate = "auth.ui.webAuthenticate" + case uiAlertPresent = "ui.alert.present" } diff --git a/Sources/CodeMode/API/SystemUIPresenter.swift b/Sources/CodeMode/API/SystemUIPresenter.swift index 24d7e64..634c25a 100644 --- a/Sources/CodeMode/API/SystemUIPresenter.swift +++ b/Sources/CodeMode/API/SystemUIPresenter.swift @@ -1,29 +1,129 @@ import Foundation public protocol SystemUIPresenter: Sendable { + func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue } -public struct UnavailableSystemUIPresenter: SystemUIPresenter { - public init() {} +public extension SystemUIPresenter { + func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } - public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context throw BridgeError.uiPresenterUnavailable } - public func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context throw BridgeError.uiPresenterUnavailable } - public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context throw BridgeError.uiPresenterUnavailable } + + func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } +} + +public struct UnavailableSystemUIPresenter: SystemUIPresenter { + public init() {} } diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index ebafc97..3f6759f 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -186,6 +186,59 @@ public enum DefaultCapabilityLoader { try eventKit.writeEvent(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .calendarUIPickCalendar, + title: "Pick calendar with system UI", + summary: "Present EventKit calendar chooser UI and return the user-selected writable calendars.", + tags: ["calendar", "eventkit", "system-ui", "picker"], + example: "await apple.calendar.pickCalendar({ selectionStyle: 'single' })", + requiredPermissions: [.calendarWriteOnly], + optionalArguments: ["selectionStyle", "displayStyle", "timeoutMs"], + argumentTypes: [ + "selectionStyle": .string, + "displayStyle": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "selectionStyle": "single (default) or multiple.", + "displayStyle": "writable (default) or all.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Array of selected calendars with identifier/title/type/allowsContentModifications." + ), + handler: { args, context in + try systemUI.pickCalendar(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .calendarUIPresentEvent, + title: "Present calendar event details", + summary: "Present system UI for an existing calendar event identifier.", + tags: ["calendar", "eventkit", "system-ui", "details"], + example: "await apple.calendar.presentEvent({ identifier: 'EVENT_ID', allowsEditing: false })", + requiredPermissions: [.calendar], + requiredArguments: ["identifier"], + optionalArguments: ["allowsEditing", "allowsCalendarPreview", "timeoutMs"], + argumentTypes: [ + "identifier": .string, + "allowsEditing": .bool, + "allowsCalendarPreview": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "identifier": "EventKit eventIdentifier from apple.calendar.listEvents.", + "allowsEditing": "Whether the user can edit from the detail UI; default false.", + "allowsCalendarPreview": "Whether the UI may show calendar day previews; default true.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action dismissed." + ), + handler: { args, context in + try systemUI.presentCalendarEvent(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .calendarUIPresentNewEvent, @@ -316,6 +369,67 @@ public enum DefaultCapabilityLoader { try systemUI.pickContacts(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .contactsUIPresentContact, + title: "Present contact card", + summary: "Present system contact card UI for a contact identifier.", + tags: ["contacts", "people", "system-ui", "details"], + example: "await apple.contacts.presentContact({ identifier: 'CONTACT_ID', allowsEditing: false })", + requiredPermissions: [.contacts], + requiredArguments: ["identifier"], + optionalArguments: ["allowsEditing", "allowsActions", "displayedPropertyKeys", "timeoutMs"], + argumentTypes: [ + "identifier": .string, + "allowsEditing": .bool, + "allowsActions": .bool, + "displayedPropertyKeys": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "identifier": "Contact identifier from apple.contacts.list/search/pick.", + "allowsEditing": "Whether the user can edit the contact; default false.", + "allowsActions": "Whether built-in actions like call/message are shown; default true.", + "displayedPropertyKeys": "Optional array of CNContact property key strings to display.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action and contact when available." + ), + handler: { args, context in + try systemUI.presentContact(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .contactsUIPresentNewContact, + title: "Present new contact editor", + summary: "Present system UI for creating a new contact draft.", + tags: ["contacts", "people", "system-ui", "create"], + example: "await apple.contacts.presentNewContact({ givenName: 'Alex', familyName: 'Lee', emailAddresses: ['alex@example.com'] })", + requiredPermissions: [.contacts], + optionalArguments: ["givenName", "familyName", "organization", "phoneNumbers", "emailAddresses", "timeoutMs"], + argumentTypes: [ + "givenName": .string, + "familyName": .string, + "organization": .string, + "phoneNumbers": .array, + "emailAddresses": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "givenName": "Optional given name to prefill.", + "familyName": "Optional family name to prefill.", + "organization": "Optional organization to prefill.", + "phoneNumbers": "Optional array of phone number strings.", + "emailAddresses": "Optional array of email address strings.", + "timeoutMs": "Optional timeout for waiting on user completion.", + ], + resultSummary: "Object with action and contact when the user saves." + ), + handler: { args, context in + try systemUI.presentNewContact(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .photosRead, @@ -381,6 +495,277 @@ public enum DefaultCapabilityLoader { try systemUI.pickPhotos(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .documentsUIPick, + title: "Pick documents with system UI", + summary: "Present Files document picker UI and copy selected documents into the sandbox artifact store.", + tags: ["documents", "files", "system-ui", "picker", "artifact"], + example: "await apple.documents.pick({ contentTypes: ['public.item'], allowMultiple: true, outputDirectory: 'tmp:imports' })", + optionalArguments: ["contentTypes", "allowMultiple", "outputDirectory", "timeoutMs"], + argumentTypes: [ + "contentTypes": .array, + "allowMultiple": .bool, + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "contentTypes": "Optional array of UTType identifiers; defaults to public.item.", + "allowMultiple": "Whether multiple files may be selected; default false.", + "outputDirectory": "Optional sandbox directory for copied files; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on user selection/copy.", + ], + resultSummary: "Array of selected documents with path/artifactID/filename/uniformTypeIdentifier/bytes." + ), + handler: { args, context in + try systemUI.pickDocuments(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .documentsUIScan, + title: "Scan documents with system UI", + summary: "Present VisionKit document scanner UI and export scanned pages into the sandbox artifact store.", + tags: ["documents", "scan", "camera", "system-ui", "artifact"], + example: "await apple.documents.scan({ outputDirectory: 'tmp:scans' })", + optionalArguments: ["outputDirectory", "timeoutMs"], + argumentTypes: [ + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "outputDirectory": "Optional sandbox directory for scanned page images; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on user scanning/export.", + ], + resultSummary: "Array of scanned page artifacts with path/artifactID/pageIndex/mediaType/bytes." + ), + handler: { args, context in + try systemUI.scanDocuments(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .shareUIPresent, + title: "Present share sheet", + summary: "Present system share sheet for text, URLs, and sandbox file artifacts.", + tags: ["share", "export", "system-ui"], + example: "await apple.share.present({ text: 'Report ready', paths: ['tmp:report.pdf'] })", + optionalArguments: ["text", "url", "path", "paths", "subject", "excludedActivityTypes", "timeoutMs"], + argumentTypes: [ + "text": .string, + "url": .string, + "path": .string, + "paths": .array, + "subject": .string, + "excludedActivityTypes": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "text": "Optional text item to share.", + "url": "Optional absolute HTTP(S) URL to share.", + "path": "Optional single sandbox file path to share.", + "paths": "Optional array of sandbox file paths to share.", + "subject": "Optional subject for services that support it.", + "excludedActivityTypes": "Optional array of UIActivity.ActivityType raw value strings to hide.", + "timeoutMs": "Optional timeout for waiting on share completion.", + ], + resultSummary: "Object with completed/activityType/action." + ), + handler: { args, context in + try systemUI.presentShareSheet(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .quickLookUIPreview, + title: "Preview files with Quick Look", + summary: "Present Quick Look preview UI for one or more sandbox file artifacts.", + tags: ["quicklook", "preview", "documents", "system-ui"], + example: "await apple.quicklook.preview({ path: 'tmp:report.pdf' })", + optionalArguments: ["path", "paths", "timeoutMs"], + argumentTypes: [ + "path": .string, + "paths": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Single sandbox file path to preview.", + "paths": "Optional array of sandbox file paths to preview.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action dismissed and count." + ), + handler: { args, context in + try systemUI.previewQuickLook(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .cameraUICapture, + title: "Capture photo or video with camera UI", + summary: "Present system camera UI and export captured media into the sandbox artifact store.", + tags: ["camera", "capture", "photos", "system-ui", "artifact"], + example: "await apple.camera.capture({ mediaType: 'image', outputDirectory: 'tmp:camera' })", + optionalArguments: ["mediaType", "outputDirectory", "timeoutMs"], + argumentTypes: [ + "mediaType": .string, + "outputDirectory": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "mediaType": "any (default), image/photo, or video.", + "outputDirectory": "Optional sandbox directory for captured media; defaults to tmp:.", + "timeoutMs": "Optional timeout for waiting on capture/export.", + ], + resultSummary: "Object with path/artifactID/mediaType/uniformTypeIdentifier/bytes." + ), + handler: { args, context in + try systemUI.captureCamera(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .mailUICompose, + title: "Compose mail with system UI", + summary: "Present system mail compose UI with optional recipients, body, and sandbox file attachments.", + tags: ["mail", "compose", "system-ui", "share"], + example: "await apple.mail.compose({ to: ['alex@example.com'], subject: 'Report', body: 'Attached.', attachments: [{ path: 'tmp:report.pdf' }] })", + optionalArguments: ["to", "cc", "bcc", "subject", "body", "isHTML", "attachments", "timeoutMs"], + argumentTypes: [ + "to": .array, + "cc": .array, + "bcc": .array, + "subject": .string, + "body": .string, + "isHTML": .bool, + "attachments": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "to": "Optional array of recipient email strings.", + "cc": "Optional array of CC email strings.", + "bcc": "Optional array of BCC email strings.", + "subject": "Optional subject.", + "body": "Optional message body.", + "isHTML": "Whether body should be treated as HTML; default false.", + "attachments": "Optional array of { path, mimeType?, filename? } sandbox file attachments.", + "timeoutMs": "Optional timeout for waiting on user completion.", + ], + resultSummary: "Object with action sent/saved/cancelled/failed." + ), + handler: { args, context in + try systemUI.composeMail(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .messagesUICompose, + title: "Compose message with system UI", + summary: "Present system Messages compose UI with optional recipients, body, and sandbox file attachments.", + tags: ["messages", "sms", "compose", "system-ui", "share"], + example: "await apple.messages.compose({ recipients: ['4085551212'], body: 'Report ready' })", + optionalArguments: ["recipients", "subject", "body", "attachments", "timeoutMs"], + argumentTypes: [ + "recipients": .array, + "subject": .string, + "body": .string, + "attachments": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "recipients": "Optional array of phone number or address strings.", + "subject": "Optional subject on devices/accounts that support it.", + "body": "Optional message body.", + "attachments": "Optional array of { path, filename? } sandbox file attachments.", + "timeoutMs": "Optional timeout for waiting on user completion.", + ], + resultSummary: "Object with action sent/cancelled/failed." + ), + handler: { args, context in + try systemUI.composeMessage(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .webUIPresent, + title: "Present web page with system UI", + summary: "Present an HTTP(S) URL with the system Safari view controller.", + tags: ["web", "safari", "browser", "system-ui"], + example: "await apple.web.present({ url: 'https://example.com' })", + requiredArguments: ["url"], + optionalArguments: ["entersReaderIfAvailable", "timeoutMs"], + argumentTypes: [ + "url": .string, + "entersReaderIfAvailable": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "url": "Absolute HTTP(S) URL to present.", + "entersReaderIfAvailable": "Whether Safari may enter Reader automatically; default false.", + "timeoutMs": "Optional timeout for waiting on dismissal.", + ], + resultSummary: "Object with action dismissed." + ), + handler: { args, context in + try systemUI.presentWeb(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .authUIWebAuthenticate, + title: "Authenticate with system web UI", + summary: "Start an ASWebAuthenticationSession for OAuth-style browser authentication.", + tags: ["auth", "oauth", "web", "browser", "system-ui"], + example: "await apple.auth.webAuthenticate({ url: 'https://example.com/oauth', callbackURLScheme: 'myapp' })", + requiredArguments: ["url"], + optionalArguments: ["callbackURLScheme", "prefersEphemeralSession", "timeoutMs"], + argumentTypes: [ + "url": .string, + "callbackURLScheme": .string, + "prefersEphemeralSession": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "url": "Absolute HTTP(S) authentication URL.", + "callbackURLScheme": "Optional custom URL scheme that completes the session.", + "prefersEphemeralSession": "Whether to prefer a private browser session; default false.", + "timeoutMs": "Optional timeout for waiting on callback/cancellation.", + ], + resultSummary: "Object with action callback/cancelled and callbackURL when available." + ), + handler: { args, context in + try systemUI.authenticateWeb(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .uiAlertPresent, + title: "Present alert with custom buttons", + summary: "Present a system alert or action sheet and return the button the user selects.", + tags: ["ui", "alert", "dialog", "system-ui"], + example: "await apple.ui.presentAlert({ title: 'Delete draft?', message: 'This cannot be undone.', buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'delete', title: 'Delete', style: 'destructive' }] })", + requiredArguments: ["buttons"], + optionalArguments: ["title", "message", "preferredStyle", "timeoutMs"], + argumentTypes: [ + "title": .string, + "message": .string, + "preferredStyle": .string, + "buttons": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "title": "Optional alert title.", + "message": "Optional alert message.", + "preferredStyle": "alert (default) or actionSheet.", + "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Object with action/buttonID/buttonTitle/buttonIndex/style for the selected button." + ), + handler: { args, context in + try systemUI.presentAlert(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .visionImageAnalyze, diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index aad7c6a..9f37cee 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -3,6 +3,19 @@ import Foundation public final class SystemUIBridge: @unchecked Sendable { public init() {} + public func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateCalendarPickerArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.pickCalendar(arguments: arguments, context: context) + } + + public func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateNonemptyString(arguments, key: "identifier", capability: "calendar.ui.presentEvent") + try validateTimeoutMs(arguments, capability: "calendar.ui.presentEvent") + try context.checkCancellation() + return try context.systemUIPresenter.presentCalendarEvent(arguments: arguments, context: context) + } + public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validateCalendarEventArguments(arguments) try context.checkCancellation() @@ -21,6 +34,109 @@ public final class SystemUIBridge: @unchecked Sendable { return try context.systemUIPresenter.pickContacts(arguments: arguments, context: context) } + public func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateNonemptyString(arguments, key: "identifier", capability: "contacts.ui.presentContact") + try validateStringArray(arguments, key: "displayedPropertyKeys", capability: "contacts.ui.presentContact") + try validateTimeoutMs(arguments, capability: "contacts.ui.presentContact") + try context.checkCancellation() + return try context.systemUIPresenter.presentContact(arguments: arguments, context: context) + } + + public func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateStringArray(arguments, key: "phoneNumbers", capability: "contacts.ui.presentNewContact") + try validateStringArray(arguments, key: "emailAddresses", capability: "contacts.ui.presentNewContact") + try validateTimeoutMs(arguments, capability: "contacts.ui.presentNewContact") + try context.checkCancellation() + return try context.systemUIPresenter.presentNewContact(arguments: arguments, context: context) + } + + public func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateStringArray(arguments, key: "contentTypes", capability: "documents.ui.pick") + try validateNonemptyOptionalString(arguments, key: "outputDirectory", capability: "documents.ui.pick") + try validateTimeoutMs(arguments, capability: "documents.ui.pick") + try context.checkCancellation() + return try context.systemUIPresenter.pickDocuments(arguments: arguments, context: context) + } + + public func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateNonemptyOptionalString(arguments, key: "outputDirectory", capability: "documents.ui.scan") + try validateTimeoutMs(arguments, capability: "documents.ui.scan") + try context.checkCancellation() + return try context.systemUIPresenter.scanDocuments(arguments: arguments, context: context) + } + + public func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateShareArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.presentShareSheet(arguments: arguments, context: context) + } + + public func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validatePathOrPaths(arguments, capability: "quicklook.ui.preview") + try validateTimeoutMs(arguments, capability: "quicklook.ui.preview") + try context.checkCancellation() + return try context.systemUIPresenter.previewQuickLook(arguments: arguments, context: context) + } + + public func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validatePhotoPickerArguments(arguments, capability: "camera.ui.capture") + try context.checkCancellation() + return try context.systemUIPresenter.captureCamera(arguments: arguments, context: context) + } + + public func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateMessageRecipients(arguments, keys: ["to", "cc", "bcc"], capability: "mail.ui.compose") + try validateAttachmentObjects(arguments, capability: "mail.ui.compose") + try validateTimeoutMs(arguments, capability: "mail.ui.compose") + try context.checkCancellation() + return try context.systemUIPresenter.composeMail(arguments: arguments, context: context) + } + + public func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateMessageRecipients(arguments, keys: ["recipients"], capability: "messages.ui.compose") + try validateAttachmentObjects(arguments, capability: "messages.ui.compose") + try validateTimeoutMs(arguments, capability: "messages.ui.compose") + try context.checkCancellation() + return try context.systemUIPresenter.composeMessage(arguments: arguments, context: context) + } + + public func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateHTTPURL(arguments, key: "url", capability: "web.ui.present") + try validateTimeoutMs(arguments, capability: "web.ui.present") + try context.checkCancellation() + return try context.systemUIPresenter.presentWeb(arguments: arguments, context: context) + } + + public func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateHTTPURL(arguments, key: "url", capability: "auth.ui.webAuthenticate") + try validateNonemptyOptionalString(arguments, key: "callbackURLScheme", capability: "auth.ui.webAuthenticate") + try validateTimeoutMs(arguments, capability: "auth.ui.webAuthenticate") + try context.checkCancellation() + return try context.systemUIPresenter.authenticateWeb(arguments: arguments, context: context) + } + + public func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateAlertArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.presentAlert(arguments: arguments, context: context) + } + + private func validateCalendarPickerArguments(_ arguments: [String: JSONValue]) throws { + if let selectionStyle = arguments.string("selectionStyle")?.lowercased(), + ["single", "multiple"].contains(selectionStyle) == false + { + throw BridgeError.invalidArguments("calendar.ui.pickCalendar selectionStyle must be single or multiple") + } + + if let displayStyle = arguments.string("displayStyle")?.lowercased(), + ["writable", "all"].contains(displayStyle) == false + { + throw BridgeError.invalidArguments("calendar.ui.pickCalendar displayStyle must be writable or all") + } + + try validateTimeoutMs(arguments, capability: "calendar.ui.pickCalendar") + } + private func validateCalendarEventArguments(_ arguments: [String: JSONValue]) throws { let formatter = ISO8601DateFormatter() for key in ["start", "end"] { @@ -33,24 +149,19 @@ public final class SystemUIBridge: @unchecked Sendable { } } - private func validatePhotoPickerArguments(_ arguments: [String: JSONValue]) throws { + private func validatePhotoPickerArguments(_ arguments: [String: JSONValue], capability: String = "photos.ui.pick") throws { if let mediaType = arguments.string("mediaType")?.lowercased(), ["any", "image", "photo", "video"].contains(mediaType) == false { - throw BridgeError.invalidArguments("photos.ui.pick mediaType must be any, image, photo, or video") + throw BridgeError.invalidArguments("\(capability) mediaType must be any, image, photo, or video") } if let limit = arguments.int("limit"), limit <= 0 { - throw BridgeError.invalidArguments("photos.ui.pick limit must be greater than 0") + throw BridgeError.invalidArguments("\(capability) limit must be greater than 0") } - if let outputDirectory = arguments.string("outputDirectory"), - outputDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - throw BridgeError.invalidArguments("photos.ui.pick outputDirectory cannot be empty") - } - - try validateTimeoutMs(arguments, capability: "photos.ui.pick") + try validateNonemptyOptionalString(arguments, key: "outputDirectory", capability: capability) + try validateTimeoutMs(arguments, capability: capability) } private func validateContactPickerArguments(_ arguments: [String: JSONValue]) throws { @@ -60,13 +171,140 @@ public final class SystemUIBridge: @unchecked Sendable { throw BridgeError.invalidArguments("contacts.ui.pick mode must be single or multiple") } - if let keys = arguments.array("displayedPropertyKeys"), - keys.contains(where: { $0.stringValue == nil }) + try validateStringArray(arguments, key: "displayedPropertyKeys", capability: "contacts.ui.pick") + try validateTimeoutMs(arguments, capability: "contacts.ui.pick") + } + + private func validateAlertArguments(_ arguments: [String: JSONValue]) throws { + if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), + ["alert", "actionSheet", "actionsheet"].contains(preferredStyle) == false { - throw BridgeError.invalidArguments("contacts.ui.pick displayedPropertyKeys must contain only strings") + throw BridgeError.invalidArguments("ui.alert.present preferredStyle must be alert or actionSheet") } - try validateTimeoutMs(arguments, capability: "contacts.ui.pick") + guard let buttons = arguments.array("buttons"), buttons.isEmpty == false else { + throw BridgeError.invalidArguments("ui.alert.present requires a non-empty buttons array") + } + + var cancelCount = 0 + for (index, button) in buttons.enumerated() { + guard let object = button.objectValue else { + throw BridgeError.invalidArguments("ui.alert.present buttons must contain objects") + } + try validateNonemptyString(object, key: "title", capability: "ui.alert.present button \(index)") + try validateNonemptyOptionalString(object, key: "id", capability: "ui.alert.present button \(index)") + if let style = object.string("style")?.lowercased() { + guard ["default", "cancel", "destructive"].contains(style) else { + throw BridgeError.invalidArguments("ui.alert.present button style must be default, cancel, or destructive") + } + if style == "cancel" { + cancelCount += 1 + } + } + } + + if cancelCount > 1 { + throw BridgeError.invalidArguments("ui.alert.present supports at most one cancel button") + } + + try validateTimeoutMs(arguments, capability: "ui.alert.present") + } + + private func validateShareArguments(_ arguments: [String: JSONValue]) throws { + try validateHTTPURL(arguments, key: "url", capability: "share.ui.present", required: false) + try validateStringArray(arguments, key: "paths", capability: "share.ui.present") + try validateStringArray(arguments, key: "excludedActivityTypes", capability: "share.ui.present") + try validateNonemptyOptionalString(arguments, key: "path", capability: "share.ui.present") + try validateTimeoutMs(arguments, capability: "share.ui.present") + + let hasItems = + arguments.string("text") != nil || + arguments.string("url") != nil || + arguments.string("path") != nil || + arguments.array("paths")?.isEmpty == false + if hasItems == false { + throw BridgeError.invalidArguments("share.ui.present requires text, url, path, or paths") + } + } + + private func validatePathOrPaths(_ arguments: [String: JSONValue], capability: String) throws { + try validateNonemptyOptionalString(arguments, key: "path", capability: capability) + try validateStringArray(arguments, key: "paths", capability: capability) + + let hasPath = arguments.string("path") != nil + let hasPaths = arguments.array("paths")?.isEmpty == false + guard hasPath || hasPaths else { + throw BridgeError.invalidArguments("\(capability) requires path or paths") + } + } + + private func validateMessageRecipients(_ arguments: [String: JSONValue], keys: [String], capability: String) throws { + for key in keys { + try validateStringArray(arguments, key: key, capability: capability) + } + } + + private func validateAttachmentObjects(_ arguments: [String: JSONValue], capability: String) throws { + guard let attachments = arguments.array("attachments") else { + return + } + + for attachment in attachments { + guard let object = attachment.objectValue else { + throw BridgeError.invalidArguments("\(capability) attachments must contain objects") + } + try validateNonemptyString(object, key: "path", capability: capability) + try validateNonemptyOptionalString(object, key: "mimeType", capability: capability) + try validateNonemptyOptionalString(object, key: "filename", capability: capability) + } + } + + private func validateHTTPURL( + _ arguments: [String: JSONValue], + key: String, + capability: String, + required: Bool = true + ) throws { + guard let value = arguments.string(key) else { + if required { + throw BridgeError.invalidArguments("\(capability) requires \(key)") + } + return + } + + guard let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host?.isEmpty == false + else { + throw BridgeError.invalidArguments("\(capability) \(key) must be an absolute HTTP(S) URL") + } + } + + private func validateNonemptyString(_ arguments: [String: JSONValue], key: String, capability: String) throws { + guard let value = arguments.string(key)?.trimmingCharacters(in: .whitespacesAndNewlines), value.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) requires non-empty \(key)") + } + } + + private func validateNonemptyOptionalString(_ arguments: [String: JSONValue], key: String, capability: String) throws { + guard let value = arguments.string(key) else { + return + } + + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw BridgeError.invalidArguments("\(capability) \(key) cannot be empty") + } + } + + private func validateStringArray(_ arguments: [String: JSONValue], key: String, capability: String) throws { + guard let values = arguments.array(key) else { + return + } + + if values.contains(where: { $0.stringValue == nil }) { + throw BridgeError.invalidArguments("\(capability) \(key) must contain only strings") + } } private func validateTimeoutMs(_ arguments: [String: JSONValue], capability: String) throws { diff --git a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift index 71b6ef3..f1e2c67 100644 --- a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift +++ b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift @@ -55,15 +55,29 @@ enum JavaScriptBindingCatalog { .weatherRead: ["apple.weather.getCurrentWeather"], .calendarRead: ["apple.calendar.listEvents"], .calendarWrite: ["apple.calendar.createEvent"], + .calendarUIPickCalendar: ["apple.calendar.pickCalendar"], + .calendarUIPresentEvent: ["apple.calendar.presentEvent"], .calendarUIPresentNewEvent: ["apple.calendar.presentNewEvent"], .remindersRead: ["apple.reminders.listReminders"], .remindersWrite: ["apple.reminders.createReminder"], .contactsRead: ["apple.contacts.list"], .contactsSearch: ["apple.contacts.search"], .contactsUIPick: ["apple.contacts.pick"], + .contactsUIPresentContact: ["apple.contacts.presentContact"], + .contactsUIPresentNewContact: ["apple.contacts.presentNewContact"], .photosRead: ["apple.photos.list"], .photosExport: ["apple.photos.export"], .photosUIPick: ["apple.photos.pick"], + .documentsUIPick: ["apple.documents.pick"], + .documentsUIScan: ["apple.documents.scan"], + .shareUIPresent: ["apple.share.present"], + .quickLookUIPreview: ["apple.quicklook.preview"], + .cameraUICapture: ["apple.camera.capture"], + .mailUICompose: ["apple.mail.compose"], + .messagesUICompose: ["apple.messages.compose"], + .webUIPresent: ["apple.web.present"], + .authUIWebAuthenticate: ["apple.auth.webAuthenticate"], + .uiAlertPresent: ["apple.ui.presentAlert"], .visionImageAnalyze: ["apple.vision.analyzeImage"], .notificationsPermissionRequest: ["apple.notifications.requestPermission"], .notificationsSchedule: ["apple.notifications.schedule"], diff --git a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift index de76454..c76a3a1 100644 --- a/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift +++ b/Sources/CodeMode/Host/CodeModeAgentToolDescriptions.swift @@ -63,7 +63,7 @@ public enum CodeModeAgentToolDescriptions { public static let executeJavaScript = CodeModeAgentToolDescription( name: "executeJavaScript", description: """ - Execute JavaScript against the CodeMode runtime. Prefer searchJavaScriptAPI first when choosing helpers or arguments. Cross-platform helpers live under apple.* and platform-specific helpers live under platform namespaces such as ios.alarm.*. System UI helpers such as apple.calendar.presentNewEvent, apple.photos.pick, and apple.contacts.pick are installed only on iOS/visionOS hosts that provide a SystemUIPresenter. Only helpers supported on the current host platform are installed. The runtime already wraps your code in an async function, so use top-level await directly and return the final value with an explicit top-level return statement; do not use an unreturned async IIFE as the final expression. Include only the required capabilities in allowedCapabilities. Execution streams logs and diagnostics and returns structured CodeModeToolError failures for syntax errors, missing JS helpers, runtime throws, validation failures, permission denials, timeouts, cancellation, and internal errors. + Execute JavaScript against the CodeMode runtime. Prefer searchJavaScriptAPI first when choosing helpers or arguments. Cross-platform helpers live under apple.* and platform-specific helpers live under platform namespaces such as ios.alarm.*. System UI helpers such as apple.ui.presentAlert, apple.calendar.presentNewEvent, apple.photos.pick, apple.contacts.pick, apple.documents.pick, apple.share.present, apple.quicklook.preview, apple.web.present, and apple.auth.webAuthenticate require a host-provided SystemUIPresenter; camera, document scan, mail, and message compose helpers are iOS-only. Only helpers supported on the current host platform are installed. The runtime already wraps your code in an async function, so use top-level await directly and return the final value with an explicit top-level return statement; do not use an unreturned async IIFE as the final expression. Include only the required capabilities in allowedCapabilities. Execution streams logs and diagnostics and returns structured CodeModeToolError failures for syntax errors, missing JS helpers, runtime throws, validation failures, permission denials, timeouts, cancellation, and internal errors. """ ) diff --git a/Sources/CodeMode/Host/HostConfigurationValidator.swift b/Sources/CodeMode/Host/HostConfigurationValidator.swift index 8606e0c..ee6cae0 100644 --- a/Sources/CodeMode/Host/HostConfigurationValidator.swift +++ b/Sources/CodeMode/Host/HostConfigurationValidator.swift @@ -25,15 +25,22 @@ public enum HostConfigurationValidator { keys.insert("NSLocationWhenInUseUsageDescription") } - if capabilities.contains(.contactsRead) || capabilities.contains(.contactsSearch) { + if capabilities.contains(.contactsRead) || + capabilities.contains(.contactsSearch) || + capabilities.contains(.contactsUIPresentContact) || + capabilities.contains(.contactsUIPresentNewContact) + { keys.insert("NSContactsUsageDescription") } - if capabilities.contains(.calendarWrite) || capabilities.contains(.calendarUIPresentNewEvent) { + if capabilities.contains(.calendarWrite) || + capabilities.contains(.calendarUIPresentNewEvent) || + capabilities.contains(.calendarUIPickCalendar) + { keys.insert("NSCalendarsWriteOnlyAccessUsageDescription") } - if capabilities.contains(.calendarRead) { + if capabilities.contains(.calendarRead) || capabilities.contains(.calendarUIPresentEvent) { keys.insert("NSCalendarsFullAccessUsageDescription") } @@ -45,6 +52,14 @@ public enum HostConfigurationValidator { keys.insert("NSPhotoLibraryUsageDescription") } + if capabilities.contains(.cameraUICapture) || capabilities.contains(.documentsUIScan) { + keys.insert("NSCameraUsageDescription") + } + + if capabilities.contains(.cameraUICapture) { + keys.insert("NSMicrophoneUsageDescription") + } + if capabilities.contains(.homeRead) || capabilities.contains(.homeWrite) { keys.insert("NSHomeKitUsageDescription") } diff --git a/Sources/CodeMode/Registry/CapabilityRegistry.swift b/Sources/CodeMode/Registry/CapabilityRegistry.swift index 9d0170e..068be2c 100644 --- a/Sources/CodeMode/Registry/CapabilityRegistry.swift +++ b/Sources/CodeMode/Registry/CapabilityRegistry.swift @@ -93,6 +93,10 @@ public struct CapabilityDescriptor: Sendable, Equatable { "title": .string, "notes": .string, "location": .string, + "selectionStyle": .string, + "displayStyle": .string, + "allowsEditing": .bool, + "allowsCalendarPreview": .bool, "dueDate": .string, "query": .string, "limit": .number, @@ -102,6 +106,28 @@ public struct CapabilityDescriptor: Sendable, Equatable { "outputDirectory": .string, "timeoutMs": .number, "displayedPropertyKeys": .array, + "allowsActions": .bool, + "givenName": .string, + "familyName": .string, + "organization": .string, + "phoneNumbers": .array, + "emailAddresses": .array, + "contentTypes": .array, + "allowMultiple": .bool, + "text": .string, + "paths": .array, + "subject": .string, + "excludedActivityTypes": .array, + "cc": .array, + "bcc": .array, + "isHTML": .bool, + "attachments": .array, + "recipients": .array, + "entersReaderIfAvailable": .bool, + "callbackURLScheme": .string, + "prefersEphemeralSession": .bool, + "preferredStyle": .string, + "buttons": .array, "features": .array, "maxResults": .number, "identifier": .string, diff --git a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift index 738ab01..4f84ecd 100644 --- a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift +++ b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift @@ -112,6 +112,8 @@ enum RuntimeJavaScript { globalThis.apple.calendar = { listEvents: function(args) { return __invokeAsync('calendar.read', args || {}); }, createEvent: function(args) { return __invokeAsync('calendar.write', args || {}); }, + pickCalendar: function(args) { return __invokeAsync('calendar.ui.pickCalendar', args || {}); }, + presentEvent: function(args) { return __invokeAsync('calendar.ui.presentEvent', args || {}); }, presentNewEvent: function(args) { return __invokeAsync('calendar.ui.presentNewEvent', args || {}); } }; @@ -123,7 +125,9 @@ enum RuntimeJavaScript { globalThis.apple.contacts = { list: function(args) { return __invokeAsync('contacts.read', args || {}); }, search: function(args) { return __invokeAsync('contacts.search', args || {}); }, - pick: function(args) { return __invokeAsync('contacts.ui.pick', args || {}); } + pick: function(args) { return __invokeAsync('contacts.ui.pick', args || {}); }, + presentContact: function(args) { return __invokeAsync('contacts.ui.presentContact', args || {}); }, + presentNewContact: function(args) { return __invokeAsync('contacts.ui.presentNewContact', args || {}); } }; globalThis.apple.photos = { @@ -132,6 +136,43 @@ enum RuntimeJavaScript { pick: function(args) { return __invokeAsync('photos.ui.pick', args || {}); } }; + globalThis.apple.documents = { + pick: function(args) { return __invokeAsync('documents.ui.pick', args || {}); }, + scan: function(args) { return __invokeAsync('documents.ui.scan', args || {}); } + }; + + globalThis.apple.share = { + present: function(args) { return __invokeAsync('share.ui.present', args || {}); } + }; + + globalThis.apple.quicklook = { + preview: function(args) { return __invokeAsync('quicklook.ui.preview', args || {}); } + }; + + globalThis.apple.camera = { + capture: function(args) { return __invokeAsync('camera.ui.capture', args || {}); } + }; + + globalThis.apple.mail = { + compose: function(args) { return __invokeAsync('mail.ui.compose', args || {}); } + }; + + globalThis.apple.messages = { + compose: function(args) { return __invokeAsync('messages.ui.compose', args || {}); } + }; + + globalThis.apple.web = { + present: function(args) { return __invokeAsync('web.ui.present', args || {}); } + }; + + globalThis.apple.auth = { + webAuthenticate: function(args) { return __invokeAsync('auth.ui.webAuthenticate', args || {}); } + }; + + globalThis.apple.ui = { + presentAlert: function(args) { return __invokeAsync('ui.alert.present', args || {}); } + }; + globalThis.apple.vision = { analyzeImage: function(args) { return __invokeAsync('vision.image.analyze', args || {}); } }; diff --git a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift index 72069b8..9470469 100644 --- a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift +++ b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift @@ -67,9 +67,23 @@ enum CapabilityPlatformSupport { ] private static let iOSCapabilities = crossAppleCapabilities.union([ + .calendarUIPickCalendar, + .calendarUIPresentEvent, .calendarUIPresentNewEvent, .contactsUIPick, + .contactsUIPresentContact, + .contactsUIPresentNewContact, .photosUIPick, + .documentsUIPick, + .documentsUIScan, + .shareUIPresent, + .quickLookUIPreview, + .cameraUICapture, + .mailUICompose, + .messagesUICompose, + .webUIPresent, + .authUIWebAuthenticate, + .uiAlertPresent, .locationPermissionRequest, .alarmPermissionRequest, .alarmRead, @@ -80,9 +94,19 @@ enum CapabilityPlatformSupport { private static let macOSCapabilities = crossAppleCapabilities private static let visionOSCapabilities = crossAppleCapabilities.union([ + .calendarUIPickCalendar, + .calendarUIPresentEvent, .calendarUIPresentNewEvent, .contactsUIPick, + .contactsUIPresentContact, + .contactsUIPresentNewContact, .photosUIPick, + .documentsUIPick, + .shareUIPresent, + .quickLookUIPreview, + .webUIPresent, + .authUIWebAuthenticate, + .uiAlertPresent, ]) } diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift index 251542f..3e2f88a 100644 --- a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift @@ -1,13 +1,22 @@ import Foundation #if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import AuthenticationServices @preconcurrency import Contacts @preconcurrency import ContactsUI @preconcurrency import EventKit @preconcurrency import EventKitUI @preconcurrency import PhotosUI +@preconcurrency import QuickLook +@preconcurrency import SafariServices @preconcurrency import UIKit @preconcurrency import UniformTypeIdentifiers +#if canImport(MessageUI) +@preconcurrency import MessageUI +#endif +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendable { private static let defaultTimeoutMs = 300_000 @@ -20,6 +29,80 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl self.presentingViewControllerProvider = presentingViewController } + public func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let selectionStyle = arguments.string("selectionStyle")?.lowercased() == "multiple" + ? EKCalendarChooserSelectionStyle.multiple + : EKCalendarChooserSelectionStyle.single + let displayStyle = arguments.string("displayStyle")?.lowercased() == "all" + ? EKCalendarChooserDisplayStyle.allCalendars + : EKCalendarChooserDisplayStyle.writableCalendarsOnly + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let store = EKEventStore() + let controller = EKCalendarChooser( + selectionStyle: selectionStyle, + displayStyle: displayStyle, + entityType: .event, + eventStore: store + ) + controller.showsDoneButton = true + controller.showsCancelButton = true + + let coordinator = CalendarChooserCoordinator { [weak self] calendars in + self?.releaseCoordinator(token) + complete(.success(.array(calendars.map { self?.mapCalendar($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let identifier = arguments.string("identifier") ?? "" + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let store = EKEventStore() + guard let event = store.event(withIdentifier: identifier) else { + complete(.failure(.invalidArguments("calendar.ui.presentEvent could not find event identifier \(identifier)"))) + return + } + + let controller = EKEventViewController() + controller.event = event + controller.allowsEditing = arguments.bool("allowsEditing") ?? false + controller.allowsCalendarPreview = arguments.bool("allowsCalendarPreview") ?? true + + let coordinator = CalendarEventViewCoordinator { [weak self] in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string("dismissed")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + public func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { let timeoutMs = Self.defaultTimeoutMs let formatter = ISO8601DateFormatter() @@ -120,38 +203,569 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl ) ) } - return .array(exported) + return .array(exported) + } + + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mode = arguments.string("mode")?.lowercased() ?? "single" + let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = CNContactPickerViewController() + controller.displayedPropertyKeys = displayedPropertyKeys + controller.predicateForSelectionOfContact = NSPredicate(value: true) + + if mode == "multiple" { + let coordinator = ContactMultiplePickerCoordinator { [weak self] contacts in + self?.releaseCoordinator(token) + complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + } else { + let coordinator = ContactSinglePickerCoordinator { [weak self] contacts in + self?.releaseCoordinator(token) + complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + } + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let identifier = arguments.string("identifier") ?? "" + let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let contact = try self.fetchContact(identifier: identifier, displayedPropertyKeys: displayedPropertyKeys) + let controller = CNContactViewController(for: contact) + controller.allowsEditing = arguments.bool("allowsEditing") ?? false + controller.allowsActions = arguments.bool("allowsActions") ?? true + controller.displayedPropertyKeys = displayedPropertyKeys + + let coordinator = ContactViewCoordinator { [weak self] contact in + var object: [String: JSONValue] = ["action": .string("dismissed")] + if let contact { + object["contact"] = self?.mapContact(contact) ?? .null + } + self?.releaseCoordinator(token) + complete(.success(.object(object))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let contact = CNMutableContact() + contact.givenName = arguments.string("givenName") ?? "" + contact.familyName = arguments.string("familyName") ?? "" + contact.organizationName = arguments.string("organization") ?? "" + contact.phoneNumbers = (arguments.array("phoneNumbers") ?? []).compactMap(\.stringValue).map { + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0)) + } + contact.emailAddresses = (arguments.array("emailAddresses") ?? []).compactMap(\.stringValue).map { + CNLabeledValue(label: CNLabelHome, value: NSString(string: $0)) + } + + let controller = CNContactViewController(forNewContact: contact) + let coordinator = ContactViewCoordinator { [weak self] contact in + var object: [String: JSONValue] = [ + "action": .string(contact == nil ? "cancelled" : "saved"), + ] + if let contact { + object["contact"] = self?.mapContact(contact) ?? .null + } + self?.releaseCoordinator(token) + complete(.success(.object(object))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(UINavigationController(rootViewController: controller), animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let outputDirectory = try outputDirectoryURL(from: arguments, context: context) + let types = try documentContentTypes(from: arguments) + let allowMultiple = arguments.bool("allowMultiple") ?? false + let token = UUID() + + let urls: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) + controller.allowsMultipleSelection = allowMultiple + + let coordinator = DocumentPickerCoordinator { [weak self] urls in + self?.releaseCoordinator(token) + complete(.success(urls)) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + return .array(try urls.enumerated().map { index, url in + try context.checkCancellation() + return try copyExternalFile( + from: url, + suggestedName: url.lastPathComponent.isEmpty ? "document-\(index)" : url.lastPathComponent, + outputDirectory: outputDirectory, + context: context + ) + }) + } + + public func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if os(iOS) && canImport(VisionKit) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let outputDirectory = try outputDirectoryURL(from: arguments, context: context) + let token = UUID() + + let urls: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard VNDocumentCameraViewController.isSupported else { + complete(.failure(.unsupportedPlatform("documents.ui.scan"))) + return + } + + let presenter = try self.requirePresenter() + let controller = VNDocumentCameraViewController() + let coordinator = DocumentScanCoordinator(outputDirectory: outputDirectory) { [weak self] result in + self?.releaseCoordinator(token) + complete(result) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + return .array(try urls.enumerated().map { index, url in + try context.checkCancellation() + return try fileArtifactMetadata( + url: url, + artifactName: url.lastPathComponent, + context: context, + extra: ["pageIndex": .number(Double(index))] + ) + }) + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("documents.ui.scan") + #endif + } + + public func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let items = try self.shareItems(from: arguments, context: context) + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + controller.excludedActivityTypes = (arguments.array("excludedActivityTypes") ?? []) + .compactMap(\.stringValue) + .map(UIActivity.ActivityType.init(rawValue:)) + if let subject = arguments.string("subject") { + controller.setValue(subject, forKey: "subject") + } + controller.popoverPresentationController?.sourceView = presenter.view + controller.popoverPresentationController?.sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + + self.retainCoordinator(controller, token: token) + controller.completionWithItemsHandler = { [weak self] activityType, completed, _, error in + self?.releaseCoordinator(token) + if let error { + complete(.failure(.nativeFailure(error.localizedDescription))) + return + } + var object: [String: JSONValue] = [ + "action": .string(completed ? "completed" : "cancelled"), + "completed": .bool(completed), + ] + if let activityType { + object["activityType"] = .string(activityType.rawValue) + } + complete(.success(.object(object))) + } + + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let urls = try sandboxURLs(arguments: arguments, context: context) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = QLPreviewController() + let coordinator = QuickLookCoordinator(urls: urls) { [weak self] in + self?.releaseCoordinator(token) + complete(.success(.object([ + "action": .string("dismissed"), + "count": .number(Double(urls.count)), + ]))) + } + self.retainCoordinator(coordinator, token: token) + controller.dataSource = coordinator + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if os(iOS) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let mediaType = arguments.string("mediaType")?.lowercased() ?? "any" + let outputDirectory = try outputDirectoryURL(from: arguments, context: context) + let token = UUID() + + let capture: CameraCaptureResult? = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard UIImagePickerController.isSourceTypeAvailable(.camera) else { + complete(.failure(.unsupportedPlatform("camera.ui.capture"))) + return + } + + let presenter = try self.requirePresenter() + let controller = UIImagePickerController() + controller.sourceType = .camera + let mediaTypes = self.cameraMediaTypes(for: mediaType) + guard mediaTypes.isEmpty == false else { + complete(.failure(.unsupportedPlatform("camera.ui.capture \(mediaType)"))) + return + } + controller.mediaTypes = mediaTypes + controller.allowsEditing = false + + let coordinator = CameraCaptureCoordinator(outputDirectory: outputDirectory) { [weak self] result in + self?.releaseCoordinator(token) + complete(result) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + guard let capture else { + return .object(["action": .string("cancelled")]) + } + return try fileArtifactMetadata( + url: capture.url, + artifactName: capture.url.lastPathComponent, + context: context, + mediaType: capture.mediaType, + typeIdentifier: capture.typeIdentifier, + extra: ["action": .string("captured")] + ) + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("camera.ui.capture") + #endif + } + + public func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(MessageUI) && os(iOS) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard MFMailComposeViewController.canSendMail() else { + complete(.failure(.unsupportedPlatform("mail.ui.compose"))) + return + } + + let presenter = try self.requirePresenter() + let controller = MFMailComposeViewController() + controller.setToRecipients(self.stringArray(arguments, key: "to")) + controller.setCcRecipients(self.stringArray(arguments, key: "cc")) + controller.setBccRecipients(self.stringArray(arguments, key: "bcc")) + controller.setSubject(arguments.string("subject") ?? "") + controller.setMessageBody(arguments.string("body") ?? "", isHTML: arguments.bool("isHTML") ?? false) + try self.addMailAttachments(from: arguments, to: controller, context: context) + + let coordinator = MailComposeCoordinator { [weak self] result in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string(self?.mailActionString(result) ?? "unknown")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.mailComposeDelegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("mail.ui.compose") + #endif + } + + public func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(MessageUI) && os(iOS) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard MFMessageComposeViewController.canSendText() else { + complete(.failure(.unsupportedPlatform("messages.ui.compose"))) + return + } + + let presenter = try self.requirePresenter() + let controller = MFMessageComposeViewController() + controller.recipients = self.stringArray(arguments, key: "recipients") + controller.body = arguments.string("body") + if MFMessageComposeViewController.canSendSubject() { + controller.subject = arguments.string("subject") + } + try self.addMessageAttachments(from: arguments, to: controller, context: context) + + let coordinator = MessageComposeCoordinator { [weak self] result in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string(self?.messageActionString(result) ?? "unknown")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.messageComposeDelegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("messages.ui.compose") + #endif + } + + public func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let url = URL(string: arguments.string("url") ?? "")! + let token = UUID() + + #if os(iOS) + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let configuration = SFSafariViewController.Configuration() + configuration.entersReaderIfAvailable = arguments.bool("entersReaderIfAvailable") ?? false + let controller = SFSafariViewController(url: url, configuration: configuration) + let coordinator = SafariCoordinator { [weak self] in + self?.releaseCoordinator(token) + complete(.success(.object(["action": .string("dismissed")]))) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + return try runUIOperation(timeoutMs: timeoutMs) { complete in + UIApplication.shared.open(url, options: [:]) { success in + complete(.success(.object([ + "action": .string(success ? "opened" : "failed"), + "opened": .bool(success), + ]))) + } + } + #endif + } + + public func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let url = URL(string: arguments.string("url") ?? "")! + let callbackURLScheme = arguments.string("callbackURLScheme") + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + guard let anchor = presenter.view.window ?? UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap(\.windows) + .first(where: { $0.isKeyWindow }) + else { + complete(.failure(.uiPresenterUnavailable)) + return + } + + let coordinator = WebAuthenticationCoordinator(anchor: anchor) + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackURLScheme + ) { [weak self] callbackURL, error in + self?.releaseCoordinator(token) + if let callbackURL { + complete(.success(.object([ + "action": .string("callback"), + "callbackURL": .string(callbackURL.absoluteString), + ]))) + return + } + + if let error = error as? ASWebAuthenticationSessionError, + error.code == .canceledLogin + { + complete(.success(.object(["action": .string("cancelled")]))) + return + } + + if let error { + complete(.failure(.nativeFailure(error.localizedDescription))) + } else { + complete(.success(.object(["action": .string("cancelled")]))) + } + } + session.presentationContextProvider = coordinator + session.prefersEphemeralWebBrowserSession = arguments.bool("prefersEphemeralSession") ?? false + coordinator.session = session + self.retainCoordinator(coordinator, token: token) + + guard session.start() else { + self.releaseCoordinator(token) + complete(.failure(.nativeFailure("auth.ui.webAuthenticate could not start authentication session"))) + return + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } } - public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + public func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs - let mode = arguments.string("mode")?.lowercased() ?? "single" - let displayedPropertyKeys = arguments.array("displayedPropertyKeys")?.compactMap(\.stringValue) let token = UUID() return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in do { let presenter = try self.requirePresenter() - let controller = CNContactPickerViewController() - controller.displayedPropertyKeys = displayedPropertyKeys - controller.predicateForSelectionOfContact = NSPredicate(value: true) + let preferredStyle = arguments.string("preferredStyle")?.lowercased() + let controller = UIAlertController( + title: arguments.string("title"), + message: arguments.string("message"), + preferredStyle: preferredStyle == "actionsheet" ? .actionSheet : .alert + ) - if mode == "multiple" { - let coordinator = ContactMultiplePickerCoordinator { [weak self] contacts in - self?.releaseCoordinator(token) - complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) - } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator - } else { - let coordinator = ContactSinglePickerCoordinator { [weak self] contacts in - self?.releaseCoordinator(token) - complete(.success(.array(contacts.map { self?.mapContact($0) ?? .null }))) + for (index, button) in (arguments.array("buttons") ?? []).enumerated() { + guard let object = button.objectValue else { + complete(.failure(.invalidArguments("ui.alert.present buttons must contain objects"))) + return } - self.retainCoordinator(coordinator, token: token) - controller.delegate = coordinator + let title = object.string("title") ?? "" + let id = object.string("id") ?? title + let styleName = object.string("style")?.lowercased() ?? "default" + let actionStyle = self.alertActionStyle(styleName) + controller.addAction( + UIAlertAction(title: title, style: actionStyle) { [weak self] _ in + self?.releaseCoordinator(token) + complete(.success(.object([ + "action": .string("selected"), + "buttonID": .string(id), + "buttonTitle": .string(title), + "buttonIndex": .number(Double(index)), + "style": .string(styleName), + ]))) + } + ) } + controller.popoverPresentationController?.sourceView = presenter.view + controller.popoverPresentationController?.sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + + self.retainCoordinator(controller, token: token) presenter.present(controller, animated: true) } catch let error as BridgeError { complete(.failure(error)) @@ -231,6 +845,17 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } + @MainActor private func alertActionStyle(_ styleName: String) -> UIAlertAction.Style { + switch styleName { + case "cancel": + return .cancel + case "destructive": + return .destructive + default: + return .default + } + } + private func exportPickerResult( _ result: PhotoPickerSelection, index: Int, @@ -335,6 +960,32 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return "unknown" } + private func mapCalendar(_ calendar: EKCalendar) -> JSONValue { + .object([ + "identifier": .string(calendar.calendarIdentifier), + "title": .string(calendar.title), + "type": .string(calendarTypeString(calendar.type)), + "allowsContentModifications": .bool(calendar.allowsContentModifications), + ]) + } + + private func calendarTypeString(_ type: EKCalendarType) -> String { + switch type { + case .local: + return "local" + case .calDAV: + return "calDAV" + case .exchange: + return "exchange" + case .subscription: + return "subscription" + case .birthday: + return "birthday" + @unknown default: + return "unknown" + } + } + private func mapContact(_ contact: CNContact) -> JSONValue { .object([ "identifier": .string(contact.identifier), @@ -346,6 +997,232 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl ]) } + @MainActor private func fetchContact(identifier: String, displayedPropertyKeys: [String]?) throws -> CNContact { + let store = CNContactStore() + var keys: [CNKeyDescriptor] = [ + CNContactViewController.descriptorForRequiredKeys(), + CNContactGivenNameKey as CNKeyDescriptor, + CNContactFamilyNameKey as CNKeyDescriptor, + CNContactOrganizationNameKey as CNKeyDescriptor, + CNContactPhoneNumbersKey as CNKeyDescriptor, + CNContactEmailAddressesKey as CNKeyDescriptor, + ] + keys.append(contentsOf: (displayedPropertyKeys ?? []).map { $0 as CNKeyDescriptor }) + return try store.unifiedContact(withIdentifier: identifier, keysToFetch: keys) + } + + private func outputDirectoryURL(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> URL { + let outputDirectory = arguments.string("outputDirectory") ?? "tmp:" + let outputURL = try context.pathPolicy.resolve(path: outputDirectory) + try FileManager.default.createDirectory(at: outputURL, withIntermediateDirectories: true) + return outputURL + } + + private func documentContentTypes(from arguments: [String: JSONValue]) throws -> [UTType] { + guard let identifiers = arguments.array("contentTypes")?.compactMap(\.stringValue), identifiers.isEmpty == false else { + return [.item] + } + + return try identifiers.map { identifier in + guard let type = UTType(identifier) else { + throw BridgeError.invalidArguments("documents.ui.pick contentTypes contains unknown UTType \(identifier)") + } + return type + } + } + + private func copyExternalFile( + from sourceURL: URL, + suggestedName: String, + outputDirectory: URL, + context: BridgeInvocationContext + ) throws -> JSONValue { + let accessed = sourceURL.startAccessingSecurityScopedResource() + defer { + if accessed { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + let outputURL = uniqueOutputURL(in: outputDirectory, suggestedName: suggestedName) + try FileManager.default.copyItem(at: sourceURL, to: outputURL) + return try fileArtifactMetadata( + url: outputURL, + artifactName: outputURL.lastPathComponent, + context: context, + typeIdentifier: typeIdentifier(for: outputURL) + ) + } + + private func fileArtifactMetadata( + url: URL, + artifactName: String, + context: BridgeInvocationContext, + mediaType: String? = nil, + typeIdentifier uniformTypeIdentifier: String? = nil, + extra: [String: JSONValue] = [:] + ) throws -> JSONValue { + let resolvedTypeIdentifier = uniformTypeIdentifier ?? typeIdentifier(for: url) + let mimeType = resolvedTypeIdentifier.flatMap { UTType($0)?.preferredMIMEType } + let artifact = try context.artifactStore.register(url: url, mimeType: mimeType) + let bytes = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.doubleValue ?? 0 + + var object: [String: JSONValue] = [ + "path": .string(url.path), + "artifactID": .string(artifact.id), + "filename": .string(artifactName), + "bytes": .number(bytes), + ] + if let resolvedTypeIdentifier { + object["uniformTypeIdentifier"] = .string(resolvedTypeIdentifier) + } + if let mimeType { + object["mimeType"] = .string(mimeType) + } + if let mediaType { + object["mediaType"] = .string(mediaType) + } else if let resolvedTypeIdentifier { + object["mediaType"] = .string(mediaTypeString(for: resolvedTypeIdentifier)) + } + for (key, value) in extra { + object[key] = value + } + return .object(object) + } + + private func typeIdentifier(for url: URL) -> String? { + if let resourceType = try? url.resourceValues(forKeys: [.contentTypeKey]).contentType { + return resourceType.identifier + } + return UTType(filenameExtension: url.pathExtension)?.identifier + } + + private func uniqueOutputURL(in directory: URL, suggestedName: String) -> URL { + let cleanName = suggestedName.isEmpty ? "artifact-\(UUID().uuidString)" : suggestedName + let initial = directory.appendingPathComponent(cleanName) + guard FileManager.default.fileExists(atPath: initial.path) else { + return initial + } + + let base = initial.deletingPathExtension().lastPathComponent + let ext = initial.pathExtension + let name = ext.isEmpty ? "\(base)-\(UUID().uuidString)" : "\(base)-\(UUID().uuidString).\(ext)" + return directory.appendingPathComponent(name) + } + + private func sandboxURLs(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [URL] { + var paths: [String] = [] + if let path = arguments.string("path") { + paths.append(path) + } + paths.append(contentsOf: (arguments.array("paths") ?? []).compactMap(\.stringValue)) + return try paths.map { try context.pathPolicy.resolve(path: $0) } + } + + @MainActor private func shareItems(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [Any] { + var items: [Any] = [] + if let text = arguments.string("text") { + items.append(text) + } + if let urlString = arguments.string("url"), let url = URL(string: urlString) { + items.append(url) + } + if let path = arguments.string("path") { + items.append(try context.pathPolicy.resolve(path: path)) + } + for value in arguments.array("paths") ?? [] { + if let path = value.stringValue { + items.append(try context.pathPolicy.resolve(path: path)) + } + } + return items + } + + private func stringArray(_ arguments: [String: JSONValue], key: String) -> [String]? { + guard let values = arguments.array(key) else { + return nil + } + return values.compactMap(\.stringValue) + } + + #if os(iOS) + @MainActor private func cameraMediaTypes(for mediaType: String) -> [String] { + let available = UIImagePickerController.availableMediaTypes(for: .camera) ?? [UTType.image.identifier] + switch mediaType { + case "image", "photo": + return available.filter { UTType($0)?.conforms(to: .image) == true } + case "video": + return available.filter { UTType($0)?.conforms(to: .movie) == true || UTType($0)?.conforms(to: .video) == true } + default: + return available + } + } + #endif + + #if canImport(MessageUI) && os(iOS) + @MainActor private func addMailAttachments( + from arguments: [String: JSONValue], + to controller: MFMailComposeViewController, + context: BridgeInvocationContext + ) throws { + for attachment in arguments.array("attachments") ?? [] { + guard let object = attachment.objectValue, let path = object.string("path") else { + continue + } + let url = try context.pathPolicy.resolve(path: path) + let data = try Data(contentsOf: url) + let mimeType = object.string("mimeType") ?? typeIdentifier(for: url).flatMap { UTType($0)?.preferredMIMEType } ?? "application/octet-stream" + let filename = object.string("filename") ?? url.lastPathComponent + controller.addAttachmentData(data, mimeType: mimeType, fileName: filename) + } + } + + @MainActor private func addMessageAttachments( + from arguments: [String: JSONValue], + to controller: MFMessageComposeViewController, + context: BridgeInvocationContext + ) throws { + for attachment in arguments.array("attachments") ?? [] { + guard let object = attachment.objectValue, let path = object.string("path") else { + continue + } + let url = try context.pathPolicy.resolve(path: path) + let filename = object.string("filename") + if controller.addAttachmentURL(url, withAlternateFilename: filename) == false { + throw BridgeError.nativeFailure("messages.ui.compose could not attach \(path)") + } + } + } + + private func mailActionString(_ result: MFMailComposeResult) -> String { + switch result { + case .cancelled: + return "cancelled" + case .saved: + return "saved" + case .sent: + return "sent" + case .failed: + return "failed" + @unknown default: + return "unknown" + } + } + + private func messageActionString(_ result: MessageComposeResult) -> String { + switch result { + case .cancelled: + return "cancelled" + case .sent: + return "sent" + case .failed: + return "failed" + @unknown default: + return "unknown" + } + } + #endif + private func availableString(_ key: String, contact: CNContact, read: (CNContact) -> String) -> String { contact.isKeyAvailable(key) ? read(contact) : "" } @@ -389,6 +1266,39 @@ private struct PhotoPickerSelection: @unchecked Sendable { } } +@MainActor private final class CalendarChooserCoordinator: NSObject, @preconcurrency EKCalendarChooserDelegate { + private let onComplete: (Set) -> Void + + init(onComplete: @escaping (Set) -> Void) { + self.onComplete = onComplete + } + + func calendarChooserDidFinish(_ calendarChooser: EKCalendarChooser) { + let calendars = calendarChooser.selectedCalendars + calendarChooser.dismiss(animated: true) + onComplete(calendars) + } + + func calendarChooserDidCancel(_ calendarChooser: EKCalendarChooser) { + calendarChooser.dismiss(animated: true) + onComplete([]) + } +} + +@MainActor private final class CalendarEventViewCoordinator: NSObject, @preconcurrency EKEventViewDelegate { + private let onComplete: () -> Void + + init(onComplete: @escaping () -> Void) { + self.onComplete = onComplete + } + + func eventViewController(_ controller: EKEventViewController, didCompleteWith action: EKEventViewAction) { + _ = action + controller.dismiss(animated: true) + onComplete() + } +} + @MainActor private final class PhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { private let onComplete: ([PHPickerResult]) -> Void @@ -437,4 +1347,238 @@ private struct PhotoPickerSelection: @unchecked Sendable { onComplete(contacts) } } + +@MainActor private final class ContactViewCoordinator: NSObject, @preconcurrency CNContactViewControllerDelegate { + private let onComplete: (CNContact?) -> Void + + init(onComplete: @escaping (CNContact?) -> Void) { + self.onComplete = onComplete + } + + func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) { + viewController.dismiss(animated: true) + onComplete(contact) + } +} + +@MainActor private final class DocumentPickerCoordinator: NSObject, UIDocumentPickerDelegate { + private let onComplete: ([URL]) -> Void + + init(onComplete: @escaping ([URL]) -> Void) { + self.onComplete = onComplete + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + controller.dismiss(animated: true) + onComplete([]) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + controller.dismiss(animated: true) + onComplete(urls) + } +} + +@MainActor private final class QuickLookCoordinator: NSObject, QLPreviewControllerDataSource, @preconcurrency QLPreviewControllerDelegate { + private let urls: [URL] + private let onComplete: () -> Void + + init(urls: [URL], onComplete: @escaping () -> Void) { + self.urls = urls + self.onComplete = onComplete + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + _ = controller + return urls.count + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + _ = controller + return urls[index] as NSURL + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + _ = controller + onComplete() + } +} + +#if os(iOS) +private struct CameraCaptureResult: Sendable { + var url: URL + var mediaType: String + var typeIdentifier: String +} + +@MainActor private final class CameraCaptureCoordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let outputDirectory: URL + private let onComplete: (Result) -> Void + + init(outputDirectory: URL, onComplete: @escaping (Result) -> Void) { + self.outputDirectory = outputDirectory + self.onComplete = onComplete + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + onComplete(.success(nil)) + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + do { + let result = try exportCapture(info: info) + picker.dismiss(animated: true) + onComplete(.success(result)) + } catch let error as BridgeError { + picker.dismiss(animated: true) + onComplete(.failure(error)) + } catch { + picker.dismiss(animated: true) + onComplete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + private func exportCapture(info: [UIImagePickerController.InfoKey: Any]) throws -> CameraCaptureResult { + let mediaType = (info[.mediaType] as? String) ?? UTType.image.identifier + if UTType(mediaType)?.conforms(to: .movie) == true || UTType(mediaType)?.conforms(to: .video) == true { + guard let sourceURL = info[.mediaURL] as? URL else { + throw BridgeError.nativeFailure("camera.ui.capture returned no video URL") + } + let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).\(sourceURL.pathExtension.isEmpty ? "mov" : sourceURL.pathExtension)") + try FileManager.default.copyItem(at: sourceURL, to: outputURL) + return CameraCaptureResult(url: outputURL, mediaType: "video", typeIdentifier: mediaType) + } + + if let imageURL = info[.imageURL] as? URL { + let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).\(imageURL.pathExtension.isEmpty ? "jpg" : imageURL.pathExtension)") + try FileManager.default.copyItem(at: imageURL, to: outputURL) + return CameraCaptureResult(url: outputURL, mediaType: "image", typeIdentifier: UTType.jpeg.identifier) + } + + guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), + let data = image.jpegData(compressionQuality: 0.92) + else { + throw BridgeError.nativeFailure("camera.ui.capture returned no image") + } + + let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).jpg") + try data.write(to: outputURL, options: .atomic) + return CameraCaptureResult(url: outputURL, mediaType: "image", typeIdentifier: UTType.jpeg.identifier) + } +} +#endif + +#if os(iOS) && canImport(VisionKit) +@MainActor private final class DocumentScanCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { + private let outputDirectory: URL + private let onComplete: (Result<[URL], BridgeError>) -> Void + + init(outputDirectory: URL, onComplete: @escaping (Result<[URL], BridgeError>) -> Void) { + self.outputDirectory = outputDirectory + self.onComplete = onComplete + } + + func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { + controller.dismiss(animated: true) + onComplete(.success([])) + } + + func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: any Error) { + controller.dismiss(animated: true) + onComplete(.failure(.nativeFailure(error.localizedDescription))) + } + + func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) { + do { + var urls: [URL] = [] + for index in 0.. Void + + init(onComplete: @escaping (MFMailComposeResult) -> Void) { + self.onComplete = onComplete + } + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: (any Error)? + ) { + _ = error + controller.dismiss(animated: true) + onComplete(result) + } +} + +@MainActor private final class MessageComposeCoordinator: NSObject, @preconcurrency MFMessageComposeViewControllerDelegate { + private let onComplete: (MessageComposeResult) -> Void + + init(onComplete: @escaping (MessageComposeResult) -> Void) { + self.onComplete = onComplete + } + + func messageComposeViewController( + _ controller: MFMessageComposeViewController, + didFinishWith result: MessageComposeResult + ) { + controller.dismiss(animated: true) + onComplete(result) + } +} +#endif + +#if os(iOS) +@MainActor private final class SafariCoordinator: NSObject, @preconcurrency SFSafariViewControllerDelegate { + private let onComplete: () -> Void + + init(onComplete: @escaping () -> Void) { + self.onComplete = onComplete + } + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + _ = controller + onComplete() + } +} +#endif + +@MainActor private final class WebAuthenticationCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { + private let anchor: ASPresentationAnchor + var session: ASWebAuthenticationSession? + + init(anchor: ASPresentationAnchor) { + self.anchor = anchor + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + _ = session + return anchor + } +} #endif diff --git a/Sources/CodeModeEvaluation/EvalScenarios.swift b/Sources/CodeModeEvaluation/EvalScenarios.swift index 5ce70b7..d924a79 100644 --- a/Sources/CodeModeEvaluation/EvalScenarios.swift +++ b/Sources/CodeModeEvaluation/EvalScenarios.swift @@ -606,22 +606,51 @@ public enum CodeModeEvalScenarios { public static let catalogSystemUIPlatformPruning = CodeModeEvalScenario( id: "catalog.system-ui-platform-pruning", title: "Catalog hides system UI helpers on unsupported hosts", - task: "Search for the system UI helpers apple.calendar.presentNewEvent, apple.photos.pick, and apple.contacts.pick. On this macOS host these iOS/visionOS helpers should be hidden, so return null for each missing helper.", + task: "Search for the system UI helper family. On this macOS host these iOS/visionOS helpers should be hidden, so return null for each missing helper.", searchCode: """ async () => { - return { - calendarUI: api.byJSName["apple.calendar.presentNewEvent"] ?? null, - photosUI: api.byJSName["apple.photos.pick"] ?? null, - contactsUI: api.byJSName["apple.contacts.pick"] ?? null - }; + const names = [ + "apple.calendar.pickCalendar", + "apple.calendar.presentEvent", + "apple.calendar.presentNewEvent", + "apple.contacts.pick", + "apple.contacts.presentContact", + "apple.contacts.presentNewContact", + "apple.photos.pick", + "apple.documents.pick", + "apple.documents.scan", + "apple.share.present", + "apple.quicklook.preview", + "apple.camera.capture", + "apple.mail.compose", + "apple.messages.compose", + "apple.web.present", + "apple.auth.webAuthenticate", + "apple.ui.presentAlert" + ]; + return Object.fromEntries(names.map(name => [name, api.byJSName[name] ?? null])); } """, expectation: CodeModeEvalExpectation( toolOrder: [.searchJavaScriptAPI], requiredSearchResultFragments: [ - "\"calendarUI\":null", - "\"contactsUI\":null", - "\"photosUI\":null", + "\"apple.auth.webAuthenticate\":null", + "\"apple.calendar.pickCalendar\":null", + "\"apple.calendar.presentEvent\":null", + "\"apple.calendar.presentNewEvent\":null", + "\"apple.camera.capture\":null", + "\"apple.contacts.pick\":null", + "\"apple.contacts.presentContact\":null", + "\"apple.contacts.presentNewContact\":null", + "\"apple.documents.pick\":null", + "\"apple.documents.scan\":null", + "\"apple.mail.compose\":null", + "\"apple.messages.compose\":null", + "\"apple.photos.pick\":null", + "\"apple.quicklook.preview\":null", + "\"apple.share.present\":null", + "\"apple.ui.presentAlert\":null", + "\"apple.web.present\":null", ] ) ) diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 37ec240..78f43de 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -20,16 +20,34 @@ import Testing } @Test func systemUICapabilitiesArePlatformScoped() { - let uiCapabilities: Set = [ + let sharedUICapabilities: Set = [ + .calendarUIPickCalendar, + .calendarUIPresentEvent, .calendarUIPresentNewEvent, .contactsUIPick, + .contactsUIPresentContact, + .contactsUIPresentNewContact, .photosUIPick, + .documentsUIPick, + .shareUIPresent, + .quickLookUIPreview, + .webUIPresent, + .authUIWebAuthenticate, + .uiAlertPresent, ] + let iOSOnlyUICapabilities: Set = [ + .documentsUIScan, + .cameraUICapture, + .mailUICompose, + .messagesUICompose, + ] + let allUICapabilities = sharedUICapabilities.union(iOSOnlyUICapabilities) - #expect(uiCapabilities.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .iOS))) - #expect(uiCapabilities.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .visionOS))) - #expect(CapabilityPlatformSupport.supportedCapabilities(for: .macOS).isDisjoint(with: uiCapabilities)) - #expect(CapabilityPlatformSupport.supportedCapabilities(for: .watchOS).isDisjoint(with: uiCapabilities)) + #expect(allUICapabilities.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .iOS))) + #expect(sharedUICapabilities.isSubset(of: CapabilityPlatformSupport.supportedCapabilities(for: .visionOS))) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .visionOS).isDisjoint(with: iOSOnlyUICapabilities)) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .macOS).isDisjoint(with: allUICapabilities)) + #expect(CapabilityPlatformSupport.supportedCapabilities(for: .watchOS).isDisjoint(with: allUICapabilities)) } @Test func systemUIDescriptorsExposeExpectedJavaScriptNames() throws { @@ -48,6 +66,28 @@ import Testing let photos = try #require(descriptors[.photosUIPick]) #expect(photos.requiredPermissions.isEmpty) #expect(JavaScriptBindingCatalog.names(for: .photosUIPick) == ["apple.photos.pick"]) + + #expect(descriptors[.documentsUIPick]?.requiredPermissions.isEmpty == true) + #expect(JavaScriptBindingCatalog.names(for: .documentsUIPick) == ["apple.documents.pick"]) + #expect(JavaScriptBindingCatalog.names(for: .documentsUIScan) == ["apple.documents.scan"]) + #expect(JavaScriptBindingCatalog.names(for: .shareUIPresent) == ["apple.share.present"]) + #expect(JavaScriptBindingCatalog.names(for: .quickLookUIPreview) == ["apple.quicklook.preview"]) + #expect(JavaScriptBindingCatalog.names(for: .cameraUICapture) == ["apple.camera.capture"]) + #expect(JavaScriptBindingCatalog.names(for: .mailUICompose) == ["apple.mail.compose"]) + #expect(JavaScriptBindingCatalog.names(for: .messagesUICompose) == ["apple.messages.compose"]) + #expect(JavaScriptBindingCatalog.names(for: .webUIPresent) == ["apple.web.present"]) + #expect(JavaScriptBindingCatalog.names(for: .authUIWebAuthenticate) == ["apple.auth.webAuthenticate"]) + #expect(JavaScriptBindingCatalog.names(for: .uiAlertPresent) == ["apple.ui.presentAlert"]) + + #expect(descriptors[.calendarUIPickCalendar]?.requiredPermissions == [.calendarWriteOnly]) + #expect(descriptors[.calendarUIPresentEvent]?.requiredPermissions == [.calendar]) + #expect(JavaScriptBindingCatalog.names(for: .calendarUIPickCalendar) == ["apple.calendar.pickCalendar"]) + #expect(JavaScriptBindingCatalog.names(for: .calendarUIPresentEvent) == ["apple.calendar.presentEvent"]) + + #expect(descriptors[.contactsUIPresentContact]?.requiredPermissions == [.contacts]) + #expect(descriptors[.contactsUIPresentNewContact]?.requiredPermissions == [.contacts]) + #expect(JavaScriptBindingCatalog.names(for: .contactsUIPresentContact) == ["apple.contacts.presentContact"]) + #expect(JavaScriptBindingCatalog.names(for: .contactsUIPresentNewContact) == ["apple.contacts.presentNewContact"]) } @Test func filesystemListDescriptorDocumentsEntryObjects() throws { diff --git a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift index 7555121..92f63e0 100644 --- a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift +++ b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift @@ -64,7 +64,7 @@ import Testing @Test func validatorRequiresWriteOnlyCalendarKeyForCalendarUIPresentationOnly() { let issues = HostConfigurationValidator.validate( - requiredCapabilities: [.calendarUIPresentNewEvent], + requiredCapabilities: [.calendarUIPresentNewEvent, .calendarUIPickCalendar], infoPlist: [:] ) @@ -73,12 +73,24 @@ import Testing } @Test func validatorDoesNotRequireFullLibraryKeysForSystemPickers() { - let keys = HostConfigurationValidator.requiredInfoPlistKeys(for: [.photosUIPick, .contactsUIPick]) + let keys = HostConfigurationValidator.requiredInfoPlistKeys(for: [.photosUIPick, .contactsUIPick, .documentsUIPick, .shareUIPresent, .quickLookUIPreview, .webUIPresent, .authUIWebAuthenticate, .uiAlertPresent]) #expect(keys.contains("NSPhotoLibraryUsageDescription") == false) #expect(keys.contains("NSContactsUsageDescription") == false) } +@Test func validatorRequiresExpectedKeysForExpandedSystemUI() { + let keys = HostConfigurationValidator.requiredInfoPlistKeys( + for: [.calendarUIPresentEvent, .contactsUIPresentContact, .contactsUIPresentNewContact, .documentsUIScan, .cameraUICapture] + ) + + #expect(keys.contains("NSCalendarsFullAccessUsageDescription")) + #expect(keys.contains("NSContactsUsageDescription")) + #expect(keys.contains("NSCameraUsageDescription")) + #expect(keys.contains("NSMicrophoneUsageDescription")) + #expect(keys.contains("NSPhotoLibraryUsageDescription") == false) +} + @Test func validatorReportsPhotosAndHomeKitMissingUsageDescriptions() { let issues = HostConfigurationValidator.validate( requiredCapabilities: [.photosRead, .homeRead], diff --git a/Tests/CodeModeTests/HostRuntimeTests.swift b/Tests/CodeModeTests/HostRuntimeTests.swift index 1682a70..0b5ae01 100644 --- a/Tests/CodeModeTests/HostRuntimeTests.swift +++ b/Tests/CodeModeTests/HostRuntimeTests.swift @@ -106,21 +106,54 @@ import Testing JavaScriptAPISearchRequest( code: """ async () => { - return { - calendarUI: api.byJSName["apple.calendar.presentNewEvent"] ?? null, - contactsUI: api.byJSName["apple.contacts.pick"] ?? null, - photosUI: api.byJSName["apple.photos.pick"] ?? null - }; + const names = [ + "apple.calendar.pickCalendar", + "apple.calendar.presentEvent", + "apple.calendar.presentNewEvent", + "apple.contacts.pick", + "apple.contacts.presentContact", + "apple.contacts.presentNewContact", + "apple.photos.pick", + "apple.documents.pick", + "apple.documents.scan", + "apple.share.present", + "apple.quicklook.preview", + "apple.camera.capture", + "apple.mail.compose", + "apple.messages.compose", + "apple.web.present", + "apple.auth.webAuthenticate", + "apple.ui.presentAlert" + ]; + return Object.fromEntries(names.map(name => [name, api.byJSName[name] ?? null])); } """ ) ) let result = try #require(response.result?.objectValue) - let shouldExpose = CapabilityPlatformSupport.isSupported(.calendarUIPresentNewEvent, for: .current) - #expect((result["calendarUI"] != .null) == shouldExpose) - #expect((result["contactsUI"] != .null) == shouldExpose) - #expect((result["photosUI"] != .null) == shouldExpose) + let namesByCapability: [CapabilityID: String] = [ + .calendarUIPickCalendar: "apple.calendar.pickCalendar", + .calendarUIPresentEvent: "apple.calendar.presentEvent", + .calendarUIPresentNewEvent: "apple.calendar.presentNewEvent", + .contactsUIPick: "apple.contacts.pick", + .contactsUIPresentContact: "apple.contacts.presentContact", + .contactsUIPresentNewContact: "apple.contacts.presentNewContact", + .photosUIPick: "apple.photos.pick", + .documentsUIPick: "apple.documents.pick", + .documentsUIScan: "apple.documents.scan", + .shareUIPresent: "apple.share.present", + .quickLookUIPreview: "apple.quicklook.preview", + .cameraUICapture: "apple.camera.capture", + .mailUICompose: "apple.mail.compose", + .messagesUICompose: "apple.messages.compose", + .webUIPresent: "apple.web.present", + .authUIWebAuthenticate: "apple.auth.webAuthenticate", + .uiAlertPresent: "apple.ui.presentAlert", + ] + for (capability, name) in namesByCapability { + #expect((result[name] != .null) == CapabilityPlatformSupport.isSupported(capability, for: .current)) + } } @Test func searchSupportsDirectCapabilityLookup() async throws { diff --git a/Tests/CodeModeTests/SystemUIBridgeTests.swift b/Tests/CodeModeTests/SystemUIBridgeTests.swift index 87d9193..31eeb88 100644 --- a/Tests/CodeModeTests/SystemUIBridgeTests.swift +++ b/Tests/CodeModeTests/SystemUIBridgeTests.swift @@ -28,7 +28,23 @@ import Testing "phones": .array([]), "emails": .array([.string("alex@example.com")]), ]), - ]) + ]), + extraResults: [ + .calendarUIPickCalendar: .array([.object(["identifier": .string("cal-1")])]), + .calendarUIPresentEvent: .object(["action": .string("dismissed")]), + .contactsUIPresentContact: .object(["action": .string("dismissed")]), + .contactsUIPresentNewContact: .object(["action": .string("saved")]), + .documentsUIPick: .array([.object(["artifactID": .string("document-1")])]), + .documentsUIScan: .array([.object(["artifactID": .string("scan-1")])]), + .shareUIPresent: .object(["completed": .bool(true)]), + .quickLookUIPreview: .object(["action": .string("dismissed")]), + .cameraUICapture: .object(["artifactID": .string("camera-1")]), + .mailUICompose: .object(["action": .string("sent")]), + .messagesUICompose: .object(["action": .string("sent")]), + .webUIPresent: .object(["action": .string("dismissed")]), + .authUIWebAuthenticate: .object(["action": .string("callback")]), + .uiAlertPresent: .object(["action": .string("selected"), "buttonID": .string("ok")]), + ] ) let (context, sandbox) = try makeInvocationContext(systemUIPresenter: presenter) @@ -53,6 +69,38 @@ import Testing bridge.pickContacts(arguments: ["mode": .string("single")], context: context) ) #expect(contacts.first?.objectValue?.string("givenName") == "Alex") + + let calendars = try requireArray(bridge.pickCalendar(arguments: [:], context: context)) + #expect(calendars.first?.objectValue?.string("identifier") == "cal-1") + let eventView = try bridge.presentCalendarEvent(arguments: ["identifier": .string("event-1")], context: context) + #expect(eventView.objectValue?.string("action") == "dismissed") + let contactView = try bridge.presentContact(arguments: ["identifier": .string("contact-1")], context: context) + #expect(contactView.objectValue?.string("action") == "dismissed") + let newContact = try bridge.presentNewContact(arguments: [:], context: context) + #expect(newContact.objectValue?.string("action") == "saved") + let documents = try requireArray(bridge.pickDocuments(arguments: ["contentTypes": .array([.string("public.item")])], context: context)) + #expect(documents.first?.objectValue?.string("artifactID") == "document-1") + let scans = try requireArray(bridge.scanDocuments(arguments: [:], context: context)) + #expect(scans.first?.objectValue?.string("artifactID") == "scan-1") + let share = try bridge.presentShareSheet(arguments: ["text": .string("hello")], context: context) + #expect(share.objectValue?.bool("completed") == true) + let preview = try bridge.previewQuickLook(arguments: ["path": .string("tmp:report.pdf")], context: context) + #expect(preview.objectValue?.string("action") == "dismissed") + let camera = try bridge.captureCamera(arguments: ["mediaType": .string("image")], context: context) + #expect(camera.objectValue?.string("artifactID") == "camera-1") + let mail = try bridge.composeMail(arguments: ["to": .array([.string("alex@example.com")])], context: context) + #expect(mail.objectValue?.string("action") == "sent") + let message = try bridge.composeMessage(arguments: ["recipients": .array([.string("4085551212")])], context: context) + #expect(message.objectValue?.string("action") == "sent") + let web = try bridge.presentWeb(arguments: ["url": .string("https://example.com")], context: context) + #expect(web.objectValue?.string("action") == "dismissed") + let auth = try bridge.authenticateWeb(arguments: ["url": .string("https://example.com/oauth")], context: context) + #expect(auth.objectValue?.string("action") == "callback") + let alert = try bridge.presentAlert( + arguments: ["buttons": .array([.object(["id": .string("ok"), "title": .string("OK")])])], + context: context + ) + #expect(alert.objectValue?.string("buttonID") == "ok") } @Test func systemUIBridgeDefaultPresenterIsStructuredFailure() throws { @@ -111,6 +159,70 @@ import Testing } catch { #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } + + do { + _ = try bridge.pickCalendar(arguments: ["selectionStyle": .string("both")], context: context) + Issue.record("Expected invalid calendar selectionStyle to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.pickDocuments(arguments: ["contentTypes": .array([.number(1)])], context: context) + Issue.record("Expected invalid contentTypes to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.presentShareSheet(arguments: [:], context: context) + Issue.record("Expected share sheet with no items to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.previewQuickLook(arguments: [:], context: context) + Issue.record("Expected Quick Look with no path to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.presentWeb(arguments: ["url": .string("file:///tmp/a.html")], context: context) + Issue.record("Expected non-HTTP web URL to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.composeMail(arguments: ["attachments": .array([.string("tmp:file.txt")])], context: context) + Issue.record("Expected invalid attachment shape to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.presentAlert(arguments: ["buttons": .array([])], context: context) + Issue.record("Expected empty alert buttons to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.presentAlert( + arguments: [ + "buttons": .array([ + .object(["title": .string("Cancel"), "style": .string("cancel")]), + .object(["title": .string("Stop"), "style": .string("cancel")]), + ]), + ], + context: context + ) + Issue.record("Expected duplicate cancel alert buttons to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } } @Test func systemUIBridgeHonorsCancellationBeforePresentation() throws { @@ -181,21 +293,58 @@ import Testing } } +@Test func expandedSystemUIPermissionsAreCheckedByRegistry() throws { + let registrations = DefaultCapabilityLoader.loadAllRegistrations().filter { + [.calendarUIPickCalendar, .calendarUIPresentEvent, .contactsUIPresentContact, .contactsUIPresentNewContact].contains($0.descriptor.id) + } + let registry = CapabilityRegistry(registrations: registrations) + let broker = FixedPermissionBroker(statuses: [ + .calendarWriteOnly: .denied, + .calendar: .denied, + .contacts: .denied, + ]) + let (context, sandbox) = try makeInvocationContext( + permissionBroker: broker, + allowedCapabilities: Set(registrations.map(\.descriptor.id)), + systemUIPresenter: FakeSystemUIPresenter() + ) + defer { cleanup(sandbox) } + + let argumentsByCapability: [CapabilityID: [String: JSONValue]] = [ + .calendarUIPickCalendar: [:], + .calendarUIPresentEvent: ["identifier": .string("event-id")], + .contactsUIPresentContact: ["identifier": .string("contact-id")], + .contactsUIPresentNewContact: [:], + ] + + for capability in registrations.map(\.descriptor.id) { + do { + _ = try registry.invoke(capability.rawValue, arguments: argumentsByCapability[capability] ?? [:], context: context) + Issue.record("Expected permission denial for \(capability.rawValue)") + } catch { + #expect(requireBridgeErrorCode(error) == "PERMISSION_DENIED") + } + } +} + private struct FakeSystemUIPresenter: SystemUIPresenter { var calendarResult: JSONValue var photosResult: JSONValue var contactsResult: JSONValue + var extraResults: [CapabilityID: JSONValue] var error: BridgeError? init( calendarResult: JSONValue = .object(["action": .string("cancelled")]), photosResult: JSONValue = .array([]), contactsResult: JSONValue = .array([]), + extraResults: [CapabilityID: JSONValue] = [:], error: BridgeError? = nil ) { self.calendarResult = calendarResult self.photosResult = photosResult self.contactsResult = contactsResult + self.extraResults = extraResults self.error = error } @@ -219,4 +368,67 @@ private struct FakeSystemUIPresenter: SystemUIPresenter { if let error { throw error } return contactsResult } + + func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .calendarUIPickCalendar, arguments: arguments, context: context) + } + + func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .calendarUIPresentEvent, arguments: arguments, context: context) + } + + func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .contactsUIPresentContact, arguments: arguments, context: context) + } + + func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .contactsUIPresentNewContact, arguments: arguments, context: context) + } + + func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIPick, arguments: arguments, context: context) + } + + func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIScan, arguments: arguments, context: context) + } + + func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .shareUIPresent, arguments: arguments, context: context) + } + + func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .quickLookUIPreview, arguments: arguments, context: context) + } + + func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .cameraUICapture, arguments: arguments, context: context) + } + + func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .mailUICompose, arguments: arguments, context: context) + } + + func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .messagesUICompose, arguments: arguments, context: context) + } + + func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .webUIPresent, arguments: arguments, context: context) + } + + func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .authUIWebAuthenticate, arguments: arguments, context: context) + } + + func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .uiAlertPresent, arguments: arguments, context: context) + } + + private func result(for capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return extraResults[capability] ?? .object(["action": .string("cancelled")]) + } } From 5aa02a8bd14db369ff38291afbfc9845e4c76b16 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 29 Apr 2026 23:26:24 -0700 Subject: [PATCH 3/5] Expand deterministic eval catalog for iOS system UI --- README.md | 2 +- Sources/CodeMode/API/BridgeModels.swift | 5 +- .../CodeMode/Host/CodeModeAgentTools.swift | 2 +- Sources/CodeMode/Support/HostPlatform.swift | 4 +- Sources/CodeModeEvaluation/EvalModels.swift | 3 + Sources/CodeModeEvaluation/EvalRunner.swift | 3 +- .../CodeModeEvaluation/EvalScenarios.swift | 104 ++++++++++++++++++ .../Sources/CodeModeEvalCLI/LLM.swift | 3 +- 8 files changed, 119 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5c48bfe..c8cf483 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ swift run --package-path Tools/CodeModeEval codemode-eval run fs.round-trip --sh swift run --package-path Tools/CodeModeEval codemode-eval run --json ``` -The eval harness runs 22 built-in user-style scenarios through the same +The eval harness runs 24 built-in user-style scenarios through the same `searchJavaScriptAPI` and `executeJavaScript` APIs that host apps expose to agents. It validates tool order, discovered catalog output, generated JavaScript fragments, exact `allowedCapabilities`, structured errors, repair suggestions, diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index d7367d0..c346738 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -7,6 +7,7 @@ public struct CodeModeConfiguration: Sendable { public var permissionBroker: any PermissionBroker public var auditLogger: any AuditLogger public var systemUIPresenter: any SystemUIPresenter + public var hostPlatform: HostPlatform public init( pathPolicy: any PathPolicy = DefaultPathPolicy(), @@ -14,7 +15,8 @@ public struct CodeModeConfiguration: Sendable { artifactStore: any ArtifactStore = InMemoryArtifactStore(), permissionBroker: any PermissionBroker = SystemPermissionBroker(), auditLogger: any AuditLogger = SyncAuditLogger(), - systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter() + systemUIPresenter: any SystemUIPresenter = UnavailableSystemUIPresenter(), + hostPlatform: HostPlatform = .current ) { self.pathPolicy = pathPolicy self.fileSystem = fileSystem @@ -22,6 +24,7 @@ public struct CodeModeConfiguration: Sendable { self.permissionBroker = permissionBroker self.auditLogger = auditLogger self.systemUIPresenter = systemUIPresenter + self.hostPlatform = hostPlatform } } diff --git a/Sources/CodeMode/Host/CodeModeAgentTools.swift b/Sources/CodeMode/Host/CodeModeAgentTools.swift index cd730dc..8a8ec2d 100644 --- a/Sources/CodeMode/Host/CodeModeAgentTools.swift +++ b/Sources/CodeMode/Host/CodeModeAgentTools.swift @@ -8,7 +8,7 @@ public final class CodeModeAgentTools: @unchecked Sendable { public init(config: CodeModeConfiguration = .init()) { let registrations = CapabilityPlatformSupport.filter( DefaultCapabilityLoader.loadAllRegistrations(fileSystem: config.fileSystem), - for: .current + for: config.hostPlatform ) let registry = CapabilityRegistry(registrations: registrations) self.registry = registry diff --git a/Sources/CodeMode/Support/HostPlatform.swift b/Sources/CodeMode/Support/HostPlatform.swift index 3b2cdfc..4704424 100644 --- a/Sources/CodeMode/Support/HostPlatform.swift +++ b/Sources/CodeMode/Support/HostPlatform.swift @@ -1,12 +1,12 @@ import Foundation -enum HostPlatform: String, Sendable { +public enum HostPlatform: String, Sendable, Codable { case iOS case macOS case watchOS case visionOS - static let current: HostPlatform = { + public static let current: HostPlatform = { #if os(iOS) return .iOS #elseif os(macOS) diff --git a/Sources/CodeModeEvaluation/EvalModels.swift b/Sources/CodeModeEvaluation/EvalModels.swift index 8987519..6c7de3c 100644 --- a/Sources/CodeModeEvaluation/EvalModels.swift +++ b/Sources/CodeModeEvaluation/EvalModels.swift @@ -117,12 +117,14 @@ public struct CodeModeEvalScenario: Codable, Identifiable, Sendable, Equatable { public var timeoutMs: Int public var seedFiles: [CodeModeEvalSeedFile] public var permissions: CodeModeEvalPermissions + public var catalogPlatform: HostPlatform? public var expectation: CodeModeEvalExpectation public init( id: String, title: String, task: String, + catalogPlatform: HostPlatform? = nil, searchCode: String? = nil, executeCode: String? = nil, executeSteps: [CodeModeEvalExecuteStep] = [], @@ -142,6 +144,7 @@ public struct CodeModeEvalScenario: Codable, Identifiable, Sendable, Equatable { self.timeoutMs = timeoutMs self.seedFiles = seedFiles self.permissions = permissions + self.catalogPlatform = catalogPlatform self.expectation = expectation } } diff --git a/Sources/CodeModeEvaluation/EvalRunner.swift b/Sources/CodeModeEvaluation/EvalRunner.swift index d2fc6dd..30fba8a 100644 --- a/Sources/CodeModeEvaluation/EvalRunner.swift +++ b/Sources/CodeModeEvaluation/EvalRunner.swift @@ -44,7 +44,8 @@ public final class CodeModeEvalRunner: Sendable { pathPolicy: pathPolicy, artifactStore: InMemoryArtifactStore(), permissionBroker: CodeModeEvalPermissionBroker(configuration: scenario.permissions), - auditLogger: SyncAuditLogger() + auditLogger: SyncAuditLogger(), + hostPlatform: scenario.catalogPlatform ?? .current ) ) diff --git a/Sources/CodeModeEvaluation/EvalScenarios.swift b/Sources/CodeModeEvaluation/EvalScenarios.swift index d924a79..1f5f9c3 100644 --- a/Sources/CodeModeEvaluation/EvalScenarios.swift +++ b/Sources/CodeModeEvaluation/EvalScenarios.swift @@ -22,6 +22,8 @@ public enum CodeModeEvalScenarios { searchRejectsNonFunctionProgram, catalogAliasAndPlatformPruning, catalogSystemUIPlatformPruning, + catalogSharedSystemUIDiscovery, + catalogIOSOnlySystemUIDiscovery, contactsPermissionDenied, weatherArgumentValidation, badFileSystemHelperSuggestion, @@ -655,6 +657,108 @@ public enum CodeModeEvalScenarios { ) ) + public static let catalogSharedSystemUIDiscovery = CodeModeEvalScenario( + id: "catalog.system-ui-shared-discovery", + title: "Catalog discovers shared iOS and visionOS system UI helpers", + task: "Search the iOS catalog for Files document picking, share sheet, Quick Look preview, Safari presentation, web authentication, and custom alert helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.documents.pick", + "apple.share.present", + "apple.quicklook.preview", + "apple.web.present", + "apple.auth.webAuthenticate", + "apple.ui.presentAlert" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.auth.webAuthenticate", + "apple.documents.pick", + "apple.quicklook.preview", + "apple.share.present", + "apple.ui.presentAlert", + "apple.web.present", + "auth.ui.webAuthenticate", + "buttonID", + "buttons", + "callbackURL", + "callbackURLScheme", + "completed", + "contentTypes", + "documents.ui.pick", + "excludedActivityTypes", + "outputDirectory", + "quicklook.ui.preview", + "share.ui.present", + "ui.alert.present", + "web.ui.present", + ] + ) + ) + + public static let catalogIOSOnlySystemUIDiscovery = CodeModeEvalScenario( + id: "catalog.system-ui-ios-only-discovery", + title: "Catalog discovers iOS-only system UI helpers", + task: "Search the iOS catalog for document scanning, camera capture, mail compose, and Messages compose helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.documents.scan", + "apple.camera.capture", + "apple.mail.compose", + "apple.messages.compose" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.camera.capture", + "apple.documents.scan", + "apple.mail.compose", + "apple.messages.compose", + "artifactID", + "attachments", + "camera.ui.capture", + "documents.ui.scan", + "mail.ui.compose", + "mediaType", + "messages.ui.compose", + "outputDirectory", + "recipients", + "sent", + ] + ) + ) + public static let contactsPermissionDenied = CodeModeEvalScenario( id: "contacts.permission-denied", title: "Permission denial stays structured", diff --git a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift index 9c30b53..bf24f5f 100644 --- a/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift +++ b/Tools/CodeModeEval/Sources/CodeModeEvalCLI/LLM.swift @@ -1332,7 +1332,8 @@ private final class CodeModeLLMRuntime: Sendable { pathPolicy: pathPolicy, artifactStore: InMemoryArtifactStore(), permissionBroker: CodeModeLLMPermissionBroker(configuration: scenario.permissions), - auditLogger: SyncAuditLogger() + auditLogger: SyncAuditLogger(), + hostPlatform: scenario.catalogPlatform ?? .current ) ) } From d044a9b9724521ada2fdccd570580c30231cc122 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 29 Apr 2026 23:55:52 -0700 Subject: [PATCH 4/5] Another round of UI expansion --- README.md | 6 +- Sources/CodeMode/API/BridgeModels.swift | 7 + Sources/CodeMode/API/SystemUIPresenter.swift | 49 ++ .../Bridges/DefaultCapabilityLoader.swift | 182 ++++++ Sources/CodeMode/Bridges/SystemUIBridge.swift | 138 +++++ .../Catalog/JavaScriptBindingCatalog.swift | 7 + .../Host/HostConfigurationValidator.swift | 10 +- .../CodeMode/Runtime/RuntimeJavaScript.swift | 20 +- .../Support/CapabilityPlatformSupport.swift | 14 + .../Support/UIKitSystemUIPresenter.swift | 543 ++++++++++++++++++ .../CodeModeEvaluation/EvalScenarios.swift | 47 +- .../CapabilityRegistryTests.swift | 14 + .../HostConfigurationValidatorTests.swift | 30 +- Tests/CodeModeTests/HostRuntimeTests.swift | 17 +- Tests/CodeModeTests/SystemUIBridgeTests.swift | 96 ++++ 15 files changed, 1165 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c8cf483..1d8fc6e 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Cross-platform privileged helpers are installed under `apple.*`. Platform-specif `apple.location.requestPermission()` is currently exposed only on iOS hosts. Other Apple platforms can expose `apple.location.*` helpers when supported, but the explicit permission-request helper is intentionally hidden outside iOS for now. -System UI helpers are installed only on supported UI platforms. Shared iOS/visionOS helpers include `apple.ui.presentAlert`, `apple.calendar.pickCalendar`, `apple.calendar.presentEvent`, `apple.calendar.presentNewEvent`, `apple.contacts.pick`, `apple.contacts.presentContact`, `apple.contacts.presentNewContact`, `apple.photos.pick`, `apple.documents.pick`, `apple.share.present`, `apple.quicklook.preview`, `apple.web.present`, and `apple.auth.webAuthenticate`. iOS-only helpers include `apple.camera.capture`, `apple.documents.scan`, `apple.mail.compose`, and `apple.messages.compose`. Host apps must provide a `SystemUIPresenter`; otherwise UI-presenting helpers fail with `UI_PRESENTER_UNAVAILABLE`. +System UI helpers are installed only on supported UI platforms. Shared iOS/visionOS helpers include `apple.ui.presentAlert`, `apple.ui.presentPrompt`, `apple.settings.open`, `apple.calendar.pickCalendar`, `apple.calendar.presentEvent`, `apple.calendar.presentNewEvent`, `apple.contacts.pick`, `apple.contacts.presentContact`, `apple.contacts.presentNewContact`, `apple.photos.pick`, `apple.photos.presentLimitedLibraryPicker`, `apple.documents.pick`, `apple.documents.export`, `apple.documents.save`, `apple.documents.openIn`, `apple.share.present`, `apple.quicklook.preview`, `apple.camera.scanData`, `apple.print.present`, `apple.web.present`, and `apple.auth.webAuthenticate`. iOS-only helpers include `apple.camera.capture`, `apple.documents.scan`, `apple.mail.compose`, and `apple.messages.compose`. Host apps must provide a `SystemUIPresenter`; otherwise UI-presenting helpers fail with `UI_PRESENTER_UNAVAILABLE`. `call.events` is a non-throwing `AsyncStream` that can emit: @@ -240,8 +240,8 @@ Required Info.plist keys by capability: - Calendar write-only (`calendar.write`): `NSCalendarsWriteOnlyAccessUsageDescription` - Calendar event editor/chooser UI (`calendar.ui.presentNewEvent`, `calendar.ui.pickCalendar`): `NSCalendarsWriteOnlyAccessUsageDescription` - Reminders (`reminders.read`, `reminders.write`): `NSRemindersFullAccessUsageDescription` -- Photos (`photos.read`, `photos.export`): `NSPhotoLibraryUsageDescription` -- Camera UI (`camera.ui.capture`, `documents.ui.scan`): `NSCameraUsageDescription` +- Photos (`photos.read`, `photos.export`, `photos.ui.presentLimitedLibraryPicker`): `NSPhotoLibraryUsageDescription` +- Camera UI (`camera.ui.capture`, `camera.ui.scanData`, `documents.ui.scan`): `NSCameraUsageDescription` - Camera video capture (`camera.ui.capture`): `NSMicrophoneUsageDescription` - AlarmKit (`alarm.permission.request`, `alarm.read`, `alarm.schedule`, `alarm.cancel`): `NSAlarmKitUsageDescription` - HealthKit read (`health.permission.request`, `health.read`): `NSHealthShareUsageDescription` diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index c346738..6482c5c 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -336,6 +336,7 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case photosRead = "photos.read" case photosExport = "photos.export" case photosUIPick = "photos.ui.pick" + case photosUIPresentLimitedLibraryPicker = "photos.ui.presentLimitedLibraryPicker" case visionImageAnalyze = "vision.image.analyze" @@ -376,13 +377,19 @@ public enum CapabilityID: String, Sendable, Codable, CaseIterable, Hashable { case contactsUIPresentContact = "contacts.ui.presentContact" case contactsUIPresentNewContact = "contacts.ui.presentNewContact" case documentsUIPick = "documents.ui.pick" + case documentsUIExport = "documents.ui.export" + case documentsUIOpenIn = "documents.ui.openIn" case documentsUIScan = "documents.ui.scan" case shareUIPresent = "share.ui.present" case quickLookUIPreview = "quicklook.ui.preview" case cameraUICapture = "camera.ui.capture" + case cameraUIScanData = "camera.ui.scanData" case mailUICompose = "mail.ui.compose" case messagesUICompose = "messages.ui.compose" + case printUIPresent = "print.ui.present" case webUIPresent = "web.ui.present" case authUIWebAuthenticate = "auth.ui.webAuthenticate" case uiAlertPresent = "ui.alert.present" + case uiPromptPresent = "ui.prompt.present" + case settingsUIOpen = "settings.ui.open" } diff --git a/Sources/CodeMode/API/SystemUIPresenter.swift b/Sources/CodeMode/API/SystemUIPresenter.swift index 634c25a..ab1eec5 100644 --- a/Sources/CodeMode/API/SystemUIPresenter.swift +++ b/Sources/CodeMode/API/SystemUIPresenter.swift @@ -9,15 +9,22 @@ public protocol SystemUIPresenter: Sendable { func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue + func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue } public extension SystemUIPresenter { @@ -69,6 +76,18 @@ public extension SystemUIPresenter { throw BridgeError.uiPresenterUnavailable } + func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context @@ -93,6 +112,12 @@ public extension SystemUIPresenter { throw BridgeError.uiPresenterUnavailable } + func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context @@ -105,6 +130,12 @@ public extension SystemUIPresenter { throw BridgeError.uiPresenterUnavailable } + func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context @@ -122,6 +153,24 @@ public extension SystemUIPresenter { _ = context throw BridgeError.uiPresenterUnavailable } + + func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } + + func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + throw BridgeError.uiPresenterUnavailable + } } public struct UnavailableSystemUIPresenter: SystemUIPresenter { diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index 3f6759f..32aa0c6 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -495,6 +495,26 @@ public enum DefaultCapabilityLoader { try systemUI.pickPhotos(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .photosUIPresentLimitedLibraryPicker, + title: "Present limited Photos picker", + summary: "Present the Photos limited-library management UI so the user can update the app's selected photo set.", + tags: ["photos", "photo-library", "system-ui", "permission", "picker"], + example: "await apple.photos.presentLimitedLibraryPicker()", + optionalArguments: ["timeoutMs"], + argumentTypes: [ + "timeoutMs": .number, + ], + argumentHints: [ + "timeoutMs": "Optional timeout for waiting on the limited-library picker completion.", + ], + resultSummary: "Object with action/status and selectedIdentifiers when the limited selection changes." + ), + handler: { args, context in + try systemUI.presentLimitedPhotoLibraryPicker(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .documentsUIPick, @@ -521,6 +541,59 @@ public enum DefaultCapabilityLoader { try systemUI.pickDocuments(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .documentsUIExport, + title: "Export documents with system UI", + summary: "Present Files export UI to save one or more sandbox files to a user-selected destination.", + tags: ["documents", "files", "system-ui", "export", "save"], + example: "await apple.documents.export({ path: 'tmp:report.pdf' })", + optionalArguments: ["path", "paths", "asCopy", "timeoutMs"], + argumentTypes: [ + "path": .string, + "paths": .array, + "asCopy": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Single sandbox file path to export.", + "paths": "Optional array of sandbox file paths to export.", + "asCopy": "Whether to export as a copy; default true.", + "timeoutMs": "Optional timeout for waiting on export completion.", + ], + resultSummary: "Object with action/count and destination URLs when the provider returns them." + ), + handler: { args, context in + try systemUI.exportDocuments(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .documentsUIOpenIn, + title: "Open document in another app", + summary: "Present the system Open In menu for a sandbox file.", + tags: ["documents", "files", "system-ui", "open-in", "handoff"], + example: "await apple.documents.openIn({ path: 'tmp:report.pdf' })", + requiredArguments: ["path"], + optionalArguments: ["name", "uti", "timeoutMs"], + argumentTypes: [ + "path": .string, + "name": .string, + "uti": .string, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Sandbox file path to hand off.", + "name": "Optional display name for the document interaction controller.", + "uti": "Optional uniform type identifier override.", + "timeoutMs": "Optional timeout for waiting on the Open In menu dismissal.", + ], + resultSummary: "Object with action and application bundle identifier when the user hands off the file." + ), + handler: { args, context in + try systemUI.openDocument(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .documentsUIScan, @@ -623,6 +696,38 @@ public enum DefaultCapabilityLoader { try systemUI.captureCamera(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .cameraUIScanData, + title: "Scan text or barcodes with camera UI", + summary: "Present VisionKit live data scanner UI and return recognized text or barcode payloads.", + tags: ["camera", "scan", "barcode", "text", "visionkit", "system-ui"], + example: "await apple.camera.scanData({ mode: 'barcode', returnsOnFirstResult: true })", + optionalArguments: ["mode", "recognizedDataTypes", "languages", "qualityLevel", "recognizesMultipleItems", "returnsOnFirstResult", "timeoutMs"], + argumentTypes: [ + "mode": .string, + "recognizedDataTypes": .array, + "languages": .array, + "qualityLevel": .string, + "recognizesMultipleItems": .bool, + "returnsOnFirstResult": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "mode": "any (default), text, or barcode.", + "recognizedDataTypes": "Optional array containing text and/or barcode; overrides mode.", + "languages": "Optional text recognition language identifiers.", + "qualityLevel": "balanced (default), fast, or accurate.", + "recognizesMultipleItems": "Whether the scanner tracks multiple items at once; default false.", + "returnsOnFirstResult": "Whether to dismiss as soon as data is recognized; default true.", + "timeoutMs": "Optional timeout for waiting on a scan result or cancellation.", + ], + resultSummary: "Object with action and items containing text transcripts or barcode payloads." + ), + handler: { args, context in + try systemUI.scanData(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .mailUICompose, @@ -685,6 +790,36 @@ public enum DefaultCapabilityLoader { try systemUI.composeMessage(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .printUIPresent, + title: "Present print UI", + summary: "Present the system print sheet for one or more sandbox files.", + tags: ["print", "documents", "system-ui", "export"], + example: "await apple.print.present({ path: 'tmp:report.pdf', jobName: 'Report' })", + optionalArguments: ["path", "paths", "jobName", "outputType", "showsNumberOfCopies", "timeoutMs"], + argumentTypes: [ + "path": .string, + "paths": .array, + "jobName": .string, + "outputType": .string, + "showsNumberOfCopies": .bool, + "timeoutMs": .number, + ], + argumentHints: [ + "path": "Single sandbox file path to print.", + "paths": "Optional array of sandbox file paths to print.", + "jobName": "Optional print job name.", + "outputType": "general (default), photo, or grayscale.", + "showsNumberOfCopies": "Whether copy count controls are shown; default true.", + "timeoutMs": "Optional timeout for waiting on print completion/cancellation.", + ], + resultSummary: "Object with action/completed for the print interaction." + ), + handler: { args, context in + try systemUI.presentPrint(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .webUIPresent, @@ -766,6 +901,53 @@ public enum DefaultCapabilityLoader { try systemUI.presentAlert(arguments: args, context: context) } ), + CapabilityRegistration( + descriptor: .init( + id: .uiPromptPresent, + title: "Present prompt with text fields", + summary: "Present a system alert with one or more text fields and custom buttons.", + tags: ["ui", "alert", "prompt", "input", "system-ui"], + example: "await apple.ui.presentPrompt({ title: 'Name', fields: [{ id: 'name', placeholder: 'Name' }], buttons: [{ id: 'cancel', title: 'Cancel', style: 'cancel' }, { id: 'ok', title: 'OK' }] })", + requiredArguments: ["fields", "buttons"], + optionalArguments: ["title", "message", "timeoutMs"], + argumentTypes: [ + "title": .string, + "message": .string, + "fields": .array, + "buttons": .array, + "timeoutMs": .number, + ], + argumentHints: [ + "fields": "Array of { id?, placeholder?, text?/defaultValue?, secure?, keyboardType? }. keyboardType is default, email, number, phone, or url.", + "buttons": "Array of { id?, title, style? }; style is default, cancel, or destructive. At most one cancel button.", + "timeoutMs": "Optional timeout for waiting on user selection.", + ], + resultSummary: "Object with selected button metadata and values keyed by field id." + ), + handler: { args, context in + try systemUI.presentPrompt(arguments: args, context: context) + } + ), + CapabilityRegistration( + descriptor: .init( + id: .settingsUIOpen, + title: "Open app settings", + summary: "Open the host app's Settings page so the user can recover denied permissions.", + tags: ["settings", "permissions", "system-ui"], + example: "await apple.settings.open()", + optionalArguments: ["timeoutMs"], + argumentTypes: [ + "timeoutMs": .number, + ], + argumentHints: [ + "timeoutMs": "Optional timeout for waiting on UIApplication.open completion.", + ], + resultSummary: "Object with action opened/failed and opened boolean." + ), + handler: { args, context in + try systemUI.openSettings(arguments: args, context: context) + } + ), CapabilityRegistration( descriptor: .init( id: .visionImageAnalyze, diff --git a/Sources/CodeMode/Bridges/SystemUIBridge.swift b/Sources/CodeMode/Bridges/SystemUIBridge.swift index 9f37cee..97a1186 100644 --- a/Sources/CodeMode/Bridges/SystemUIBridge.swift +++ b/Sources/CodeMode/Bridges/SystemUIBridge.swift @@ -28,6 +28,12 @@ public final class SystemUIBridge: @unchecked Sendable { return try context.systemUIPresenter.pickPhotos(arguments: arguments, context: context) } + public func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateTimeoutMs(arguments, capability: "photos.ui.presentLimitedLibraryPicker") + try context.checkCancellation() + return try context.systemUIPresenter.presentLimitedPhotoLibraryPicker(arguments: arguments, context: context) + } + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validateContactPickerArguments(arguments) try context.checkCancellation() @@ -58,6 +64,20 @@ public final class SystemUIBridge: @unchecked Sendable { return try context.systemUIPresenter.pickDocuments(arguments: arguments, context: context) } + public func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validatePathOrPaths(arguments, capability: "documents.ui.export") + try validateTimeoutMs(arguments, capability: "documents.ui.export") + try context.checkCancellation() + return try context.systemUIPresenter.exportDocuments(arguments: arguments, context: context) + } + + public func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateNonemptyString(arguments, key: "path", capability: "documents.ui.openIn") + try validateTimeoutMs(arguments, capability: "documents.ui.openIn") + try context.checkCancellation() + return try context.systemUIPresenter.openDocument(arguments: arguments, context: context) + } + public func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validateNonemptyOptionalString(arguments, key: "outputDirectory", capability: "documents.ui.scan") try validateTimeoutMs(arguments, capability: "documents.ui.scan") @@ -84,6 +104,12 @@ public final class SystemUIBridge: @unchecked Sendable { return try context.systemUIPresenter.captureCamera(arguments: arguments, context: context) } + public func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateDataScannerArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.scanData(arguments: arguments, context: context) + } + public func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validateMessageRecipients(arguments, keys: ["to", "cc", "bcc"], capability: "mail.ui.compose") try validateAttachmentObjects(arguments, capability: "mail.ui.compose") @@ -100,6 +126,18 @@ public final class SystemUIBridge: @unchecked Sendable { return try context.systemUIPresenter.composeMessage(arguments: arguments, context: context) } + public func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validatePathOrPaths(arguments, capability: "print.ui.present") + if let outputType = arguments.string("outputType")?.lowercased(), + ["general", "photo", "grayscale"].contains(outputType) == false + { + throw BridgeError.invalidArguments("print.ui.present outputType must be general, photo, or grayscale") + } + try validateTimeoutMs(arguments, capability: "print.ui.present") + try context.checkCancellation() + return try context.systemUIPresenter.presentPrint(arguments: arguments, context: context) + } + public func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try validateHTTPURL(arguments, key: "url", capability: "web.ui.present") try validateTimeoutMs(arguments, capability: "web.ui.present") @@ -121,6 +159,18 @@ public final class SystemUIBridge: @unchecked Sendable { return try context.systemUIPresenter.presentAlert(arguments: arguments, context: context) } + public func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validatePromptArguments(arguments) + try context.checkCancellation() + return try context.systemUIPresenter.presentPrompt(arguments: arguments, context: context) + } + + public func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try validateTimeoutMs(arguments, capability: "settings.ui.open") + try context.checkCancellation() + return try context.systemUIPresenter.openSettings(arguments: arguments, context: context) + } + private func validateCalendarPickerArguments(_ arguments: [String: JSONValue]) throws { if let selectionStyle = arguments.string("selectionStyle")?.lowercased(), ["single", "multiple"].contains(selectionStyle) == false @@ -210,6 +260,94 @@ public final class SystemUIBridge: @unchecked Sendable { try validateTimeoutMs(arguments, capability: "ui.alert.present") } + private func validatePromptArguments(_ arguments: [String: JSONValue]) throws { + try validateAlertArguments(arguments, capability: "ui.prompt.present") + if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), + ["actionSheet", "actionsheet"].contains(preferredStyle) + { + throw BridgeError.invalidArguments("ui.prompt.present preferredStyle must be alert") + } + + guard let fields = arguments.array("fields"), fields.isEmpty == false else { + throw BridgeError.invalidArguments("ui.prompt.present requires a non-empty fields array") + } + + for (index, field) in fields.enumerated() { + guard let object = field.objectValue else { + throw BridgeError.invalidArguments("ui.prompt.present fields must contain objects") + } + try validateNonemptyOptionalString(object, key: "id", capability: "ui.prompt.present field \(index)") + try validateNonemptyOptionalString(object, key: "placeholder", capability: "ui.prompt.present field \(index)") + try validateNonemptyOptionalString(object, key: "text", capability: "ui.prompt.present field \(index)") + try validateNonemptyOptionalString(object, key: "defaultValue", capability: "ui.prompt.present field \(index)") + if let keyboardType = object.string("keyboardType")?.lowercased(), + ["default", "email", "number", "phone", "url"].contains(keyboardType) == false + { + throw BridgeError.invalidArguments("ui.prompt.present field keyboardType must be default, email, number, phone, or url") + } + } + } + + private func validateAlertArguments(_ arguments: [String: JSONValue], capability: String) throws { + if let preferredStyle = arguments.string("preferredStyle")?.lowercased(), + ["alert", "actionSheet", "actionsheet"].contains(preferredStyle) == false + { + throw BridgeError.invalidArguments("\(capability) preferredStyle must be alert or actionSheet") + } + + guard let buttons = arguments.array("buttons"), buttons.isEmpty == false else { + throw BridgeError.invalidArguments("\(capability) requires a non-empty buttons array") + } + + var cancelCount = 0 + for (index, button) in buttons.enumerated() { + guard let object = button.objectValue else { + throw BridgeError.invalidArguments("\(capability) buttons must contain objects") + } + try validateNonemptyString(object, key: "title", capability: "\(capability) button \(index)") + try validateNonemptyOptionalString(object, key: "id", capability: "\(capability) button \(index)") + if let style = object.string("style")?.lowercased() { + guard ["default", "cancel", "destructive"].contains(style) else { + throw BridgeError.invalidArguments("\(capability) button style must be default, cancel, or destructive") + } + if style == "cancel" { + cancelCount += 1 + } + } + } + + if cancelCount > 1 { + throw BridgeError.invalidArguments("\(capability) supports at most one cancel button") + } + + try validateTimeoutMs(arguments, capability: capability) + } + + private func validateDataScannerArguments(_ arguments: [String: JSONValue]) throws { + if let mode = arguments.string("mode")?.lowercased(), + ["any", "text", "barcode"].contains(mode) == false + { + throw BridgeError.invalidArguments("camera.ui.scanData mode must be any, text, or barcode") + } + + try validateStringArray(arguments, key: "recognizedDataTypes", capability: "camera.ui.scanData") + for value in arguments.array("recognizedDataTypes") ?? [] { + guard let type = value.stringValue?.lowercased(), + ["text", "barcode"].contains(type) + else { + throw BridgeError.invalidArguments("camera.ui.scanData recognizedDataTypes must contain text or barcode") + } + } + + try validateStringArray(arguments, key: "languages", capability: "camera.ui.scanData") + if let qualityLevel = arguments.string("qualityLevel")?.lowercased(), + ["balanced", "fast", "accurate"].contains(qualityLevel) == false + { + throw BridgeError.invalidArguments("camera.ui.scanData qualityLevel must be balanced, fast, or accurate") + } + try validateTimeoutMs(arguments, capability: "camera.ui.scanData") + } + private func validateShareArguments(_ arguments: [String: JSONValue]) throws { try validateHTTPURL(arguments, key: "url", capability: "share.ui.present", required: false) try validateStringArray(arguments, key: "paths", capability: "share.ui.present") diff --git a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift index f1e2c67..299cf67 100644 --- a/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift +++ b/Sources/CodeMode/Catalog/JavaScriptBindingCatalog.swift @@ -68,16 +68,23 @@ enum JavaScriptBindingCatalog { .photosRead: ["apple.photos.list"], .photosExport: ["apple.photos.export"], .photosUIPick: ["apple.photos.pick"], + .photosUIPresentLimitedLibraryPicker: ["apple.photos.presentLimitedLibraryPicker"], .documentsUIPick: ["apple.documents.pick"], + .documentsUIExport: ["apple.documents.export", "apple.documents.save"], + .documentsUIOpenIn: ["apple.documents.openIn"], .documentsUIScan: ["apple.documents.scan"], .shareUIPresent: ["apple.share.present"], .quickLookUIPreview: ["apple.quicklook.preview"], .cameraUICapture: ["apple.camera.capture"], + .cameraUIScanData: ["apple.camera.scanData"], .mailUICompose: ["apple.mail.compose"], .messagesUICompose: ["apple.messages.compose"], + .printUIPresent: ["apple.print.present"], .webUIPresent: ["apple.web.present"], .authUIWebAuthenticate: ["apple.auth.webAuthenticate"], .uiAlertPresent: ["apple.ui.presentAlert"], + .uiPromptPresent: ["apple.ui.presentPrompt"], + .settingsUIOpen: ["apple.settings.open"], .visionImageAnalyze: ["apple.vision.analyzeImage"], .notificationsPermissionRequest: ["apple.notifications.requestPermission"], .notificationsSchedule: ["apple.notifications.schedule"], diff --git a/Sources/CodeMode/Host/HostConfigurationValidator.swift b/Sources/CodeMode/Host/HostConfigurationValidator.swift index ee6cae0..60c8f19 100644 --- a/Sources/CodeMode/Host/HostConfigurationValidator.swift +++ b/Sources/CodeMode/Host/HostConfigurationValidator.swift @@ -48,11 +48,17 @@ public enum HostConfigurationValidator { keys.insert("NSRemindersFullAccessUsageDescription") } - if capabilities.contains(.photosRead) || capabilities.contains(.photosExport) { + if capabilities.contains(.photosRead) || + capabilities.contains(.photosExport) || + capabilities.contains(.photosUIPresentLimitedLibraryPicker) + { keys.insert("NSPhotoLibraryUsageDescription") } - if capabilities.contains(.cameraUICapture) || capabilities.contains(.documentsUIScan) { + if capabilities.contains(.cameraUICapture) || + capabilities.contains(.documentsUIScan) || + capabilities.contains(.cameraUIScanData) + { keys.insert("NSCameraUsageDescription") } diff --git a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift index 4f84ecd..d1c2d8e 100644 --- a/Sources/CodeMode/Runtime/RuntimeJavaScript.swift +++ b/Sources/CodeMode/Runtime/RuntimeJavaScript.swift @@ -133,11 +133,15 @@ enum RuntimeJavaScript { globalThis.apple.photos = { list: function(args) { return __invokeAsync('photos.read', args || {}); }, export: function(args) { return __invokeAsync('photos.export', args || {}); }, - pick: function(args) { return __invokeAsync('photos.ui.pick', args || {}); } + pick: function(args) { return __invokeAsync('photos.ui.pick', args || {}); }, + presentLimitedLibraryPicker: function(args) { return __invokeAsync('photos.ui.presentLimitedLibraryPicker', args || {}); } }; globalThis.apple.documents = { pick: function(args) { return __invokeAsync('documents.ui.pick', args || {}); }, + export: function(args) { return __invokeAsync('documents.ui.export', args || {}); }, + save: function(args) { return __invokeAsync('documents.ui.export', args || {}); }, + openIn: function(args) { return __invokeAsync('documents.ui.openIn', args || {}); }, scan: function(args) { return __invokeAsync('documents.ui.scan', args || {}); } }; @@ -150,7 +154,8 @@ enum RuntimeJavaScript { }; globalThis.apple.camera = { - capture: function(args) { return __invokeAsync('camera.ui.capture', args || {}); } + capture: function(args) { return __invokeAsync('camera.ui.capture', args || {}); }, + scanData: function(args) { return __invokeAsync('camera.ui.scanData', args || {}); } }; globalThis.apple.mail = { @@ -161,6 +166,10 @@ enum RuntimeJavaScript { compose: function(args) { return __invokeAsync('messages.ui.compose', args || {}); } }; + globalThis.apple.print = { + present: function(args) { return __invokeAsync('print.ui.present', args || {}); } + }; + globalThis.apple.web = { present: function(args) { return __invokeAsync('web.ui.present', args || {}); } }; @@ -170,7 +179,12 @@ enum RuntimeJavaScript { }; globalThis.apple.ui = { - presentAlert: function(args) { return __invokeAsync('ui.alert.present', args || {}); } + presentAlert: function(args) { return __invokeAsync('ui.alert.present', args || {}); }, + presentPrompt: function(args) { return __invokeAsync('ui.prompt.present', args || {}); } + }; + + globalThis.apple.settings = { + open: function(args) { return __invokeAsync('settings.ui.open', args || {}); } }; globalThis.apple.vision = { diff --git a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift index 9470469..81782b9 100644 --- a/Sources/CodeMode/Support/CapabilityPlatformSupport.swift +++ b/Sources/CodeMode/Support/CapabilityPlatformSupport.swift @@ -74,16 +74,23 @@ enum CapabilityPlatformSupport { .contactsUIPresentContact, .contactsUIPresentNewContact, .photosUIPick, + .photosUIPresentLimitedLibraryPicker, .documentsUIPick, + .documentsUIExport, + .documentsUIOpenIn, .documentsUIScan, .shareUIPresent, .quickLookUIPreview, .cameraUICapture, + .cameraUIScanData, .mailUICompose, .messagesUICompose, + .printUIPresent, .webUIPresent, .authUIWebAuthenticate, .uiAlertPresent, + .uiPromptPresent, + .settingsUIOpen, .locationPermissionRequest, .alarmPermissionRequest, .alarmRead, @@ -101,12 +108,19 @@ enum CapabilityPlatformSupport { .contactsUIPresentContact, .contactsUIPresentNewContact, .photosUIPick, + .photosUIPresentLimitedLibraryPicker, .documentsUIPick, + .documentsUIExport, + .documentsUIOpenIn, .shareUIPresent, .quickLookUIPreview, + .cameraUIScanData, + .printUIPresent, .webUIPresent, .authUIWebAuthenticate, .uiAlertPresent, + .uiPromptPresent, + .settingsUIOpen, ]) } diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift index 3e2f88a..c5fac79 100644 --- a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift @@ -6,6 +6,7 @@ import Foundation @preconcurrency import ContactsUI @preconcurrency import EventKit @preconcurrency import EventKitUI +@preconcurrency import Photos @preconcurrency import PhotosUI @preconcurrency import QuickLook @preconcurrency import SafariServices @@ -206,6 +207,34 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return .array(exported) } + public func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + guard status == .limited else { + return .object([ + "action": .string("notLimited"), + "status": .string(photoAuthorizationStatusString(status)), + ]) + } + + return try runUIOperation(timeoutMs: timeoutMs) { complete in + do { + let presenter = try self.requirePresenter() + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: presenter) { identifiers in + complete(.success(.object([ + "action": .string("completed"), + "status": .string("limited"), + "selectedIdentifiers": .array(identifiers.map(JSONValue.string)), + ]))) + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + public func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs let mode = arguments.string("mode")?.lowercased() ?? "single" @@ -359,6 +388,76 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl }) } + public func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let urls = try sandboxURLs(arguments: arguments, context: context) + let asCopy = arguments.bool("asCopy") ?? true + let token = UUID() + + let destinationURLs: [URL] = try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIDocumentPickerViewController(forExporting: urls, asCopy: asCopy) + let coordinator = DocumentPickerCoordinator { [weak self] urls in + self?.releaseCoordinator(token) + complete(.success(urls)) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + return .object([ + "action": .string(destinationURLs.isEmpty ? "cancelled" : "exported"), + "count": .number(Double(urls.count)), + "destinationURLs": .array(destinationURLs.map { .string($0.absoluteString) }), + ]) + } + + public func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let url = try sandboxURL(path: arguments.string("path") ?? "", context: context) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIDocumentInteractionController(url: url) + controller.name = arguments.string("name") + controller.uti = arguments.string("uti") + + let coordinator = DocumentInteractionCoordinator(presenter: presenter) { [weak self] result in + self?.releaseCoordinator(token) + complete(.success(result)) + } + coordinator.controller = controller + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + let sourceRect = CGRect( + x: presenter.view.bounds.midX, + y: presenter.view.bounds.midY, + width: 1, + height: 1 + ) + guard controller.presentOpenInMenu(from: sourceRect, in: presenter.view, animated: true) else { + self.releaseCoordinator(token) + complete(.failure(.unsupportedPlatform("documents.ui.openIn"))) + return + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + public func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { #if os(iOS) && canImport(VisionKit) let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs @@ -538,6 +637,71 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl #endif } + public func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + #if canImport(VisionKit) + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + guard DataScannerViewController.isSupported, DataScannerViewController.isAvailable else { + complete(.failure(.unsupportedPlatform("camera.ui.scanData"))) + return + } + + let presenter = try self.requirePresenter() + let controller = DataScannerViewController( + recognizedDataTypes: self.scannerRecognizedDataTypes(from: arguments), + qualityLevel: self.scannerQualityLevel(from: arguments), + recognizesMultipleItems: arguments.bool("recognizesMultipleItems") ?? false, + isHighFrameRateTrackingEnabled: true, + isPinchToZoomEnabled: true, + isGuidanceEnabled: true, + isHighlightingEnabled: true + ) + let navigation = UINavigationController(rootViewController: controller) + let coordinator = DataScannerCoordinator( + controller: controller, + navigationController: navigation, + returnsOnFirstResult: arguments.bool("returnsOnFirstResult") ?? true + ) { [weak self] result in + self?.releaseCoordinator(token) + complete(result) + } + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + controller.navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: coordinator, + action: #selector(DataScannerCoordinator.cancel) + ) + + presenter.present(navigation, animated: true) { + do { + try controller.startScanning() + } catch let error as DataScannerViewController.ScanningUnavailable { + navigation.dismiss(animated: true) + self.releaseCoordinator(token) + complete(.failure(.unsupportedPlatform("camera.ui.scanData \(error)"))) + } catch { + navigation.dismiss(animated: true) + self.releaseCoordinator(token) + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + #else + _ = arguments + _ = context + throw BridgeError.unsupportedPlatform("camera.ui.scanData") + #endif + } + public func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { #if canImport(MessageUI) && os(iOS) let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs @@ -620,6 +784,57 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl #endif } + public func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let urls = try sandboxURLs(arguments: arguments, context: context) + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIPrintInteractionController.shared + let printInfo = UIPrintInfo(dictionary: nil) + printInfo.jobName = arguments.string("jobName") ?? urls.first?.lastPathComponent ?? "CodeMode Print Job" + printInfo.outputType = self.printOutputType(from: arguments) + controller.printInfo = printInfo + controller.showsNumberOfCopies = arguments.bool("showsNumberOfCopies") ?? true + if urls.count == 1 { + controller.printingItem = urls[0] + controller.printingItems = nil + } else { + controller.printingItem = nil + controller.printingItems = urls + } + + let coordinator = PrintCoordinator(parent: presenter) + self.retainCoordinator(coordinator, token: token) + controller.delegate = coordinator + + guard controller.present(animated: true, completionHandler: { [weak self] controller, completed, error in + controller.delegate = nil + self?.releaseCoordinator(token) + if let error { + complete(.failure(.nativeFailure(error.localizedDescription))) + return + } + complete(.success(.object([ + "action": .string(completed ? "completed" : "cancelled"), + "completed": .bool(completed), + ]))) + }) else { + controller.delegate = nil + self.releaseCoordinator(token) + complete(.failure(.unsupportedPlatform("print.ui.present"))) + return + } + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + public func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs let url = URL(string: arguments.string("url") ?? "")! @@ -775,6 +990,85 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } + public func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + let token = UUID() + + return try runUIOperation(timeoutMs: timeoutMs, onTimeout: { self.releaseCoordinator(token) }) { complete in + do { + let presenter = try self.requirePresenter() + let controller = UIAlertController( + title: arguments.string("title"), + message: arguments.string("message"), + preferredStyle: .alert + ) + let fields = arguments.array("fields") ?? [] + + for field in fields { + let object = field.objectValue ?? [:] + controller.addTextField { textField in + textField.placeholder = object.string("placeholder") + textField.text = object.string("text") ?? object.string("defaultValue") + textField.isSecureTextEntry = object.bool("secure") ?? false + textField.keyboardType = self.keyboardType(object.string("keyboardType")) + } + } + + for (index, button) in (arguments.array("buttons") ?? []).enumerated() { + guard let object = button.objectValue else { + complete(.failure(.invalidArguments("ui.prompt.present buttons must contain objects"))) + return + } + let title = object.string("title") ?? "" + let id = object.string("id") ?? title + let styleName = object.string("style")?.lowercased() ?? "default" + let actionStyle = self.alertActionStyle(styleName) + controller.addAction( + UIAlertAction(title: title, style: actionStyle) { [weak self, weak controller] _ in + var values: [String: JSONValue] = [:] + for (fieldIndex, field) in fields.enumerated() { + let fieldID = field.objectValue?.string("id") ?? "\(fieldIndex)" + values[fieldID] = .string(controller?.textFields?[fieldIndex].text ?? "") + } + self?.releaseCoordinator(token) + complete(.success(.object([ + "action": .string("selected"), + "buttonID": .string(id), + "buttonTitle": .string(title), + "buttonIndex": .number(Double(index)), + "style": .string(styleName), + "values": .object(values), + ]))) + } + ) + } + + self.retainCoordinator(controller, token: token) + presenter.present(controller, animated: true) + } catch let error as BridgeError { + complete(.failure(error)) + } catch { + complete(.failure(.nativeFailure(error.localizedDescription))) + } + } + } + + public func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + let timeoutMs = arguments.int("timeoutMs") ?? Self.defaultTimeoutMs + guard let url = URL(string: UIApplication.openSettingsURLString) else { + throw BridgeError.unsupportedPlatform("settings.ui.open") + } + + return try runUIOperation(timeoutMs: timeoutMs) { complete in + UIApplication.shared.open(url, options: [:]) { success in + complete(.success(.object([ + "action": .string(success ? "opened" : "failed"), + "opened": .bool(success), + ]))) + } + } + } + private func runUIOperation( timeoutMs: Int, onTimeout: (@Sendable () -> Void)? = nil, @@ -856,6 +1150,80 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl } } + @MainActor private func keyboardType(_ name: String?) -> UIKeyboardType { + switch name?.lowercased() { + case "email": + return .emailAddress + case "number": + return .numberPad + case "phone": + return .phonePad + case "url": + return .URL + default: + return .default + } + } + + @MainActor private func printOutputType(from arguments: [String: JSONValue]) -> UIPrintInfo.OutputType { + switch arguments.string("outputType")?.lowercased() { + case "photo": + return .photo + case "grayscale": + return .grayscale + default: + return .general + } + } + + private func photoAuthorizationStatusString(_ status: PHAuthorizationStatus) -> String { + switch status { + case .authorized: + return "authorized" + case .limited: + return "limited" + case .denied: + return "denied" + case .restricted: + return "restricted" + case .notDetermined: + return "notDetermined" + @unknown default: + return "unknown" + } + } + + #if canImport(VisionKit) + @MainActor private func scannerRecognizedDataTypes(from arguments: [String: JSONValue]) -> Set { + let languages = (arguments.array("languages") ?? []).compactMap(\.stringValue) + let requested = (arguments.array("recognizedDataTypes") ?? []).compactMap { $0.stringValue?.lowercased() } + let types = requested.isEmpty ? [arguments.string("mode")?.lowercased() ?? "any"] : requested + + var recognized: Set = [] + if types.contains("any") || types.contains("text") { + recognized.insert(.text(languages: languages)) + } + if types.contains("any") || types.contains("barcode") { + recognized.insert(.barcode()) + } + if recognized.isEmpty { + recognized = [.text(languages: languages), .barcode()] + } + return recognized + } + + @MainActor private func scannerQualityLevel(from arguments: [String: JSONValue]) -> DataScannerViewController.QualityLevel { + switch arguments.string("qualityLevel")?.lowercased() { + case "fast": + return .fast + case "accurate": + return .accurate + default: + return .balanced + } + } + #endif + private func exportPickerResult( _ result: PhotoPickerSelection, index: Int, @@ -1119,6 +1487,10 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return try paths.map { try context.pathPolicy.resolve(path: $0) } } + private func sandboxURL(path: String, context: BridgeInvocationContext) throws -> URL { + try context.pathPolicy.resolve(path: path) + } + @MainActor private func shareItems(from arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> [Any] { var items: [Any] = [] if let text = arguments.string("text") { @@ -1379,6 +1751,66 @@ private struct PhotoPickerSelection: @unchecked Sendable { } } +@MainActor private final class DocumentInteractionCoordinator: NSObject, @preconcurrency UIDocumentInteractionControllerDelegate { + weak var presenter: UIViewController? + var controller: UIDocumentInteractionController? + private var application: String? + private var didSend = false + private let onComplete: (JSONValue) -> Void + + init(presenter: UIViewController, onComplete: @escaping (JSONValue) -> Void) { + self.presenter = presenter + self.onComplete = onComplete + } + + func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { + _ = controller + return presenter ?? UIViewController() + } + + func documentInteractionController( + _ controller: UIDocumentInteractionController, + willBeginSendingToApplication application: String? + ) { + _ = controller + self.application = application + didSend = true + } + + func documentInteractionController( + _ controller: UIDocumentInteractionController, + didEndSendingToApplication application: String? + ) { + _ = controller + self.application = application ?? self.application + didSend = true + } + + func documentInteractionControllerDidDismissOpenInMenu(_ controller: UIDocumentInteractionController) { + _ = controller + var object: [String: JSONValue] = [ + "action": .string(didSend ? "sent" : "dismissed"), + ] + if let application { + object["application"] = .string(application) + } + onComplete(.object(object)) + } +} + +@MainActor private final class PrintCoordinator: NSObject, UIPrintInteractionControllerDelegate { + weak var parent: UIViewController? + + init(parent: UIViewController) { + self.parent = parent + } + + func printInteractionControllerParentViewController(_ printInteractionController: UIPrintInteractionController) -> UIViewController? { + _ = printInteractionController + return parent + } +} + @MainActor private final class QuickLookCoordinator: NSObject, QLPreviewControllerDataSource, @preconcurrency QLPreviewControllerDelegate { private let urls: [URL] private let onComplete: () -> Void @@ -1404,6 +1836,117 @@ private struct PhotoPickerSelection: @unchecked Sendable { } } +#if canImport(VisionKit) +@MainActor private final class DataScannerCoordinator: NSObject, DataScannerViewControllerDelegate { + private weak var controller: DataScannerViewController? + private weak var navigationController: UINavigationController? + private let returnsOnFirstResult: Bool + private let onComplete: (Result) -> Void + private var completed = false + private var currentItems: [RecognizedItem] = [] + + init( + controller: DataScannerViewController, + navigationController: UINavigationController, + returnsOnFirstResult: Bool, + onComplete: @escaping (Result) -> Void + ) { + self.controller = controller + self.navigationController = navigationController + self.returnsOnFirstResult = returnsOnFirstResult + self.onComplete = onComplete + } + + @objc func cancel() { + complete(action: "cancelled", items: currentItems) + } + + func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { + _ = dataScanner + complete(action: "selected", items: [item]) + } + + func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { + _ = dataScanner + currentItems = allItems + if returnsOnFirstResult, (allItems.isEmpty == false || addedItems.isEmpty == false) { + complete(action: "recognized", items: allItems.isEmpty ? addedItems : allItems) + } + } + + func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) { + _ = dataScanner + _ = updatedItems + currentItems = allItems + } + + func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) { + _ = dataScanner + _ = removedItems + currentItems = allItems + } + + func dataScanner(_ dataScanner: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { + _ = dataScanner + complete(.failure(.unsupportedPlatform("camera.ui.scanData \(error)"))) + } + + private func complete(action: String, items: [RecognizedItem]) { + complete(.success(.object([ + "action": .string(action), + "items": .array(items.map(mapRecognizedItem)), + ]))) + } + + private func complete(_ result: Result) { + guard completed == false else { + return + } + completed = true + controller?.stopScanning() + navigationController?.dismiss(animated: true) + onComplete(result) + } +} + +@MainActor private func mapRecognizedItem(_ item: RecognizedItem) -> JSONValue { + var object: [String: JSONValue] = [ + "id": .string(item.id.uuidString), + "bounds": mapRecognizedBounds(item.bounds), + ] + + switch item { + case .text(let text): + object["type"] = .string("text") + object["transcript"] = .string(text.transcript) + case .barcode(let barcode): + object["type"] = .string("barcode") + object["payload"] = .string(barcode.payloadStringValue ?? "") + object["symbology"] = .string(String(describing: barcode.observation.symbology)) + @unknown default: + object["type"] = .string("unknown") + } + + return .object(object) +} + +@MainActor private func mapRecognizedBounds(_ bounds: RecognizedItem.Bounds) -> JSONValue { + .object([ + "topLeft": mapPoint(bounds.topLeft), + "topRight": mapPoint(bounds.topRight), + "bottomRight": mapPoint(bounds.bottomRight), + "bottomLeft": mapPoint(bounds.bottomLeft), + ]) +} + +@MainActor private func mapPoint(_ point: CGPoint) -> JSONValue { + .object([ + "x": .number(Double(point.x)), + "y": .number(Double(point.y)), + ]) +} +#endif + #if os(iOS) private struct CameraCaptureResult: Sendable { var url: URL diff --git a/Sources/CodeModeEvaluation/EvalScenarios.swift b/Sources/CodeModeEvaluation/EvalScenarios.swift index 1f5f9c3..831d211 100644 --- a/Sources/CodeModeEvaluation/EvalScenarios.swift +++ b/Sources/CodeModeEvaluation/EvalScenarios.swift @@ -619,16 +619,24 @@ public enum CodeModeEvalScenarios { "apple.contacts.presentContact", "apple.contacts.presentNewContact", "apple.photos.pick", + "apple.photos.presentLimitedLibraryPicker", "apple.documents.pick", + "apple.documents.export", + "apple.documents.save", + "apple.documents.openIn", "apple.documents.scan", "apple.share.present", "apple.quicklook.preview", "apple.camera.capture", + "apple.camera.scanData", "apple.mail.compose", "apple.messages.compose", + "apple.print.present", "apple.web.present", "apple.auth.webAuthenticate", - "apple.ui.presentAlert" + "apple.ui.presentAlert", + "apple.ui.presentPrompt", + "apple.settings.open" ]; return Object.fromEntries(names.map(name => [name, api.byJSName[name] ?? null])); } @@ -644,14 +652,22 @@ public enum CodeModeEvalScenarios { "\"apple.contacts.pick\":null", "\"apple.contacts.presentContact\":null", "\"apple.contacts.presentNewContact\":null", + "\"apple.documents.export\":null", + "\"apple.documents.openIn\":null", "\"apple.documents.pick\":null", + "\"apple.documents.save\":null", "\"apple.documents.scan\":null", + "\"apple.camera.scanData\":null", "\"apple.mail.compose\":null", "\"apple.messages.compose\":null", + "\"apple.photos.presentLimitedLibraryPicker\":null", "\"apple.photos.pick\":null", + "\"apple.print.present\":null", "\"apple.quicklook.preview\":null", + "\"apple.settings.open\":null", "\"apple.share.present\":null", "\"apple.ui.presentAlert\":null", + "\"apple.ui.presentPrompt\":null", "\"apple.web.present\":null", ] ) @@ -660,17 +676,24 @@ public enum CodeModeEvalScenarios { public static let catalogSharedSystemUIDiscovery = CodeModeEvalScenario( id: "catalog.system-ui-shared-discovery", title: "Catalog discovers shared iOS and visionOS system UI helpers", - task: "Search the iOS catalog for Files document picking, share sheet, Quick Look preview, Safari presentation, web authentication, and custom alert helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", + task: "Search the iOS catalog for Files document picking/export/open-in, share sheet, Quick Look, print, Safari, web authentication, Photos limited-library management, data scanning, settings, alert, and prompt helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", catalogPlatform: .iOS, searchCode: """ async () => { const names = [ "apple.documents.pick", + "apple.documents.export", + "apple.documents.openIn", "apple.share.present", "apple.quicklook.preview", + "apple.print.present", "apple.web.present", "apple.auth.webAuthenticate", - "apple.ui.presentAlert" + "apple.photos.presentLimitedLibraryPicker", + "apple.camera.scanData", + "apple.ui.presentAlert", + "apple.ui.presentPrompt", + "apple.settings.open" ]; return Object.fromEntries(names.map(name => { const ref = api.byJSName[name]; @@ -689,24 +712,42 @@ public enum CodeModeEvalScenarios { toolOrder: [.searchJavaScriptAPI], requiredSearchResultFragments: [ "apple.auth.webAuthenticate", + "apple.camera.scanData", + "apple.documents.export", + "apple.documents.openIn", "apple.documents.pick", + "apple.documents.save", + "apple.photos.presentLimitedLibraryPicker", + "apple.print.present", "apple.quicklook.preview", + "apple.settings.open", "apple.share.present", "apple.ui.presentAlert", + "apple.ui.presentPrompt", "apple.web.present", "auth.ui.webAuthenticate", "buttonID", "buttons", "callbackURL", "callbackURLScheme", + "camera.ui.scanData", "completed", "contentTypes", + "documents.ui.export", + "documents.ui.openIn", "documents.ui.pick", "excludedActivityTypes", + "fields", + "jobName", "outputDirectory", + "photos.ui.presentLimitedLibraryPicker", + "print.ui.present", "quicklook.ui.preview", + "recognizedDataTypes", + "settings.ui.open", "share.ui.present", "ui.alert.present", + "ui.prompt.present", "web.ui.present", ] ) diff --git a/Tests/CodeModeTests/CapabilityRegistryTests.swift b/Tests/CodeModeTests/CapabilityRegistryTests.swift index 78f43de..a056c03 100644 --- a/Tests/CodeModeTests/CapabilityRegistryTests.swift +++ b/Tests/CodeModeTests/CapabilityRegistryTests.swift @@ -28,12 +28,19 @@ import Testing .contactsUIPresentContact, .contactsUIPresentNewContact, .photosUIPick, + .photosUIPresentLimitedLibraryPicker, .documentsUIPick, + .documentsUIExport, + .documentsUIOpenIn, .shareUIPresent, .quickLookUIPreview, + .cameraUIScanData, + .printUIPresent, .webUIPresent, .authUIWebAuthenticate, .uiAlertPresent, + .uiPromptPresent, + .settingsUIOpen, ] let iOSOnlyUICapabilities: Set = [ .documentsUIScan, @@ -69,15 +76,22 @@ import Testing #expect(descriptors[.documentsUIPick]?.requiredPermissions.isEmpty == true) #expect(JavaScriptBindingCatalog.names(for: .documentsUIPick) == ["apple.documents.pick"]) + #expect(JavaScriptBindingCatalog.names(for: .documentsUIExport) == ["apple.documents.export", "apple.documents.save"]) + #expect(JavaScriptBindingCatalog.names(for: .documentsUIOpenIn) == ["apple.documents.openIn"]) #expect(JavaScriptBindingCatalog.names(for: .documentsUIScan) == ["apple.documents.scan"]) #expect(JavaScriptBindingCatalog.names(for: .shareUIPresent) == ["apple.share.present"]) #expect(JavaScriptBindingCatalog.names(for: .quickLookUIPreview) == ["apple.quicklook.preview"]) #expect(JavaScriptBindingCatalog.names(for: .cameraUICapture) == ["apple.camera.capture"]) + #expect(JavaScriptBindingCatalog.names(for: .cameraUIScanData) == ["apple.camera.scanData"]) #expect(JavaScriptBindingCatalog.names(for: .mailUICompose) == ["apple.mail.compose"]) #expect(JavaScriptBindingCatalog.names(for: .messagesUICompose) == ["apple.messages.compose"]) + #expect(JavaScriptBindingCatalog.names(for: .printUIPresent) == ["apple.print.present"]) #expect(JavaScriptBindingCatalog.names(for: .webUIPresent) == ["apple.web.present"]) #expect(JavaScriptBindingCatalog.names(for: .authUIWebAuthenticate) == ["apple.auth.webAuthenticate"]) #expect(JavaScriptBindingCatalog.names(for: .uiAlertPresent) == ["apple.ui.presentAlert"]) + #expect(JavaScriptBindingCatalog.names(for: .uiPromptPresent) == ["apple.ui.presentPrompt"]) + #expect(JavaScriptBindingCatalog.names(for: .photosUIPresentLimitedLibraryPicker) == ["apple.photos.presentLimitedLibraryPicker"]) + #expect(JavaScriptBindingCatalog.names(for: .settingsUIOpen) == ["apple.settings.open"]) #expect(descriptors[.calendarUIPickCalendar]?.requiredPermissions == [.calendarWriteOnly]) #expect(descriptors[.calendarUIPresentEvent]?.requiredPermissions == [.calendar]) diff --git a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift index 92f63e0..d2609b0 100644 --- a/Tests/CodeModeTests/HostConfigurationValidatorTests.swift +++ b/Tests/CodeModeTests/HostConfigurationValidatorTests.swift @@ -73,7 +73,23 @@ import Testing } @Test func validatorDoesNotRequireFullLibraryKeysForSystemPickers() { - let keys = HostConfigurationValidator.requiredInfoPlistKeys(for: [.photosUIPick, .contactsUIPick, .documentsUIPick, .shareUIPresent, .quickLookUIPreview, .webUIPresent, .authUIWebAuthenticate, .uiAlertPresent]) + let keys = HostConfigurationValidator.requiredInfoPlistKeys( + for: [ + .photosUIPick, + .contactsUIPick, + .documentsUIPick, + .documentsUIExport, + .documentsUIOpenIn, + .shareUIPresent, + .quickLookUIPreview, + .printUIPresent, + .webUIPresent, + .authUIWebAuthenticate, + .uiAlertPresent, + .uiPromptPresent, + .settingsUIOpen, + ] + ) #expect(keys.contains("NSPhotoLibraryUsageDescription") == false) #expect(keys.contains("NSContactsUsageDescription") == false) @@ -81,14 +97,22 @@ import Testing @Test func validatorRequiresExpectedKeysForExpandedSystemUI() { let keys = HostConfigurationValidator.requiredInfoPlistKeys( - for: [.calendarUIPresentEvent, .contactsUIPresentContact, .contactsUIPresentNewContact, .documentsUIScan, .cameraUICapture] + for: [ + .calendarUIPresentEvent, + .contactsUIPresentContact, + .contactsUIPresentNewContact, + .photosUIPresentLimitedLibraryPicker, + .documentsUIScan, + .cameraUICapture, + .cameraUIScanData, + ] ) #expect(keys.contains("NSCalendarsFullAccessUsageDescription")) #expect(keys.contains("NSContactsUsageDescription")) #expect(keys.contains("NSCameraUsageDescription")) #expect(keys.contains("NSMicrophoneUsageDescription")) - #expect(keys.contains("NSPhotoLibraryUsageDescription") == false) + #expect(keys.contains("NSPhotoLibraryUsageDescription")) } @Test func validatorReportsPhotosAndHomeKitMissingUsageDescriptions() { diff --git a/Tests/CodeModeTests/HostRuntimeTests.swift b/Tests/CodeModeTests/HostRuntimeTests.swift index 0b5ae01..79f425f 100644 --- a/Tests/CodeModeTests/HostRuntimeTests.swift +++ b/Tests/CodeModeTests/HostRuntimeTests.swift @@ -114,16 +114,24 @@ import Testing "apple.contacts.presentContact", "apple.contacts.presentNewContact", "apple.photos.pick", + "apple.photos.presentLimitedLibraryPicker", "apple.documents.pick", + "apple.documents.export", + "apple.documents.save", + "apple.documents.openIn", "apple.documents.scan", "apple.share.present", "apple.quicklook.preview", "apple.camera.capture", + "apple.camera.scanData", "apple.mail.compose", "apple.messages.compose", + "apple.print.present", "apple.web.present", "apple.auth.webAuthenticate", - "apple.ui.presentAlert" + "apple.ui.presentAlert", + "apple.ui.presentPrompt", + "apple.settings.open" ]; return Object.fromEntries(names.map(name => [name, api.byJSName[name] ?? null])); } @@ -140,16 +148,23 @@ import Testing .contactsUIPresentContact: "apple.contacts.presentContact", .contactsUIPresentNewContact: "apple.contacts.presentNewContact", .photosUIPick: "apple.photos.pick", + .photosUIPresentLimitedLibraryPicker: "apple.photos.presentLimitedLibraryPicker", .documentsUIPick: "apple.documents.pick", + .documentsUIExport: "apple.documents.export", + .documentsUIOpenIn: "apple.documents.openIn", .documentsUIScan: "apple.documents.scan", .shareUIPresent: "apple.share.present", .quickLookUIPreview: "apple.quicklook.preview", .cameraUICapture: "apple.camera.capture", + .cameraUIScanData: "apple.camera.scanData", .mailUICompose: "apple.mail.compose", .messagesUICompose: "apple.messages.compose", + .printUIPresent: "apple.print.present", .webUIPresent: "apple.web.present", .authUIWebAuthenticate: "apple.auth.webAuthenticate", .uiAlertPresent: "apple.ui.presentAlert", + .uiPromptPresent: "apple.ui.presentPrompt", + .settingsUIOpen: "apple.settings.open", ] for (capability, name) in namesByCapability { #expect((result[name] != .null) == CapabilityPlatformSupport.isSupported(capability, for: .current)) diff --git a/Tests/CodeModeTests/SystemUIBridgeTests.swift b/Tests/CodeModeTests/SystemUIBridgeTests.swift index 31eeb88..d6399f5 100644 --- a/Tests/CodeModeTests/SystemUIBridgeTests.swift +++ b/Tests/CodeModeTests/SystemUIBridgeTests.swift @@ -35,15 +35,22 @@ import Testing .contactsUIPresentContact: .object(["action": .string("dismissed")]), .contactsUIPresentNewContact: .object(["action": .string("saved")]), .documentsUIPick: .array([.object(["artifactID": .string("document-1")])]), + .documentsUIExport: .object(["action": .string("exported"), "count": .number(1)]), + .documentsUIOpenIn: .object(["action": .string("sent"), "application": .string("com.example.viewer")]), .documentsUIScan: .array([.object(["artifactID": .string("scan-1")])]), .shareUIPresent: .object(["completed": .bool(true)]), .quickLookUIPreview: .object(["action": .string("dismissed")]), .cameraUICapture: .object(["artifactID": .string("camera-1")]), + .cameraUIScanData: .object(["action": .string("recognized"), "items": .array([.object(["type": .string("barcode")])])]), .mailUICompose: .object(["action": .string("sent")]), .messagesUICompose: .object(["action": .string("sent")]), + .printUIPresent: .object(["action": .string("completed"), "completed": .bool(true)]), .webUIPresent: .object(["action": .string("dismissed")]), .authUIWebAuthenticate: .object(["action": .string("callback")]), .uiAlertPresent: .object(["action": .string("selected"), "buttonID": .string("ok")]), + .uiPromptPresent: .object(["action": .string("selected"), "values": .object(["name": .string("Alex")])]), + .photosUIPresentLimitedLibraryPicker: .object(["action": .string("completed")]), + .settingsUIOpen: .object(["action": .string("opened"), "opened": .bool(true)]), ] ) @@ -80,6 +87,10 @@ import Testing #expect(newContact.objectValue?.string("action") == "saved") let documents = try requireArray(bridge.pickDocuments(arguments: ["contentTypes": .array([.string("public.item")])], context: context)) #expect(documents.first?.objectValue?.string("artifactID") == "document-1") + let export = try bridge.exportDocuments(arguments: ["path": .string("tmp:report.pdf")], context: context) + #expect(export.objectValue?.string("action") == "exported") + let openIn = try bridge.openDocument(arguments: ["path": .string("tmp:report.pdf")], context: context) + #expect(openIn.objectValue?.string("application") == "com.example.viewer") let scans = try requireArray(bridge.scanDocuments(arguments: [:], context: context)) #expect(scans.first?.objectValue?.string("artifactID") == "scan-1") let share = try bridge.presentShareSheet(arguments: ["text": .string("hello")], context: context) @@ -88,10 +99,14 @@ import Testing #expect(preview.objectValue?.string("action") == "dismissed") let camera = try bridge.captureCamera(arguments: ["mediaType": .string("image")], context: context) #expect(camera.objectValue?.string("artifactID") == "camera-1") + let scanData = try bridge.scanData(arguments: ["mode": .string("barcode")], context: context) + #expect(scanData.objectValue?.string("action") == "recognized") let mail = try bridge.composeMail(arguments: ["to": .array([.string("alex@example.com")])], context: context) #expect(mail.objectValue?.string("action") == "sent") let message = try bridge.composeMessage(arguments: ["recipients": .array([.string("4085551212")])], context: context) #expect(message.objectValue?.string("action") == "sent") + let print = try bridge.presentPrint(arguments: ["path": .string("tmp:report.pdf")], context: context) + #expect(print.objectValue?.bool("completed") == true) let web = try bridge.presentWeb(arguments: ["url": .string("https://example.com")], context: context) #expect(web.objectValue?.string("action") == "dismissed") let auth = try bridge.authenticateWeb(arguments: ["url": .string("https://example.com/oauth")], context: context) @@ -101,6 +116,18 @@ import Testing context: context ) #expect(alert.objectValue?.string("buttonID") == "ok") + let prompt = try bridge.presentPrompt( + arguments: [ + "fields": .array([.object(["id": .string("name"), "placeholder": .string("Name")])]), + "buttons": .array([.object(["id": .string("ok"), "title": .string("OK")])]), + ], + context: context + ) + #expect(prompt.objectValue?.object("values")?.string("name") == "Alex") + let limited = try bridge.presentLimitedPhotoLibraryPicker(arguments: [:], context: context) + #expect(limited.objectValue?.string("action") == "completed") + let settings = try bridge.openSettings(arguments: [:], context: context) + #expect(settings.objectValue?.bool("opened") == true) } @Test func systemUIBridgeDefaultPresenterIsStructuredFailure() throws { @@ -174,6 +201,20 @@ import Testing #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } + do { + _ = try bridge.exportDocuments(arguments: [:], context: context) + Issue.record("Expected document export with no path to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.openDocument(arguments: [:], context: context) + Issue.record("Expected document openIn with no path to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + do { _ = try bridge.presentShareSheet(arguments: [:], context: context) Issue.record("Expected share sheet with no items to fail") @@ -188,6 +229,20 @@ import Testing #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } + do { + _ = try bridge.presentPrint(arguments: ["path": .string("tmp:report.pdf"), "outputType": .string("thermal")], context: context) + Issue.record("Expected invalid print outputType to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + + do { + _ = try bridge.scanData(arguments: ["mode": .string("qr")], context: context) + Issue.record("Expected invalid scanData mode to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } + do { _ = try bridge.presentWeb(arguments: ["url": .string("file:///tmp/a.html")], context: context) Issue.record("Expected non-HTTP web URL to fail") @@ -223,6 +278,19 @@ import Testing } catch { #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") } + + do { + _ = try bridge.presentPrompt( + arguments: [ + "fields": .array([]), + "buttons": .array([.object(["title": .string("OK")])]), + ], + context: context + ) + Issue.record("Expected prompt with no fields to fail") + } catch { + #expect(requireBridgeErrorCode(error) == "INVALID_ARGUMENTS") + } } @Test func systemUIBridgeHonorsCancellationBeforePresentation() throws { @@ -389,6 +457,14 @@ private struct FakeSystemUIPresenter: SystemUIPresenter { try result(for: .documentsUIPick, arguments: arguments, context: context) } + func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIExport, arguments: arguments, context: context) + } + + func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIOpenIn, arguments: arguments, context: context) + } + func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try result(for: .documentsUIScan, arguments: arguments, context: context) } @@ -405,6 +481,10 @@ private struct FakeSystemUIPresenter: SystemUIPresenter { try result(for: .cameraUICapture, arguments: arguments, context: context) } + func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .cameraUIScanData, arguments: arguments, context: context) + } + func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try result(for: .mailUICompose, arguments: arguments, context: context) } @@ -413,6 +493,10 @@ private struct FakeSystemUIPresenter: SystemUIPresenter { try result(for: .messagesUICompose, arguments: arguments, context: context) } + func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .printUIPresent, arguments: arguments, context: context) + } + func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { try result(for: .webUIPresent, arguments: arguments, context: context) } @@ -425,6 +509,18 @@ private struct FakeSystemUIPresenter: SystemUIPresenter { try result(for: .uiAlertPresent, arguments: arguments, context: context) } + func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .uiPromptPresent, arguments: arguments, context: context) + } + + func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .photosUIPresentLimitedLibraryPicker, arguments: arguments, context: context) + } + + func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .settingsUIOpen, arguments: arguments, context: context) + } + private func result(for capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { _ = arguments _ = context From 2a61edd39f3ef900095c4981e77d329cfb2d668f Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 30 Apr 2026 14:34:00 -0700 Subject: [PATCH 5/5] Expand system UI coverage and rebased cleanup --- README.md | 2 +- Sources/CodeMode/Bridges/HealthBridge.swift | 15 +- .../Support/UIKitSystemUICoordinators.swift | 531 ++++++++++++++++++ .../Support/UIKitSystemUIPresenter.swift | 511 ----------------- .../CodeModeEvaluation/EvalScenarios.swift | 121 +++- Tests/CodeModeTests/SystemUIBridgeTests.swift | 134 ----- Tests/CodeModeTests/SystemUITestSupport.swift | 136 +++++ 7 files changed, 771 insertions(+), 679 deletions(-) create mode 100644 Sources/CodeMode/Support/UIKitSystemUICoordinators.swift create mode 100644 Tests/CodeModeTests/SystemUITestSupport.swift diff --git a/README.md b/README.md index 1d8fc6e..7f3b566 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ swift run --package-path Tools/CodeModeEval codemode-eval run fs.round-trip --sh swift run --package-path Tools/CodeModeEval codemode-eval run --json ``` -The eval harness runs 24 built-in user-style scenarios through the same +The eval harness runs 26 built-in user-style scenarios through the same `searchJavaScriptAPI` and `executeJavaScript` APIs that host apps expose to agents. It validates tool order, discovered catalog output, generated JavaScript fragments, exact `allowedCapabilities`, structured errors, repair suggestions, diff --git a/Sources/CodeMode/Bridges/HealthBridge.swift b/Sources/CodeMode/Bridges/HealthBridge.swift index dfcff03..319cb88 100644 --- a/Sources/CodeMode/Bridges/HealthBridge.swift +++ b/Sources/CodeMode/Bridges/HealthBridge.swift @@ -480,11 +480,7 @@ public final class HealthBridge: @unchecked Sendable { if spec.isWorkout, let workout = sample as? HKWorkout { payload["activityType"] = .number(Double(workout.workoutActivityType.rawValue)) payload["durationSeconds"] = .number(workout.duration) - #if os(macOS) - payload["totalEnergyBurnedKCal"] = .number(0) - #else - payload["totalEnergyBurnedKCal"] = .number(workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()) ?? 0) - #endif + payload["totalEnergyBurnedKCal"] = .number(workoutActiveEnergyBurnedKCal(workout)) payload["totalDistanceMeters"] = .number(workout.totalDistance?.doubleValue(for: .meter()) ?? 0) return .object(payload) } @@ -503,6 +499,15 @@ public final class HealthBridge: @unchecked Sendable { return .object(payload) } + private func workoutActiveEnergyBurnedKCal(_ workout: HKWorkout) -> Double { + guard let quantityType = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned), + let quantity = workout.statistics(for: quantityType)?.sumQuantity() + else { + return 0 + } + return quantity.doubleValue(for: .kilocalorie()) + } + private func resolveUnit(override: String?, fallback: HKUnit) throws -> HKUnit { guard let override, override.isEmpty == false else { return fallback diff --git a/Sources/CodeMode/Support/UIKitSystemUICoordinators.swift b/Sources/CodeMode/Support/UIKitSystemUICoordinators.swift new file mode 100644 index 0000000..1f69012 --- /dev/null +++ b/Sources/CodeMode/Support/UIKitSystemUICoordinators.swift @@ -0,0 +1,531 @@ +import Foundation + +#if canImport(UIKit) && (os(iOS) || os(visionOS)) +@preconcurrency import AuthenticationServices +@preconcurrency import Contacts +@preconcurrency import ContactsUI +@preconcurrency import EventKit +@preconcurrency import EventKitUI +@preconcurrency import PhotosUI +@preconcurrency import QuickLook +@preconcurrency import SafariServices +@preconcurrency import UIKit +@preconcurrency import UniformTypeIdentifiers +#if canImport(MessageUI) +@preconcurrency import MessageUI +#endif +#if canImport(VisionKit) +@preconcurrency import VisionKit +#endif + +struct PhotoPickerSelection: @unchecked Sendable { + var assetIdentifier: String? + var itemProvider: NSItemProvider + + init(result: PHPickerResult) { + self.assetIdentifier = result.assetIdentifier + self.itemProvider = result.itemProvider + } +} + +@MainActor final class CalendarEventEditCoordinator: NSObject, @preconcurrency EKEventEditViewDelegate { + private let onComplete: (EKEventEditViewAction, EKEvent?) -> Void + + init(onComplete: @escaping (EKEventEditViewAction, EKEvent?) -> Void) { + self.onComplete = onComplete + } + + func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { + let event = controller.event + controller.dismiss(animated: true) + onComplete(action, event) + } +} + +@MainActor final class CalendarChooserCoordinator: NSObject, @preconcurrency EKCalendarChooserDelegate { + private let onComplete: (Set) -> Void + + init(onComplete: @escaping (Set) -> Void) { + self.onComplete = onComplete + } + + func calendarChooserDidFinish(_ calendarChooser: EKCalendarChooser) { + let calendars = calendarChooser.selectedCalendars + calendarChooser.dismiss(animated: true) + onComplete(calendars) + } + + func calendarChooserDidCancel(_ calendarChooser: EKCalendarChooser) { + calendarChooser.dismiss(animated: true) + onComplete([]) + } +} + +@MainActor final class CalendarEventViewCoordinator: NSObject, @preconcurrency EKEventViewDelegate { + private let onComplete: () -> Void + + init(onComplete: @escaping () -> Void) { + self.onComplete = onComplete + } + + func eventViewController(_ controller: EKEventViewController, didCompleteWith action: EKEventViewAction) { + _ = action + controller.dismiss(animated: true) + onComplete() + } +} + +@MainActor final class PhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { + private let onComplete: ([PHPickerResult]) -> Void + + init(onComplete: @escaping ([PHPickerResult]) -> Void) { + self.onComplete = onComplete + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + onComplete(results) + } +} + +@MainActor final class ContactSinglePickerCoordinator: NSObject, @preconcurrency CNContactPickerDelegate { + private let onComplete: ([CNContact]) -> Void + + init(onComplete: @escaping ([CNContact]) -> Void) { + self.onComplete = onComplete + } + + func contactPickerDidCancel(_ picker: CNContactPickerViewController) { + picker.dismiss(animated: true) + onComplete([]) + } + + func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { + picker.dismiss(animated: true) + onComplete([contact]) + } +} + +@MainActor final class ContactMultiplePickerCoordinator: NSObject, @preconcurrency CNContactPickerDelegate { + private let onComplete: ([CNContact]) -> Void + + init(onComplete: @escaping ([CNContact]) -> Void) { + self.onComplete = onComplete + } + + func contactPickerDidCancel(_ picker: CNContactPickerViewController) { + picker.dismiss(animated: true) + onComplete([]) + } + + func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) { + picker.dismiss(animated: true) + onComplete(contacts) + } +} + +@MainActor final class ContactViewCoordinator: NSObject, @preconcurrency CNContactViewControllerDelegate { + private let onComplete: (CNContact?) -> Void + + init(onComplete: @escaping (CNContact?) -> Void) { + self.onComplete = onComplete + } + + func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) { + viewController.dismiss(animated: true) + onComplete(contact) + } +} + +@MainActor final class DocumentPickerCoordinator: NSObject, UIDocumentPickerDelegate { + private let onComplete: ([URL]) -> Void + + init(onComplete: @escaping ([URL]) -> Void) { + self.onComplete = onComplete + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + controller.dismiss(animated: true) + onComplete([]) + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + controller.dismiss(animated: true) + onComplete(urls) + } +} + +@MainActor final class DocumentInteractionCoordinator: NSObject, @preconcurrency UIDocumentInteractionControllerDelegate { + weak var presenter: UIViewController? + var controller: UIDocumentInteractionController? + private var application: String? + private var didSend = false + private let onComplete: (JSONValue) -> Void + + init(presenter: UIViewController, onComplete: @escaping (JSONValue) -> Void) { + self.presenter = presenter + self.onComplete = onComplete + } + + func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { + _ = controller + return presenter ?? UIViewController() + } + + func documentInteractionController( + _ controller: UIDocumentInteractionController, + willBeginSendingToApplication application: String? + ) { + _ = controller + self.application = application + didSend = true + } + + func documentInteractionController( + _ controller: UIDocumentInteractionController, + didEndSendingToApplication application: String? + ) { + _ = controller + self.application = application ?? self.application + didSend = true + } + + func documentInteractionControllerDidDismissOpenInMenu(_ controller: UIDocumentInteractionController) { + _ = controller + var object: [String: JSONValue] = [ + "action": .string(didSend ? "sent" : "dismissed"), + ] + if let application { + object["application"] = .string(application) + } + onComplete(.object(object)) + } +} + +@MainActor final class PrintCoordinator: NSObject, UIPrintInteractionControllerDelegate { + weak var parent: UIViewController? + + init(parent: UIViewController) { + self.parent = parent + } + + func printInteractionControllerParentViewController(_ printInteractionController: UIPrintInteractionController) -> UIViewController? { + _ = printInteractionController + return parent + } +} + +@MainActor final class QuickLookCoordinator: NSObject, QLPreviewControllerDataSource, @preconcurrency QLPreviewControllerDelegate { + private let urls: [URL] + private let onComplete: () -> Void + + init(urls: [URL], onComplete: @escaping () -> Void) { + self.urls = urls + self.onComplete = onComplete + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + _ = controller + return urls.count + } + + func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { + _ = controller + return urls[index] as NSURL + } + + func previewControllerDidDismiss(_ controller: QLPreviewController) { + _ = controller + onComplete() + } +} + +#if canImport(VisionKit) +@MainActor final class DataScannerCoordinator: NSObject, DataScannerViewControllerDelegate { + private weak var controller: DataScannerViewController? + private weak var navigationController: UINavigationController? + private let returnsOnFirstResult: Bool + private let onComplete: (Result) -> Void + private var completed = false + private var currentItems: [RecognizedItem] = [] + + init( + controller: DataScannerViewController, + navigationController: UINavigationController, + returnsOnFirstResult: Bool, + onComplete: @escaping (Result) -> Void + ) { + self.controller = controller + self.navigationController = navigationController + self.returnsOnFirstResult = returnsOnFirstResult + self.onComplete = onComplete + } + + @objc func cancel() { + complete(action: "cancelled", items: currentItems) + } + + func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { + _ = dataScanner + complete(action: "selected", items: [item]) + } + + func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { + _ = dataScanner + currentItems = allItems + if returnsOnFirstResult, (allItems.isEmpty == false || addedItems.isEmpty == false) { + complete(action: "recognized", items: allItems.isEmpty ? addedItems : allItems) + } + } + + func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) { + _ = dataScanner + _ = updatedItems + currentItems = allItems + } + + func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) { + _ = dataScanner + _ = removedItems + currentItems = allItems + } + + func dataScanner(_ dataScanner: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { + _ = dataScanner + complete(.failure(.unsupportedPlatform("camera.ui.scanData \(error)"))) + } + + private func complete(action: String, items: [RecognizedItem]) { + complete(.success(.object([ + "action": .string(action), + "items": .array(items.map(mapRecognizedItem)), + ]))) + } + + private func complete(_ result: Result) { + guard completed == false else { + return + } + completed = true + controller?.stopScanning() + navigationController?.dismiss(animated: true) + onComplete(result) + } +} + +@MainActor private func mapRecognizedItem(_ item: RecognizedItem) -> JSONValue { + var object: [String: JSONValue] = [ + "id": .string(item.id.uuidString), + "bounds": mapRecognizedBounds(item.bounds), + ] + + switch item { + case .text(let text): + object["type"] = .string("text") + object["transcript"] = .string(text.transcript) + case .barcode(let barcode): + object["type"] = .string("barcode") + object["payload"] = .string(barcode.payloadStringValue ?? "") + object["symbology"] = .string(String(describing: barcode.observation.symbology)) + @unknown default: + object["type"] = .string("unknown") + } + + return .object(object) +} + +@MainActor private func mapRecognizedBounds(_ bounds: RecognizedItem.Bounds) -> JSONValue { + .object([ + "topLeft": mapPoint(bounds.topLeft), + "topRight": mapPoint(bounds.topRight), + "bottomRight": mapPoint(bounds.bottomRight), + "bottomLeft": mapPoint(bounds.bottomLeft), + ]) +} + +@MainActor private func mapPoint(_ point: CGPoint) -> JSONValue { + .object([ + "x": .number(Double(point.x)), + "y": .number(Double(point.y)), + ]) +} +#endif + +#if os(iOS) +struct CameraCaptureResult: Sendable { + var url: URL + var mediaType: String + var typeIdentifier: String +} + +@MainActor final class CameraCaptureCoordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let outputDirectory: URL + private let onComplete: (Result) -> Void + + init(outputDirectory: URL, onComplete: @escaping (Result) -> Void) { + self.outputDirectory = outputDirectory + self.onComplete = onComplete + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + onComplete(.success(nil)) + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + do { + let result = try exportCapture(info: info) + picker.dismiss(animated: true) + onComplete(.success(result)) + } catch let error as BridgeError { + picker.dismiss(animated: true) + onComplete(.failure(error)) + } catch { + picker.dismiss(animated: true) + onComplete(.failure(.nativeFailure(error.localizedDescription))) + } + } + + private func exportCapture(info: [UIImagePickerController.InfoKey: Any]) throws -> CameraCaptureResult { + let mediaType = (info[.mediaType] as? String) ?? UTType.image.identifier + if UTType(mediaType)?.conforms(to: .movie) == true || UTType(mediaType)?.conforms(to: .video) == true { + guard let sourceURL = info[.mediaURL] as? URL else { + throw BridgeError.nativeFailure("camera.ui.capture returned no video URL") + } + let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).\(sourceURL.pathExtension.isEmpty ? "mov" : sourceURL.pathExtension)") + try FileManager.default.copyItem(at: sourceURL, to: outputURL) + return CameraCaptureResult(url: outputURL, mediaType: "video", typeIdentifier: mediaType) + } + + if let imageURL = info[.imageURL] as? URL { + let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).\(imageURL.pathExtension.isEmpty ? "jpg" : imageURL.pathExtension)") + try FileManager.default.copyItem(at: imageURL, to: outputURL) + return CameraCaptureResult(url: outputURL, mediaType: "image", typeIdentifier: UTType.jpeg.identifier) + } + + guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), + let data = image.jpegData(compressionQuality: 0.92) + else { + throw BridgeError.nativeFailure("camera.ui.capture returned no image") + } + + let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).jpg") + try data.write(to: outputURL, options: .atomic) + return CameraCaptureResult(url: outputURL, mediaType: "image", typeIdentifier: UTType.jpeg.identifier) + } +} +#endif + +#if os(iOS) && canImport(VisionKit) +@MainActor final class DocumentScanCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { + private let outputDirectory: URL + private let onComplete: (Result<[URL], BridgeError>) -> Void + + init(outputDirectory: URL, onComplete: @escaping (Result<[URL], BridgeError>) -> Void) { + self.outputDirectory = outputDirectory + self.onComplete = onComplete + } + + func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { + controller.dismiss(animated: true) + onComplete(.success([])) + } + + func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: any Error) { + controller.dismiss(animated: true) + onComplete(.failure(.nativeFailure(error.localizedDescription))) + } + + func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) { + do { + var urls: [URL] = [] + for index in 0.. Void + + init(onComplete: @escaping (MFMailComposeResult) -> Void) { + self.onComplete = onComplete + } + + func mailComposeController( + _ controller: MFMailComposeViewController, + didFinishWith result: MFMailComposeResult, + error: (any Error)? + ) { + _ = error + controller.dismiss(animated: true) + onComplete(result) + } +} + +@MainActor final class MessageComposeCoordinator: NSObject, @preconcurrency MFMessageComposeViewControllerDelegate { + private let onComplete: (MessageComposeResult) -> Void + + init(onComplete: @escaping (MessageComposeResult) -> Void) { + self.onComplete = onComplete + } + + func messageComposeViewController( + _ controller: MFMessageComposeViewController, + didFinishWith result: MessageComposeResult + ) { + controller.dismiss(animated: true) + onComplete(result) + } +} +#endif + +#if os(iOS) +@MainActor final class SafariCoordinator: NSObject, @preconcurrency SFSafariViewControllerDelegate { + private let onComplete: () -> Void + + init(onComplete: @escaping () -> Void) { + self.onComplete = onComplete + } + + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + _ = controller + onComplete() + } +} +#endif + +@MainActor final class WebAuthenticationCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { + private let anchor: ASPresentationAnchor + var session: ASWebAuthenticationSession? + + init(anchor: ASPresentationAnchor) { + self.anchor = anchor + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + _ = session + return anchor + } +} +#endif diff --git a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift index c5fac79..2f57f67 100644 --- a/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift +++ b/Sources/CodeMode/Support/UIKitSystemUIPresenter.swift @@ -1613,515 +1613,4 @@ public final class UIKitSystemUIPresenter: SystemUIPresenter, @unchecked Sendabl return contact.emailAddresses.map { .string(String($0.value)) } } } - -private struct PhotoPickerSelection: @unchecked Sendable { - var assetIdentifier: String? - var itemProvider: NSItemProvider - - init(result: PHPickerResult) { - self.assetIdentifier = result.assetIdentifier - self.itemProvider = result.itemProvider - } -} - -@MainActor private final class CalendarEventEditCoordinator: NSObject, @preconcurrency EKEventEditViewDelegate { - private let onComplete: (EKEventEditViewAction, EKEvent?) -> Void - - init(onComplete: @escaping (EKEventEditViewAction, EKEvent?) -> Void) { - self.onComplete = onComplete - } - - func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { - let event = controller.event - controller.dismiss(animated: true) - onComplete(action, event) - } -} - -@MainActor private final class CalendarChooserCoordinator: NSObject, @preconcurrency EKCalendarChooserDelegate { - private let onComplete: (Set) -> Void - - init(onComplete: @escaping (Set) -> Void) { - self.onComplete = onComplete - } - - func calendarChooserDidFinish(_ calendarChooser: EKCalendarChooser) { - let calendars = calendarChooser.selectedCalendars - calendarChooser.dismiss(animated: true) - onComplete(calendars) - } - - func calendarChooserDidCancel(_ calendarChooser: EKCalendarChooser) { - calendarChooser.dismiss(animated: true) - onComplete([]) - } -} - -@MainActor private final class CalendarEventViewCoordinator: NSObject, @preconcurrency EKEventViewDelegate { - private let onComplete: () -> Void - - init(onComplete: @escaping () -> Void) { - self.onComplete = onComplete - } - - func eventViewController(_ controller: EKEventViewController, didCompleteWith action: EKEventViewAction) { - _ = action - controller.dismiss(animated: true) - onComplete() - } -} - -@MainActor private final class PhotoPickerCoordinator: NSObject, PHPickerViewControllerDelegate { - private let onComplete: ([PHPickerResult]) -> Void - - init(onComplete: @escaping ([PHPickerResult]) -> Void) { - self.onComplete = onComplete - } - - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - picker.dismiss(animated: true) - onComplete(results) - } -} - -@MainActor private final class ContactSinglePickerCoordinator: NSObject, @preconcurrency CNContactPickerDelegate { - private let onComplete: ([CNContact]) -> Void - - init(onComplete: @escaping ([CNContact]) -> Void) { - self.onComplete = onComplete - } - - func contactPickerDidCancel(_ picker: CNContactPickerViewController) { - picker.dismiss(animated: true) - onComplete([]) - } - - func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { - picker.dismiss(animated: true) - onComplete([contact]) - } -} - -@MainActor private final class ContactMultiplePickerCoordinator: NSObject, @preconcurrency CNContactPickerDelegate { - private let onComplete: ([CNContact]) -> Void - - init(onComplete: @escaping ([CNContact]) -> Void) { - self.onComplete = onComplete - } - - func contactPickerDidCancel(_ picker: CNContactPickerViewController) { - picker.dismiss(animated: true) - onComplete([]) - } - - func contactPicker(_ picker: CNContactPickerViewController, didSelect contacts: [CNContact]) { - picker.dismiss(animated: true) - onComplete(contacts) - } -} - -@MainActor private final class ContactViewCoordinator: NSObject, @preconcurrency CNContactViewControllerDelegate { - private let onComplete: (CNContact?) -> Void - - init(onComplete: @escaping (CNContact?) -> Void) { - self.onComplete = onComplete - } - - func contactViewController(_ viewController: CNContactViewController, didCompleteWith contact: CNContact?) { - viewController.dismiss(animated: true) - onComplete(contact) - } -} - -@MainActor private final class DocumentPickerCoordinator: NSObject, UIDocumentPickerDelegate { - private let onComplete: ([URL]) -> Void - - init(onComplete: @escaping ([URL]) -> Void) { - self.onComplete = onComplete - } - - func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - controller.dismiss(animated: true) - onComplete([]) - } - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - controller.dismiss(animated: true) - onComplete(urls) - } -} - -@MainActor private final class DocumentInteractionCoordinator: NSObject, @preconcurrency UIDocumentInteractionControllerDelegate { - weak var presenter: UIViewController? - var controller: UIDocumentInteractionController? - private var application: String? - private var didSend = false - private let onComplete: (JSONValue) -> Void - - init(presenter: UIViewController, onComplete: @escaping (JSONValue) -> Void) { - self.presenter = presenter - self.onComplete = onComplete - } - - func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { - _ = controller - return presenter ?? UIViewController() - } - - func documentInteractionController( - _ controller: UIDocumentInteractionController, - willBeginSendingToApplication application: String? - ) { - _ = controller - self.application = application - didSend = true - } - - func documentInteractionController( - _ controller: UIDocumentInteractionController, - didEndSendingToApplication application: String? - ) { - _ = controller - self.application = application ?? self.application - didSend = true - } - - func documentInteractionControllerDidDismissOpenInMenu(_ controller: UIDocumentInteractionController) { - _ = controller - var object: [String: JSONValue] = [ - "action": .string(didSend ? "sent" : "dismissed"), - ] - if let application { - object["application"] = .string(application) - } - onComplete(.object(object)) - } -} - -@MainActor private final class PrintCoordinator: NSObject, UIPrintInteractionControllerDelegate { - weak var parent: UIViewController? - - init(parent: UIViewController) { - self.parent = parent - } - - func printInteractionControllerParentViewController(_ printInteractionController: UIPrintInteractionController) -> UIViewController? { - _ = printInteractionController - return parent - } -} - -@MainActor private final class QuickLookCoordinator: NSObject, QLPreviewControllerDataSource, @preconcurrency QLPreviewControllerDelegate { - private let urls: [URL] - private let onComplete: () -> Void - - init(urls: [URL], onComplete: @escaping () -> Void) { - self.urls = urls - self.onComplete = onComplete - } - - func numberOfPreviewItems(in controller: QLPreviewController) -> Int { - _ = controller - return urls.count - } - - func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { - _ = controller - return urls[index] as NSURL - } - - func previewControllerDidDismiss(_ controller: QLPreviewController) { - _ = controller - onComplete() - } -} - -#if canImport(VisionKit) -@MainActor private final class DataScannerCoordinator: NSObject, DataScannerViewControllerDelegate { - private weak var controller: DataScannerViewController? - private weak var navigationController: UINavigationController? - private let returnsOnFirstResult: Bool - private let onComplete: (Result) -> Void - private var completed = false - private var currentItems: [RecognizedItem] = [] - - init( - controller: DataScannerViewController, - navigationController: UINavigationController, - returnsOnFirstResult: Bool, - onComplete: @escaping (Result) -> Void - ) { - self.controller = controller - self.navigationController = navigationController - self.returnsOnFirstResult = returnsOnFirstResult - self.onComplete = onComplete - } - - @objc func cancel() { - complete(action: "cancelled", items: currentItems) - } - - func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) { - _ = dataScanner - complete(action: "selected", items: [item]) - } - - func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) { - _ = dataScanner - currentItems = allItems - if returnsOnFirstResult, (allItems.isEmpty == false || addedItems.isEmpty == false) { - complete(action: "recognized", items: allItems.isEmpty ? addedItems : allItems) - } - } - - func dataScanner(_ dataScanner: DataScannerViewController, didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) { - _ = dataScanner - _ = updatedItems - currentItems = allItems - } - - func dataScanner(_ dataScanner: DataScannerViewController, didRemove removedItems: [RecognizedItem], allItems: [RecognizedItem]) { - _ = dataScanner - _ = removedItems - currentItems = allItems - } - - func dataScanner(_ dataScanner: DataScannerViewController, becameUnavailableWithError error: DataScannerViewController.ScanningUnavailable) { - _ = dataScanner - complete(.failure(.unsupportedPlatform("camera.ui.scanData \(error)"))) - } - - private func complete(action: String, items: [RecognizedItem]) { - complete(.success(.object([ - "action": .string(action), - "items": .array(items.map(mapRecognizedItem)), - ]))) - } - - private func complete(_ result: Result) { - guard completed == false else { - return - } - completed = true - controller?.stopScanning() - navigationController?.dismiss(animated: true) - onComplete(result) - } -} - -@MainActor private func mapRecognizedItem(_ item: RecognizedItem) -> JSONValue { - var object: [String: JSONValue] = [ - "id": .string(item.id.uuidString), - "bounds": mapRecognizedBounds(item.bounds), - ] - - switch item { - case .text(let text): - object["type"] = .string("text") - object["transcript"] = .string(text.transcript) - case .barcode(let barcode): - object["type"] = .string("barcode") - object["payload"] = .string(barcode.payloadStringValue ?? "") - object["symbology"] = .string(String(describing: barcode.observation.symbology)) - @unknown default: - object["type"] = .string("unknown") - } - - return .object(object) -} - -@MainActor private func mapRecognizedBounds(_ bounds: RecognizedItem.Bounds) -> JSONValue { - .object([ - "topLeft": mapPoint(bounds.topLeft), - "topRight": mapPoint(bounds.topRight), - "bottomRight": mapPoint(bounds.bottomRight), - "bottomLeft": mapPoint(bounds.bottomLeft), - ]) -} - -@MainActor private func mapPoint(_ point: CGPoint) -> JSONValue { - .object([ - "x": .number(Double(point.x)), - "y": .number(Double(point.y)), - ]) -} -#endif - -#if os(iOS) -private struct CameraCaptureResult: Sendable { - var url: URL - var mediaType: String - var typeIdentifier: String -} - -@MainActor private final class CameraCaptureCoordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - private let outputDirectory: URL - private let onComplete: (Result) -> Void - - init(outputDirectory: URL, onComplete: @escaping (Result) -> Void) { - self.outputDirectory = outputDirectory - self.onComplete = onComplete - } - - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - picker.dismiss(animated: true) - onComplete(.success(nil)) - } - - func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - do { - let result = try exportCapture(info: info) - picker.dismiss(animated: true) - onComplete(.success(result)) - } catch let error as BridgeError { - picker.dismiss(animated: true) - onComplete(.failure(error)) - } catch { - picker.dismiss(animated: true) - onComplete(.failure(.nativeFailure(error.localizedDescription))) - } - } - - private func exportCapture(info: [UIImagePickerController.InfoKey: Any]) throws -> CameraCaptureResult { - let mediaType = (info[.mediaType] as? String) ?? UTType.image.identifier - if UTType(mediaType)?.conforms(to: .movie) == true || UTType(mediaType)?.conforms(to: .video) == true { - guard let sourceURL = info[.mediaURL] as? URL else { - throw BridgeError.nativeFailure("camera.ui.capture returned no video URL") - } - let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).\(sourceURL.pathExtension.isEmpty ? "mov" : sourceURL.pathExtension)") - try FileManager.default.copyItem(at: sourceURL, to: outputURL) - return CameraCaptureResult(url: outputURL, mediaType: "video", typeIdentifier: mediaType) - } - - if let imageURL = info[.imageURL] as? URL { - let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).\(imageURL.pathExtension.isEmpty ? "jpg" : imageURL.pathExtension)") - try FileManager.default.copyItem(at: imageURL, to: outputURL) - return CameraCaptureResult(url: outputURL, mediaType: "image", typeIdentifier: UTType.jpeg.identifier) - } - - guard let image = (info[.editedImage] as? UIImage) ?? (info[.originalImage] as? UIImage), - let data = image.jpegData(compressionQuality: 0.92) - else { - throw BridgeError.nativeFailure("camera.ui.capture returned no image") - } - - let outputURL = outputDirectory.appendingPathComponent("capture-\(UUID().uuidString).jpg") - try data.write(to: outputURL, options: .atomic) - return CameraCaptureResult(url: outputURL, mediaType: "image", typeIdentifier: UTType.jpeg.identifier) - } -} -#endif - -#if os(iOS) && canImport(VisionKit) -@MainActor private final class DocumentScanCoordinator: NSObject, @preconcurrency VNDocumentCameraViewControllerDelegate { - private let outputDirectory: URL - private let onComplete: (Result<[URL], BridgeError>) -> Void - - init(outputDirectory: URL, onComplete: @escaping (Result<[URL], BridgeError>) -> Void) { - self.outputDirectory = outputDirectory - self.onComplete = onComplete - } - - func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) { - controller.dismiss(animated: true) - onComplete(.success([])) - } - - func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: any Error) { - controller.dismiss(animated: true) - onComplete(.failure(.nativeFailure(error.localizedDescription))) - } - - func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) { - do { - var urls: [URL] = [] - for index in 0.. Void - - init(onComplete: @escaping (MFMailComposeResult) -> Void) { - self.onComplete = onComplete - } - - func mailComposeController( - _ controller: MFMailComposeViewController, - didFinishWith result: MFMailComposeResult, - error: (any Error)? - ) { - _ = error - controller.dismiss(animated: true) - onComplete(result) - } -} - -@MainActor private final class MessageComposeCoordinator: NSObject, @preconcurrency MFMessageComposeViewControllerDelegate { - private let onComplete: (MessageComposeResult) -> Void - - init(onComplete: @escaping (MessageComposeResult) -> Void) { - self.onComplete = onComplete - } - - func messageComposeViewController( - _ controller: MFMessageComposeViewController, - didFinishWith result: MessageComposeResult - ) { - controller.dismiss(animated: true) - onComplete(result) - } -} -#endif - -#if os(iOS) -@MainActor private final class SafariCoordinator: NSObject, @preconcurrency SFSafariViewControllerDelegate { - private let onComplete: () -> Void - - init(onComplete: @escaping () -> Void) { - self.onComplete = onComplete - } - - func safariViewControllerDidFinish(_ controller: SFSafariViewController) { - _ = controller - onComplete() - } -} -#endif - -@MainActor private final class WebAuthenticationCoordinator: NSObject, ASWebAuthenticationPresentationContextProviding { - private let anchor: ASPresentationAnchor - var session: ASWebAuthenticationSession? - - init(anchor: ASPresentationAnchor) { - self.anchor = anchor - } - - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - _ = session - return anchor - } -} #endif diff --git a/Sources/CodeModeEvaluation/EvalScenarios.swift b/Sources/CodeModeEvaluation/EvalScenarios.swift index 831d211..396e419 100644 --- a/Sources/CodeModeEvaluation/EvalScenarios.swift +++ b/Sources/CodeModeEvaluation/EvalScenarios.swift @@ -22,7 +22,9 @@ public enum CodeModeEvalScenarios { searchRejectsNonFunctionProgram, catalogAliasAndPlatformPruning, catalogSystemUIPlatformPruning, - catalogSharedSystemUIDiscovery, + catalogDocumentSystemUIDiscovery, + catalogInteractionSystemUIDiscovery, + catalogPhotoCameraSystemUIDiscovery, catalogIOSOnlySystemUIDiscovery, contactsPermissionDenied, weatherArgumentValidation, @@ -673,10 +675,10 @@ public enum CodeModeEvalScenarios { ) ) - public static let catalogSharedSystemUIDiscovery = CodeModeEvalScenario( - id: "catalog.system-ui-shared-discovery", - title: "Catalog discovers shared iOS and visionOS system UI helpers", - task: "Search the iOS catalog for Files document picking/export/open-in, share sheet, Quick Look, print, Safari, web authentication, Photos limited-library management, data scanning, settings, alert, and prompt helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", + public static let catalogDocumentSystemUIDiscovery = CodeModeEvalScenario( + id: "catalog.system-ui-documents-discovery", + title: "Catalog discovers document system UI helpers", + task: "Search the iOS catalog for Files document picking, document export/save, document open-in, Quick Look preview, and print helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", catalogPlatform: .iOS, searchCode: """ async () => { @@ -684,13 +686,54 @@ public enum CodeModeEvalScenarios { "apple.documents.pick", "apple.documents.export", "apple.documents.openIn", - "apple.share.present", "apple.quicklook.preview", + "apple.print.present" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.documents.export", + "apple.documents.openIn", + "apple.documents.pick", + "apple.documents.save", "apple.print.present", + "apple.quicklook.preview", + "completed", + "contentTypes", + "documents.ui.export", + "documents.ui.openIn", + "documents.ui.pick", + "jobName", + "print.ui.present", + "quicklook.ui.preview", + ] + ) + ) + + public static let catalogInteractionSystemUIDiscovery = CodeModeEvalScenario( + id: "catalog.system-ui-interaction-discovery", + title: "Catalog discovers interaction system UI helpers", + task: "Search the iOS catalog for share sheet, Safari, web authentication, alert, prompt, and settings helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.share.present", "apple.web.present", "apple.auth.webAuthenticate", - "apple.photos.presentLimitedLibraryPicker", - "apple.camera.scanData", "apple.ui.presentAlert", "apple.ui.presentPrompt", "apple.settings.open" @@ -712,14 +755,6 @@ public enum CodeModeEvalScenarios { toolOrder: [.searchJavaScriptAPI], requiredSearchResultFragments: [ "apple.auth.webAuthenticate", - "apple.camera.scanData", - "apple.documents.export", - "apple.documents.openIn", - "apple.documents.pick", - "apple.documents.save", - "apple.photos.presentLimitedLibraryPicker", - "apple.print.present", - "apple.quicklook.preview", "apple.settings.open", "apple.share.present", "apple.ui.presentAlert", @@ -730,20 +765,8 @@ public enum CodeModeEvalScenarios { "buttons", "callbackURL", "callbackURLScheme", - "camera.ui.scanData", - "completed", - "contentTypes", - "documents.ui.export", - "documents.ui.openIn", - "documents.ui.pick", "excludedActivityTypes", "fields", - "jobName", - "outputDirectory", - "photos.ui.presentLimitedLibraryPicker", - "print.ui.present", - "quicklook.ui.preview", - "recognizedDataTypes", "settings.ui.open", "share.ui.present", "ui.alert.present", @@ -753,6 +776,48 @@ public enum CodeModeEvalScenarios { ) ) + public static let catalogPhotoCameraSystemUIDiscovery = CodeModeEvalScenario( + id: "catalog.system-ui-photo-camera-discovery", + title: "Catalog discovers photo and camera system UI helpers", + task: "Search the iOS catalog for photo picking, Photos limited-library management, and live camera data scanning helpers. Return each capability, JavaScript name, arguments, hints, and result summary.", + catalogPlatform: .iOS, + searchCode: """ + async () => { + const names = [ + "apple.photos.pick", + "apple.photos.presentLimitedLibraryPicker", + "apple.camera.scanData" + ]; + return Object.fromEntries(names.map(name => { + const ref = api.byJSName[name]; + return [name, ref ? { + capability: ref.capability, + jsNames: ref.jsNames, + requiredArguments: ref.requiredArguments, + optionalArguments: ref.optionalArguments, + argumentHints: ref.argumentHints, + resultSummary: ref.resultSummary + } : null]; + })); + } + """, + expectation: CodeModeEvalExpectation( + toolOrder: [.searchJavaScriptAPI], + requiredSearchResultFragments: [ + "apple.camera.scanData", + "apple.photos.pick", + "apple.photos.presentLimitedLibraryPicker", + "camera.ui.scanData", + "limit", + "mediaType", + "outputDirectory", + "photos.ui.pick", + "photos.ui.presentLimitedLibraryPicker", + "recognizedDataTypes", + ] + ) + ) + public static let catalogIOSOnlySystemUIDiscovery = CodeModeEvalScenario( id: "catalog.system-ui-ios-only-discovery", title: "Catalog discovers iOS-only system UI helpers", diff --git a/Tests/CodeModeTests/SystemUIBridgeTests.swift b/Tests/CodeModeTests/SystemUIBridgeTests.swift index d6399f5..505f017 100644 --- a/Tests/CodeModeTests/SystemUIBridgeTests.swift +++ b/Tests/CodeModeTests/SystemUIBridgeTests.swift @@ -394,137 +394,3 @@ import Testing } } } - -private struct FakeSystemUIPresenter: SystemUIPresenter { - var calendarResult: JSONValue - var photosResult: JSONValue - var contactsResult: JSONValue - var extraResults: [CapabilityID: JSONValue] - var error: BridgeError? - - init( - calendarResult: JSONValue = .object(["action": .string("cancelled")]), - photosResult: JSONValue = .array([]), - contactsResult: JSONValue = .array([]), - extraResults: [CapabilityID: JSONValue] = [:], - error: BridgeError? = nil - ) { - self.calendarResult = calendarResult - self.photosResult = photosResult - self.contactsResult = contactsResult - self.extraResults = extraResults - self.error = error - } - - func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments - _ = context - if let error { throw error } - return calendarResult - } - - func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments - _ = context - if let error { throw error } - return photosResult - } - - func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments - _ = context - if let error { throw error } - return contactsResult - } - - func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .calendarUIPickCalendar, arguments: arguments, context: context) - } - - func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .calendarUIPresentEvent, arguments: arguments, context: context) - } - - func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .contactsUIPresentContact, arguments: arguments, context: context) - } - - func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .contactsUIPresentNewContact, arguments: arguments, context: context) - } - - func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .documentsUIPick, arguments: arguments, context: context) - } - - func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .documentsUIExport, arguments: arguments, context: context) - } - - func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .documentsUIOpenIn, arguments: arguments, context: context) - } - - func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .documentsUIScan, arguments: arguments, context: context) - } - - func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .shareUIPresent, arguments: arguments, context: context) - } - - func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .quickLookUIPreview, arguments: arguments, context: context) - } - - func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .cameraUICapture, arguments: arguments, context: context) - } - - func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .cameraUIScanData, arguments: arguments, context: context) - } - - func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .mailUICompose, arguments: arguments, context: context) - } - - func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .messagesUICompose, arguments: arguments, context: context) - } - - func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .printUIPresent, arguments: arguments, context: context) - } - - func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .webUIPresent, arguments: arguments, context: context) - } - - func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .authUIWebAuthenticate, arguments: arguments, context: context) - } - - func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .uiAlertPresent, arguments: arguments, context: context) - } - - func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .uiPromptPresent, arguments: arguments, context: context) - } - - func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .photosUIPresentLimitedLibraryPicker, arguments: arguments, context: context) - } - - func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - try result(for: .settingsUIOpen, arguments: arguments, context: context) - } - - private func result(for capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { - _ = arguments - _ = context - if let error { throw error } - return extraResults[capability] ?? .object(["action": .string("cancelled")]) - } -} diff --git a/Tests/CodeModeTests/SystemUITestSupport.swift b/Tests/CodeModeTests/SystemUITestSupport.swift new file mode 100644 index 0000000..0eaedd6 --- /dev/null +++ b/Tests/CodeModeTests/SystemUITestSupport.swift @@ -0,0 +1,136 @@ +import Foundation +@testable import CodeMode + +struct FakeSystemUIPresenter: SystemUIPresenter { + var calendarResult: JSONValue + var photosResult: JSONValue + var contactsResult: JSONValue + var extraResults: [CapabilityID: JSONValue] + var error: BridgeError? + + init( + calendarResult: JSONValue = .object(["action": .string("cancelled")]), + photosResult: JSONValue = .array([]), + contactsResult: JSONValue = .array([]), + extraResults: [CapabilityID: JSONValue] = [:], + error: BridgeError? = nil + ) { + self.calendarResult = calendarResult + self.photosResult = photosResult + self.contactsResult = contactsResult + self.extraResults = extraResults + self.error = error + } + + func presentNewCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return calendarResult + } + + func pickPhotos(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return photosResult + } + + func pickContacts(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return contactsResult + } + + func pickCalendar(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .calendarUIPickCalendar, arguments: arguments, context: context) + } + + func presentCalendarEvent(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .calendarUIPresentEvent, arguments: arguments, context: context) + } + + func presentContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .contactsUIPresentContact, arguments: arguments, context: context) + } + + func presentNewContact(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .contactsUIPresentNewContact, arguments: arguments, context: context) + } + + func pickDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIPick, arguments: arguments, context: context) + } + + func exportDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIExport, arguments: arguments, context: context) + } + + func openDocument(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIOpenIn, arguments: arguments, context: context) + } + + func scanDocuments(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .documentsUIScan, arguments: arguments, context: context) + } + + func presentShareSheet(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .shareUIPresent, arguments: arguments, context: context) + } + + func previewQuickLook(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .quickLookUIPreview, arguments: arguments, context: context) + } + + func captureCamera(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .cameraUICapture, arguments: arguments, context: context) + } + + func scanData(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .cameraUIScanData, arguments: arguments, context: context) + } + + func composeMail(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .mailUICompose, arguments: arguments, context: context) + } + + func composeMessage(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .messagesUICompose, arguments: arguments, context: context) + } + + func presentPrint(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .printUIPresent, arguments: arguments, context: context) + } + + func presentWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .webUIPresent, arguments: arguments, context: context) + } + + func authenticateWeb(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .authUIWebAuthenticate, arguments: arguments, context: context) + } + + func presentAlert(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .uiAlertPresent, arguments: arguments, context: context) + } + + func presentPrompt(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .uiPromptPresent, arguments: arguments, context: context) + } + + func presentLimitedPhotoLibraryPicker(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .photosUIPresentLimitedLibraryPicker, arguments: arguments, context: context) + } + + func openSettings(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + try result(for: .settingsUIOpen, arguments: arguments, context: context) + } + + private func result(for capability: CapabilityID, arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { + _ = arguments + _ = context + if let error { throw error } + return extraResults[capability] ?? .object(["action": .string("cancelled")]) + } +}