mac: tidy menu and gateway support
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 we’ll check Node for you.")
|
"""
|
||||||
|
Clawdis now runs the WebSocket gateway from the global "clawdis" package.
|
||||||
|
Install/update it here and we’ll check Node for you.
|
||||||
|
""")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user