feat(macos): clarify local gateway choice
This commit is contained in:
@@ -6,6 +6,11 @@ import Observation
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class GatewayDiscoveryModel {
|
final class GatewayDiscoveryModel {
|
||||||
|
struct LocalIdentity: Equatable {
|
||||||
|
var hostTokens: Set<String>
|
||||||
|
var displayTokens: Set<String>
|
||||||
|
}
|
||||||
|
|
||||||
struct DiscoveredGateway: Identifiable, Equatable {
|
struct DiscoveredGateway: Identifiable, Equatable {
|
||||||
var id: String { self.stableID }
|
var id: String { self.stableID }
|
||||||
var displayName: String
|
var displayName: String
|
||||||
@@ -14,6 +19,7 @@ final class GatewayDiscoveryModel {
|
|||||||
var sshPort: Int
|
var sshPort: Int
|
||||||
var stableID: String
|
var stableID: String
|
||||||
var debugID: String
|
var debugID: String
|
||||||
|
var isLocal: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var gateways: [DiscoveredGateway] = []
|
var gateways: [DiscoveredGateway] = []
|
||||||
@@ -22,6 +28,7 @@ final class GatewayDiscoveryModel {
|
|||||||
private var browsers: [String: NWBrowser] = [:]
|
private var browsers: [String: NWBrowser] = [:]
|
||||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||||
|
private let localIdentity: LocalIdentity = GatewayDiscoveryModel.buildLocalIdentity()
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
if !self.browsers.isEmpty { return }
|
if !self.browsers.isEmpty { return }
|
||||||
@@ -74,13 +81,19 @@ final class GatewayDiscoveryModel {
|
|||||||
sshPort = parsed
|
sshPort = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isLocal = Self.isLocalGateway(
|
||||||
|
lanHost: lanHost,
|
||||||
|
tailnetDns: tailnetDns,
|
||||||
|
displayName: prettyName,
|
||||||
|
local: self.localIdentity)
|
||||||
return DiscoveredGateway(
|
return DiscoveredGateway(
|
||||||
displayName: prettyName,
|
displayName: prettyName,
|
||||||
lanHost: lanHost,
|
lanHost: lanHost,
|
||||||
tailnetDns: tailnetDns,
|
tailnetDns: tailnetDns,
|
||||||
sshPort: sshPort,
|
sshPort: sshPort,
|
||||||
stableID: BridgeEndpointID.stableID(result.endpoint),
|
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
||||||
|
isLocal: isLocal)
|
||||||
}
|
}
|
||||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||||
|
|
||||||
@@ -107,6 +120,7 @@ final class GatewayDiscoveryModel {
|
|||||||
private func recomputeGateways() {
|
private func recomputeGateways() {
|
||||||
self.gateways = self.gatewaysByDomain.values
|
self.gateways = self.gatewaysByDomain.values
|
||||||
.flatMap(\.self)
|
.flatMap(\.self)
|
||||||
|
.filter { !$0.isLocal }
|
||||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,4 +175,79 @@ final class GatewayDiscoveryModel {
|
|||||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
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 showAdvancedConnection = false
|
||||||
@State private var preferredGatewayID: String?
|
@State private var preferredGatewayID: String?
|
||||||
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
||||||
|
@State private var localGatewayProbe: LocalGatewayProbe?
|
||||||
@Bindable private var state: AppState
|
@Bindable private var state: AppState
|
||||||
private var permissionMonitor: PermissionMonitor
|
private var permissionMonitor: PermissionMonitor
|
||||||
|
|
||||||
@@ -110,6 +111,13 @@ struct OnboardingView: View {
|
|||||||
private let devLinkCommand =
|
private let devLinkCommand =
|
||||||
"ln -sf /Applications/Clawdis.app/Contents/Resources/Relay/clawdis /usr/local/bin/clawdis"
|
"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(
|
init(
|
||||||
state: AppState = AppStateStore.shared,
|
state: AppState = AppStateStore.shared,
|
||||||
permissionMonitor: PermissionMonitor = .shared,
|
permissionMonitor: PermissionMonitor = .shared,
|
||||||
@@ -281,9 +289,19 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
self.onboardingCard(spacing: 12, padding: 14) {
|
self.onboardingCard(spacing: 12, padding: 14) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
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(
|
self.connectionChoiceButton(
|
||||||
title: "This Mac",
|
title: "This Mac",
|
||||||
subtitle: "Run the Gateway locally.",
|
subtitle: localSubtitle,
|
||||||
selected: self.state.connectionMode == .local)
|
selected: self.state.connectionMode == .local)
|
||||||
{
|
{
|
||||||
self.selectLocalGateway()
|
self.selectLocalGateway()
|
||||||
@@ -1318,6 +1336,7 @@ struct OnboardingView: View {
|
|||||||
if shouldMonitor, !self.monitoringDiscovery {
|
if shouldMonitor, !self.monitoringDiscovery {
|
||||||
self.monitoringDiscovery = true
|
self.monitoringDiscovery = true
|
||||||
self.gatewayDiscovery.start()
|
self.gatewayDiscovery.start()
|
||||||
|
Task { await self.refreshLocalGatewayProbe() }
|
||||||
} else if !shouldMonitor, self.monitoringDiscovery {
|
} else if !shouldMonitor, self.monitoringDiscovery {
|
||||||
self.monitoringDiscovery = false
|
self.monitoringDiscovery = false
|
||||||
self.gatewayDiscovery.stop()
|
self.gatewayDiscovery.stop()
|
||||||
@@ -1391,6 +1410,27 @@ struct OnboardingView: View {
|
|||||||
GatewayEnvironment.check()
|
GatewayEnvironment.check()
|
||||||
}.value
|
}.value
|
||||||
self.gatewayStatus = status
|
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