diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index 01a0d81dd..5f14a673f 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -5,6 +5,7 @@ import Network actor BridgeClient { private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private var lineBuffer = Data() func pairAndHello( endpoint: NWEndpoint, @@ -15,6 +16,7 @@ actor BridgeClient { existingToken: String?, onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String { + self.lineBuffer = Data() let connection = NWConnection(to: endpoint, using: .tcp) let queue = DispatchQueue(label: "com.steipete.clawdis.ios.bridge-client") defer { connection.cancel() } @@ -32,9 +34,8 @@ actor BridgeClient { version: version), over: connection) - var buffer = Data() 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: [ NSLocalizedDescriptionKey: "Bridge closed connection during hello", ]) @@ -66,7 +67,7 @@ actor BridgeClient { onStatus?("Waiting for 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 { case "pair-ok": return try self.decoder.decode(BridgePairOk.self, from: next.data) @@ -110,8 +111,8 @@ actor BridgeClient { var data: Data } - private func receiveFrame(over connection: NWConnection, buffer: inout Data) async throws -> ReceivedFrame? { - guard let lineData = try await self.receiveLineData(over: connection, buffer: &buffer) else { + private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? { + guard let lineData = try await self.receiveLineData(over: connection) else { return nil } 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 { - if let idx = buffer.firstIndex(of: 0x0A) { - let line = buffer.prefix(upTo: idx) - buffer.removeSubrange(...idx) + if let idx = self.lineBuffer.firstIndex(of: 0x0A) { + let line = self.lineBuffer.prefix(upTo: idx) + self.lineBuffer.removeSubrange(...idx) return Data(line) } let chunk = try await self.receiveChunk(over: connection) if chunk.isEmpty { return nil } - buffer.append(chunk) + self.lineBuffer.append(chunk) } } @@ -160,7 +161,7 @@ actor BridgeClient { } } - private func withTimeout( + private func withTimeout( seconds: Int, purpose: String, _ op: @escaping @Sendable () async throws -> T) async throws -> T @@ -181,24 +182,33 @@ actor BridgeClient { private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws { try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation) 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 - if didResume { return } switch state { case .ready: - didResume = true - cont.resume(returning: ()) + if didResume.trySet() { cont.resume(returning: ()) } case let .failed(err): - didResume = true - cont.resume(throwing: err) + if didResume.trySet() { cont.resume(throwing: err) } case let .waiting(err): - didResume = true - cont.resume(throwing: err) + if didResume.trySet() { cont.resume(throwing: err) } case .cancelled: - didResume = true - cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [ - NSLocalizedDescriptionKey: "Connection cancelled", - ])) + if didResume.trySet() { + cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [ + NSLocalizedDescriptionKey: "Connection cancelled", + ])) + } default: break } diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index 3229db54d..96b5f2022 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -52,7 +52,11 @@ final class BridgeDiscoveryModel: ObservableObject { switch result.endpoint { case let .service(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( name: prettyName, endpoint: result.endpoint, @@ -80,6 +84,7 @@ final class BridgeDiscoveryModel: ObservableObject { private static func prettifyInstanceName(_ decodedName: String) -> String { let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ") let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "") + .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) return stripped.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/apps/ios/Sources/Bridge/BridgeEndpointID.swift b/apps/ios/Sources/Bridge/BridgeEndpointID.swift index 0bb07fb34..b6e8e64cb 100644 --- a/apps/ios/Sources/Bridge/BridgeEndpointID.swift +++ b/apps/ios/Sources/Bridge/BridgeEndpointID.swift @@ -10,7 +10,7 @@ enum BridgeEndpointID { let normalizedName = Self.normalizeServiceNameForID(name) return "\(type)|\(domain)|\(normalizedName)" default: - String(describing: endpoint) + return String(describing: endpoint) } } diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index a35be1e92..66950724c 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -24,6 +24,11 @@ function safeServiceName(name: string) { 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 = { advertise: () => Promise; destroy: () => Promise; @@ -52,11 +57,13 @@ export async function startGatewayBonjourAdvertiser( typeof opts.instanceName === "string" && opts.instanceName.trim() ? opts.instanceName.trim() : `${hostname} (Clawdis)`; + const displayName = prettifyInstanceName(instanceName); const txtBase: Record = { role: "master", gatewayPort: String(opts.gatewayPort), lanHost: `${hostname}.local`, + displayName, }; if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) { txtBase.bridgePort = String(opts.bridgePort);