fix: refresh bridge tokens and enrich node settings
This commit is contained in:
@@ -6,6 +6,15 @@ import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
protocol BridgePairingClient: Sendable {
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
}
|
||||
|
||||
extension BridgeClient: BridgePairingClient {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BridgeConnectionController {
|
||||
@@ -18,8 +27,15 @@ final class BridgeConnectionController {
|
||||
private var didAutoConnect = false
|
||||
private var seenStableIDs = Set<String>()
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
private let bridgeClientFactory: @Sendable () -> any BridgePairingClient
|
||||
|
||||
init(
|
||||
appModel: NodeAppModel,
|
||||
startDiscovery: Bool = true,
|
||||
bridgeClientFactory: @escaping @Sendable () -> any BridgePairingClient = { BridgeClient() })
|
||||
{
|
||||
self.appModel = appModel
|
||||
self.bridgeClientFactory = bridgeClientFactory
|
||||
|
||||
BridgeSettingsStore.bootstrapPersistence()
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -85,7 +101,7 @@ final class BridgeConnectionController {
|
||||
|
||||
let token = KeychainStore.loadString(
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: "bridge-token.\(instanceId)")?
|
||||
account: self.keychainAccount(instanceId: instanceId))?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !token.isEmpty else { return }
|
||||
|
||||
@@ -99,9 +115,8 @@ final class BridgeConnectionController {
|
||||
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))
|
||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,7 +127,7 @@ final class BridgeConnectionController {
|
||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
appModel.connectToBridge(endpoint: target.endpoint, hello: self.makeHello(token: token))
|
||||
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
@@ -140,6 +155,40 @@ final class BridgeConnectionController {
|
||||
commands: self.currentCommands())
|
||||
}
|
||||
|
||||
private func keychainAccount(instanceId: String) -> String {
|
||||
"bridge-token.\(instanceId)"
|
||||
}
|
||||
|
||||
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||
guard let appModel else { return }
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let hello = self.makeHello(token: token)
|
||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
onStatus: { status in
|
||||
Task { @MainActor in
|
||||
appModel.bridgeStatusText = status
|
||||
}
|
||||
})
|
||||
let resolvedToken = refreshed.isEmpty ? token : refreshed
|
||||
if !refreshed.isEmpty, refreshed != token {
|
||||
_ = KeychainStore.saveString(
|
||||
refreshed,
|
||||
service: "com.steipete.clawdis.bridge",
|
||||
account: self.keychainAccount(instanceId: instanceId))
|
||||
}
|
||||
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -265,5 +314,13 @@ extension BridgeConnectionController {
|
||||
func _test_appVersion() -> String {
|
||||
self.appVersion()
|
||||
}
|
||||
|
||||
func _test_setBridges(_ bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||
self.bridges = bridges
|
||||
}
|
||||
|
||||
func _test_triggerAutoConnect() {
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -18,6 +18,12 @@ final class BridgeDiscoveryModel {
|
||||
var endpoint: NWEndpoint
|
||||
var stableID: String
|
||||
var debugID: String
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var gatewayPort: Int?
|
||||
var bridgePort: Int?
|
||||
var canvasPort: Int?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
var bridges: [DiscoveredBridge] = []
|
||||
@@ -68,7 +74,8 @@ final class BridgeDiscoveryModel {
|
||||
switch result.endpoint {
|
||||
case let .service(name, _, _, _):
|
||||
let decodedName = BonjourEscapes.decode(name)
|
||||
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
|
||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||
let advertisedName = txt["displayName"]
|
||||
let prettyAdvertised = advertisedName
|
||||
.map(Self.prettifyInstanceName)
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
@@ -77,7 +84,13 @@ final class BridgeDiscoveryModel {
|
||||
name: prettyName,
|
||||
endpoint: result.endpoint,
|
||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||
lanHost: Self.txtValue(txt, key: "lanHost"),
|
||||
tailnetDns: Self.txtValue(txt, key: "tailnetDns"),
|
||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -191,4 +204,14 @@ final class BridgeDiscoveryModel {
|
||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? nil : raw
|
||||
}
|
||||
|
||||
private static func txtIntValue(_ dict: [String: String], key: String) -> Int? {
|
||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||
return Int(raw)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent("Platform", value: self.platformString())
|
||||
LabeledContent("Version", value: self.appVersion())
|
||||
LabeledContent("Model", value: self.modelIdentifier())
|
||||
}
|
||||
|
||||
Section("Bridge") {
|
||||
@@ -227,6 +230,12 @@ struct SettingsTab: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(bridge.name)
|
||||
let detailLines = self.bridgeDetailLines(bridge)
|
||||
ForEach(detailLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -504,4 +513,26 @@ struct SettingsTab: View {
|
||||
private static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func bridgeDetailLines(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = bridge.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = bridge.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
|
||||
let gatewayPort = bridge.gatewayPort
|
||||
let bridgePort = bridge.bridgePort
|
||||
let canvasPort = bridge.canvasPort
|
||||
if gatewayPort != nil || bridgePort != nil || canvasPort != nil {
|
||||
let gw = gatewayPort.map(String.init) ?? "—"
|
||||
let br = bridgePort.map(String.init) ?? "—"
|
||||
let canvas = canvasPort.map(String.init) ?? "—"
|
||||
lines.append("Ports: gw \(gw) · bridge \(br) · canvas \(canvas)")
|
||||
}
|
||||
|
||||
if lines.isEmpty {
|
||||
lines.append(bridge.debugID)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ClawdisKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Clawdis
|
||||
@@ -15,6 +16,25 @@ private let instanceIdEntry = KeychainEntry(service: nodeService, account: "inst
|
||||
private let preferredBridgeEntry = KeychainEntry(service: bridgeService, account: "preferredStableID")
|
||||
private let lastBridgeEntry = KeychainEntry(service: bridgeService, account: "lastDiscoveredStableID")
|
||||
|
||||
private actor MockBridgePairingClient: BridgePairingClient {
|
||||
private(set) var lastToken: String?
|
||||
private let resultToken: String
|
||||
|
||||
init(resultToken: String) {
|
||||
self.resultToken = resultToken
|
||||
}
|
||||
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||
{
|
||||
self.lastToken = hello.token
|
||||
onStatus?("Testing…")
|
||||
return self.resultToken
|
||||
}
|
||||
}
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
@@ -156,4 +176,52 @@ private func withKeychainValues<T>(_ updates: [KeychainEntry: String?], _ body:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func autoConnectRefreshesTokenOnUnauthorized() async {
|
||||
let bridge = BridgeDiscoveryModel.DiscoveredBridge(
|
||||
name: "Gateway",
|
||||
endpoint: .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: 18790),
|
||||
stableID: "bridge-1",
|
||||
debugID: "bridge-debug",
|
||||
lanHost: "Mac.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
bridgePort: 18790,
|
||||
canvasPort: 18793,
|
||||
cliPath: nil)
|
||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||
let account = "bridge-token.ios-test"
|
||||
|
||||
withKeychainValues([
|
||||
instanceIdEntry: nil,
|
||||
preferredBridgeEntry: nil,
|
||||
lastBridgeEntry: nil,
|
||||
KeychainEntry(service: bridgeService, account: account): "old-token",
|
||||
]) {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"bridge.lastDiscoveredStableID": "bridge-1",
|
||||
"bridge.manual.enabled": false,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = BridgeConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: false,
|
||||
bridgeClientFactory: { mock })
|
||||
controller._test_setBridges([bridge])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
for _ in 0..<20 {
|
||||
if appModel.connectedBridgeID == bridge.stableID { break }
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
|
||||
#expect(appModel.connectedBridgeID == bridge.stableID)
|
||||
let stored = KeychainStore.loadString(service: bridgeService, account: account)
|
||||
#expect(stored == "new-token")
|
||||
let lastToken = await mock.lastToken
|
||||
#expect(lastToken == "old-token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user