diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt index 92a360c94..527aa470a 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt @@ -27,7 +27,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val manualEnabled: StateFlow = runtime.manualEnabled val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort - val discoveryDomain: StateFlow = runtime.discoveryDomain val chatMessages: StateFlow> = runtime.chatMessages val chatError: StateFlow = runtime.chatError @@ -57,10 +56,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setManualPort(value) } - fun setDiscoveryDomain(value: String) { - runtime.setDiscoveryDomain(value) - } - fun setWakeWords(words: List) { runtime.setWakeWords(words) } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 0c2867463..60e2093be 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -33,7 +33,7 @@ class NodeRuntime(context: Context) { val camera = CameraCaptureManager(appContext) private val json = Json { ignoreUnknownKeys = true } - private val discovery = BridgeDiscovery(appContext, scope = scope, discoveryDomain = prefs.discoveryDomain) + private val discovery = BridgeDiscovery(appContext, scope = scope) val bridges: StateFlow> = discovery.bridges private val _isConnected = MutableStateFlow(false) @@ -82,7 +82,6 @@ class NodeRuntime(context: Context) { val manualEnabled: StateFlow = prefs.manualEnabled val manualHost: StateFlow = prefs.manualHost val manualPort: StateFlow = prefs.manualPort - val discoveryDomain: StateFlow = prefs.discoveryDomain val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId private var didAutoConnect = false @@ -158,10 +157,6 @@ class NodeRuntime(context: Context) { prefs.setManualPort(value) } - fun setDiscoveryDomain(value: String) { - prefs.setDiscoveryDomain(value) - } - fun setWakeWords(words: List) { prefs.setWakeWords(words) scheduleWakeWordsSyncIfNeeded() diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt index d41195535..b6dd875e8 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt @@ -50,9 +50,6 @@ class SecurePrefs(context: Context) { private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790)) val manualPort: StateFlow = _manualPort - private val _discoveryDomain = MutableStateFlow(prefs.getString("bridge.discovery.domain", "")!!) - val discoveryDomain: StateFlow = _discoveryDomain - private val _lastDiscoveredStableId = MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!) val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId @@ -93,12 +90,6 @@ class SecurePrefs(context: Context) { _manualPort.value = value } - fun setDiscoveryDomain(value: String) { - val trimmed = value.trim() - prefs.edit().putString("bridge.discovery.domain", trimmed).apply() - _discoveryDomain.value = trimmed - } - fun loadBridgeToken(): String? { val key = "bridge.token.${_instanceId.value}" return prefs.getString(key, null) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt index 84915fbbf..9725d4b25 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import org.xbill.DNS.Lookup import org.xbill.DNS.PTRRecord @@ -24,16 +23,16 @@ import org.xbill.DNS.Type class BridgeDiscovery( context: Context, private val scope: CoroutineScope, - discoveryDomain: StateFlow, ) { private val nsd = context.getSystemService(NsdManager::class.java) private val serviceType = "_clawdis-bridge._tcp." + private val wideAreaDomain = "clawdis.internal." - private val byId = ConcurrentHashMap() + private val localById = ConcurrentHashMap() + private val unicastById = ConcurrentHashMap() private val _bridges = MutableStateFlow>(emptyList()) val bridges: StateFlow> = _bridges.asStateFlow() - private var activeDomain: String = normalizeDomain(discoveryDomain.value) private var unicastJob: Job? = null private val discoveryListener = @@ -50,36 +49,14 @@ class BridgeDiscovery( override fun onServiceLost(serviceInfo: NsdServiceInfo) { val id = stableId(serviceInfo.serviceName, "local.") - byId.remove(id) + localById.remove(id) publish() } } init { - scope.launch { - discoveryDomain.collect { raw -> - val normalized = normalizeDomain(raw) - if (normalized == activeDomain) return@collect - activeDomain = normalized - restartDiscovery() - } - } - restartDiscovery() - } - - private fun restartDiscovery() { - byId.clear() - publish() - - stopLocalDiscovery() - unicastJob?.cancel() - unicastJob = null - - if (activeDomain == "local.") { - startLocalDiscovery() - } else { - startUnicastDiscovery(activeDomain) - } + startLocalDiscovery() + startUnicastDiscovery(wideAreaDomain) } private fun startLocalDiscovery() { @@ -125,7 +102,7 @@ class BridgeDiscovery( val displayName = txt(resolved, "displayName") ?: resolved.serviceName val id = stableId(resolved.serviceName, "local.") - byId[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port) + localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port) publish() } }, @@ -133,7 +110,8 @@ class BridgeDiscovery( } private fun publish() { - _bridges.value = byId.values.sortedBy { it.name.lowercase() } + _bridges.value = + (localById.values + unicastById.values).sortedBy { it.name.lowercase() } } private fun stableId(serviceName: String, domain: String): String { @@ -181,8 +159,8 @@ class BridgeDiscovery( next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port) } - byId.clear() - byId.putAll(next) + unicastById.clear() + unicastById.putAll(next) publish() } @@ -197,12 +175,6 @@ class BridgeDiscovery( return normalizeName(stripTrailingDot(withoutSuffix)) } - private fun normalizeDomain(raw: String): String { - val trimmed = raw.trim().lowercase() - if (trimmed.isEmpty() || trimmed == "local" || trimmed == "local.") return "local." - return if (trimmed.endsWith(".")) trimmed else "$trimmed." - } - private fun stripTrailingDot(raw: String): String { return raw.removeSuffix(".") } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt index e11d0ef59..6bfa0b428 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt @@ -51,7 +51,6 @@ fun SettingsSheet(viewModel: MainViewModel) { val manualEnabled by viewModel.manualEnabled.collectAsState() val manualHost by viewModel.manualHost.collectAsState() val manualPort by viewModel.manualPort.collectAsState() - val discoveryDomain by viewModel.discoveryDomain.collectAsState() val statusText by viewModel.statusText.collectAsState() val serverName by viewModel.serverName.collectAsState() val remoteAddress by viewModel.remoteAddress.collectAsState() @@ -182,15 +181,6 @@ fun SettingsSheet(viewModel: MainViewModel) { Text(if (manualEnabled) "Manual Bridge Enabled" else "Manual Bridge Disabled") } } - item { - OutlinedTextField( - value = discoveryDomain, - onValueChange = viewModel::setDiscoveryDomain, - label = { Text("Discovery Domain (leave empty for local.)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - } item { OutlinedTextField( value = manualHost, diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 122c00b97..2e6e4d01a 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -22,7 +22,6 @@ final class BridgeConnectionController { BridgeSettingsStore.bootstrapPersistence() let defaults = UserDefaults.standard self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs")) - self.discovery.setServiceDomain(defaults.string(forKey: "bridge.discovery.domain")) self.updateFromDiscovery() self.observeDiscovery() @@ -36,10 +35,6 @@ final class BridgeConnectionController { self.discovery.setDebugLoggingEnabled(enabled) } - func setDiscoveryDomain(_ domain: String?) { - self.discovery.setServiceDomain(domain) - } - func setScenePhase(_ phase: ScenePhase) { switch phase { case .background: diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index 820e8929d..56e250cba 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -24,10 +24,11 @@ final class BridgeDiscoveryModel { var statusText: String = "Idle" private(set) var debugLog: [DebugLogEntry] = [] - private var browser: NWBrowser? + private var browsers: [String: NWBrowser] = [:] + private var bridgesByDomain: [String: [DiscoveredBridge]] = [:] + private var statesByDomain: [String: NWBrowser.State] = [:] private var debugLoggingEnabled = false private var lastStableIDs = Set() - private var serviceDomain: String = ClawdisBonjour.bridgeServiceDomain func setDebugLoggingEnabled(_ enabled: Bool) { let wasEnabled = self.debugLoggingEnabled @@ -40,103 +41,142 @@ final class BridgeDiscoveryModel { } } - func setServiceDomain(_ domain: String?) { - let normalized = ClawdisBonjour.normalizeServiceDomain(domain) - guard normalized != self.serviceDomain else { return } - self.appendDebugLog("service domain: \(self.serviceDomain) → \(normalized)") - self.serviceDomain = normalized - - if self.browser != nil { - self.stop() - self.start() - } - } - func start() { - if self.browser != nil { return } + if !self.browsers.isEmpty { return } self.appendDebugLog("start()") - let params = NWParameters.tcp - params.includePeerToPeer = true - let browser = NWBrowser( - for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: self.serviceDomain), - using: params) - browser.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - guard let self else { return } - switch state { - case .setup: - self.statusText = "Setup" - self.appendDebugLog("state: setup") - case .ready: - self.statusText = "Searching…" - self.appendDebugLog("state: ready") - case let .failed(err): - self.statusText = "Failed: \(err)" - self.appendDebugLog("state: failed (\(err))") - self.browser?.cancel() - self.browser = nil - case .cancelled: - self.statusText = "Stopped" - self.appendDebugLog("state: cancelled") - self.browser = nil - case let .waiting(err): - self.statusText = "Waiting: \(err)" - self.appendDebugLog("state: waiting (\(err))") - @unknown default: - self.statusText = "Unknown" - self.appendDebugLog("state: unknown") + for domain in ClawdisBonjour.bridgeServiceDomains { + let params = NWParameters.tcp + params.includePeerToPeer = true + let browser = NWBrowser( + for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain), + using: params) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self else { return } + self.statesByDomain[domain] = state + self.updateStatusText() + self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))") } } - } - browser.browseResultsChangedHandler = { [weak self] results, _ in - Task { @MainActor in - guard let self else { return } - let next = results.compactMap { result -> DiscoveredBridge? in - switch result.endpoint { - case let .service(name, _, _, _): - let decodedName = BonjourEscapes.decode(name) - let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"] - let prettyAdvertised = advertisedName - .map(Self.prettifyInstanceName) - .flatMap { $0.isEmpty ? nil : $0 } - let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) - return DiscoveredBridge( - name: prettyName, - endpoint: result.endpoint, - stableID: BridgeEndpointID.stableID(result.endpoint), - debugID: BridgeEndpointID.prettyDescription(result.endpoint)) - default: - return nil + browser.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor in + guard let self else { return } + self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in + switch result.endpoint { + case let .service(name, _, _, _): + let decodedName = BonjourEscapes.decode(name) + let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"] + let prettyAdvertised = advertisedName + .map(Self.prettifyInstanceName) + .flatMap { $0.isEmpty ? nil : $0 } + let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) + return DiscoveredBridge( + name: prettyName, + endpoint: result.endpoint, + stableID: BridgeEndpointID.stableID(result.endpoint), + debugID: BridgeEndpointID.prettyDescription(result.endpoint)) + default: + return nil + } } - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - let nextIDs = Set(next.map(\.stableID)) - let added = nextIDs.subtracting(self.lastStableIDs) - let removed = self.lastStableIDs.subtracting(nextIDs) - if !added.isEmpty || !removed.isEmpty { - self.appendDebugLog( - "results: total=\(next.count) added=\(added.count) removed=\(removed.count)") + self.recomputeBridges() } - self.lastStableIDs = nextIDs - self.bridges = next } - } - self.browser = browser - browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery")) + self.browsers[domain] = browser + browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery.\(domain)")) + } } func stop() { self.appendDebugLog("stop()") - self.browser?.cancel() - self.browser = nil + for browser in self.browsers.values { + browser.cancel() + } + self.browsers = [:] + self.bridgesByDomain = [:] + self.statesByDomain = [:] self.bridges = [] self.statusText = "Stopped" } + private func recomputeBridges() { + let next = self.bridgesByDomain.values + .flatMap { $0 } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + let nextIDs = Set(next.map(\.stableID)) + let added = nextIDs.subtracting(self.lastStableIDs) + let removed = self.lastStableIDs.subtracting(nextIDs) + if !added.isEmpty || !removed.isEmpty { + self.appendDebugLog("results: total=\(next.count) added=\(added.count) removed=\(removed.count)") + } + self.lastStableIDs = nextIDs + self.bridges = next + } + + private func updateStatusText() { + let states = Array(self.statesByDomain.values) + if states.isEmpty { + self.statusText = self.browsers.isEmpty ? "Idle" : "Setup" + return + } + + if let failed = states.first(where: { state in + if case .failed = state { return true } + return false + }) { + if case let .failed(err) = failed { + self.statusText = "Failed: \(err)" + return + } + } + + if let waiting = states.first(where: { state in + if case .waiting = state { return true } + return false + }) { + if case let .waiting(err) = waiting { + self.statusText = "Waiting: \(err)" + return + } + } + + if states.contains(where: { if case .ready = $0 { return true } else { return false } }) { + self.statusText = "Searching…" + return + } + + if states.contains(where: { if case .setup = $0 { return true } else { return false } }) { + self.statusText = "Setup" + return + } + + self.statusText = "Searching…" + } + + private static func prettyState(_ state: NWBrowser.State) -> String { + switch state { + case .setup: + "setup" + case .ready: + "ready" + case let .failed(err): + "failed (\(err))" + case .cancelled: + "cancelled" + case let .waiting(err): + "waiting (\(err))" + @unknown default: + "unknown" + } + } + private func appendDebugLog(_ message: String) { guard self.debugLoggingEnabled else { return } self.debugLog.append(DebugLogEntry(ts: Date(), message: message)) diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 446345482..b226db058 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -26,7 +26,6 @@ struct SettingsTab: View { @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false @AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 - @AppStorage("bridge.discovery.domain") private var discoveryDomain: String = "" @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @State private var connectStatus = ConnectStatusStore() @State private var connectingBridgeID: String? @@ -133,20 +132,6 @@ struct SettingsTab: View { TextField("Port", value: self.$manualBridgePort, format: .number) .keyboardType(.numberPad) - TextField("Discovery Domain", text: self.$discoveryDomain) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .onChange(of: self.discoveryDomain) { _, newValue in - self.bridgeController.setDiscoveryDomain(newValue) - } - - Text( - "Default discovery domain is “local.” (mDNS on the same LAN). " - + - "For Wide-Area Bonjour / Unicast DNS-SD (e.g. over Tailscale), set a unicast DNS zone like “clawdis.internal.” and configure Tailnet split DNS accordingly.") - .font(.footnote) - .foregroundStyle(.secondary) - Button { Task { await self.connectManual() } } label: { @@ -194,7 +179,6 @@ struct SettingsTab: View { } .onAppear { self.localIPAddress = Self.primaryIPv4Address() - self.bridgeController.setDiscoveryDomain(self.discoveryDomain) } .onChange(of: self.preferredBridgeStableID) { _, newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift index 45aed6a98..2577478ca 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift @@ -4,6 +4,12 @@ public enum ClawdisBonjour { // v0: internal-only, subject to rename. public static let bridgeServiceType = "_clawdis-bridge._tcp" public static let bridgeServiceDomain = "local." + public static let wideAreaBridgeServiceDomain = "clawdis.internal." + + public static let bridgeServiceDomains = [ + bridgeServiceDomain, + wideAreaBridgeServiceDomain, + ] public static func normalizeServiceDomain(_ raw: String?) -> String { let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/docs/android/connect.md b/docs/android/connect.md index c7456b8b2..8d10d70d5 100644 --- a/docs/android/connect.md +++ b/docs/android/connect.md @@ -34,7 +34,7 @@ Confirm in logs you see something like: For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead: -- Set `CLAWDIS_BRIDGE_HOST=` on the gateway host. +- Set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json` on the gateway host. - Restart the Gateway / macOS menubar app. ## 2) Verify discovery (optional) @@ -53,7 +53,6 @@ Android NSD/mDNS discovery won’t cross networks. If your Android node and the 1) Set up a DNS-SD zone (example `clawdis.internal.`) on the gateway host and publish `_clawdis-bridge._tcp` records. 2) Configure Tailscale split DNS for `clawdis.internal` pointing at that DNS server. -3) In the Android app: Settings → Advanced → set **Discovery Domain** to `clawdis.internal.` Details and example CoreDNS config: `docs/bonjour.md`. diff --git a/docs/ios/connect.md b/docs/ios/connect.md index 042f57173..c9312b388 100644 --- a/docs/ios/connect.md +++ b/docs/ios/connect.md @@ -36,7 +36,7 @@ Confirm in logs you see something like: For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead: -- Set `CLAWDIS_BRIDGE_HOST=` on the gateway host. +- Set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json` on the gateway host. - Restart the Gateway / macOS menubar app. ## 2) Verify Bonjour discovery (optional but recommended) @@ -63,7 +63,6 @@ If Iris and the gateway are on different networks but connected via Tailscale, m 1) Set up a DNS-SD zone (example `clawdis.internal.`) on the gateway host and publish `_clawdis-bridge._tcp` records. 2) Configure Tailscale split DNS for `clawdis.internal` pointing at that DNS server. -3) In Iris: Settings → Bridge → Advanced → set **Discovery Domain** to `clawdis.internal.` Details and example CoreDNS config: `docs/bonjour.md`.