fix(mac): harden remote webchat tunnel and keep it alive
This commit is contained in:
@@ -11,10 +11,12 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
private let sessionKey: String
|
||||
private var tunnel: WebChatTunnel?
|
||||
private var baseEndpoint: URL?
|
||||
private let remotePort: Int
|
||||
|
||||
init(sessionKey: String) {
|
||||
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
||||
self.sessionKey = sessionKey
|
||||
self.remotePort = AppStateStore.webChatPort
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
let contentController = WKUserContentController()
|
||||
@@ -59,7 +61,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
guard AppStateStore.webChatEnabled else {
|
||||
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
||||
}
|
||||
let endpoint = try await self.prepareEndpoint(remotePort: AppStateStore.webChatPort)
|
||||
let endpoint = try await self.prepareEndpoint(remotePort: self.remotePort)
|
||||
self.baseEndpoint = endpoint
|
||||
await MainActor.run {
|
||||
var comps = URLComponents(url: endpoint.appendingPathComponent("webchat/"), resolvingAgainstBaseURL: false)
|
||||
@@ -79,17 +81,42 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
||||
|
||||
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
let tunnel = try await WebChatTunnel.create(remotePort: remotePort)
|
||||
self.tunnel = tunnel
|
||||
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)/")!
|
||||
return try await self.startOrRestartTunnel()
|
||||
} else {
|
||||
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
||||
}
|
||||
}
|
||||
|
||||
private func startOrRestartTunnel() async throws -> URL {
|
||||
// Kill existing tunnel if any
|
||||
self.tunnel?.terminate()
|
||||
|
||||
let tunnel = try await WebChatTunnel.create(remotePort: self.remotePort, preferredLocalPort: 18_788)
|
||||
self.tunnel = tunnel
|
||||
|
||||
// Auto-restart on unexpected termination while window lives
|
||||
tunnel.process.terminationHandler = { [weak self] _ in
|
||||
guard let self else { return }
|
||||
webChatLogger.error("webchat tunnel terminated; restarting")
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
_ = try await self.startOrRestartTunnel()
|
||||
if let base = self.baseEndpoint {
|
||||
await MainActor.run { self.loadPage(baseURL: base) }
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { 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 showError(_ text: String) {
|
||||
let html = """
|
||||
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(text)</body></html>
|
||||
@@ -134,14 +161,29 @@ final class WebChatTunnel {
|
||||
self.process.terminate()
|
||||
}
|
||||
|
||||
static func create(remotePort: Int) async throws -> WebChatTunnel {
|
||||
func terminate() {
|
||||
if self.process.isRunning {
|
||||
self.process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
static func create(remotePort: Int, preferredLocalPort: UInt16? = nil) async throws -> WebChatTunnel {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard settings.mode == .remote, let parsed = VoiceWakeForwarder.parse(target: settings.target) else {
|
||||
throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"])
|
||||
}
|
||||
|
||||
let localPort = try Self.findFreePort()
|
||||
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-N", "-L", "\(localPort):127.0.0.1:\(remotePort)"]
|
||||
let localPort = try 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]) }
|
||||
@@ -153,12 +195,21 @@ final class WebChatTunnel {
|
||||
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()
|
||||
|
||||
return WebChatTunnel(process: process, localPort: localPort)
|
||||
}
|
||||
|
||||
private static func findFreePort() throws -> UInt16 {
|
||||
private static func findPort(preferred: UInt16?) throws -> UInt16 {
|
||||
if let preferred {
|
||||
if Self.portIsFree(preferred) { return preferred }
|
||||
}
|
||||
let listener = try NWListener(using: .tcp, on: .any)
|
||||
listener.start(queue: .main)
|
||||
while listener.port == nil {
|
||||
@@ -169,6 +220,16 @@ final class WebChatTunnel {
|
||||
guard let port else { throw NSError(domain: "WebChat", code: 4, userInfo: [NSLocalizedDescriptionKey: "no port"])}
|
||||
return port
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user