feat(discovery): bonjour beacons + bridge presence
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -11,4 +11,3 @@ enum GatewayPush: Sendable {
|
||||
/// A detected sequence gap (`expected...received`) for event frames.
|
||||
case seqGap(expected: Int, received: Int)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -20,4 +20,3 @@ struct MasterDiscoveryMenu: View {
|
||||
.help("Discover Clawdis masters on your LAN")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,4 +27,3 @@ struct MenuHostedItem: NSViewRepresentable {
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: fitting.height))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
167
apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift
Normal file
167
apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,4 +17,3 @@ extension View {
|
||||
.onPreferenceChange(ViewWidthPreferenceKey.self, perform: onChange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,4 +35,3 @@ struct VisualEffectView: NSViewRepresentable {
|
||||
nsView.isEmphasized = self.emphasized
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user