fix(bridge): prefer bonjour TXT displayName

This commit is contained in:
Peter Steinberger
2025-12-13 18:31:06 +00:00
parent 537c515dde
commit 3b853b329f
4 changed files with 47 additions and 25 deletions

View File

@@ -5,6 +5,7 @@ import Network
actor BridgeClient { actor BridgeClient {
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello( func pairAndHello(
endpoint: NWEndpoint, endpoint: NWEndpoint,
@@ -15,6 +16,7 @@ actor BridgeClient {
existingToken: String?, existingToken: String?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{ {
self.lineBuffer = Data()
let connection = NWConnection(to: endpoint, using: .tcp) let connection = NWConnection(to: endpoint, using: .tcp)
let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client") let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client")
defer { connection.cancel() } defer { connection.cancel() }
@@ -32,9 +34,8 @@ actor BridgeClient {
version: version), version: version),
over: connection) over: connection)
var buffer = Data()
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection, buffer: &buffer) else { guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [ throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello", NSLocalizedDescriptionKey: "Bridge closed connection during hello",
]) ])
@@ -66,7 +67,7 @@ actor BridgeClient {
onStatus?("Waiting for approval…") onStatus?("Waiting for approval…")
let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") { let ok = try await self.withTimeout(seconds: 60, purpose: "pairing approval") {
while let next = try await self.receiveFrame(over: connection, buffer: &buffer) { while let next = try await self.receiveFrame(over: connection) {
switch next.base.type { switch next.base.type {
case "pair-ok": case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data) return try self.decoder.decode(BridgePairOk.self, from: next.data)
@@ -110,8 +111,8 @@ actor BridgeClient {
var data: Data var data: Data
} }
private func receiveFrame(over connection: NWConnection, buffer: inout Data) async throws -> ReceivedFrame? { private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection, buffer: &buffer) else { guard let lineData = try await self.receiveLineData(over: connection) else {
return nil return nil
} }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData) let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
@@ -134,17 +135,17 @@ actor BridgeClient {
} }
} }
private func receiveLineData(over connection: NWConnection, buffer: inout Data) async throws -> Data? { private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true { while true {
if let idx = buffer.firstIndex(of: 0x0A) { if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = buffer.prefix(upTo: idx) let line = self.lineBuffer.prefix(upTo: idx)
buffer.removeSubrange(...idx) self.lineBuffer.removeSubrange(...idx)
return Data(line) return Data(line)
} }
let chunk = try await self.receiveChunk(over: connection) let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil } if chunk.isEmpty { return nil }
buffer.append(chunk) self.lineBuffer.append(chunk)
} }
} }
@@ -160,7 +161,7 @@ actor BridgeClient {
} }
} }
private func withTimeout<T>( private func withTimeout<T: Sendable>(
seconds: Int, seconds: Int,
purpose: String, purpose: String,
_ op: @escaping @Sendable () async throws -> T) async throws -> T _ op: @escaping @Sendable () async throws -> T) async throws -> T
@@ -181,24 +182,33 @@ actor BridgeClient {
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws { private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
var didResume = false final class ResumeFlag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func trySet() -> Bool {
self.lock.lock()
defer { self.lock.unlock() }
if self.value { return false }
self.value = true
return true
}
}
let didResume = ResumeFlag()
connection.stateUpdateHandler = { state in connection.stateUpdateHandler = { state in
if didResume { return }
switch state { switch state {
case .ready: case .ready:
didResume = true if didResume.trySet() { cont.resume(returning: ()) }
cont.resume(returning: ())
case let .failed(err): case let .failed(err):
didResume = true if didResume.trySet() { cont.resume(throwing: err) }
cont.resume(throwing: err)
case let .waiting(err): case let .waiting(err):
didResume = true if didResume.trySet() { cont.resume(throwing: err) }
cont.resume(throwing: err)
case .cancelled: case .cancelled:
didResume = true if didResume.trySet() {
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [ cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled", NSLocalizedDescriptionKey: "Connection cancelled",
])) ]))
}
default: default:
break break
} }

View File

@@ -52,7 +52,11 @@ final class BridgeDiscoveryModel: ObservableObject {
switch result.endpoint { switch result.endpoint {
case let .service(name, _, _, _): case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name) let decodedName = BonjourEscapes.decode(name)
let prettyName = Self.prettifyInstanceName(decodedName) let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge( return DiscoveredBridge(
name: prettyName, name: prettyName,
endpoint: result.endpoint, endpoint: result.endpoint,
@@ -80,6 +84,7 @@ final class BridgeDiscoveryModel: ObservableObject {
private static func prettifyInstanceName(_ decodedName: String) -> String { private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "") let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
return stripped.trimmingCharacters(in: .whitespacesAndNewlines) return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
} }
} }

View File

@@ -10,7 +10,7 @@ enum BridgeEndpointID {
let normalizedName = Self.normalizeServiceNameForID(name) let normalizedName = Self.normalizeServiceNameForID(name)
return "\(type)|\(domain)|\(normalizedName)" return "\(type)|\(domain)|\(normalizedName)"
default: default:
String(describing: endpoint) return String(describing: endpoint)
} }
} }

View File

@@ -24,6 +24,11 @@ function safeServiceName(name: string) {
return trimmed.length > 0 ? trimmed : "Clawdis"; return trimmed.length > 0 ? trimmed : "Clawdis";
} }
function prettifyInstanceName(name: string) {
const normalized = name.trim().replace(/\s+/g, " ");
return normalized.replace(/\s+\(Clawdis\)\s*$/i, "").trim() || normalized;
}
type BonjourService = { type BonjourService = {
advertise: () => Promise<void>; advertise: () => Promise<void>;
destroy: () => Promise<void>; destroy: () => Promise<void>;
@@ -52,11 +57,13 @@ export async function startGatewayBonjourAdvertiser(
typeof opts.instanceName === "string" && opts.instanceName.trim() typeof opts.instanceName === "string" && opts.instanceName.trim()
? opts.instanceName.trim() ? opts.instanceName.trim()
: `${hostname} (Clawdis)`; : `${hostname} (Clawdis)`;
const displayName = prettifyInstanceName(instanceName);
const txtBase: Record<string, string> = { const txtBase: Record<string, string> = {
role: "master", role: "master",
gatewayPort: String(opts.gatewayPort), gatewayPort: String(opts.gatewayPort),
lanHost: `${hostname}.local`, lanHost: `${hostname}.local`,
displayName,
}; };
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) { if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
txtBase.bridgePort = String(opts.bridgePort); txtBase.bridgePort = String(opts.bridgePort);