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 let sessionKey: String
|
||||||
private var tunnel: WebChatTunnel?
|
private var tunnel: WebChatTunnel?
|
||||||
private var baseEndpoint: URL?
|
private var baseEndpoint: URL?
|
||||||
|
private let remotePort: Int
|
||||||
|
|
||||||
init(sessionKey: String) {
|
init(sessionKey: String) {
|
||||||
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
||||||
self.sessionKey = sessionKey
|
self.sessionKey = sessionKey
|
||||||
|
self.remotePort = AppStateStore.webChatPort
|
||||||
|
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
let contentController = WKUserContentController()
|
let contentController = WKUserContentController()
|
||||||
@@ -59,7 +61,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate {
|
|||||||
guard AppStateStore.webChatEnabled else {
|
guard AppStateStore.webChatEnabled else {
|
||||||
throw NSError(domain: "WebChat", code: 5, userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
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
|
self.baseEndpoint = endpoint
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
var comps = URLComponents(url: endpoint.appendingPathComponent("webchat/"), resolvingAgainstBaseURL: false)
|
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 {
|
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
||||||
if CommandResolver.connectionModeIsRemote() {
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
let tunnel = try await WebChatTunnel.create(remotePort: remotePort)
|
return try await self.startOrRestartTunnel()
|
||||||
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)/")!
|
|
||||||
} else {
|
} else {
|
||||||
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
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) {
|
private func showError(_ text: String) {
|
||||||
let html = """
|
let html = """
|
||||||
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(text)</body></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()
|
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()
|
let settings = CommandResolver.connectionSettings()
|
||||||
guard settings.mode == .remote, let parsed = VoiceWakeForwarder.parse(target: settings.target) else {
|
guard settings.mode == .remote, let parsed = VoiceWakeForwarder.parse(target: settings.target) else {
|
||||||
throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"])
|
throw NSError(domain: "WebChat", code: 3, userInfo: [NSLocalizedDescriptionKey: "remote not configured"])
|
||||||
}
|
}
|
||||||
|
|
||||||
let localPort = try Self.findFreePort()
|
let localPort = try Self.findPort(preferred: preferredLocalPort)
|
||||||
var args: [String] = ["-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-N", "-L", "\(localPort):127.0.0.1:\(remotePort)"]
|
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)]) }
|
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
||||||
@@ -153,12 +195,21 @@ final class WebChatTunnel {
|
|||||||
process.arguments = args
|
process.arguments = args
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
process.standardError = 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()
|
try process.run()
|
||||||
|
|
||||||
return WebChatTunnel(process: process, localPort: localPort)
|
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)
|
let listener = try NWListener(using: .tcp, on: .any)
|
||||||
listener.start(queue: .main)
|
listener.start(queue: .main)
|
||||||
while listener.port == nil {
|
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"])}
|
guard let port else { throw NSError(domain: "WebChat", code: 4, userInfo: [NSLocalizedDescriptionKey: "no port"])}
|
||||||
return 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 {
|
extension URL {
|
||||||
|
|||||||
Reference in New Issue
Block a user