Merge pull request #960 from kkarimi/fix/mac-node-bridge-tunnel-865
macOS: prefer bridge tunnel port in remote mode
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -86,7 +86,28 @@
|
|||||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
||||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
||||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
||||||
|
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
||||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
||||||
|
|
||||||
|
#### Agents / Auth / Tools / Sandbox
|
||||||
|
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
||||||
|
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
||||||
|
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
||||||
|
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
||||||
|
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
||||||
|
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
||||||
|
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
||||||
|
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
||||||
|
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
||||||
|
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
||||||
|
|
||||||
|
#### macOS / Apps
|
||||||
|
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
||||||
|
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
||||||
|
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
||||||
|
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
||||||
|
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
||||||
|
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
||||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
||||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
||||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
||||||
|
|||||||
@@ -307,8 +307,8 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||||
urls.append(cwd.appendingPathComponent("docs")
|
urls.append(cwd.appendingPathComponent("docs")
|
||||||
.appendingPathComponent(self.templateDirname)
|
.appendingPathComponent(self.templateDirname)
|
||||||
.appendingPathComponent(named))
|
.appendingPathComponent(named))
|
||||||
return urls
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ struct AnthropicAuthControls: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(self.busy || self.connectionMode != .local || self.code
|
.disabled(self.busy || self.connectionMode != .local || self.code
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty)
|
.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ enum ClawdbotOAuthStore {
|
|||||||
static func oauthDir() -> URL {
|
static func oauthDir() -> URL {
|
||||||
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
|
if let override = ProcessInfo.processInfo.environment[self.clawdbotOAuthDirEnv]?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!override.isEmpty
|
!override.isEmpty
|
||||||
{
|
{
|
||||||
let expanded = NSString(string: override).expandingTildeInPath
|
let expanded = NSString(string: override).expandingTildeInPath
|
||||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||||
|
|||||||
@@ -264,8 +264,8 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let configHasRemoteUrl = !(configRemoteUrl?
|
let configHasRemoteUrl = !(configRemoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
|
||||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||||
@@ -356,8 +356,8 @@ final class AppState {
|
|||||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let hasRemoteUrl = !(remoteUrl?
|
let hasRemoteUrl = !(remoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
|
||||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||||
case "local":
|
case "local":
|
||||||
|
|||||||
@@ -182,12 +182,12 @@ actor BridgeServer {
|
|||||||
?? "main"
|
?? "main"
|
||||||
|
|
||||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: text,
|
message: text,
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
thinking: "low",
|
thinking: "low",
|
||||||
deliver: false,
|
deliver: false,
|
||||||
to: nil,
|
to: nil,
|
||||||
channel: .last))
|
channel: .last))
|
||||||
|
|
||||||
case "agent.request":
|
case "agent.request":
|
||||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||||
@@ -208,12 +208,12 @@ actor BridgeServer {
|
|||||||
let channel = GatewayAgentChannel(raw: link.channel)
|
let channel = GatewayAgentChannel(raw: link.channel)
|
||||||
|
|
||||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: message,
|
message: message,
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
deliver: link.deliver,
|
deliver: link.deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel))
|
channel: channel))
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
|||||||
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
|
guard let name = ClawdbotCanvasA2UIAction.extractActionName(userAction) else { return }
|
||||||
let actionId =
|
let actionId =
|
||||||
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
(userAction["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||||
?? UUID().uuidString
|
?? UUID().uuidString
|
||||||
|
|
||||||
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
canvasWindowLogger.info("A2UI action \(name, privacy: .public) session=\(self.sessionKey, privacy: .public)")
|
||||||
|
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ final class CanvasFileWatcher: @unchecked Sendable {
|
|||||||
kFSEventStreamCreateFlagNoDefer)
|
kFSEventStreamCreateFlagNoDefer)
|
||||||
|
|
||||||
guard let stream = FSEventStreamCreate(
|
guard let stream = FSEventStreamCreate(
|
||||||
kCFAllocatorDefault,
|
kCFAllocatorDefault,
|
||||||
Self.callback,
|
Self.callback,
|
||||||
&context,
|
&context,
|
||||||
paths,
|
paths,
|
||||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||||
0.05,
|
0.05,
|
||||||
flags)
|
flags)
|
||||||
else {
|
else {
|
||||||
retainedSelf.release()
|
retainedSelf.release()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -242,8 +242,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let url = CanvasScheme.makeURL(
|
guard let url = CanvasScheme.makeURL(
|
||||||
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
|
||||||
path: trimmed)
|
path: trimmed)
|
||||||
else {
|
else {
|
||||||
canvasWindowLogger
|
canvasWindowLogger
|
||||||
.error(
|
.error(
|
||||||
|
|||||||
@@ -125,13 +125,13 @@ enum CommandResolver {
|
|||||||
|
|
||||||
// fnm
|
// fnm
|
||||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||||
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
|
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
|
||||||
suffix: "installation/bin"))
|
suffix: "installation/bin"))
|
||||||
|
|
||||||
// nvm
|
// nvm
|
||||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||||
base: home.appendingPathComponent(".nvm/versions/node"),
|
base: home.appendingPathComponent(".nvm/versions/node"),
|
||||||
suffix: "bin"))
|
suffix: "bin"))
|
||||||
|
|
||||||
return bins
|
return bins
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
|||||||
kFSEventStreamCreateFlagNoDefer)
|
kFSEventStreamCreateFlagNoDefer)
|
||||||
|
|
||||||
guard let stream = FSEventStreamCreate(
|
guard let stream = FSEventStreamCreate(
|
||||||
kCFAllocatorDefault,
|
kCFAllocatorDefault,
|
||||||
Self.callback,
|
Self.callback,
|
||||||
&context,
|
&context,
|
||||||
paths,
|
paths,
|
||||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||||
0.05,
|
0.05,
|
||||||
flags)
|
flags)
|
||||||
else {
|
else {
|
||||||
retainedSelf.release()
|
retainedSelf.release()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct ConfigSettings: View {
|
|||||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||||
private static let browserProfileNote =
|
private static let browserProfileNote =
|
||||||
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
"Clawd uses a separate Chrome profile and ports (default 18791/18792) "
|
||||||
+ "so it won’t interfere with your daily browser."
|
+ "so it won’t interfere with your daily browser."
|
||||||
@State private var configModel: String = ""
|
@State private var configModel: String = ""
|
||||||
@State private var configSaving = false
|
@State private var configSaving = false
|
||||||
@State private var hasLoaded = false
|
@State private var hasLoaded = false
|
||||||
@@ -97,8 +97,8 @@ extension ConfigSettings {
|
|||||||
Text("Clawdbot CLI config")
|
Text("Clawdbot CLI config")
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text(self.isNixMode
|
Text(self.isNixMode
|
||||||
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
|
||||||
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
: "Edit ~/.clawdbot/clawdbot.json (agent / session / routing / messages).")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -753,9 +753,9 @@ extension ConfigSettings {
|
|||||||
do {
|
do {
|
||||||
let res: ModelsListResult =
|
let res: ModelsListResult =
|
||||||
try await GatewayConnection.shared
|
try await GatewayConnection.shared
|
||||||
.requestDecoded(
|
.requestDecoded(
|
||||||
method: .modelsList,
|
method: .modelsList,
|
||||||
timeoutMs: 15000)
|
timeoutMs: 15000)
|
||||||
self.models = res.models
|
self.models = res.models
|
||||||
self.modelsSourceLabel = "gateway"
|
self.modelsSourceLabel = "gateway"
|
||||||
} catch {
|
} catch {
|
||||||
@@ -792,8 +792,8 @@ extension ConfigSettings {
|
|||||||
choice.provider,
|
choice.provider,
|
||||||
self.modelRef(for: choice),
|
self.modelRef(for: choice),
|
||||||
]
|
]
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
.lowercased()
|
.lowercased()
|
||||||
return tokens.allSatisfy { haystack.contains($0) }
|
return tokens.allSatisfy { haystack.contains($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ final class ConnectionModeCoordinator {
|
|||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
target: settings.target,
|
target: settings.target,
|
||||||
identity: settings.identity))
|
identity: settings.identity))
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -377,18 +377,18 @@ extension ConnectionsSettings {
|
|||||||
case .telegram:
|
case .telegram:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
.date(fromMs: self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case .discord:
|
case .discord:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
case .signal:
|
case .signal:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
case .imessage:
|
case .imessage:
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
.date(fromMs: self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ extension ConnectionsStore {
|
|||||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||||
let ui = snap.config?[
|
let ui = snap.config?[
|
||||||
"ui",
|
"ui",
|
||||||
]?.dictionaryValue
|
]?.dictionaryValue
|
||||||
let rawSeam = ui?[
|
let rawSeam = ui?[
|
||||||
"seamColor",
|
"seamColor",
|
||||||
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,20 +189,20 @@ enum CritterIconRenderer {
|
|||||||
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
canvas.context.setFillColor(NSColor.labelColor.cgColor)
|
||||||
|
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: geometry.bodyRect,
|
roundedRect: geometry.bodyRect,
|
||||||
cornerWidth: geometry.bodyCorner,
|
cornerWidth: geometry.bodyCorner,
|
||||||
cornerHeight: geometry.bodyCorner,
|
cornerHeight: geometry.bodyCorner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: geometry.leftEarRect,
|
roundedRect: geometry.leftEarRect,
|
||||||
cornerWidth: geometry.earCorner,
|
cornerWidth: geometry.earCorner,
|
||||||
cornerHeight: geometry.earCorner,
|
cornerHeight: geometry.earCorner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: geometry.rightEarRect,
|
roundedRect: geometry.rightEarRect,
|
||||||
cornerWidth: geometry.earCorner,
|
cornerWidth: geometry.earCorner,
|
||||||
cornerHeight: geometry.earCorner,
|
cornerHeight: geometry.earCorner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
|
|
||||||
for i in 0..<4 {
|
for i in 0..<4 {
|
||||||
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing)
|
||||||
@@ -213,10 +213,10 @@ enum CritterIconRenderer {
|
|||||||
width: geometry.legW,
|
width: geometry.legW,
|
||||||
height: geometry.legH * geometry.legHeightScale)
|
height: geometry.legH * geometry.legHeightScale)
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: rect,
|
roundedRect: rect,
|
||||||
cornerWidth: geometry.legW * 0.34,
|
cornerWidth: geometry.legW * 0.34,
|
||||||
cornerHeight: geometry.legW * 0.34,
|
cornerHeight: geometry.legW * 0.34,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
}
|
}
|
||||||
canvas.context.fillPath()
|
canvas.context.fillPath()
|
||||||
}
|
}
|
||||||
@@ -252,15 +252,15 @@ enum CritterIconRenderer {
|
|||||||
height: holeH)
|
height: holeH)
|
||||||
|
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: leftHoleRect,
|
roundedRect: leftHoleRect,
|
||||||
cornerWidth: holeCorner,
|
cornerWidth: holeCorner,
|
||||||
cornerHeight: holeCorner,
|
cornerHeight: holeCorner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: rightHoleRect,
|
roundedRect: rightHoleRect,
|
||||||
cornerWidth: holeCorner,
|
cornerWidth: holeCorner,
|
||||||
cornerHeight: holeCorner,
|
cornerHeight: holeCorner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.eyesClosedLines {
|
if options.eyesClosedLines {
|
||||||
@@ -278,41 +278,41 @@ enum CritterIconRenderer {
|
|||||||
width: lineW,
|
width: lineW,
|
||||||
height: lineH)
|
height: lineH)
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: leftRect,
|
roundedRect: leftRect,
|
||||||
cornerWidth: corner,
|
cornerWidth: corner,
|
||||||
cornerHeight: corner,
|
cornerHeight: corner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
canvas.context.addPath(CGPath(
|
canvas.context.addPath(CGPath(
|
||||||
roundedRect: rightRect,
|
roundedRect: rightRect,
|
||||||
cornerWidth: corner,
|
cornerWidth: corner,
|
||||||
cornerHeight: corner,
|
cornerHeight: corner,
|
||||||
transform: nil))
|
transform: nil))
|
||||||
} else {
|
} else {
|
||||||
let eyeOpen = max(0.05, 1 - options.blink)
|
let eyeOpen = max(0.05, 1 - options.blink)
|
||||||
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
|
let eyeH = canvas.snapY(geometry.bodyRect.height * 0.26 * eyeOpen)
|
||||||
|
|
||||||
let left = CGMutablePath()
|
let left = CGMutablePath()
|
||||||
left.move(to: CGPoint(
|
left.move(to: CGPoint(
|
||||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||||
y: canvas.snapY(leftCenter.y - eyeH)))
|
y: canvas.snapY(leftCenter.y - eyeH)))
|
||||||
left.addLine(to: CGPoint(
|
left.addLine(to: CGPoint(
|
||||||
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
x: canvas.snapX(leftCenter.x + geometry.eyeW / 2),
|
||||||
y: canvas.snapY(leftCenter.y)))
|
y: canvas.snapY(leftCenter.y)))
|
||||||
left.addLine(to: CGPoint(
|
left.addLine(to: CGPoint(
|
||||||
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
x: canvas.snapX(leftCenter.x - geometry.eyeW / 2),
|
||||||
y: canvas.snapY(leftCenter.y + eyeH)))
|
y: canvas.snapY(leftCenter.y + eyeH)))
|
||||||
left.closeSubpath()
|
left.closeSubpath()
|
||||||
|
|
||||||
let right = CGMutablePath()
|
let right = CGMutablePath()
|
||||||
right.move(to: CGPoint(
|
right.move(to: CGPoint(
|
||||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||||
y: canvas.snapY(rightCenter.y - eyeH)))
|
y: canvas.snapY(rightCenter.y - eyeH)))
|
||||||
right.addLine(to: CGPoint(
|
right.addLine(to: CGPoint(
|
||||||
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
x: canvas.snapX(rightCenter.x - geometry.eyeW / 2),
|
||||||
y: canvas.snapY(rightCenter.y)))
|
y: canvas.snapY(rightCenter.y)))
|
||||||
right.addLine(to: CGPoint(
|
right.addLine(to: CGPoint(
|
||||||
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
x: canvas.snapX(rightCenter.x + geometry.eyeW / 2),
|
||||||
y: canvas.snapY(rightCenter.y + eyeH)))
|
y: canvas.snapY(rightCenter.y + eyeH)))
|
||||||
right.closeSubpath()
|
right.closeSubpath()
|
||||||
|
|
||||||
canvas.context.addPath(left)
|
canvas.context.addPath(left)
|
||||||
|
|||||||
@@ -121,12 +121,12 @@ extension CritterStatusLabel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Image(nsImage: CritterIconRenderer.makeIcon(
|
return Image(nsImage: CritterIconRenderer.makeIcon(
|
||||||
blink: self.blinkAmount,
|
blink: self.blinkAmount,
|
||||||
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||||
earWiggle: self.earWiggle,
|
earWiggle: self.earWiggle,
|
||||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||||
earHoles: self.earBoostActive,
|
earHoles: self.earBoostActive,
|
||||||
badge: badge))
|
badge: badge))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetMotion() {
|
private func resetMotion() {
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ struct CronJobEditor: View {
|
|||||||
let labelColumnWidth: CGFloat = 160
|
let labelColumnWidth: CGFloat = 160
|
||||||
static let introText =
|
static let introText =
|
||||||
"Create a schedule that wakes clawd via the Gateway. "
|
"Create a schedule that wakes clawd via the Gateway. "
|
||||||
+ "Use an isolated session for agent turns so your main chat stays clean."
|
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||||
static let sessionTargetNote =
|
static let sessionTargetNote =
|
||||||
"Main jobs post a system event into the current main session. "
|
"Main jobs post a system event into the current main session. "
|
||||||
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
|
||||||
static let scheduleKindNote =
|
static let scheduleKindNote =
|
||||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||||
static let isolatedPayloadNote =
|
static let isolatedPayloadNote =
|
||||||
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
|
||||||
+ "and a short summary is posted back to your main chat."
|
+ "and a short summary is posted back to your main chat."
|
||||||
static let mainPayloadNote =
|
static let mainPayloadNote =
|
||||||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||||||
static let mainSummaryNote =
|
static let mainSummaryNote =
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ enum CronSchedule: Codable, Equatable {
|
|||||||
enum CronPayload: Codable, Equatable {
|
enum CronPayload: Codable, Equatable {
|
||||||
case systemEvent(text: String)
|
case systemEvent(text: String)
|
||||||
case agentTurn(
|
case agentTurn(
|
||||||
message: String,
|
message: String,
|
||||||
thinking: String?,
|
thinking: String?,
|
||||||
timeoutSeconds: Int?,
|
timeoutSeconds: Int?,
|
||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
to: String?,
|
to: String?,
|
||||||
bestEffortDeliver: Bool?)
|
bestEffortDeliver: Bool?)
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
case kind, text, message, thinking, timeoutSeconds, deliver, channel, provider, to, bestEffortDeliver
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ extension CronSettings {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
.alert("Delete cron job?", isPresented: Binding(
|
.alert("Delete cron job?", isPresented: Binding(
|
||||||
get: { self.confirmDelete != nil },
|
get: { self.confirmDelete != nil },
|
||||||
set: { if !$0 { self.confirmDelete = nil } }))
|
set: { if !$0 { self.confirmDelete = nil } }))
|
||||||
{
|
{
|
||||||
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
|
||||||
Button("Delete", role: .destructive) {
|
Button("Delete", role: .destructive) {
|
||||||
@@ -42,9 +42,9 @@ extension CronSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.store.selectedJobId) { _, newValue in
|
.onChange(of: self.store.selectedJobId) { _, newValue in
|
||||||
guard let newValue else { return }
|
guard let newValue else { return }
|
||||||
Task { await self.store.refreshRuns(jobId: newValue) }
|
Task { await self.store.refreshRuns(jobId: newValue) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var schedulerBanner: some View {
|
var schedulerBanner: some View {
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ extension CronSettings {
|
|||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Toggle("Enabled", isOn: Binding(
|
Toggle("Enabled", isOn: Binding(
|
||||||
get: { job.enabled },
|
get: { job.enabled },
|
||||||
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ enum DebugActions {
|
|||||||
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
target: settings.target,
|
target: settings.target,
|
||||||
identity: settings.identity))
|
identity: settings.identity))
|
||||||
} catch {
|
} catch {
|
||||||
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
|
// ControlChannel will surface a degraded state; also refresh health to update the menu text.
|
||||||
Task { await HealthStore.shared.refresh(onDemand: true) }
|
Task { await HealthStore.shared.refresh(onDemand: true) }
|
||||||
@@ -127,8 +127,8 @@ enum DebugActions {
|
|||||||
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
_ = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
target: settings.target,
|
target: settings.target,
|
||||||
identity: settings.identity))
|
identity: settings.identity))
|
||||||
await HealthStore.shared.refresh(onDemand: true)
|
await HealthStore.shared.refresh(onDemand: true)
|
||||||
return .success("SSH tunnel reset.")
|
return .success("SSH tunnel reset.")
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ enum DeviceModelCatalog {
|
|||||||
|
|
||||||
private static func loadMapping(resourceName: String) -> [String: String] {
|
private static func loadMapping(resourceName: String) -> [String: String] {
|
||||||
guard let url = self.resourceBundle?.url(
|
guard let url = self.resourceBundle?.url(
|
||||||
forResource: resourceName,
|
forResource: resourceName,
|
||||||
withExtension: "json",
|
withExtension: "json",
|
||||||
subdirectory: self.resourceSubdirectory)
|
subdirectory: self.resourceSubdirectory)
|
||||||
else { return [:] }
|
else { return [:] }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -113,17 +113,17 @@ actor GatewayChannelActor {
|
|||||||
self.task = nil
|
self.task = nil
|
||||||
|
|
||||||
await self.failPending(NSError(
|
await self.failPending(NSError(
|
||||||
domain: "Gateway",
|
domain: "Gateway",
|
||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||||
|
|
||||||
let waiters = self.connectWaiters
|
let waiters = self.connectWaiters
|
||||||
self.connectWaiters.removeAll()
|
self.connectWaiters.removeAll()
|
||||||
for waiter in waiters {
|
for waiter in waiters {
|
||||||
waiter.resume(throwing: NSError(
|
waiter.resume(throwing: NSError(
|
||||||
domain: "Gateway",
|
domain: "Gateway",
|
||||||
code: 0,
|
code: 0,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -433,14 +433,14 @@ extension GatewayConnection {
|
|||||||
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?)
|
||||||
{
|
{
|
||||||
await self.sendAgent(GatewayAgentInvocation(
|
await self.sendAgent(GatewayAgentInvocation(
|
||||||
message: message,
|
message: message,
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
thinking: thinking,
|
thinking: thinking,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: to,
|
to: to,
|
||||||
channel: channel,
|
channel: channel,
|
||||||
timeoutSeconds: timeoutSeconds,
|
timeoutSeconds: timeoutSeconds,
|
||||||
idempotencyKey: idempotencyKey))
|
idempotencyKey: idempotencyKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSystemEvent(_ params: [String: AnyCodable]) async {
|
func sendSystemEvent(_ params: [String: AnyCodable]) async {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||||
let target = self.suggestedSSHTarget(gateway)
|
let target = self.suggestedSSHTarget(gateway)
|
||||||
let selected = (target != nil && self.currentTarget?
|
let selected = (target != nil && self.currentTarget?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
@@ -61,8 +61,8 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.fill(self.rowBackground(
|
.fill(self.rowBackground(
|
||||||
selected: selected,
|
selected: selected,
|
||||||
hovered: self.hoveredGatewayID == gateway.id)))
|
hovered: self.hoveredGatewayID == gateway.id)))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
|
|||||||
@@ -206,10 +206,10 @@ actor GatewayEndpointStore {
|
|||||||
let port = self.deps.localPort()
|
let port = self.deps.localPort()
|
||||||
let host = await self.deps.localHost()
|
let host = await self.deps.localHost()
|
||||||
self.setState(.ready(
|
self.setState(.ready(
|
||||||
mode: .local,
|
mode: .local,
|
||||||
url: URL(string: "ws://\(host):\(port)")!,
|
url: URL(string: "ws://\(host):\(port)")!,
|
||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .remote:
|
case .remote:
|
||||||
let port = await self.deps.remotePortIfRunning()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
@@ -219,10 +219,10 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
self.cancelRemoteEnsure()
|
self.cancelRemoteEnsure()
|
||||||
self.setState(.ready(
|
self.setState(.ready(
|
||||||
mode: .remote,
|
mode: .remote,
|
||||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
self.cancelRemoteEnsure()
|
self.cancelRemoteEnsure()
|
||||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ enum GatewayPayloadDecoding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
|
static func decodeIfPresent<T: Decodable>(_ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws
|
||||||
-> T?
|
-> T?
|
||||||
{
|
{
|
||||||
guard let payload else { return nil }
|
guard let payload else { return nil }
|
||||||
return try self.decode(payload, as: T.self)
|
return try self.decode(payload, as: T.self)
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayDiscoveryInlineList(
|
GatewayDiscoveryInlineList(
|
||||||
@@ -627,8 +627,8 @@ extension GeneralSettings {
|
|||||||
let originalMode = AppStateStore.shared.connectionMode
|
let originalMode = AppStateStore.shared.connectionMode
|
||||||
do {
|
do {
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
target: settings.target,
|
target: settings.target,
|
||||||
identity: settings.identity))
|
identity: settings.identity))
|
||||||
let data = try await ControlChannel.shared.health(timeout: 10)
|
let data = try await ControlChannel.shared.health(timeout: 10)
|
||||||
if decodeHealthSnapshot(from: data) != nil {
|
if decodeHealthSnapshot(from: data) != nil {
|
||||||
self.remoteStatus = .ok
|
self.remoteStatus = .ok
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ enum InstanceIdentity {
|
|||||||
let defaults = Self.defaults
|
let defaults = Self.defaults
|
||||||
if let existing = defaults.string(forKey: instanceIdKey)?
|
if let existing = defaults.string(forKey: instanceIdKey)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!existing.isEmpty
|
!existing.isEmpty
|
||||||
{
|
{
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ enum LogLocator {
|
|||||||
self.ensureLogDirExists()
|
self.ensureLogDirExists()
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let files = (try? fm.contentsOfDirectory(
|
let files = (try? fm.contentsOfDirectory(
|
||||||
at: self.logDir,
|
at: self.logDir,
|
||||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||||
options: [.skipsHiddenFiles])) ?? []
|
options: [.skipsHiddenFiles])) ?? []
|
||||||
|
|
||||||
return files
|
return files
|
||||||
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
|
.filter { $0.lastPathComponent.hasPrefix("clawdbot") && $0.pathExtension == "log" }
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
|||||||
let initialWidth = self.initialCardWidth(for: menu)
|
let initialWidth = self.initialCardWidth(for: menu)
|
||||||
|
|
||||||
let initial = AnyView(ContextMenuCardView(
|
let initial = AnyView(ContextMenuCardView(
|
||||||
rows: initialRows,
|
rows: initialRows,
|
||||||
statusText: initialStatusText,
|
statusText: initialStatusText,
|
||||||
isLoading: initialIsLoading))
|
isLoading: initialIsLoading))
|
||||||
|
|
||||||
let hosting = NSHostingView(rootView: initial)
|
let hosting = NSHostingView(rootView: initial)
|
||||||
hosting.frame.size.width = max(1, initialWidth)
|
hosting.frame.size.width = max(1, initialWidth)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
extension MenuSessionsInjector {
|
extension MenuSessionsInjector {
|
||||||
// MARK: - Injection
|
// MARK: - Injection
|
||||||
|
|
||||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
private func inject(into menu: NSMenu) {
|
||||||
@@ -138,8 +139,8 @@ extension MenuSessionsInjector {
|
|||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: rows.count,
|
count: rows.count,
|
||||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@@ -175,8 +176,8 @@ extension MenuSessionsInjector {
|
|||||||
: self.controlChannelStatusText(for: channelState)
|
: self.controlChannelStatusText(for: channelState)
|
||||||
let hosted = self.makeHostedView(
|
let hosted = self.makeHostedView(
|
||||||
rootView: AnyView(MenuSessionsHeaderView(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: 0,
|
count: 0,
|
||||||
statusText: statusText)),
|
statusText: statusText)),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
headerItem.view = hosted
|
headerItem.view = hosted
|
||||||
@@ -299,7 +300,7 @@ extension MenuSessionsInjector {
|
|||||||
headerItem.isEnabled = false
|
headerItem.isEnabled = false
|
||||||
headerItem.view = self.makeHostedView(
|
headerItem.view = self.makeHostedView(
|
||||||
rootView: AnyView(MenuUsageHeaderView(
|
rootView: AnyView(MenuUsageHeaderView(
|
||||||
count: rows.count)),
|
count: rows.count)),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
@@ -472,11 +473,11 @@ extension MenuSessionsInjector {
|
|||||||
item.tag = self.tag
|
item.tag = self.tag
|
||||||
item.isEnabled = false
|
item.isEnabled = false
|
||||||
let view = AnyView(SessionMenuPreviewView(
|
let view = AnyView(SessionMenuPreviewView(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
width: width,
|
width: width,
|
||||||
maxItems: 10,
|
maxItems: 10,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
title: title))
|
title: title))
|
||||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
@@ -596,10 +597,10 @@ extension MenuSessionsInjector {
|
|||||||
let width = self.submenuWidth()
|
let width = self.submenuWidth()
|
||||||
|
|
||||||
menu.addItem(self.makeSessionPreviewItem(
|
menu.addItem(self.makeSessionPreviewItem(
|
||||||
sessionKey: row.key,
|
sessionKey: row.key,
|
||||||
title: "Recent messages (last 10)",
|
title: "Recent messages (last 10)",
|
||||||
width: width,
|
width: width,
|
||||||
maxLines: 3))
|
maxLines: 3))
|
||||||
|
|
||||||
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
|
let morePreview = NSMenuItem(title: "More preview…", action: nil, keyEquivalent: "")
|
||||||
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
|
morePreview.submenu = self.buildPreviewSubmenu(sessionKey: row.key, width: width)
|
||||||
@@ -703,10 +704,10 @@ extension MenuSessionsInjector {
|
|||||||
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
|
private func buildPreviewSubmenu(sessionKey: String, width: CGFloat) -> NSMenu {
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
menu.addItem(self.makeSessionPreviewItem(
|
menu.addItem(self.makeSessionPreviewItem(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
title: "Recent messages (expanded)",
|
title: "Recent messages (expanded)",
|
||||||
width: width,
|
width: width,
|
||||||
maxLines: 8))
|
maxLines: 8))
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,9 +764,9 @@ extension MenuSessionsInjector {
|
|||||||
!commands.isEmpty
|
!commands.isEmpty
|
||||||
{
|
{
|
||||||
menu.addItem(self.makeNodeMultilineItem(
|
menu.addItem(self.makeNodeMultilineItem(
|
||||||
label: "Commands",
|
label: "Commands",
|
||||||
value: commands.joined(separator: ", "),
|
value: commands.joined(separator: ", "),
|
||||||
width: width))
|
width: width))
|
||||||
}
|
}
|
||||||
|
|
||||||
return menu
|
return menu
|
||||||
@@ -855,9 +856,9 @@ extension MenuSessionsInjector {
|
|||||||
guard let key = sender.representedObject as? String else { return }
|
guard let key = sender.representedObject as? String else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard SessionActions.confirmDestructiveAction(
|
guard SessionActions.confirmDestructiveAction(
|
||||||
title: "Reset session?",
|
title: "Reset session?",
|
||||||
message: "Starts a new session id for “\(key)”.",
|
message: "Starts a new session id for “\(key)”.",
|
||||||
action: "Reset")
|
action: "Reset")
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -874,9 +875,9 @@ extension MenuSessionsInjector {
|
|||||||
guard let key = sender.representedObject as? String else { return }
|
guard let key = sender.representedObject as? String else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard SessionActions.confirmDestructiveAction(
|
guard SessionActions.confirmDestructiveAction(
|
||||||
title: "Compact session log?",
|
title: "Compact session log?",
|
||||||
message: "Keeps the last 400 lines; archives the old file.",
|
message: "Keeps the last 400 lines; archives the old file.",
|
||||||
action: "Compact")
|
action: "Compact")
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -893,9 +894,9 @@ extension MenuSessionsInjector {
|
|||||||
guard let key = sender.representedObject as? String else { return }
|
guard let key = sender.representedObject as? String else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard SessionActions.confirmDestructiveAction(
|
guard SessionActions.confirmDestructiveAction(
|
||||||
title: "Delete session?",
|
title: "Delete session?",
|
||||||
message: "Deletes the “\(key)” entry and archives its transcript.",
|
message: "Deletes the “\(key)” entry and archives its transcript.",
|
||||||
action: "Delete")
|
action: "Delete")
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ actor MacNodeBridgeSession {
|
|||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
async throws
|
async throws
|
||||||
{
|
{
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.disconnectHandler = onDisconnected
|
self.disconnectHandler = onDisconnected
|
||||||
@@ -77,15 +77,15 @@ actor MacNodeBridgeSession {
|
|||||||
})
|
})
|
||||||
|
|
||||||
guard let line = try await AsyncTimeout.withTimeout(
|
guard let line = try await AsyncTimeout.withTimeout(
|
||||||
seconds: 6,
|
seconds: 6,
|
||||||
onTimeout: {
|
onTimeout: {
|
||||||
TimeoutError(message: "operation timed out")
|
TimeoutError(message: "operation timed out")
|
||||||
},
|
},
|
||||||
operation: {
|
operation: {
|
||||||
try await self.receiveLine()
|
try await self.receiveLine()
|
||||||
}),
|
}),
|
||||||
let data = line.data(using: .utf8),
|
let data = line.data(using: .utf8),
|
||||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||||
else {
|
else {
|
||||||
self.logger.error("node bridge hello failed (unexpected response)")
|
self.logger.error("node bridge hello failed (unexpected response)")
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
|
|||||||
@@ -312,9 +312,23 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let remotePort = Self.remoteBridgePort()
|
let remotePort = Self.remoteBridgePort()
|
||||||
|
let preferredLocalPort = Self.loopbackBridgePort()
|
||||||
|
if let preferredLocalPort {
|
||||||
|
self.logger.info(
|
||||||
|
"mac node bridge tunnel starting " +
|
||||||
|
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
|
||||||
|
"remotePort=\(remotePort, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
self.logger.info(
|
||||||
|
"mac node bridge tunnel starting " +
|
||||||
|
"preferredLocalPort=none " +
|
||||||
|
"remotePort=\(remotePort, privacy: .public)")
|
||||||
|
}
|
||||||
self.tunnel = try await RemotePortTunnel.create(
|
self.tunnel = try await RemotePortTunnel.create(
|
||||||
remotePort: remotePort,
|
remotePort: remotePort,
|
||||||
allowRemoteUrlOverride: false)
|
preferredLocalPort: preferredLocalPort,
|
||||||
|
allowRemoteUrlOverride: false,
|
||||||
|
allowRandomLocalPort: true)
|
||||||
if let localPort = self.tunnel?.localPort,
|
if let localPort = self.tunnel?.localPort,
|
||||||
let port = NWEndpoint.Port(rawValue: localPort)
|
let port = NWEndpoint.Port(rawValue: localPort)
|
||||||
{
|
{
|
||||||
@@ -389,10 +403,10 @@ final class MacNodeModeCoordinator {
|
|||||||
let preferred = BridgeDiscoveryPreferences.preferredStableID()
|
let preferred = BridgeDiscoveryPreferences.preferredStableID()
|
||||||
if let preferred,
|
if let preferred,
|
||||||
let match = results.first(where: {
|
let match = results.first(where: {
|
||||||
if case .service = $0.endpoint {
|
if case .service = $0.endpoint {
|
||||||
return BridgeEndpointID.stableID($0.endpoint) == preferred
|
return BridgeEndpointID.stableID($0.endpoint) == preferred
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
state.finish(match.endpoint)
|
state.finish(match.endpoint)
|
||||||
|
|||||||
@@ -181,10 +181,10 @@ actor MacNodeRuntime {
|
|||||||
var height: Int
|
var height: Int
|
||||||
}
|
}
|
||||||
let payload = try Self.encodePayload(SnapPayload(
|
let payload = try Self.encodePayload(SnapPayload(
|
||||||
format: (params.format ?? .jpg).rawValue,
|
format: (params.format ?? .jpg).rawValue,
|
||||||
base64: res.data.base64EncodedString(),
|
base64: res.data.base64EncodedString(),
|
||||||
width: Int(res.size.width),
|
width: Int(res.size.width),
|
||||||
height: Int(res.size.height)))
|
height: Int(res.size.height)))
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
case ClawdbotCameraCommand.clip.rawValue:
|
case ClawdbotCameraCommand.clip.rawValue:
|
||||||
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
|
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
|
||||||
@@ -204,10 +204,10 @@ actor MacNodeRuntime {
|
|||||||
var hasAudio: Bool
|
var hasAudio: Bool
|
||||||
}
|
}
|
||||||
let payload = try Self.encodePayload(ClipPayload(
|
let payload = try Self.encodePayload(ClipPayload(
|
||||||
format: (params.format ?? .mp4).rawValue,
|
format: (params.format ?? .mp4).rawValue,
|
||||||
base64: data.base64EncodedString(),
|
base64: data.base64EncodedString(),
|
||||||
durationMs: res.durationMs,
|
durationMs: res.durationMs,
|
||||||
hasAudio: res.hasAudio))
|
hasAudio: res.hasAudio))
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
case ClawdbotCameraCommand.list.rawValue:
|
case ClawdbotCameraCommand.list.rawValue:
|
||||||
let devices = await self.cameraCapture.listDevices()
|
let devices = await self.cameraCapture.listDevices()
|
||||||
@@ -312,12 +312,12 @@ actor MacNodeRuntime {
|
|||||||
var hasAudio: Bool
|
var hasAudio: Bool
|
||||||
}
|
}
|
||||||
let payload = try Self.encodePayload(ScreenPayload(
|
let payload = try Self.encodePayload(ScreenPayload(
|
||||||
format: "mp4",
|
format: "mp4",
|
||||||
base64: data.base64EncodedString(),
|
base64: data.base64EncodedString(),
|
||||||
durationMs: params.durationMs,
|
durationMs: params.durationMs,
|
||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
screenIndex: params.screenIndex,
|
screenIndex: params.screenIndex,
|
||||||
hasAudio: res.hasAudio))
|
hasAudio: res.hasAudio))
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,12 +454,12 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let payload = try Self.encodePayload(RunPayload(
|
let payload = try Self.encodePayload(RunPayload(
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
timedOut: result.timedOut,
|
timedOut: result.timedOut,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
error: result.errorMessage))
|
error: result.errorMessage))
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,8 +576,8 @@ actor MacNodeRuntime {
|
|||||||
case .jpeg:
|
case .jpeg:
|
||||||
let clamped = min(1.0, max(0.05, quality))
|
let clamped = min(1.0, max(0.05, quality))
|
||||||
guard let data = rep.representation(
|
guard let data = rep.representation(
|
||||||
using: .jpeg,
|
using: .jpeg,
|
||||||
properties: [.compressionFactor: clamped])
|
properties: [.compressionFactor: clamped])
|
||||||
else {
|
else {
|
||||||
throw NSError(domain: "Canvas", code: 24, userInfo: [
|
throw NSError(domain: "Canvas", code: 24, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "jpeg encode failed",
|
NSLocalizedDescriptionKey: "jpeg encode failed",
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
let settings = await center.notificationSettings()
|
let settings = await center.notificationSettings()
|
||||||
guard settings.authorizationStatus == .authorized ||
|
guard settings.authorizationStatus == .authorized ||
|
||||||
settings.authorizationStatus == .provisional
|
settings.authorizationStatus == .provisional
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -547,7 +547,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
||||||
guard let gateway else { return nil }
|
guard let gateway else { return nil }
|
||||||
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||||
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
|
||||||
guard let host, !host.isEmpty else { return nil }
|
guard let host, !host.isEmpty else { return nil }
|
||||||
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
|
let port = gateway.sshPort > 0 ? gateway.sshPort : 22
|
||||||
return SSHTarget(host: host, port: port)
|
return SSHTarget(host: host, port: port)
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ final class PeekabooBridgeHostCoordinator {
|
|||||||
|
|
||||||
var infoCF: CFDictionary?
|
var infoCF: CFDictionary?
|
||||||
guard SecCodeCopySigningInformation(
|
guard SecCodeCopySigningInformation(
|
||||||
staticCode,
|
staticCode,
|
||||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||||
&infoCF) == errSecSuccess,
|
&infoCF) == errSecSuccess,
|
||||||
let info = infoCF as? [String: Any]
|
let info = infoCF as? [String: Any]
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -106,9 +106,9 @@ private final class ClawdbotPeekabooBridgeServices: PeekabooBridgeServiceProvidi
|
|||||||
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
|
let feedbackClient: any AutomationFeedbackClient = NoopAutomationFeedbackClient()
|
||||||
|
|
||||||
let snapshots = InMemorySnapshotManager(options: .init(
|
let snapshots = InMemorySnapshotManager(options: .init(
|
||||||
snapshotValidityWindow: 600,
|
snapshotValidityWindow: 600,
|
||||||
maxSnapshots: 50,
|
maxSnapshots: 50,
|
||||||
deleteArtifactsOnCleanup: false))
|
deleteArtifactsOnCleanup: false))
|
||||||
let applications = ApplicationService(feedbackClient: feedbackClient)
|
let applications = ApplicationService(feedbackClient: feedbackClient)
|
||||||
|
|
||||||
let screenCapture = ScreenCaptureService(loggingService: logging)
|
let screenCapture = ScreenCaptureService(loggingService: logging)
|
||||||
|
|||||||
@@ -158,10 +158,10 @@ actor PortGuardian {
|
|||||||
mode: mode,
|
mode: mode,
|
||||||
listeners: listeners)
|
listeners: listeners)
|
||||||
reports.append(Self.buildReport(
|
reports.append(Self.buildReport(
|
||||||
port: port,
|
port: port,
|
||||||
listeners: listeners,
|
listeners: listeners,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
tunnelHealthy: tunnelHealthy))
|
tunnelHealthy: tunnelHealthy))
|
||||||
}
|
}
|
||||||
|
|
||||||
return reports
|
return reports
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ final class RemotePortTunnel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let line = String(data: data, encoding: .utf8)?
|
guard let line = String(data: data, encoding: .utf8)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!line.isEmpty
|
!line.isEmpty
|
||||||
else { return }
|
else { return }
|
||||||
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
|
Self.logger.error("ssh tunnel stderr: \(line, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ struct RuntimeResolution {
|
|||||||
enum RuntimeResolutionError: Error {
|
enum RuntimeResolutionError: Error {
|
||||||
case notFound(searchPaths: [String])
|
case notFound(searchPaths: [String])
|
||||||
case unsupported(
|
case unsupported(
|
||||||
kind: RuntimeKind,
|
kind: RuntimeKind,
|
||||||
found: RuntimeVersion,
|
found: RuntimeVersion,
|
||||||
required: RuntimeVersion,
|
required: RuntimeVersion,
|
||||||
path: String,
|
path: String,
|
||||||
searchPaths: [String])
|
searchPaths: [String])
|
||||||
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
|
case versionParse(kind: RuntimeKind, raw: String, path: String, searchPaths: [String])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,21 +65,21 @@ enum RuntimeLocator {
|
|||||||
}
|
}
|
||||||
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
|
guard let rawVersion = readVersion(of: binary, pathEnv: pathEnv) else {
|
||||||
return .failure(.versionParse(
|
return .failure(.versionParse(
|
||||||
kind: runtime,
|
kind: runtime,
|
||||||
raw: "(unreadable)",
|
raw: "(unreadable)",
|
||||||
path: binary,
|
path: binary,
|
||||||
searchPaths: searchPaths))
|
searchPaths: searchPaths))
|
||||||
}
|
}
|
||||||
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
|
guard let parsed = RuntimeVersion.from(string: rawVersion) else {
|
||||||
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
|
return .failure(.versionParse(kind: runtime, raw: rawVersion, path: binary, searchPaths: searchPaths))
|
||||||
}
|
}
|
||||||
guard parsed >= self.minNode else {
|
guard parsed >= self.minNode else {
|
||||||
return .failure(.unsupported(
|
return .failure(.unsupported(
|
||||||
kind: runtime,
|
kind: runtime,
|
||||||
found: parsed,
|
found: parsed,
|
||||||
required: self.minNode,
|
required: self.minNode,
|
||||||
path: binary,
|
path: binary,
|
||||||
searchPaths: searchPaths))
|
searchPaths: searchPaths))
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
|
return .success(RuntimeResolution(kind: runtime, path: binary, version: parsed))
|
||||||
|
|||||||
@@ -251,11 +251,11 @@ private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate,
|
|||||||
if let err = self.writer.error {
|
if let err = self.writer.error {
|
||||||
cont
|
cont
|
||||||
.resume(throwing: ScreenRecordService.ScreenRecordError
|
.resume(throwing: ScreenRecordService.ScreenRecordError
|
||||||
.writeFailed(err.localizedDescription))
|
.writeFailed(err.localizedDescription))
|
||||||
} else if self.writer.status != .completed {
|
} else if self.writer.status != .completed {
|
||||||
cont
|
cont
|
||||||
.resume(throwing: ScreenRecordService.ScreenRecordError
|
.resume(throwing: ScreenRecordService.ScreenRecordError
|
||||||
.writeFailed("Failed to finalize video"))
|
.writeFailed("Failed to finalize video"))
|
||||||
} else {
|
} else {
|
||||||
cont.resume()
|
cont.resume()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ enum SoundEffectCatalog {
|
|||||||
var map: [String: URL] = [:]
|
var map: [String: URL] = [:]
|
||||||
for root in Self.searchRoots {
|
for root in Self.searchRoots {
|
||||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||||
at: root,
|
at: root,
|
||||||
includingPropertiesForKeys: nil,
|
includingPropertiesForKeys: nil,
|
||||||
options: [.skipsHiddenFiles])
|
options: [.skipsHiddenFiles])
|
||||||
else { continue }
|
else { continue }
|
||||||
|
|
||||||
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
|
for url in contents where Self.allowedExtensions.contains(url.pathExtension.lowercased()) {
|
||||||
@@ -88,9 +88,9 @@ enum SoundEffectPlayer {
|
|||||||
static func sound(from bookmark: Data) -> NSSound? {
|
static func sound(from bookmark: Data) -> NSSound? {
|
||||||
var stale = false
|
var stale = false
|
||||||
guard let url = try? URL(
|
guard let url = try? URL(
|
||||||
resolvingBookmarkData: bookmark,
|
resolvingBookmarkData: bookmark,
|
||||||
options: [.withoutUI, .withSecurityScope],
|
options: [.withoutUI, .withSecurityScope],
|
||||||
bookmarkDataIsStale: &stale)
|
bookmarkDataIsStale: &stale)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let scoped = url.startAccessingSecurityScopedResource()
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
|||||||
@@ -354,9 +354,9 @@ actor TalkModeRuntime {
|
|||||||
"session=\(sessionKey, privacy: .public)")
|
"session=\(sessionKey, privacy: .public)")
|
||||||
|
|
||||||
guard let assistantText = await self.waitForAssistantText(
|
guard let assistantText = await self.waitForAssistantText(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
since: startedAt,
|
since: startedAt,
|
||||||
timeoutSeconds: 45)
|
timeoutSeconds: 45)
|
||||||
else {
|
else {
|
||||||
self.logger.warning("talk assistant text missing after timeout")
|
self.logger.warning("talk assistant text missing after timeout")
|
||||||
await self.startListening()
|
await self.startListening()
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ enum VoiceWakeForwarder {
|
|||||||
let payload = Self.prefixedTranscript(transcript)
|
let payload = Self.prefixedTranscript(transcript)
|
||||||
let deliver = options.channel.shouldDeliver(options.deliver)
|
let deliver = options.channel.shouldDeliver(options.deliver)
|
||||||
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
let result = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||||
message: payload,
|
message: payload,
|
||||||
sessionKey: options.sessionKey,
|
sessionKey: options.sessionKey,
|
||||||
thinking: options.thinking,
|
thinking: options.thinking,
|
||||||
deliver: deliver,
|
deliver: deliver,
|
||||||
to: options.to,
|
to: options.to,
|
||||||
channel: options.channel))
|
channel: options.channel))
|
||||||
|
|
||||||
if result.ok {
|
if result.ok {
|
||||||
self.logger.info("voice wake forward ok")
|
self.logger.info("voice wake forward ok")
|
||||||
|
|||||||
@@ -493,10 +493,10 @@ actor VoiceWakeRuntime {
|
|||||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||||
{
|
{
|
||||||
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||||
transcript: transcript,
|
transcript: transcript,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
minCommandLength: config.minCommandLength,
|
minCommandLength: config.minCommandLength,
|
||||||
trimWake: Self.trimmedAfterTrigger)
|
trimWake: Self.trimmedAfterTrigger)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||||
}
|
}
|
||||||
@@ -519,9 +519,9 @@ actor VoiceWakeRuntime {
|
|||||||
guard let lastSeenAt, let lastText else { return }
|
guard let lastSeenAt, let lastText else { return }
|
||||||
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
guard let match = self.textOnlyFallbackMatch(
|
guard let match = self.textOnlyFallbackMatch(
|
||||||
transcript: lastText,
|
transcript: lastText,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
config: gateConfig)
|
config: gateConfig)
|
||||||
else { return }
|
else { return }
|
||||||
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
if let cooldown = self.cooldownUntil, Date() < cooldown {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ struct VoiceWakeSettings: View {
|
|||||||
Label("Add word", systemImage: "plus")
|
Label("Add word", systemImage: "plus")
|
||||||
}
|
}
|
||||||
.disabled(self.state.swabbleTriggerWords
|
.disabled(self.state.swabbleTriggerWords
|
||||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
||||||
}
|
}
|
||||||
@@ -440,21 +440,21 @@ struct VoiceWakeSettings: View {
|
|||||||
{ idx, localeID in
|
{ idx, localeID in
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Picker("Extra \(idx + 1)", selection: Binding(
|
Picker("Extra \(idx + 1)", selection: Binding(
|
||||||
get: { localeID },
|
get: { localeID },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
guard self.state
|
guard self.state
|
||||||
.voiceWakeAdditionalLocaleIDs.indices
|
.voiceWakeAdditionalLocaleIDs.indices
|
||||||
.contains(idx) else { return }
|
.contains(idx) else { return }
|
||||||
self.state
|
self.state
|
||||||
.voiceWakeAdditionalLocaleIDs[idx] =
|
.voiceWakeAdditionalLocaleIDs[idx] =
|
||||||
newValue
|
newValue
|
||||||
})) {
|
})) {
|
||||||
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
||||||
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.labelsHidden()
|
||||||
.labelsHidden()
|
.frame(width: 220)
|
||||||
.frame(width: 220)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||||
|
|||||||
@@ -360,10 +360,10 @@ final class VoiceWakeTester {
|
|||||||
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
config: WakeWordGateConfig) -> WakeWordGateMatch?
|
||||||
{
|
{
|
||||||
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
guard let command = VoiceWakeTextUtils.textOnlyCommand(
|
||||||
transcript: transcript,
|
transcript: transcript,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
minCommandLength: config.minCommandLength,
|
minCommandLength: config.minCommandLength,
|
||||||
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||||
}
|
}
|
||||||
@@ -408,9 +408,9 @@ final class VoiceWakeTester {
|
|||||||
guard let lastSeenAt, let lastText else { return }
|
guard let lastSeenAt, let lastText else { return }
|
||||||
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
|
||||||
guard let match = self.textOnlyFallbackMatch(
|
guard let match = self.textOnlyFallbackMatch(
|
||||||
transcript: lastText,
|
transcript: lastText,
|
||||||
triggers: triggers,
|
triggers: triggers,
|
||||||
config: WakeWordGateConfig(triggers: triggers)) else { return }
|
config: WakeWordGateConfig(triggers: triggers)) else { return }
|
||||||
self.holdingAfterDetect = true
|
self.holdingAfterDetect = true
|
||||||
self.detectedText = match.command
|
self.detectedText = match.command
|
||||||
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
|||||||
switch push {
|
switch push {
|
||||||
case let .snapshot(hello):
|
case let .snapshot(hello):
|
||||||
let ok = (try? JSONDecoder().decode(
|
let ok = (try? JSONDecoder().decode(
|
||||||
ClawdbotGatewayHealthOK.self,
|
ClawdbotGatewayHealthOK.self,
|
||||||
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true
|
||||||
return .health(ok: ok)
|
return .health(ok: ok)
|
||||||
|
|
||||||
case let .event(evt):
|
case let .event(evt):
|
||||||
@@ -101,16 +101,16 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
|||||||
case "health":
|
case "health":
|
||||||
guard let payload = evt.payload else { return nil }
|
guard let payload = evt.payload else { return nil }
|
||||||
let ok = (try? JSONDecoder().decode(
|
let ok = (try? JSONDecoder().decode(
|
||||||
ClawdbotGatewayHealthOK.self,
|
ClawdbotGatewayHealthOK.self,
|
||||||
from: JSONEncoder().encode(payload)))?.ok ?? true
|
from: JSONEncoder().encode(payload)))?.ok ?? true
|
||||||
return .health(ok: ok)
|
return .health(ok: ok)
|
||||||
case "tick":
|
case "tick":
|
||||||
return .tick
|
return .tick
|
||||||
case "chat":
|
case "chat":
|
||||||
guard let payload = evt.payload else { return nil }
|
guard let payload = evt.payload else { return nil }
|
||||||
guard let chat = try? JSONDecoder().decode(
|
guard let chat = try? JSONDecoder().decode(
|
||||||
ClawdbotChatEventPayload.self,
|
ClawdbotChatEventPayload.self,
|
||||||
from: JSONEncoder().encode(payload))
|
from: JSONEncoder().encode(payload))
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -118,8 +118,8 @@ struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable {
|
|||||||
case "agent":
|
case "agent":
|
||||||
guard let payload = evt.payload else { return nil }
|
guard let payload = evt.payload else { return nil }
|
||||||
guard let agent = try? JSONDecoder().decode(
|
guard let agent = try? JSONDecoder().decode(
|
||||||
ClawdbotAgentEventPayload.self,
|
ClawdbotAgentEventPayload.self,
|
||||||
from: JSONEncoder().encode(payload))
|
from: JSONEncoder().encode(payload))
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -157,9 +157,9 @@ final class WebChatSwiftUIWindowController {
|
|||||||
let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport)
|
let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport)
|
||||||
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex)
|
||||||
self.hosting = NSHostingController(rootView: ClawdbotChatView(
|
self.hosting = NSHostingController(rootView: ClawdbotChatView(
|
||||||
viewModel: vm,
|
viewModel: vm,
|
||||||
showsSessionSwitcher: true,
|
showsSessionSwitcher: true,
|
||||||
userAccent: accent))
|
userAccent: accent))
|
||||||
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting)
|
||||||
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ enum WideAreaGatewayDiscovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let ips = collectTailnetIPv4s(
|
guard let ips = collectTailnetIPv4s(
|
||||||
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
|
statusJson: context.tailscaleStatus()).nonEmpty else { return [] }
|
||||||
var candidates = Array(ips.prefix(self.maxCandidates))
|
var candidates = Array(ips.prefix(self.maxCandidates))
|
||||||
guard let nameserver = findNameserver(
|
guard let nameserver = findNameserver(
|
||||||
candidates: &candidates,
|
candidates: &candidates,
|
||||||
remaining: remaining,
|
remaining: remaining,
|
||||||
dig: context.dig)
|
dig: context.dig)
|
||||||
else {
|
else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -55,9 +55,9 @@ enum WideAreaGatewayDiscovery {
|
|||||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
||||||
guard let ptrLines = context.dig(
|
guard let ptrLines = context.dig(
|
||||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||||
!ptrLines.isEmpty
|
!ptrLines.isEmpty
|
||||||
else {
|
else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -74,8 +74,8 @@ enum WideAreaGatewayDiscovery {
|
|||||||
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
|
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
|
||||||
|
|
||||||
guard let srv = context.dig(
|
guard let srv = context.dig(
|
||||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
||||||
min(defaultTimeoutSeconds, remaining()))
|
min(defaultTimeoutSeconds, remaining()))
|
||||||
else { continue }
|
else { continue }
|
||||||
guard let (host, port) = parseSrv(srv) else { continue }
|
guard let (host, port) = parseSrv(srv) else { continue }
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
if let stdout = dig(
|
if let stdout = dig(
|
||||||
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
|
["+short", "+time=1", "+tries=1", "@\(ip)", probeName, "PTR"],
|
||||||
min(defaultTimeoutSeconds, budget)),
|
min(defaultTimeoutSeconds, budget)),
|
||||||
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
stdout.split(whereSeparator: \.isNewline).isEmpty == false
|
||||||
{
|
{
|
||||||
state.lock.lock()
|
state.lock.lock()
|
||||||
if state.found == nil {
|
if state.found == nil {
|
||||||
|
|||||||
@@ -107,18 +107,18 @@ public enum CanvasA2UICommand: String, Codable, Sendable {
|
|||||||
|
|
||||||
public enum Request: Sendable {
|
public enum Request: Sendable {
|
||||||
case notify(
|
case notify(
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
sound: String?,
|
sound: String?,
|
||||||
priority: NotificationPriority?,
|
priority: NotificationPriority?,
|
||||||
delivery: NotificationDelivery?)
|
delivery: NotificationDelivery?)
|
||||||
case ensurePermissions([Capability], interactive: Bool)
|
case ensurePermissions([Capability], interactive: Bool)
|
||||||
case runShell(
|
case runShell(
|
||||||
command: [String],
|
command: [String],
|
||||||
cwd: String?,
|
cwd: String?,
|
||||||
env: [String: String]?,
|
env: [String: String]?,
|
||||||
timeoutSec: Double?,
|
timeoutSec: Double?,
|
||||||
needsScreenRecording: Bool)
|
needsScreenRecording: Bool)
|
||||||
case status
|
case status
|
||||||
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
|
case agent(message: String, thinking: String?, session: String?, deliver: Bool, to: String?)
|
||||||
case rpcStatus
|
case rpcStatus
|
||||||
@@ -410,6 +410,6 @@ extension Request: Codable {
|
|||||||
// Shared transport settings
|
// Shared transport settings
|
||||||
public let controlSocketPath =
|
public let controlSocketPath =
|
||||||
FileManager.default
|
FileManager.default
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||||
.path
|
.path
|
||||||
|
|||||||
@@ -110,6 +110,32 @@ Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
|
|||||||
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
|
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
|
||||||
the Node CLI’s `dns-sd` based discovery.
|
the Node CLI’s `dns-sd` based discovery.
|
||||||
|
|
||||||
|
## Remote connection plumbing (SSH tunnels)
|
||||||
|
|
||||||
|
When the macOS app runs in **Remote** mode, it opens SSH tunnels so local UI
|
||||||
|
components can talk to a remote Gateway as if it were on localhost. There are
|
||||||
|
two independent tunnels:
|
||||||
|
|
||||||
|
### Control tunnel (Gateway control/WebSocket port)
|
||||||
|
- **Purpose:** health checks, status, Web Chat, config, and other control-plane calls.
|
||||||
|
- **Local port:** the Gateway port (default `18789`), always stable.
|
||||||
|
- **Remote port:** the same Gateway port on the remote host.
|
||||||
|
- **Behavior:** no random local port; the app reuses an existing healthy tunnel
|
||||||
|
or restarts it if needed.
|
||||||
|
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
||||||
|
ExitOnForwardFailure + keepalive options.
|
||||||
|
|
||||||
|
### Node bridge tunnel (macOS node mode)
|
||||||
|
- **Purpose:** connect the macOS node to the Gateway **Bridge** protocol (TCP JSONL).
|
||||||
|
- **Remote port:** `gatewayPort + 1` (default `18790`), derived from the Gateway port.
|
||||||
|
- **Local port preference:** `CLAWDBOT_BRIDGE_PORT` or the default `18790`.
|
||||||
|
- **Behavior:** prefer the default bridge port for consistency; fall back to a
|
||||||
|
random local port if the preferred one is busy. The node then connects to the
|
||||||
|
resolved local port.
|
||||||
|
|
||||||
|
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
||||||
|
details, see [Bridge protocol](/gateway/bridge-protocol).
|
||||||
|
|
||||||
## Related docs
|
## Related docs
|
||||||
|
|
||||||
- [Gateway runbook](/gateway)
|
- [Gateway runbook](/gateway)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { pollUntil } from "../../../test/helpers/poll.js";
|
|||||||
import { approveNodePairing, listNodePairing } from "../node-pairing.js";
|
import { approveNodePairing, listNodePairing } from "../node-pairing.js";
|
||||||
import { configureNodeBridgeSocket, startNodeBridgeServer } from "./server.js";
|
import { configureNodeBridgeSocket, startNodeBridgeServer } from "./server.js";
|
||||||
|
|
||||||
|
const pairingTimeoutMs = process.platform === "win32" ? 8000 : 3000;
|
||||||
|
const suiteTimeoutMs = process.platform === "win32" ? 20000 : 10000;
|
||||||
|
|
||||||
function createLineReader(socket: net.Socket) {
|
function createLineReader(socket: net.Socket) {
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
const pending: Array<(line: string) => void> = [];
|
const pending: Array<(line: string) => void> = [];
|
||||||
@@ -55,9 +58,8 @@ async function waitForSocketConnect(socket: net.Socket) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("node bridge server", () => {
|
describe("node bridge server", { timeout: suiteTimeoutMs }, () => {
|
||||||
let baseDir = "";
|
let baseDir = "";
|
||||||
const pairingTimeoutMs = process.platform === "win32" ? 8000 : 3000;
|
|
||||||
|
|
||||||
const pickNonLoopbackIPv4 = () => {
|
const pickNonLoopbackIPv4 = () => {
|
||||||
const ifaces = os.networkInterfaces();
|
const ifaces = os.networkInterfaces();
|
||||||
|
|||||||
Reference in New Issue
Block a user