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)
|
||||
} catch {
|
||||
// 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 {
|
||||
GatewayProcessManager.shared.stop()
|
||||
|
||||
@@ -147,9 +147,14 @@ final class ControlChannel: ObservableObject {
|
||||
// to free the port instead of a vague message.
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == "Gateway",
|
||||
nsError.localizedDescription.contains("hello failed (unexpected response)") {
|
||||
nsError.localizedDescription.contains("hello failed (unexpected response)")
|
||||
{
|
||||
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 {
|
||||
@@ -171,7 +176,8 @@ final class ControlChannel: ObservableObject {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -234,11 +234,11 @@ enum CritterIconRenderer {
|
||||
colorSpaceName: .deviceRGB,
|
||||
bitmapFormat: [],
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
) else {
|
||||
return NSImage(size: size)
|
||||
bitsPerPixel: 0)
|
||||
else {
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
rep.size = size
|
||||
rep.size = self.size
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
if let context = NSGraphicsContext(bitmapImageRep: rep) {
|
||||
@@ -247,8 +247,8 @@ enum CritterIconRenderer {
|
||||
context.cgContext.setShouldAntialias(false)
|
||||
defer { NSGraphicsContext.restoreGraphicsState() }
|
||||
|
||||
let stepX = size.width / max(CGFloat(rep.pixelsWide), 1)
|
||||
let stepY = size.height / max(CGFloat(rep.pixelsHigh), 1)
|
||||
let stepX = self.size.width / max(CGFloat(rep.pixelsWide), 1)
|
||||
let stepY = self.size.height / max(CGFloat(rep.pixelsHigh), 1)
|
||||
let snapX: (CGFloat) -> CGFloat = { ($0 / stepX).rounded() * stepX }
|
||||
let snapY: (CGFloat) -> CGFloat = { ($0 / stepY).rounded() * stepY }
|
||||
|
||||
@@ -373,7 +373,7 @@ enum CritterIconRenderer {
|
||||
context.cgContext.restoreGState()
|
||||
} else {
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
return NSImage(size: size)
|
||||
return NSImage(size: self.size)
|
||||
}
|
||||
|
||||
let image = NSImage(size: size)
|
||||
|
||||
@@ -94,7 +94,7 @@ struct DebugSettings: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if self.portReports.isEmpty && !self.portCheckInFlight {
|
||||
if self.portReports.isEmpty, !self.portCheckInFlight {
|
||||
Text("Check which process owns 18788/18789 and suggest fixes.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -281,8 +281,7 @@ struct DebugSettings: View {
|
||||
primaryButton: .destructive(Text("Kill")) {
|
||||
Task { await self.killConfirmed(listener.pid) }
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ private actor GatewayChannelActor {
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15_000
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
|
||||
init(url: URL, token: String?) {
|
||||
self.url = url
|
||||
@@ -94,7 +94,8 @@ private actor GatewayChannelActor {
|
||||
maxprotocol: GATEWAY_PROTOCOL_VERSION,
|
||||
client: [
|
||||
"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),
|
||||
"mode": ClawdisProtocol.AnyCodable("app"),
|
||||
"instanceId": ClawdisProtocol.AnyCodable(Host.current().localizedName ?? UUID().uuidString),
|
||||
@@ -106,7 +107,10 @@ private actor GatewayChannelActor {
|
||||
let data = try JSONEncoder().encode(hello)
|
||||
try await self.task?.send(.data(data))
|
||||
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)
|
||||
}
|
||||
@@ -118,7 +122,10 @@ private actor GatewayChannelActor {
|
||||
@unknown default: nil
|
||||
}
|
||||
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()
|
||||
if let ok = try? decoder.decode(HelloOk.self, from: data) {
|
||||
@@ -134,9 +141,15 @@ private actor GatewayChannelActor {
|
||||
return
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -105,7 +105,10 @@ enum GatewayEnvironment {
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
import Subprocess
|
||||
import Network
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
@@ -229,8 +229,7 @@ final class GatewayProcessManager: ObservableObject {
|
||||
FilePath(path),
|
||||
.readWrite,
|
||||
options: [.create, .exclusiveCreate],
|
||||
permissions: [.ownerReadWrite]
|
||||
)
|
||||
permissions: [.ownerReadWrite])
|
||||
return GatewayLockHandle(fd: fd, path: path)
|
||||
}
|
||||
|
||||
|
||||
@@ -525,13 +525,18 @@ extension GeneralSettings {
|
||||
let target = LogLocator.bestLogFile()
|
||||
|
||||
if let target {
|
||||
NSWorkspace.shared.selectFile(target.path, inFileViewerRootedAtPath: target.deletingLastPathComponent().path)
|
||||
NSWorkspace.shared.selectFile(
|
||||
target.path,
|
||||
inFileViewerRootedAtPath: target.deletingLastPathComponent().path)
|
||||
return
|
||||
}
|
||||
|
||||
let alert = NSAlert()
|
||||
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.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
|
||||
@@ -4,22 +4,23 @@ enum LogLocator {
|
||||
private static let logDir = URL(fileURLWithPath: "/tmp/clawdis")
|
||||
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.
|
||||
static func bestLogFile() -> URL? {
|
||||
let fm = FileManager.default
|
||||
let files = (try? fm.contentsOfDirectory(
|
||||
at: logDir,
|
||||
at: self.logDir,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles])) ?? []
|
||||
|
||||
return files
|
||||
.filter { $0.lastPathComponent.hasPrefix("clawdis") && $0.pathExtension == "log" }
|
||||
.sorted { lhs, rhs in
|
||||
let lDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let rDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return lDate > rDate
|
||||
.max { lhs, rhs in
|
||||
self.modificationDate(for: lhs) < self.modificationDate(for: rhs)
|
||||
}
|
||||
.first
|
||||
}
|
||||
|
||||
/// Path to use for launchd stdout/err.
|
||||
|
||||
@@ -15,7 +15,7 @@ struct ClawdisApp: App {
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
|
||||
|
||||
@MainActor
|
||||
private func updateStatusHighlight() {
|
||||
self.statusItem?.button?.highlight(self.isPanelVisible)
|
||||
@@ -94,7 +94,7 @@ struct ClawdisApp: App {
|
||||
handler.leadingAnchor.constraint(equalTo: button.leadingAnchor),
|
||||
handler.trailingAnchor.constraint(equalTo: button.trailingAnchor),
|
||||
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
|
||||
}
|
||||
if AppStateStore.webChatEnabled {
|
||||
Button("Open Chat") { WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey()) }
|
||||
Button("Open Chat") {
|
||||
WebChatManager.shared.show(sessionKey: WebChatManager.shared.preferredSessionKey())
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Settings…") { self.open(tab: .general) }
|
||||
@@ -59,7 +61,9 @@ struct MenuContent: View {
|
||||
await self.reloadSessionMenu()
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
} 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: {}) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
return Button(
|
||||
action: {},
|
||||
label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
}
|
||||
|
||||
private var heartbeatStatusRow: some View {
|
||||
@@ -254,19 +262,21 @@ struct MenuContent: View {
|
||||
}
|
||||
}()
|
||||
|
||||
return Button(action: {}) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
return Button(
|
||||
action: {},
|
||||
label: {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
|
||||
@@ -182,7 +182,10 @@ struct OnboardingView: View {
|
||||
Text("Install the gateway")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
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)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -23,11 +23,12 @@ actor PortGuardian {
|
||||
|
||||
private var records: [Record] = []
|
||||
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!
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -43,12 +44,20 @@ actor PortGuardian {
|
||||
guard !listeners.isEmpty else { continue }
|
||||
for listener in listeners {
|
||||
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
|
||||
}
|
||||
let killed = await self.kill(listener.pid)
|
||||
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 {
|
||||
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 {
|
||||
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -93,9 +108,9 @@ actor PortGuardian {
|
||||
|
||||
var summary: String {
|
||||
switch self.status {
|
||||
case let .ok(text): return text
|
||||
case let .missing(text): return text
|
||||
case let .interference(text, _): return text
|
||||
case let .ok(text): text
|
||||
case let .missing(text): text
|
||||
case let .interference(text, _): text
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +120,7 @@ actor PortGuardian {
|
||||
let path = Self.executablePath(for: listener.pid)
|
||||
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private struct Listener {
|
||||
@@ -132,6 +148,7 @@ actor PortGuardian {
|
||||
let listeners = await self.listeners(on: port)
|
||||
let expectedDesc: String
|
||||
let okPredicate: (Listener) -> Bool
|
||||
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
@@ -143,7 +160,7 @@ actor PortGuardian {
|
||||
: "Gateway websocket (node/tsx)"
|
||||
okPredicate = { listener in
|
||||
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 {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
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 {
|
||||
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
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 }
|
||||
// Drop trailing null and decode as UTF-8.
|
||||
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
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func kill(_ pid: Int32) async -> Bool {
|
||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if term.ok { return true }
|
||||
@@ -259,6 +286,7 @@ actor PortGuardian {
|
||||
|
||||
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
|
||||
let cmd = listener.command.lowercased()
|
||||
let expectedCommands = ["node", "clawdis", "tsx", "pnpm", "bun"]
|
||||
switch mode {
|
||||
case .remote:
|
||||
if port == 18788 {
|
||||
@@ -266,7 +294,7 @@ actor PortGuardian {
|
||||
}
|
||||
return false
|
||||
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 {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
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,
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort {
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
return local
|
||||
}
|
||||
|
||||
|
||||
@@ -271,8 +271,7 @@ struct ToolsSettings: View {
|
||||
let current = self.installStates[tool.id] ?? .checking
|
||||
return Binding(
|
||||
get: { self.installStates[tool.id] ?? current },
|
||||
set: { self.installStates[tool.id] = $0 }
|
||||
)
|
||||
set: { self.installStates[tool.id] = $0 })
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
|
||||
@@ -123,7 +123,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
|
||||
private func loadPlaceholder() {
|
||||
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)
|
||||
}
|
||||
@@ -165,9 +176,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
|
||||
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return try await self.startOrRestartTunnel()
|
||||
try await self.startOrRestartTunnel()
|
||||
} 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -306,8 +320,18 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
private func showError(_ text: String) {
|
||||
self.bootWatchTask?.cancel()
|
||||
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>\(
|
||||
text)</body></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>\(text)
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.webView.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
@@ -354,8 +378,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
private func installDismissMonitor() {
|
||||
guard self.localDismissMonitor == nil, let panel = self.window else { return }
|
||||
self.localDismissMonitor = NSEvent.addGlobalMonitorForEvents(
|
||||
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]
|
||||
) { [weak self] _ in
|
||||
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
|
||||
{ [weak self] _ in
|
||||
guard let self else { return }
|
||||
let pt = NSEvent.mouseLocation // screen coordinates
|
||||
if !panel.frame.contains(pt) {
|
||||
@@ -379,8 +403,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
let o1 = nc.addObserver(
|
||||
forName: NSApplication.didResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
queue: .main)
|
||||
{ [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.closePanel()
|
||||
}
|
||||
@@ -388,8 +412,8 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
let o2 = nc.addObserver(
|
||||
forName: NSWindow.didChangeOcclusionStateNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
queue: .main)
|
||||
{ [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, case .panel = self.presentation else { return }
|
||||
if !(window.occlusionState.contains(.visible)) {
|
||||
@@ -402,7 +426,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
|
||||
private func removePanelObservers() {
|
||||
let nc = NotificationCenter.default
|
||||
for o in self.observers { nc.removeObserver(o) }
|
||||
for o in self.observers {
|
||||
nc.removeObserver(o)
|
||||
}
|
||||
self.observers.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -431,7 +457,7 @@ extension WebChatWindowController {
|
||||
{
|
||||
let end = hostname.firstIndex(of: 0) ?? hostname.count
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -539,7 +565,10 @@ final class WebChatManager {
|
||||
self.browserTunnel?.terminate()
|
||||
self.browserTunnel = tunnel
|
||||
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)/")!
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user