Compare commits

..
15 changed files with 54 additions and 691 deletions

No files matched your search

@@ -8,7 +8,6 @@ import SwiftUI
final class BridgeConnectionController: ObservableObject {
@Published private(set) var bridges: [BridgeDiscoveryModel.DiscoveredBridge] = []
@Published private(set) var discoveryStatusText: String = "Idle"
@Published private(set) var discoveryDebugLog: [BridgeDiscoveryModel.DebugLogEntry] = []
private let discovery = BridgeDiscoveryModel()
private weak var appModel: NodeAppModel?
@@ -20,8 +19,6 @@ final class BridgeConnectionController: ObservableObject {
self.appModel = appModel
BridgeSettingsStore.bootstrapPersistence()
self.discovery.setDebugLoggingEnabled(
UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs"))
self.discovery.$bridges
.sink { [weak self] newValue in
@@ -35,18 +32,11 @@ final class BridgeConnectionController: ObservableObject {
self.discovery.$statusText
.assign(to: &self.$discoveryStatusText)
self.discovery.$debugLog
.assign(to: &self.$discoveryDebugLog)
if startDiscovery {
self.discovery.start()
}
}
func setDiscoveryDebugLoggingEnabled(_ enabled: Bool) {
self.discovery.setDebugLoggingEnabled(enabled)
}
func setScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
@@ -1,69 +0,0 @@
import SwiftUI
import UIKit
struct BridgeDiscoveryDebugLogView: View {
@EnvironmentObject private var bridgeController: BridgeConnectionController
@AppStorage("bridge.discovery.debugLogs") private var debugLogsEnabled: Bool = false
var body: some View {
List {
if !self.debugLogsEnabled {
Text("Enable “Discovery Debug Logs” to start collecting events.")
.foregroundStyle(.secondary)
}
if self.bridgeController.discoveryDebugLog.isEmpty {
Text("No log entries yet.")
.foregroundStyle(.secondary)
} else {
ForEach(self.bridgeController.discoveryDebugLog) { entry in
VStack(alignment: .leading, spacing: 2) {
Text(Self.formatTime(entry.ts))
.font(.caption)
.foregroundStyle(.secondary)
Text(entry.message)
.font(.callout)
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Discovery Logs")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Copy") {
UIPasteboard.general.string = self.formattedLog()
}
.disabled(self.bridgeController.discoveryDebugLog.isEmpty)
}
}
}
private func formattedLog() -> String {
self.bridgeController.discoveryDebugLog
.map { "\(Self.formatISO($0.ts)) \($0.message)" }
.joined(separator: "\n")
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static func formatTime(_ date: Date) -> String {
self.timeFormatter.string(from: date)
}
private static func formatISO(_ date: Date) -> String {
self.isoFormatter.string(from: date)
}
}
@@ -4,12 +4,6 @@ import Network
@MainActor
final class BridgeDiscoveryModel: ObservableObject {
struct DebugLogEntry: Identifiable, Equatable {
var id = UUID()
var ts: Date
var message: String
}
struct DiscoveredBridge: Identifiable, Equatable {
var id: String { self.stableID }
var name: String
@@ -20,26 +14,11 @@ final class BridgeDiscoveryModel: ObservableObject {
@Published var bridges: [DiscoveredBridge] = []
@Published var statusText: String = "Idle"
@Published private(set) var debugLog: [DebugLogEntry] = []
private var browser: NWBrowser?
private var debugLoggingEnabled = false
private var lastStableIDs = Set<String>()
func setDebugLoggingEnabled(_ enabled: Bool) {
let wasEnabled = self.debugLoggingEnabled
self.debugLoggingEnabled = enabled
if !enabled {
self.debugLog = []
} else if !wasEnabled {
self.appendDebugLog("debug logging enabled")
self.appendDebugLog("snapshot: status=\(self.statusText) bridges=\(self.bridges.count)")
}
}
func start() {
if self.browser != nil { return }
self.appendDebugLog("start()")
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
@@ -52,25 +31,16 @@ final class BridgeDiscoveryModel: ObservableObject {
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")
}
}
}
@@ -78,7 +48,7 @@ final class BridgeDiscoveryModel: ObservableObject {
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
let next = results.compactMap { result -> DiscoveredBridge? in
self.bridges = results.compactMap { result -> DiscoveredBridge? in
switch result.endpoint {
case let .service(name, _, _, _):
let decodedName = BonjourEscapes.decode(name)
@@ -97,16 +67,6 @@ final class BridgeDiscoveryModel: ObservableObject {
}
}
.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
}
}
@@ -115,21 +75,12 @@ final class BridgeDiscoveryModel: ObservableObject {
}
func stop() {
self.appendDebugLog("stop()")
self.browser?.cancel()
self.browser = nil
self.bridges = []
self.statusText = "Stopped"
}
private func appendDebugLog(_ message: String) {
guard self.debugLoggingEnabled else { return }
self.debugLog.append(DebugLogEntry(ts: Date(), message: message))
if self.debugLog.count > 200 {
self.debugLog.removeFirst(self.debugLog.count - 200)
}
}
private static func prettifyInstanceName(_ decodedName: String) -> String {
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
let stripped = normalized.replacingOccurrences(of: " (Clawdis)", with: "")
@@ -24,7 +24,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.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
@StateObject private var connectStatus = ConnectStatusStore()
@State private var connectingBridgeID: String?
@State private var localIPAddress: String?
@@ -152,15 +151,6 @@ struct SettingsTab: View {
+ "The bridge runs on the gateway (default port 18790).")
.font(.footnote)
.foregroundStyle(.secondary)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
self.bridgeController.setDiscoveryDebugLoggingEnabled(newValue)
}
NavigationLink("Discovery Logs") {
BridgeDiscoveryDebugLogView()
}
}
}
}
@@ -26,6 +26,7 @@ let webChatPortKey = "clawdis.webChatPort"
let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"
let deepLinkAgentEnabledKey = "clawdis.deepLinkAgentEnabled"
let deepLinkKeyKey = "clawdis.deepLinkKey"
let modelCatalogPathKey = "clawdis.modelCatalogPath"
let modelCatalogReloadKey = "clawdis.modelCatalogReload"
+14 -7
View File
@@ -8,6 +8,8 @@ struct DebugSettings: View {
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
@AppStorage(canvasEnabledKey) private var canvasEnabled: Bool = true
@AppStorage(deepLinkAgentEnabledKey) private var deepLinkAgentEnabled: Bool = false
@State private var modelsCount: Int?
@State private var modelsLoading = false
@State private var modelsError: String?
@@ -149,6 +151,13 @@ struct DebugSettings: View {
"to an already-running gateway " +
"and will not start one itself.")
}
GridRow {
self.gridLabel("Deep links")
Toggle("", isOn: self.$deepLinkAgentEnabled)
.labelsHidden()
.toggleStyle(.checkbox)
.help("Enables handling of clawdis://agent?... deep links to trigger an agent run.")
}
}
let key = DeepLinkHandler.currentKey()
@@ -178,10 +187,6 @@ struct DebugSettings: View {
Spacer(minLength: 0)
}
Text("Deep links (clawdis://…) are always enabled; the key controls unattended runs.")
.font(.caption2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("Stdout / stderr")
.font(.caption.weight(.semibold))
@@ -479,9 +484,11 @@ struct DebugSettings: View {
private var canvasSection: some View {
GroupBox("Canvas") {
VStack(alignment: .leading, spacing: 10) {
Text("Enable/disable Canvas in General settings.")
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Allow Canvas (agent)", isOn: self.$canvasEnabled)
.toggleStyle(.checkbox)
.help(
"When off, agent Canvas requests return “Canvas disabled by user”. " +
"Manual debug actions still work.")
HStack(spacing: 8) {
TextField("Session", text: self.$canvasSessionKey)
@@ -17,6 +17,12 @@ final class DeepLinkHandler {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
return
}
guard UserDefaults.standard.bool(forKey: deepLinkAgentEnabledKey) else {
self.presentAlert(
title: "Deep links are disabled",
message: "Enable “Allow URL scheme (agent)” in Clawdis Debug Settings to accept clawdis:// links.")
return
}
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "Clawdis is paused", message: "Unpause Clawdis to run agent actions.")
return
@@ -151,10 +151,7 @@ struct GeneralSettings: View {
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
self.applyDiscoveredMaster(master)
}
.padding(.leading, 58)
@@ -4,9 +4,7 @@ import SwiftUI
// swiftlint:disable:next inclusive_language
struct MasterDiscoveryInlineList: View {
@ObservedObject var discovery: MasterDiscoveryModel
var currentTarget: String?
var onSelect: (MasterDiscoveryModel.DiscoveredMaster) -> Void
@State private var hoveredMasterID: MasterDiscoveryModel.DiscoveredMaster.ID?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -26,58 +24,22 @@ struct MasterDiscoveryInlineList: View {
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.masters.prefix(6)) { master in
let target = self.suggestedSSHTarget(master)
let selected = target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.onSelect(master)
}
self.onSelect(master)
} label: {
HStack(alignment: .center, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(master.displayName)
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let target {
Text(target)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
HStack(spacing: 8) {
Text(master.displayName)
.lineLimit(1)
Spacer()
if let host = master.tailnetDns ?? master.lanHost {
Text(host)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground(
selected: selected,
hovered: self.hoveredMasterID == master.id)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering in
self.hoveredMasterID = hovering ? master
.id : (self.hoveredMasterID == master.id ? nil : self.hoveredMasterID)
}
}
}
.padding(10)
@@ -86,24 +48,7 @@ struct MasterDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Click a discovered master to fill the SSH target.")
}
private func suggestedSSHTarget(_ master: MasterDiscoveryModel.DiscoveredMaster) -> String? {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
return target
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
if selected { return Color.accentColor.opacity(0.12) }
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
.help("Discover Clawdis masters on your LAN")
}
}
+10 -8
View File
@@ -190,10 +190,15 @@ struct OnboardingView: View {
Text("Security notice")
.font(.headline)
Text(
"The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " +
"including running commands, reading/writing files, and capturing screenshots — " +
"depending on the permissions you grant.\n\n" +
"Only enable Clawdis if you understand the risks and trust the prompts and integrations you use.")
"""
The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac,
including running
commands, reading/writing files, and capturing screenshots — depending on the
permissions you grant.
Only enable Clawdis if you understand the risks and trust the prompts
and integrations you use.
""")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -243,10 +248,7 @@ struct OnboardingView: View {
.frame(width: fieldWidth)
}
MasterDiscoveryInlineList(
discovery: self.masterDiscovery,
currentTarget: self.state.remoteTarget)
{ master in
MasterDiscoveryInlineList(discovery: self.masterDiscovery) { master in
self.applyDiscoveredMaster(master)
}
.frame(width: fieldWidth, alignment: .leading)
-21
View File
@@ -44,27 +44,6 @@ Useful built-in tools:
If browsing shows instances but resolving fails, you’re usually hitting a LAN policy / multicast issue.
## Debugging in Gateway logs
The Gateway writes a rolling log file (printed on startup as `gateway log file: ...`).
Look for `bonjour:` lines, especially:
- `bonjour: advertise failed ...` (probing/announce failure)
- `bonjour: ... name conflict resolved` / `hostname conflict resolved`
- `bonjour: watchdog detected non-announced service; attempting re-advertise ...` (self-heal attempt after sleep/interface churn)
## Debugging on iOS (Iris)
Iris discovers bridges via `NWBrowser` browsing `_clawdis-bridge._tcp`.
To capture what the browser is doing:
- Settings → Bridge → Advanced → enable **Discovery Debug Logs**
- Settings → Bridge → Advanced → open **Discovery Logs** → reproduce the “Searching…” / “No bridges found” case → **Copy**
The log includes browser state transitions (`ready`, `waiting`, `failed`, `cancelled`) and result-set changes (added/removed counts).
## Common failure modes
- **Bonjour doesn’t cross networks**: London/Vienna style setups require Tailnet (MagicDNS/IP) or SSH.
-116
View File
@@ -1,116 +0,0 @@
---
summary: "Planned first-run onboarding flow for Clawdis (local vs remote, Anthropic OAuth, workspace identity)"
read_when:
- Designing the macOS onboarding assistant
- Implementing Pi authentication or identity setup
---
<!-- {% raw %} -->
# Onboarding (macOS app)
This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for Pi, and set the agent’s identity + workspace.
## Page order (high level)
1) **Local vs Remote**
2) **(Local only)** Connect Claude (Anthropic OAuth) — optional, but recommended
3) **Identity** — name, theme, emoji
4) **Workspace** — create + populate `AGENTS.md` (and recommend git backup)
## 1) Local vs Remote
First question: where does the **Gateway** run?
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write Pi’s token store locally.
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
## 2) Local-only: Connect Claude (Anthropic OAuth)
This is the “bind Pi to Clawdis” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
### Recommended: OAuth
The macOS app should:
- Start the Anthropic OAuth (PKCE) flow in the user’s browser.
- Ask the user to paste the `code#state` value.
- Exchange it for tokens and write Pi-compatible credentials to:
- `~/.pi/agent/oauth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: it makes Pi work immediately (Clawdis doesn’t need a terminal and doesn’t need to re-implement Pi’s auth plumbing later).
### Alternative: API key (instructions only)
Offer an “API key” option, but for now it is **instructions only**:
- Get an Anthropic API key.
- Provide it to Pi (or to Clawdis’s Pi invocation) via your preferred mechanism.
Note: environment variables are often confusing when the Gateway is launched by a GUI app (launchd environment != your shell).
### Provider/model safety rule
Clawdis should **always pass** `--provider` and `--model` when invoking Pi (don’t rely on Pi defaults).
Until that is hard-coded, the equivalent configuration is:
```json5
{
inbound: {
reply: {
mode: "command",
command: [
"pi",
"--mode",
"rpc",
"--provider",
"anthropic",
"--model",
"claude-opus-4-5",
"{{BodyStripped}}"
],
agent: { kind: "pi", format: "json" }
}
}
}
```
If the user skips auth, onboarding should be clear: the agent likely won’t respond until auth is configured.
## 3) Identity (name + theme + emoji)
After auth (or skip), onboarding asks:
1) Agent **name** (e.g. “Samantha”)
2) Agent **theme/persona** (e.g. “space lobster”, “helpful sloth”)
3) Suggested **emoji** (based on theme; user can override)
Persist identity in two places:
- Workspace `AGENTS.md` (human-editable, lives with the agent’s “memory” files)
- `~/.clawdis/clawdis.json` (structured identity, used for defaults/UI)
“Use this name everywhere” should derive defaults like:
- outbound prefix emoji (`inbound.responsePrefix`)
- group mention patterns / wake words
- default session intro (“You are Samantha…”)
- macOS UI labels
## 4) Workspace (AGENTS.md + backup tip)
Onboarding should create a dedicated agent workspace (default `~/.clawdis/workspace`) and ensure it has an `AGENTS.md`.
Recommendation: treat the workspace as the agent’s “memory” and make it a git repo (ideally private) so identity + memories are backed up:
```bash
cd ~/.clawdis/workspace
git init
git add AGENTS.md
git commit -m "Add agent workspace"
```
## Remote mode note (why OAuth is hidden)
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where Pi runs).
For now, remote onboarding should:
- explain why OAuth isn’t shown
- point the user at the credential location (`~/.pi/agent/oauth.json`) and the workspace location on the gateway host
<!-- {% endraw %} -->
+2 -185
View File
@@ -5,26 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const createService = vi.fn();
const shutdown = vi.fn();
const logWarn = vi.fn();
const logDebug = vi.fn();
const getLoggerInfo = vi.fn();
vi.mock("../logger.js", () => {
return {
logWarn: (message: string) => logWarn(message),
logDebug: (message: string) => logDebug(message),
logInfo: vi.fn(),
logError: vi.fn(),
logSuccess: vi.fn(),
};
});
vi.mock("../logging.js", () => {
return {
getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
};
});
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
@@ -54,13 +34,8 @@ describe("gateway bonjour advertiser", () => {
for (const [key, value] of Object.entries(prevEnv)) {
process.env[key] = value;
}
createService.mockReset();
shutdown.mockReset();
logWarn.mockReset();
logDebug.mockReset();
getLoggerInfo.mockReset();
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -78,19 +53,7 @@ describe("gateway bonjour advertiser", () => {
setTimeout(resolve, 250);
}),
);
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
createService.mockReturnValue({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
@@ -133,141 +96,6 @@ describe("gateway bonjour advertiser", () => {
expect(shutdown).toHaveBeenCalledTimes(1);
});
it("attaches conflict listeners for services", async () => {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
process.env.NODE_ENV = "development";
vi.spyOn(os, "hostname").mockReturnValue("test-host");
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
const onCalls: Array<{ event: string }> = [];
createService.mockImplementation((options: Record<string, unknown>) => {
const on = vi.fn((event: string) => {
onCalls.push({ event });
});
return {
advertise,
destroy,
serviceState: "announced",
on,
getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
// 2 services × 2 listeners each
expect(onCalls.map((c) => c.event)).toEqual([
"name-change",
"hostname-change",
"name-change",
"hostname-change",
]);
await started.stop();
});
it("logs advertise failures and retries via watchdog", async () => {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
process.env.NODE_ENV = "development";
vi.useFakeTimers();
vi.spyOn(os, "hostname").mockReturnValue("test-host");
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi
.fn()
.mockRejectedValueOnce(new Error("boom")) // initial advertise fails
.mockResolvedValue(undefined); // watchdog retry succeeds
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "unannounced",
on: vi.fn(),
getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 0,
});
// initial advertise attempt happens immediately
expect(advertise).toHaveBeenCalledTimes(1);
// allow promise rejection handler to run
await Promise.resolve();
expect(logWarn).toHaveBeenCalledWith(
expect.stringContaining("advertise failed"),
);
// watchdog should attempt re-advertise at the 60s interval tick
await vi.advanceTimersByTimeAsync(60_000);
expect(advertise).toHaveBeenCalledTimes(2);
await started.stop();
await vi.advanceTimersByTimeAsync(120_000);
expect(advertise).toHaveBeenCalledTimes(2);
});
it("handles advertise throwing synchronously", async () => {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
process.env.NODE_ENV = "development";
vi.spyOn(os, "hostname").mockReturnValue("test-host");
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn(() => {
throw new Error("sync-fail");
});
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "unannounced",
on: vi.fn(),
getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 0,
});
expect(advertise).toHaveBeenCalledTimes(1);
expect(logWarn).toHaveBeenCalledWith(
expect.stringContaining("advertise threw"),
);
await started.stop();
});
it("normalizes hostnames with domains for service names", async () => {
// Allow advertiser to run in unit tests.
delete process.env.VITEST;
@@ -277,18 +105,7 @@ describe("gateway bonjour advertiser", () => {
const destroy = vi.fn().mockResolvedValue(undefined);
const advertise = vi.fn().mockResolvedValue(undefined);
createService.mockImplementation((options: Record<string, unknown>) => {
return {
advertise,
destroy,
serviceState: "announced",
on: vi.fn(),
getFQDN: () =>
`${String(options.type ?? "service")}.${String(options.domain ?? "local")}.`,
getHostname: () => String(options.hostname ?? "unknown"),
getPort: () => Number(options.port ?? -1),
};
});
createService.mockReturnValue({ advertise, destroy });
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
+8 -143
View File
@@ -1,8 +1,5 @@
import os from "node:os";
import { logDebug, logWarn } from "../logger.js";
import { getLogger } from "../logging.js";
export type GatewayBonjourAdvertiser = {
stop: () => Promise<void>;
};
@@ -35,45 +32,8 @@ function prettifyInstanceName(name: string) {
type BonjourService = {
advertise: () => Promise<void>;
destroy: () => Promise<void>;
getFQDN: () => string;
getHostname: () => string;
getPort: () => number;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
serviceState: string;
};
function formatBonjourError(err: unknown): string {
if (err instanceof Error) {
const msg = err.message || String(err);
return err.name && err.name !== "Error" ? `${err.name}: ${msg}` : msg;
}
return String(err);
}
function serviceSummary(label: string, svc: BonjourService): string {
let fqdn = "unknown";
let hostname = "unknown";
let port = -1;
try {
fqdn = svc.getFQDN();
} catch {
// ignore
}
try {
hostname = svc.getHostname();
} catch {
// ignore
}
try {
port = svc.getPort();
} catch {
// ignore
}
const state =
typeof svc.serviceState === "string" ? svc.serviceState : "unknown";
return `${label} fqdn=${fqdn} host=${hostname} port=${port} state=${state}`;
}
export async function startGatewayBonjourAdvertiser(
opts: GatewayBonjourAdvertiseOpts,
): Promise<GatewayBonjourAdvertiser> {
@@ -112,7 +72,7 @@ export async function startGatewayBonjourAdvertiser(
txtBase.tailnetDns = opts.tailnetDns.trim();
}
const services: Array<{ label: string; svc: BonjourService }> = [];
const services: BonjourService[] = [];
// Master beacon: used for discovery (auto-fill SSH/direct targets).
// We advertise a TCP service so clients can resolve the host; the port itself is informational.
@@ -128,10 +88,7 @@ export async function startGatewayBonjourAdvertiser(
sshPort: String(opts.sshPort ?? 22),
},
});
services.push({
label: "master",
svc: master as unknown as BonjourService,
});
services.push(master);
// Optional bridge beacon (same type used by Iris/iOS today).
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
@@ -147,113 +104,21 @@ export async function startGatewayBonjourAdvertiser(
transport: "bridge",
},
});
services.push({
label: "bridge",
svc: bridge as unknown as BonjourService,
});
}
logDebug(
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
safeServiceName(instanceName),
)}, gatewayPort=${opts.gatewayPort}, bridgePort=${opts.bridgePort ?? 0}, sshPort=${
opts.sshPort ?? 22
})`,
);
for (const { label, svc } of services) {
try {
svc.on("name-change", (name: unknown) => {
const next = typeof name === "string" ? name : String(name);
logWarn(
`bonjour: ${label} name conflict resolved; newName=${JSON.stringify(next)}`,
);
});
svc.on("hostname-change", (nextHostname: unknown) => {
const next =
typeof nextHostname === "string"
? nextHostname
: String(nextHostname);
logWarn(
`bonjour: ${label} hostname conflict resolved; newHostname=${JSON.stringify(next)}`,
);
});
} catch (err) {
logDebug(
`bonjour: failed to attach listeners for ${label}: ${String(err)}`,
);
}
services.push(bridge);
}
// Do not block gateway startup on mDNS probing/announce. Advertising can take
// multiple seconds depending on network state; the gateway should come up even
// if Bonjour is slow or fails.
for (const { label, svc } of services) {
try {
void svc
.advertise()
.then(() => {
// Keep this out of stdout/stderr (menubar + tests) but capture in the rolling log.
getLogger().info(`bonjour: advertised ${serviceSummary(label, svc)}`);
})
.catch((err) => {
logWarn(
`bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
});
} catch (err) {
logWarn(
`bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
for (const svc of services) {
void svc.advertise().catch(() => {
/* ignore */
});
}
// Watchdog: if we ever end up in an unannounced state (e.g. after sleep/wake or
// interface churn), try to re-advertise instead of requiring a full gateway restart.
const lastRepairAttempt = new Map<string, number>();
const watchdog = setInterval(() => {
for (const { label, svc } of services) {
const stateUnknown = (svc as { serviceState?: unknown }).serviceState;
if (typeof stateUnknown !== "string") continue;
if (stateUnknown === "announced" || stateUnknown === "announcing")
continue;
let key = label;
try {
key = `${label}:${svc.getFQDN()}`;
} catch {
// ignore
}
const now = Date.now();
const last = lastRepairAttempt.get(key) ?? 0;
if (now - last < 30_000) continue;
lastRepairAttempt.set(key, now);
logWarn(
`bonjour: watchdog detected non-announced service; attempting re-advertise (${serviceSummary(
label,
svc,
)})`,
);
try {
void svc.advertise().catch((err) => {
logWarn(
`bonjour: watchdog advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
});
} catch (err) {
logWarn(
`bonjour: watchdog advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
);
}
}
}, 60_000);
watchdog.unref?.();
return {
stop: async () => {
clearInterval(watchdog);
for (const { svc } of services) {
for (const svc of services) {
try {
await svc.destroy();
} catch {
+1 -3
View File
@@ -822,9 +822,7 @@ describe("web auto-reply", () => {
await run;
const heartbeatCall = replyResolver.mock.calls.find(
(call) =>
call[0]?.Body === HEARTBEAT_PROMPT &&
call[0]?.MessageSid === "sid-main",
(call) => call[0]?.Body === HEARTBEAT_PROMPT,
);
expect(heartbeatCall?.[0]?.From).toBe("+1555");
expect(heartbeatCall?.[0]?.To).toBe("+1555");