diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift new file mode 100644 index 00000000000..8ccbab42da7 --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewaySetupCode.swift @@ -0,0 +1,42 @@ +import Foundation + +struct GatewaySetupPayload: Codable { + var url: String? + var host: String? + var port: Int? + var tls: Bool? + var token: String? + var password: String? +} + +enum GatewaySetupCode { + static func decode(raw: String) -> GatewaySetupPayload? { + if let payload = decodeFromJSON(raw) { + return payload + } + if let decoded = decodeBase64Payload(raw), + let payload = decodeFromJSON(decoded) + { + return payload + } + return nil + } + + private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data) + } + + private static func decodeBase64Payload(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = trimmed + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = normalized.count % 4 + let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) + guard let data = Data(base64Encoded: padded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + diff --git a/apps/ios/Sources/Gateway/TCPProbe.swift b/apps/ios/Sources/Gateway/TCPProbe.swift new file mode 100644 index 00000000000..e22da96298f --- /dev/null +++ b/apps/ios/Sources/Gateway/TCPProbe.swift @@ -0,0 +1,43 @@ +import Foundation +import Network +import os + +enum TCPProbe { + static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool { + guard port >= 1, port <= 65535 else { return false } + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } + + let endpointHost = NWEndpoint.Host(host) + let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) + + return await withCheckedContinuation { cont in + let queue = DispatchQueue(label: queueLabel) + let finished = OSAllocatedUnfairLock(initialState: false) + let finish: @Sendable (Bool) -> Void = { ok in + let shouldResume = finished.withLock { flag -> Bool in + if flag { return false } + flag = true + return true + } + guard shouldResume else { return } + connection.cancel() + cont.resume(returning: ok) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + finish(true) + case .failed, .cancelled: + finish(false) + default: + break + } + } + + connection.start(queue: queue) + queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) } + } + } +} + diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 372f8361d30..e8dce2cd30c 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -61,37 +61,10 @@ extension NodeAppModel { private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool { guard let host = url.host, !host.isEmpty else { return false } let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80) - guard portInt >= 1, portInt <= 65535 else { return false } - guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false } - - let endpointHost = NWEndpoint.Host(host) - let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) - return await withCheckedContinuation { cont in - let queue = DispatchQueue(label: "a2ui.preflight") - let finished = OSAllocatedUnfairLock(initialState: false) - let finish: @Sendable (Bool) -> Void = { ok in - let shouldResume = finished.withLock { flag -> Bool in - if flag { return false } - flag = true - return true - } - guard shouldResume else { return } - connection.cancel() - cont.resume(returning: ok) - } - - connection.stateUpdateHandler = { state in - switch state { - case .ready: - finish(true) - case .failed, .cancelled: - finish(false) - default: - break - } - } - connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) } - } + return await TCPProbe.probe( + host: host, + port: portInt, + timeoutSeconds: timeoutSeconds, + queueLabel: "a2ui.preflight") } } diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index 09c9e2429a6..bf6c0ba2d18 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -257,15 +257,6 @@ private struct ManualEntryStep: View { self.manualPassword = "" } - private struct SetupPayload: Codable { - var url: String? - var host: String? - var port: Int? - var tls: Bool? - var token: String? - var password: String? - } - private func applySetupCode() { let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { @@ -273,7 +264,7 @@ private struct ManualEntryStep: View { return } - guard let payload = self.decodeSetupPayload(raw: raw) else { + guard let payload = GatewaySetupCode.decode(raw: raw) else { self.setupStatusText = "Setup code not recognized." return } @@ -323,34 +314,7 @@ private struct ManualEntryStep: View { } } - private func decodeSetupPayload(raw: String) -> SetupPayload? { - if let payload = decodeSetupPayloadFromJSON(raw) { - return payload - } - if let decoded = decodeBase64Payload(raw), - let payload = decodeSetupPayloadFromJSON(decoded) - { - return payload - } - return nil - } - - private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(SetupPayload.self, from: data) - } - - private func decodeBase64Payload(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = normalized.count % 4 - let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) - guard let data = Data(base64Encoded: padded) else { return nil } - return String(data: data, encoding: .utf8) - } + // (GatewaySetupCode) decode raw setup codes. } private struct ConnectionStatusBox: View { diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 514e1b4cc47..c8f13eef407 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -256,64 +256,11 @@ private struct CanvasContent: View { } private var statusActivity: StatusPill.Activity? { - // Status pill owns transient activity state so it doesn't overlap the connection indicator. - if self.appModel.isBackgrounded { - return StatusPill.Activity( - title: "Foreground required", - systemImage: "exclamationmark.triangle.fill", - tint: .orange) - } - - let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayLower = gatewayStatus.lowercased() - if gatewayLower.contains("repair") { - return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) - } - if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { - return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if self.appModel.screenRecordActive { - return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) - } - - if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { - let systemImage: String - let tint: Color? - switch cameraHUDKind { - case .photo: - systemImage = "camera.fill" - tint = nil - case .recording: - systemImage = "video.fill" - tint = .red - case .success: - systemImage = "checkmark.circle.fill" - tint = .green - case .error: - systemImage = "exclamationmark.triangle.fill" - tint = .red - } - return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) - } - - if self.voiceWakeEnabled { - let voiceStatus = self.appModel.voiceWake.statusText - if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { - return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) - } - if voiceStatus == "Paused" { - // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. - if self.appModel.talkMode.isEnabled { - return nil - } - let suffix = self.appModel.isBackgrounded ? " (background)" : "" - return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") - } - } - - return nil + StatusActivityBuilder.build( + appModel: self.appModel, + voiceWakeEnabled: self.voiceWakeEnabled, + cameraHUDText: self.cameraHUDText, + cameraHUDKind: self.cameraHUDKind) } } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 278e56d6150..35786fa89a6 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -104,66 +104,10 @@ struct RootTabs: View { } private var statusActivity: StatusPill.Activity? { - // Keep the top pill consistent across tabs (camera + voice wake + pairing states). - if self.appModel.isBackgrounded { - return StatusPill.Activity( - title: "Foreground required", - systemImage: "exclamationmark.triangle.fill", - tint: .orange) - } - - let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) - let gatewayLower = gatewayStatus.lowercased() - if gatewayLower.contains("repair") { - return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) - } - if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { - return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") - } - // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. - - if self.appModel.screenRecordActive { - return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) - } - - if let cameraHUDText = self.appModel.cameraHUDText, - let cameraHUDKind = self.appModel.cameraHUDKind, - !cameraHUDText.isEmpty - { - let systemImage: String - let tint: Color? - switch cameraHUDKind { - case .photo: - systemImage = "camera.fill" - tint = nil - case .recording: - systemImage = "video.fill" - tint = .red - case .success: - systemImage = "checkmark.circle.fill" - tint = .green - case .error: - systemImage = "exclamationmark.triangle.fill" - tint = .red - } - return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) - } - - if self.voiceWakeEnabled { - let voiceStatus = self.appModel.voiceWake.statusText - if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { - return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) - } - if voiceStatus == "Paused" { - // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. - if self.appModel.talkMode.isEnabled { - return nil - } - let suffix = self.appModel.isBackgrounded ? " (background)" : "" - return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") - } - } - - return nil + StatusActivityBuilder.build( + appModel: self.appModel, + voiceWakeEnabled: self.voiceWakeEnabled, + cameraHUDText: self.appModel.cameraHUDText, + cameraHUDKind: self.appModel.cameraHUDKind) } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 50b4984520b..8eb725df4a1 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -590,15 +590,6 @@ struct SettingsTab: View { } } - private struct SetupPayload: Codable { - var url: String? - var host: String? - var port: Int? - var tls: Bool? - var token: String? - var password: String? - } - private func applySetupCodeAndConnect() async { self.setupStatusText = nil guard self.applySetupCode() else { return } @@ -626,7 +617,7 @@ struct SettingsTab: View { return false } - guard let payload = self.decodeSetupPayload(raw: raw) else { + guard let payload = GatewaySetupCode.decode(raw: raw) else { self.setupStatusText = "Setup code not recognized." return false } @@ -727,67 +718,14 @@ struct SettingsTab: View { } private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool { - guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false } - let endpointHost = NWEndpoint.Host(host) - let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp) - return await withCheckedContinuation { cont in - let queue = DispatchQueue(label: "gateway.preflight") - let finished = OSAllocatedUnfairLock(initialState: false) - let finish: @Sendable (Bool) -> Void = { ok in - let shouldResume = finished.withLock { flag -> Bool in - if flag { return false } - flag = true - return true - } - guard shouldResume else { return } - connection.cancel() - cont.resume(returning: ok) - } - connection.stateUpdateHandler = { state in - switch state { - case .ready: - finish(true) - case .failed, .cancelled: - finish(false) - default: - break - } - } - connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + timeoutSeconds) { - finish(false) - } - } + await TCPProbe.probe( + host: host, + port: port, + timeoutSeconds: timeoutSeconds, + queueLabel: "gateway.preflight") } - private func decodeSetupPayload(raw: String) -> SetupPayload? { - if let payload = decodeSetupPayloadFromJSON(raw) { - return payload - } - if let decoded = decodeBase64Payload(raw), - let payload = decodeSetupPayloadFromJSON(decoded) - { - return payload - } - return nil - } - - private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? { - guard let data = json.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(SetupPayload.self, from: data) - } - - private func decodeBase64Payload(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let normalized = trimmed - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = normalized.count % 4 - let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding) - guard let data = Data(base64Encoded: padded) else { return nil } - return String(data: data, encoding: .utf8) - } + // (GatewaySetupCode) decode raw setup codes. private func connectManual() async { let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/Sources/Status/StatusActivityBuilder.swift b/apps/ios/Sources/Status/StatusActivityBuilder.swift new file mode 100644 index 00000000000..a335e2f4643 --- /dev/null +++ b/apps/ios/Sources/Status/StatusActivityBuilder.swift @@ -0,0 +1,70 @@ +import SwiftUI + +enum StatusActivityBuilder { + static func build( + appModel: NodeAppModel, + voiceWakeEnabled: Bool, + cameraHUDText: String?, + cameraHUDKind: NodeAppModel.CameraHUDKind? + ) -> StatusPill.Activity? { + // Keep the top pill consistent across tabs (camera + voice wake + pairing states). + if appModel.isBackgrounded { + return StatusPill.Activity( + title: "Foreground required", + systemImage: "exclamationmark.triangle.fill", + tint: .orange) + } + + let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { + return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) + } + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { + return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") + } + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. + + if appModel.screenRecordActive { + return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) + } + + if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { + let systemImage: String + let tint: Color? + switch cameraHUDKind { + case .photo: + systemImage = "camera.fill" + tint = nil + case .recording: + systemImage = "video.fill" + tint = .red + case .success: + systemImage = "checkmark.circle.fill" + tint = .green + case .error: + systemImage = "exclamationmark.triangle.fill" + tint = .red + } + return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) + } + + if voiceWakeEnabled { + let voiceStatus = appModel.voiceWake.statusText + if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { + return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) + } + if voiceStatus == "Paused" { + // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. + if appModel.talkMode.isEnabled { + return nil + } + let suffix = appModel.isBackgrounded ? " (background)" : "" + return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") + } + } + + return nil + } +} +