feat(ios): unify manual bridge config and auto-reconnect

This commit is contained in:
Peter Steinberger
2025-12-14 01:54:48 +00:00
parent dccdc950bf
commit e2451484d9
3 changed files with 184 additions and 41 deletions

View File

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

View File

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

View File

@@ -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<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }