Files
clawdbot/apps/macos/Sources/Clawdis/WebChatWindow.swift
2025-12-08 12:17:39 +01:00

291 lines
11 KiB
Swift

import AppKit
import Foundation
import Network
import OSLog
import WebKit
import ClawdisIPC
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
private struct WebChatCliInfo: Decodable {
let port: Int
let token: String?
let host: String?
let basePath: String?
}
final class WebChatWindowController: NSWindowController, WKScriptMessageHandler, WKNavigationDelegate {
private let webView: WKWebView
private let sessionKey: String
private var initialMessagesJSON: String = "[]"
private var tunnel: WebChatTunnel?
init(sessionKey: String) {
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
self.sessionKey = sessionKey
let config = WKWebViewConfiguration()
let contentController = WKUserContentController()
config.userContentController = contentController
config.preferences.isElementFullscreenEnabled = true
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
let callbackScript = """
window.__clawdisCallbacks = new Map();
window.__clawdisReceive = function(resp) {
const entry = window.__clawdisCallbacks.get(resp.id);
if (!entry) return;
window.__clawdisCallbacks.delete(resp.id);
if (resp.ok) {
entry.resolve(resp.result);
} else {
entry.reject(resp.error || 'unknown error');
}
};
window.__clawdisSend = function(payload) {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
window.__clawdisCallbacks.set(id, { resolve, reject });
window.webkit?.messageHandlers?.clawdis?.postMessage({ id, ...payload });
});
};
window.__clawdisLog = function(msg) {
try {
window.webkit?.messageHandlers?.clawdis?.postMessage({ id: 'log', log: String(msg) });
} catch (_) {}
};
"""
let userScript = WKUserScript(source: callbackScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
contentController.addUserScript(userScript)
self.webView = WKWebView(frame: .zero, configuration: config)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 960, height: 720),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false)
window.title = "Clawd Web Chat"
window.contentView = self.webView
super.init(window: window)
self.webView.navigationDelegate = self
contentController.add(self, name: "clawdis")
self.loadPlaceholder()
Task { await self.bootstrap() }
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
private func loadPlaceholder() {
let html = """
<html><body style='font-family:-apple-system;padding:24px;color:#888'>Connecting to web chat…</body></html>
"""
self.webView.loadHTMLString(html, baseURL: nil)
}
private func loadPage(baseURL: URL) {
let bootstrapScript = """
window.__clawdisBootstrap = {
sessionKey: \(self.sessionKey),
initialMessages: \(self.initialMessagesJSON)
};
"""
let userScript = WKUserScript(
source: bootstrapScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: true)
self.webView.configuration.userContentController.addUserScript(userScript)
let url = baseURL.appendingPathComponent("index.html")
self.webView.load(URLRequest(url: url))
webChatLogger.debug("loadPage url=\(url.absoluteString, privacy: .public)")
}
// MARK: - Bootstrap
private func bootstrap() async {
do {
let cliInfo = try await self.fetchWebChatCliInfo()
let endpoint = try await self.prepareEndpoint(remotePort: cliInfo.port)
let infoURL = endpoint.appendingPathComponent("webchat/info")
.appending(queryItems: [URLQueryItem(name: "session", value: self.sessionKey)])
let (data, _) = try await URLSession.shared.data(from: infoURL)
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let msgs = obj["initialMessages"]
{
if let json = try? JSONSerialization.data(withJSONObject: msgs, options: []) {
self.initialMessagesJSON = String(data: json, encoding: .utf8) ?? "[]"
}
}
await MainActor.run {
self.loadPage(baseURL: endpoint.appendingPathComponent("webchat/"))
}
} catch {
let message = error.localizedDescription
webChatLogger.error("webchat bootstrap failed: \(message, privacy: .public)")
await MainActor.run { self.showError(message) }
}
}
private func fetchWebChatCliInfo() async throws -> WebChatCliInfo {
let response = await ShellRunner.run(
command: CommandResolver.clawdisCommand(subcommand: "webchat", extraArgs: ["--json"]),
cwd: CommandResolver.projectRootPath(),
env: nil,
timeout: 10)
guard response.ok, let data = response.payload else {
throw NSError(domain: "WebChat", code: 1, userInfo: [NSLocalizedDescriptionKey: response.message ?? "webchat cli failed"])
}
return try JSONDecoder().decode(WebChatCliInfo.self, from: data)
}
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)/")!
} else {
return URL(string: "http://127.0.0.1:\(remotePort)/")!
}
}
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>
"""
self.webView.loadHTMLString(html, baseURL: nil)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webChatLogger.debug("didFinish navigation url=\(webView.url?.absoluteString ?? "nil", privacy: .public)")
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == "clawdis" else { return }
if let body = message.body as? [String: Any], body["id"] as? String == "log" {
if let log = body["log"] as? String { webChatLogger.debug("JS: \(log, privacy: .public)") }
return
}
guard let body = message.body as? [String: Any],
let id = body["id"] as? String
else { return }
guard let type = body["type"] as? String,
type == "chat",
let payload = body["payload"] as? [String: Any],
let text = payload["text"] as? String
else { return }
Task { @MainActor in
let reply = await runAgent(text: text, sessionKey: sessionKey)
let json: [String: Any] = [
"id": id,
"ok": reply.error == nil,
"result": ["text": reply.text ?? ""],
"error": reply.error ?? NSNull(),
]
if let data = try? JSONSerialization.data(withJSONObject: json),
let js = String(data: data, encoding: .utf8)
{
_ = try? await self.webView.evaluateJavaScript("window.__clawdisReceive(" + js + ")")
}
}
}
private func runAgent(text: String, sessionKey: String) async -> (text: String?, error: String?) {
await MainActor.run { AppStateStore.shared.setWorking(true) }
defer { Task { await MainActor.run { AppStateStore.shared.setWorking(false) } } }
let result = await AgentRPC.shared.send(
text: text,
thinking: "default",
session: sessionKey,
deliver: false,
to: sessionKey)
return (result.text, result.error)
}
}
// MARK: - Manager
@MainActor
final class WebChatManager {
static let shared = WebChatManager()
private var controller: WebChatWindowController?
func show(sessionKey: String) {
if self.controller == nil {
self.controller = WebChatWindowController(sessionKey: sessionKey)
}
self.controller?.showWindow(nil)
self.controller?.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}
// 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 {
self.process.terminate()
}
static func create(remotePort: Int) 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)"]
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
try process.run()
return WebChatTunnel(process: process, localPort: localPort)
}
private static func findFreePort() throws -> UInt16 {
let listener = try NWListener(using: .tcp, on: .any)
listener.start(queue: .main)
while listener.port == nil {
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
}
let port = listener.port?.rawValue
listener.cancel()
guard let port else { throw NSError(domain: "WebChat", code: 4, userInfo: [NSLocalizedDescriptionKey: "no port"])}
return port
}
}
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
}
}