bridge.ts RawHistoryFolder
1/**
2 * WebKit bridge utilities for communicating with the Go backend.
3 *
4 * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived).
5 * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it
6 * fails silently, dropping the message entirely. This module provides safe
7 * serialization that converts Proxies to plain objects before sending.
8 */
9
10export interface BridgeMessage {
11 type: string;
12 webviewId: number;
13 payload: Record<string, unknown>;
14}
15
16interface WebKitBridge {
17 postMessage: (msg: unknown) => void;
18}
19
20/**
21 * Get the WebKit message handler bridge, if available.
22 */
23export function getWebKitBridge(): WebKitBridge | null {
24 // eslint-disable-next-line @typescript-eslint/no-explicit-any
25 const bridge = (window as any).webkit?.messageHandlers?.dumber;
26 return bridge && typeof bridge.postMessage === "function" ? bridge : null;
27}
28
29/**
30 * Get the current webview ID assigned by the Go backend.
31 */
32export function getWebViewId(): number {
33 // eslint-disable-next-line @typescript-eslint/no-explicit-any
34 return (window as any).__dumber_webview_id || 0;
35}
36
37/**
38 * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects.
39 * Uses JSON serialization which strips Proxy wrappers.
40 *
41 * @throws Error if the object contains non-serializable values (functions, circular refs, etc.)
42 */
43export function toPlainObject<T>(obj: T): T {
44 try {
45 return JSON.parse(JSON.stringify(obj));
46 } catch (err) {
47 const message = err instanceof Error ? err.message : String(err);
48 throw new Error(`Failed to serialize object for bridge: ${message}`);
49 }
50}
51
52/**
53 * Post a message to the Go backend via WebKit bridge.
54 *
55 * Automatically:
56 * - Converts Svelte 5 Proxy objects to plain objects
57 * - Adds webviewId if not provided
58 * - Logs errors if serialization fails
59 *
60 * @throws Error if bridge is not available or serialization fails
61 */
62export function postMessage(msg: BridgeMessage): void {
63 const bridge = getWebKitBridge();
64 if (!bridge) {
65 throw new Error("WebKit bridge not available");
66 }
67
68 // Ensure webviewId is set
69 if (!msg.webviewId) {
70 msg.webviewId = getWebViewId();
71 }
72
73 // Convert to plain object to strip Svelte 5 Proxies
74 // This is critical - WebKit silently drops messages with Proxy objects
75 const plainMsg = toPlainObject(msg);
76 bridge.postMessage(plainMsg);
77}
78
79/**
80 * Internal callback registry using Map for tracking pending requests.
81 * Callbacks must also be exposed on window for Go backend to call,
82 * but we track them here for proper cleanup and to prevent leaks.
83 */
84const pendingCallbacks = new Map<string, {
85 cleanup: () => void;
86 timeoutId: ReturnType<typeof setTimeout>;
87}>();
88
89let callbackCounter = 0;
90const CALLBACK_TIMEOUT_MS = 30000;
91
92/**
93 * Post a message and set up success/error callbacks with automatic cleanup.
94 * Uses unique IDs to prevent naming conflicts between concurrent requests.
95 *
96 * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
97 * @param payload - Message payload (will be serialized to strip Proxies)
98 * @param successCallback - Base name for success callback (will be made unique)
99 * @param errorCallback - Base name for error callback (will be made unique)
100 * @param onSuccess - Handler for success response
101 * @param onError - Handler for error response
102 */
103export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
104 type: string,
105 payload: TPayload,
106 successCallback: string,
107 errorCallback: string,
108 onSuccess: (response: TResponse) => void,
109 onError: (error: string) => void,
110): void {
111 const id = ++callbackCounter;
112 const successKey = `${successCallback}_${id}`;
113 const errorKey = `${errorCallback}_${id}`;
114
115 // Cleanup removes from both window and our tracking Map
116 const cleanup = () => {
117 const entry = pendingCallbacks.get(successKey);
118 if (entry) {
119 clearTimeout(entry.timeoutId);
120 pendingCallbacks.delete(successKey);
121 }
122 // eslint-disable-next-line @typescript-eslint/no-explicit-any
123 delete (window as any)[successKey];
124 // eslint-disable-next-line @typescript-eslint/no-explicit-any
125 delete (window as any)[errorKey];
126 };
127
128 // Timeout prevents memory leaks if backend never responds
129 const timeoutId = setTimeout(() => {
130 cleanup();
131 onError("Request timed out");
132 }, CALLBACK_TIMEOUT_MS);
133
134 // Track in our Map for proper lifecycle management
135 pendingCallbacks.set(successKey, { cleanup, timeoutId });
136
137 // Expose on window for Go backend (it calls window[callbackName](response))
138 // eslint-disable-next-line @typescript-eslint/no-explicit-any
139 (window as any)[successKey] = (response: TResponse) => {
140 cleanup();
141 onSuccess(response);
142 };
143 // eslint-disable-next-line @typescript-eslint/no-explicit-any
144 (window as any)[errorKey] = (msg: unknown) => {
145 cleanup();
146 onError(typeof msg === "string" ? msg : "Unknown error");
147 };
148
149 try {
150 postMessage({
151 type,
152 webviewId: getWebViewId(),
153 payload: {
154 ...payload,
155 successCallback: successKey,
156 errorCallback: errorKey,
157 },
158 });
159 } catch (err) {
160 cleanup();
161 onError(err instanceof Error ? err.message : String(err));
162 }
163}