1/**
2 * Dumber GUI bootstrap routine shared between injected pages and special schemes.
3 *
4 * Sets up toast, omnibox, keyboard service, and workspace event listeners.
5 */
6
7import { initializeToast, type ToastConfig } from "./modules/toast";
8import {
9 initializeOmnibox,
10 type OmniboxInitConfig,
11} from "./modules/omnibox";
12import "./modules/workspace"; // Auto-initializes pane mode UI notifications
13import { keyboardService, type KeyboardService } from "$lib/keyboard";
14import type { Suggestion } from "../components/omnibox/types";
15
16declare global {
17 interface Window {
18 __dumber_gui?: DumberGUI;
19 __dumber_gui_ready?: boo
20 __dumber_gui_ready_for?: Document | null;
21 __dumber_showToast?: (
22 message: string,
23 duration?: number,
24 type?: "info" | "success" | "error",
25 ) => number | void;
26 __dumber_showZoomToast?: (level: number) => void;
27 __dumber_keyboard?: KeyboardService;
28 __dumber_omnibox?: {
29 setSuggestions: (suggestions: Suggestion[]) => void;
30 toggle: () => void;
31 open: (mode?: string, query?: string) => void;
32 close: () => void;
33 findQuery: (query: string) => void;
34 setActive: (active: boolean) => void;
35 };
36 __dumber_gui_bootstrap?: () => void;
37 __dumber_toggle?: () => void;
38 __dumber_find_open?: (query?: string) => void;
39 __dumber_find_close?: () => void;
40 __dumber_find_query?: (query: string) => void;
41 __dumber_dismissToast?: (id: number) => void;
42 __dumber_clearToasts?: () => void;
43 }
44}
45
46interface DumberGUI {
47 initializeToast: (config?: ToastConfig) => Promise<void>;
48 initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>;
49 keyboard: KeyboardService;
50 isReady: boolean;
51}
52
53function whenDOMReady(callback: () => void) {
54 if (document.readyState === "loading") {
55 document.addEventListener("DOMContentLoaded", callback, { once: true });
56 } else if (document.readyState === "interactive" && !document.body) {
57 setTimeout(() => whenDOMReady(callback), 10);
58 } else {
59 callback();
60 }
61}
62
63function normalizeWebviewId(value: unknown): number | null {
64 if (typeof value === "number" && Number.isFinite(value)) {
65 return value;
66 }
67 if (typeof value === "string") {
68 const trimmed = value.trim();
69 if (!trimmed || trimmed === "__WEBVIEW_ID__") {
70 return null;
71 }
72 const parsed = Number.parseInt(trimmed, 10);
73 return Number.isNaN(parsed) ? null : parsed;
74 }
75
76 return null;
77}
78
79export function bootstrapGUI(): void {
80 if (window.__dumber_gui_ready_for === document) {
81 return;
82 }
83
84 if (window.self !== window.top) {
85 console.log("🚫 Dumber GUI skipped in iframe");
86 return;
87 }
88
89 window.__dumber_gui_ready_for = document;
90 window.__dumber_gui_ready = true;
91
92 try {
93 window.addEventListener("pagehide", () => {
94 window.__dumber_gui_ready = false;
95 window.__dumber_gui_ready_for = null;
96 }, { once: true });
97 } catch {
98 // Ignore environments without pagehide support
99 }
100
101 let workspaceHasFocus = false;
102 const initialRawWebViewId = window.__dumber_webview_id;
103 let currentWebViewId = normalizeWebviewId(initialRawWebViewId);
104 if (currentWebViewId !== null) {
105 window.__dumber_webview_id = currentWebViewId;
106 }
107 let hasReceivedFocusEvent = false;
108
109 if (currentWebViewId === null) {
110 console.log("[workspace] WebView ID unknown, requesting from Go backend");
111 if (window.webkit?.messageHandlers?.dumber) {
112 window.webkit.messageHandlers.dumber.postMessage(
113 JSON.stringify({
114 type: "request-webview-id",
115 payload: { timestamp: Date.now() },
116 }),
117 );
118 }
119 }
120
121 document.addEventListener("dumber:webview-id", (event: Event) => {
122 const detail = (event as CustomEvent).detail || {};
123 const incomingId = normalizeWebviewId(detail.webviewId);
124 if (incomingId !== null && incomingId !== currentWebViewId) {
125 currentWebViewId = incomingId;
126 window.__dumber_webview_id = incomingId;
127 console.log("[workspace] Received webview ID from Go:", incomingId);
128 }
129 });
130
131 document.addEventListener("dumber:workspace-focus", (event: Event) => {
132 const detail = (event as CustomEvent).detail || {};
133 const eventWebviewId = normalizeWebviewId(detail?.webviewId);
134
135 if (eventWebviewId !== null) {
136 if (currentWebViewId === null) {
137 currentWebViewId = eventWebviewId;
138 window.__dumber_webview_id = eventWebviewId;
139 console.log(
140 "[workspace] Learned webview ID from focus event:",
141 eventWebviewId,
142 );
143 } else if (eventWebviewId !== currentWebViewId) {
144 console.log("[workspace] Ignoring focus event for other webview", {
145 eventWebviewId,
146 self: currentWebViewId,
147 detail,
148 });
149 return;
150 }
151 }
152
153 console.log("[workspace focus event]", detail, "prev focus=", workspaceHasFocus);
154
155 hasReceivedFocusEvent = true;
156
157 if (typeof detail?.active === "boolean") {
158 workspaceHasFocus = detail.active;
159 } else {
160 workspaceHasFocus = detail !== false;
161 }
162
163 console.log(
164 "[workspace focus updated] focus=",
165 workspaceHasFocus,
166 "webviewId=",
167 currentWebViewId,
168 );
169
170 if (window.__dumber_omnibox) {
171 if (workspaceHasFocus) {
172 window.__dumber_omnibox.setActive(true);
173 } else {
174 window.__dumber_omnibox.setActive(false);
175 window.__dumber_omnibox.close();
176 }
177 }
178 });
179
180 whenDOMReady(async () => {
181 console.log("[GUI] DOM ready, starting initialization");
182 let toastInitialized = false;
183
184 if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") {
185 console.log("[GUI] Toast already initialized, skipping");
186 toastInitialized = true;
187 } else {
188 try {
189 console.log("[GUI] Attempting toast initialization");
190 await initializeToast();
191 if (typeof window.__dumber_showToast !== "function") {
192 throw new Error("Toast functions not exposed after initialization");
193 }
194 toastInitialized = true;
195 console.log("[GUI] Toast initialized successfully");
196 } catch (e) {
197 console.error("❌ Failed to initialize Svelte toast system:", e);
198 throw e;
199 }
200 }
201
202 try {
203 await initializeOmnibox();
204 console.log("✅ Omnibox system initialized successfully");
205
206 // Auto-open omnibox on about:blank pages
207 if (window.location.href === "about:blank" && window.__dumber_omnibox) {
208 console.log("[about:blank] Auto-opening omnibox");
209 setTimeout(() => {
210 window.__dumber_omnibox?.open?.("omnibox", "");
211 }, 100);
212 }
213 } catch (omniboxError) {
214 console.error("❌ Failed to initialize omnibox system:", omniboxError);
215 }
216
217 console.log("✅ GUI systems initialization complete", {
218 toast: toastInitialized ? "Svelte" : "Fallback",
219 omnibox: !!window.__dumber_omnibox,
220 });
221 });
222
223 window.__dumber_keyboard = keyboardService;
224
225 whenDOMReady(() => {
226 document.addEventListener(
227 "keydown",
228 (event) => {
229 keyboardService.handleKeyboardEvent(event);
230 },
231 true,
232 );
233
234 document.addEventListener(
235 "mousedown",
236 (event) => {
237 keyboardService.handleMouseEvent(event);
238 },
239 true,
240 );
241
242 console.log("✅ KeyboardService initialized with global listeners");
243
244 document.addEventListener("dumber:key", (e: Event) => {
245 const detail = (e as CustomEvent).detail || {};
246 if (detail && typeof detail.shortcut === "string") {
247 keyboardService.handleNativeShortcut(detail.shortcut);
248 }
249 });
250
251 document.addEventListener("dumber:ui:shortcut", (e: Event) => {
252 const detail = (e as CustomEvent).detail || {};
253 const action = detail?.action;
254 const eventWebViewId = normalizeWebviewId(detail?.webviewId);
255 const source = detail?.source || "unknown";
256
257 if (eventWebViewId !== null && currentWebViewId === null) {
258 currentWebViewId = eventWebViewId;
259 window.__dumber_webview_id = eventWebViewId;
260 console.log(
261 "[dumber shortcuts] Learned webview ID from shortcut event:",
262 eventWebViewId,
263 );
264 }
265
266 console.log("[dumber shortcuts] Event received", {
267 action,
268 eventWebViewId,
269 currentWebViewId,
270 detail,
271 });
272
273 if (typeof action !== "string") {
274 console.log("[dumber shortcuts] No action in event, ignoring");
275 return;
276 }
277
278 const isForThisWebView =
279 eventWebViewId === null || eventWebViewId === currentWebViewId;
280 const isOmniboxAction = action.startsWith("omnibox-");
281 const shouldHandle =
282 isForThisWebView &&
283 (isOmniboxAction
284 ? hasReceivedFocusEvent
285 ? workspaceHasFocus
286 : true
287 : true);
288
289 if (!shouldHandle) {
290 console.log("[dumber shortcuts] ignored action", {
291 action,
292 workspaceHasFocus,
293 isForThisWebView,
294 eventWebViewId,
295 currentWebViewId,
296 source,
297 detail,
298 hasReceivedFocusEvent,
299 });
300 return;
301 }
302
303 console.log("[dumber shortcuts] handling action", {
304 action,
305 source,
306 webviewId: currentWebViewId,
307 });
308
309 const omnibox = window.__dumber_omnibox;
310
311 switch (action) {
312 case "omnibox-nav-toggle":
313 omnibox?.open?.("omnibox", detail?.query);
314 break;
315 case "omnibox-open":
316 omnibox?.open?.("omnibox", detail?.query);
317 break;
318 case "omnibox-find-toggle":
319 omnibox?.open?.("find", detail?.query);
320 break;
321 case "omnibox-close":
322 omnibox?.close?.();
323 break;
324 default:
325 console.warn("[dumber] Unknown UI shortcut action:", action);
326 }
327 });
328 });
329
330 window.__dumber_toggle = () => {
331 try {
332 console.log("🎯 __dumber_toggle called");
333 console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox);
334 if (window.__dumber_omnibox?.toggle) {
335 console.log("✅ Using Svelte omnibox toggle");
336 window.__dumber_omnibox.toggle();
337 return;
338 }
339
340 throw new Error("Omnibox toggle requested but API is unavailable");
341 } catch (error) {
342 console.error("❌ Error in __dumber_toggle:", error);
343 }
344 };
345
346 window.__dumber_find_open = (query?: string) => {
347 try {
348 if (window.__dumber_omnibox?.open) {
349 window.__dumber_omnibox.open("find", query);
350 return;
351 }
352
353 throw new Error("Omnibox find requested but API is unavailable");
354 } catch (error) {
355 console.error("❌ Error in __dumber_find_open:", error);
356 }
357 };
358
359 window.__dumber_find_close = () => {
360 if (window.__dumber_omnibox?.close) {
361 window.__dumber_omnibox.close();
362 return;
363 }
364
365 const error = new Error("Omnibox close requested but API is unavailable");
366 console.error("❌ Error in __dumber_find_close:", error);
367 };
368
369 window.__dumber_find_query = (query: string) => {
370 if (window.__dumber_omnibox?.findQuery) {
371 window.__dumber_omnibox.findQuery(query);
372 }
373 };
374
375 window.__dumber_gui = {
376 initializeToast: async (config?: ToastConfig) => {
377 return await initializeToast(config);
378 },
379 initializeOmnibox: async (config?: OmniboxInitConfig) => {
380 return await initializeOmnibox(config);
381 },
382 keyboard: keyboardService,
383 isReady: true,
384 };
385
386 window.__dumber_gui_bootstrap = function () {
387 console.log("[gui-bootstrap] Initializing GUI for workspace");
388
389 if (!window.__dumber_omnibox) {
390 console.log(
391 `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`,
392 );
393 } else {
394 console.log(
395 `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`,
396 );
397 window.__dumber_omnibox.setActive(workspaceHasFocus);
398 }
399
400 console.log(
401 `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`,
402 );
403 };
404}