757 lines
29 KiB
Swift
757 lines
29 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import OSLog
|
|
import WebKit
|
|
|
|
private let webChatLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat")
|
|
|
|
private enum WebChatLayout {
|
|
static let windowSize = NSSize(width: 1120, height: 840)
|
|
static let panelSize = NSSize(width: 480, height: 640)
|
|
static let anchorPadding: CGFloat = 8
|
|
}
|
|
|
|
/// A borderless panel that can still accept key focus (needed for typing into the embedded WebChat).
|
|
final class WebChatPanel: NSPanel {
|
|
override var canBecomeKey: Bool { true }
|
|
override var canBecomeMain: Bool { true }
|
|
}
|
|
|
|
enum WebChatPresentation {
|
|
case window
|
|
case panel(anchorProvider: () -> NSRect?)
|
|
|
|
var isPanel: Bool {
|
|
if case .panel = self { return true }
|
|
return false
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class WebChatWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
|
|
private let webView: WKWebView
|
|
private let sessionKey: String
|
|
private var baseEndpoint: URL?
|
|
private let remotePort: Int
|
|
private var resolvedGatewayPort: Int?
|
|
private var reachabilityTask: Task<Void, Never>?
|
|
private var bootWatchTask: Task<Void, Never>?
|
|
let presentation: WebChatPresentation
|
|
var onPanelClosed: (() -> Void)?
|
|
var onVisibilityChanged: ((Bool) -> Void)?
|
|
private var panelCloseNotified = false
|
|
private var localDismissMonitor: Any?
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
init(sessionKey: String, presentation: WebChatPresentation = .window) {
|
|
webChatLogger.debug("init WebChatWindowController sessionKey=\(sessionKey, privacy: .public)")
|
|
self.sessionKey = sessionKey
|
|
self.remotePort = AppStateStore.webChatPort
|
|
self.presentation = presentation
|
|
|
|
let config = WKWebViewConfiguration()
|
|
let contentController = WKUserContentController()
|
|
config.userContentController = contentController
|
|
config.preferences.isElementFullscreenEnabled = true
|
|
config.preferences.setValue(true, forKey: "developerExtrasEnabled")
|
|
|
|
self.webView = WKWebView(frame: .zero, configuration: config)
|
|
let window = Self.makeWindow(for: presentation, contentView: self.webView)
|
|
super.init(window: window)
|
|
self.webView.navigationDelegate = self
|
|
self.window?.delegate = self
|
|
|
|
self.loadPlaceholder()
|
|
Task { await self.bootstrap() }
|
|
|
|
if case .panel = presentation {
|
|
self.installPanelObservers()
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
@MainActor deinit {
|
|
self.reachabilityTask?.cancel()
|
|
self.bootWatchTask?.cancel()
|
|
self.removeDismissMonitor()
|
|
self.removePanelObservers()
|
|
}
|
|
|
|
private static func makeWindow(for presentation: WebChatPresentation, contentView: NSView) -> NSWindow {
|
|
let wrappedContent = Self.makeRoundedContainer(containing: contentView)
|
|
switch presentation {
|
|
case .window:
|
|
let window = NSWindow(
|
|
contentRect: NSRect(origin: .zero, size: WebChatLayout.windowSize),
|
|
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
|
backing: .buffered,
|
|
defer: false)
|
|
window.title = "Clawd Web Chat"
|
|
window.contentView = wrappedContent
|
|
window.center()
|
|
WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatLayout.windowSize)
|
|
window.minSize = NSSize(width: 880, height: 680)
|
|
return window
|
|
case .panel:
|
|
let panel = WebChatPanel(
|
|
contentRect: NSRect(origin: .zero, size: WebChatLayout.panelSize),
|
|
styleMask: [.borderless],
|
|
backing: .buffered,
|
|
defer: false)
|
|
panel.level = .statusBar
|
|
panel.hidesOnDeactivate = true
|
|
panel.hasShadow = true
|
|
panel.isMovable = false
|
|
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
panel.titleVisibility = .hidden
|
|
panel.titlebarAppearsTransparent = true
|
|
panel.backgroundColor = .clear
|
|
panel.isOpaque = false
|
|
panel.contentView = wrappedContent
|
|
panel.becomesKeyOnlyIfNeeded = true
|
|
panel.setFrame(
|
|
WindowPlacement.topRightFrame(
|
|
size: WebChatLayout.panelSize,
|
|
padding: WebChatLayout.anchorPadding),
|
|
display: false)
|
|
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>
|
|
<head>
|
|
<style>
|
|
html, body { height: 100%; margin: 0; padding: 0; }
|
|
body {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #fff;
|
|
}
|
|
.boot {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
.boot span {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background: #0f172a;
|
|
animation: boot-pulse 1s ease-in-out infinite;
|
|
}
|
|
.boot span:nth-child(2) { animation-delay: 0.15s; }
|
|
.boot span:nth-child(3) { animation-delay: 0.3s; }
|
|
@keyframes boot-pulse {
|
|
0%, 80%, 100% { opacity: 0.25; transform: scale(0.8); }
|
|
40% { opacity: 1; transform: scale(1.1); }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body style='font-family:-apple-system;
|
|
margin:0;
|
|
padding:0;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
height:100vh;
|
|
color:#888'>
|
|
<div class="boot" aria-label="Booting web chat">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
self.webView.loadHTMLString(html, baseURL: nil)
|
|
}
|
|
|
|
private func loadPage(baseURL: URL) {
|
|
self.webView.load(URLRequest(url: baseURL))
|
|
self.startBootWatch()
|
|
webChatLogger.debug("loadPage url=\(baseURL.absoluteString, privacy: .public)")
|
|
}
|
|
|
|
// MARK: - Bootstrap
|
|
|
|
private func bootstrap() async {
|
|
do {
|
|
guard AppStateStore.webChatEnabled else {
|
|
throw NSError(
|
|
domain: "WebChat",
|
|
code: 5,
|
|
userInfo: [NSLocalizedDescriptionKey: "Web chat disabled in settings"])
|
|
}
|
|
self.resolvedGatewayPort = try await self.prepareGatewayPort()
|
|
let endpoint = try await self.prepareEndpoint(remotePort: self.remotePort)
|
|
self.baseEndpoint = endpoint
|
|
self.reachabilityTask?.cancel()
|
|
self.reachabilityTask = Task { [endpoint, weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
try await self.verifyReachable(endpoint: endpoint)
|
|
await MainActor.run { self.loadWebChat(baseEndpoint: endpoint) }
|
|
} catch {
|
|
await MainActor.run { self.showError(error.localizedDescription) }
|
|
}
|
|
}
|
|
} catch {
|
|
let message = error.localizedDescription
|
|
webChatLogger.error("webchat bootstrap failed: \(message, privacy: .public)")
|
|
await MainActor.run { self.showError(message) }
|
|
}
|
|
}
|
|
|
|
private func prepareGatewayPort() async throws -> Int {
|
|
if CommandResolver.connectionModeIsRemote() {
|
|
let forwarded = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
|
return Int(forwarded)
|
|
}
|
|
return GatewayEnvironment.gatewayPort()
|
|
}
|
|
|
|
private func prepareEndpoint(remotePort: Int) async throws -> URL {
|
|
if CommandResolver.connectionModeIsRemote() {
|
|
let root = try Self.webChatAssetsRootURL()
|
|
WebChatServer.shared.start(root: root, preferredPort: nil)
|
|
let deadline = Date().addingTimeInterval(2.0)
|
|
while Date() < deadline {
|
|
if let url = WebChatServer.shared.baseURL() {
|
|
return url
|
|
}
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
throw NSError(
|
|
domain: "WebChat",
|
|
code: 11,
|
|
userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"])
|
|
}
|
|
return URL(string: "http://127.0.0.1:\(remotePort)/")!
|
|
}
|
|
|
|
private func loadWebChat(baseEndpoint: URL) {
|
|
var comps = URLComponents(url: baseEndpoint, resolvingAgainstBaseURL: false)
|
|
if comps?.path.isEmpty ?? true {
|
|
comps?.path = "/"
|
|
}
|
|
var items = [URLQueryItem(name: "session", value: self.sessionKey)]
|
|
let gatewayPort = self.resolvedGatewayPort ?? GatewayEnvironment.gatewayPort()
|
|
items.append(URLQueryItem(name: "gatewayPort", value: String(gatewayPort)))
|
|
items.append(URLQueryItem(name: "gatewayHost", value: baseEndpoint.host ?? "127.0.0.1"))
|
|
if let hostName = Host.current().localizedName ?? Host.current().name {
|
|
items.append(URLQueryItem(name: "host", value: hostName))
|
|
}
|
|
if let ip = Self.primaryIPv4Address() {
|
|
items.append(URLQueryItem(name: "ip", value: ip))
|
|
}
|
|
comps?.queryItems = items
|
|
guard let url = comps?.url else {
|
|
self.showError("invalid webchat url")
|
|
return
|
|
}
|
|
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"
|
|
let sessionConfig = URLSessionConfiguration.ephemeral
|
|
sessionConfig.waitsForConnectivity = false
|
|
let session = URLSession(configuration: sessionConfig)
|
|
do {
|
|
let (_, response) = try await session.data(for: request)
|
|
if let http = response as? HTTPURLResponse {
|
|
guard (200..<500).contains(http.statusCode) else {
|
|
throw NSError(
|
|
domain: "WebChat",
|
|
code: http.statusCode,
|
|
userInfo: [NSLocalizedDescriptionKey: "webchat returned HTTP \(http.statusCode)"])
|
|
}
|
|
}
|
|
} catch {
|
|
throw NSError(
|
|
domain: "WebChat",
|
|
code: 7,
|
|
userInfo: [NSLocalizedDescriptionKey: "webchat unreachable: \(error.localizedDescription)"])
|
|
}
|
|
}
|
|
|
|
func presentAnchoredPanel(anchorProvider: @escaping () -> NSRect?) {
|
|
guard case .panel = self.presentation, let window else { return }
|
|
self.panelCloseNotified = false
|
|
self.repositionPanel(using: anchorProvider)
|
|
self.installDismissMonitor()
|
|
window.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeFirstResponder(self.webView)
|
|
self.onVisibilityChanged?(true)
|
|
}
|
|
|
|
func closePanel() {
|
|
guard case .panel = self.presentation else { return }
|
|
self.removeDismissMonitor()
|
|
self.window?.orderOut(nil)
|
|
self.onVisibilityChanged?(false)
|
|
self.notifyPanelClosedOnce()
|
|
}
|
|
|
|
private func repositionPanel(using anchorProvider: () -> NSRect?) {
|
|
guard let panel = self.window else { return }
|
|
guard let anchor = anchorProvider() else {
|
|
panel.setFrame(
|
|
WindowPlacement.topRightFrame(
|
|
size: WebChatLayout.panelSize,
|
|
padding: WebChatLayout.anchorPadding),
|
|
display: false)
|
|
return
|
|
}
|
|
|
|
var frame = panel.frame
|
|
let screen = NSScreen.screens.first { screen in
|
|
screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY))
|
|
} ?? NSScreen.main
|
|
|
|
if let screen {
|
|
let bounds = screen.visibleFrame.insetBy(dx: WebChatLayout.anchorPadding, dy: WebChatLayout.anchorPadding)
|
|
|
|
let desiredX = round(anchor.midX - frame.width / 2)
|
|
let desiredY = anchor.minY - frame.height - WebChatLayout.anchorPadding
|
|
|
|
let maxX = bounds.maxX - frame.width
|
|
let maxY = bounds.maxY - frame.height
|
|
|
|
frame.origin.x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX
|
|
frame.origin.y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY
|
|
} else {
|
|
frame.origin.x = round(anchor.midX - frame.width / 2)
|
|
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;
|
|
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)
|
|
}
|
|
|
|
func shutdown() {
|
|
self.reachabilityTask?.cancel()
|
|
self.bootWatchTask?.cancel()
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
webChatLogger.debug("didFinish navigation url=\(webView.url?.absoluteString ?? "nil", privacy: .public)")
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
if Self.shouldIgnoreNavigationError(error) {
|
|
webChatLogger.debug("webchat navigation cancelled (provisional)")
|
|
return
|
|
}
|
|
webChatLogger.error("webchat navigation failed (provisional): \(error.localizedDescription, privacy: .public)")
|
|
self.showError(error.localizedDescription)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
if Self.shouldIgnoreNavigationError(error) {
|
|
webChatLogger.debug("webchat navigation cancelled")
|
|
return
|
|
}
|
|
webChatLogger.error("webchat navigation failed: \(error.localizedDescription, privacy: .public)")
|
|
self.showError(error.localizedDescription)
|
|
}
|
|
|
|
func windowDidResignKey(_ notification: Notification) {
|
|
guard case .panel = self.presentation else { return }
|
|
self.closePanel()
|
|
self.notifyPanelClosedOnce()
|
|
}
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
guard case .panel = self.presentation else { return }
|
|
self.removeDismissMonitor()
|
|
self.onVisibilityChanged?(false)
|
|
self.notifyPanelClosedOnce()
|
|
}
|
|
|
|
private func notifyPanelClosedOnce() {
|
|
guard !self.panelCloseNotified else { return }
|
|
self.panelCloseNotified = true
|
|
self.onPanelClosed?()
|
|
}
|
|
|
|
private func installDismissMonitor() {
|
|
guard self.localDismissMonitor == nil, let panel = self.window else { return }
|
|
self.localDismissMonitor = NSEvent.addGlobalMonitorForEvents(
|
|
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown])
|
|
{ [weak self] _ in
|
|
guard let self else { return }
|
|
let pt = NSEvent.mouseLocation // screen coordinates
|
|
if !panel.frame.contains(pt) {
|
|
Task { @MainActor in
|
|
self.closePanel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeDismissMonitor() {
|
|
if let monitor = self.localDismissMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
self.localDismissMonitor = nil
|
|
}
|
|
}
|
|
|
|
private func installPanelObservers() {
|
|
guard let window = self.window else { return }
|
|
let nc = NotificationCenter.default
|
|
let o1 = nc.addObserver(
|
|
forName: NSApplication.didResignActiveNotification,
|
|
object: nil,
|
|
queue: .main)
|
|
{ [weak self] _ in
|
|
Task { @MainActor in
|
|
self?.closePanel()
|
|
}
|
|
}
|
|
let o2 = nc.addObserver(
|
|
forName: NSWindow.didChangeOcclusionStateNotification,
|
|
object: window,
|
|
queue: .main)
|
|
{ [weak self] _ in
|
|
Task { @MainActor in
|
|
guard let self, case .panel = self.presentation else { return }
|
|
if !(window.occlusionState.contains(.visible)) {
|
|
self.closePanel()
|
|
}
|
|
}
|
|
}
|
|
self.observers.append(contentsOf: [o1, o2])
|
|
}
|
|
|
|
private func removePanelObservers() {
|
|
let nc = NotificationCenter.default
|
|
for o in self.observers {
|
|
nc.removeObserver(o)
|
|
}
|
|
self.observers.removeAll()
|
|
}
|
|
|
|
fileprivate static func webChatAssetsRootURL() throws -> URL {
|
|
if let url = Bundle.main.url(forResource: "WebChat", withExtension: nil) { return url }
|
|
if let url = Bundle.main.resourceURL?.appendingPathComponent("WebChat"),
|
|
FileManager.default.fileExists(atPath: url.path)
|
|
{
|
|
return url
|
|
}
|
|
if let url = Bundle.module.url(forResource: "WebChat", withExtension: nil) { return url }
|
|
throw NSError(domain: "WebChat", code: 10, userInfo: [NSLocalizedDescriptionKey: "WebChat assets missing"])
|
|
}
|
|
|
|
private static func shouldIgnoreNavigationError(_ error: Error) -> Bool {
|
|
let ns = error as NSError
|
|
return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled
|
|
}
|
|
}
|
|
|
|
extension WebChatWindowController {
|
|
/// Returns the first non-loopback IPv4 address, skipping link-local (169.254.x.x).
|
|
fileprivate static func primaryIPv4Address() -> String? {
|
|
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
|
guard getifaddrs(&ifaddr) == 0, let first = ifaddr else { return nil }
|
|
defer { freeifaddrs(ifaddr) }
|
|
|
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
|
let flags = Int32(ptr.pointee.ifa_flags)
|
|
let addrFamily = ptr.pointee.ifa_addr.pointee.sa_family
|
|
if (flags & IFF_UP) == 0 || (flags & IFF_LOOPBACK) != 0 { continue }
|
|
if addrFamily == UInt8(AF_INET) {
|
|
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
if getnameinfo(
|
|
ptr.pointee.ifa_addr,
|
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
|
&hostname,
|
|
socklen_t(hostname.count),
|
|
nil,
|
|
0,
|
|
NI_NUMERICHOST) == 0
|
|
{
|
|
let end = hostname.firstIndex(of: 0) ?? hostname.count
|
|
let bytes = hostname[..<end].map { UInt8(bitPattern: $0) }
|
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
|
if !ip.hasPrefix("169.254") { return ip }
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Manager
|
|
|
|
@MainActor
|
|
final class WebChatManager {
|
|
static let shared = WebChatManager()
|
|
private var windowController: WebChatWindowController?
|
|
private var panelController: WebChatWindowController?
|
|
private var panelSessionKey: String?
|
|
private var swiftWindowController: WebChatSwiftUIWindowController?
|
|
private var swiftPanelController: WebChatSwiftUIWindowController?
|
|
private var swiftPanelSessionKey: String?
|
|
var onPanelVisibilityChanged: ((Bool) -> Void)?
|
|
|
|
func show(sessionKey: String) {
|
|
self.closePanel()
|
|
if AppStateStore.webChatSwiftUIEnabled {
|
|
if let controller = self.swiftWindowController {
|
|
controller.show()
|
|
return
|
|
}
|
|
let controller = WebChatSwiftUIWindowController(sessionKey: sessionKey, presentation: .window)
|
|
controller.onVisibilityChanged = { [weak self] visible in
|
|
self?.onPanelVisibilityChanged?(visible)
|
|
}
|
|
self.swiftWindowController = controller
|
|
controller.show()
|
|
} else {
|
|
if let controller = self.windowController {
|
|
controller.showWindow(nil)
|
|
controller.window?.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
return
|
|
}
|
|
|
|
let controller = WebChatWindowController(sessionKey: sessionKey)
|
|
self.windowController = controller
|
|
controller.showWindow(nil)
|
|
controller.window?.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
}
|
|
|
|
func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) {
|
|
if AppStateStore.webChatSwiftUIEnabled {
|
|
if let controller = self.swiftPanelController {
|
|
if self.swiftPanelSessionKey != sessionKey {
|
|
controller.close()
|
|
self.swiftPanelController = nil
|
|
self.swiftPanelSessionKey = nil
|
|
} else {
|
|
if controller.isVisible {
|
|
controller.close()
|
|
} else {
|
|
controller.presentAnchored(anchorProvider: anchorProvider)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
let controller = WebChatSwiftUIWindowController(
|
|
sessionKey: sessionKey,
|
|
presentation: .panel(anchorProvider: anchorProvider))
|
|
controller.onClosed = { [weak self] in
|
|
self?.panelHidden()
|
|
}
|
|
controller.onVisibilityChanged = { [weak self] visible in
|
|
self?.onPanelVisibilityChanged?(visible)
|
|
}
|
|
self.swiftPanelController = controller
|
|
self.swiftPanelSessionKey = sessionKey
|
|
controller.presentAnchored(anchorProvider: anchorProvider)
|
|
} else {
|
|
if let controller = self.panelController {
|
|
if self.panelSessionKey != sessionKey {
|
|
controller.shutdown()
|
|
controller.close()
|
|
self.panelController = nil
|
|
self.panelSessionKey = nil
|
|
} else {
|
|
if controller.window?.isVisible == true {
|
|
controller.closePanel()
|
|
} else {
|
|
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
let controller = WebChatWindowController(
|
|
sessionKey: sessionKey,
|
|
presentation: .panel(anchorProvider: anchorProvider))
|
|
self.panelController = controller
|
|
self.panelSessionKey = sessionKey
|
|
controller.onPanelClosed = { [weak self] in
|
|
self?.panelHidden()
|
|
}
|
|
controller.onVisibilityChanged = { [weak self] visible in
|
|
guard let self else { return }
|
|
self.onPanelVisibilityChanged?(visible)
|
|
}
|
|
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
|
// visibility will be reported by the controller callback
|
|
}
|
|
}
|
|
|
|
func closePanel() {
|
|
if let controller = self.panelController {
|
|
controller.closePanel()
|
|
}
|
|
if let controller = self.swiftPanelController {
|
|
controller.close()
|
|
}
|
|
}
|
|
|
|
func preferredSessionKey() -> String {
|
|
// The gateway store uses a canonical direct-chat bucket (default: "main").
|
|
// Avoid reading local session files; in remote mode they are not authoritative.
|
|
"main"
|
|
}
|
|
|
|
@MainActor
|
|
func resetTunnels() {
|
|
self.windowController?.shutdown()
|
|
self.windowController?.close()
|
|
self.windowController = nil
|
|
self.panelController?.shutdown()
|
|
self.panelController?.close()
|
|
self.panelController = nil
|
|
self.panelSessionKey = nil
|
|
self.swiftWindowController?.close()
|
|
self.swiftWindowController = nil
|
|
self.swiftPanelController?.close()
|
|
self.swiftPanelController = nil
|
|
self.swiftPanelSessionKey = nil
|
|
}
|
|
|
|
@MainActor
|
|
func openInBrowser(sessionKey: String) async {
|
|
let base: URL
|
|
let gatewayPort: Int
|
|
if CommandResolver.connectionModeIsRemote() {
|
|
do {
|
|
let forwarded = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
|
gatewayPort = Int(forwarded)
|
|
|
|
let root = try WebChatWindowController.webChatAssetsRootURL()
|
|
WebChatServer.shared.start(root: root, preferredPort: nil)
|
|
let deadline = Date().addingTimeInterval(2.0)
|
|
var resolved: URL?
|
|
while Date() < deadline {
|
|
if let url = WebChatServer.shared.baseURL() {
|
|
resolved = url
|
|
break
|
|
}
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
guard let resolved else {
|
|
throw NSError(
|
|
domain: "WebChat",
|
|
code: 11,
|
|
userInfo: [NSLocalizedDescriptionKey: "webchat server did not start"])
|
|
}
|
|
base = resolved
|
|
} catch {
|
|
NSAlert(error: error).runModal()
|
|
return
|
|
}
|
|
} else {
|
|
let port = AppStateStore.webChatPort
|
|
gatewayPort = GatewayEnvironment.gatewayPort()
|
|
base = URL(string: "http://127.0.0.1:\(port)/")!
|
|
}
|
|
|
|
var comps = URLComponents(url: base, resolvingAgainstBaseURL: false)
|
|
comps?.path = "/webchat/"
|
|
comps?.queryItems = [
|
|
URLQueryItem(name: "session", value: sessionKey),
|
|
URLQueryItem(name: "gatewayPort", value: String(gatewayPort)),
|
|
URLQueryItem(name: "gatewayHost", value: base.host ?? "127.0.0.1"),
|
|
]
|
|
guard let url = comps?.url else { return }
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
|
|
func close() {
|
|
self.windowController?.shutdown()
|
|
self.windowController?.close()
|
|
self.windowController = nil
|
|
|
|
self.panelController?.shutdown()
|
|
self.panelController?.close()
|
|
self.panelController = nil
|
|
self.panelSessionKey = nil
|
|
|
|
self.swiftWindowController?.close()
|
|
self.swiftWindowController = nil
|
|
self.swiftPanelController?.close()
|
|
self.swiftPanelController = nil
|
|
self.swiftPanelSessionKey = nil
|
|
}
|
|
|
|
private func panelHidden() {
|
|
self.onPanelVisibilityChanged?(false)
|
|
// Keep panel controllers cached so reopening doesn't re-bootstrap.
|
|
}
|
|
}
|