mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 01:37:03 +00:00
Split AppDelegate into per-bridge files behind ConduitBridgeHost (#794)
Some checks failed
CI / Mobile app (analyze + test) (push) Has been cancelled
CI / Dart packages (push) Has been cancelled
CI / Dart packages-1 (push) Has been cancelled
CI / Dart packages-2 (push) Has been cancelled
CI / Dart packages-3 (push) Has been cancelled
CI / Web-safe packages (push) Has been cancelled
CI / Localization (push) Has been cancelled
Some checks failed
CI / Mobile app (analyze + test) (push) Has been cancelled
CI / Dart packages (push) Has been cancelled
CI / Dart packages-1 (push) Has been cancelled
CI / Dart packages-2 (push) Has been cancelled
CI / Dart packages-3 (push) Has been cancelled
CI / Web-safe packages (push) Has been cancelled
CI / Localization (push) Has been cancelled
Split ios/Runner/AppDelegate.swift into one file per bridge and put the bridges behind a host-neutral ConduitBridgeHost protocol, so each bridge can be tested and reused without the Flutter view controller. The boundary check in tool/check_package_boundaries.dart now holds every Swift file in ios/Runner to one side of that seam. No intended behaviour change. Review follow-ups: an expiring streaming background task that keepAlive already replaced no longer reports a suspension, and the Send Image App Intent rejects files that do not resolve to an image type.
This commit is contained in:
parent
8c88ee4782
commit
efd452906d
28 changed files with 3162 additions and 2668 deletions
|
|
@ -15,6 +15,17 @@
|
|||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
A66B6643C89B7BB44E2B9471 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42FA90454A654BE3B4EA8A89 /* Pods_RunnerTests.framework */; };
|
||||
C073CCD19D2508BCAD536200 /* NativeIosTtsBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07E41E507BC83CDF6A2F806 /* NativeIosTtsBridge.swift */; };
|
||||
C073D293D6D3C0AE42208200 /* AppIntentBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0733DBE0EC21B8BA584F742 /* AppIntentBridge.swift */; };
|
||||
C0778A160B9E0F6B3D0E5C00 /* BackgroundStreamingHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07B0ACB6D900F7182D98598 /* BackgroundStreamingHandler.swift */; };
|
||||
C0784AC8CDA88C741E323C00 /* CookieBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C073FA4B620A52333B648A35 /* CookieBridge.swift */; };
|
||||
C078DBCB5B400CC724551100 /* ConduitBridgeHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07202B2AF712D67C45FE1BC /* ConduitBridgeHost.swift */; };
|
||||
C079094EC8D3F2DCA002F300 /* VoiceAudioRouteBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0748D3BCA6CB2E7D24C8438 /* VoiceAudioRouteBridge.swift */; };
|
||||
C0799EE7361E2F33F8F82200 /* ConduitAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = C073CB9E0C18631B25DCC01B /* ConduitAppIntents.swift */; };
|
||||
C07A1ABDC1D26441C3348E00 /* AppIntentInvocationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0700E3C1D3E25D23C2D9095 /* AppIntentInvocationStore.swift */; };
|
||||
C07CD0FC676C9F75CE320600 /* FlutterConduitBridgeHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07CD1E93C33B4AE1AD1A0F2 /* FlutterConduitBridgeHost.swift */; };
|
||||
C07E03850D838F4CC4E73700 /* ShareImportBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C076D53F677D9AB9577FCB47 /* ShareImportBridge.swift */; };
|
||||
C07FFD404BC56A760606B800 /* PlatformEnvironmentBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07D6E81F9707D1BBEDB9BB5 /* PlatformEnvironmentBridge.swift */; };
|
||||
CA4A256DAFB6E4374BC67B91 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E952EFF81F1FEABA348FE2CC /* Pods_Runner.framework */; };
|
||||
D2A0B1C42F5E4D6C7B8A9012 /* NativePasteBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A0B1C32F5E4D6C7B8A9012 /* NativePasteBridge.swift */; };
|
||||
D2A0B1D42F5E4D6C7B8A9012 /* NativeKeyboardAttachmentBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A0B1D32F5E4D6C7B8A9012 /* NativeKeyboardAttachmentBridge.swift */; };
|
||||
|
|
@ -112,6 +123,17 @@
|
|||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C0700E3C1D3E25D23C2D9095 /* AppIntentInvocationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentInvocationStore.swift; sourceTree = "<group>"; };
|
||||
C07202B2AF712D67C45FE1BC /* ConduitBridgeHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConduitBridgeHost.swift; sourceTree = "<group>"; };
|
||||
C0733DBE0EC21B8BA584F742 /* AppIntentBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentBridge.swift; sourceTree = "<group>"; };
|
||||
C073CB9E0C18631B25DCC01B /* ConduitAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConduitAppIntents.swift; sourceTree = "<group>"; };
|
||||
C073FA4B620A52333B648A35 /* CookieBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieBridge.swift; sourceTree = "<group>"; };
|
||||
C0748D3BCA6CB2E7D24C8438 /* VoiceAudioRouteBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceAudioRouteBridge.swift; sourceTree = "<group>"; };
|
||||
C076D53F677D9AB9577FCB47 /* ShareImportBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareImportBridge.swift; sourceTree = "<group>"; };
|
||||
C07B0ACB6D900F7182D98598 /* BackgroundStreamingHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundStreamingHandler.swift; sourceTree = "<group>"; };
|
||||
C07CD1E93C33B4AE1AD1A0F2 /* FlutterConduitBridgeHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlutterConduitBridgeHost.swift; sourceTree = "<group>"; };
|
||||
C07D6E81F9707D1BBEDB9BB5 /* PlatformEnvironmentBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformEnvironmentBridge.swift; sourceTree = "<group>"; };
|
||||
C07E41E507BC83CDF6A2F806 /* NativeIosTtsBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeIosTtsBridge.swift; sourceTree = "<group>"; };
|
||||
D2A0B1C32F5E4D6C7B8A9012 /* NativePasteBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePasteBridge.swift; sourceTree = "<group>"; };
|
||||
D2A0B1D32F5E4D6C7B8A9012 /* NativeKeyboardAttachmentBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeKeyboardAttachmentBridge.swift; sourceTree = "<group>"; };
|
||||
D2A0B1E32F5E4D6C7B8A9012 /* NativeSheetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSheetBridge.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -340,6 +362,17 @@
|
|||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
C07D6E81F9707D1BBEDB9BB5 /* PlatformEnvironmentBridge.swift */,
|
||||
C07CD1E93C33B4AE1AD1A0F2 /* FlutterConduitBridgeHost.swift */,
|
||||
C07202B2AF712D67C45FE1BC /* ConduitBridgeHost.swift */,
|
||||
C073FA4B620A52333B648A35 /* CookieBridge.swift */,
|
||||
C073CB9E0C18631B25DCC01B /* ConduitAppIntents.swift */,
|
||||
C0733DBE0EC21B8BA584F742 /* AppIntentBridge.swift */,
|
||||
C0700E3C1D3E25D23C2D9095 /* AppIntentInvocationStore.swift */,
|
||||
C07E41E507BC83CDF6A2F806 /* NativeIosTtsBridge.swift */,
|
||||
C0748D3BCA6CB2E7D24C8438 /* VoiceAudioRouteBridge.swift */,
|
||||
C07B0ACB6D900F7182D98598 /* BackgroundStreamingHandler.swift */,
|
||||
C076D53F677D9AB9577FCB47 /* ShareImportBridge.swift */,
|
||||
D2A0B2132F5E4D6C7B8A9012 /* ConduitPlatformApis.g.swift */,
|
||||
D2A0B1C32F5E4D6C7B8A9012 /* NativePasteBridge.swift */,
|
||||
D2A0B1D32F5E4D6C7B8A9012 /* NativeKeyboardAttachmentBridge.swift */,
|
||||
|
|
@ -654,6 +687,17 @@
|
|||
D2A0B1C42F5E4D6C7B8A9012 /* NativePasteBridge.swift in Sources */,
|
||||
D2A0B2312F5E4D6C7B8A9012 /* NativeShareEnvelopeStore.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
C07FFD404BC56A760606B800 /* PlatformEnvironmentBridge.swift in Sources */,
|
||||
C07CD0FC676C9F75CE320600 /* FlutterConduitBridgeHost.swift in Sources */,
|
||||
C078DBCB5B400CC724551100 /* ConduitBridgeHost.swift in Sources */,
|
||||
C0784AC8CDA88C741E323C00 /* CookieBridge.swift in Sources */,
|
||||
C0799EE7361E2F33F8F82200 /* ConduitAppIntents.swift in Sources */,
|
||||
C073D293D6D3C0AE42208200 /* AppIntentBridge.swift in Sources */,
|
||||
C07A1ABDC1D26441C3348E00 /* AppIntentInvocationStore.swift in Sources */,
|
||||
C073CCD19D2508BCAD536200 /* NativeIosTtsBridge.swift in Sources */,
|
||||
C079094EC8D3F2DCA002F300 /* VoiceAudioRouteBridge.swift in Sources */,
|
||||
C0778A160B9E0F6B3D0E5C00 /* BackgroundStreamingHandler.swift in Sources */,
|
||||
C07E03850D838F4CC4E73700 /* ShareImportBridge.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
500
ios/Runner/AppIntentBridge.swift
Normal file
500
ios/Runner/AppIntentBridge.swift
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import CryptoKit
|
||||
import Flutter
|
||||
import Foundation
|
||||
|
||||
func appLocalized(_ key: String, _ fallback: String) -> String {
|
||||
NSLocalizedString(key, tableName: nil, bundle: .main, value: fallback, comment: "")
|
||||
}
|
||||
|
||||
/// Manages the method channel for App Intent invocations to Flutter.
|
||||
/// Native Swift intents call this to invoke Flutter-side business logic.
|
||||
final class AppIntentReadiness {
|
||||
private let lock = NSLock()
|
||||
private var ready = false
|
||||
private var waiters: [UUID: CheckedContinuation<Bool, Never>] = [:]
|
||||
|
||||
func update(_ value: Bool) {
|
||||
lock.lock()
|
||||
ready = value
|
||||
let readyWaiters: [CheckedContinuation<Bool, Never>]
|
||||
if value {
|
||||
readyWaiters = Array(waiters.values)
|
||||
waiters.removeAll(keepingCapacity: false)
|
||||
} else {
|
||||
readyWaiters = []
|
||||
}
|
||||
lock.unlock()
|
||||
readyWaiters.forEach { $0.resume(returning: true) }
|
||||
}
|
||||
|
||||
func currentValue() -> Bool {
|
||||
lock.lock()
|
||||
let value = ready
|
||||
lock.unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
func waitUntilReady(timeoutNanoseconds: UInt64) async -> Bool {
|
||||
if Task.isCancelled { return false }
|
||||
if currentValue() { return true }
|
||||
guard timeoutNanoseconds > 0 else { return false }
|
||||
|
||||
return await withTaskGroup(of: Bool.self) { group in
|
||||
group.addTask { [weak self] in
|
||||
guard let self else { return false }
|
||||
return await self.waitForReadySignal()
|
||||
}
|
||||
group.addTask {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: timeoutNanoseconds)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
let first = await group.next() ?? false
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForReadySignal() async -> Bool {
|
||||
if Task.isCancelled { return false }
|
||||
let waiterId = UUID()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
lock.lock()
|
||||
if ready {
|
||||
lock.unlock()
|
||||
continuation.resume(returning: true)
|
||||
return
|
||||
}
|
||||
if Task.isCancelled {
|
||||
lock.unlock()
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
waiters[waiterId] = continuation
|
||||
lock.unlock()
|
||||
}
|
||||
} onCancel: {
|
||||
self.resolveWaiter(waiterId, value: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveWaiter(_ waiterId: UUID, value: Bool) {
|
||||
lock.lock()
|
||||
let continuation = waiters.removeValue(forKey: waiterId)
|
||||
lock.unlock()
|
||||
continuation?.resume(returning: value)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppIntentStagedImage: Equatable {
|
||||
let filePath: String
|
||||
let contentDigest: String
|
||||
}
|
||||
|
||||
final class AppIntentBridge: AppIntentHostApi, @unchecked Sendable {
|
||||
private static let sharedLock = NSLock()
|
||||
private static var storedShared: AppIntentBridge?
|
||||
private static let sharedReadiness = AppIntentReadiness()
|
||||
|
||||
static var shared: AppIntentBridge? {
|
||||
get {
|
||||
sharedLock.lock()
|
||||
let bridge = storedShared
|
||||
sharedLock.unlock()
|
||||
return bridge
|
||||
}
|
||||
set {
|
||||
sharedLock.lock()
|
||||
storedShared = newValue
|
||||
let ready = newValue?.readiness.currentValue() ?? false
|
||||
sharedReadiness.update(ready)
|
||||
sharedLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private static let imageByteLimit = 20 * 1024 * 1024
|
||||
private static let imageStagingDirectoryName = "conduit-app-intents"
|
||||
private static let invocationTimeout: TimeInterval = 10
|
||||
|
||||
private let api: AppIntentFlutterApi
|
||||
private let readiness = AppIntentReadiness()
|
||||
|
||||
init(messenger: FlutterBinaryMessenger) {
|
||||
api = AppIntentFlutterApi(binaryMessenger: messenger)
|
||||
AppIntentHostApiSetup.setUp(binaryMessenger: messenger, api: self)
|
||||
}
|
||||
|
||||
/// Replaces the shared bridge with one bound to `host`'s messenger.
|
||||
static func attach(to host: ConduitBridgeHost) {
|
||||
shared = AppIntentBridge(messenger: host.messenger)
|
||||
}
|
||||
|
||||
func setReady(ready: Bool) throws {
|
||||
// Pigeon acknowledges this synchronous host call as soon as the
|
||||
// method returns. Apply readiness before returning so Dart never sees
|
||||
// a successful setReady(true) while native intents still see false.
|
||||
Self.sharedLock.lock()
|
||||
readiness.update(ready)
|
||||
if Self.storedShared === self {
|
||||
Self.sharedReadiness.update(ready)
|
||||
}
|
||||
Self.sharedLock.unlock()
|
||||
}
|
||||
|
||||
/// Waits for both the Flutter engine and the Dart handler to be ready.
|
||||
/// App Intents can be asked to run while a cold launch is still between
|
||||
/// native plugin registration and the deferred Dart coordinator startup.
|
||||
static func readyBridge() async -> AppIntentBridge? {
|
||||
let deadline = ProcessInfo.processInfo.systemUptime + 8
|
||||
while true {
|
||||
guard !Task.isCancelled else { return nil }
|
||||
if let bridge = readySharedBridge() {
|
||||
return bridge
|
||||
}
|
||||
let remainingSeconds = deadline - ProcessInfo.processInfo.systemUptime
|
||||
guard remainingSeconds > 0 else { return nil }
|
||||
let remainingNanoseconds = UInt64(
|
||||
min(remainingSeconds * 1_000_000_000, Double(UInt64.max))
|
||||
)
|
||||
guard await sharedReadiness.waitUntilReady(
|
||||
timeoutNanoseconds: remainingNanoseconds
|
||||
) else { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
private static func readySharedBridge() -> AppIntentBridge? {
|
||||
sharedLock.lock()
|
||||
let bridge = storedShared
|
||||
let ready = bridge?.readiness.currentValue() ?? false
|
||||
sharedLock.unlock()
|
||||
return ready ? bridge : nil
|
||||
}
|
||||
|
||||
static func stageImage(data: Data, filename: String) async throws -> String {
|
||||
try await stageImageArtifact(data: data, filename: filename).filePath
|
||||
}
|
||||
|
||||
static func stageImageArtifact(
|
||||
data: Data,
|
||||
filename: String
|
||||
) async throws -> AppIntentStagedImage {
|
||||
guard !data.isEmpty, data.count <= imageByteLimit else {
|
||||
throw AppIntentError.executionFailed(
|
||||
appLocalized("appIntent.imageTooLarge", "Image is too large (20 MB maximum).")
|
||||
)
|
||||
}
|
||||
let stagingTask = Task.detached(priority: .userInitiated) {
|
||||
try Task.checkCancellation()
|
||||
let directory = try imageStagingDirectory()
|
||||
let fileExtension = safeImageFileExtension(filename)
|
||||
let destination = directory.appendingPathComponent(
|
||||
"\(UUID().uuidString)-intent.\(fileExtension)"
|
||||
)
|
||||
var completed = false
|
||||
defer {
|
||||
if !completed {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
}
|
||||
try data.write(to: destination, options: [.atomic])
|
||||
try Task.checkCancellation()
|
||||
completed = true
|
||||
return AppIntentStagedImage(
|
||||
filePath: destination.path,
|
||||
contentDigest: SHA256.hash(data: data)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
)
|
||||
}
|
||||
return try await withTaskCancellationHandler {
|
||||
try await stagingTask.value
|
||||
} onCancel: {
|
||||
stagingTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
static func stageImage(fileURL: URL, filename: String) async throws -> String {
|
||||
try await stageImageArtifact(
|
||||
fileURL: fileURL,
|
||||
filename: filename
|
||||
).filePath
|
||||
}
|
||||
|
||||
static func stageImageArtifact(
|
||||
fileURL: URL,
|
||||
filename: String
|
||||
) async throws -> AppIntentStagedImage {
|
||||
let stagingTask = Task.detached(priority: .userInitiated) {
|
||||
try Task.checkCancellation()
|
||||
let didAccessSecurityScope = fileURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didAccessSecurityScope {
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
let values = try fileURL.resourceValues(
|
||||
forKeys: [
|
||||
.fileSizeKey,
|
||||
.isRegularFileKey,
|
||||
.isSymbolicLinkKey,
|
||||
]
|
||||
)
|
||||
guard values.isRegularFile == true,
|
||||
values.isSymbolicLink != true else {
|
||||
throw CocoaError(.fileReadUnsupportedScheme)
|
||||
}
|
||||
if let fileSize = values.fileSize,
|
||||
fileSize <= 0 || fileSize > imageByteLimit {
|
||||
throw AppIntentError.executionFailed(
|
||||
appLocalized(
|
||||
"appIntent.imageTooLarge",
|
||||
"Image is too large (20 MB maximum)."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let directory = try imageStagingDirectory()
|
||||
let destination = directory.appendingPathComponent(
|
||||
"\(UUID().uuidString)-intent.\(safeImageFileExtension(filename))"
|
||||
)
|
||||
guard FileManager.default.createFile(
|
||||
atPath: destination.path,
|
||||
contents: nil
|
||||
) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
|
||||
var completed = false
|
||||
defer {
|
||||
if !completed {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
}
|
||||
let input = try FileHandle(forReadingFrom: fileURL)
|
||||
let output = try FileHandle(forWritingTo: destination)
|
||||
defer {
|
||||
try? input.close()
|
||||
try? output.close()
|
||||
}
|
||||
|
||||
var totalBytes = 0
|
||||
var digest = SHA256()
|
||||
while let chunk = try input.read(upToCount: 64 * 1024),
|
||||
!chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard chunk.count <= imageByteLimit - totalBytes else {
|
||||
throw AppIntentError.executionFailed(
|
||||
appLocalized(
|
||||
"appIntent.imageTooLarge",
|
||||
"Image is too large (20 MB maximum)."
|
||||
)
|
||||
)
|
||||
}
|
||||
try output.write(contentsOf: chunk)
|
||||
digest.update(data: chunk)
|
||||
totalBytes += chunk.count
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard totalBytes > 0 else {
|
||||
throw AppIntentError.executionFailed(
|
||||
appLocalized(
|
||||
"appIntent.imageTooLarge",
|
||||
"Image is too large (20 MB maximum)."
|
||||
)
|
||||
)
|
||||
}
|
||||
completed = true
|
||||
return AppIntentStagedImage(
|
||||
filePath: destination.path,
|
||||
contentDigest: digest.finalize()
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
)
|
||||
}
|
||||
return try await withTaskCancellationHandler {
|
||||
try await stagingTask.value
|
||||
} onCancel: {
|
||||
stagingTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
static func removeStagedImageIfOwned(atPath filePath: String) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(imageStagingDirectoryName, isDirectory: true)
|
||||
.standardizedFileURL
|
||||
let candidate = URL(fileURLWithPath: filePath).standardizedFileURL
|
||||
guard candidate.deletingLastPathComponent().path == root.path,
|
||||
let values = try? candidate.resourceValues(
|
||||
forKeys: [.isRegularFileKey, .isSymbolicLinkKey]
|
||||
),
|
||||
values.isRegularFile == true,
|
||||
values.isSymbolicLink != true
|
||||
else { return }
|
||||
try? FileManager.default.removeItem(at: candidate)
|
||||
}
|
||||
|
||||
private static func imageStagingDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(imageStagingDirectoryName, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let values = try directory.resourceValues(
|
||||
forKeys: [.isDirectoryKey, .isSymbolicLinkKey]
|
||||
)
|
||||
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
||||
throw CocoaError(.fileWriteInvalidFileName)
|
||||
}
|
||||
return directory.standardizedFileURL
|
||||
}
|
||||
|
||||
private static func safeImageFileExtension(_ filename: String) -> String {
|
||||
let rawExtension = (filename as NSString).pathExtension.lowercased()
|
||||
let allowed = rawExtension.unicodeScalars.filter {
|
||||
CharacterSet.alphanumerics.contains($0)
|
||||
}
|
||||
let sanitized = String(String.UnicodeScalarView(allowed)).prefix(10)
|
||||
return sanitized.isEmpty ? "img" : String(sanitized)
|
||||
}
|
||||
|
||||
/// Invokes a Flutter handler for the given intent identifier.
|
||||
func invokeIntent(
|
||||
identifier: String,
|
||||
parameters: [String: Any],
|
||||
canonicalParameters: [String: String]
|
||||
) async -> [String: Any] {
|
||||
let invocationLease = AppIntentInvocationStore.shared.lease(
|
||||
identifier: identifier,
|
||||
canonicalParameters: canonicalParameters
|
||||
)
|
||||
let result: [String: Any]
|
||||
switch identifier {
|
||||
case "app.cogwheel.conduit.ask_chat":
|
||||
result = await invoke { bridge, completion in
|
||||
bridge.api.askChat(
|
||||
invocationId: invocationLease.invocationId,
|
||||
prompt: parameters["prompt"] as? String,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
case "app.cogwheel.conduit.start_voice_call":
|
||||
result = await invoke { bridge, completion in
|
||||
bridge.api.startVoiceCall(
|
||||
invocationId: invocationLease.invocationId,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
case "app.cogwheel.conduit.send_text":
|
||||
result = await invoke { bridge, completion in
|
||||
bridge.api.sendText(
|
||||
invocationId: invocationLease.invocationId,
|
||||
text: parameters["text"] as? String ?? "",
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
case "app.cogwheel.conduit.send_url":
|
||||
result = await invoke { bridge, completion in
|
||||
bridge.api.sendUrl(
|
||||
invocationId: invocationLease.invocationId,
|
||||
url: parameters["url"] as? String ?? "",
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
case "app.cogwheel.conduit.send_image":
|
||||
guard let filePath = parameters["filePath"] as? String,
|
||||
!filePath.isEmpty else {
|
||||
result = [
|
||||
"success": false,
|
||||
"error": "No staged image provided."
|
||||
]
|
||||
break
|
||||
}
|
||||
let payload = PlatformAppIntentImagePayload(
|
||||
filename: parameters["filename"] as? String ?? "shared_image.jpg",
|
||||
filePath: filePath
|
||||
)
|
||||
result = await invoke { bridge, completion in
|
||||
bridge.api.sendImage(
|
||||
invocationId: invocationLease.invocationId,
|
||||
payload: payload,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
default:
|
||||
result = [
|
||||
"success": false,
|
||||
"error": "Unknown intent: \(identifier)"
|
||||
]
|
||||
}
|
||||
AppIntentInvocationStore.shared.resolve(
|
||||
invocationLease,
|
||||
dispatchState: result[appIntentNativeDispatchStateKey] as? String
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private func invoke(
|
||||
_ call: @escaping (
|
||||
AppIntentBridge,
|
||||
@escaping (Result<PlatformAppIntentResponse, PigeonError>) -> Void
|
||||
) -> Void
|
||||
) async -> [String: Any] {
|
||||
let completion = AppIntentInvocationCompletion()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
completion.install(continuation)
|
||||
guard !completion.isResolved else { return }
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||
deadline: .now() + Self.invocationTimeout
|
||||
) {
|
||||
completion.resolveInterrupted(
|
||||
"App Intent timed out waiting for Conduit."
|
||||
)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Reacquire at the dispatch boundary. The bridge returned
|
||||
// by readyBridge() can be replaced (or deallocated)
|
||||
// before this block reaches the main queue; the current
|
||||
// ready bridge receives the same durable invocation.
|
||||
guard let targetBridge = Self.readySharedBridge() else {
|
||||
completion.resolveNotDispatched(
|
||||
"App Intent bridge was replaced."
|
||||
)
|
||||
return
|
||||
}
|
||||
guard completion.beginDispatch() else { return }
|
||||
call(targetBridge) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
var payload: [String: Any] = [
|
||||
"success": response.success,
|
||||
]
|
||||
payload["value"] = response.value
|
||||
payload["error"] = response.error
|
||||
payload[appIntentNativeOwnedFilePathKey] =
|
||||
response.ownedFilePath
|
||||
completion.resolveCompleted(payload)
|
||||
case .failure(let error):
|
||||
// The message crossed the engine boundary, but a
|
||||
// transport failure cannot prove whether Dart took
|
||||
// ownership before its response was lost.
|
||||
completion.resolveTransportFailure(
|
||||
error.message ?? error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
completion.resolveInterrupted("App Intent was cancelled.")
|
||||
}
|
||||
}
|
||||
}
|
||||
317
ios/Runner/AppIntentInvocationStore.swift
Normal file
317
ios/Runner/AppIntentInvocationStore.swift
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
let appIntentNativeDispatchStateKey = "_nativeDispatchState"
|
||||
let appIntentNativeDispatchNotDispatched = "notDispatched"
|
||||
let appIntentNativeDispatchCompleted = "completed"
|
||||
let appIntentNativeDispatchIndeterminate = "indeterminate"
|
||||
let appIntentNativeOwnedFilePathKey = "_nativeOwnedFilePath"
|
||||
|
||||
struct AppIntentInvocationLease: Equatable {
|
||||
let invocationId: String
|
||||
fileprivate let fingerprint: String
|
||||
}
|
||||
|
||||
/// Persists the identity of an invocation whose dispatch outcome was
|
||||
/// indeterminate. App Intents does not expose an execution identifier, so a
|
||||
/// retry is correlated by a privacy-safe digest of the intent and its
|
||||
/// canonical inputs. Completed and provably-undispatched calls immediately
|
||||
/// release their lease; only an interrupted dispatched call remains reusable.
|
||||
final class AppIntentInvocationStore: @unchecked Sendable {
|
||||
private struct Record: Codable, Equatable {
|
||||
let invocationId: String
|
||||
let fingerprint: String
|
||||
var expiresAtMilliseconds: Int64
|
||||
}
|
||||
|
||||
private struct LegacyRecord: Codable {
|
||||
let invocationId: String
|
||||
let expiresAtMilliseconds: Int64
|
||||
}
|
||||
|
||||
static let shared = AppIntentInvocationStore()
|
||||
|
||||
private static let defaultsKey =
|
||||
"app.cogwheel.conduit.app-intent-invocations-v1"
|
||||
private static let leaseLifetimeMilliseconds: Int64 = 5 * 60 * 1_000
|
||||
|
||||
private let lock = NSLock()
|
||||
private let defaults: UserDefaults
|
||||
private let nowMilliseconds: () -> Int64
|
||||
// Activity is intentionally process-local. A record left behind by
|
||||
// process termination must become retryable when the next process starts.
|
||||
private var activeInvocationIds = Set<String>()
|
||||
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
nowMilliseconds: @escaping () -> Int64 = {
|
||||
Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
}
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.nowMilliseconds = nowMilliseconds
|
||||
}
|
||||
|
||||
func lease(
|
||||
identifier: String,
|
||||
canonicalParameters: [String: String]
|
||||
) -> AppIntentInvocationLease {
|
||||
let fingerprint = Self.fingerprint(
|
||||
identifier: identifier,
|
||||
canonicalParameters: canonicalParameters
|
||||
)
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
let now = nowMilliseconds()
|
||||
var records = readRecordsLocked().filter {
|
||||
$0.value.expiresAtMilliseconds > now
|
||||
}
|
||||
activeInvocationIds.formIntersection(records.keys)
|
||||
if let reusable = records.values
|
||||
.filter({
|
||||
!activeInvocationIds.contains($0.invocationId) &&
|
||||
$0.fingerprint == fingerprint &&
|
||||
UUID(uuidString: $0.invocationId) != nil
|
||||
})
|
||||
.max(by: {
|
||||
if $0.expiresAtMilliseconds == $1.expiresAtMilliseconds {
|
||||
return $0.invocationId < $1.invocationId
|
||||
}
|
||||
return $0.expiresAtMilliseconds < $1.expiresAtMilliseconds
|
||||
}) {
|
||||
var renewed = reusable
|
||||
renewed.expiresAtMilliseconds =
|
||||
now + Self.leaseLifetimeMilliseconds
|
||||
records[renewed.invocationId] = renewed
|
||||
activeInvocationIds.insert(renewed.invocationId)
|
||||
persistLocked(records)
|
||||
return AppIntentInvocationLease(
|
||||
invocationId: renewed.invocationId,
|
||||
fingerprint: fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
let invocationId = UUID().uuidString.lowercased()
|
||||
records[invocationId] = Record(
|
||||
invocationId: invocationId,
|
||||
fingerprint: fingerprint,
|
||||
expiresAtMilliseconds: now + Self.leaseLifetimeMilliseconds
|
||||
)
|
||||
activeInvocationIds.insert(invocationId)
|
||||
persistLocked(records)
|
||||
return AppIntentInvocationLease(
|
||||
invocationId: invocationId,
|
||||
fingerprint: fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
func resolve(
|
||||
_ lease: AppIntentInvocationLease,
|
||||
dispatchState: String?
|
||||
) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
var records = readRecordsLocked()
|
||||
activeInvocationIds.remove(lease.invocationId)
|
||||
guard var record = records[lease.invocationId],
|
||||
record.fingerprint == lease.fingerprint else { return }
|
||||
if dispatchState == appIntentNativeDispatchIndeterminate {
|
||||
record.expiresAtMilliseconds =
|
||||
nowMilliseconds() + Self.leaseLifetimeMilliseconds
|
||||
records[lease.invocationId] = record
|
||||
} else {
|
||||
records.removeValue(forKey: lease.invocationId)
|
||||
}
|
||||
persistLocked(records)
|
||||
}
|
||||
|
||||
static func fingerprint(
|
||||
identifier: String,
|
||||
canonicalParameters: [String: String]
|
||||
) -> String {
|
||||
var input = Data()
|
||||
func append(_ value: String) {
|
||||
let bytes = Data(value.utf8)
|
||||
var length = UInt64(bytes.count).bigEndian
|
||||
withUnsafeBytes(of: &length) { input.append(contentsOf: $0) }
|
||||
input.append(bytes)
|
||||
}
|
||||
append(identifier)
|
||||
for key in canonicalParameters.keys.sorted() {
|
||||
append(key)
|
||||
append(canonicalParameters[key] ?? "")
|
||||
}
|
||||
return SHA256.hash(data: input)
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
}
|
||||
|
||||
private func readRecordsLocked() -> [String: Record] {
|
||||
guard let data = defaults.data(forKey: Self.defaultsKey) else {
|
||||
return [:]
|
||||
}
|
||||
if let records = try? JSONDecoder().decode(
|
||||
[String: Record].self,
|
||||
from: data
|
||||
) {
|
||||
return records
|
||||
}
|
||||
guard let legacyRecords = try? JSONDecoder().decode(
|
||||
[String: LegacyRecord].self,
|
||||
from: data
|
||||
) else { return [:] }
|
||||
return legacyRecords.reduce(into: [:]) { records, entry in
|
||||
let (fingerprint, legacy) = entry
|
||||
guard UUID(uuidString: legacy.invocationId) != nil else { return }
|
||||
records[legacy.invocationId] = Record(
|
||||
invocationId: legacy.invocationId,
|
||||
fingerprint: fingerprint,
|
||||
expiresAtMilliseconds: legacy.expiresAtMilliseconds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked(_ records: [String: Record]) {
|
||||
if records.isEmpty {
|
||||
defaults.removeObject(forKey: Self.defaultsKey)
|
||||
} else if let data = try? JSONEncoder().encode(records) {
|
||||
defaults.set(data, forKey: Self.defaultsKey)
|
||||
}
|
||||
// App Intent execution may be terminated immediately after perform()
|
||||
// returns; flush the tiny lease record before exposing the result.
|
||||
defaults.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
/// Exactly-once bridge between callback-based Pigeon calls and async App
|
||||
/// Intents. Cancellation can race continuation installation, so the gate also
|
||||
/// retains an early terminal payload until the continuation is ready.
|
||||
final class AppIntentInvocationCompletion: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<[String: Any], Never>?
|
||||
private var earlyPayload: [String: Any]?
|
||||
private var resolved = false
|
||||
private var dispatched = false
|
||||
|
||||
var isResolved: Bool {
|
||||
lock.lock()
|
||||
let value = resolved
|
||||
lock.unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
func install(
|
||||
_ continuation: CheckedContinuation<[String: Any], Never>
|
||||
) {
|
||||
lock.lock()
|
||||
if resolved {
|
||||
let payload = earlyPayload ?? Self.failurePayload(
|
||||
"App Intent was cancelled.",
|
||||
dispatchState: appIntentNativeDispatchNotDispatched
|
||||
)
|
||||
earlyPayload = nil
|
||||
lock.unlock()
|
||||
continuation.resume(returning: payload)
|
||||
return
|
||||
}
|
||||
self.continuation = continuation
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Atomically claims the right to send the Pigeon message.
|
||||
func beginDispatch() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !resolved, !dispatched else { return false }
|
||||
dispatched = true
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resolveCompleted(_ payload: [String: Any]) -> Bool {
|
||||
resolve(
|
||||
Self.payload(
|
||||
payload,
|
||||
dispatchState: appIntentNativeDispatchCompleted
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resolveTransportFailure(_ message: String) -> Bool {
|
||||
resolve(Self.failurePayload(
|
||||
message,
|
||||
dispatchState: appIntentNativeDispatchIndeterminate
|
||||
))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resolveNotDispatched(_ message: String) -> Bool {
|
||||
resolve(Self.failurePayload(
|
||||
message,
|
||||
dispatchState: appIntentNativeDispatchNotDispatched
|
||||
))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resolveInterrupted(_ message: String) -> Bool {
|
||||
lock.lock()
|
||||
let dispatchState = dispatched
|
||||
? appIntentNativeDispatchIndeterminate
|
||||
: appIntentNativeDispatchNotDispatched
|
||||
guard !resolved else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
resolved = true
|
||||
let payload = Self.failurePayload(
|
||||
message,
|
||||
dispatchState: dispatchState
|
||||
)
|
||||
let callback = continuation
|
||||
continuation = nil
|
||||
if callback == nil {
|
||||
earlyPayload = payload
|
||||
}
|
||||
lock.unlock()
|
||||
callback?.resume(returning: payload)
|
||||
return true
|
||||
}
|
||||
|
||||
private func resolve(_ payload: [String: Any]) -> Bool {
|
||||
lock.lock()
|
||||
guard !resolved else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
resolved = true
|
||||
let callback = continuation
|
||||
continuation = nil
|
||||
if callback == nil {
|
||||
earlyPayload = payload
|
||||
}
|
||||
lock.unlock()
|
||||
callback?.resume(returning: payload)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func payload(
|
||||
_ payload: [String: Any],
|
||||
dispatchState: String
|
||||
) -> [String: Any] {
|
||||
var result = payload
|
||||
result[appIntentNativeDispatchStateKey] = dispatchState
|
||||
return result
|
||||
}
|
||||
|
||||
private static func failurePayload(
|
||||
_ message: String,
|
||||
dispatchState: String
|
||||
) -> [String: Any] {
|
||||
payload(
|
||||
["success": false, "error": message],
|
||||
dispatchState: dispatchState
|
||||
)
|
||||
}
|
||||
}
|
||||
529
ios/Runner/BackgroundStreamingHandler.swift
Normal file
529
ios/Runner/BackgroundStreamingHandler.swift
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
import AVFoundation
|
||||
import BackgroundTasks
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
/// Manages AVAudioSession for voice calls in the background.
|
||||
///
|
||||
/// IMPORTANT: This manager is ONLY used for server-side STT (speech-to-text).
|
||||
/// When using local STT, the native recognizer path manages its own audio
|
||||
/// session. Do NOT activate this manager when local STT is in use to avoid
|
||||
/// audio session conflicts.
|
||||
///
|
||||
/// The voice_call_service.dart checks `useServerMic` before calling
|
||||
/// startBackgroundExecution with requiresMicrophone:true.
|
||||
final class VoiceBackgroundAudioManager {
|
||||
static let shared = VoiceBackgroundAudioManager()
|
||||
|
||||
private var isActive = false
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Flag indicating another component owns the audio session.
|
||||
/// When true, this manager will skip activation to avoid conflicts.
|
||||
private var externalSessionOwner = false
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Mark that an external component is managing the audio session.
|
||||
/// Call this before starting local STT to prevent conflicts.
|
||||
func setExternalSessionOwner(_ isExternal: Bool) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
externalSessionOwner = isExternal
|
||||
|
||||
if isExternal {
|
||||
print("VoiceBackgroundAudioManager: External session owner active, deferring to external management")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an external component owns the audio session.
|
||||
var hasExternalSessionOwner: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return externalSessionOwner
|
||||
}
|
||||
|
||||
func activate() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard !isActive else { return }
|
||||
|
||||
// Skip if another component is managing the audio session
|
||||
if externalSessionOwner {
|
||||
print("VoiceBackgroundAudioManager: Skipping activation - external session owner active")
|
||||
return
|
||||
}
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
// Check current category to avoid unnecessary reconfiguration
|
||||
// This helps prevent conflicts if local STT already configured the session.
|
||||
let currentCategory = session.category
|
||||
let needsReconfiguration = currentCategory != .playAndRecord
|
||||
|
||||
if needsReconfiguration {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [
|
||||
// Keep the session on duplex-capable routes while the
|
||||
// server-side recorder is streaming PCM from the mic.
|
||||
.allowBluetoothHFP,
|
||||
.defaultToSpeaker,
|
||||
]
|
||||
)
|
||||
}
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
isActive = true
|
||||
} catch {
|
||||
print("VoiceBackgroundAudioManager: Failed to activate audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func deactivate() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard isActive else { return }
|
||||
|
||||
// Don't deactivate if external owner - they manage their own lifecycle
|
||||
if externalSessionOwner {
|
||||
print("VoiceBackgroundAudioManager: Skipping deactivation - external session owner active")
|
||||
isActive = false
|
||||
return
|
||||
}
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
print("VoiceBackgroundAudioManager: Failed to deactivate audio session: \(error)")
|
||||
}
|
||||
|
||||
isActive = false
|
||||
}
|
||||
|
||||
/// Check if audio session is currently active (thread-safe).
|
||||
var isSessionActive: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return isActive
|
||||
}
|
||||
}
|
||||
|
||||
private struct BackgroundStreamingLease {
|
||||
let id: String
|
||||
let kind: String
|
||||
let requiresMicrophone: Bool
|
||||
let startedAtMillis: Int64
|
||||
|
||||
var isChat: Bool { kind == "chat" }
|
||||
var isVoice: Bool { kind == "voice" }
|
||||
var isSocket: Bool { id == "socket-keepalive" }
|
||||
}
|
||||
|
||||
private extension PlatformBackgroundStreamKind {
|
||||
var payloadName: String {
|
||||
switch self {
|
||||
case .chat: "chat"
|
||||
case .voice: "voice"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackgroundStreamingLease {
|
||||
init(_ lease: PlatformBackgroundStreamLease) {
|
||||
id = lease.id
|
||||
kind = lease.kind.payloadName
|
||||
requiresMicrophone = lease.requiresMicrophone
|
||||
startedAtMillis = lease.startedAtMillis
|
||||
}
|
||||
|
||||
func asPlatformLease() -> PlatformBackgroundStreamLease {
|
||||
PlatformBackgroundStreamLease(
|
||||
id: id,
|
||||
kind: isVoice ? .voice : .chat,
|
||||
requiresMicrophone: requiresMicrophone,
|
||||
startedAtMillis: startedAtMillis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class BGProcessingCompletionState {
|
||||
var completed = false
|
||||
}
|
||||
|
||||
// Background streaming handler class
|
||||
@MainActor
|
||||
class BackgroundStreamingHandler: NSObject, ConduitBridge, BackgroundStreamingHostApi {
|
||||
private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||
private var bgProcessingTask: BGTask?
|
||||
private var activeLeases: [String: BackgroundStreamingLease] = [:]
|
||||
private var flutterApi: BackgroundStreamingFlutterApi?
|
||||
|
||||
static let shared = BackgroundStreamingHandler()
|
||||
static let processingTaskIdentifier = "app.cogwheel.conduit.refresh"
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupNotifications()
|
||||
}
|
||||
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
flutterApi = BackgroundStreamingFlutterApi(binaryMessenger: messenger)
|
||||
BackgroundStreamingHostApiSetup.setUp(
|
||||
binaryMessenger: messenger,
|
||||
api: self
|
||||
)
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appWillEnterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func appDidEnterBackground() {
|
||||
if hasBackgroundExecutionLeases {
|
||||
startBackgroundTask()
|
||||
if hasChatLeases {
|
||||
scheduleBGProcessingTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func appWillEnterForeground() {
|
||||
endBackgroundTask()
|
||||
}
|
||||
|
||||
func startBackgroundExecution(request: PlatformBackgroundStartRequest) throws {
|
||||
startBackgroundExecution(
|
||||
leases: parseLeases(
|
||||
request.leases,
|
||||
streamIds: request.streamIds,
|
||||
requiresMic: request.requiresMicrophone
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func stopBackgroundExecution(request: PlatformBackgroundStopRequest) throws {
|
||||
stopBackgroundExecution(streamIds: request.streamIds)
|
||||
}
|
||||
|
||||
func keepAlive(request: PlatformBackgroundKeepAliveRequest) throws {
|
||||
keepAlive()
|
||||
}
|
||||
|
||||
func checkBackgroundRefreshStatus() throws -> Bool {
|
||||
switch UIApplication.shared.backgroundRefreshStatus {
|
||||
case .available:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
@unknown default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func checkNotificationPermission() throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func setExternalAudioSessionOwner(
|
||||
request: PlatformBackgroundAudioSessionOwnerRequest
|
||||
) throws {
|
||||
VoiceBackgroundAudioManager.shared.setExternalSessionOwner(
|
||||
request.isExternal
|
||||
)
|
||||
}
|
||||
|
||||
func getActiveStreamCount() throws -> Int64 {
|
||||
Int64(activeLeases.count)
|
||||
}
|
||||
|
||||
func getActiveStreamLeases() throws -> [PlatformBackgroundStreamLease] {
|
||||
activeLeases.values.map { $0.asPlatformLease() }
|
||||
}
|
||||
|
||||
func stopAllBackgroundExecution() throws {
|
||||
stopBackgroundExecution(streamIds: Array(activeLeases.keys))
|
||||
}
|
||||
|
||||
private var hasChatLeases: Bool {
|
||||
activeLeases.values.contains { $0.isChat && !$0.isSocket }
|
||||
}
|
||||
|
||||
private var hasBackgroundExecutionLeases: Bool {
|
||||
activeLeases.values.contains {
|
||||
!$0.isSocket && ($0.isChat || $0.isVoice)
|
||||
}
|
||||
}
|
||||
|
||||
private var hasMicrophoneLeases: Bool {
|
||||
activeLeases.values.contains { $0.requiresMicrophone }
|
||||
}
|
||||
|
||||
private func parseLeases(
|
||||
_ rawLeases: [PlatformBackgroundStreamLease],
|
||||
streamIds: [String],
|
||||
requiresMic: Bool
|
||||
) -> [BackgroundStreamingLease] {
|
||||
if !rawLeases.isEmpty {
|
||||
return rawLeases.compactMap { lease in
|
||||
guard lease.id != "socket-keepalive" else { return nil }
|
||||
return BackgroundStreamingLease(lease)
|
||||
}
|
||||
}
|
||||
|
||||
let startedAtMillis = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
return streamIds.compactMap { id in
|
||||
guard id != "socket-keepalive" else { return nil }
|
||||
return BackgroundStreamingLease(
|
||||
id: id,
|
||||
kind: requiresMic ? "voice" : "chat",
|
||||
requiresMicrophone: requiresMic,
|
||||
startedAtMillis: startedAtMillis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func startBackgroundExecution(leases: [BackgroundStreamingLease]) {
|
||||
for lease in leases {
|
||||
activeLeases[lease.id] = lease
|
||||
}
|
||||
|
||||
// Activate audio session for microphone access in background
|
||||
if hasMicrophoneLeases {
|
||||
VoiceBackgroundAudioManager.shared.activate()
|
||||
}
|
||||
|
||||
// Start background tasks if app is already backgrounded
|
||||
if UIApplication.shared.applicationState == .background &&
|
||||
hasBackgroundExecutionLeases {
|
||||
startBackgroundTask()
|
||||
if hasChatLeases {
|
||||
scheduleBGProcessingTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopBackgroundExecution(streamIds: [String]) {
|
||||
streamIds.forEach { activeLeases.removeValue(forKey: $0) }
|
||||
|
||||
if !hasBackgroundExecutionLeases {
|
||||
endBackgroundTask()
|
||||
cancelBGProcessingTask()
|
||||
} else if !hasChatLeases {
|
||||
cancelBGProcessingTask()
|
||||
}
|
||||
|
||||
if !hasMicrophoneLeases {
|
||||
VoiceBackgroundAudioManager.shared.deactivate()
|
||||
}
|
||||
}
|
||||
|
||||
private func startBackgroundTask() {
|
||||
guard backgroundTask == .invalid else { return }
|
||||
|
||||
backgroundTask = beginStreamingBackgroundTask()
|
||||
}
|
||||
|
||||
private func beginStreamingBackgroundTask() -> UIBackgroundTaskIdentifier {
|
||||
var taskIdentifier: UIBackgroundTaskIdentifier = .invalid
|
||||
taskIdentifier = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
// keepAlive replaces the task before ending the old one. A
|
||||
// replaced task expiring is not a suspension: the newer
|
||||
// task still holds background time.
|
||||
guard self.backgroundTask == taskIdentifier else {
|
||||
if taskIdentifier != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(taskIdentifier)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.notifyStreamsSuspending(reason: "background_task_expiring")
|
||||
self.flutterApi?.backgroundTaskExpiring { _ in }
|
||||
self.endBackgroundTask()
|
||||
}
|
||||
}
|
||||
return taskIdentifier
|
||||
}
|
||||
|
||||
private func endBackgroundTask() {
|
||||
guard backgroundTask != .invalid else { return }
|
||||
|
||||
UIApplication.shared.endBackgroundTask(backgroundTask)
|
||||
backgroundTask = .invalid
|
||||
}
|
||||
|
||||
private func keepAlive() {
|
||||
if hasBackgroundExecutionLeases &&
|
||||
UIApplication.shared.applicationState == .background {
|
||||
let oldTask = backgroundTask
|
||||
let newTask = beginStreamingBackgroundTask()
|
||||
if newTask != .invalid {
|
||||
backgroundTask = newTask
|
||||
if oldTask != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(oldTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep audio session active for microphone streams
|
||||
if hasMicrophoneLeases {
|
||||
VoiceBackgroundAudioManager.shared.activate()
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStreamsSuspending(reason: String) {
|
||||
guard !activeLeases.isEmpty else { return }
|
||||
flutterApi?.streamsSuspending(
|
||||
event: PlatformStreamsSuspendingEvent(
|
||||
streamIds: Array(activeLeases.keys),
|
||||
reason: reason
|
||||
)
|
||||
) { _ in }
|
||||
}
|
||||
|
||||
// MARK: - BGTaskScheduler Methods
|
||||
//
|
||||
// IMPORTANT: BGProcessingTask limitations on iOS:
|
||||
// - iOS schedules these during opportunistic windows (device charging, overnight, etc.)
|
||||
// - The earliestBeginDate is a HINT, not a guarantee of immediate execution
|
||||
// - Typical execution time is ~1-3 minutes when granted, but may NOT run at all
|
||||
// - BGProcessingTask is "best-effort bonus time", NOT "guaranteed extended execution"
|
||||
//
|
||||
// For reliable background execution:
|
||||
// - Voice calls: UIBackgroundModes "audio" + AVAudioSession keeps app alive reliably
|
||||
// - Chat streaming: beginBackgroundTask gives ~30 seconds (only reliable mechanism)
|
||||
// - Socket keepalive: Best-effort; iOS may suspend app regardless
|
||||
//
|
||||
// The BGProcessingTask here provides opportunistic extended time for long-running
|
||||
// streams, but callers should NOT depend on it for critical functionality.
|
||||
|
||||
func registerBackgroundTasks() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: Self.processingTaskIdentifier,
|
||||
using: nil
|
||||
) { [weak self] task in
|
||||
guard let processingTask = task as? BGProcessingTask else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleBGProcessingTask(task: processingTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleBGProcessingTask() {
|
||||
guard hasChatLeases else { return }
|
||||
// Cancel any existing task
|
||||
cancelBGProcessingTask()
|
||||
|
||||
let request = BGProcessingTaskRequest(identifier: Self.processingTaskIdentifier)
|
||||
request.requiresNetworkConnectivity = true
|
||||
request.requiresExternalPower = false
|
||||
|
||||
// Active chat streams need the task to be eligible during the current
|
||||
// response. This is still best-effort and only scheduled for chat leases.
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 1)
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
print("BackgroundStreamingHandler: Scheduled BGProcessingTask")
|
||||
} catch {
|
||||
print("BackgroundStreamingHandler: Failed to schedule BGProcessingTask: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelBGProcessingTask() {
|
||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.processingTaskIdentifier)
|
||||
print("BackgroundStreamingHandler: Cancelled BGProcessingTask")
|
||||
}
|
||||
|
||||
private func handleBGProcessingTask(task: BGProcessingTask) {
|
||||
print("BackgroundStreamingHandler: BGProcessingTask started")
|
||||
bgProcessingTask = task
|
||||
let completionState = BGProcessingCompletionState()
|
||||
|
||||
// Schedule a new task for continuation if streams are still active
|
||||
if hasChatLeases {
|
||||
scheduleBGProcessingTask()
|
||||
}
|
||||
|
||||
func completeTask(success: Bool) {
|
||||
guard !completionState.completed else { return }
|
||||
completionState.completed = true
|
||||
task.setTaskCompleted(success: success)
|
||||
if bgProcessingTask === task {
|
||||
bgProcessingTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Set expiration handler
|
||||
task.expirationHandler = { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
print("BackgroundStreamingHandler: BGProcessingTask expiring")
|
||||
self.notifyStreamsSuspending(reason: "bg_processing_task_expiring")
|
||||
self.flutterApi?.backgroundTaskExpiring { _ in }
|
||||
completeTask(success: false)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify Flutter that we have extended background time
|
||||
flutterApi?.backgroundTaskExtended(
|
||||
event: PlatformBackgroundTaskExtendedEvent(
|
||||
streamIds: Array(activeLeases.keys),
|
||||
estimatedTime: 180 // ~3 minutes typical for BGProcessingTask
|
||||
)
|
||||
) { _ in }
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else {
|
||||
completeTask(success: false)
|
||||
return
|
||||
}
|
||||
let keepAliveInterval: UInt64 = 30_000_000_000
|
||||
let maxTime: TimeInterval = 180
|
||||
var elapsedTime: TimeInterval = 0
|
||||
|
||||
while !completionState.completed &&
|
||||
self.hasChatLeases &&
|
||||
elapsedTime < maxTime {
|
||||
try? await Task.sleep(nanoseconds: keepAliveInterval)
|
||||
elapsedTime += 30
|
||||
|
||||
if !completionState.completed && self.hasChatLeases {
|
||||
self.flutterApi?.backgroundKeepAlive { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
completeTask(success: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
let task = backgroundTask
|
||||
if task != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(task)
|
||||
}
|
||||
VoiceBackgroundAudioManager.shared.deactivate()
|
||||
}
|
||||
}
|
||||
307
ios/Runner/ConduitAppIntents.swift
Normal file
307
ios/Runner/ConduitAppIntents.swift
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import AppIntents
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
enum AppIntentError: Error {
|
||||
case executionFailed(String)
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct AskConduitIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Ask Conduit"
|
||||
static var description = IntentDescription(
|
||||
"Start a Conduit chat with an optional prompt."
|
||||
)
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(
|
||||
title: "Prompt",
|
||||
requestValueDialog: IntentDialog("What should Conduit answer?")
|
||||
)
|
||||
var prompt: String?
|
||||
|
||||
init() {}
|
||||
|
||||
init(prompt: String?) {
|
||||
self.prompt = prompt
|
||||
}
|
||||
|
||||
func perform() async throws
|
||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||
{
|
||||
guard let channel = await AppIntentBridge.readyBridge() else {
|
||||
throw AppIntentError.executionFailed(appLocalized("appIntent.appNotReady", "App not ready"))
|
||||
}
|
||||
|
||||
let parameters: [String: Any] = prompt?.isEmpty == false
|
||||
? ["prompt": prompt ?? ""]
|
||||
: [:]
|
||||
let result = await channel.invokeIntent(
|
||||
identifier: "app.cogwheel.conduit.ask_chat",
|
||||
parameters: parameters,
|
||||
canonicalParameters: ["prompt": prompt ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? appLocalized("appIntent.openingChat", "Opening chat")
|
||||
return .result(value: value)
|
||||
}
|
||||
|
||||
let message = result["error"] as? String
|
||||
?? appLocalized("appIntent.unableOpenChat", "Unable to open Conduit chat")
|
||||
throw AppIntentError.executionFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct StartVoiceCallIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Start Voice Call"
|
||||
static var description = IntentDescription(
|
||||
"Start a live voice call with Conduit."
|
||||
)
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
func perform() async throws
|
||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||
{
|
||||
guard let channel = await AppIntentBridge.readyBridge() else {
|
||||
throw AppIntentError.executionFailed(appLocalized("appIntent.appNotReady", "App not ready"))
|
||||
}
|
||||
|
||||
let result = await channel.invokeIntent(
|
||||
identifier: "app.cogwheel.conduit.start_voice_call",
|
||||
parameters: [:],
|
||||
canonicalParameters: [:]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? appLocalized("appIntent.startingVoiceCall", "Starting voice call")
|
||||
return .result(value: value)
|
||||
}
|
||||
|
||||
let message = result["error"] as? String
|
||||
?? appLocalized("appIntent.unableStartVoiceCall", "Unable to start voice call")
|
||||
throw AppIntentError.executionFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ConduitSendTextIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send to Conduit"
|
||||
static var description = IntentDescription(
|
||||
"Start a Conduit chat with provided text."
|
||||
)
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(
|
||||
title: "Text",
|
||||
requestValueDialog: IntentDialog("What should Conduit process?")
|
||||
)
|
||||
var text: String?
|
||||
|
||||
func perform() async throws
|
||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||
{
|
||||
guard let channel = await AppIntentBridge.readyBridge() else {
|
||||
throw AppIntentError.executionFailed(appLocalized("appIntent.appNotReady", "App not ready"))
|
||||
}
|
||||
|
||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let result = await channel.invokeIntent(
|
||||
identifier: "app.cogwheel.conduit.send_text",
|
||||
parameters: ["text": trimmed ?? ""],
|
||||
canonicalParameters: ["text": trimmed ?? ""]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? appLocalized("appIntent.sentToConduit", "Sent to Conduit")
|
||||
return .result(value: value)
|
||||
}
|
||||
|
||||
let message = result["error"] as? String ?? appLocalized("appIntent.unableSendText", "Unable to send text")
|
||||
throw AppIntentError.executionFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ConduitSendUrlIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Link to Conduit"
|
||||
static var description = IntentDescription(
|
||||
"Send a URL into Conduit for summary or analysis."
|
||||
)
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(
|
||||
title: "URL",
|
||||
requestValueDialog: IntentDialog("Which link should Conduit analyze?")
|
||||
)
|
||||
var url: URL
|
||||
|
||||
func perform() async throws
|
||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||
{
|
||||
guard let channel = await AppIntentBridge.readyBridge() else {
|
||||
throw AppIntentError.executionFailed(appLocalized("appIntent.appNotReady", "App not ready"))
|
||||
}
|
||||
|
||||
let result = await channel.invokeIntent(
|
||||
identifier: "app.cogwheel.conduit.send_url",
|
||||
parameters: ["url": url.absoluteString],
|
||||
canonicalParameters: ["url": url.absoluteString]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
let value = result["value"] as? String ?? appLocalized("appIntent.sentLinkToConduit", "Sent link to Conduit")
|
||||
return .result(value: value)
|
||||
}
|
||||
|
||||
let message = result["error"] as? String ?? appLocalized("appIntent.unableSendLink", "Unable to send link")
|
||||
throw AppIntentError.executionFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct ConduitSendImageIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send Image to Conduit"
|
||||
static var description = IntentDescription(
|
||||
"Send an image into Conduit for analysis."
|
||||
)
|
||||
static var isDiscoverable = true
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(
|
||||
title: "Image",
|
||||
requestValueDialog: IntentDialog("Choose an image for Conduit.")
|
||||
)
|
||||
var image: IntentFile
|
||||
|
||||
func perform() async throws
|
||||
-> some IntentResult & ReturnsValue<String> & OpensIntent
|
||||
{
|
||||
guard let channel = await AppIntentBridge.readyBridge() else {
|
||||
throw AppIntentError.executionFailed(appLocalized("appIntent.appNotReady", "App not ready"))
|
||||
}
|
||||
|
||||
// Some providers omit the declared type, so fall back to the file
|
||||
// extension. Anything that still does not resolve to an image is
|
||||
// rejected rather than staged and sent as an attachment.
|
||||
let type = image.type
|
||||
?? UTType(filenameExtension: (image.filename as NSString).pathExtension)
|
||||
guard let type, type.conforms(to: .image) else {
|
||||
throw AppIntentError.executionFailed(
|
||||
appLocalized("appIntent.onlyImagesSupported", "Only image files are supported.")
|
||||
)
|
||||
}
|
||||
|
||||
let name = image.filename
|
||||
let stagedImage: AppIntentStagedImage
|
||||
if let fileURL = image.fileURL {
|
||||
stagedImage = try await AppIntentBridge.stageImageArtifact(
|
||||
fileURL: fileURL,
|
||||
filename: name
|
||||
)
|
||||
} else {
|
||||
// Some providers expose only in-memory data. Keep this fallback,
|
||||
// but prefer file-backed streaming so the 20 MB limit is enforced
|
||||
// before materializing the full image in the app process.
|
||||
stagedImage = try await AppIntentBridge.stageImageArtifact(
|
||||
data: image.data,
|
||||
filename: name
|
||||
)
|
||||
}
|
||||
let filePath = stagedImage.filePath
|
||||
var dartMayOwnStagedImage = false
|
||||
defer {
|
||||
if !dartMayOwnStagedImage {
|
||||
AppIntentBridge.removeStagedImageIfOwned(atPath: filePath)
|
||||
}
|
||||
}
|
||||
|
||||
let result = await channel.invokeIntent(
|
||||
identifier: "app.cogwheel.conduit.send_image",
|
||||
parameters: [
|
||||
"filename": name,
|
||||
"filePath": filePath,
|
||||
],
|
||||
canonicalParameters: [
|
||||
"filename": name,
|
||||
"contentDigest": stagedImage.contentDigest,
|
||||
]
|
||||
)
|
||||
|
||||
if let success = result["success"] as? Bool, success {
|
||||
// A retry stages a fresh copy but reuses the invocation ID. Dart
|
||||
// may return the cached result for the original path; transfer
|
||||
// only the exact path Dart reports owning so the duplicate copy
|
||||
// is reclaimed by this defer.
|
||||
dartMayOwnStagedImage =
|
||||
result[appIntentNativeOwnedFilePathKey] as? String == filePath
|
||||
let value = result["value"] as? String ?? appLocalized("appIntent.sentImageToConduit", "Sent image to Conduit")
|
||||
return .result(value: value)
|
||||
}
|
||||
|
||||
// A timeout, cancellation, or transport failure after dispatch is an
|
||||
// indeterminate ownership boundary. Dart may already have persisted a
|
||||
// queue row for this exact path, so native cleanup must fail safe.
|
||||
if result[appIntentNativeDispatchStateKey] as? String ==
|
||||
appIntentNativeDispatchIndeterminate {
|
||||
dartMayOwnStagedImage = true
|
||||
}
|
||||
|
||||
let message = result["error"] as? String ?? appLocalized("appIntent.unableSendImage", "Unable to send image")
|
||||
throw AppIntentError.executionFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct AppShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
return [
|
||||
AppShortcut(
|
||||
intent: AskConduitIntent(),
|
||||
phrases: [
|
||||
"Ask with \(.applicationName)",
|
||||
"Start chat in \(.applicationName)",
|
||||
"Open composer in \(.applicationName)",
|
||||
]
|
||||
),
|
||||
AppShortcut(
|
||||
intent: StartVoiceCallIntent(),
|
||||
phrases: [
|
||||
"Start voice call in \(.applicationName)",
|
||||
"Call with \(.applicationName)",
|
||||
"Begin voice chat in \(.applicationName)",
|
||||
]
|
||||
),
|
||||
AppShortcut(
|
||||
intent: ConduitSendTextIntent(),
|
||||
phrases: [
|
||||
"Send text to \(.applicationName)",
|
||||
"Share text with \(.applicationName)",
|
||||
"Summarize this in \(.applicationName)",
|
||||
]
|
||||
),
|
||||
AppShortcut(
|
||||
intent: ConduitSendUrlIntent(),
|
||||
phrases: [
|
||||
"Summarize link in \(.applicationName)",
|
||||
"Analyze link with \(.applicationName)",
|
||||
"Send URL to \(.applicationName)",
|
||||
]
|
||||
),
|
||||
AppShortcut(
|
||||
intent: ConduitSendImageIntent(),
|
||||
phrases: [
|
||||
"Send image to \(.applicationName)",
|
||||
"Analyze image with \(.applicationName)",
|
||||
"Share photo to \(.applicationName)",
|
||||
]
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
114
ios/Runner/ConduitBridgeHost.swift
Normal file
114
ios/Runner/ConduitBridgeHost.swift
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// Conduit's native feature bridges run under more than one host: the shipping
|
||||
// Flutter app (this Runner) and any other iOS host. A bridge that depends
|
||||
// only on the types in this file can be copied into any host unchanged.
|
||||
//
|
||||
// Host types stay in each host's composition root and never appear in a
|
||||
// bridge: the app delegate, engines, view controllers, scene delegates and
|
||||
// plugin registrars. `tool/check_package_boundaries.dart` enforces this for
|
||||
// every host-agnostic file it lists.
|
||||
|
||||
/// What a native feature bridge may ask of the app that hosts it.
|
||||
///
|
||||
/// Members are read on the main thread.
|
||||
protocol ConduitBridgeHost: AnyObject {
|
||||
/// Carries method channels, event channels and Pigeon APIs to Dart.
|
||||
var messenger: FlutterBinaryMessenger { get }
|
||||
|
||||
/// Root of the key window's view hierarchy. Bridges that present native UI
|
||||
/// walk its presented controllers to find the top-most one.
|
||||
var presentingViewController: UIViewController? { get }
|
||||
|
||||
/// The foreground-active window scene, for system APIs that take a scene.
|
||||
var activeWindowScene: UIWindowScene? { get }
|
||||
|
||||
/// App group shared with the share extension and the widget, if any.
|
||||
var appGroupIdentifier: String? { get }
|
||||
}
|
||||
|
||||
extension ConduitBridgeHost {
|
||||
/// Container of `appGroupIdentifier`; nil without the app group entitlement.
|
||||
var appGroupContainerURL: URL? {
|
||||
guard let appGroupIdentifier else { return nil }
|
||||
return FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
/// Preferences shared through `appGroupIdentifier`.
|
||||
var appGroupUserDefaults: UserDefaults? {
|
||||
guard let appGroupIdentifier else { return nil }
|
||||
return UserDefaults(suiteName: appGroupIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
/// A native feature bridge that a host attaches to its messenger.
|
||||
protocol ConduitBridge: AnyObject {
|
||||
/// Wires the bridge's channels on `host.messenger`.
|
||||
///
|
||||
/// Hosts call this again when they attach a new messenger, so an
|
||||
/// implementation replaces whatever it registered before.
|
||||
@MainActor func attach(to host: ConduitBridgeHost)
|
||||
}
|
||||
|
||||
/// Supplies the bridge host to entry points the host does not own, such as
|
||||
/// the CarPlay scene. The application delegate of every host conforms.
|
||||
protocol ConduitBridgeHostProvider: AnyObject {
|
||||
/// Returns the running host, starting it first if necessary, or nil when
|
||||
/// it could not start.
|
||||
@MainActor func ensureBridgeHost() -> ConduitBridgeHost?
|
||||
}
|
||||
|
||||
extension UIApplication {
|
||||
/// The host provider, when the application delegate is one.
|
||||
var conduitBridgeHostProvider: ConduitBridgeHostProvider? {
|
||||
delegate as? ConduitBridgeHostProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// App group lookup shared by every host and extension.
|
||||
enum ConduitAppGroup {
|
||||
static let infoDictionaryKey = "AppGroupId"
|
||||
|
||||
/// The group named by the bundle's `AppGroupId`, or `group.<bundle id>`.
|
||||
static func identifier(in bundle: Bundle = .main) -> String? {
|
||||
let appGroupId = bundle.object(
|
||||
forInfoDictionaryKey: infoDictionaryKey
|
||||
) as? String
|
||||
let defaultGroupId = bundle.bundleIdentifier.map { "group.\($0)" }
|
||||
return appGroupId ?? defaultGroupId
|
||||
}
|
||||
}
|
||||
|
||||
/// The registration hooks a host calls from its application delegate.
|
||||
///
|
||||
/// Flutter-only bridges (the FlutterTextInputView swizzles and the display
|
||||
/// boost) are not listed here; the Flutter host attaches them itself.
|
||||
@MainActor
|
||||
enum ConduitBridgeRegistry {
|
||||
/// Call from `application(_:didFinishLaunchingWithOptions:)` before it
|
||||
/// returns. BGTaskScheduler only accepts launch handlers registered then.
|
||||
static func applicationDidFinishLaunching() {
|
||||
BackgroundStreamingHandler.shared.registerBackgroundTasks()
|
||||
}
|
||||
|
||||
/// Attaches every host-agnostic bridge to `host`'s messenger.
|
||||
static func attachAll(to host: ConduitBridgeHost) {
|
||||
PlatformEnvironmentBridge.shared.attach(to: host)
|
||||
AppIntentBridge.attach(to: host)
|
||||
ConduitCarPlayBridge.shared.attach(to: host)
|
||||
NativeSheetBridge.shared.attach(to: host)
|
||||
NativeDropdownBridge.shared.attach(to: host)
|
||||
NativeImageViewerBridge.shared.attach(to: host)
|
||||
NativeSymbolImageBridge.shared.attach(to: host)
|
||||
NativeSttBridge.shared.attach(to: host)
|
||||
PccBridge.shared.attach(to: host)
|
||||
VoiceAudioRouteBridge.shared.attach(to: host)
|
||||
NativeIosTtsBridge.shared.attach(to: host)
|
||||
BackgroundStreamingHandler.shared.attach(to: host)
|
||||
ShareImportBridge.shared.attach(to: host)
|
||||
CookieBridge.shared.attach(to: host)
|
||||
}
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ struct ConduitCarPlayActionResult {
|
|||
)
|
||||
}
|
||||
|
||||
final class ConduitCarPlayBridge {
|
||||
final class ConduitCarPlayBridge: ConduitBridge {
|
||||
static let shared = ConduitCarPlayBridge()
|
||||
|
||||
private let channelName = "conduit/carplay"
|
||||
|
|
@ -94,7 +94,8 @@ final class ConduitCarPlayBridge {
|
|||
|
||||
private init() {}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
channel?.setMethodCallHandler(nil)
|
||||
let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
|
||||
methodChannel.setMethodCallHandler { [weak self] call, result in
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ final class ConduitCarPlaySceneDelegate: UIResponder,
|
|||
}
|
||||
|
||||
if #available(iOS 26.4, *) {
|
||||
let didStartFlutter = (UIApplication.shared.delegate as? AppDelegate)?
|
||||
.ensureCarPlayFlutterEngine() ?? false
|
||||
if !didStartFlutter {
|
||||
print("ConduitCarPlaySceneDelegate: CarPlay Flutter engine is not ready")
|
||||
let didStartHost = UIApplication.shared.conduitBridgeHostProvider?
|
||||
.ensureBridgeHost() != nil
|
||||
if !didStartHost {
|
||||
print("ConduitCarPlaySceneDelegate: CarPlay bridge host is not ready")
|
||||
}
|
||||
|
||||
didNotifyBridgeConnected = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// FLUTTER HOST ONLY. Hosts the shared Flutter engine in the app's window
|
||||
// scene. Other hosts have their own scene delegate and do not copy
|
||||
// this file.
|
||||
|
||||
@objc class ConduitSceneDelegate: FlutterSceneDelegate {
|
||||
private weak var registeredFlutterEngine: FlutterEngine?
|
||||
|
||||
|
|
@ -87,7 +91,7 @@ import UIKit
|
|||
guard let bundleIdentifier = Bundle.main.bundleIdentifier,
|
||||
url.absoluteString.hasPrefix("SharingMedia-\(bundleIdentifier)")
|
||||
else { return false }
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.notifyShareImportEvent()
|
||||
ShareImportBridge.shared.notifyPayloadReady()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
ios/Runner/CookieBridge.swift
Normal file
138
ios/Runner/CookieBridge.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import Flutter
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
/// Matches an HTTP cookie using its host-only/domain scope, Secure attribute,
|
||||
/// and RFC 6265 path boundary rules.
|
||||
func cookieMatchesUrl(cookie: HTTPCookie, url: URL) -> Bool {
|
||||
guard let host = url.host?.lowercased(), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if cookie.isSecure && url.scheme?.lowercased() != "https" {
|
||||
return false
|
||||
}
|
||||
|
||||
let rawDomain = cookie.domain.lowercased()
|
||||
let isDomainCookie = rawDomain.hasPrefix(".")
|
||||
let cookieHost = isDomainCookie
|
||||
? String(rawDomain.dropFirst())
|
||||
: rawDomain
|
||||
guard !cookieHost.isEmpty else { return false }
|
||||
|
||||
if isDomainCookie {
|
||||
guard host == cookieHost || host.hasSuffix(".\(cookieHost)") else {
|
||||
return false
|
||||
}
|
||||
} else if host != cookieHost {
|
||||
return false
|
||||
}
|
||||
|
||||
// RFC 6265 compares the request-target path as encoded octets. `URL.path`
|
||||
// decodes `%2F` into `/`, which would incorrectly broaden `/admin` to
|
||||
// match a request for `/admin%2Fpublic`.
|
||||
let encodedPath = URLComponents(
|
||||
url: url,
|
||||
resolvingAgainstBaseURL: false
|
||||
)?.percentEncodedPath ?? ""
|
||||
let requestPath = encodedPath.isEmpty ? "/" : encodedPath
|
||||
let cookiePath = cookie.path.isEmpty ? "/" : cookie.path
|
||||
guard requestPath.hasPrefix(cookiePath) else { return false }
|
||||
if requestPath == cookiePath || cookiePath.hasSuffix("/") {
|
||||
return true
|
||||
}
|
||||
|
||||
let boundary = requestPath.index(
|
||||
requestPath.startIndex,
|
||||
offsetBy: cookiePath.count
|
||||
)
|
||||
return requestPath[boundary] == "/"
|
||||
}
|
||||
|
||||
/// Collapses matching cookies to the name/value map expected by Dart.
|
||||
///
|
||||
/// WKHTTPCookieStore does not promise a useful order. Prefer the cookie with
|
||||
/// the longest matching path for duplicate names, then apply stable scope and
|
||||
/// lexical tie-breakers so store enumeration order cannot change the result.
|
||||
func cookieValuesForUrl(cookies: [HTTPCookie], url: URL) -> [String: String] {
|
||||
var selected: [String: HTTPCookie] = [:]
|
||||
for cookie in cookies where cookieMatchesUrl(cookie: cookie, url: url) {
|
||||
guard let current = selected[cookie.name] else {
|
||||
selected[cookie.name] = cookie
|
||||
continue
|
||||
}
|
||||
if cookieIsPreferred(candidate: cookie, over: current) {
|
||||
selected[cookie.name] = cookie
|
||||
}
|
||||
}
|
||||
return selected.mapValues(\.value)
|
||||
}
|
||||
|
||||
private func cookieIsPreferred(
|
||||
candidate: HTTPCookie,
|
||||
over current: HTTPCookie
|
||||
) -> Bool {
|
||||
let candidatePath = candidate.path.isEmpty ? "/" : candidate.path
|
||||
let currentPath = current.path.isEmpty ? "/" : current.path
|
||||
if candidatePath.utf8.count != currentPath.utf8.count {
|
||||
return candidatePath.utf8.count > currentPath.utf8.count
|
||||
}
|
||||
|
||||
let candidateHostOnly = !candidate.domain.hasPrefix(".")
|
||||
let currentHostOnly = !current.domain.hasPrefix(".")
|
||||
if candidateHostOnly != currentHostOnly {
|
||||
return candidateHostOnly
|
||||
}
|
||||
|
||||
if candidate.isSecure != current.isSecure {
|
||||
return candidate.isSecure
|
||||
}
|
||||
|
||||
let candidateDomain = candidate.domain.lowercased()
|
||||
let currentDomain = current.domain.lowercased()
|
||||
if candidateDomain.utf8.count != currentDomain.utf8.count {
|
||||
return candidateDomain.utf8.count > currentDomain.utf8.count
|
||||
}
|
||||
if candidatePath != currentPath {
|
||||
return candidatePath < currentPath
|
||||
}
|
||||
if candidateDomain != currentDomain {
|
||||
return candidateDomain < currentDomain
|
||||
}
|
||||
return candidate.value < current.value
|
||||
}
|
||||
|
||||
/// Reads the WebView cookie store for Dart, which cannot see it directly.
|
||||
final class CookieBridge: ConduitBridge {
|
||||
static let shared = CookieBridge()
|
||||
|
||||
private var cookieChannel: FlutterMethodChannel?
|
||||
|
||||
private init() {}
|
||||
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let cookieChannel = FlutterMethodChannel(
|
||||
name: "com.conduit.app/cookies",
|
||||
binaryMessenger: host.messenger
|
||||
)
|
||||
self.cookieChannel = cookieChannel
|
||||
|
||||
cookieChannel.setMethodCallHandler { (call, result) in
|
||||
if call.method == "getCookies" {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let urlString = args["url"] as? String,
|
||||
let url = URL(string: urlString) else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Invalid URL", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
// Get cookies from WKWebView's cookie store
|
||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||
result(cookieValuesForUrl(cookies: cookies, url: url))
|
||||
}
|
||||
} else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
// FLUTTER HOST ONLY. Works around the Flutter engine's frame-rate ramp, so it
|
||||
// is not a `ConduitBridge` and other hosts do not copy this file.
|
||||
// The Flutter app delegate attaches it directly.
|
||||
|
||||
import Flutter
|
||||
import QuartzCore
|
||||
import UIKit
|
||||
|
|
|
|||
37
ios/Runner/FlutterConduitBridgeHost.swift
Normal file
37
ios/Runner/FlutterConduitBridgeHost.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
|
||||
// FLUTTER HOST ONLY. This is the Flutter app's implementation of
|
||||
// `ConduitBridgeHost`; other hosts provide their own and do not copy
|
||||
// this file.
|
||||
|
||||
/// Exposes one Flutter engine's messenger, and the app's key window, to the
|
||||
/// native feature bridges.
|
||||
///
|
||||
/// The app delegate creates one for each engine it attaches: the shared
|
||||
/// scene engine, or the implicit engine through its application registrar.
|
||||
final class FlutterConduitBridgeHost: ConduitBridgeHost {
|
||||
let messenger: FlutterBinaryMessenger
|
||||
|
||||
init(messenger: FlutterBinaryMessenger) {
|
||||
self.messenger = messenger
|
||||
}
|
||||
|
||||
var presentingViewController: UIViewController? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.rootViewController
|
||||
}
|
||||
|
||||
var activeWindowScene: UIWindowScene? {
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }
|
||||
}
|
||||
|
||||
var appGroupIdentifier: String? {
|
||||
ConduitAppGroup.identifier()
|
||||
}
|
||||
}
|
||||
|
|
@ -139,13 +139,16 @@ private struct NativeDropdownConfiguration {
|
|||
}
|
||||
|
||||
/// Presents simple option lists with native UIKit menus/action sheets.
|
||||
final class NativeDropdownBridge: NativeDropdownHostApi {
|
||||
final class NativeDropdownBridge: ConduitBridge, NativeDropdownHostApi {
|
||||
static let shared = NativeDropdownBridge()
|
||||
|
||||
private var host: ConduitBridgeHost?
|
||||
|
||||
private init() {}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
NativeDropdownHostApiSetup.setUp(binaryMessenger: messenger, api: self)
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
self.host = host
|
||||
NativeDropdownHostApiSetup.setUp(binaryMessenger: host.messenger, api: self)
|
||||
}
|
||||
|
||||
func show(
|
||||
|
|
@ -247,13 +250,7 @@ final class NativeDropdownBridge: NativeDropdownHostApi {
|
|||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
let root = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.rootViewController
|
||||
|
||||
return topViewController(from: root)
|
||||
topViewController(from: host?.presentingViewController)
|
||||
}
|
||||
|
||||
private func topViewController(from root: UIViewController?) -> UIViewController? {
|
||||
|
|
|
|||
|
|
@ -70,15 +70,17 @@ private final class NativeImageViewerSession {
|
|||
/// Dart downloads and writes the files before presenting, so server
|
||||
/// credentials never reach this layer. Dart also deletes the files once
|
||||
/// `present` completes, which happens after dismissal.
|
||||
final class NativeImageViewerBridge: NSObject, NativeImageViewerHostApi {
|
||||
final class NativeImageViewerBridge: NSObject, ConduitBridge, NativeImageViewerHostApi {
|
||||
static let shared = NativeImageViewerBridge()
|
||||
|
||||
private var session: NativeImageViewerSession?
|
||||
private var host: ConduitBridgeHost?
|
||||
|
||||
private override init() {}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
NativeImageViewerHostApiSetup.setUp(binaryMessenger: messenger, api: self)
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
self.host = host
|
||||
NativeImageViewerHostApiSetup.setUp(binaryMessenger: host.messenger, api: self)
|
||||
}
|
||||
|
||||
func present(
|
||||
|
|
@ -193,13 +195,7 @@ final class NativeImageViewerBridge: NSObject, NativeImageViewerHostApi {
|
|||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
let root = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.rootViewController
|
||||
|
||||
return topViewController(from: root)
|
||||
topViewController(from: host?.presentingViewController)
|
||||
}
|
||||
|
||||
private func topViewController(from root: UIViewController?) -> UIViewController? {
|
||||
|
|
|
|||
406
ios/Runner/NativeIosTtsBridge.swift
Normal file
406
ios/Runner/NativeIosTtsBridge.swift
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
import AVFoundation
|
||||
import Flutter
|
||||
|
||||
private let nativeIosTtsMethodChannelName = "app.cogwheel.conduit/native_ios_tts"
|
||||
private let nativeIosTtsEventChannelName = "app.cogwheel.conduit/native_ios_tts/events"
|
||||
|
||||
final class NativeIosTtsStartAcknowledgement {
|
||||
private let lock = NSLock()
|
||||
private var completion: FlutterResult?
|
||||
private var timeoutTimer: Timer?
|
||||
|
||||
init(completion: @escaping FlutterResult) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func armTimeout(after seconds: TimeInterval, onTimeout: @escaping () -> Void) {
|
||||
let timer = Timer(timeInterval: seconds, repeats: false) { _ in
|
||||
onTimeout()
|
||||
}
|
||||
lock.lock()
|
||||
guard completion != nil else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
timeoutTimer = timer
|
||||
lock.unlock()
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resolve(started: Bool) -> Bool {
|
||||
lock.lock()
|
||||
guard let completion else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
self.completion = nil
|
||||
let timer = timeoutTimer
|
||||
timeoutTimer = nil
|
||||
lock.unlock()
|
||||
timer?.invalidate()
|
||||
completion(started)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
final class NativeIosTtsPendingStarts {
|
||||
private let lock = NSLock()
|
||||
private var acknowledgements: [
|
||||
ObjectIdentifier: NativeIosTtsStartAcknowledgement
|
||||
] = [:]
|
||||
|
||||
func register(
|
||||
_ acknowledgement: NativeIosTtsStartAcknowledgement,
|
||||
for utteranceId: ObjectIdentifier
|
||||
) {
|
||||
lock.lock()
|
||||
acknowledgements[utteranceId] = acknowledgement
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resolve(_ utteranceId: ObjectIdentifier, started: Bool) -> Bool {
|
||||
lock.lock()
|
||||
let acknowledgement = acknowledgements.removeValue(forKey: utteranceId)
|
||||
lock.unlock()
|
||||
return acknowledgement?.resolve(started: started) ?? false
|
||||
}
|
||||
|
||||
func resolveAll(started: Bool) {
|
||||
lock.lock()
|
||||
let pending = Array(acknowledgements.values)
|
||||
acknowledgements.removeAll(keepingCapacity: false)
|
||||
lock.unlock()
|
||||
pending.forEach { $0.resolve(started: started) }
|
||||
}
|
||||
}
|
||||
|
||||
final class NativeIosTtsBridge: NSObject, ConduitBridge, FlutterStreamHandler, AVSpeechSynthesizerDelegate {
|
||||
static let shared = NativeIosTtsBridge()
|
||||
private static let startAcknowledgementTimeout: TimeInterval = 2
|
||||
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let pendingStarts = NativeIosTtsPendingStarts()
|
||||
private var methodChannel: FlutterMethodChannel?
|
||||
private var eventSink: FlutterEventSink?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
synthesizer.usesApplicationAudioSession = true
|
||||
synthesizer.delegate = self
|
||||
}
|
||||
|
||||
deinit {}
|
||||
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: nativeIosTtsMethodChannelName,
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
self.methodChannel = methodChannel
|
||||
methodChannel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handle(call: call, result: result)
|
||||
}
|
||||
|
||||
FlutterEventChannel(
|
||||
name: nativeIosTtsEventChannelName,
|
||||
binaryMessenger: messenger
|
||||
).setStreamHandler(self)
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
eventSink = events
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handle(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "isAvailable":
|
||||
result(true)
|
||||
case "getVoices":
|
||||
loadVoicesForPicker(result: result)
|
||||
case "speak":
|
||||
guard let arguments = call.arguments as? [String: Any],
|
||||
let text = arguments["text"] as? String,
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
|
||||
if arguments["voiceCall"] as? Bool == true {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(
|
||||
true,
|
||||
options: .notifyOthersOnDeactivation
|
||||
)
|
||||
} catch {
|
||||
result(FlutterError(
|
||||
code: "AUDIO_SESSION_ACTIVATION_FAILED",
|
||||
message: "Unable to activate voice-call audio for speech",
|
||||
details: error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if synthesizer.isSpeaking || synthesizer.isPaused {
|
||||
resolveAllPendingStarts(started: false)
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
|
||||
let utterance = AVSpeechUtterance(string: text)
|
||||
if let identifier = arguments["voiceIdentifier"] as? String,
|
||||
!identifier.isEmpty,
|
||||
let voice = resolveVoice(identifier) {
|
||||
utterance.voice = voice
|
||||
}
|
||||
utterance.rate = Self.speechRate(from: arguments["rate"])
|
||||
utterance.pitchMultiplier = Self.floatValue(
|
||||
arguments["pitch"],
|
||||
fallback: 1.0,
|
||||
min: 0.5,
|
||||
max: 2.0
|
||||
)
|
||||
utterance.volume = Self.floatValue(
|
||||
arguments["volume"],
|
||||
fallback: 1.0,
|
||||
min: 0.0,
|
||||
max: 1.0
|
||||
)
|
||||
let utteranceId = ObjectIdentifier(utterance)
|
||||
let acknowledgement = NativeIosTtsStartAcknowledgement(
|
||||
completion: result
|
||||
)
|
||||
pendingStarts.register(acknowledgement, for: utteranceId)
|
||||
acknowledgement.armTimeout(
|
||||
after: Self.startAcknowledgementTimeout
|
||||
) { [weak self] in
|
||||
guard let self,
|
||||
self.pendingStarts.resolve(
|
||||
utteranceId,
|
||||
started: false
|
||||
) else { return }
|
||||
self.synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
synthesizer.speak(utterance)
|
||||
case "stop":
|
||||
resolveAllPendingStarts(started: false)
|
||||
result(synthesizer.stopSpeaking(at: .immediate))
|
||||
case "pause":
|
||||
result(synthesizer.pauseSpeaking(at: .word))
|
||||
case "resume":
|
||||
result(synthesizer.continueSpeaking())
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadVoicesForPicker(result: @escaping FlutterResult) {
|
||||
if #available(iOS 17.0, *),
|
||||
AVSpeechSynthesizer.personalVoiceAuthorizationStatus == .notDetermined {
|
||||
AVSpeechSynthesizer.requestPersonalVoiceAuthorization { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
result(self?.availableVoicePayloads() ?? [])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result(availableVoicePayloads())
|
||||
}
|
||||
|
||||
private func availableVoicePayloads() -> [[String: Any]] {
|
||||
AVSpeechSynthesisVoice.speechVoices()
|
||||
.sorted { left, right in
|
||||
let leftLanguage = left.language.localizedCaseInsensitiveCompare(right.language)
|
||||
if leftLanguage != .orderedSame {
|
||||
return leftLanguage == .orderedAscending
|
||||
}
|
||||
|
||||
let leftName = left.name.localizedCaseInsensitiveCompare(right.name)
|
||||
if leftName != .orderedSame {
|
||||
return leftName == .orderedAscending
|
||||
}
|
||||
|
||||
return left.identifier.localizedCaseInsensitiveCompare(right.identifier) == .orderedAscending
|
||||
}
|
||||
.map(voicePayload)
|
||||
}
|
||||
|
||||
private func voicePayload(_ voice: AVSpeechSynthesisVoice) -> [String: Any] {
|
||||
var payload: [String: Any] = [
|
||||
"id": voice.identifier,
|
||||
"identifier": voice.identifier,
|
||||
"name": voice.name,
|
||||
"displayName": displayName(for: voice),
|
||||
"locale": voice.language,
|
||||
"language": voice.language,
|
||||
"languageName": Locale.current.localizedString(forIdentifier: voice.language) ?? voice.language,
|
||||
"quality": voice.quality.rawValue,
|
||||
"qualityName": qualityName(voice.quality),
|
||||
"gender": voice.gender.rawValue,
|
||||
]
|
||||
|
||||
if #available(iOS 17.0, *) {
|
||||
let traits = voice.voiceTraits
|
||||
let isPersonalVoice = traits.contains(.isPersonalVoice)
|
||||
let isNoveltyVoice = traits.contains(.isNoveltyVoice)
|
||||
payload["isPersonalVoice"] = isPersonalVoice
|
||||
payload["isNoveltyVoice"] = isNoveltyVoice
|
||||
payload["traits"] = voiceTraitNames(
|
||||
isPersonalVoice: isPersonalVoice,
|
||||
isNoveltyVoice: isNoveltyVoice
|
||||
)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
private func displayName(for voice: AVSpeechSynthesisVoice) -> String {
|
||||
if #available(iOS 17.0, *) {
|
||||
if voice.voiceTraits.contains(.isPersonalVoice) {
|
||||
return "\(voice.name) (Personal Voice)"
|
||||
}
|
||||
if voice.voiceTraits.contains(.isNoveltyVoice) {
|
||||
return "\(voice.name) (Novelty)"
|
||||
}
|
||||
}
|
||||
|
||||
return voice.name
|
||||
}
|
||||
|
||||
private func voiceTraitNames(isPersonalVoice: Bool, isNoveltyVoice: Bool) -> [String] {
|
||||
var names: [String] = []
|
||||
if isPersonalVoice {
|
||||
names.append("personal")
|
||||
}
|
||||
if isNoveltyVoice {
|
||||
names.append("novelty")
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
private func resolveVoice(_ requested: String) -> AVSpeechSynthesisVoice? {
|
||||
let trimmed = requested.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if let voice = AVSpeechSynthesisVoice(identifier: trimmed) {
|
||||
return voice
|
||||
}
|
||||
|
||||
let normalized = trimmed.lowercased()
|
||||
if let exact = AVSpeechSynthesisVoice.speechVoices().first(where: { voice in
|
||||
voice.identifier.lowercased() == normalized ||
|
||||
voice.name.lowercased() == normalized ||
|
||||
voice.language.lowercased() == normalized
|
||||
}) {
|
||||
return exact
|
||||
}
|
||||
|
||||
return AVSpeechSynthesisVoice(language: trimmed)
|
||||
}
|
||||
|
||||
private func qualityName(_ quality: AVSpeechSynthesisVoiceQuality) -> String {
|
||||
switch quality {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .enhanced:
|
||||
return "Enhanced"
|
||||
case .premium:
|
||||
return "Premium"
|
||||
@unknown default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private static func speechRate(from raw: Any?) -> Float {
|
||||
let requested = floatValue(
|
||||
raw,
|
||||
fallback: AVSpeechUtteranceDefaultSpeechRate,
|
||||
min: AVSpeechUtteranceMinimumSpeechRate,
|
||||
max: AVSpeechUtteranceMaximumSpeechRate
|
||||
)
|
||||
return requested
|
||||
}
|
||||
|
||||
private static func floatValue(
|
||||
_ raw: Any?,
|
||||
fallback: Float,
|
||||
min: Float,
|
||||
max: Float
|
||||
) -> Float {
|
||||
let value: Float
|
||||
if let number = raw as? NSNumber {
|
||||
value = number.floatValue
|
||||
} else if let double = raw as? Double {
|
||||
value = Float(double)
|
||||
} else if let string = raw as? String, let parsed = Float(string) {
|
||||
value = parsed
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
return Swift.min(Swift.max(value, min), max)
|
||||
}
|
||||
|
||||
private func emit(_ event: [String: Any]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.eventSink?(event)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func resolvePendingStart(
|
||||
_ utteranceId: ObjectIdentifier,
|
||||
started: Bool
|
||||
) -> Bool {
|
||||
pendingStarts.resolve(utteranceId, started: started)
|
||||
}
|
||||
|
||||
private func resolveAllPendingStarts(started: Bool) {
|
||||
pendingStarts.resolveAll(started: started)
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didStart utterance: AVSpeechUtterance) {
|
||||
guard resolvePendingStart(ObjectIdentifier(utterance), started: true)
|
||||
else { return }
|
||||
emit(["type": "start"])
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
_ = resolvePendingStart(ObjectIdentifier(utterance), started: false)
|
||||
emit(["type": "complete"])
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
_ = resolvePendingStart(ObjectIdentifier(utterance), started: false)
|
||||
emit(["type": "cancel"])
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
|
||||
emit(["type": "pause"])
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didContinue utterance: AVSpeechUtterance) {
|
||||
emit(["type": "continue"])
|
||||
}
|
||||
|
||||
func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
willSpeakRangeOfSpeechString characterRange: NSRange,
|
||||
utterance: AVSpeechUtterance
|
||||
) {
|
||||
emit([
|
||||
"type": "progress",
|
||||
"start": characterRange.location,
|
||||
"end": characterRange.location + characterRange.length,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
// FLUTTER HOST ONLY. Swizzles Flutter's FlutterTextInputView to attach a
|
||||
// native accessory bar, so it is not a `ConduitBridge` and other
|
||||
// hosts do not copy this file. The Flutter app delegate attaches it directly.
|
||||
|
||||
import Flutter
|
||||
import ObjectiveC.runtime
|
||||
import UIKit
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// FLUTTER HOST ONLY. Swizzles Flutter's FlutterTextInputView to intercept
|
||||
// paste, so it is not a `ConduitBridge` and other hosts do not copy
|
||||
// this file. The Flutter app delegate attaches it directly.
|
||||
|
||||
import Darwin
|
||||
import Flutter
|
||||
import Foundation
|
||||
|
|
|
|||
|
|
@ -1115,7 +1115,7 @@ func applyNativeSheetModelUpdateSynchronouslyOnMain(
|
|||
}
|
||||
}
|
||||
|
||||
final class NativeSheetBridge: NativeSheetHostApi {
|
||||
final class NativeSheetBridge: ConduitBridge, NativeSheetHostApi {
|
||||
static let shared = NativeSheetBridge()
|
||||
|
||||
private enum ActiveSheetMode {
|
||||
|
|
@ -1145,9 +1145,13 @@ final class NativeSheetBridge: NativeSheetHostApi {
|
|||
private weak var activeModelSelectorController: NativeModelSelectorTableViewController?
|
||||
private var activeModelSelectorPresentationId: String?
|
||||
|
||||
private var host: ConduitBridgeHost?
|
||||
|
||||
private init() {}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
self.host = host
|
||||
let messenger = host.messenger
|
||||
flutterApi = NativeSheetFlutterApi(binaryMessenger: messenger)
|
||||
NativeSheetHostApiSetup.setUp(
|
||||
binaryMessenger: messenger,
|
||||
|
|
@ -1203,10 +1207,7 @@ final class NativeSheetBridge: NativeSheetHostApi {
|
|||
func requestAppStoreReview() throws -> Bool {
|
||||
let request = {
|
||||
MainActor.assumeIsolated {
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive })
|
||||
else {
|
||||
guard let scene = self.host?.activeWindowScene else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -2217,13 +2218,7 @@ final class NativeSheetBridge: NativeSheetHostApi {
|
|||
}
|
||||
|
||||
private func topViewController() -> UIViewController? {
|
||||
let root = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first { $0.isKeyWindow }?
|
||||
.rootViewController
|
||||
|
||||
return topViewController(from: root)
|
||||
topViewController(from: host?.presentingViewController)
|
||||
}
|
||||
|
||||
private func topViewController(from root: UIViewController?) -> UIViewController? {
|
||||
|
|
|
|||
|
|
@ -1244,7 +1244,7 @@ final class NativeCallKitCallOwnership {
|
|||
}
|
||||
}
|
||||
|
||||
final class NativeSttBridge: NSObject, FlutterStreamHandler {
|
||||
final class NativeSttBridge: NSObject, ConduitBridge, FlutterStreamHandler {
|
||||
static let shared = NativeSttBridge()
|
||||
private static let stopAcknowledgementTimeoutNanoseconds: UInt64 =
|
||||
1_000_000_000
|
||||
|
|
@ -1281,7 +1281,8 @@ final class NativeSttBridge: NSObject, FlutterStreamHandler {
|
|||
_ = lifecycle.beginStop()
|
||||
}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: nativeSttMethodChannelName,
|
||||
binaryMessenger: messenger
|
||||
|
|
@ -2845,7 +2846,12 @@ private final class SFSpeechNativeSttSession: NativeSttSession {
|
|||
// CallKit invokes these callbacks natively even while Flutter is suspended.
|
||||
// Keep the response-wait microphone owner aligned with the system audio
|
||||
// session, and always fulfill actions that the plugin delegates to the app.
|
||||
extension AppDelegate: CallkitIncomingAppDelegate {
|
||||
//
|
||||
// flutter_callkit_incoming looks the callbacks up on the application
|
||||
// delegate, so each host conforms its own delegate with an empty extension.
|
||||
protocol NativeSttCallKitAppDelegate: CallkitIncomingAppDelegate {}
|
||||
|
||||
extension NativeSttCallKitAppDelegate {
|
||||
func onAccept(
|
||||
_ call: flutter_callkit_incoming.Call,
|
||||
_ action: CXAnswerCallAction
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import UIKit
|
|||
/// UIKit draws the glyph, so Conduit never bundles Apple's artwork and the
|
||||
/// symbol always matches the running system. Flutter-drawn avatars cache the
|
||||
/// result, which keeps this off the scrolling path.
|
||||
final class NativeSymbolImageBridge {
|
||||
final class NativeSymbolImageBridge: ConduitBridge {
|
||||
static let shared = NativeSymbolImageBridge()
|
||||
|
||||
/// Guards against a malformed or hostile request asking for a canvas that
|
||||
|
|
@ -18,7 +18,8 @@ final class NativeSymbolImageBridge {
|
|||
|
||||
private init() {}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "conduit/native_symbol_image",
|
||||
binaryMessenger: messenger
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ private final class PccResponseState {
|
|||
var emittedContent = false
|
||||
}
|
||||
|
||||
final class PccBridge: PccHostApi {
|
||||
final class PccBridge: ConduitBridge, PccHostApi {
|
||||
static let shared = PccBridge()
|
||||
|
||||
private static let logger = Logger(
|
||||
|
|
@ -316,7 +316,8 @@ final class PccBridge: PccHostApi {
|
|||
|
||||
private init() {}
|
||||
|
||||
func configure(messenger: FlutterBinaryMessenger) {
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
flutterApi = PccFlutterApi(binaryMessenger: messenger)
|
||||
PccHostApiSetup.setUp(binaryMessenger: messenger, api: self)
|
||||
}
|
||||
|
|
|
|||
28
ios/Runner/PlatformEnvironmentBridge.swift
Normal file
28
ios/Runner/PlatformEnvironmentBridge.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Flutter
|
||||
import Foundation
|
||||
|
||||
private let platformEnvironmentChannelName = "app.cogwheel.conduit/platform_environment"
|
||||
|
||||
/// Answers questions about the process the app runs in.
|
||||
final class PlatformEnvironmentBridge: ConduitBridge {
|
||||
static let shared = PlatformEnvironmentBridge()
|
||||
|
||||
private var channel: FlutterMethodChannel?
|
||||
|
||||
private init() {}
|
||||
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: platformEnvironmentChannelName,
|
||||
binaryMessenger: host.messenger
|
||||
)
|
||||
channel.setMethodCallHandler { call, result in
|
||||
guard call.method == "isIOSAppOnMac" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
result(ProcessInfo.processInfo.isiOSAppOnMac)
|
||||
}
|
||||
self.channel = channel
|
||||
}
|
||||
}
|
||||
241
ios/Runner/ShareImportBridge.swift
Normal file
241
ios/Runner/ShareImportBridge.swift
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import Darwin
|
||||
import Flutter
|
||||
import Foundation
|
||||
|
||||
private let conduitShareChannelName = "conduit/share_receiver_text"
|
||||
|
||||
func nativeSharedPayloadTypeIsText(_ type: Any?) -> Bool {
|
||||
if let type = type as? String {
|
||||
return type == "text" || type == "url"
|
||||
}
|
||||
if let type = type as? NSNumber {
|
||||
// JSON booleans bridge through NSNumber, where false.intValue is 0 and
|
||||
// true.intValue is 1. They are not valid share-media type codes.
|
||||
guard CFGetTypeID(type) != CFBooleanGetTypeID() else { return false }
|
||||
let value = type.intValue
|
||||
return value == 0 || value == 1 || value == 5
|
||||
}
|
||||
if let type = type as? Int {
|
||||
return type == 0 || type == 1 || type == 5
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Builds the acknowledgement-bearing payload only from a complete native
|
||||
/// record. Returning content without its durable status identifier would make
|
||||
/// the record impossible for Dart to acknowledge and permanently wedge the
|
||||
/// pending-share signal.
|
||||
func nativeValidatedShareImportPayload(
|
||||
rawItems: [[String: Any]],
|
||||
message: String?,
|
||||
status: [String: Any]?,
|
||||
shareStagingDirectoryPath: String?
|
||||
) -> [String: Any]? {
|
||||
guard let id = (status?["id"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!id.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var textParts: [String] = []
|
||||
var seenText = Set<String>()
|
||||
var filePaths: [String] = []
|
||||
var seenFilePaths = Set<String>()
|
||||
|
||||
func addText(_ value: String?) {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let trimmed, !trimmed.isEmpty,
|
||||
seenText.insert(trimmed).inserted else { return }
|
||||
textParts.append(trimmed)
|
||||
}
|
||||
|
||||
func addFilePath(_ value: String?) -> Bool {
|
||||
guard let value = value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines
|
||||
), !value.isEmpty else { return false }
|
||||
|
||||
let path: String
|
||||
if value.lowercased().hasPrefix("file:") {
|
||||
guard let url = URL(string: value), url.isFileURL,
|
||||
url.host == nil || url.host?.isEmpty == true ||
|
||||
url.host?.lowercased() == "localhost" else {
|
||||
return false
|
||||
}
|
||||
path = url.standardizedFileURL.path
|
||||
} else {
|
||||
guard value.hasPrefix("/") else { return false }
|
||||
path = URL(fileURLWithPath: value).standardizedFileURL.path
|
||||
}
|
||||
guard !path.isEmpty, path.hasPrefix("/"),
|
||||
let rawRoot = shareStagingDirectoryPath,
|
||||
rawRoot.hasPrefix("/") else { return false }
|
||||
let root = URL(fileURLWithPath: rawRoot, isDirectory: true)
|
||||
.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
let candidate = URL(fileURLWithPath: path).standardizedFileURL
|
||||
let canonicalCandidate = candidate.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
guard canonicalCandidate.deletingLastPathComponent().path == root.path else {
|
||||
return false
|
||||
}
|
||||
var rootMetadata = stat()
|
||||
var candidateMetadata = stat()
|
||||
guard root.path.withCString({ lstat($0, &rootMetadata) }) == 0,
|
||||
rootMetadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR),
|
||||
candidate.path.withCString({ lstat($0, &candidateMetadata) }) == 0,
|
||||
candidateMetadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else {
|
||||
return false
|
||||
}
|
||||
if seenFilePaths.insert(canonicalCandidate.path).inserted {
|
||||
filePaths.append(canonicalCandidate.path)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
addText(message)
|
||||
for item in rawItems {
|
||||
// Every encoded media entry must carry both its type and content. Treat a
|
||||
// partially decoded map as corruption instead of silently returning an
|
||||
// unacknowledgeable or truncated handoff.
|
||||
guard item["type"] != nil,
|
||||
let value = item["path"] as? String ?? item["value"] as? String,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if nativeSharedPayloadTypeIsText(item["type"]) {
|
||||
addText(value)
|
||||
} else {
|
||||
guard addFilePath(value) else { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
guard !textParts.isEmpty || !filePaths.isEmpty else { return nil }
|
||||
var payload: [String: Any] = [
|
||||
"id": id,
|
||||
"filePaths": filePaths,
|
||||
]
|
||||
if !textParts.isEmpty {
|
||||
payload["text"] = textParts.joined(separator: "\n")
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Hands payloads staged by the share extension in the app group to Dart.
|
||||
final class ShareImportBridge: ConduitBridge {
|
||||
static let shared = ShareImportBridge()
|
||||
|
||||
private var host: ConduitBridgeHost?
|
||||
private var shareImportChannel: FlutterMethodChannel?
|
||||
|
||||
private init() {}
|
||||
|
||||
private lazy var shareEnvelopeStore: NativeShareEnvelopeStore? = {
|
||||
guard let container = host?.appGroupContainerURL else { return nil }
|
||||
return NativeShareEnvelopeStore(
|
||||
containerURL: container,
|
||||
legacyDefaults: host?.appGroupUserDefaults
|
||||
)
|
||||
}()
|
||||
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
self.host = host
|
||||
let shareImportChannel = FlutterMethodChannel(
|
||||
name: conduitShareChannelName,
|
||||
binaryMessenger: host.messenger
|
||||
)
|
||||
self.shareImportChannel = shareImportChannel
|
||||
shareImportChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self = self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
switch call.method {
|
||||
case "pendingShareImportStatus":
|
||||
result(self.pendingShareImportStatus())
|
||||
case "takePendingShareImportPayload":
|
||||
result(self.takePendingShareImportPayload())
|
||||
case "ackPendingShareImportPayload":
|
||||
let arguments = call.arguments as? [String: Any]
|
||||
result(self.acknowledgePendingShareImportPayload(
|
||||
id: arguments?["id"] as? String
|
||||
))
|
||||
case "shareStagingDirectoryPath":
|
||||
result(self.shareStagingDirectoryPath())
|
||||
case "clearShareImportStatus":
|
||||
let arguments = call.arguments as? [String: Any]
|
||||
self.clearShareImportStatus(id: arguments?["id"] as? String)
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells Dart that the share extension staged a new payload.
|
||||
func notifyPayloadReady() {
|
||||
shareImportChannel?.invokeMethod("stagedSharePayloadReady", arguments: nil)
|
||||
}
|
||||
|
||||
private func shareStagingDirectoryPath() -> String? {
|
||||
guard let container = host?.appGroupContainerURL else { return nil }
|
||||
let directory = container.appendingPathComponent(
|
||||
nativeShareStagingDirectoryName,
|
||||
isDirectory: true
|
||||
)
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let values = try directory.resourceValues(
|
||||
forKeys: [.isDirectoryKey, .isSymbolicLinkKey]
|
||||
)
|
||||
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
||||
return nil
|
||||
}
|
||||
return directory.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func pendingShareImportStatus() -> [String: Any]? {
|
||||
guard let store = shareEnvelopeStore,
|
||||
let data = try? store.currentStatusJSON() else {
|
||||
return nil
|
||||
}
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
private func clearShareImportStatus(id: String?) {
|
||||
guard let store = shareEnvelopeStore else { return }
|
||||
_ = try? store.clearStatus(id: id)
|
||||
}
|
||||
|
||||
private func takePendingShareImportPayload() -> [String: Any]? {
|
||||
guard let store = shareEnvelopeStore,
|
||||
let snapshot = try? store.takeCurrent(),
|
||||
let rawItems = (try? JSONSerialization.jsonObject(
|
||||
with: snapshot.envelope.itemsJSON
|
||||
))
|
||||
as? [[String: Any]],
|
||||
let status = (try? JSONSerialization.jsonObject(
|
||||
with: snapshot.statusJSON
|
||||
)) as? [String: Any],
|
||||
let payload = nativeValidatedShareImportPayload(
|
||||
rawItems: rawItems,
|
||||
message: snapshot.envelope.message,
|
||||
status: status,
|
||||
shareStagingDirectoryPath: shareStagingDirectoryPath()
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func acknowledgePendingShareImportPayload(id: String?) -> Bool {
|
||||
guard let id, let store = shareEnvelopeStore else { return false }
|
||||
return (try? store.acknowledge(id: id)) == true
|
||||
}
|
||||
}
|
||||
184
ios/Runner/VoiceAudioRouteBridge.swift
Normal file
184
ios/Runner/VoiceAudioRouteBridge.swift
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import AVFoundation
|
||||
import Flutter
|
||||
|
||||
private let conduitVoiceAudioRouteChannelName = "app.cogwheel.conduit/voice_audio_route"
|
||||
|
||||
final class VoiceAudioRouteBridge: ConduitBridge {
|
||||
static let shared = VoiceAudioRouteBridge()
|
||||
|
||||
private var methodChannel: FlutterMethodChannel?
|
||||
|
||||
private init() {}
|
||||
|
||||
deinit {}
|
||||
|
||||
func attach(to host: ConduitBridgeHost) {
|
||||
let messenger = host.messenger
|
||||
let channel = FlutterMethodChannel(
|
||||
name: conduitVoiceAudioRouteChannelName,
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
methodChannel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
switch call.method {
|
||||
case "preferBluetoothHfpInput":
|
||||
result(self.preferBluetoothHfpInput())
|
||||
case "clearPreferredInput":
|
||||
result(self.clearPreferredInput())
|
||||
case "setSpeakerphoneEnabled":
|
||||
let enabled = (call.arguments as? [String: Any])?["enabled"] as? Bool ?? false
|
||||
result(self.setSpeakerphoneEnabled(enabled))
|
||||
case "currentRoute":
|
||||
result(self.currentRoutePayload(operation: "currentRoute"))
|
||||
case "setActiveCallKitCallId":
|
||||
guard let callId =
|
||||
(call.arguments as? [String: Any])?["callId"] as? String
|
||||
else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
result(NativeSttBridge.shared.setActiveCallKitCallId(callId))
|
||||
case "beginResponseWaitCapture":
|
||||
let callKitCallId =
|
||||
(call.arguments as? [String: Any])?["callKitCallId"] as? String
|
||||
guard let transition = NativeSttBridge.shared
|
||||
.prepareResponseWaitCapture(callKitCallId: callKitCallId)
|
||||
else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
Task {
|
||||
let accepted = await NativeSttBridge.shared
|
||||
.finishResponseWaitCapture(transition)
|
||||
await MainActor.run {
|
||||
result(accepted)
|
||||
}
|
||||
}
|
||||
case "endResponseWaitCapture":
|
||||
let shutdown = NativeSttBridge.shared
|
||||
.prepareEndResponseWaitCapture()
|
||||
Task {
|
||||
let stopped = await NativeSttBridge.shared
|
||||
.finishEndResponseWaitCapture(shutdown)
|
||||
await MainActor.run {
|
||||
result(stopped)
|
||||
}
|
||||
}
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func responseWaitCaptureFailed(_ error: Error) {
|
||||
let errorDomain = (error as NSError).domain
|
||||
let errorCode = (error as NSError).code
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.methodChannel?.invokeMethod(
|
||||
"responseWaitCaptureFailed",
|
||||
arguments: [
|
||||
"message": "iOS response-wait audio capture stopped after an audio route change.",
|
||||
"domain": errorDomain,
|
||||
"code": errorCode,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func preferBluetoothHfpInput() -> [String: Any] {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let availableInputs = session.availableInputs ?? []
|
||||
guard let bluetoothInput = availableInputs.first(where: { $0.portType == .bluetoothHFP }) else {
|
||||
var payload = currentRoutePayload(operation: "preferBluetoothHfpInput")
|
||||
payload["selected"] = false
|
||||
payload["reason"] = "bluetooth-hfp-input-unavailable"
|
||||
payload["availableInputs"] = availableInputs.map { portPayload($0) }
|
||||
return payload
|
||||
}
|
||||
|
||||
do {
|
||||
try session.setPreferredInput(bluetoothInput)
|
||||
var payload = currentRoutePayload(
|
||||
operation: "preferBluetoothHfpInput",
|
||||
preferredInput: bluetoothInput
|
||||
)
|
||||
payload["selected"] = true
|
||||
payload["availableInputs"] = availableInputs.map { portPayload($0) }
|
||||
return payload
|
||||
} catch {
|
||||
var payload = currentRoutePayload(
|
||||
operation: "preferBluetoothHfpInput",
|
||||
preferredInput: bluetoothInput
|
||||
)
|
||||
payload["selected"] = false
|
||||
payload["error"] = error.localizedDescription
|
||||
payload["availableInputs"] = availableInputs.map { portPayload($0) }
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPreferredInput() -> [String: Any] {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setPreferredInput(nil)
|
||||
var payload = currentRoutePayload(operation: "clearPreferredInput")
|
||||
payload["cleared"] = true
|
||||
return payload
|
||||
} catch {
|
||||
var payload = currentRoutePayload(operation: "clearPreferredInput")
|
||||
payload["cleared"] = false
|
||||
payload["error"] = error.localizedDescription
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func setSpeakerphoneEnabled(_ enabled: Bool) -> [String: Any] {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.overrideOutputAudioPort(enabled ? .speaker : .none)
|
||||
var payload = currentRoutePayload(operation: "setSpeakerphoneEnabled")
|
||||
payload["enabled"] = enabled
|
||||
return payload
|
||||
} catch {
|
||||
var payload = currentRoutePayload(operation: "setSpeakerphoneEnabled")
|
||||
payload["enabled"] = enabled
|
||||
payload["error"] = error.localizedDescription
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
private func currentRoutePayload(
|
||||
operation: String,
|
||||
preferredInput: AVAudioSessionPortDescription? = nil
|
||||
) -> [String: Any] {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
var payload: [String: Any] = [
|
||||
"operation": operation,
|
||||
"category": session.category.rawValue,
|
||||
"mode": session.mode.rawValue,
|
||||
"sampleRate": session.sampleRate,
|
||||
"currentInputs": session.currentRoute.inputs.map { portPayload($0) },
|
||||
"currentOutputs": session.currentRoute.outputs.map { portPayload($0) },
|
||||
]
|
||||
|
||||
if let preferredInput {
|
||||
payload["preferredInput"] = portPayload(preferredInput)
|
||||
} else if let preferredInput = session.preferredInput {
|
||||
payload["preferredInput"] = portPayload(preferredInput)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
private func portPayload(_ port: AVAudioSessionPortDescription) -> [String: Any] {
|
||||
[
|
||||
"type": port.portType.rawValue,
|
||||
"uid": port.uid,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,16 @@ import XCTest
|
|||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testAppDelegateConformsToCallKitCallbacksAtRuntime() {
|
||||
// flutter_callkit_incoming reaches the callbacks with
|
||||
// `UIApplication.shared.delegate as? CallkitIncomingAppDelegate`, a Swift
|
||||
// protocol cast. The default implementations live in an extension of
|
||||
// NativeSttCallKitAppDelegate, so the empty conformance on AppDelegate
|
||||
// must still satisfy that cast.
|
||||
let delegate: AnyObject = AppDelegate()
|
||||
XCTAssertNotNil(delegate as? NativeSttCallKitAppDelegate)
|
||||
}
|
||||
|
||||
func testPccSnapshotDeltaEmitsOnlyNewContent() throws {
|
||||
XCTAssertEqual(
|
||||
try pccSnapshotDelta(previous: "Hello", snapshot: "Hello world"),
|
||||
|
|
|
|||
43
test/tool/check_package_boundaries_test.dart
Normal file
43
test/tool/check_package_boundaries_test.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:checks/checks.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../tool/check_package_boundaries.dart';
|
||||
|
||||
void main() {
|
||||
group('stripSwiftComments', () {
|
||||
test('drops a trailing line comment', () {
|
||||
check(stripSwiftComments('let a = 1 // AppDelegate'))
|
||||
.not((it) => it.contains('AppDelegate'));
|
||||
});
|
||||
|
||||
test('keeps code after a // inside a string literal', () {
|
||||
const line = 'let u = "https://x.test"; _ = AppDelegate.self // note';
|
||||
final stripped = stripSwiftComments(line);
|
||||
check(stripped).contains('AppDelegate');
|
||||
check(stripped).not((it) => it.contains('note'));
|
||||
});
|
||||
|
||||
test('handles escaped quotes inside strings', () {
|
||||
const line = r'let s = "a\"//b"; x() // c';
|
||||
check(stripSwiftComments(line)).startsWith(r'let s = "a\"//b"; x() ');
|
||||
});
|
||||
|
||||
test('keeps code after a block comment containing //', () {
|
||||
const line = '/* https://example.test */ let _ = AppDelegate.self';
|
||||
check(stripSwiftComments(line)).contains('AppDelegate');
|
||||
});
|
||||
|
||||
test('blanks nested multi-line block comments and keeps line numbers', () {
|
||||
const source = 'a\n/* AppDelegate\n/* nested */ AppDelegate\n*/ b\nc';
|
||||
final stripped = stripSwiftComments(source);
|
||||
check(stripped).not((it) => it.contains('AppDelegate'));
|
||||
check(stripped.split('\n')).length.equals(5);
|
||||
check(stripped.split('\n')[3]).contains('b');
|
||||
});
|
||||
|
||||
test('treats comment markers in a multi-line string as code', () {
|
||||
const source = 'let s = """\n// AppDelegate\n"""\nlet t = 1';
|
||||
check(stripSwiftComments(source)).contains('AppDelegate');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
//
|
||||
// dart run tool/check_package_boundaries.dart
|
||||
//
|
||||
// Two rules, both of which exist to stop the mobile and desktop front-ends
|
||||
// from re-implementing the same logic:
|
||||
// The first two rules exist to stop the mobile and desktop front-ends from
|
||||
// re-implementing the same logic:
|
||||
//
|
||||
// 1. Packages shared with the renderer must stay web-safe, so they can
|
||||
// cross `dart compile js`. CI also compiles them for real; this check
|
||||
|
|
@ -11,6 +11,14 @@
|
|||
// 2. apps/desktop_ui may depend only on the shared packages, Jaspr and
|
||||
// package:web. If it can reach dio, drift or conduit_core, then "logic
|
||||
// lives in the core" stops being enforceable by review alone.
|
||||
//
|
||||
// The third keeps the iOS feature bridges portable between hosts:
|
||||
//
|
||||
// 3. Swift in ios/Runner is either host-agnostic, and depends on the host
|
||||
// only through `ConduitBridgeHost`, or marked as Flutter-host-only.
|
||||
// Host-agnostic files are shared unchanged with other hosts, so
|
||||
// they may not name the Flutter app delegate, engine, view controller,
|
||||
// scene delegate or plugin registrar.
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
|
|
@ -93,6 +101,59 @@ const List<String> _desktopUiForbiddenDartLibraries = <String>[
|
|||
'dart:cli',
|
||||
];
|
||||
|
||||
/// Swift under ios/Runner that depends on its host only through
|
||||
/// `ConduitBridgeHost`, and is reusable unchanged by other hosts.
|
||||
const List<String> _iosHostAgnosticSwift = <String>[
|
||||
'ios/Runner/AppIntentBridge.swift',
|
||||
'ios/Runner/AppIntentInvocationStore.swift',
|
||||
'ios/Runner/BackgroundStreamingHandler.swift',
|
||||
'ios/Runner/ConduitAppIntents.swift',
|
||||
'ios/Runner/ConduitBridgeHost.swift',
|
||||
'ios/Runner/ConduitCarPlayBridge.swift',
|
||||
'ios/Runner/ConduitCarPlaySceneDelegate.swift',
|
||||
'ios/Runner/ConduitPlatformApis.g.swift',
|
||||
'ios/Runner/CookieBridge.swift',
|
||||
'ios/Runner/NativeDropdownBridge.swift',
|
||||
'ios/Runner/NativeImageViewerBridge.swift',
|
||||
'ios/Runner/NativeIosTtsBridge.swift',
|
||||
'ios/Runner/NativeSheetBridge.swift',
|
||||
'ios/Runner/NativeSheetUIFoundation.swift',
|
||||
'ios/Runner/NativeSttBridge.swift',
|
||||
'ios/Runner/NativeSymbolImageBridge.swift',
|
||||
'ios/Runner/PccBridge.swift',
|
||||
'ios/Runner/PlatformEnvironmentBridge.swift',
|
||||
'ios/Runner/ShareImportBridge.swift',
|
||||
'ios/Runner/VoiceAudioRouteBridge.swift',
|
||||
];
|
||||
|
||||
/// Swift under ios/Runner that belongs to the Flutter host. Each file says so
|
||||
/// in its header, so nobody copies it into another host by mistake.
|
||||
const List<String> _iosFlutterHostSwift = <String>[
|
||||
'ios/Runner/AppDelegate.swift',
|
||||
'ios/Runner/ConduitSceneDelegate.swift',
|
||||
'ios/Runner/DisplayBoostBridge.swift',
|
||||
'ios/Runner/FlutterConduitBridgeHost.swift',
|
||||
'ios/Runner/NativeKeyboardAttachmentBridge.swift',
|
||||
'ios/Runner/NativePasteBridge.swift',
|
||||
];
|
||||
|
||||
const String _iosFlutterHostMarker = '// FLUTTER HOST ONLY.';
|
||||
|
||||
/// Host types a host-agnostic bridge must reach through `ConduitBridgeHost`
|
||||
/// instead of naming them.
|
||||
final List<RegExp> _iosHostTypes = <RegExp>[
|
||||
RegExp(r'\bAppDelegate\b'),
|
||||
RegExp(r'\bFlutterAppDelegate\b'),
|
||||
RegExp(r'\bFlutterEngine'),
|
||||
RegExp(r'\bFlutterImplicitEngine'),
|
||||
RegExp(r'\bFlutterViewController\b'),
|
||||
RegExp(r'\bFlutterSceneDelegate\b'),
|
||||
RegExp(r'\bFlutterPluginRegist'),
|
||||
RegExp(r'\bGeneratedPluginRegistrant\b'),
|
||||
RegExp(r'UIApplication\.shared\.delegate\b'),
|
||||
RegExp(r'\bconnectedScenes\b'),
|
||||
];
|
||||
|
||||
final RegExp _importRegex = RegExp(
|
||||
'''^\\s*(?:import|export)\\s+['"]([^'"]+)['"]''',
|
||||
multiLine: true,
|
||||
|
|
@ -142,6 +203,7 @@ void main() {
|
|||
}
|
||||
|
||||
violations.addAll(_scanDesktopUi());
|
||||
violations.addAll(_scanIosBridges());
|
||||
|
||||
if (violations.isEmpty) {
|
||||
stdout.writeln('Package boundaries OK.');
|
||||
|
|
@ -345,3 +407,121 @@ Set<String> _declaredDependencies(String pubspec) {
|
|||
}
|
||||
|
||||
final RegExp _dependencyName = RegExp(r'^ ([a-z_0-9]+):');
|
||||
|
||||
/// Returns Swift [source] with comments blanked out.
|
||||
///
|
||||
/// Line comments and (nested) block comments become spaces, and newlines stay,
|
||||
/// so line numbers still match the file. String literals are kept as code: a
|
||||
/// `//` inside `"https://..."` is not a comment, and code after it still gets
|
||||
/// scanned.
|
||||
String stripSwiftComments(String source) {
|
||||
final out = StringBuffer();
|
||||
final length = source.length;
|
||||
var blockDepth = 0;
|
||||
var i = 0;
|
||||
while (i < length) {
|
||||
final char = source[i];
|
||||
final next = i + 1 < length ? source[i + 1] : '';
|
||||
if (blockDepth > 0) {
|
||||
if (char == '/' && next == '*') {
|
||||
blockDepth++;
|
||||
out.write(' ');
|
||||
i += 2;
|
||||
} else if (char == '*' && next == '/') {
|
||||
blockDepth--;
|
||||
out.write(' ');
|
||||
i += 2;
|
||||
} else {
|
||||
out.write(char == '\n' ? '\n' : ' ');
|
||||
i++;
|
||||
}
|
||||
} else if (char == '/' && next == '/') {
|
||||
while (i < length && source[i] != '\n') {
|
||||
out.write(' ');
|
||||
i++;
|
||||
}
|
||||
} else if (char == '/' && next == '*') {
|
||||
blockDepth = 1;
|
||||
out.write(' ');
|
||||
i += 2;
|
||||
} else if (char == '"') {
|
||||
final multiline = source.startsWith('"""', i);
|
||||
final quote = multiline ? '"""' : '"';
|
||||
out.write(quote);
|
||||
i += quote.length;
|
||||
while (i < length && !source.startsWith(quote, i)) {
|
||||
if (!multiline && source[i] == '\n') break;
|
||||
if (source[i] == '\\' && i + 1 < length) {
|
||||
out.write(source[i]);
|
||||
i++;
|
||||
}
|
||||
out.write(source[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < length && source.startsWith(quote, i)) {
|
||||
out.write(quote);
|
||||
i += quote.length;
|
||||
}
|
||||
} else {
|
||||
out.write(char);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// Holds every Swift file in ios/Runner to one side of the host seam.
|
||||
///
|
||||
/// A new file has to be listed before this passes, which makes "does another
|
||||
/// host reuse this?" a question somebody answers on purpose.
|
||||
List<String> _scanIosBridges() {
|
||||
final runner = Directory('ios/Runner');
|
||||
if (!runner.existsSync()) return const <String>[];
|
||||
final violations = <String>[];
|
||||
final listed = <String>{..._iosHostAgnosticSwift, ..._iosFlutterHostSwift};
|
||||
for (final entity in runner.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.swift')) continue;
|
||||
if (!listed.contains(entity.path)) {
|
||||
violations.add(
|
||||
'${entity.path} is not classified - add it to _iosHostAgnosticSwift '
|
||||
'or _iosFlutterHostSwift in tool/check_package_boundaries.dart',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final path in _iosHostAgnosticSwift) {
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) {
|
||||
violations.add('$path is listed as host-agnostic but does not exist');
|
||||
continue;
|
||||
}
|
||||
final lines = stripSwiftComments(file.readAsStringSync()).split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
final code = lines[i];
|
||||
for (final hostType in _iosHostTypes) {
|
||||
final match = hostType.firstMatch(code);
|
||||
if (match == null) continue;
|
||||
violations.add(
|
||||
'$path:${i + 1} names "${match.group(0)}" - host-agnostic bridges '
|
||||
'reach the host through ConduitBridgeHost so other hosts '
|
||||
'can reuse them unchanged',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final path in _iosFlutterHostSwift) {
|
||||
final file = File(path);
|
||||
if (!file.existsSync()) {
|
||||
violations.add('$path is listed as Flutter-host-only but does not exist');
|
||||
continue;
|
||||
}
|
||||
if (!file.readAsStringSync().contains(_iosFlutterHostMarker)) {
|
||||
violations.add(
|
||||
'$path is Flutter-host-only but lacks the "$_iosFlutterHostMarker" '
|
||||
'header',
|
||||
);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue