feat(macos): clarify local gateway choice

This commit is contained in:
Peter Steinberger
2025-12-20 14:11:46 +00:00
parent 9bf5b92d8f
commit 4abaf62783
3 changed files with 179 additions and 2 deletions

View File

@@ -6,6 +6,11 @@ import Observation
@MainActor
@Observable
final class GatewayDiscoveryModel {
struct LocalIdentity: Equatable {
var hostTokens: Set<String>
var displayTokens: Set<String>
}
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<String> = []
var displayTokens: Set<String> = []
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()
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}