From e2451484d972a650e220b98d243f9fb72e2e47b8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 01:54:48 +0000 Subject: [PATCH] feat(ios): unify manual bridge config and auto-reconnect --- .../Bridge/BridgeConnectionController.swift | 24 +++- apps/ios/Sources/Model/NodeAppModel.swift | 96 +++++++++------- apps/ios/Sources/Settings/SettingsTab.swift | 105 ++++++++++++++++++ 3 files changed, 184 insertions(+), 41 deletions(-) diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 390ae41fa..c80989182 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -52,9 +52,7 @@ final class BridgeConnectionController: ObservableObject { guard appModel.bridgeServerName == nil else { return } let defaults = UserDefaults.standard - let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !targetStableID.isEmpty else { return } + let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled") let instanceId = defaults.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -66,6 +64,26 @@ final class BridgeConnectionController: ObservableObject { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !token.isEmpty else { return } + if manualEnabled { + let manualHost = defaults.string(forKey: "bridge.manual.host")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !manualHost.isEmpty else { return } + + let manualPort = defaults.integer(forKey: "bridge.manual.port") + let resolvedPort = manualPort > 0 ? manualPort : 18790 + guard let port = NWEndpoint.Port(rawValue: UInt16(resolvedPort)) else { return } + + self.didAutoConnect = true + appModel.connectToBridge( + endpoint: .hostPort(host: NWEndpoint.Host(manualHost), port: port), + hello: self.makeHello(token: token)) + return + } + + let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !targetStableID.isEmpty else { return } + guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return } self.didAutoConnect = true diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index c581c6b30..6bc7e46fd 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -54,52 +54,72 @@ final class NodeAppModel: ObservableObject { hello: BridgeHello) { self.bridgeTask?.cancel() - self.bridgeStatusText = "Connecting…" self.bridgeServerName = nil self.bridgeRemoteAddress = nil self.connectedBridgeID = BridgeEndpointID.stableID(endpoint) self.bridgeTask = Task { - do { - try await self.bridge.connect( - endpoint: endpoint, - hello: hello, - onConnected: { [weak self] serverName in - guard let self else { return } - await MainActor.run { - self.bridgeStatusText = "Connected" - self.bridgeServerName = serverName - } - if let addr = await self.bridge.currentRemoteAddress() { - await MainActor.run { - self.bridgeRemoteAddress = addr - } - } - }, - onInvoke: { [weak self] req in - guard let self else { - return BridgeInvokeResponse( - id: req.id, - ok: false, - error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) - } - return await self.handleInvoke(req) - }) + var attempt = 0 + while !Task.isCancelled { + await MainActor.run { + if attempt == 0 { + self.bridgeStatusText = "Connecting…" + } else { + self.bridgeStatusText = "Reconnecting…" + } + self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + } - await MainActor.run { - self.bridgeStatusText = "Disconnected" - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil - self.connectedBridgeID = nil - } - } catch { - await MainActor.run { - self.bridgeStatusText = "Bridge error: \(error.localizedDescription)" - self.bridgeServerName = nil - self.bridgeRemoteAddress = nil - self.connectedBridgeID = nil + do { + try await self.bridge.connect( + endpoint: endpoint, + hello: hello, + onConnected: { [weak self] serverName in + guard let self else { return } + await MainActor.run { + self.bridgeStatusText = "Connected" + self.bridgeServerName = serverName + } + if let addr = await self.bridge.currentRemoteAddress() { + await MainActor.run { + self.bridgeRemoteAddress = addr + } + } + }, + onInvoke: { [weak self] req in + guard let self else { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: ClawdisNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready")) + } + return await self.handleInvoke(req) + }) + + if Task.isCancelled { break } + attempt += 1 + let sleepSeconds = min(6.0, 0.35 * pow(1.7, Double(attempt))) + try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) + } catch { + if Task.isCancelled { break } + attempt += 1 + await MainActor.run { + self.bridgeStatusText = "Bridge error: \(error.localizedDescription)" + self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + } + let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt))) + try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000)) } } + + await MainActor.run { + self.bridgeStatusText = "Disconnected" + self.bridgeServerName = nil + self.bridgeRemoteAddress = nil + self.connectedBridgeID = nil + } } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index de01a2548..9395048b4 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -1,4 +1,5 @@ import ClawdisKit +import Network import SwiftUI import UIKit @@ -20,6 +21,9 @@ struct SettingsTab: View { @AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = "" @AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: String = "" + @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false + @AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" + @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 @StateObject private var connectStatus = ConnectStatusStore() @State private var connectingBridgeID: String? @State private var localIPAddress: String? @@ -114,6 +118,40 @@ struct SettingsTab: View { .font(.footnote) .foregroundStyle(.secondary) } + + DisclosureGroup("Advanced") { + Toggle("Use Manual Bridge", isOn: self.$manualBridgeEnabled) + + TextField("Host", text: self.$manualBridgeHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("Port", value: self.$manualBridgePort, format: .number) + .keyboardType(.numberPad) + + Button { + Task { await self.connectManual() } + } label: { + if self.connectingBridgeID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect (Manual)") + } + } + .disabled(self.connectingBridgeID != nil || self.manualBridgeHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty || self.manualBridgePort <= 0 || self.manualBridgePort > 65535) + + Text( + "Use this when mDNS/Bonjour discovery is blocked. " + + "The bridge runs on the gateway (default port 18790).") + .font(.footnote) + .foregroundStyle(.secondary) + } } } .navigationTitle("Settings") @@ -206,6 +244,7 @@ struct SettingsTab: View { private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async { self.connectingBridgeID = bridge.id + self.manualBridgeEnabled = false self.preferredBridgeStableID = bridge.stableID BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID) self.lastDiscoveredBridgeStableID = bridge.stableID @@ -257,6 +296,72 @@ struct SettingsTab: View { } } + private func connectManual() async { + let host = self.manualBridgeHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + self.connectStatus.text = "Failed: host required" + return + } + guard self.manualBridgePort > 0, self.manualBridgePort <= 65535 else { + self.connectStatus.text = "Failed: invalid port" + return + } + guard let port = NWEndpoint.Port(rawValue: UInt16(self.manualBridgePort)) else { + self.connectStatus.text = "Failed: invalid port" + return + } + + self.connectingBridgeID = "manual" + self.manualBridgeEnabled = true + defer { self.connectingBridgeID = nil } + + let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port) + + do { + let existing = KeychainStore.loadString( + service: "com.steipete.clawdis.bridge", + account: self.keychainAccount()) + let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? + existing : + nil + + let hello = BridgeHello( + nodeId: self.instanceId, + displayName: self.displayName, + token: existingToken, + platform: self.platformString(), + version: self.appVersion()) + let token = try await BridgeClient().pairAndHello( + endpoint: endpoint, + hello: hello, + onStatus: { status in + let store = self.connectStatus + Task { @MainActor in + store.text = status + } + }) + + if !token.isEmpty, token != existingToken { + _ = KeychainStore.saveString( + token, + service: "com.steipete.clawdis.bridge", + account: self.keychainAccount()) + } + + self.appModel.connectToBridge( + endpoint: endpoint, + hello: BridgeHello( + nodeId: self.instanceId, + displayName: self.displayName, + token: token, + platform: self.platformString(), + version: self.appVersion())) + + } catch { + self.connectStatus.text = "Failed: \(error.localizedDescription)" + } + } + private static func primaryIPv4Address() -> String? { var addrList: UnsafeMutablePointer? guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }