feat(ios): unify manual bridge config and auto-reconnect
This commit is contained in:
@@ -52,9 +52,7 @@ final class BridgeConnectionController: ObservableObject {
|
|||||||
guard appModel.bridgeServerName == nil else { return }
|
guard appModel.bridgeServerName == nil else { return }
|
||||||
|
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let targetStableID = defaults.string(forKey: "bridge.lastDiscoveredStableID")?
|
let manualEnabled = defaults.bool(forKey: "bridge.manual.enabled")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
guard !targetStableID.isEmpty else { return }
|
|
||||||
|
|
||||||
let instanceId = defaults.string(forKey: "node.instanceId")?
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
@@ -66,6 +64,26 @@ final class BridgeConnectionController: ObservableObject {
|
|||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
guard !token.isEmpty else { return }
|
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 }
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
|
|||||||
@@ -54,52 +54,72 @@ final class NodeAppModel: ObservableObject {
|
|||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
{
|
{
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
self.bridgeStatusText = "Connecting…"
|
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
do {
|
var attempt = 0
|
||||||
try await self.bridge.connect(
|
while !Task.isCancelled {
|
||||||
endpoint: endpoint,
|
await MainActor.run {
|
||||||
hello: hello,
|
if attempt == 0 {
|
||||||
onConnected: { [weak self] serverName in
|
self.bridgeStatusText = "Connecting…"
|
||||||
guard let self else { return }
|
} else {
|
||||||
await MainActor.run {
|
self.bridgeStatusText = "Reconnecting…"
|
||||||
self.bridgeStatusText = "Connected"
|
}
|
||||||
self.bridgeServerName = serverName
|
self.bridgeServerName = nil
|
||||||
}
|
self.bridgeRemoteAddress = nil
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
await MainActor.run {
|
do {
|
||||||
self.bridgeStatusText = "Disconnected"
|
try await self.bridge.connect(
|
||||||
self.bridgeServerName = nil
|
endpoint: endpoint,
|
||||||
self.bridgeRemoteAddress = nil
|
hello: hello,
|
||||||
self.connectedBridgeID = nil
|
onConnected: { [weak self] serverName in
|
||||||
}
|
guard let self else { return }
|
||||||
} catch {
|
await MainActor.run {
|
||||||
await MainActor.run {
|
self.bridgeStatusText = "Connected"
|
||||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
self.bridgeServerName = serverName
|
||||||
self.bridgeServerName = nil
|
}
|
||||||
self.bridgeRemoteAddress = nil
|
if let addr = await self.bridge.currentRemoteAddress() {
|
||||||
self.connectedBridgeID = nil
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import ClawdisKit
|
import ClawdisKit
|
||||||
|
import Network
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||||
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||||
@AppStorage("bridge.lastDiscoveredStableID") private var lastDiscoveredBridgeStableID: 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()
|
@StateObject private var connectStatus = ConnectStatusStore()
|
||||||
@State private var connectingBridgeID: String?
|
@State private var connectingBridgeID: String?
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
@@ -114,6 +118,40 @@ struct SettingsTab: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.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")
|
.navigationTitle("Settings")
|
||||||
@@ -206,6 +244,7 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||||
self.connectingBridgeID = bridge.id
|
self.connectingBridgeID = bridge.id
|
||||||
|
self.manualBridgeEnabled = false
|
||||||
self.preferredBridgeStableID = bridge.stableID
|
self.preferredBridgeStableID = bridge.stableID
|
||||||
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
|
BridgeSettingsStore.savePreferredBridgeStableID(bridge.stableID)
|
||||||
self.lastDiscoveredBridgeStableID = 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? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
|||||||
Reference in New Issue
Block a user