mac: tidy menu and gateway support

This commit is contained in:
Peter Steinberger
2025-12-10 01:00:53 +00:00
parent 5ed1d4e178
commit 70fb4d452e
18 changed files with 198 additions and 98 deletions

View File

@@ -18,7 +18,8 @@ final class ConnectionModeCoordinator {
try await ControlChannel.shared.configure(mode: .local) try await ControlChannel.shared.configure(mode: .local)
} catch { } catch {
// Control channel will mark itself degraded; nothing else to do here. // Control channel will mark itself degraded; nothing else to do here.
self.logger.error("control channel local configure failed: \(error.localizedDescription, privacy: .public)") self.logger.error(
"control channel local configure failed: \(error.localizedDescription, privacy: .public)")
} }
if paused { if paused {
GatewayProcessManager.shared.stop() GatewayProcessManager.shared.stop()

View File

@@ -147,9 +147,14 @@ final class ControlChannel: ObservableObject {
// to free the port instead of a vague message. // to free the port instead of a vague message.
let nsError = error as NSError let nsError = error as NSError
if nsError.domain == "Gateway", if nsError.domain == "Gateway",
nsError.localizedDescription.contains("hello failed (unexpected response)") { nsError.localizedDescription.contains("hello failed (unexpected response)")
{
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()
return "Gateway handshake got non-gateway data on localhost:\(port). Another process is using that port or the SSH forward failed. Stop the local gateway/port-forward on \(port) and retry Remote mode." return """
Gateway handshake got non-gateway data on localhost:\(port).
Another process is using that port or the SSH forward failed.
Stop the local gateway/port-forward on \(port) and retry Remote mode.
"""
} }
if let urlError = error as? URLError { if let urlError = error as? URLError {
@@ -171,7 +176,8 @@ final class ControlChannel: ObservableObject {
} }
if nsError.domain == "Gateway", nsError.code == 5 { if nsError.domain == "Gateway", nsError.code == 5 {
return "Gateway request timed out; check the gateway process on localhost:\(GatewayEnvironment.gatewayPort())." let port = GatewayEnvironment.gatewayPort()
return "Gateway request timed out; check the gateway process on localhost:\(port)."
} }
let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription let detail = nsError.localizedDescription.isEmpty ? "unknown gateway error" : nsError.localizedDescription

View File

@@ -234,11 +234,11 @@ enum CritterIconRenderer {
colorSpaceName: .deviceRGB, colorSpaceName: .deviceRGB,
bitmapFormat: [], bitmapFormat: [],
bytesPerRow: 0, bytesPerRow: 0,
bitsPerPixel: 0 bitsPerPixel: 0)
) else { else {
return NSImage(size: size) return NSImage(size: self.size)
} }
rep.size = size rep.size = self.size
NSGraphicsContext.saveGraphicsState() NSGraphicsContext.saveGraphicsState()
if let context = NSGraphicsContext(bitmapImageRep: rep) { if let context = NSGraphicsContext(bitmapImageRep: rep) {
@@ -247,8 +247,8 @@ enum CritterIconRenderer {
context.cgContext.setShouldAntialias(false) context.cgContext.setShouldAntialias(false)
defer { NSGraphicsContext.restoreGraphicsState() } defer { NSGraphicsContext.restoreGraphicsState() }
let stepX = size.width / max(CGFloat(rep.pixelsWide), 1) let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
let stepY = size.height / max(CGFloat(rep.pixelsHigh), 1) let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX } let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY } let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
@@ -373,7 +373,7 @@ enum CritterIconRenderer {
context.cgContext.restoreGState() context.cgContext.restoreGState()
} else { } else {
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return NSImage(size: size) return NSImage(size: self.size)
} }
let image = NSImage(size: size) let image = NSImage(size: size)

View File

@@ -94,7 +94,7 @@ struct DebugSettings: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if self.portReports.isEmpty && !self.portCheckInFlight { if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18788/18789 and suggest fixes.") Text("Check which process owns 18788/18789 and suggest fixes.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -281,8 +281,7 @@ struct DebugSettings: View {
primaryButton: .destructive(Text("Kill")) { primaryButton: .destructive(Text("Kill")) {
Task { await self.killConfirmed(listener.pid) } Task { await self.killConfirmed(listener.pid) }
}, },
secondaryButton: .cancel() secondaryButton: .cancel())
)
} }
} }

