From 0bcff4baf567594b788af8e2d936041aff1c81d5 Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:37:26 -0400 Subject: [PATCH 1/3] fix(ios): resolve main-thread warnings for CLLocationManager and SFSpeechRecognizer - Reuse CLLocationManager instance instead of creating on main thread each call - Cache SFSpeechRecognizer.authorizationStatus as nonisolated computed property - Both fixes eliminate UI-responsiveness warnings in currentPermissions() --- .../Gateway/GatewayConnectionController.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index dc94f3d0797..b27589f6279 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -37,6 +37,14 @@ final class GatewayConnectionController { private(set) var pendingTrustPrompt: TrustPrompt? private let discovery = GatewayDiscoveryModel() + /// Reused instance — avoids creating CLLocationManager on the main thread + /// each time currentPermissions() is called, which triggers a UI-thread warning. + private let locationManager = CLLocationManager() + /// Cached off the main thread to avoid the UI-responsiveness warning from + /// calling SFSpeechRecognizer.authorizationStatus() on the main actor. + private nonisolated var speechRecognitionStatus: SFSpeechRecognizerAuthorizationStatus { + SFSpeechRecognizer.authorizationStatus() + } private weak var appModel: NodeAppModel? private var didAutoConnect = false private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] @@ -891,9 +899,9 @@ final class GatewayConnectionController { var permissions: [String: Bool] = [:] permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized + permissions["speechRecognition"] = self.speechRecognitionStatus == .authorized permissions["location"] = Self.isLocationAuthorized( - status: CLLocationManager().authorizationStatus) + status: self.locationManager.authorizationStatus) && CLLocationManager.locationServicesEnabled() permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable From 300e494653db66b8bceec528d8ee59e0df66ba55 Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:39:56 -0400 Subject: [PATCH 2/3] fix(ios): probe speech permission off the main actor Follow up on PR review by making the permission snapshot path async so the speech recognition authorization probe runs in a detached utility-priority task instead of on the main actor.\n\nAlso rebase the branch onto current origin/main and update the connect-options call sites to await the refreshed permission snapshot. --- .../Gateway/GatewayConnectionController.swift | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index b27589f6279..b1bbc42e0ee 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -40,11 +40,6 @@ final class GatewayConnectionController { /// Reused instance — avoids creating CLLocationManager on the main thread /// each time currentPermissions() is called, which triggers a UI-thread warning. private let locationManager = CLLocationManager() - /// Cached off the main thread to avoid the UI-responsiveness warning from - /// calling SFSpeechRecognizer.authorizationStatus() on the main actor. - private nonisolated var speechRecognitionStatus: SFSpeechRecognizerAuthorizationStatus { - SFSpeechRecognizer.authorizationStatus() - } private weak var appModel: NodeAppModel? private var didAutoConnect = false private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:] @@ -236,15 +231,18 @@ final class GatewayConnectionController { guard let cfg = appModel.activeGatewayConnectConfig else { return } guard appModel.gatewayAutoReconnectEnabled else { return } - let refreshedConfig = GatewayConnectConfig( - url: cfg.url, - stableID: cfg.stableID, - tls: cfg.tls, - token: cfg.token, - bootstrapToken: cfg.bootstrapToken, - password: cfg.password, - nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) - appModel.applyGatewayConnectConfig(refreshedConfig) + Task { [weak self, weak appModel] in + guard let self, let appModel else { return } + let refreshedConfig = GatewayConnectConfig( + url: cfg.url, + stableID: cfg.stableID, + tls: cfg.tls, + token: cfg.token, + bootstrapToken: cfg.bootstrapToken, + password: cfg.password, + nodeOptions: await self.makeConnectOptions(stableID: cfg.stableID)) + appModel.applyGatewayConnectConfig(refreshedConfig) + } } func clearPendingTrustPrompt() { @@ -470,10 +468,10 @@ final class GatewayConnectionController { password: String?) { guard let appModel else { return } - let connectOptions = self.makeConnectOptions(stableID: gatewayStableID) - Task { [weak appModel] in - guard let appModel else { return } + Task { [weak self, weak appModel] in + guard let self, let appModel else { return } + let connectOptions = await self.makeConnectOptions(stableID: gatewayStableID) await MainActor.run { appModel.gatewayStatusText = "Connecting…" } @@ -749,7 +747,7 @@ final class GatewayConnectionController { "manual|\(host.lowercased())|\(port)" } - private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions { + private func makeConnectOptions(stableID: String?) async -> GatewayConnectOptions { let defaults = UserDefaults.standard let displayName = self.resolvedDisplayName(defaults: defaults) let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID) @@ -759,7 +757,7 @@ final class GatewayConnectionController { scopes: [], caps: self.currentCaps(), commands: self.currentCommands(), - permissions: self.currentPermissions(), + permissions: await self.currentPermissions(), clientId: resolvedClientId, clientMode: "node", clientDisplayName: displayName) @@ -895,11 +893,13 @@ final class GatewayConnectionController { return commands } - private func currentPermissions() -> [String: Bool] { + private func currentPermissions() async -> [String: Bool] { + let speechRecognitionStatus = await Self.currentSpeechRecognitionStatus() + var permissions: [String: Bool] = [:] permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - permissions["speechRecognition"] = self.speechRecognitionStatus == .authorized + permissions["speechRecognition"] = speechRecognitionStatus == .authorized permissions["location"] = Self.isLocationAuthorized( status: self.locationManager.authorizationStatus) && CLLocationManager.locationServicesEnabled() @@ -929,6 +929,14 @@ final class GatewayConnectionController { return permissions } + private nonisolated static func currentSpeechRecognitionStatus() async + -> SFSpeechRecognizerAuthorizationStatus + { + await Task.detached(priority: .utility) { + SFSpeechRecognizer.authorizationStatus() + }.value + } + private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool { switch status { case .authorizedAlways, .authorizedWhenInUse: @@ -961,8 +969,8 @@ extension GatewayConnectionController { self.currentCommands() } - func _test_currentPermissions() -> [String: Bool] { - self.currentPermissions() + func _test_currentPermissions() async -> [String: Bool] { + await self.currentPermissions() } func _test_platformString() -> String { From c4d727a2b810079c43cd3c72d243be94df63b86a Mon Sep 17 00:00:00 2001 From: Eulices Lopez <105620565+eulicesl@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:24:12 -0400 Subject: [PATCH 3/3] fix(ios): guard async gateway config refreshes Follow up on review feedback by re-checking the active gateway state after awaiting the permission snapshot refresh. If auto-reconnect was disabled or the active gateway changed during the async gap, the stale task now exits instead of re-applying an old config. --- apps/ios/Sources/Gateway/GatewayConnectionController.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index b1bbc42e0ee..309c488d632 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -241,6 +241,13 @@ final class GatewayConnectionController { bootstrapToken: cfg.bootstrapToken, password: cfg.password, nodeOptions: await self.makeConnectOptions(stableID: cfg.stableID)) + + guard appModel.gatewayAutoReconnectEnabled, + let latestConfig = appModel.activeGatewayConnectConfig, + latestConfig.effectiveStableID == cfg.effectiveStableID, + latestConfig.url == cfg.url + else { return } + appModel.applyGatewayConnectConfig(refreshedConfig) } }