fix(ios): improve bridge discovery and pairing UX
This commit is contained in:
@@ -12,69 +12,85 @@ actor BridgeClient {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
platform: String,
|
platform: String,
|
||||||
version: String,
|
version: String,
|
||||||
existingToken: String?) async throws -> String
|
existingToken: String?,
|
||||||
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
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")
|
||||||
connection.start(queue: queue)
|
defer { connection.cancel() }
|
||||||
|
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||||
|
try await self.startAndWaitForReady(connection, queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
let token = existingToken
|
onStatus?("Authenticating…")
|
||||||
try await self.send(
|
try await self.send(
|
||||||
BridgeHello(
|
BridgeHello(
|
||||||
nodeId: nodeId,
|
nodeId: nodeId,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
token: token,
|
token: existingToken,
|
||||||
platform: platform,
|
platform: platform,
|
||||||
version: version),
|
version: version),
|
||||||
over: connection)
|
over: connection)
|
||||||
|
|
||||||
if let line = try await self.receiveLine(over: connection),
|
var buffer = Data()
|
||||||
let data = line.data(using: .utf8),
|
let first = try await self.withTimeout(seconds: 10, purpose: "hello") { () -> ReceivedFrame in
|
||||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
guard let frame = try await self.receiveFrame(over: connection, buffer: &buffer) else {
|
||||||
{
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
if base.type == "hello-ok" {
|
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||||
connection.cancel()
|
])
|
||||||
return existingToken ?? ""
|
|
||||||
}
|
}
|
||||||
if base.type == "error" {
|
return frame
|
||||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
}
|
||||||
if err.code == "NOT_PAIRED" || err.code == "UNAUTHORIZED" {
|
|
||||||
try await self.send(
|
|
||||||
BridgePairRequest(
|
|
||||||
nodeId: nodeId,
|
|
||||||
displayName: displayName,
|
|
||||||
platform: platform,
|
|
||||||
version: version),
|
|
||||||
over: connection)
|
|
||||||
|
|
||||||
while let next = try await self.receiveLine(over: connection) {
|
switch first.base.type {
|
||||||
guard let nextData = next.data(using: .utf8) else { continue }
|
case "hello-ok":
|
||||||
let nextBase = try self.decoder.decode(BridgeBaseFrame.self, from: nextData)
|
// We only return a token if we have one; callers should treat empty as "no token yet".
|
||||||
if nextBase.type == "pair-ok" {
|
return existingToken ?? ""
|
||||||
let ok = try self.decoder.decode(BridgePairOk.self, from: nextData)
|
|
||||||
connection.cancel()
|
case "error":
|
||||||
return ok.token
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||||
}
|
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
|
||||||
if nextBase.type == "error" {
|
|
||||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: nextData)
|
|
||||||
connection.cancel()
|
|
||||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.cancel()
|
|
||||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
connection.cancel()
|
onStatus?("Requesting approval…")
|
||||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
try await self.send(
|
||||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
BridgePairRequest(
|
||||||
])
|
nodeId: nodeId,
|
||||||
|
displayName: displayName,
|
||||||
|
platform: platform,
|
||||||
|
version: version),
|
||||||
|
over: connection)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
switch next.base.type {
|
||||||
|
case "pair-ok":
|
||||||
|
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||||
|
case "error":
|
||||||
|
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||||
|
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok.token
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||||
@@ -89,18 +105,17 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveLine(over connection: NWConnection) async throws -> String? {
|
private struct ReceivedFrame {
|
||||||
var buffer = Data()
|
var base: BridgeBaseFrame
|
||||||
while true {
|
var data: Data
|
||||||
if let idx = buffer.firstIndex(of: 0x0A) {
|
}
|
||||||
let lineData = buffer.prefix(upTo: idx)
|
|
||||||
return String(data: lineData, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunk = try await self.receiveChunk(over: connection)
|
private func receiveFrame(over connection: NWConnection, buffer: inout Data) async throws -> ReceivedFrame? {
|
||||||
if chunk.isEmpty { return nil }
|
guard let lineData = try await self.receiveLineData(over: connection, buffer: &buffer) else {
|
||||||
buffer.append(chunk)
|
return nil
|
||||||
}
|
}
|
||||||
|
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
|
||||||
|
return ReceivedFrame(base: base, data: lineData)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||||
@@ -118,4 +133,77 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func receiveLineData(over connection: NWConnection, buffer: inout Data) async throws -> Data? {
|
||||||
|
while true {
|
||||||
|
if let idx = buffer.firstIndex(of: 0x0A) {
|
||||||
|
let line = buffer.prefix(upTo: idx)
|
||||||
|
buffer.removeSubrange(...idx)
|
||||||
|
return Data(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = try await self.receiveChunk(over: connection)
|
||||||
|
if chunk.isEmpty { return nil }
|
||||||
|
buffer.append(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TimeoutError: LocalizedError, Sendable {
|
||||||
|
var purpose: String
|
||||||
|
var seconds: Int
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
if self.purpose == "pairing approval" {
|
||||||
|
return "Timed out waiting for approval (\(self.seconds)s). Approve the node on your gateway and try again."
|
||||||
|
}
|
||||||
|
return "Timed out during \(self.purpose) (\(self.seconds)s)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withTimeout<T>(
|
||||||
|
seconds: Int,
|
||||||
|
purpose: String,
|
||||||
|
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||||
|
{
|
||||||
|
try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
try await op()
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||||
|
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||||
|
}
|
||||||
|
let result = try await group.next()!
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||||
|
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
var didResume = false
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
if didResume { return }
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
didResume = true
|
||||||
|
cont.resume(returning: ())
|
||||||
|
case let .failed(err):
|
||||||
|
didResume = true
|
||||||
|
cont.resume(throwing: err)
|
||||||
|
case let .waiting(err):
|
||||||
|
didResume = true
|
||||||
|
cont.resume(throwing: err)
|
||||||
|
case .cancelled:
|
||||||
|
didResume = true
|
||||||
|
cont.resume(throwing: NSError(domain: "Bridge", code: 50, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||||
|
]))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ 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)
|
||||||
return DiscoveredBridge(
|
return DiscoveredBridge(
|
||||||
name: decodedName,
|
name: prettyName,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
||||||
@@ -75,4 +76,10 @@ final class BridgeDiscoveryModel: ObservableObject {
|
|||||||
self.bridges = []
|
self.bridges = []
|
||||||
self.statusText = "Stopped"
|
self.statusText = "Stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||||
|
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
|
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
|
||||||
|
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ enum BridgeEndpointID {
|
|||||||
static func stableID(_ endpoint: NWEndpoint) -> String {
|
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case let .service(name, type, domain, _):
|
case let .service(name, type, domain, _):
|
||||||
// Keep this stable across encode/decode differences; use raw service tuple.
|
// Keep this stable across encode/decode differences (e.g. `\032` for spaces).
|
||||||
"\(type)|\(domain)|\(name)"
|
let normalizedName = Self.normalizeServiceNameForID(name)
|
||||||
|
return "\(type)|\(domain)|\(normalizedName)"
|
||||||
default:
|
default:
|
||||||
String(describing: endpoint)
|
String(describing: endpoint)
|
||||||
}
|
}
|
||||||
@@ -16,4 +17,10 @@ enum BridgeEndpointID {
|
|||||||
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||||
BonjourEscapes.decode(String(describing: endpoint))
|
BonjourEscapes.decode(String(describing: endpoint))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizeServiceNameForID(_ rawName: String) -> String {
|
||||||
|
let decoded = BonjourEscapes.decode(rawName)
|
||||||
|
let normalized = decoded.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||||
|
return normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
@AppStorage("node.displayName") private var displayName: String = "iOS Node"
|
||||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||||
|
@AppStorage("bridge.preferredStableID") private var preferredBridgeStableID: String = ""
|
||||||
@StateObject private var discovery = BridgeDiscoveryModel()
|
@StateObject private var discovery = BridgeDiscoveryModel()
|
||||||
@State private var connectStatus: String?
|
@State private var connectStatus: String?
|
||||||
@State private var isConnecting = false
|
@State private var connectingBridgeID: String?
|
||||||
@State private var didAutoConnect = false
|
@State private var didAutoConnect = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -74,11 +75,12 @@ struct SettingsTab: View {
|
|||||||
service: "com.steipete.clawdis.bridge",
|
service: "com.steipete.clawdis.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
guard let existing, !existing.isEmpty else { return }
|
guard let existing, !existing.isEmpty else { return }
|
||||||
guard let first = newValue.first else { return }
|
guard let target = self.pickAutoConnectBridge(from: newValue) else { return }
|
||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
|
self.preferredBridgeStableID = target.stableID
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: first.endpoint,
|
endpoint: target.endpoint,
|
||||||
token: existing,
|
token: existing,
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -120,10 +122,17 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(self.isConnecting ? "…" : "Connect") {
|
Button {
|
||||||
Task { await self.connect(bridge) }
|
Task { await self.connect(bridge) }
|
||||||
|
} label: {
|
||||||
|
if self.connectingBridgeID == bridge.id {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(self.isConnecting)
|
.disabled(self.connectingBridgeID != nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,31 +158,36 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
private func connect(_ bridge: BridgeDiscoveryModel.DiscoveredBridge) async {
|
||||||
self.isConnecting = true
|
self.connectingBridgeID = bridge.id
|
||||||
defer { self.isConnecting = false }
|
self.preferredBridgeStableID = bridge.stableID
|
||||||
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
let existing = KeychainStore.loadString(service: "com.steipete.clawdis.bridge", account: self.keychainAccount())
|
|
||||||
do {
|
do {
|
||||||
let token: String
|
let existing = KeychainStore.loadString(
|
||||||
if let existing, !existing.isEmpty {
|
service: "com.steipete.clawdis.bridge",
|
||||||
token = existing
|
account: self.keychainAccount())
|
||||||
} else {
|
let existingToken = (existing?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ?
|
||||||
let newToken = try await BridgeClient().pairAndHello(
|
existing :
|
||||||
endpoint: bridge.endpoint,
|
nil
|
||||||
nodeId: self.instanceId,
|
|
||||||
displayName: self.displayName,
|
let token = try await BridgeClient().pairAndHello(
|
||||||
platform: self.platformString(),
|
endpoint: bridge.endpoint,
|
||||||
version: self.appVersion(),
|
nodeId: self.instanceId,
|
||||||
existingToken: nil)
|
displayName: self.displayName,
|
||||||
guard !newToken.isEmpty else {
|
platform: self.platformString(),
|
||||||
self.connectStatus = "Pairing failed: empty token"
|
version: self.appVersion(),
|
||||||
return
|
existingToken: existingToken,
|
||||||
}
|
onStatus: { status in
|
||||||
|
Task { @MainActor in
|
||||||
|
self.connectStatus = status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if !token.isEmpty, token != existingToken {
|
||||||
_ = KeychainStore.saveString(
|
_ = KeychainStore.saveString(
|
||||||
newToken,
|
token,
|
||||||
service: "com.steipete.clawdis.bridge",
|
service: "com.steipete.clawdis.bridge",
|
||||||
account: self.keychainAccount())
|
account: self.keychainAccount())
|
||||||
token = newToken
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
@@ -184,9 +198,18 @@ struct SettingsTab: View {
|
|||||||
platform: self.platformString(),
|
platform: self.platformString(),
|
||||||
version: self.appVersion())
|
version: self.appVersion())
|
||||||
|
|
||||||
self.connectStatus = "Connected"
|
|
||||||
} catch {
|
} catch {
|
||||||
self.connectStatus = "Failed: \(error.localizedDescription)"
|
self.connectStatus = "Failed: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func pickAutoConnectBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) -> BridgeDiscoveryModel
|
||||||
|
.DiscoveredBridge? {
|
||||||
|
if !self.preferredBridgeStableID.isEmpty,
|
||||||
|
let match = bridges.first(where: { $0.stableID == self.preferredBridgeStableID })
|
||||||
|
{
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return bridges.first
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
getLastHeartbeatEvent,
|
getLastHeartbeatEvent,
|
||||||
onHeartbeatEvent,
|
onHeartbeatEvent,
|
||||||
} from "../infra/heartbeat-events.js";
|
} from "../infra/heartbeat-events.js";
|
||||||
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import {
|
import {
|
||||||
approveNodePairing,
|
approveNodePairing,
|
||||||
listNodePairing,
|
listNodePairing,
|
||||||
@@ -118,6 +119,13 @@ type Client = {
|
|||||||
presenceKey?: string;
|
presenceKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatBonjourInstanceName(displayName: string) {
|
||||||
|
const trimmed = displayName.trim();
|
||||||
|
if (!trimmed) return "Clawdis";
|
||||||
|
if (/clawdis/i.test(trimmed)) return trimmed;
|
||||||
|
return `${trimmed} (Clawdis)`;
|
||||||
|
}
|
||||||
|
|
||||||
type GatewaySessionsDefaults = {
|
type GatewaySessionsDefaults = {
|
||||||
model: string | null;
|
model: string | null;
|
||||||
contextTokens: number | null;
|
contextTokens: number | null;
|
||||||
@@ -797,11 +805,14 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const machineDisplayName = await getMachineDisplayName();
|
||||||
|
|
||||||
if (bridgeEnabled && bridgePort > 0) {
|
if (bridgeEnabled && bridgePort > 0) {
|
||||||
try {
|
try {
|
||||||
const started = await startNodeBridgeServer({
|
const started = await startNodeBridgeServer({
|
||||||
host: bridgeHost,
|
host: bridgeHost,
|
||||||
port: bridgePort,
|
port: bridgePort,
|
||||||
|
serverName: machineDisplayName,
|
||||||
onAuthenticated: (node) => {
|
onAuthenticated: (node) => {
|
||||||
const host = node.displayName?.trim() || node.nodeId;
|
const host = node.displayName?.trim() || node.nodeId;
|
||||||
const ip = node.remoteIp?.trim();
|
const ip = node.remoteIp?.trim();
|
||||||
@@ -887,6 +898,7 @@ export async function startGatewayServer(
|
|||||||
tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined;
|
tailnetDnsEnv && tailnetDnsEnv.length > 0 ? tailnetDnsEnv : undefined;
|
||||||
|
|
||||||
const bonjour = await startGatewayBonjourAdvertiser({
|
const bonjour = await startGatewayBonjourAdvertiser({
|
||||||
|
instanceName: formatBonjourInstanceName(machineDisplayName),
|
||||||
gatewayPort: port,
|
gatewayPort: port,
|
||||||
bridgePort: bridge?.port,
|
bridgePort: bridge?.port,
|
||||||
sshPort,
|
sshPort,
|
||||||
|
|||||||
43
src/infra/machine-name.ts
Normal file
43
src/infra/machine-name.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import os from "node:os";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
let cachedPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
async function tryScutil(key: "ComputerName" | "LocalHostName") {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync("/usr/sbin/scutil", ["--get", key], {
|
||||||
|
timeout: 1000,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
const value = String(stdout ?? "").trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackHostName() {
|
||||||
|
return (
|
||||||
|
os
|
||||||
|
.hostname()
|
||||||
|
.replace(/\.local$/i, "")
|
||||||
|
.trim() || "clawdis"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMachineDisplayName(): Promise<string> {
|
||||||
|
if (cachedPromise) return cachedPromise;
|
||||||
|
cachedPromise = (async () => {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const computerName = await tryScutil("ComputerName");
|
||||||
|
if (computerName) return computerName;
|
||||||
|
const localHostName = await tryScutil("LocalHostName");
|
||||||
|
if (localHostName) return localHostName;
|
||||||
|
}
|
||||||
|
return fallbackHostName();
|
||||||
|
})();
|
||||||
|
return cachedPromise;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user