View File

@@ -34,7 +34,7 @@ private actor GatewayChannelActor {
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private var watchdogTask: Task<Void, Never>? private var watchdogTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15_000 private let defaultRequestTimeoutMs: Double = 15000
init(url: URL, token: String?) { init(url: URL, token: String?) {
self.url = url self.url = url
@@ -94,7 +94,8 @@ private actor GatewayChannelActor {
maxprotocol: GATEWAY_PROTOCOL_VERSION, maxprotocol: GATEWAY_PROTOCOL_VERSION,
client: [ client: [
"name": ClawdisProtocol.AnyCodable("clawdis-mac"), "name": ClawdisProtocol.AnyCodable("clawdis-mac"),
"version": ClawdisProtocol.AnyCodable(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), "version": ClawdisProtocol.AnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ClawdisProtocol.AnyCodable(platform), "platform": ClawdisProtocol.AnyCodable(platform),
"mode": ClawdisProtocol.AnyCodable("app"), "mode": ClawdisProtocol.AnyCodable("app"),
"instanceId": ClawdisProtocol.AnyCodable(Host.current().localizedName ?? UUID().uuidString), "instanceId": ClawdisProtocol.AnyCodable(Host.current().localizedName ?? UUID().uuidString),
@@ -106,7 +107,10 @@ private actor GatewayChannelActor {
let data = try JSONEncoder().encode(hello) let data = try JSONEncoder().encode(hello)
try await self.task?.send(.data(data)) try await self.task?.send(.data(data))
guard let msg = try await task?.receive() else { guard let msg = try await task?.receive() else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed (no response)"]) throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (no response)"])
} }
try await self.handleHelloResponse(msg) try await self.handleHelloResponse(msg)
} }
@@ -118,7 +122,10 @@ private actor GatewayChannelActor {
@unknown default: nil @unknown default: nil
} }
guard let data else { guard let data else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed (empty response)"]) throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (empty response)"])
} }
let decoder = JSONDecoder() let decoder = JSONDecoder()
if let ok = try? decoder.decode(HelloOk.self, from: data) { if let ok = try? decoder.decode(HelloOk.self, from: data) {
@@ -134,9 +141,15 @@ private actor GatewayChannelActor {
return return
} }
if let err = try? decoder.decode(HelloError.self, from: data) { if let err = try? decoder.decode(HelloError.self, from: data) {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello-error: \(err.reason)"]) throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello-error: \(err.reason)"])
} }
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"]) throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"])
} }
private func listen() { private func listen() {

View File

@@ -105,7 +105,10 @@ enum GatewayEnvironment {
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: installed.description, gatewayVersion: installed.description,
requiredGateway: expected.description, requiredGateway: expected.description,
message: "Gateway version \(installed.description) is incompatible with app \(expected.description); install/update the global package.") message: """
Gateway version \(installed.description) is incompatible with app \(expected.description);
install or update the global package.
""")
} }
let gatewayLabel = gatewayBin != nil ? "global" : "local" let gatewayLabel = gatewayBin != nil ? "global" : "local"

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
import Network
import OSLog import OSLog
import Subprocess import Subprocess
import Network
#if canImport(Darwin) #if canImport(Darwin)
import Darwin import Darwin
#endif #endif
@@ -229,8 +229,7 @@ final class GatewayProcessManager: ObservableObject {
FilePath(path), FilePath(path),
.readWrite, .readWrite,
options: [.create, .exclusiveCreate], options: [.create, .exclusiveCreate],
permissions: [.ownerReadWrite] permissions: [.ownerReadWrite])
)
return GatewayLockHandle(fd: fd, path: path) return GatewayLockHandle(fd: fd, path: path)
} }

