feat(macos): clarify local gateway choice
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user