fix(mac): serve webchat locally in remote mode
parent
241cf10bdb
commit
7d37195c1a
|
|
@ -15,7 +15,7 @@ final class WebChatServer: @unchecked Sendable {
|
||||||
private var port: NWEndpoint.Port?
|
private var port: NWEndpoint.Port?
|
||||||
|
|
||||||
/// Start the local HTTP server if it isn't already running. Safe to call multiple times.
|
/// 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 {
|
self.queue.async {
|
||||||
webChatServerLogger.debug("WebChatServer start requested root=\(root.path, privacy: .public)")
|
webChatServerLogger.debug("WebChatServer start requested root=\(root.path, privacy: .public)")
|
||||||
if self.listener != nil { return }
|
if self.listener != nil { return }
|
||||||
|
|
@ -23,8 +23,9 @@ final class WebChatServer: @unchecked Sendable {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.allowLocalEndpointReuse = true
|
params.allowLocalEndpointReuse = true
|
||||||
params.requiredInterfaceType = .loopback
|
params.requiredInterfaceType = .loopback
|
||||||
|
let prefer = preferredPort.flatMap { NWEndpoint.Port(rawValue: $0) }
|
||||||
do {
|
do {
|
||||||
let listener = try NWListener(using: params, on: .any)
|
let listener = try NWListener(using: params, on: prefer ?? .any)
|
||||||
listener.stateUpdateHandler = { [weak self] state in
|
listener.stateUpdateHandler = { [weak self] state in
|
||||||
switch state {
|
switch state {
|
||||||
case .ready:
|
case .ready:
|
||||||
|
|
@ -44,8 +45,38 @@ final class WebChatServer: @unchecked Sendable {
|
||||||
listener.start(queue: self.queue)
|
listener.start(queue: self.queue)
|
||||||
self.listener = listener
|
self.listener = listener
|
||||||
} catch {
|
} catch {
|
||||||
webChatServerLogger
|
if let prefer {
|
||||||
.error("WebChatServer could not start: \(error.localizedDescription, privacy: .public)")
|
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)")
|
webChatServerLogger.debug("WebChatServer request line=\(requestLine, privacy: .public)")
|
||||||
let parts = requestLine.split(separator: " ")
|
let parts = requestLine.split(separator: " ")
|
||||||
guard parts.count >= 2, parts[0] == "GET" else {
|
guard parts.count >= 2 else {
|
||||||
webChatServerLogger.error("WebChatServer non-GET request: \(requestLine, privacy: .public)")
|
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()
|
connection.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -137,25 +176,53 @@ final class WebChatServer: @unchecked Sendable {
|
||||||
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
|
webChatServerLogger.debug("WebChatServer resolved file=\(fileURL.path, privacy: .public)")
|
||||||
// Simple directory traversal guard: served files must live under the bundled web root.
|
// Simple directory traversal guard: served files must live under the bundled web root.
|
||||||
guard fileURL.path.hasPrefix(root.path) else {
|
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
|
return
|
||||||
}
|
}
|
||||||
guard let data = try? Data(contentsOf: fileURL) else {
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
webChatServerLogger.error("WebChatServer 404 missing \(fileURL.lastPathComponent, privacy: .public)")
|
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
|
return
|
||||||
}
|
}
|
||||||
let mime = self.mimeType(forExtension: fileURL.pathExtension)
|
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" +
|
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" +
|
"Content-Type: \(mime)\r\n" +
|
||||||
"Connection: close\r\n\r\n"
|
"Connection: close\r\n\r\n"
|
||||||
var response = Data(headers.utf8)
|
var response = Data(headers.utf8)
|
||||||
response.append(body)
|
if includeBody {
|
||||||
|
response.append(body)
|
||||||
|
}
|
||||||
connection.send(content: response, completion: .contentProcessed { _ in
|
connection.send(content: response, completion: .contentProcessed { _ in
|
||||||
connection.cancel()
|
connection.cancel()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
|
||||||
import OSLog
|
import OSLog
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
|
|
@ -32,12 +31,10 @@ enum WebChatPresentation {
|
||||||
final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
||||||
private let webView: WKWebView
|
private let webView: WKWebView
|
||||||
private let sessionKey: String
|
private let sessionKey: String
|
||||||
private var tunnel: WebChatTunnel?
|
|
||||||
private var baseEndpoint: URL?
|
private var baseEndpoint: URL?
|
||||||
private let remotePort: Int
|
private let remotePort: Int
|
||||||
private var resolvedGatewayPort: Int?
|
private var resolvedGatewayPort: Int?
|
||||||
private var reachabilityTask: Task<Void, Never>?
|
private var reachabilityTask: Task<Void, Never>?
|
||||||
private var tunnelRestartEnabled = false
|
|
||||||
private var bootWatchTask: Task<Void, Never>?
|
private var bootWatchTask: Task<Void, Never>?
|
||||||
let presentation: WebChatPresentation
|
let presentation: WebChatPresentation
|
||||||
var onPanelClosed: (() -> Void)?
|
var onPanelClosed: (() -> Void)?
|
||||||
|
|
@ -78,7 +75,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||||
@MainActor deinit {
|
@MainActor deinit {
|
||||||
self.reachabilityTask?.cancel()
|
self.reachabilityTask?.cancel()
|
||||||
self.bootWatchTask?.cancel()
|
self.bootWatchTask?.cancel()
|
||||||
self.stopTunnel(allowRestart: false)
|
|
||||||
self.removeDismissMonitor()
|
self.removeDismissMonitor()
|
||||||
self.removePanelObservers()
|
self.removePanelObservers()
|
||||||
}
|
}
|
||||||
|
|
@ -232,10 +228,21 @@ 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() {
|
||||||
try await self.startOrRestartTunnel()
|
let root = try Self.webChatAssetsRootURL()
|
||||||
} else {
|
WebChatServer.shared.start(root: root, preferredPort: nil)
|
||||||
URL(string: "http://127.0.0.1:\(remotePort)/")!
|
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) {
|
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?) {
|
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
||||||
guard case .panel = self.presentation, let window else { return }
|
guard case .panel = self.presentation, let window else { return }
|
||||||
self.panelCloseNotified = false
|
self.panelCloseNotified = false
|
||||||
|
|
@ -410,7 +381,6 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||||
func shutdown() {
|
func shutdown() {
|
||||||
self.reachabilityTask?.cancel()
|
self.reachabilityTask?.cancel()
|
||||||
self.bootWatchTask?.cancel()
|
self.bootWatchTask?.cancel()
|
||||||
self.stopTunnel(allowRestart: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
|
@ -502,6 +472,17 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
|
||||||
}
|
}
|
||||||
self.observers.removeAll()
|
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 {
|
extension WebChatWindowController {
|
||||||
|
|
@ -548,7 +529,6 @@ final class WebChatManager {
|
||||||
private var swiftWindowController: WebChatSwiftUIWindowController?
|
private var swiftWindowController: WebChatSwiftUIWindowController?
|
||||||
private var swiftPanelController: WebChatSwiftUIWindowController?
|
private var swiftPanelController: WebChatSwiftUIWindowController?
|
||||||
private var swiftPanelSessionKey: String?
|
private var swiftPanelSessionKey: String?
|
||||||
private var browserTunnel: WebChatTunnel?
|
|
||||||
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
func show(sessionKey: String) {
|
func show(sessionKey: String) {
|
||||||
|
|
@ -671,8 +651,6 @@ final class WebChatManager {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func resetTunnels() {
|
func resetTunnels() {
|
||||||
self.browserTunnel?.terminate()
|
|
||||||
self.browserTunnel = nil
|
|
||||||
self.windowController?.shutdown()
|
self.windowController?.shutdown()
|
||||||
self.windowController?.close()
|
self.windowController?.close()
|
||||||
self.windowController = nil
|
self.windowController = nil
|
||||||
|
|
@ -689,31 +667,37 @@ final class WebChatManager {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func openInBrowser(sessionKey: String) async {
|
func openInBrowser(sessionKey: String) async {
|
||||||
let port = AppStateStore.webChatPort
|
|
||||||
let base: URL
|
let base: URL
|
||||||
let gatewayPort: Int
|
let gatewayPort: Int
|
||||||
if CommandResolver.connectionModeIsRemote() {
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
do {
|
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()
|
let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel()
|
||||||
gatewayPort = Int(forwarded)
|
gatewayPort = Int(forwarded)
|
||||||
self.browserTunnel?.terminate()
|
|
||||||
self.browserTunnel = tunnel
|
let root = try WebChatWindowController.webChatAssetsRootURL()
|
||||||
guard let local = tunnel.localPort else {
|
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(
|
throw NSError(
|
||||||
domain: "WebChat",
|
domain: "WebChat",
|
||||||
code: 7,
|
code: 11,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Tunnel missing local port"])
|
userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"])
|
||||||
}
|
}
|
||||||
base = URL(string: "http://127.0.0.1:\(local)/")!
|
base = resolved
|
||||||
} catch {
|
} catch {
|
||||||
NSAlert(error: error).runModal()
|
NSAlert(error: error).runModal()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let port = AppStateStore.webChatPort
|
||||||
gatewayPort = GatewayEnvironment.gatewayPort()
|
gatewayPort = GatewayEnvironment.gatewayPort()
|
||||||
base = URL(string: "http://127.0.0.1:\(port)/")!
|
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.
|
// 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.
|
4) Health checks and Web Chat will now run through this SSH tunnel automatically.
|
||||||
|
|
||||||
## Web Chat over SSH
|
## Web Chat over SSH
|
||||||
- The gateway hosts a loopback-only HTTP server (default 18788, see `webchat.port`).
|
- 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 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 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.
|
- Keep the feature enabled in *Settings → Config → Web chat*. Disable it to hide the menu entry entirely.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ read_when:
|
||||||
---
|
---
|
||||||
# Web Chat (macOS app)
|
# 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
|
## Launch & debugging
|
||||||
- Manual: Lobster menu → “Open Chat”.
|
- 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).
|
- 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
|
## 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.
|
- Web Inspector is opt-in via right-click; otherwise WKWebView stays in the app sandbox.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue