From b0dddfc24ffbb6bde3afb71eebd2194dda18f124 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 12:42:33 +0700 Subject: [PATCH 1/2] feat(macos): make OpenWorlds the native app shell --- docs/OPENWORLDS_NATIVE_APP_ROADMAP.md | 131 +++++++ .../Sources/ClawDnDApp/App/ClawDnDApp.swift | 12 + .../ClawDnDApp/Models/LocalEndpoint.swift | 4 +- .../Sources/ClawDnDApp/Views/RootView.swift | 351 ++++++++++++++++++ .../Sources/ClawDnDApp/Views/WebView.swift | 142 ++++++- viewer/openworlds/SOURCE.md | 1 + viewer/openworlds/app.jsx | 74 +++- viewer/openworlds/chrome.jsx | 17 +- viewer/openworlds/index.html | 1 + viewer/openworlds/native-bridge.js | 15 + viewer/openworlds/screen-launcher.jsx | 9 - viewer/openworlds/screen-settings.jsx | 106 +++++- viewer/openworlds/styles.css | 28 ++ viewer/server.py | 12 +- viewer/tests/test_openworlds_static.py | 14 +- 15 files changed, 897 insertions(+), 20 deletions(-) create mode 100644 docs/OPENWORLDS_NATIVE_APP_ROADMAP.md create mode 100644 viewer/openworlds/native-bridge.js diff --git a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md new file mode 100644 index 00000000..738bdb67 --- /dev/null +++ b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md @@ -0,0 +1,131 @@ +# OpenWorlds Native App Roadmap + +Date: 2026-05-26 + +## Correction + +OpenWorlds is the visible macOS app experience. SwiftUI/AppKit remains in the +product, but only as the native supervisor and bridge for process lifecycle, +provider launch, dependency checks, diagnostics, settings persistence, and +WKWebView integration. + +The old SwiftUI Play/Campaigns/Monitor/Providers/Settings/Logs shell is not the +normal product UI. It stays temporarily as a debug/recovery control center while +the OpenWorlds bridge reaches parity. + +## State Authority + +- Engine and existing player move paths are the only campaign-state writers. +- Viewer exposes browser-safe read models. +- OpenWorlds reads viewer APIs and may submit player intent through `POST /move`. +- The native bridge may start/stop local processes and return app diagnostics. +- OpenWorlds and Swift code must not write `snapshot.json`, `play-state`, + `qa/state`, inventory, quests, XP, clocks, companion state, or private lore + directly. + +## Surface Map + +| Surface | Current state | Backing | +| --- | --- | --- | +| Chronicles | Wired/partial | `/openworlds/campaigns.json`; native start/resume still needs bridge hardening | +| Table | Wired/partial | `/session-surface` plus `/move` for enabled actions | +| Combat | Wired/partial | `/combat-surface` plus `/move` for enabled actions | +| Atlas | Wired/partial | `/atlas-surface` plus `/move` for enabled travel | +| Settings | Native bridge | App preferences, dependency status, provider status, diagnostics | +| Providers | Native bridge | `ProviderAdapters.swift` and `AppProcessService` | +| Logs | Native bridge | supervisor/provider logs from Swift | +| Relations/Camp | Display-only | needs companion/camp read model | +| Inventory/Merchant/Forge | Display-only | needs item/economy read models and engine-owned actions | +| Acts | Display-only | needs campaign-director projection | +| Bestiary/Codex | Display-only | needs player-known lore projection | +| Character/Create/Seed/Dialog | Display-only | prototype UI retained until backed | + +## Capability Labels + +Every OpenWorlds screen should expose one of these labels: + +- `Wired`: backed by viewer/native bridge and usable. +- `Read-only`: backed by real data but no mutation path. +- `Display-only`: prototype UI retained for roadmap/fidelity, not backed. +- `Provider required`: requires Claude/Codex/OpenClaw session. +- `Unavailable`: dependency or native bridge missing. + +## Sprint Order + +1. Sprint 0: roadmap correction and trailing-slash bug fix. +2. Sprint 1: full-window OpenWorlds host in the macOS app. +3. Sprint 2: `window.ClawDnDNative.request(type, payload)` bridge. +4. Sprint 3: map Settings, Providers, and Logs into OpenWorlds. +5. Sprint 4: make Chronicles the real app home with live/stale run state. +6. Sprint 5+: finish gameplay surfaces in impact order: table, combat, atlas, + relations/camp, inventory/economy, acts, bestiary/codex. +7. Release trust: add a Sparkle-backed update channel (#134) after the local `.app`, + signing, and bundle identity are stable. This should let owners update the + native app without repeated manual rebuild/download cycles, while keeping the + viewer/engine state directories outside the app bundle. + +## Sparkle Update Lane + +Sparkle is intentionally out of the correction PR's runtime scope. Track it as a +release-trust feature in #134 after the OpenWorlds host and native bridge are +stable. + +Implementation goals: + +- Keep app updates separate from campaign state, `play-state`, `qa/state`, and + private world content. +- Add a stable bundle identifier, signing identity decision, appcast location, + update cadence, rollback notes, and release-channel naming before enabling + automatic checks. +- Surface update status inside the OpenWorlds Settings screen through the native + bridge, not through a second visible SwiftUI settings shell. +- Keep local dev builds working without Sparkle so contributors can still use + `./script/build_and_run.sh --verify`. + +## Native Bridge Contract + +Browser API: + +```js +window.ClawDnDNative.request(type, payload) +``` + +Supported request types: + +- `appStatus` +- `dependencyStatus` +- `providerStatuses` +- `startViewer` +- `stopViewer` +- `startProviderSession` +- `stopProvider` +- `diagnostics` +- `copyDiagnostics` +- `openFallbackDashboard` + +Native replies: + +```json +{ "ok": true, "requestId": "uuid", "type": "appStatus", "payload": {} } +``` + +Errors: + +```json +{ "ok": false, "requestId": "uuid", "type": "startProviderSession", "error": "message" } +``` + +## Validation + +Run from a Lexar-backed checkout: + +```bash +python3 -m unittest viewer.tests.test_openworlds_static -q +python3 -m py_compile viewer/server.py +swift build --package-path macos/ClawDnDApp +./script/build_and_run.sh --verify +python3 scripts/license_check.py +git diff --check +``` + +No story/DM content changes and no narrative QA runs are part of this lane. diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift index 80bc4eaa..b3a21910 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/App/ClawDnDApp.swift @@ -4,6 +4,7 @@ import SwiftUI @main struct ClawDnDApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @Environment(\.openWindow) private var openWindow @StateObject private var processService = AppProcessService() @StateObject private var campaignStore = CampaignStore() @@ -14,9 +15,20 @@ struct ClawDnDApp: App { .environmentObject(campaignStore) .frame(minWidth: 1120, minHeight: 720) } + WindowGroup("Debug Control Center", id: "debug-control-center") { + DebugControlCenterView() + .environmentObject(processService) + .environmentObject(campaignStore) + .frame(minWidth: 1120, minHeight: 720) + } .commands { CommandGroup(replacing: .newItem) {} CommandGroup(after: .appInfo) { + Button("Open Debug Control Center") { + openWindow(id: "debug-control-center") + } + .keyboardShortcut("d", modifiers: [.command, .option]) + Button("Copy Diagnostics") { Diagnostics.copy(processService: processService) } diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift index e124b345..9019eb1f 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Models/LocalEndpoint.swift @@ -23,7 +23,9 @@ struct LocalEndpoint: Identifiable, Equatable { } var openWorldsURL: URL { - url.appendingPathComponent("openworlds") + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.path = "/openworlds/" + return components?.url ?? url.appendingPathComponent("openworlds/") } var monitorURL: URL { diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift index 3f03d033..90cbd7bf 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift @@ -1,3 +1,5 @@ +import AppKit +import Foundation import SwiftUI struct RootView: View { @@ -16,6 +18,355 @@ struct RootView: View { @AppStorage("maxTurns") private var maxTurns: String = "40" @AppStorage("voiceBackend") private var voiceBackend: String = "null" + @State private var webURL: URL? + @State private var webViewErrorMessage: String? + @State private var launchMessage = "Opening OpenWorlds" + @State private var launchError: String? + @State private var isStarting = false + @State private var launchTask: Task? + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if let webURL, webViewErrorMessage == nil { + WebView( + url: webURL, + navigationError: $webViewErrorMessage, + nativeRequestHandler: handleNativeRequest + ) + .ignoresSafeArea() + } else { + OpenWorldsLaunchOverlay( + message: launchError ?? launchMessage, + isError: launchError != nil, + isStarting: isStarting, + retry: startOpenWorlds + ) + } + + if let webViewErrorMessage { + WebViewErrorView(message: webViewErrorMessage) { + self.webViewErrorMessage = nil + startOpenWorlds() + } + .background(.black.opacity(0.82)) + } + } + .onAppear { + refresh() + startOpenWorlds() + } + .onChange(of: repoPath) { _ in + refresh() + startOpenWorlds() + } + .onDisappear { + launchTask?.cancel() + } + } + + private func refresh() { + processService.refreshDependencies() + campaignStore.reload(repoPath: repoPath) + } + + private func startOpenWorlds() { + launchTask?.cancel() + launchTask = Task { @MainActor in + isStarting = true + launchError = nil + webViewErrorMessage = nil + webURL = nil + launchMessage = "Starting the local viewer" + do { + let url = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir + ) + launchMessage = "Waiting for OpenWorlds" + try await waitForOpenWorlds(url) + guard !Task.isCancelled else { return } + webURL = url + launchMessage = "OpenWorlds ready" + } catch { + guard !Task.isCancelled else { return } + launchError = error.localizedDescription + } + isStarting = false + } + } + + private func waitForOpenWorlds(_ url: URL) async throws { + let deadline = Date().addingTimeInterval(8) + var lastError = "not ready" + + while Date() < deadline { + try Task.checkCancellation() + do { + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 1 + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, (200..<500).contains(http.statusCode) { + return + } + } catch { + lastError = error.localizedDescription + } + try await Task.sleep(nanoseconds: 250_000_000) + } + + throw ProviderError.configuration("Viewer did not become ready at \(url.absoluteString): \(lastError)") + } + + private func handleNativeRequest(_ request: NativeBridgeRequest) async -> NativeBridgeReply { + do { + let payload = try await nativePayload(for: request) + return .success(request: request, payload: payload) + } catch { + return .failure(request: request, error: error.localizedDescription) + } + } + + private func nativePayload(for request: NativeBridgeRequest) async throws -> [String: Any] { + switch request.type { + case "appStatus": + return appStatusPayload() + case "dependencyStatus": + processService.refreshDependencies() + return ["dependencies": dependencyPayload()] + case "providerStatuses": + return ["providers": providerStatusesPayload()] + case "startViewer": + return try await startViewerFromBridge(request.payload) + case "stopViewer": + processService.stopViewer() + webURL = nil + return appStatusPayload() + case "startProviderSession": + return try await startProviderFromBridge(request.payload) + case "stopProvider": + processService.stopProvider() + return appStatusPayload() + case "diagnostics": + return ["diagnostics": processService.diagnostics] + case "copyDiagnostics": + Diagnostics.copy(processService: processService) + return ["copied": true] + case "openFallbackDashboard": + let dashboardURL = try await ensureDashboardURL() + NSWorkspace.shared.open(dashboardURL) + return ["url": dashboardURL.absoluteString] + default: + throw ProviderError.configuration("Unknown native bridge request: \(request.type)") + } + } + + private func startViewerFromBridge(_ payload: [String: Any]) async throws -> [String: Any] { + let campaignID = stringPayload(payload, "campaignID").flatMap { $0.isEmpty ? nil : $0 } + let url = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir, + campaignID: campaignID + ) + try await waitForOpenWorlds(url) + webURL = url + return appStatusPayload(extra: ["url": url.absoluteString]) + } + + private func startProviderFromBridge(_ payload: [String: Any]) async throws -> [String: Any] { + let providerRaw = stringPayload(payload, "provider") ?? selectedProviderRaw + let provider = ProviderKind(rawValue: providerRaw) ?? .claude + let world = stringPayload(payload, "world") ?? defaultWorld + let runId = stringPayload(payload, "runId").flatMap { $0.isEmpty ? nil : $0 } ?? Self.newRunID() + let companions = stringPayload(payload, "companions") ?? "" + let url = try processService.startProviderSession( + kind: provider, + repoPath: repoPath, + world: world, + runId: runId, + preferredPort: preferredPort, + companions: companions, + stateDir: stateDir, + preferences: providerPreferences + ) + try await waitForOpenWorlds(url) + webURL = url + return appStatusPayload(extra: ["url": url.absoluteString, "runId": runId]) + } + + private func ensureDashboardURL() async throws -> URL { + if let endpoint = processService.viewerEndpoint { + return endpoint.dashboardURL + } + let url = try processService.startViewer( + repoPath: repoPath, + preferredPort: preferredPort, + stateDir: stateDir + ) + try await waitForOpenWorlds(url) + webURL = url + return processService.viewerEndpoint?.dashboardURL ?? url + } + + private func appStatusPayload(extra: [String: Any] = [:]) -> [String: Any] { + var payload: [String: Any] = [ + "repoPath": repoPath, + "stateDir": stateDir.isEmpty ? "default" : stateDir, + "preferredPort": preferredPort, + "defaultWorld": defaultWorld, + "selectedProvider": selectedProviderRaw, + "voiceBackend": voiceBackend, + "viewer": endpointPayload(processService.viewerEndpoint) as Any, + "activeCampaign": processService.activeCampaignID ?? "", + "runningProvider": processService.runningProvider?.rawValue ?? "", + "lastError": processService.lastError ?? "", + "preferences": [ + "repoPath": repoPath, + "preferredPort": preferredPort, + "stateDir": stateDir, + "selectedProvider": selectedProviderRaw, + "defaultWorld": defaultWorld, + "budget": budget, + "sessionBudget": sessionBudget, + "maxTurns": maxTurns, + "voiceBackend": voiceBackend, + ], + "providers": providerStatusesPayload(), + "dependencies": dependencyPayload(), + "providerDiagnostics": Diagnostics.providerLaunchSummary(processService.providerLaunchMetadata), + ] + extra.forEach { payload[$0.key] = $0.value } + return payload + } + + private func endpointPayload(_ endpoint: LocalEndpoint?) -> [String: Any] { + guard let endpoint else { + return ["status": EndpointStatus.stopped.rawValue] + } + return [ + "name": endpoint.name, + "url": endpoint.url.absoluteString, + "openWorldsURL": endpoint.openWorldsURL.absoluteString, + "dashboardURL": endpoint.dashboardURL.absoluteString, + "monitorURL": endpoint.monitorURL.absoluteString, + "healthPath": endpoint.healthPath, + "status": endpoint.status.rawValue, + "port": endpoint.port, + ] + } + + private func dependencyPayload() -> [[String: Any]] { + processService.dependencies.map { + [ + "command": $0.command, + "requiredFor": $0.requiredFor, + "path": $0.path ?? "", + "installed": $0.isInstalled, + ] + } + } + + private func providerStatusesPayload() -> [[String: Any]] { + processService.providerStatuses(repoPath: repoPath, preferences: providerPreferences).map { + [ + "kind": $0.kind.rawValue, + "displayName": $0.kind.displayName, + "availability": $0.availability.rawValue, + "detail": $0.detail, + "detectedPath": $0.detectedPath ?? "", + "launchable": $0.isLaunchable, + ] + } + } + + private var providerPreferences: ProviderPreferences { + ProviderPreferences( + codexCommand: codexProviderCommand, + openClawCommand: openClawProviderCommand, + budget: budget, + sessionBudget: sessionBudget, + maxTurns: maxTurns + ) + } + + private func stringPayload(_ payload: [String: Any], _ key: String) -> String? { + guard let value = payload[key] else { return nil } + if let string = value as? String { + return string.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "\(value)".trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static let runIDFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyyMMdd-HHmmss" + return formatter + }() + + private static func newRunID() -> String { + "play-\(runIDFormatter.string(from: Date()))" + } +} + +private struct OpenWorldsLaunchOverlay: View { + let message: String + let isError: Bool + let isStarting: Bool + let retry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Text("Open Worlds") + .font(.system(size: 28, weight: .semibold, design: .serif)) + .foregroundStyle(Color(red: 0.94, green: 0.82, blue: 0.52)) + .tracking(6) + Text(message) + .font(.callout) + .foregroundStyle(isError ? .orange : .secondary) + .multilineTextAlignment(.center) + if isStarting { + ProgressView() + .controlSize(.small) + } + if isError { + Button("Retry", action: retry) + .buttonStyle(.borderedProminent) + } + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + LinearGradient( + colors: [Color(red: 0.06, green: 0.035, blue: 0.02), Color(red: 0.15, green: 0.09, blue: 0.05)], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + +struct DebugControlCenterView: View { + @EnvironmentObject private var processService: AppProcessService + @EnvironmentObject private var campaignStore: CampaignStore + + @AppStorage("repoPath") private var repoPath: String = RepositoryLocator.defaultRepoPath() ?? "" + @AppStorage("preferredPort") private var preferredPort: Int = 8765 + @AppStorage("stateDir") private var stateDir: String = "" + @AppStorage("selectedProvider") private var selectedProviderRaw: String = ProviderKind.claude.rawValue + @AppStorage("defaultWorld") private var defaultWorld: String = "baldurs-gate" + @AppStorage("codexProviderCommand") private var codexProviderCommand: String = "" + @AppStorage("openClawProviderCommand") private var openClawProviderCommand: String = "" + @AppStorage("budget") private var budget: String = "1.50" + @AppStorage("sessionBudget") private var sessionBudget: String = "15.00" + @AppStorage("maxTurns") private var maxTurns: String = "40" + @AppStorage("voiceBackend") private var voiceBackend: String = "null" + @State private var selection: AppSection? = .play @State private var webURL: URL? diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift index a6405b52..2beafe30 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift @@ -1,20 +1,92 @@ +import Foundation import SwiftUI import WebKit +struct NativeBridgeRequest { + let requestId: String + let type: String + let payload: [String: Any] + + init?(body: Any) { + guard let dict = body as? [String: Any], + let type = dict["type"] as? String, + !type.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + self.requestId = (dict["requestId"] as? String) ?? UUID().uuidString + self.type = type + self.payload = dict["payload"] as? [String: Any] ?? [:] + } +} + +struct NativeBridgeReply { + let ok: Bool + let requestId: String + let type: String + let payload: [String: Any] + let error: String? + + static func success(request: NativeBridgeRequest, payload: [String: Any]) -> NativeBridgeReply { + NativeBridgeReply(ok: true, requestId: request.requestId, type: request.type, payload: payload, error: nil) + } + + static func failure(request: NativeBridgeRequest, error: String) -> NativeBridgeReply { + NativeBridgeReply(ok: false, requestId: request.requestId, type: request.type, payload: [:], error: error) + } + + static func malformed() -> NativeBridgeReply { + NativeBridgeReply(ok: false, requestId: UUID().uuidString, type: "malformed", payload: [:], error: "Malformed native bridge request.") + } + + var dictionary: [String: Any] { + var dict: [String: Any] = [ + "ok": ok, + "requestId": requestId, + "type": type, + "payload": payload, + ] + if let error { + dict["error"] = error + } + return dict + } +} + struct WebView: NSViewRepresentable { + typealias NativeRequestHandler = @MainActor (NativeBridgeRequest) async -> NativeBridgeReply + let url: URL? @Binding var navigationError: String? + let nativeRequestHandler: NativeRequestHandler? + + init( + url: URL?, + navigationError: Binding, + nativeRequestHandler: NativeRequestHandler? = nil + ) { + self.url = url + _navigationError = navigationError + self.nativeRequestHandler = nativeRequestHandler + } func makeNSView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + if nativeRequestHandler != nil { + configuration.userContentController.addUserScript(Self.nativeBridgeScript) + configuration.userContentController.add(context.coordinator, name: "clawdnd") + } let view = WKWebView(frame: .zero, configuration: configuration) view.allowsBackForwardNavigationGestures = true view.navigationDelegate = context.coordinator + context.coordinator.webView = view + context.coordinator.nativeRequestHandler = nativeRequestHandler return view } func updateNSView(_ view: WKWebView, context: Context) { + context.coordinator.nativeRequestHandler = nativeRequestHandler guard let url else { return } if view.url != url { navigationError = nil @@ -26,8 +98,50 @@ struct WebView: NSViewRepresentable { Coordinator(navigationError: $navigationError) } - final class Coordinator: NSObject, WKNavigationDelegate { + static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { + nsView.configuration.userContentController.removeScriptMessageHandler(forName: "clawdnd") + } + + private static let nativeBridgeScript = WKUserScript( + source: """ + (function () { + if (window.ClawDnDNative && window.ClawDnDNative.__installed) return; + const callbacks = {}; + function uuid() { + if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); + return "native-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); + } + window.ClawDnDNative = { + __installed: true, + request: function (type, payload) { + const requestId = uuid(); + const body = { requestId: requestId, type: type, payload: payload || {} }; + return new Promise(function (resolve, reject) { + callbacks[requestId] = { resolve: resolve, reject: reject }; + window.webkit.messageHandlers.clawdnd.postMessage(body); + }); + }, + _reply: function (message) { + const callback = callbacks[message.requestId]; + if (callback) { + delete callbacks[message.requestId]; + if (message.ok) callback.resolve(message.payload || {}); + else callback.reject(new Error(message.error || "Native bridge request failed.")); + } + window.dispatchEvent(new CustomEvent("clawdnd:native-reply", { detail: message })); + } + }; + window.dispatchEvent(new CustomEvent("clawdnd:native-ready")); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + + final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { private let navigationError: Binding + weak var webView: WKWebView? + var nativeRequestHandler: NativeRequestHandler? init(navigationError: Binding) { self.navigationError = navigationError @@ -50,6 +164,32 @@ struct WebView: NSViewRepresentable { guard nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled else { return } navigationError.wrappedValue = "Surface failed to load: \(error.localizedDescription)" } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == "clawdnd" else { return } + guard let request = NativeBridgeRequest(body: message.body) else { + send(.malformed()) + return + } + guard let nativeRequestHandler else { + send(.failure(request: request, error: "Native bridge is unavailable.")) + return + } + Task { @MainActor in + let reply = await nativeRequestHandler(request) + self.send(reply) + } + } + + private func send(_ reply: NativeBridgeReply) { + guard JSONSerialization.isValidJSONObject(reply.dictionary), + let data = try? JSONSerialization.data(withJSONObject: reply.dictionary), + let json = String(data: data, encoding: .utf8) + else { + return + } + webView?.evaluateJavaScript("window.ClawDnDNative && window.ClawDnDNative._reply(\(json));") + } } } diff --git a/viewer/openworlds/SOURCE.md b/viewer/openworlds/SOURCE.md index ad69fc4b..025aad41 100644 --- a/viewer/openworlds/SOURCE.md +++ b/viewer/openworlds/SOURCE.md @@ -21,6 +21,7 @@ The production viewer route maps source files into this directory as follows: | `openworlds/Open Worlds.html` | `viewer/openworlds/index.html` | | `openworlds/styles.css` | `viewer/openworlds/styles.css` | | `openworlds/data.js` | `viewer/openworlds/data.js` | +| native bridge helper | `viewer/openworlds/native-bridge.js` | | `openworlds/app.jsx` | `viewer/openworlds/app.jsx` | | `openworlds/chrome.jsx` | `viewer/openworlds/chrome.jsx` | | `openworlds/tooltip.jsx` | `viewer/openworlds/tooltip.jsx` | diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index fb30c02b..5861c4f2 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -10,6 +10,13 @@ function App() { const [state, setState] = React.useState(window.INITIAL_STATE || {}); const [screen, setScreen] = React.useState("launcher"); const [campMode, setCampMode] = React.useState(false); + const [nativeState, setNativeState] = React.useState(() => ({ + bridge: Boolean(window.OpenWorldsNative?.hasBridge?.()), + appStatus: null, + dependencies: [], + providers: [], + error: "", + })); const [t, setTweak] = (window.useTweaks ? window.useTweaks(TWEAK_DEFAULTS) : [TWEAK_DEFAULTS, () => {}]); @@ -64,6 +71,41 @@ function App() { return () => { cancelled = true; }; }, []); + const refreshNative = React.useCallback(async () => { + const bridge = Boolean(window.OpenWorldsNative?.hasBridge?.()); + if (!bridge) { + setNativeState((s) => ({ ...s, bridge: false, error: "Native bridge unavailable" })); + return; + } + try { + const appStatus = await window.OpenWorldsNative.request("appStatus", {}); + setNativeState({ + bridge: true, + appStatus, + dependencies: Array.isArray(appStatus?.dependencies) ? appStatus.dependencies : [], + providers: Array.isArray(appStatus?.providers) ? appStatus.providers : [], + error: appStatus?.lastError || "", + }); + } catch (error) { + setNativeState((s) => ({ + ...s, + bridge: false, + error: error?.message || "Native bridge unavailable", + })); + } + }, []); + + React.useEffect(() => { + refreshNative(); + const onReady = () => refreshNative(); + window.addEventListener("clawdnd:native-ready", onReady); + const timer = window.setInterval(refreshNative, 5000); + return () => { + window.removeEventListener("clawdnd:native-ready", onReady); + window.clearInterval(timer); + }; + }, [refreshNative]); + // Keyboard shortcuts React.useEffect(() => { const onKey = (e) => { @@ -122,6 +164,8 @@ function App() { campaign={current.title} location={SCREEN_TITLES[screen]} day={current.day} + capability={capabilityForScreen(screen, nativeState)} + nativeStatus={nativeState} />
@@ -130,7 +174,16 @@ function App() {
- +
@@ -196,7 +249,22 @@ const SCREEN_TITLES = { settings: "Setting", }; -function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMode }) { +function capabilityForScreen(screen, nativeState) { + if (screen === "settings") { + return nativeState?.bridge + ? { label: "Wired", tone: "emerald", detail: "Native bridge ready" } + : { label: "Unavailable", tone: "crimson", detail: "Native bridge missing" }; + } + if (["launcher", "table", "combat", "map"].includes(screen)) { + return { label: "Wired", tone: "emerald", detail: "Backed by viewer read models" }; + } + if (screen === "dialogue") { + return { label: "Provider required", tone: "royal", detail: "Requires a provider session" }; + } + return { label: "Display-only", tone: "brass", detail: "Prototype surface awaiting a read model" }; +} + +function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMode, nativeState, refreshNative }) { switch (screen) { case "launcher": return ; case "table": return ; @@ -213,7 +281,7 @@ function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMo case "bestiary": return ; case "merchant": return ; case "dialogue": return ; - case "settings": return ; + case "settings": return ; default: return ; } } diff --git a/viewer/openworlds/chrome.jsx b/viewer/openworlds/chrome.jsx index 32965f7b..29a3db8d 100644 --- a/viewer/openworlds/chrome.jsx +++ b/viewer/openworlds/chrome.jsx @@ -353,7 +353,19 @@ function TabBar({ current, onNavigate }) { ); } -function TitleBar({ campaign, location, day }) { +function CapabilityBadge({ capability, nativeStatus }) { + if (!capability) return null; + const tone = capability.tone || "brass"; + const bridge = nativeStatus?.bridge ? "native" : "browser"; + return ( + + {capability.label} + {bridge} + + ); +} + +function TitleBar({ campaign, location, day, capability, nativeStatus }) { return (
+ {day && {day}}
@@ -375,5 +388,5 @@ function TitleBar({ campaign, location, day }) { Object.assign(window, { NAV_GROUPS, NAV_BOTTOM, ALL_NAV, getGroupForScreen, getDefaultScreen, Glyph, CornerOrnament, Divider, SectionTitle, Pill, - Placeholder, IconPlate, BrassButton, Panel, NavRail, TabBar, TitleBar, + Placeholder, IconPlate, BrassButton, Panel, NavRail, TabBar, CapabilityBadge, TitleBar, }); diff --git a/viewer/openworlds/index.html b/viewer/openworlds/index.html index ff95466e..17eb06ec 100644 --- a/viewer/openworlds/index.html +++ b/viewer/openworlds/index.html @@ -17,6 +17,7 @@ + diff --git a/viewer/openworlds/native-bridge.js b/viewer/openworlds/native-bridge.js new file mode 100644 index 00000000..647b217e --- /dev/null +++ b/viewer/openworlds/native-bridge.js @@ -0,0 +1,15 @@ +(function () { + function hasBridge() { + return Boolean(window.ClawDnDNative && typeof window.ClawDnDNative.request === "function"); + } + + window.OpenWorldsNative = { + hasBridge, + request(type, payload) { + if (!hasBridge()) { + return Promise.reject(new Error("OpenWorlds is running without the native ClawDnD bridge.")); + } + return window.ClawDnDNative.request(type, payload || {}); + }, + }; +})(); diff --git a/viewer/openworlds/screen-launcher.jsx b/viewer/openworlds/screen-launcher.jsx index ba20f59f..25c4c727 100644 --- a/viewer/openworlds/screen-launcher.jsx +++ b/viewer/openworlds/screen-launcher.jsx @@ -16,16 +16,7 @@ function ScreenLauncher({ onNavigate, state, setState }) { const onResume = () => { const nextCampaign = campaigns.some((c) => c.id === selected) ? selected : campaigns[0]?.id; if (!nextCampaign) return; - const campaign = campaigns.find((c) => c.id === nextCampaign); setState((s) => ({ ...s, activeCampaign: nextCampaign })); - if (campaign?.resumeUrl) { - window.location.assign(campaign.resumeUrl); - return; - } - if (campaign?.monitorUrl) { - window.location.assign(campaign.monitorUrl); - return; - } onNavigate("table"); }; diff --git a/viewer/openworlds/screen-settings.jsx b/viewer/openworlds/screen-settings.jsx index 7cc57c34..a6c88cc8 100644 --- a/viewer/openworlds/screen-settings.jsx +++ b/viewer/openworlds/screen-settings.jsx @@ -1,7 +1,7 @@ /* Screen: Settings โ€” audio, video, controls, save slots, accessibility */ -function ScreenSettings({ onNavigate, state, setState }) { - const [section, setSection] = React.useState("audio"); +function ScreenSettings({ onNavigate, state, setState, nativeState, refreshNative }) { + const [section, setSection] = React.useState("native"); const [audio, setAudio] = React.useState({ master: 72, music: 60, sfx: 80, ambience: 50, voice: 70, duckMusic: true, crossfade: true }); const [display, setDisplay] = React.useState({ scale: 100, contrast: 50, vignette: true, paperGrain: true, candleGlow: true }); const [gameplay, setGameplay] = React.useState({ auto: 15, narration: "balanced", dice: "visible", dangerHints: true, confirmDestructive: true, aiPartyRolls: false }); @@ -9,6 +9,7 @@ function ScreenSettings({ onNavigate, state, setState }) { const [accessibility, setAccessibility] = React.useState({ dyslexic: false, reducedMotion: false, captions: true, contrast: false, underlineChoices: false }); const SECTIONS = [ + { id: "native", label: "ClawDnD" }, { id: "audio", label: "Sound" }, { id: "display", label: "Display" }, { id: "gameplay", label: "Gameplay" }, @@ -54,6 +55,10 @@ function ScreenSettings({ onNavigate, state, setState }) { {/* RIGHT โ€” section content */} + {section === "native" && ( + + )} + {section === "audio" && ( setAudio({ ...audio, master: v })} /> @@ -212,6 +217,103 @@ function ScreenSettings({ onNavigate, state, setState }) { ); } +function NativeAppSection({ nativeState, refreshNative }) { + const toast = window.useToast ? window.useToast() : (() => {}); + const app = nativeState?.appStatus || {}; + const viewer = app.viewer || {}; + const providers = Array.isArray(nativeState?.providers) ? nativeState.providers : []; + const dependencies = Array.isArray(nativeState?.dependencies) ? nativeState.dependencies : []; + const bridgeReady = Boolean(nativeState?.bridge); + + const nativeAction = async (type, payload = {}) => { + if (!window.OpenWorldsNative?.hasBridge?.()) { + toast({ kind: "danger", title: "Native bridge unavailable", body: "OpenWorlds is running outside the ClawDnD macOS app." }); + return; + } + try { + await window.OpenWorldsNative.request(type, payload); + await refreshNative?.(); + toast({ kind: "ok", title: "Native action complete", body: type }); + } catch (error) { + toast({ kind: "danger", title: "Native action failed", body: error?.message || String(error) }); + } + }; + + const startProvider = () => { + const prefs = app.preferences || {}; + const now = new Date(); + const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "").replace(/^(\d{8})(\d{6})$/, "$1-$2"); + nativeAction("startProviderSession", { + provider: prefs.selectedProvider || app.selectedProvider || "claude", + world: prefs.defaultWorld || app.defaultWorld || "baldurs-gate", + runId: `play-${stamp}`, + companions: "", + }); + }; + + return ( + +
+ {bridgeReady ? "Wired" : "Unavailable"} + Viewer {viewer.status || "stopped"} + {app.runningProvider && Provider {app.runningProvider}} +
+ +
+ + App Status + + + + + + + + + Native Actions +
+ nativeAction("startViewer")}>Start Viewer + nativeAction("stopViewer")}>Stop Viewer + Start Provider + nativeAction("stopProvider")}>Stop Provider + nativeAction("copyDiagnostics")}>Copy Diagnostics + nativeAction("openFallbackDashboard")}>Debug Dashboard +
+

+ Native actions supervise local processes only. Game intent still travels through the existing engine/player move lane. +

+
+
+ + + Providers +
+ {providers.map((p) => ( + +
{p.displayName || p.kind}
+

{p.availability}

+
{p.detail}
+
+ ))} + {!providers.length && ( +
Provider status is unavailable until the native bridge is connected.
+ )} +
+ + + Dependencies +
+ {dependencies.map((d) => ( +
+ {d.command} + {d.installed ? "ready" : "missing"} +
+ ))} +
+
+ ); +} + function SettingsSection({ title, eyebrow, ordinal, children }) { return (
diff --git a/viewer/openworlds/styles.css b/viewer/openworlds/styles.css index a8c4e105..a7892b53 100644 --- a/viewer/openworlds/styles.css +++ b/viewer/openworlds/styles.css @@ -232,6 +232,34 @@ a { color: inherit; text-decoration: none; cursor: pointer; } letter-spacing: 0.2em; text-transform: uppercase; color: var(--b-300); + align-items: center; +} + +.capability-badge { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 250px; + padding: 4px 8px; + border: 1px solid rgba(176, 141, 87, 0.5); + background: rgba(20, 12, 6, 0.34); + color: var(--b-100); + letter-spacing: 0.16em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + -webkit-app-region: no-drag; +} + +.capability-badge.emerald { color: #b9e0bd; border-color: rgba(47, 90, 58, 0.8); } +.capability-badge.crimson { color: #f0c0ba; border-color: rgba(154, 42, 42, 0.8); } +.capability-badge.royal { color: #c8d3ff; border-color: rgba(59, 82, 148, 0.8); } +.capability-badge.brass { color: var(--b-100); border-color: rgba(176, 141, 87, 0.85); } + +.capability-source { + color: var(--b-300); + opacity: 0.8; + letter-spacing: 0.12em; } /* ===== App layout (nav rail + stage) ===== */ diff --git a/viewer/server.py b/viewer/server.py index 0aecf968..1bbdb8f5 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -2849,6 +2849,13 @@ def _send(self, code: int, body: bytes, ctype: str) -> None: def _json(self, obj) -> None: self._send(200, json.dumps(obj).encode("utf-8"), "application/json") + def _redirect(self, location: str) -> None: + self.send_response(302) + self.send_header("Location", location) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + def _serve_image(self, scope: str) -> None: """GET /image?scope= โ€” serve the most-recent cached image for a scope. @@ -3004,7 +3011,10 @@ def do_GET(self) -> None: # noqa: N802 live=live, is_live_view=live and cid == self.campaign_id, )) - elif route == _OPENWORLDS_ROUTE or route.startswith(f"{_OPENWORLDS_ROUTE}/"): + elif route == _OPENWORLDS_ROUTE: + suffix = f"?{parsed.query}" if parsed.query else "" + self._redirect(f"{_OPENWORLDS_ROUTE}/{suffix}") + elif route.startswith(f"{_OPENWORLDS_ROUTE}/"): asset = _openworlds_asset(route) if asset is None: self._send(404, b"not found", "text/plain") diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 0f62cdb5..f313ed20 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -47,17 +47,28 @@ def tearDown(self): server._HERE = self._old_here def _get(self, path: str) -> tuple[int, str, bytes]: + status, headers, body = self._get_with_headers(path) + return status, headers.get("Content-Type", ""), body + + def _get_with_headers(self, path: str) -> tuple[int, http.client.HTTPMessage, bytes]: conn = http.client.HTTPConnection(self._host, self._port, timeout=5) try: conn.request("GET", path) response = conn.getresponse() - return response.status, response.headers.get("Content-Type", ""), response.read() + return response.status, response.headers, response.read() finally: conn.close() def _status(self, path: str) -> int: return self._get(path)[0] + def test_openworlds_without_trailing_slash_redirects_to_directory_route(self): + status, headers, body = self._get_with_headers("/openworlds") + + self.assertEqual(status, 302) + self.assertEqual(headers.get("Location"), "/openworlds/") + self.assertEqual(body, b"") + def test_openworlds_index_uses_local_runtime_assets(self): status, ctype, body = self._get("/openworlds/") @@ -67,6 +78,7 @@ def test_openworlds_index_uses_local_runtime_assets(self): self.assertIn(b'vendor/react-dom-18.3.1.development.js', body) self.assertIn(b'vendor/babel-standalone-7.29.0.min.js', body) self.assertIn(b'vendor/google-fonts.css', body) + self.assertIn(b'native-bridge.js', body) self.assertNotIn(b"https://unpkg.com", body) self.assertNotIn(b"https://fonts.googleapis.com", body) self.assertNotIn(b"tweaks-panel.jsx", body) From 80a45d8843e1a8f379de9196c4ddf3f84b001708 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 13:05:09 +0700 Subject: [PATCH 2/2] fix(macos): harden OpenWorlds native bridge --- docs/OPENWORLDS_NATIVE_APP_ROADMAP.md | 15 ++++- .../Sources/ClawDnDApp/Views/RootView.swift | 58 ++++++++++++++++++- .../Sources/ClawDnDApp/Views/WebView.swift | 24 ++++++-- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md index 738bdb67..2d7e9ff4 100644 --- a/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md +++ b/docs/OPENWORLDS_NATIVE_APP_ROADMAP.md @@ -53,7 +53,8 @@ Every OpenWorlds screen should expose one of these labels: ## Sprint Order 1. Sprint 0: roadmap correction and trailing-slash bug fix. -2. Sprint 1: full-window OpenWorlds host in the macOS app. +2. Sprint 1: full-window OpenWorlds host in the macOS app, including the first + pass at single-frame custom chrome (#136). 3. Sprint 2: `window.ClawDnDNative.request(type, payload)` bridge. 4. Sprint 3: map Settings, Providers, and Logs into OpenWorlds. 5. Sprint 4: make Chronicles the real app home with live/stale run state. @@ -82,6 +83,18 @@ Implementation goals: - Keep local dev builds working without Sparkle so contributors can still use `./script/build_and_run.sh --verify`. +## Window Chrome Lane + +The visible app should not show duplicate window traffic lights. Track the full +custom-chrome work in #136: + +- native titlebar and traffic lights hidden in normal OpenWorlds play; +- OpenWorlds frame becomes the apparent app frame; +- visible OpenWorlds controls later call native close/minimize/zoom through the + bridge; +- a reliable drag region is added without stealing gameplay/settings clicks; +- Debug Control Center keeps normal native chrome for recovery. + ## Native Bridge Contract Browser API: diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift index 90cbd7bf..59d3a876 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/RootView.swift @@ -64,6 +64,7 @@ struct RootView: View { .onDisappear { launchTask?.cancel() } + .background(OpenWorldsWindowChrome()) } private func refresh() { @@ -109,9 +110,24 @@ struct RootView: View { request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.timeoutInterval = 1 let (_, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse, (200..<500).contains(http.statusCode) { + guard let http = response as? HTTPURLResponse else { + lastError = "non-HTTP response" + continue + } + switch http.statusCode { + case 200..<300: return + case 300..<400: + lastError = "HTTP \(http.statusCode) redirect" + case 400..<500: + throw ProviderError.configuration( + "Viewer returned HTTP \(http.statusCode) at \(url.absoluteString)" + ) + default: + lastError = "HTTP \(http.statusCode)" } + } catch let error as ProviderError { + throw error } catch { lastError = error.localizedDescription } @@ -314,6 +330,46 @@ struct RootView: View { } } +private struct OpenWorldsWindowChrome: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + OpenWorldsChromeHostView(frame: .zero) + } + + func updateNSView(_ view: NSView, context: Context) { + OpenWorldsChromeHostView.configure(view.window) + } +} + +private final class OpenWorldsChromeHostView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + Self.configure(window) + } + + static func configure(_ window: NSWindow?) { + guard let window else { return } + DispatchQueue.main.async { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + window.styleMask.remove(.titled) + window.styleMask.insert(.resizable) + window.toolbar = nil + window.backgroundColor = .black + window.isOpaque = true + window.isMovableByWindowBackground = true + + [ + NSWindow.ButtonType.closeButton, + .miniaturizeButton, + .zoomButton + ].forEach { button in + window.standardWindowButton(button)?.isHidden = true + } + } + } +} + private struct OpenWorldsLaunchOverlay: View { let message: String let isError: Bool diff --git a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift index 2beafe30..dd4ad971 100644 --- a/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift +++ b/macos/ClawDnDApp/Sources/ClawDnDApp/Views/WebView.swift @@ -76,6 +76,7 @@ struct WebView: NSViewRepresentable { if nativeRequestHandler != nil { configuration.userContentController.addUserScript(Self.nativeBridgeScript) configuration.userContentController.add(context.coordinator, name: "clawdnd") + context.coordinator.hasNativeMessageHandler = true } let view = WKWebView(frame: .zero, configuration: configuration) view.allowsBackForwardNavigationGestures = true @@ -99,7 +100,9 @@ struct WebView: NSViewRepresentable { } static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { - nsView.configuration.userContentController.removeScriptMessageHandler(forName: "clawdnd") + if coordinator.hasNativeMessageHandler { + nsView.configuration.userContentController.removeScriptMessageHandler(forName: "clawdnd") + } } private static let nativeBridgeScript = WKUserScript( @@ -142,6 +145,7 @@ struct WebView: NSViewRepresentable { private let navigationError: Binding weak var webView: WKWebView? var nativeRequestHandler: NativeRequestHandler? + var hasNativeMessageHandler = false init(navigationError: Binding) { self.navigationError = navigationError @@ -182,13 +186,21 @@ struct WebView: NSViewRepresentable { } private func send(_ reply: NativeBridgeReply) { - guard JSONSerialization.isValidJSONObject(reply.dictionary), - let data = try? JSONSerialization.data(withJSONObject: reply.dictionary), - let json = String(data: data, encoding: .utf8) - else { + let dictionary = reply.dictionary + guard JSONSerialization.isValidJSONObject(dictionary) else { + NSLog("ClawDnD native bridge produced an invalid JSON reply: \(dictionary)") return } - webView?.evaluateJavaScript("window.ClawDnDNative && window.ClawDnDNative._reply(\(json));") + do { + let data = try JSONSerialization.data(withJSONObject: dictionary) + guard let json = String(data: data, encoding: .utf8) else { + NSLog("ClawDnD native bridge failed to encode reply as UTF-8: \(dictionary)") + return + } + webView?.evaluateJavaScript("window.ClawDnDNative && window.ClawDnDNative._reply(\(json));") + } catch { + NSLog("ClawDnD native bridge reply serialization failed: \(error); reply=\(dictionary)") + } } } }