feat(discovery): bonjour beacons + bridge presence

This commit is contained in:
Peter Steinberger
2025-12-13 04:28:12 +00:00
parent 3ee0e041fa
commit 1f37d94f9e
49 changed files with 1182 additions and 320 deletions

View File

@@ -142,7 +142,9 @@ final class AppState: ObservableObject {
}
@Published var webChatSwiftUIEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } }
didSet { self.ifNotPreview { UserDefaults.standard.set(
self.webChatSwiftUIEnabled,
forKey: webChatSwiftUIEnabledKey) } }
}
@Published var webChatPort: Int {

View File

@@ -1,5 +1,5 @@
import Foundation
import CoreServices
import Foundation
final class CanvasFileWatcher: @unchecked Sendable {
private let url: URL

View File

@@ -94,7 +94,9 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(standardizedFile.path, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
canvasLogger.error("failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
canvasLogger
.error(
"failed reading \(standardizedFile.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
return self.html("Failed to read file.", title: "Canvas error")
}
}

View File

@@ -2,8 +2,8 @@ import AppKit
import ClawdisIPC
import Foundation
import OSLog
import WebKit
import QuartzCore
import WebKit
private let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
@@ -97,7 +97,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
func showCanvas(path: String? = nil) {
if case .panel(let anchorProvider) = self.presentation {
if case let .panel(anchorProvider) = self.presentation {
self.presentAnchoredPanel(anchorProvider: anchorProvider)
if let path {
self.goto(path: path)
@@ -131,14 +131,21 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
func goto(path: String) {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http" {
if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
{
canvasWindowLogger.debug("canvas goto web \(url.absoluteString, privacy: .public)")
self.webView.load(URLRequest(url: url))
return
}
guard let url = CanvasScheme.makeURL(session: CanvasWindowController.sanitizeSessionKey(self.sessionKey), path: trimmed) else {
canvasWindowLogger.error("invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
guard let url = CanvasScheme.makeURL(
session: CanvasWindowController.sanitizeSessionKey(self.sessionKey),
path: trimmed)
else {
canvasWindowLogger
.error(
"invalid canvas url session=\(self.sessionKey, privacy: .public) path=\(trimmed, privacy: .public)")
return
}
canvasWindowLogger.debug("canvas goto canvas \(url.absoluteString, privacy: .public)")
@@ -257,11 +264,15 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
let anchor = anchorProvider()
let screen = NSScreen.screens.first { screen in
guard let anchor else { return false }
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(
x: anchor.midX,
y: anchor.midY))
} ?? NSScreen.main
// Base frame: restored frame (preferred), otherwise default top-right.
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(panel: panel, screen: screen)
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(
panel: panel,
screen: screen)
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
@@ -289,11 +300,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
let s = screen ?? panel.screen ?? NSScreen.main
let constrained: NSRect
if let s {
constrained = panel.constrainFrameRect(frame, to: s)
let constrained: NSRect = if let s {
panel.constrainFrameRect(frame, to: s)
} else {
constrained = frame
frame
}
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
@@ -371,11 +381,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
private static func storedFrameDefaultsKey(sessionKey: String) -> String {
"clawdis.canvas.frame.\(sanitizeSessionKey(sessionKey))"
"clawdis.canvas.frame.\(self.sanitizeSessionKey(sessionKey))"
}
private static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
@@ -383,8 +393,10 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
private static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set([Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)], forKey: key)
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set(
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
forKey: key)
}
}
@@ -442,125 +454,125 @@ private final class HoverChromeContainerView: NSView {
userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
}
private final class CanvasDragHandleView: NSView {
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
}
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = window.constrainFrameRect(frame, to: screen)
private final class CanvasDragHandleView: NSView {
override func mouseDown(with event: NSEvent) {
self.window?.performDrag(with: event)
}
window.setFrame(frame, display: true)
}
}
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private let closeButton: NSButton = {
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.secondaryLabelColor
btn.toolTip = "Close"
return btn
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.borderWidth = 1
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
self.dragHandle.wantsLayer = true
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
self.addSubview(self.closeButton)
NSLayoutConstraint.activate([
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
private final class CanvasResizeHandleView: NSView {
private var startPoint: NSPoint = .zero
private var startFrame: NSRect = .zero
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
guard self.alphaValue > 0.02 else { return nil }
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
override func mouseDown(with event: NSEvent) {
guard let window else { return }
_ = window.makeFirstResponder(self)
self.startPoint = NSEvent.mouseLocation
self.startFrame = window.frame
super.mouseDown(with: event)
}
override func mouseDragged(with _: NSEvent) {
guard let window else { return }
let current = NSEvent.mouseLocation
let dx = current.x - self.startPoint.x
let dy = current.y - self.startPoint.y
var frame = self.startFrame
frame.size.width = max(CanvasLayout.minPanelSize.width, frame.size.width + dx)
frame.origin.y += dy
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = window.constrainFrameRect(frame, to: screen)
}
window.setFrame(frame, display: true)
}
}
@objc private func handleClose() {
self.onClose?()
private final class CanvasChromeOverlayView: NSView {
var onClose: (() -> Void)?
private let dragHandle = CanvasDragHandleView(frame: .zero)
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
private let closeButton: NSButton = {
let img = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Close")
?? NSImage(size: NSSize(width: 18, height: 18))
let btn = NSButton(image: img, target: nil, action: nil)
btn.isBordered = false
btn.bezelStyle = .regularSquare
btn.imageScaling = .scaleProportionallyDown
btn.contentTintColor = NSColor.secondaryLabelColor
btn.toolTip = "Close"
return btn
}()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
self.wantsLayer = true
self.layer?.cornerRadius = 12
self.layer?.masksToBounds = true
self.layer?.borderWidth = 1
self.layer?.borderColor = NSColor.black.withAlphaComponent(0.18).cgColor
self.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.02).cgColor
self.dragHandle.translatesAutoresizingMaskIntoConstraints = false
self.dragHandle.wantsLayer = true
self.dragHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.dragHandle)
self.resizeHandle.translatesAutoresizingMaskIntoConstraints = false
self.resizeHandle.wantsLayer = true
self.resizeHandle.layer?.backgroundColor = NSColor.clear.cgColor
self.addSubview(self.resizeHandle)
self.closeButton.translatesAutoresizingMaskIntoConstraints = false
self.closeButton.target = self
self.closeButton.action = #selector(self.handleClose)
self.addSubview(self.closeButton)
NSLayoutConstraint.activate([
self.dragHandle.leadingAnchor.constraint(equalTo: self.leadingAnchor),
self.dragHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.dragHandle.topAnchor.constraint(equalTo: self.topAnchor),
self.dragHandle.heightAnchor.constraint(equalToConstant: 30),
self.closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
self.closeButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
self.closeButton.widthAnchor.constraint(equalToConstant: 18),
self.closeButton.heightAnchor.constraint(equalToConstant: 18),
self.resizeHandle.trailingAnchor.constraint(equalTo: self.trailingAnchor),
self.resizeHandle.bottomAnchor.constraint(equalTo: self.bottomAnchor),
self.resizeHandle.widthAnchor.constraint(equalToConstant: 18),
self.resizeHandle.heightAnchor.constraint(equalToConstant: 18),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func hitTest(_ point: NSPoint) -> NSView? {
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
guard self.alphaValue > 0.02 else { return nil }
if self.closeButton.frame.contains(point) { return self.closeButton }
if self.dragHandle.frame.contains(point) { return self.dragHandle }
if self.resizeHandle.frame.contains(point) { return self.resizeHandle }
return nil
}
@objc private func handleClose() {
self.onClose?()
}
}
}
override func mouseEntered(with _: NSEvent) {
NSAnimationContext.runAnimationGroup { ctx in

View File

@@ -15,8 +15,8 @@ struct ContextMenuCardView: View {
init(
rows: [SessionRow],
statusText: String? = nil,
isLoading: Bool = false
) {
isLoading: Bool = false)
{
self.rows = rows
self.statusText = statusText
self.isLoading = isLoading

View File

@@ -122,7 +122,10 @@ final class ControlChannel: ObservableObject {
{
do {
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
let data = try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
let data = try await GatewayConnection.shared.request(
method: method,
params: rawParams,
timeoutMs: timeoutMs)
self.state = .connected
return data
} catch {

View File

@@ -1,12 +1,12 @@
import ClawdisIPC
import Foundation
import Darwin
import Foundation
import OSLog
/// Lightweight UNIX-domain socket server so `clawdis-mac` can talk to the app
/// without a launchd MachService. Listens on `controlSocketPath`.
final actor ControlSocketServer {
nonisolated private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
private nonisolated static let logger = Logger(subsystem: "com.steipete.clawdis", category: "control.socket")
private var listenFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
@@ -60,7 +60,7 @@ final actor ControlSocketServer {
}
addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr))
let len = socklen_t(MemoryLayout.size(ofValue: addr))
if bind(fd, withUnsafePointer(to: &addr, { UnsafePointer<sockaddr>(OpaquePointer($0)) }), len) != 0 {
if bind(fd, withUnsafePointer(to: &addr) { UnsafePointer<sockaddr>(OpaquePointer($0)) }, len) != 0 {
close(fd)
return
}
@@ -103,7 +103,7 @@ final actor ControlSocketServer {
{
while !Task.isCancelled {
var addr = sockaddr()
var len: socklen_t = socklen_t(MemoryLayout<sockaddr>.size)
var len = socklen_t(MemoryLayout<sockaddr>.size)
let client = accept(listenFD, &addr, &len)
if client < 0 {
if errno == EINTR { continue }

View File

@@ -106,7 +106,7 @@ final class CronJobsStore: ObservableObject {
_ = try await self.request(
method: "cron.run",
params: ["id": id, "mode": force ? "force" : "due"],
timeoutMs: 20_000)
timeoutMs: 20000)
} catch {
self.lastError = error.localizedDescription
}

View File

@@ -34,15 +34,15 @@ enum CronSchedule: Codable, Equatable {
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "at":
self = .at(atMs: try container.decode(Int.self, forKey: .atMs))
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
case "every":
self = .every(
everyMs: try container.decode(Int.self, forKey: .everyMs),
anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs))
self = try .every(
everyMs: container.decode(Int.self, forKey: .everyMs),
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
case "cron":
self = .cron(
expr: try container.decode(String.self, forKey: .expr),
tz: try container.decodeIfPresent(String.self, forKey: .tz))
self = try .cron(
expr: container.decode(String.self, forKey: .expr),
tz: container.decodeIfPresent(String.self, forKey: .tz))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
@@ -94,16 +94,16 @@ enum CronPayload: Codable, Equatable {
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "systemEvent":
self = .systemEvent(text: try container.decode(String.self, forKey: .text))
self = try .systemEvent(text: container.decode(String.self, forKey: .text))
case "agentTurn":
self = .agentTurn(
message: try container.decode(String.self, forKey: .message),
thinking: try container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: try container.decodeIfPresent(String.self, forKey: .channel),
to: try container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
self = try .agentTurn(
message: container.decode(String.self, forKey: .message),
thinking: container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: container.decodeIfPresent(String.self, forKey: .channel),
to: container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
@@ -209,4 +209,3 @@ struct CronListResponse: Codable {
struct CronRunsResponse: Codable {
let entries: [CronRunLogEntry]
}

View File

@@ -53,9 +53,9 @@ struct CronSettings: View {
}
}
.onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
}
private var schedulerBanner: some View {
@@ -69,7 +69,8 @@ struct CronSettings: View {
.font(.headline)
Spacer()
}
Text("Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` and the Gateway restarts.")
Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` and the Gateway restarts.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -246,8 +247,8 @@ struct CronSettings: View {
Toggle("Enabled", isOn: Binding(
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch)
.labelsHidden()
.toggleStyle(.switch)
.labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
.buttonStyle(.borderedProminent)
Button("Edit") {
@@ -398,7 +399,7 @@ struct CronSettings: View {
HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if (deliver ?? false) {
if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary)
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
@@ -482,7 +483,7 @@ private struct CronJobEditor: View {
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State private var scheduleKind: ScheduleKind = .every
@State private var atDate: Date = Date().addingTimeInterval(60 * 5)
@State private var atDate: Date = .init().addingTimeInterval(60 * 5)
@State private var everyText: String = "1h"
@State private var cronExpr: String = "0 9 * * 3"
@State private var cronTz: String = ""
@@ -696,7 +697,10 @@ private struct CronJobEditor: View {
case .cron:
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty {
@@ -719,11 +723,17 @@ private struct CronJobEditor: View {
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
} else if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
@@ -740,7 +750,8 @@ private struct CronJobEditor: View {
if self.postToMain {
root["isolation"] = [
"postToMain": true,
"postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Cron" : self.postPrefix,
"postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ? "Cron" : self.postPrefix,
]
} else if self.job != nil {
// Allow clearing isolation on edit.
@@ -786,7 +797,7 @@ private struct CronJobEditor: View {
let factor: Double = switch unit {
case "ms": 1
case "s": 1000
case "m": 60_000
case "m": 60000
case "h": 3_600_000
default: 86_400_000
}
@@ -829,11 +840,25 @@ struct CronSettings_Previews: PreviewProvider {
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"),
state: CronJobState(nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), runningAtMs: nil, lastRunAtMs: nil, lastStatus: nil, lastError: nil, lastDurationMs: nil)),
state: CronJobState(
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
runningAtMs: nil,
lastRunAtMs: nil,
lastStatus: nil,
lastError: nil,
lastDurationMs: nil)),
]
store.selectedJobId = "job-1"
store.runEntries = [
CronRunLogEntry(ts: Int(Date().timeIntervalSince1970 * 1000), jobId: "job-1", action: "finished", status: "ok", error: nil, runAtMs: nil, durationMs: 1234, nextRunAtMs: nil),
CronRunLogEntry(
ts: Int(Date().timeIntervalSince1970 * 1000),
jobId: "job-1",
action: "finished",
status: "ok",
error: nil,
runAtMs: nil,
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)

View File

@@ -104,12 +104,18 @@ actor GatewayChannelActor {
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
await self.failPending(NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
await self.failPending(NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
waiter.resume(throwing: NSError(domain: "Gateway", code: 0, userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
waiter.resume(throwing: NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway channel shutdown"]))
}
}
@@ -268,7 +274,6 @@ actor GatewayChannelActor {
await self.watchTicks()
}
await self.pushHandler?(.snapshot(ok))
return
}
private func listen() {

View File

@@ -11,6 +11,6 @@ enum GatewayPayloadDecoding {
-> T?
{
guard let payload else { return nil }
return try decode(payload, as: T.self)
return try self.decode(payload, as: T.self)
}
}

View File

@@ -4,4 +4,3 @@ import ClawdisProtocol
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
extension HelloOk: @unchecked Sendable {}
extension EventFrame: @unchecked Sendable {}

View File

@@ -11,4 +11,3 @@ enum GatewayPush: Sendable {
/// A detected sequence gap (`expected...received`) for event frames.
case seqGap(expected: Int, received: Int)
}

View File

@@ -7,6 +7,7 @@ enum InstanceIdentity {
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
static let instanceId: String = {
let defaults = Self.defaults
if let existing = defaults.string(forKey: instanceIdKey)?

View File

@@ -20,4 +20,3 @@ struct MasterDiscoveryMenu: View {
.help("Discover Clawdis masters on your LAN")
}
}

View File

@@ -178,6 +178,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let state {
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
}
NodePairingApprovalPrompter.shared.start()
Task { PresenceReporter.shared.start() }
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
@@ -194,6 +195,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
GatewayProcessManager.shared.stop()
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
WebChatManager.shared.close()
WebChatManager.shared.resetTunnels()
Task { await RemoteTunnelManager.shared.stopAll() }

View File

@@ -55,7 +55,9 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
let hosting = NSHostingView(rootView: initial)
let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.initialCardWidth(for: menu), height: size.height))
hosting.frame = NSRect(
origin: .zero,
size: NSSize(width: self.initialCardWidth(for: menu), height: size.height))
let item = NSMenuItem()
item.tag = self.tag

View File

@@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable {
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
}
}

View File

@@ -0,0 +1,167 @@
import AppKit
import ClawdisProtocol
import Foundation
import OSLog
@MainActor
final class NodePairingApprovalPrompter {
static let shared = NodePairingApprovalPrompter()
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "node-pairing")
private var task: Task<Void, Never>?
private var isPresenting = false
private var queue: [PendingRequest] = []
private struct PendingRequest: Codable, Equatable, Identifiable {
let requestId: String
let nodeId: String
let displayName: String?
let platform: String?
let version: String?
let remoteIp: String?
let isRepair: Bool?
let ts: Double
var id: String { self.requestId }
}
func start() {
guard self.task == nil else { return }
self.task = Task { [weak self] in
guard let self else { return }
_ = try? await GatewayConnection.shared.refresh()
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in self?.handle(push: push) }
}
}
}
func stop() {
self.task?.cancel()
self.task = nil
self.queue.removeAll(keepingCapacity: false)
self.isPresenting = false
}
private func handle(push: GatewayPush) {
guard case let .event(evt) = push else { return }
guard evt.event == "node.pair.requested" else { return }
guard let payload = evt.payload else { return }
do {
let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self)
self.enqueue(req)
} catch {
self.logger.error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)")
}
}
private func enqueue(_ req: PendingRequest) {
if self.queue.contains(req) { return }
self.queue.append(req)
self.presentNextIfNeeded()
}
private func presentNextIfNeeded() {
guard !self.isPresenting else { return }
guard let next = self.queue.first else { return }
self.isPresenting = true
self.presentAlert(for: next)
}
private func presentAlert(for req: PendingRequest) {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow node to connect?"
alert.informativeText = Self.describe(req)
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
alert.addButton(withTitle: "Later")
let response = alert.runModal()
Task { [weak self] in
await self?.handleAlertResponse(response, request: req)
}
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
defer {
if self.queue.first == request {
self.queue.removeFirst()
} else {
self.queue.removeAll { $0 == request }
}
self.isPresenting = false
self.presentNextIfNeeded()
}
switch response {
case .alertFirstButtonReturn:
await self.approve(requestId: request.requestId)
case .alertSecondButtonReturn:
await self.reject(requestId: request.requestId)
default:
// Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL.
return
}
}
private func approve(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.approve",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
}
}
private func reject(requestId: String) async {
do {
_ = try await GatewayConnection.shared.request(
method: "node.pair.reject",
params: ["requestId": AnyCodable(requestId)],
timeoutMs: 10000)
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
private static func describe(_ req: PendingRequest) -> String {
let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
let platform = self.prettyPlatform(req.platform)
let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines)
let ip = self.prettyIP(req.remoteIp)
var lines: [String] = []
lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")")
lines.append("Node ID: \(req.nodeId)")
if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") }
if let version, !version.isEmpty { lines.append("App: \(version)") }
if let ip, !ip.isEmpty { lines.append("IP: \(ip)") }
if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") }
return lines.joined(separator: "\n")
}
private static func prettyIP(_ ip: String?) -> String? {
let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let trimmed, !trimmed.isEmpty else { return nil }
return trimmed.replacingOccurrences(of: "::ffff:", with: "")
}
private static func prettyPlatform(_ platform: String?) -> String? {
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let raw, !raw.isEmpty else { return nil }
if raw.lowercased() == "ios" { return "iOS" }
if raw.lowercased() == "macos" { return "macOS" }
return raw
}
}

View File

@@ -177,15 +177,12 @@ private struct NotifyOverlayView: View {
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.regularMaterial)
)
.fill(.regularMaterial))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1)
)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.onTapGesture {
self.controller.dismiss()
}
}
}

View File

@@ -42,7 +42,7 @@ struct SessionTokenStats {
static func formatKTokens(_ value: Int) -> String {
if value < 1000 { return "\(value)" }
let thousands = Double(value) / 1000
let decimals = value >= 10_000 ? 0 : 1
let decimals = value >= 10000 ? 0 : 1
return String(format: "%.\(decimals)fk", thousands)
}
}
@@ -277,7 +277,9 @@ enum SessionLoader {
let input = entry.inputTokens ?? 0
let output = entry.outputTokens ?? 0
let fallbackTotal = entry.totalTokens ?? input + output
let promptTokens = entry.sessionId.flatMap { self.promptTokensFromSessionLog(sessionId: $0, storeDir: storeDir) }
let promptTokens = entry.sessionId.flatMap { self.promptTokensFromSessionLog(
sessionId: $0,
storeDir: storeDir) }
let total = max(fallbackTotal, promptTokens ?? 0)
let context = entry.contextTokens ?? defaults.contextTokens
let model = entry.model ?? defaults.model

View File

@@ -17,4 +17,3 @@ extension View {
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
}
}

View File

@@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable {
nsView.isEmphasized = self.emphasized
}
}

View File

@@ -166,7 +166,7 @@ final class WebChatViewModel: ObservableObject {
text: trimmed,
mimeType: nil,
fileName: nil,
content: nil)
content: nil),
],
timestamp: Date().timeIntervalSince1970 * 1000)
self.messages.append(userMessage)
@@ -176,7 +176,7 @@ final class WebChatViewModel: ObservableObject {
"type": att.type,
"mimeType": att.mimeType,
"fileName": att.fileName,
"content": att.data.base64EncodedString()
"content": att.data.base64EncodedString(),
]
}
@@ -188,7 +188,7 @@ final class WebChatViewModel: ObservableObject {
"attachments": AnyCodable(attachmentsPayload as Any),
"thinking": AnyCodable(self.thinkingLevel),
"idempotencyKey": AnyCodable(runId),
"timeoutMs": AnyCodable(30_000)
"timeoutMs": AnyCodable(30000),
]
let data = try await GatewayConnection.shared.request(method: "chat.send", params: params)
let response = try JSONDecoder().decode(ChatSendResponse.self, from: data)
@@ -250,9 +250,9 @@ struct WebChatView: View {
.ignoresSafeArea()
VStack(spacing: 14) {
header
messageList
composer
self.header
self.messageList
self.composer
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
@@ -262,15 +262,14 @@ struct WebChatView: View {
LinearGradient(
colors: [
Color(red: 0.96, green: 0.97, blue: 1.0),
Color(red: 0.93, green: 0.94, blue: 0.98)
Color(red: 0.93, green: 0.94, blue: 0.98),
],
startPoint: .top,
endPoint: .bottom)
.opacity(0.35)
.ignoresSafeArea()
)
.ignoresSafeArea())
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { viewModel.load() }
.onAppear { self.viewModel.load() }
}
private var header: some View {
@@ -278,7 +277,8 @@ struct WebChatView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Clawd Web Chat")
.font(.title2.weight(.semibold))
Text("Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")")
Text(
"Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -295,8 +295,7 @@ struct WebChatView: View {
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
.shadow(color: .black.opacity(0.06), radius: 10, y: 4)
)
.shadow(color: .black.opacity(0.06), radius: 10, y: 4))
}
private var messageList: some View {
@@ -311,14 +310,13 @@ struct WebChatView: View {
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 12, y: 6)
)
.shadow(color: .black.opacity(0.05), radius: 12, y: 6))
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
thinkingPicker
self.thinkingPicker
Spacer()
Button {
self.pickFiles()
@@ -355,16 +353,14 @@ struct WebChatView: View {
.strokeBorder(Color.secondary.opacity(0.2))
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
)
.fill(Color(nsColor: .textBackgroundColor)))
.overlay(
TextEditor(text: self.$viewModel.input)
.font(.body)
.background(Color.clear)
.frame(minHeight: 96, maxHeight: 168)
.padding(.horizontal, 10)
.padding(.vertical, 8)
)
.padding(.vertical, 8))
.frame(maxHeight: 180)
HStack {
@@ -388,8 +384,7 @@ struct WebChatView: View {
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
.shadow(color: .black.opacity(0.06), radius: 12, y: 6)
)
.shadow(color: .black.opacity(0.06), radius: 12, y: 6))
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
}
@@ -471,8 +466,7 @@ private struct MessageBubble: View {
.background(self.isUser ? Color.accentColor.opacity(0.12) : Color(nsColor: .textBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(self.isUser ? Color.accentColor.opacity(0.35) : Color.secondary.opacity(0.15))
)
.stroke(self.isUser ? Color.accentColor.opacity(0.35) : Color.secondary.opacity(0.15)))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.padding(.horizontal, 6)
@@ -482,7 +476,7 @@ private struct MessageBubble: View {
private var primaryText: String? {
self.message.content?
.compactMap { $0.text }
.compactMap(\.text)
.joined(separator: "\n")
}
@@ -508,7 +502,7 @@ final class WebChatSwiftUIWindowController {
self.presentation = presentation
let vm = WebChatViewModel(sessionKey: sessionKey)
self.hosting = NSHostingController(rootView: WebChatView(viewModel: vm))
self.window = Self.makeWindow(for: presentation, contentViewController: hosting)
self.window = Self.makeWindow(for: presentation, contentViewController: self.hosting)
}
deinit {}
@@ -580,7 +574,10 @@ final class WebChatSwiftUIWindowController {
}
}
private static func makeWindow(for presentation: WebChatPresentation, contentViewController: NSViewController) -> NSWindow {
private static func makeWindow(
for presentation: WebChatPresentation,
contentViewController: NSViewController) -> NSWindow
{
switch presentation {
case .window:
let window = NSWindow(

View File

@@ -522,10 +522,10 @@ struct ClawdisCLI {
switch request {
case let .runShell(_, _, _, timeoutSec, _):
// Allow longer for commands; still cap overall to a sane bound.
return min(300, max(10, (timeoutSec ?? 10) + 2))
min(300, max(10, (timeoutSec ?? 10) + 2))
default:
// Fail-fast so callers (incl. SSH tool calls) don't hang forever.
return 10
10
}
}

View File

@@ -403,6 +403,81 @@ public struct WakeParams: Codable {
}
}
public struct NodePairRequestParams: Codable {
public let nodeid: String
public let displayname: String?
public let platform: String?
public let version: String?
public let remoteip: String?
public init(
nodeid: String,
displayname: String?,
platform: String?,
version: String?,
remoteip: String?
) {
self.nodeid = nodeid
self.displayname = displayname
self.platform = platform
self.version = version
self.remoteip = remoteip
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case displayname = "displayName"
case platform
case version
case remoteip = "remoteIp"
}
}
public struct NodePairListParams: Codable {
}
public struct NodePairApproveParams: Codable {
public let requestid: String
public init(
requestid: String
) {
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
}
}
public struct NodePairRejectParams: Codable {
public let requestid: String
public init(
requestid: String
) {
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
}
}
public struct NodePairVerifyParams: Codable {
public let nodeid: String
public let token: String
public init(
nodeid: String,
token: String
) {
self.nodeid = nodeid
self.token = token
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case token
}
}
public struct CronJob: Codable {
public let id: String
public let name: String?