View File

@@ -525,13 +525,18 @@ extension GeneralSettings {
let target = LogLocator.bestLogFile() let target = LogLocator.bestLogFile()
if let target { if let target {
NSWorkspace.shared.selectFile(target.path, inFileViewerRootedAtPath: target.deletingLastPathComponent().path) NSWorkspace.shared.selectFile(
target.path,
inFileViewerRootedAtPath: target.deletingLastPathComponent().path)
return return
} }
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Log file not found" alert.messageText = "Log file not found"
alert.informativeText = "Looked for clawdis logs in /tmp/clawdis/. Run a health check or send a message to generate activity, then try again." alert.informativeText = """
Looked for clawdis logs in /tmp/clawdis/.
Run a health check or send a message to generate activity, then try again.
"""
alert.alertStyle = .informational alert.alertStyle = .informational
alert.addButton(withTitle: "OK") alert.addButton(withTitle: "OK")
alert.runModal() alert.runModal()

View File

@@ -4,22 +4,23 @@ enum LogLocator {
private static let logDir = URL(fileURLWithPath: "/tmp/clawdis") private static let logDir = URL(fileURLWithPath: "/tmp/clawdis")
private static let stdoutLog = logDir.appendingPathComponent("clawdis-stdout.log") private static let stdoutLog = logDir.appendingPathComponent("clawdis-stdout.log")
private static func modificationDate(for url: URL) -> Date {
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
}
/// Returns the newest log file under /tmp/clawdis/ (rolling or stdout), or nil if none exist. /// Returns the newest log file under /tmp/clawdis/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? { static func bestLogFile() -> URL? {
let fm = FileManager.default let fm = FileManager.default
let files = (try? fm.contentsOfDirectory( let files = (try? fm.contentsOfDirectory(
at: logDir, at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey], includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles])) ?? [] options: [.skipsHiddenFiles])) ?? []
return files return files
.filter { $0.lastPathComponent.hasPrefix("clawdis") && $0.pathExtension == "log" } .filter { $0.lastPathComponent.hasPrefix("clawdis") && $0.pathExtension == "log" }
.sorted { lhs, rhs in .max { lhs, rhs in
let lDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast self.modificationDate(for: lhs) < self.modificationDate(for: rhs)
let rDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return lDate > rDate
} }
.first
} }
/// Path to use for launchd stdout/err. /// Path to use for launchd stdout/err.

View File

@@ -15,7 +15,7 @@ struct ClawdisApp: App {
@State private var statusItem: NSStatusItem? @State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isPanelVisible = false @State private var isPanelVisible = false
@MainActor @MainActor
private func updateStatusHighlight() { private func updateStatusHighlight() {
self.statusItem?.button?.highlight(self.isPanelVisible) self.statusItem?.button?.highlight(self.isPanelVisible)
@@ -94,7 +94,7 @@ struct ClawdisApp: App {
handler.leadingAnchor.constraint(equalTo: button.leadingAnchor), handler.leadingAnchor.constraint(equalTo: button.leadingAnchor),
handler.trailingAnchor.constraint(equalTo: button.trailingAnchor), handler.trailingAnchor.constraint(equalTo: button.trailingAnchor),
handler.topAnchor.constraint(equalTo: button.topAnchor), handler.topAnchor.constraint(equalTo: button.topAnchor),
handler.bottomAnchor.constraint(equalTo: button.bottomAnchor) handler.bottomAnchor.constraint(equalTo: button.bottomAnchor),
]) ])
} }

View File

