mac: tidy menu and gateway support

This commit is contained in:
Peter Steinberger
2025-12-10 01:00:53 +00:00
parent 5ed1d4e178
commit 70fb4d452e
18 changed files with 198 additions and 98 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -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() {

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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),
])
}

View File

@@ -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> {

View File

@@ -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 well check Node for you.")
"""
Clawdis now runs the WebSocket gateway from the global "clawdis" package.
Install/update it here and well check Node for you.
""")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)

View File

@@ -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) }
}
}

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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 {