fix(mac): serve webchat locally in remote mode
This commit is contained in:
@@ -15,7 +15,7 @@ final class WebChatServer: @unchecked Sendable {
|
||||
private var port: NWEndpoint.Port?
|
||||
|
||||
/// Start the local HTTP server if it isn't already running. Safe to call multiple times.
|
||||
func start(root: URL) {
|
||||
func start(root: URL, preferredPort: UInt16? = nil) {
|
||||
self.queue.async {
|
||||
webChatServerLogger.debug("WebChatServer start requested root=\(root.path, privacy: .public)")
|
||||
if self.listener != nil { return }
|
||||
@@ -23,8 +23,9 @@ final class WebChatServer: @unchecked Sendable {
|
||||
let params = NWParameters.tcp
|
||||
params.allowLocalEndpointReuse = true
|
||||
params.requiredInterfaceType = .loopback
|
||||
let prefer = preferredPort.flatMap { NWEndpoint.Port(rawValue: $0) }
|
||||
do {
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
let listener = try NWListener(using: params, on: prefer ?? .any)
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
@@ -44,8 +45,38 @@ final class WebChatServer: @unchecked Sendable {
|
||||
listener.start(queue: self.queue)
|
||||
self.listener = listener
|
||||
} catch {
|
||||
webChatServerLogger
|
||||
.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)")
|
||||
if let prefer {
|
||||
do {
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
self?.port = listener.port
|
||||
webChatServerLogger.debug(
|
||||
"WebChatServer ready on 127.0.0.1:\(listener.port?.rawValue ?? 0)")
|
||||
case let .failed(error):
|
||||
webChatServerLogger
|
||||
.error("WebChatServer failed: \(error.localizedDescription, privacy: .public)")
|
||||
self?.listener = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.handle(connection: connection)
|
||||
}
|
||||
listener.start(queue: self.queue)
|
||||
self.listener = listener
|
||||
webChatServerLogger.debug(
|
||||
"WebChatServer fell back to ephemeral port (preferred \(prefer.rawValue))")
|
||||
} catch {
|
||||
webChatServerLogger
|
||||
.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
} else {
|
||||
webChatServerLogger
|
||||
.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,8 +143,16 @@ final class WebChatServer: @unchecked Sendable {
|
||||
}
|
||||
webChatServerLogger.debug("WebChatServer request line=\(requestLine, privacy: .public)")
|
||||
let parts = requestLine.split(separator: " ")
|
||||
guard parts.count >= 2, parts[0] == "GET" else {
|
||||
webChatServerLogger.error("WebChatServer non-GET request: \(requestLine, privacy: .public)")
|
||||
guard parts.count >= 2 else {
|
||||
webChatServerLogger.error("WebChatServer invalid request: \(requestLine, privacy: .public)")
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
let method = parts[0]
|
||||
let includeBody = method == "GET"
|
||||
guard includeBody || method == "HEAD" else {
|
||||
webChatServerLogger.error(
|
||||
"WebChatServer unsupported request method: \(requestLine, privacy: .public)")
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
@@ -137,25 +176,53 @@ final class WebChatServer: @unchecked Sendable {
|
||||
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
|
||||
// Simple directory traversal guard: served files must live under the bundled web root.
|
||||
guard fileURL.path.hasPrefix(root.path) else {
|
||||
self.send(status: 403, mime: "text/plain", body: Data("Forbidden".utf8), over: connection)
|
||||
let forbidden = Data("Forbidden".utf8)
|
||||
self.send(
|
||||
status: 403,
|
||||
mime: "text/plain",
|
||||
body: forbidden,
|
||||
contentLength: forbidden.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
return
|
||||
}
|
||||
guard let data = try? Data(contentsOf: fileURL) else {
|
||||
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
|
||||
self.send(status: 404, mime: "text/plain", body: Data("Not Found".utf8), over: connection)
|
||||
self.send(
|
||||
status: 404,
|
||||
mime: "text/plain",
|
||||
body: Data("Not Found".utf8),
|
||||
contentLength: "Not Found".utf8.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
return
|
||||
}
|
||||
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
||||
self.send(status: 200, mime: mime, body: data, over: connection)
|
||||
self.send(
|
||||
status: 200,
|
||||
mime: mime,
|
||||
body: data,
|
||||
contentLength: data.count,
|
||||
includeBody: includeBody,
|
||||
over: connection)
|
||||
}
|
||||
|
||||
private func send(status: Int, mime: String, body: Data, over connection: NWConnection) {
|
||||
private func send(
|
||||
status: Int,
|
||||
mime: String,
|
||||
body: Data,
|
||||
contentLength: Int,
|
||||
includeBody: Bool,
|
||||
over connection: NWConnection)
|
||||
{
|
||||
let headers = "HTTP/1.1 \(status) \(statusText(status))\r\n" +
|
||||
"Content-Length: \(body.count)\r\n" +
|
||||
"Content-Length: \(contentLength)\r\n" +
|
||||
"Content-Type: \(mime)\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
var response = Data(headers.utf8)
|
||||
response.append(body)
|
||||
if includeBody {
|
||||
response.append(body)
|
||||
}
|
||||
connection.send(content: response, completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
import WebKit
|
||||
|
||||
@@ -32,12 +31,10 @@ enum WebChatPresentation {
|
||||
final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||
private let webView: WKWebView
|
||||
private let sessionKey: String
|
||||
private var tunnel: WebChatTunnel?
|
||||
private var baseEndpoint: URL?
|
||||
private let remotePort: Int
|
||||
private var resolvedGatewayPort: Int?
|
||||
private var reachabilityTask: Task<Void, Never>?
|
||||
private var tunnelRestartEnabled = false
|
||||
private var bootWatchTask: Task<Void, Never>?
|
||||
let presentation: WebChatPresentation
|
||||
var onPanelClosed: (() -> Void)?
|
||||
@@ -78,7 +75,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
@MainActor deinit {
|
||||
self.reachabilityTask?.cancel()
|
||||
self.bootWatchTask?.cancel()
|
||||
self.stopTunnel(allowRestart: false)
|
||||
self.removeDismissMonitor()
|
||||
self.removePanelObservers()
|
||||
}
|
||||
@@ -232,10 +228,21 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
|
||||
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
try await self.startOrRestartTunnel()
|
||||
} else {
|
||||
URL(string: "http://127.0.0.1:\(remotePort)/")!
|
||||
let root = try Self.webChatAssetsRootURL()
|
||||
WebChatServer.shared.start(root: root, preferredPort: nil)
|
||||
let deadline = Date().addingTimeInterval(2.0)
|
||||
while Date() < deadline {
|
||||
if let url = WebChatServer.shared.baseURL() {
|
||||
return url
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
throw NSError(
|
||||
domain: "WebChat",
|
||||
code: 11,
|
||||
userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"])
|
||||
}
|
||||
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
||||
}
|
||||
|
||||
private func loadWebChat(baseEndpoint: URL) {
|
||||
@@ -311,42 +318,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
}
|
||||
}
|
||||
|
||||
private func startOrRestartTunnel() async throws -> URL {
|
||||
// Kill existing tunnel if any
|
||||
self.stopTunnel(allowRestart: false)
|
||||
|
||||
let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18788)
|
||||
self.tunnel = tunnel
|
||||
self.tunnelRestartEnabled = true
|
||||
|
||||
// Auto-restart on unexpected termination while window lives
|
||||
tunnel.process.terminationHandler = { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.tunnelRestartEnabled else { return }
|
||||
webChatLogger.error("webchat tunnel terminated; restarting")
|
||||
do {
|
||||
// Recreate the tunnel silently so the window keeps working without user intervention.
|
||||
let base = try await self.startOrRestartTunnel()
|
||||
self.loadPage(baseURL: base)
|
||||
} catch {
|
||||
self.showError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let port = tunnel.localPort else {
|
||||
throw NSError(domain: "WebChat", code: 2, userInfo: [NSLocalizedDescriptionKey: "tunnel missing port"])
|
||||
}
|
||||
return URL(string: "http://127.0.0.1:\(port)/")!
|
||||
}
|
||||
|
||||
private func stopTunnel(allowRestart: Bool) {
|
||||
self.tunnelRestartEnabled = allowRestart
|
||||
self.tunnel?.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
|
||||
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
||||
guard case .panel = self.presentation, let window else { return }
|
||||
self.panelCloseNotified = false
|
||||
@@ -410,7 +381,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
func shutdown() {
|
||||
self.reachabilityTask?.cancel()
|
||||
self.bootWatchTask?.cancel()
|
||||
self.stopTunnel(allowRestart: false)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
@@ -502,6 +472,17 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||
}
|
||||
self.observers.removeAll()
|
||||
}
|
||||
|
||||
fileprivate static func webChatAssetsRootURL() throws -> URL {
|
||||
if let url = Bundle.main.url(forResource: "WebChat", withExtension: nil) { return url }
|
||||
if let url = Bundle.main.resourceURL?.appendingPathComponent("WebChat"),
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
{
|
||||
return url
|
||||
}
|
||||
if let url = Bundle.module.url(forResource: "WebChat", withExtension: nil) { return url }
|
||||
throw NSError(domain: "WebChat", code: 10, userInfo: [NSLocalizedDescriptionKey: "WebChat assets missing"])
|
||||
}
|
||||
}
|
||||
|
||||
extension WebChatWindowController {
|
||||
@@ -548,7 +529,6 @@ final class WebChatManager {
|
||||
private var swiftWindowController: WebChatSwiftUIWindowController?
|
||||
private var swiftPanelController: WebChatSwiftUIWindowController?
|
||||
private var swiftPanelSessionKey: String?
|
||||
private var browserTunnel: WebChatTunnel?
|
||||
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
func show(sessionKey: String) {
|
||||
@@ -671,8 +651,6 @@ final class WebChatManager {
|
||||
|
||||
@MainActor
|
||||
func resetTunnels() {
|
||||
self.browserTunnel?.terminate()
|
||||
self.browserTunnel = nil
|
||||
self.windowController?.shutdown()
|
||||
self.windowController?.close()
|
||||
self.windowController = nil
|
||||
@@ -689,31 +667,37 @@ final class WebChatManager {
|
||||
|
||||
@MainActor
|
||||
func openInBrowser(sessionKey: String) async {
|
||||
let port = AppStateStore.webChatPort
|
||||
let base: URL
|
||||
let gatewayPort: Int
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
do {
|
||||
// Prefer the configured port; fall back if busy.
|
||||
let tunnel = try await WebChatTunnel.create(
|
||||
remotePort: port,
|
||||
preferredLocalPort: UInt16(port))
|
||||
let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||
gatewayPort = Int(forwarded)
|
||||
self.browserTunnel?.terminate()
|
||||
self.browserTunnel = tunnel
|
||||
guard let local = tunnel.localPort else {
|
||||
|
||||
let root = try WebChatWindowController.webChatAssetsRootURL()
|
||||
WebChatServer.shared.start(root: root, preferredPort: nil)
|
||||
let deadline = Date().addingTimeInterval(2.0)
|
||||
var resolved: URL?
|
||||
while Date() < deadline {
|
||||
if let url = WebChatServer.shared.baseURL() {
|
||||
resolved = url
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
guard let resolved else {
|
||||
throw NSError(
|
||||
domain: "WebChat",
|
||||
code: 7,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Tunnel missing local port"])
|
||||
code: 11,
|
||||
userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"])
|
||||
}
|
||||
base = URL(string: "http://127.0.0.1:\(local)/")!
|
||||
base = resolved
|
||||
} catch {
|
||||
NSAlert(error: error).runModal()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let port = AppStateStore.webChatPort
|
||||
gatewayPort = GatewayEnvironment.gatewayPort()
|
||||
base = URL(string: "http://127.0.0.1:\(port)/")!
|
||||
}
|
||||
@@ -751,128 +735,3 @@ final class WebChatManager {
|
||||
// Keep panel controllers cached so reopening doesn't re-bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Port forwarding tunnel
|
||||
|
||||
final class WebChatTunnel {
|
||||
let process: Process
|
||||
let localPort: UInt16?
|
||||
|
||||
private init(process: Process, localPort: UInt16?) {
|
||||
self.process = process
|
||||
self.localPort = localPort
|
||||
}
|
||||
|
||||
deinit {
|
||||
let pid = self.process.processIdentifier
|
||||
self.process.terminate()
|
||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||
}
|
||||
|
||||
func terminate() {
|
||||
let pid = self.process.processIdentifier
|
||||
if self.process.isRunning {
|
||||
self.process.terminate()
|
||||
self.process.waitUntilExit()
|
||||
}
|
||||
Task { await PortGuardian.shared.removeRecord(pid: pid) }
|
||||
}
|
||||
|
||||
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = CommandResolver.parseSSHTarget(settings.target) else {
|
||||
throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"])
|
||||
}
|
||||
|
||||
let localPort = try await Self.findPort(preferred: preferredLocalPort)
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=15",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-o", "TCPKeepAlive=yes",
|
||||
"-N",
|
||||
"-L", "\(localPort):127.0.0.1:\(remotePort)",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
process.arguments = args
|
||||
let pipe = Pipe()
|
||||
process.standardError = pipe
|
||||
// Consume stderr so ssh cannot block if it logs
|
||||
pipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty,
|
||||
let line = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!line.isEmpty else { return }
|
||||
webChatLogger.error("webchat tunnel stderr: \(line, privacy: .public)")
|
||||
}
|
||||
try process.run()
|
||||
// Track tunnel so we can clean up stale listeners on restart.
|
||||
Task {
|
||||
await PortGuardian.shared.record(
|
||||
port: Int(localPort),
|
||||
pid: process.processIdentifier,
|
||||
command: process.executableURL?.path ?? "ssh",
|
||||
mode: CommandResolver.connectionSettings().mode)
|
||||
}
|
||||
|
||||
return WebChatTunnel(process: process, localPort: localPort)
|
||||
}
|
||||
|
||||
private static func findPort(preferred: UInt16?) async throws -> UInt16 {
|
||||
if let preferred, portIsFree(preferred) { return preferred }
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
let queue = DispatchQueue(label: "com.steipete.clawdis.webchat.port", qos: .utility)
|
||||
do {
|
||||
let listener = try NWListener(using: .tcp, on: .any)
|
||||
listener.newConnectionHandler = { connection in connection.cancel() }
|
||||
listener.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = listener.port?.rawValue {
|
||||
listener.stateUpdateHandler = nil
|
||||
listener.cancel()
|
||||
cont.resume(returning: port)
|
||||
}
|
||||
case let .failed(error):
|
||||
listener.stateUpdateHandler = nil
|
||||
listener.cancel()
|
||||
cont.resume(throwing: error)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
} catch {
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func portIsFree(_ port: UInt16) -> Bool {
|
||||
do {
|
||||
let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!)
|
||||
listener.cancel()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
func appending(queryItems: [URLQueryItem]) -> URL {
|
||||
guard var comps = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self }
|
||||
comps.queryItems = (comps.queryItems ?? []) + queryItems
|
||||
return comps.url ?? self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway
|
||||
4) Health checks and Web Chat will now run through this SSH tunnel automatically.
|
||||
|
||||
## Web Chat over SSH
|
||||
- The gateway hosts a loopback-only HTTP server (default 18788, see `webchat.port`).
|
||||
- The mac app forwards `127.0.0.1:<port>` over SSH (`ssh -L <ephemeral>:127.0.0.1:<port>`), then loads `/webchat/?session=<key>` in-app. Sends go in-process on the gateway (no CLI spawn/PATH issues).
|
||||
- The mac app serves the WebChat assets locally (from the app bundle) and connects to the gateway over the forwarded WebSocket control port (default 18789).
|
||||
- The gateway’s own loopback WebChat HTTP server (default 18788, see `webchat.port`) is not required in remote mode.
|
||||
- Keep the feature enabled in *Settings → Config → Web chat*. Disable it to hide the menu entry entirely.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -5,7 +5,10 @@ read_when:
|
||||
---
|
||||
# Web Chat (macOS app)
|
||||
|
||||
The macOS menu bar app opens the gateway’s loopback web chat server in a WKWebView. It reuses the **primary Clawd session** (`main` by default, configurable via `inbound.reply.session.mainKey`). The server is started by the Node gateway (default port 18788, see `webchat.port`).
|
||||
The macOS menu bar app embeds the WebChat UI in a WKWebView and reuses the **primary Clawd session** (`main` by default, configurable via `inbound.reply.session.mainKey`).
|
||||
|
||||
- **Local mode**: loads the gateway’s loopback WebChat HTTP server (default port 18788, see `webchat.port`).
|
||||
- **Remote mode**: serves the WebChat assets locally from the mac app bundle (via `WebChatServer`) and only forwards the gateway WebSocket control port over SSH.
|
||||
|
||||
## Launch & debugging
|
||||
- Manual: Lobster menu → “Open Chat”.
|
||||
@@ -20,7 +23,7 @@ The macOS menu bar app opens the gateway’s loopback web chat server in a WKWeb
|
||||
- Debug-only: a native SwiftUI “glass” chat UI (same WS transport, attachments + thinking selector) can replace the WKWebView. Enable it via Debug → “Use SwiftUI web chat (glass, gateway WS)” (default off).
|
||||
|
||||
## Security / surface area
|
||||
- Loopback server only; remote mode uses SSH port-forwarding from the gateway host to the Mac. CSP is set to `default-src 'self' 'unsafe-inline' data: blob:`.
|
||||
- Loopback server only; remote mode forwards only the gateway WebSocket control port over SSH. CSP is set to `default-src 'self' 'unsafe-inline' data: blob:`.
|
||||
- Web Inspector is opt-in via right-click; otherwise WKWebView stays in the app sandbox.
|
||||
|
||||
## Known limitations
|
||||
|
||||
Reference in New Issue
Block a user