refactor(ios): dedupe gateway helpers

This commit is contained in:
Peter Steinberger
2026-02-15 20:38:26 +00:00
parent ef2c66a16b
commit 778959b3dc
8 changed files with 179 additions and 258 deletions

View File

@@ -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)
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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")
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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
}
}