Nodes: auto-discover clawdis.internal

main
Peter Steinberger 2025-12-17 17:01:30 +01:00
parent e9ae10e569
commit 2ce24fdbf8
11 changed files with 140 additions and 174 deletions

View File

@ -27,7 +27,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
val manualHost: StateFlow<String> = runtime.manualHost val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort val manualPort: StateFlow<Int> = runtime.manualPort
val discoveryDomain: StateFlow<String> = runtime.discoveryDomain
val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages
val chatError: StateFlow<String?> = runtime.chatError val chatError: StateFlow<String?> = runtime.chatError
@ -57,10 +56,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualPort(value) runtime.setManualPort(value)
} }
fun setDiscoveryDomain(value: String) {
runtime.setDiscoveryDomain(value)
}
fun setWakeWords(words: List<String>) { fun setWakeWords(words: List<String>) {
runtime.setWakeWords(words) runtime.setWakeWords(words)
} }

View File

@ -33,7 +33,7 @@ class NodeRuntime(context: Context) {
val camera = CameraCaptureManager(appContext) val camera = CameraCaptureManager(appContext)
private val json = Json { ignoreUnknownKeys = true } 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<List<BridgeEndpoint>> = discovery.bridges val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
@ -82,7 +82,6 @@ class NodeRuntime(context: Context) {
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
val manualHost: StateFlow<String> = prefs.manualHost val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort val manualPort: StateFlow<Int> = prefs.manualPort
val discoveryDomain: StateFlow<String> = prefs.discoveryDomain
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
private var didAutoConnect = false private var didAutoConnect = false
@ -158,10 +157,6 @@ class NodeRuntime(context: Context) {
prefs.setManualPort(value) prefs.setManualPort(value)
} }
fun setDiscoveryDomain(value: String) {
prefs.setDiscoveryDomain(value)
}
fun setWakeWords(words: List<String>) { fun setWakeWords(words: List<String>) {
prefs.setWakeWords(words) prefs.setWakeWords(words)
scheduleWakeWordsSyncIfNeeded() scheduleWakeWordsSyncIfNeeded()

View File

@ -50,9 +50,6 @@ class SecurePrefs(context: Context) {
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790)) private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
val manualPort: StateFlow<Int> = _manualPort val manualPort: StateFlow<Int> = _manualPort
private val _discoveryDomain = MutableStateFlow(prefs.getString("bridge.discovery.domain", "")!!)
val discoveryDomain: StateFlow<String> = _discoveryDomain
private val _lastDiscoveredStableId = private val _lastDiscoveredStableId =
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!) MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
@ -93,12 +90,6 @@ class SecurePrefs(context: Context) {
_manualPort.value = value _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? { fun loadBridgeToken(): String? {
val key = "bridge.token.${_instanceId.value}" val key = "bridge.token.${_instanceId.value}"
return prefs.getString(key, null) return prefs.getString(key, null)

View File

@ -13,7 +13,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xbill.DNS.Lookup import org.xbill.DNS.Lookup
import org.xbill.DNS.PTRRecord import org.xbill.DNS.PTRRecord
@ -24,16 +23,16 @@ import org.xbill.DNS.Type
class BridgeDiscovery( class BridgeDiscovery(
context: Context, context: Context,
private val scope: CoroutineScope, private val scope: CoroutineScope,
discoveryDomain: StateFlow<String>,
) { ) {
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val serviceType = "_clawdis-bridge._tcp." private val serviceType = "_clawdis-bridge._tcp."
private val wideAreaDomain = "clawdis.internal."
private val byId = ConcurrentHashMap<String, BridgeEndpoint>() private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList()) private val _bridges = MutableStateFlow<List<BridgeEndpoint>>(emptyList())
val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow() val bridges: StateFlow<List<BridgeEndpoint>> = _bridges.asStateFlow()
private var activeDomain: String = normalizeDomain(discoveryDomain.value)
private var unicastJob: Job? = null private var unicastJob: Job? = null
private val discoveryListener = private val discoveryListener =
@ -50,36 +49,14 @@ class BridgeDiscovery(
override fun onServiceLost(serviceInfo: NsdServiceInfo) { override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val id = stableId(serviceInfo.serviceName, "local.") val id = stableId(serviceInfo.serviceName, "local.")
byId.remove(id) localById.remove(id)
publish() publish()
} }
} }
init { init {
scope.launch { startLocalDiscovery()
discoveryDomain.collect { raw -> startUnicastDiscovery(wideAreaDomain)
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)
}
} }
private fun startLocalDiscovery() { private fun startLocalDiscovery() {
@ -125,7 +102,7 @@ class BridgeDiscovery(
val displayName = txt(resolved, "displayName") ?: resolved.serviceName val displayName = txt(resolved, "displayName") ?: resolved.serviceName
val id = stableId(resolved.serviceName, "local.") 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() publish()
} }
}, },
@ -133,7 +110,8 @@ class BridgeDiscovery(
} }
private fun publish() { 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 { 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) next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
} }
byId.clear() unicastById.clear()
byId.putAll(next) unicastById.putAll(next)
publish() publish()
} }
@ -197,12 +175,6 @@ class BridgeDiscovery(
return normalizeName(stripTrailingDot(withoutSuffix)) 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 { private fun stripTrailingDot(raw: String): String {
return raw.removeSuffix(".") return raw.removeSuffix(".")
} }

View File

@ -51,7 +51,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualEnabled by viewModel.manualEnabled.collectAsState() val manualEnabled by viewModel.manualEnabled.collectAsState()
val manualHost by viewModel.manualHost.collectAsState() val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState() val manualPort by viewModel.manualPort.collectAsState()
val discoveryDomain by viewModel.discoveryDomain.collectAsState()
val statusText by viewModel.statusText.collectAsState() val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState() val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.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") 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 { item {
OutlinedTextField( OutlinedTextField(
value = manualHost, value = manualHost,

View File

@ -22,7 +22,6 @@ final class BridgeConnectionController {
BridgeSettingsStore.bootstrapPersistence() BridgeSettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs")) self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.setServiceDomain(defaults.string(forKey: "bridge.discovery.domain"))
self.updateFromDiscovery() self.updateFromDiscovery()
self.observeDiscovery() self.observeDiscovery()
@ -36,10 +35,6 @@ final class BridgeConnectionController {
self.discovery.setDebugLoggingEnabled(enabled) self.discovery.setDebugLoggingEnabled(enabled)
} }
func setDiscoveryDomain(_ domain: String?) {
self.discovery.setServiceDomain(domain)
}
func setScenePhase(_ phase: ScenePhase) { func setScenePhase(_ phase: ScenePhase) {
switch phase { switch phase {
case .background: case .background:

View File

@ -24,10 +24,11 @@ final class BridgeDiscoveryModel {
var statusText: String = "Idle" var statusText: String = "Idle"
private(set) var debugLog: [DebugLogEntry] = [] 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 debugLoggingEnabled = false
private var lastStableIDs = Set<String>() private var lastStableIDs = Set<String>()
private var serviceDomain: String = ClawdisBonjour.bridgeServiceDomain
func setDebugLoggingEnabled(_ enabled: Bool) { func setDebugLoggingEnabled(_ enabled: Bool) {
let wasEnabled = self.debugLoggingEnabled 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() { func start() {
if self.browser != nil { return } if !self.browsers.isEmpty { return }
self.appendDebugLog("start()") 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 for domain in ClawdisBonjour.bridgeServiceDomains {
Task { @MainActor in let params = NWParameters.tcp
guard let self else { return } params.includePeerToPeer = true
switch state { let browser = NWBrowser(
case .setup: for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: domain),
self.statusText = "Setup" using: params)
self.appendDebugLog("state: setup")
case .ready: browser.stateUpdateHandler = { [weak self] state in
self.statusText = "Searching…" Task { @MainActor in
self.appendDebugLog("state: ready") guard let self else { return }
case let .failed(err): self.statesByDomain[domain] = state
self.statusText = "Failed: \(err)" self.updateStatusText()
self.appendDebugLog("state: failed (\(err))") self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
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")
} }
} }
}
browser.browseResultsChangedHandler = { [weak self] results, _ in browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
let next = results.compactMap { result -> DiscoveredBridge? in self.bridgesByDomain[domain] = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint { switch result.endpoint {
case let .service(name, _, _, _): case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name) let decodedName = BonjourEscapes.decode(name)
let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"] let advertisedName = result.endpoint.txtRecord?.dictionary["displayName"]
let prettyAdvertised = advertisedName let prettyAdvertised = advertisedName
.map(Self.prettifyInstanceName) .map(Self.prettifyInstanceName)
.flatMap { $0.isEmpty ? nil : $0 } .flatMap { $0.isEmpty ? nil : $0 }
let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName) let prettyName = prettyAdvertised ?? Self.prettifyInstanceName(decodedName)
return DiscoveredBridge( return DiscoveredBridge(
name: prettyName, name: prettyName,
endpoint: result.endpoint, endpoint: result.endpoint,
stableID: BridgeEndpointID.stableID(result.endpoint), stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint)) debugID: BridgeEndpointID.prettyDescription(result.endpoint))
default: default:
return nil return nil
}
} }
} .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
let nextIDs = Set(next.map(\.stableID)) self.recomputeBridges()
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
} }
}
self.browser = browser self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery")) browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.ios.bridge-discovery.\(domain)"))
}
} }
func stop() { func stop() {
self.appendDebugLog("stop()") self.appendDebugLog("stop()")
self.browser?.cancel() for browser in self.browsers.values {
self.browser = nil browser.cancel()
}
self.browsers = [:]
self.bridgesByDomain = [:]
self.statesByDomain = [:]
self.bridges = [] self.bridges = []
self.statusText = "Stopped" 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) { private func appendDebugLog(_ message: String) {
guard self.debugLoggingEnabled else { return } guard self.debugLoggingEnabled else { return }
self.debugLog.append(DebugLogEntry(ts: Date(), message: message)) self.debugLog.append(DebugLogEntry(ts: Date(), message: message))

View File

@ -26,7 +26,6 @@ struct SettingsTab: View {
@AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false
@AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" @AppStorage("bridge.manual.host") private var manualBridgeHost: String = ""
@AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 @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 @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@State private var connectStatus = ConnectStatusStore() @State private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String? @State private var connectingBridgeID: String?
@ -133,20 +132,6 @@ struct SettingsTab: View {
TextField("Port", value: self.$manualBridgePort, format: .number) TextField("Port", value: self.$manualBridgePort, format: .number)
.keyboardType(.numberPad) .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 { Button {
Task { await self.connectManual() } Task { await self.connectManual() }
} label: { } label: {
@ -194,7 +179,6 @@ struct SettingsTab: View {
} }
.onAppear { .onAppear {
self.localIPAddress = Self.primaryIPv4Address() self.localIPAddress = Self.primaryIPv4Address()
self.bridgeController.setDiscoveryDomain(self.discoveryDomain)
} }
.onChange(of: self.preferredBridgeStableID) { _, newValue in .onChange(of: self.preferredBridgeStableID) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -4,6 +4,12 @@ public enum ClawdisBonjour {
// v0: internal-only, subject to rename. // v0: internal-only, subject to rename.
public static let bridgeServiceType = "_clawdis-bridge._tcp" public static let bridgeServiceType = "_clawdis-bridge._tcp"
public static let bridgeServiceDomain = "local." public static let bridgeServiceDomain = "local."
public static let wideAreaBridgeServiceDomain = "clawdis.internal."
public static let bridgeServiceDomains = [
bridgeServiceDomain,
wideAreaBridgeServiceDomain,
]
public static func normalizeServiceDomain(_ raw: String?) -> String { public static func normalizeServiceDomain(_ raw: String?) -> String {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -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 machines Tailscale IP instead: For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machines Tailscale IP instead:
- Set `CLAWDIS_BRIDGE_HOST=<TAILNET_IPV4>` on the gateway host. - Set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json` on the gateway host.
- Restart the Gateway / macOS menubar app. - Restart the Gateway / macOS menubar app.
## 2) Verify discovery (optional) ## 2) Verify discovery (optional)
@ -53,7 +53,6 @@ Android NSD/mDNS discovery wont 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. 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. 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`. Details and example CoreDNS config: `docs/bonjour.md`.

View File

@ -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 machines Tailscale IP instead: For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machines Tailscale IP instead:
- Set `CLAWDIS_BRIDGE_HOST=<TAILNET_IPV4>` on the gateway host. - Set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json` on the gateway host.
- Restart the Gateway / macOS menubar app. - Restart the Gateway / macOS menubar app.
## 2) Verify Bonjour discovery (optional but recommended) ## 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. 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. 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`. Details and example CoreDNS config: `docs/bonjour.md`.