mac: harden webchat panel

This commit is contained in:
Peter Steinberger
2025-12-09 21:43:54 +01:00
parent 6675c273fd
commit 510552c5e6
3 changed files with 64 additions and 12 deletions

View File

@@ -264,6 +264,7 @@ const startChat = async () => {
startChat().catch((err) => {
const msg = err?.stack || err?.message || String(err);
logStatus(`boot failed: ${msg}`);
document.body.dataset.webchatError = "1";
document.body.style.color = "#e06666";
document.body.style.fontFamily = "monospace";
document.body.style.padding = "16px";

View File

@@ -131248,11 +131248,14 @@ var init_katex = __esmMin((() => {
case "\\htmlData": {
var data = value.split(",");
for (var i$7 = 0; i$7 < data.length; i$7++) {
var keyVal = data[i$7].split("=");
if (keyVal.length !== 2) {
throw new ParseError("Error parsing key-value for \\htmlData");
var item = data[i$7];
var firstEquals = item.indexOf("=");
if (firstEquals < 0) {
throw new ParseError("\\htmlData key/value '" + item + "'" + " missing equals sign");
}
attributes["data-" + keyVal[0].trim()] = keyVal[1].trim();
var key = item.slice(0, firstEquals);
var _value = item.slice(firstEquals + 1);
attributes["data-" + key.trim()] = _value;
}
trustContext = {
command: "\\htmlData",
@@ -135726,7 +135729,7 @@ var init_katex = __esmMin((() => {
return renderError(error$2, expression, settings);
}
};
version$2 = "0.16.26";
version$2 = "0.16.27";
__domTree = {
Span,
Anchor,
@@ -196565,10 +196568,11 @@ const startChat = async () => {
startChat().catch((err) => {
const msg = err?.stack || err?.message || String(err);
logStatus(`boot failed: ${msg}`);
document.body.dataset.webchatError = "1";
document.body.style.color = "#e06666";
document.body.style.fontFamily = "monospace";
document.body.style.padding = "16px";
document.body.innerText = "Web chat failed to load:\\n" + msg;
});
//#endregion
//#endregion

View File

@@ -25,6 +25,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private let remotePort: Int
private var reachabilityTask: Task<Void, Never>?
private var tunnelRestartEnabled = false
private var bootWatchTask: Task<Void, Never>?
let presentation: WebChatPresentation
var onPanelClosed: (() -> Void)?
private var panelCloseNotified = false
@@ -56,10 +57,12 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
@MainActor deinit {
self.reachabilityTask?.cancel()
self.bootWatchTask?.cancel()
self.stopTunnel(allowRestart: false)
}
private static func makeWindow(for presentation: WebChatPresentation, contentView: NSView) -> NSWindow {
let wrappedContent = Self.makeRoundedContainer(containing: contentView)
switch presentation {
case .window:
let window = NSWindow(
@@ -68,7 +71,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
backing: .buffered,
defer: false)
window.title = "Clawd Web Chat"
window.contentView = contentView
window.contentView = wrappedContent
return window
case .panel:
let panel = NSPanel(
@@ -83,14 +86,32 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.backgroundColor = .windowBackgroundColor
panel.backgroundColor = .clear
panel.isOpaque = false
panel.contentView = contentView
panel.contentView = wrappedContent
panel.becomesKeyOnlyIfNeeded = true
return panel
}
}
private static func makeRoundedContainer(containing contentView: NSView) -> NSView {
let container = NSView(frame: .zero)
container.wantsLayer = true
container.layer?.cornerRadius = 12
container.layer?.masksToBounds = true
container.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(contentView)
NSLayoutConstraint.activate([
contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
contentView.topAnchor.constraint(equalTo: container.topAnchor),
contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
return container
}
private func loadPlaceholder() {
let html = """
<html><body style='font-family:-apple-system;padding:24px;color:#888'>Connecting to web chat…</body></html>
@@ -100,6 +121,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func loadPage(baseURL: URL) {
self.webView.load(URLRequest(url: baseURL))
self.startBootWatch()
webChatLogger.debug("loadPage url=\(baseURL.absoluteString, privacy: .public)")
}
@@ -134,9 +156,9 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
private func prepareEndpoint(remotePort: Int) async throws -> URL {
if CommandResolver.connectionModeIsRemote() {
try await self.startOrRestartTunnel()
return try await self.startOrRestartTunnel()
} else {
URL(string: "http://127.0.0.1:\(remotePort)/")!
return URL(string: "http://127.0.0.1:\(remotePort)/")!
}
}
@@ -157,6 +179,29 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
self.loadPage(baseURL: url)
}
private func startBootWatch() {
self.bootWatchTask?.cancel()
self.bootWatchTask = Task { [weak self] in
guard let self else { return }
for _ in 0..<12 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
if Task.isCancelled { return }
if await self.isWebChatBooted() { return }
}
await MainActor.run {
self.showError("web chat did not finish booting. Check that the gateway is running and try reopening.")
}
}
}
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
cont.resume(returning: result as? Bool ?? false)
}
}
}
private func verifyReachable(endpoint: URL) async throws {
var request = URLRequest(url: endpoint, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 3)
request.httpMethod = "HEAD"
@@ -238,11 +283,12 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
var frame = panel.frame
frame.origin.x = round(anchor.midX - frame.width / 2)
frame.origin.y = anchor.minY - frame.height - 6
frame.origin.y = anchor.minY - frame.height
panel.setFrame(frame, display: false)
}
private func showError(_ text: String) {
self.bootWatchTask?.cancel()
let html = """
<html><body style='font-family:-apple-system;padding:24px;color:#c00'>Web chat failed to connect.<br><br>\(
text)</body></html>
@@ -252,6 +298,7 @@ final class WebChatWindowController: NSWindowController, WKNavigationDelegate, N
func shutdown() {
self.reachabilityTask?.cancel()
self.bootWatchTask?.cancel()
self.stopTunnel(allowRestart: false)
}