From 4abaf627836b033c40a91e52b9192c93486fa1e1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 14:11:46 +0000 Subject: [PATCH] feat(macos): clarify local gateway choice --- .../Clawdis/GatewayDiscoveryModel.swift | 91 ++++++++++++++++++- apps/macos/Sources/Clawdis/Onboarding.swift | 42 ++++++++- .../GatewayDiscoveryModelTests.swift | 48 ++++++++++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift diff --git a/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift b/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift index 173a6f56e..0eaed2ce1 100644 --- a/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift @@ -6,6 +6,11 @@ import Observation @MainActor @Observable final class GatewayDiscoveryModel { + struct LocalIdentity: Equatable { + var hostTokens: Set + var displayTokens: Set + } + struct DiscoveredGateway: Identifiable, Equatable { var id: String { self.stableID } var displayName: String @@ -14,6 +19,7 @@ final class GatewayDiscoveryModel { var sshPort: Int var stableID: String var debugID: String + var isLocal: Bool } var gateways: [DiscoveredGateway] = [] @@ -22,6 +28,7 @@ final class GatewayDiscoveryModel { private var browsers: [String: NWBrowser] = [:] private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:] private var statesByDomain: [String: NWBrowser.State] = [:] + private let localIdentity: LocalIdentity = GatewayDiscoveryModel.buildLocalIdentity() func start() { if !self.browsers.isEmpty { return } @@ -74,13 +81,19 @@ final class GatewayDiscoveryModel { sshPort = parsed } + let isLocal = Self.isLocalGateway( + lanHost: lanHost, + tailnetDns: tailnetDns, + displayName: prettyName, + local: self.localIdentity) return DiscoveredGateway( displayName: prettyName, lanHost: lanHost, tailnetDns: tailnetDns, sshPort: sshPort, stableID: BridgeEndpointID.stableID(result.endpoint), - debugID: BridgeEndpointID.prettyDescription(result.endpoint)) + debugID: BridgeEndpointID.prettyDescription(result.endpoint), + isLocal: isLocal) } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } @@ -107,6 +120,7 @@ final class GatewayDiscoveryModel { private func recomputeGateways() { self.gateways = self.gatewaysByDomain.values .flatMap(\.self) + .filter { !$0.isLocal } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } @@ -161,4 +175,79 @@ final class GatewayDiscoveryModel { .replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression) return stripped.trimmingCharacters(in: .whitespacesAndNewlines) } + + static func isLocalGateway( + lanHost: String?, + tailnetDns: String?, + displayName: String?, + local: LocalIdentity) -> Bool + { + if let host = normalizeHostToken(lanHost), + local.hostTokens.contains(host) + { + return true + } + if let host = normalizeHostToken(tailnetDns), + local.hostTokens.contains(host) + { + return true + } + if let name = normalizeDisplayToken(displayName), + local.displayTokens.contains(name) + { + return true + } + return false + } + + private static func buildLocalIdentity() -> LocalIdentity { + var hostTokens: Set = [] + var displayTokens: Set = [] + + let hostName = ProcessInfo.processInfo.hostName + if let token = normalizeHostToken(hostName) { + hostTokens.insert(token) + } + if let host = Host.current().name, + let token = normalizeHostToken(host) + { + hostTokens.insert(token) + } + + let displayCandidates = [ + Host.current().localizedName, + InstanceIdentity.displayName, + ] + for raw in displayCandidates { + if let token = normalizeDisplayToken(raw) { + displayTokens.insert(token) + } + } + + return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens) + } + + private static func normalizeHostToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + let lower = trimmed.lowercased() + let strippedTrailingDot = lower.hasSuffix(".") + ? String(lower.dropLast()) + : lower + let withoutLocal = strippedTrailingDot.hasSuffix(".local") + ? String(strippedTrailingDot.dropLast(6)) + : strippedTrailingDot + let firstLabel = withoutLocal.split(separator: ".").first.map(String.init) + let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } + + private static func normalizeDisplayToken(_ raw: String?) -> String? { + guard let raw else { return nil } + let prettified = Self.prettifyInstanceName(raw) + let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + return trimmed.lowercased() + } } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index b7daea87d..f1e118caa 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -78,6 +78,7 @@ struct OnboardingView: View { @State private var showAdvancedConnection = false @State private var preferredGatewayID: String? @State private var gatewayDiscovery: GatewayDiscoveryModel + @State private var localGatewayProbe: LocalGatewayProbe? @Bindable private var state: AppState private var permissionMonitor: PermissionMonitor @@ -110,6 +111,13 @@ struct OnboardingView: View { private let devLinkCommand = "ln -sf /Applications/Clawdis.app/Contents/Resources/Relay/clawdis /usr/local/bin/clawdis" + private struct LocalGatewayProbe: Equatable { + let port: Int + let pid: Int32 + let command: String + let expected: Bool + } + init( state: AppState = AppStateStore.shared, permissionMonitor: PermissionMonitor = .shared, @@ -281,9 +289,19 @@ struct OnboardingView: View { self.onboardingCard(spacing: 12, padding: 14) { VStack(alignment: .leading, spacing: 10) { + let localSubtitle: String = { + guard let probe = self.localGatewayProbe else { + return "Run the Gateway locally." + } + let base = probe.expected + ? "Existing gateway detected" + : "Port \(probe.port) already in use" + let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" + return "\(base)\(command). Will attach." + }() self.connectionChoiceButton( title: "This Mac", - subtitle: "Run the Gateway locally.", + subtitle: localSubtitle, selected: self.state.connectionMode == .local) { self.selectLocalGateway() @@ -1318,6 +1336,7 @@ struct OnboardingView: View { if shouldMonitor, !self.monitoringDiscovery { self.monitoringDiscovery = true self.gatewayDiscovery.start() + Task { await self.refreshLocalGatewayProbe() } } else if !shouldMonitor, self.monitoringDiscovery { self.monitoringDiscovery = false self.gatewayDiscovery.stop() @@ -1391,6 +1410,27 @@ struct OnboardingView: View { GatewayEnvironment.check() }.value self.gatewayStatus = status + await self.refreshLocalGatewayProbe() + } + } + + private func refreshLocalGatewayProbe() async { + let port = GatewayEnvironment.gatewayPort() + let desc = await PortGuardian.shared.describe(port: port) + await MainActor.run { + guard let desc else { + self.localGatewayProbe = nil + return + } + let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines) + let expectedTokens = ["node", "clawdis", "tsx", "pnpm", "bun"] + let lower = command.lowercased() + let expected = expectedTokens.contains { lower.contains($0) } + self.localGatewayProbe = LocalGatewayProbe( + port: port, + pid: desc.pid, + command: command, + expected: expected) } } diff --git a/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift new file mode 100644 index 000000000..a774c7f13 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/GatewayDiscoveryModelTests.swift @@ -0,0 +1,48 @@ +import Testing +@testable import Clawdis + +@Suite struct GatewayDiscoveryModelTests { + @Test func localGatewayMatchesLanHost() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: []) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: "studio.local", + tailnetDns: nil, + displayName: nil, + local: local)) + } + + @Test func localGatewayMatchesTailnetDns() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: []) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: "studio.tailnet.example", + displayName: nil, + local: local)) + } + + @Test func localGatewayMatchesDisplayName() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: [], + displayTokens: ["peter's mac studio"]) + #expect(GatewayDiscoveryModel.isLocalGateway( + lanHost: nil, + tailnetDns: nil, + displayName: "Peter's Mac Studio (Clawdis)", + local: local)) + } + + @Test func remoteGatewayDoesNotMatch() { + let local = GatewayDiscoveryModel.LocalIdentity( + hostTokens: ["studio"], + displayTokens: ["peter's mac studio"]) + #expect(!GatewayDiscoveryModel.isLocalGateway( + lanHost: "other.local", + tailnetDns: "other.tailnet.example", + displayName: "Other Mac", + local: local)) + } +}