@@ -33,7 +33,9 @@ struct MenuContent: View {
self.voiceWakeMicMenu self.voiceWakeMicMenu
} }
if AppStateStore.webChatEnabled { if AppStateStore.webChatEnabled {
Button("Open Chat") { WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey()) } Button("Open Chat") {
WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey())
}
} }
Divider() Divider()
Button("Settings…") { self.open(tab: .general) } Button("Settings…") { self.open(tab: .general) }
@@ -59,7 +61,9 @@ struct MenuContent: View {
await self.reloadSessionMenu() await self.reloadSessionMenu()
} }
} label: { } label: {
Label(level.capitalized, systemImage: row.thinkingLevel == normalized ? "checkmark" : "") Label(
level.capitalized,
systemImage: row.thinkingLevel == normalized ? "checkmark" : "")
} }
} }
} }
@@ -75,7 +79,9 @@ struct MenuContent: View {
await self.reloadSessionMenu() await self.reloadSessionMenu()
} }
} label: { } label: {
Label(level.capitalized, systemImage: row.verboseLevel == normalized ? "checkmark" : "") Label(
level.capitalized,
systemImage: row.verboseLevel == normalized ? "checkmark" : "")
} }
} }
} }
@@ -216,19 +222,21 @@ struct MenuContent: View {
} }
}() }()
return Button(action: {}) { return Button(
HStack(spacing: 8) { action: {},
Circle() label: {
.fill(color) HStack(spacing: 8) {
.frame(width: 8, height: 8) Circle()
Text(label) .fill(color)
.font(.caption.weight(.semibold)) .frame(width: 8, height: 8)
.foregroundStyle(.primary) Text(label)
} .font(.caption.weight(.semibold))
.padding(.vertical, 4) .foregroundStyle(.primary)
} }
.buttonStyle(.plain) .padding(.vertical, 4)
.disabled(true) })
.buttonStyle(.plain)
.disabled(true)
} }
private var heartbeatStatusRow: some View { private var heartbeatStatusRow: some View {
@@ -254,19 +262,21 @@ struct MenuContent: View {
} }
}() }()
return Button(action: {}) { return Button(
HStack(spacing: 8) { action: {},
Circle() label: {
.fill(color) HStack(spacing: 8) {
.frame(width: 8, height: 8) Circle()
Text(label) .fill(color)
.font(.caption.weight(.semibold)) .frame(width: 8, height: 8)
.foregroundStyle(.primary) Text(label)
} .font(.caption.weight(.semibold))
.padding(.vertical, 2) .foregroundStyle(.primary)
} }
.buttonStyle(.plain) .padding(.vertical, 2)
.disabled(true) })
.buttonStyle(.plain)
.disabled(true)
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {

View File

@@ -182,7 +182,10 @@ struct OnboardingView: View {
Text("Install the gateway") Text("Install the gateway")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text( Text(
"Clawdis now runs the WebSocket gateway from the global \"clawdis\" package. Install/update it here and well check Node for you.") """
Clawdis now runs the WebSocket gateway from the global "clawdis" package.
Install/update it here and well check Node for you.
""")
.font(.body) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)

View File

@@ -23,11 +23,12 @@ actor PortGuardian {
private var records: [Record] = [] private var records: [Record] = []
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "portguard") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "portguard")
nonisolated private static let appSupportDir: URL = { private nonisolated static let appSupportDir: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdis", isDirectory: true) return base.appendingPathComponent("Clawdis", isDirectory: true)
}() }()
nonisolated private static var recordPath: URL {
private nonisolated static var recordPath: URL {
self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false) self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false)
} }
@@ -43,12 +44,20 @@ actor PortGuardian {
guard !listeners.isEmpty else { continue } guard !listeners.isEmpty else { continue }
for listener in listeners { for listener in listeners {
if self.isExpected(listener, port: port, mode: mode) { if self.isExpected(listener, port: port, mode: mode) {
self.logger.info("port \(port, privacy: .public) already served by expected \(listener.command, privacy: .public) (pid \(listener.pid)) — keeping") let message = """
port \(port) already served by expected \(listener.command)
(pid \(listener.pid)) — keeping
"""
self.logger.info("\(message, privacy: .public)")
continue continue
} }
let killed = await self.kill(listener.pid) let killed = await self.kill(listener.pid)
if killed { if killed {
self.logger.error("port \(port, privacy: .public) was held by \(listener.command, privacy: .public) (pid \(listener.pid)); terminated") let message = """
port \(port) was held by \(listener.command)
(pid \(listener.pid)); terminated
"""
self.logger.error("\(message, privacy: .public)")
} else { } else {
self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)") self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)")
} }
@@ -60,7 +69,13 @@ actor PortGuardian {
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
self.records.removeAll { $0.pid == pid } self.records.removeAll { $0.pid == pid }
self.records.append(Record(port: port, pid: pid, command: command, mode: mode.rawValue, timestamp: Date().timeIntervalSince1970)) self.records.append(
Record(
port: port,
pid: pid,
command: command,
mode: mode.rawValue,
timestamp: Date().timeIntervalSince1970))
self.save() self.save()
} }
@@ -93,9 +108,9 @@ actor PortGuardian {
var summary: String { var summary: String {
switch self.status { switch self.status {
case let .ok(text): return text case let .ok(text): text
case let .missing(text): return text case let .missing(text): text
case let .interference(text, _): return text case let .interference(text, _): text
} }
} }
} }
@@ -105,6 +120,7 @@ actor PortGuardian {
let path = Self.executablePath(for: listener.pid) let path = Self.executablePath(for: listener.pid)
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path) return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
} }
// MARK: - Internals // MARK: - Internals
private struct Listener { private struct Listener {
@@ -132,6 +148,7 @@ actor PortGuardian {
let listeners = await self.listeners(on: port) let listeners = await self.listeners(on: port)
let expectedDesc: String let expectedDesc: String
let okPredicate: (Listener) -> Bool let okPredicate: (Listener) -> Bool
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
switch mode { switch mode {
case .remote: case .remote:
@@ -143,7 +160,7 @@ actor PortGuardian {
: "Gateway websocket (node/tsx)" : "Gateway websocket (node/tsx)"
okPredicate = { listener in okPredicate = { listener in
let c = listener.command.lowercased() let c = listener.command.lowercased()
return c.contains("node") || c.contains("clawdis") || c.contains("tsx") || c.contains("pnpm") || c.contains("bun") return expectedCommands.contains { c.contains($0) }
} }
} }
@@ -166,11 +183,19 @@ actor PortGuardian {
if offenders.isEmpty { if offenders.isEmpty {
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
let okText = "Port \(port) is served by \(list)." let okText = "Port \(port) is served by \(list)."
reports.append(.init(port: port, expected: expectedDesc, status: .ok(okText), listeners: reportListeners)) reports.append(.init(
port: port,
expected: expectedDesc,
status: .ok(okText),
listeners: reportListeners))
} else { } else {
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ") let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
let reason = "Port \(port) is held by \(list), expected \(expectedDesc)." let reason = "Port \(port) is held by \(list), expected \(expectedDesc)."
reports.append(.init(port: port, expected: expectedDesc, status: .interference(reason, offenders: offenders), listeners: reportListeners)) reports.append(.init(
port: port,
expected: expectedDesc,
status: .interference(reason, offenders: offenders),
listeners: reportListeners))
} }
} }
@@ -245,11 +270,13 @@ actor PortGuardian {
guard length > 0 else { return nil } guard length > 0 else { return nil }
// Drop trailing null and decode as UTF-8. // Drop trailing null and decode as UTF-8.
let trimmed = buffer.prefix { $0 != 0 } let trimmed = buffer.prefix { $0 != 0 }
return String(decoding: trimmed.map { UInt8(bitPattern: $0) }, as: UTF8.self) let bytes = trimmed.map { UInt8(bitPattern: $0) }
return String(bytes: bytes, encoding: .utf8)
#else #else
return nil return nil
#endif #endif
} }
private func kill(_ pid: Int32) async -> Bool { private func kill(_ pid: Int32) async -> Bool {
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2) let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
if term.ok { return true } if term.ok { return true }
@@ -259,6 +286,7 @@ actor PortGuardian {
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
let cmd = listener.command.lowercased() let cmd = listener.command.lowercased()
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
switch mode { switch mode {
case .remote: case .remote:
if port == 18788 { if port == 18788 {
@@ -266,7 +294,7 @@ actor PortGuardian {
} }
return false return false
case .local: case .local:
return cmd.contains("node") || cmd.contains("clawdis") || cmd.contains("tsx") || cmd.contains("pnpm") || cmd.contains("bun") return expectedCommands.contains { cmd.contains($0) }
} }
} }

View File

@@ -11,12 +11,16 @@ actor RemoteTunnelManager {
func ensureControlTunnel() async throws -> UInt16 { func ensureControlTunnel() async throws -> UInt16 {
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote else { guard settings.mode == .remote else {
throw NSError(domain: "RemoteTunnel", code: 1, userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"]) throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
} }
if let tunnel = self.controlTunnel, if let tunnel = self.controlTunnel,
tunnel.process.isRunning, tunnel.process.isRunning,
let local = tunnel.localPort { let local = tunnel.localPort
{
return local return local
} }

View File

@@ -271,8 +271,7 @@ struct ToolsSettings: View {
let current = self.installStates[tool.id] ?? .checking let current = self.installStates[tool.id] ?? .checking
return Binding( return Binding(
get: { self.installStates[tool.id] ?? current }, get: { self.installStates[tool.id] ?? current },
set: { self.installStates[tool.id] = $0 } set: { self.installStates[tool.id] = $0 })
)
} }
private func refreshAll() { private func refreshAll() {

View File

@@ -123,7 +123,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func loadPlaceholder() { private func loadPlaceholder() {
let html = """ let html = """
<html><body style='font-family:-apple-system;margin:0;padding:0;display:flex;align-items:center;justify-content:center;height:100vh;color:#888'>Connecting to web chat…</body></html> <html>
<body style='font-family:-apple-system;
margin:0;
padding:0;
display:flex;
align-items:center;
justify-content:center;
height:100vh;
color:#888'>
Connecting to web chat…
</body>
</html>
""" """
self.webView.loadHTMLString(html, baseURL: nil) self.webView.loadHTMLString(html, baseURL: nil)
} }
@@ -165,9 +176,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func prepareEndpoint(remotePort: Int) async throws -> URL { private func prepareEndpoint(remotePort: Int) async throws -> URL {
if CommandResolver.connectionModeIsRemote() { if CommandResolver.connectionModeIsRemote() {
return try await self.startOrRestartTunnel() try await self.startOrRestartTunnel()
} else { } else {
return URL(string: "http://127.0.0.1:\(remotePort)/")! URL(string: "http://127.0.0.1:\(remotePort)/")!
} }
} }
@@ -208,7 +219,10 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func isWebChatBooted() async -> Bool { private func isWebChatBooted() async -> Bool {
await withCheckedContinuation { cont in await withCheckedContinuation { cont in
self.webView.evaluateJavaScript("document.getElementById('app')?.dataset.booted === '1' || document.body.dataset.webchatError === '1'") { result, _ in self.webView.evaluateJavaScript("""
document.getElementById('app')?.dataset.booted === '1' ||
document.body.dataset.webchatError === '1'
""") { result, _ in
cont.resume(returning: result as? Bool ?? false) cont.resume(returning: result as? Bool ?? false)
} }
} }
@@ -306,8 +320,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func showError(_ text: String) { private func showError(_ text: String) {
self.bootWatchTask?.cancel() self.bootWatchTask?.cancel()
let html = """ let html = """
<html><body style='font-family:-apple-system;margin:0;padding:0;display:flex;align-items:center;justify-content:center;height:100vh;color:#c00'>Web chat failed to connect.<br><br>\( <html>
text)</body></html> <body style='font-family:-apple-system;
margin:0;
padding:0;
display:flex;
align-items:center;
justify-content:center;
height:100vh;
color:#c00'>
Web chat failed to connect.<br><br>\(text)
</body>
</html>
""" """
self.webView.loadHTMLString(html, baseURL: nil) self.webView.loadHTMLString(html, baseURL: nil)
} }
@@ -354,8 +378,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func installDismissMonitor() { private func installDismissMonitor() {
guard self.localDismissMonitor == nil, let panel = self.window else { return } guard self.localDismissMonitor == nil, let panel = self.window else { return }
self.localDismissMonitor = NSEvent.addGlobalMonitorForEvents( self.localDismissMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
) { [weak self] _ in { [weak self] _ in
guard let self else { return } guard let self else { return }
let pt = NSEvent.mouseLocation // screen coordinates let pt = NSEvent.mouseLocation // screen coordinates
if !panel.frame.contains(pt) { if !panel.frame.contains(pt) {
@@ -379,8 +403,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
let o1 = nc.addObserver( let o1 = nc.addObserver(
forName: NSApplication.didResignActiveNotification, forName: NSApplication.didResignActiveNotification,
object: nil, object: nil,
queue: .main queue: .main)
) { [weak self] _ in { [weak self] _ in
Task { @MainActor in Task { @MainActor in
self?.closePanel() self?.closePanel()
} }
@@ -388,8 +412,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
let o2 = nc.addObserver( let o2 = nc.addObserver(
forName: NSWindow.didChangeOcclusionStateNotification, forName: NSWindow.didChangeOcclusionStateNotification,
object: window, object: window,
queue: .main queue: .main)
) { [weak self] _ in { [weak self] _ in
Task { @MainActor in Task { @MainActor in
guard let self, case .panel = self.presentation else { return } guard let self, case .panel = self.presentation else { return }
if !(window.occlusionState.contains(.visible)) { if !(window.occlusionState.contains(.visible)) {
@@ -402,7 +426,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func removePanelObservers() { private func removePanelObservers() {
let nc = NotificationCenter.default let nc = NotificationCenter.default
for o in self.observers { nc.removeObserver(o) } for o in self.observers {
nc.removeObserver(o)
}
self.observers.removeAll() self.observers.removeAll()
} }
} }
@@ -431,7 +457,7 @@ extension WebChatWindowController {
{ {
let end = hostname.firstIndex(of: 0) ?? hostname.count let end = hostname.firstIndex(of: 0) ?? hostname.count
let bytes = hostname[..<end].map { UInt8(bitPattern: $0) } let bytes = hostname[..<end].map { UInt8(bitPattern: $0) }
let ip = String(decoding: bytes, as: UTF8.self) guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if !ip.hasPrefix("169.254") { return ip } if !ip.hasPrefix("169.254") { return ip }
} }
} }
@@ -539,7 +565,10 @@ final class WebChatManager {
self.browserTunnel?.terminate() self.browserTunnel?.terminate()
self.browserTunnel = tunnel self.browserTunnel = tunnel
guard let local = tunnel.localPort else { guard let local = tunnel.localPort else {
throw NSError(domain: "WebChat", code: 7, userInfo: [NSLocalizedDescriptionKey: "Tunnel missing local port"]) throw NSError(
domain: "WebChat",
code: 7,
userInfo: [NSLocalizedDescriptionKey: "Tunnel missing local port"])
} }
base = URL(string: "http://127.0.0.1:\(local)/")! base = URL(string: "http://127.0.0.1:\(local)/")!
} catch { } catch {

View File

@@ -1,6 +1,6 @@
import ClawdisProtocol
import Foundation import Foundation
import Testing import Testing
import ClawdisProtocol
@Suite struct GatewayFrameDecodeTests { @Suite struct GatewayFrameDecodeTests {
@Test func decodesEventFrameWithAnyCodablePayload() throws { @Test func decodesEventFrameWithAnyCodablePayload() throws {

View File

@@ -1,6 +1,6 @@
import ClawdisProtocol
import Testing import Testing
@testable import Clawdis @testable import Clawdis
import ClawdisProtocol
@Suite struct InstancesStoreTests { @Suite struct InstancesStoreTests {
@Test @Test