1/**
2 * Main-world bridge script for Dumber Browser
3 *
4 * This script is injected into the page-world context to provide:
5 * - Window.open interceptor with debouncing
6 * - Toast notifications bridge
7 * - Theme management
8 * - DOM zoom functionality
9 * - Omnibox suggestions bridge
10 */
11
12// Type definitions for window extensions
13declare global {
14 interface Window {
15 __dumber: DumberAPI;
16 __dumber_page_bridge_installed?: boolean;
17 __dumber_dom_zoom_seed?: number;
18 __dumber_dom_zoom_level?: number;
19 __dumber_initial_theme?: string;
20 __dumber_setTheme?: (theme: "light" | "dark") => void;
21 __dumber_applyPalette?: (theme: "light" | "dark") => void;
22 __dumber_palette?: Record<string, Record<string, string>>;
23 __dumber_color_palettes?: (palettes: unknown) => void;
24 __dumber_color_palettes_error?: (error: string) => void;
25 __dumber_search_shortcuts?: (data: unknown) => void;
26 __dumber_search_shortcuts_error?: (error: string) => void;
27 __dumber_applyDomZoom?: (level: number) => void;
28 __dumber_showToast?: (
29 message: string,
30 duration?: number,
31 type?: "info" | "success" | "error",
32 ) => number | void;
33 __dumber_showZoomToast?: (level: number) => void;
34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
35 __dumber_favorites?: (favorites: unknown[]) => void;
36 __dumber_webview_id?: string | number;
37 __dumber_is_active?: boolean;
38 __dumber_teardown?: () => void;
39 webkit?: {
40 messageHandlers?: {
41 dumber?: {
42 postMessage: (message: string) => void;
43 };
44 };
45 };
46 }
47}
48
49interface Suggestion {
50 // Define proper suggestion types based on your needs
51 [key: string]: unknown;
52}
53
54interface DumberAPI {
55 omnibox: {
56 suggestions: (suggestions: Suggestion[]) => void;
57 };
58}
59
60(() => {
61 try {
62 // Prevent multiple installations
63 if (window.__dumber_page_bridge_installed) {
64 return;
65 }
66 window.__dumber_page_bridge_installed = true;
67
68 const cleanupHandlers: Array<() => void> = [];
69 let teardownExecuted = false;
70
71 // Initialize zoom level placeholder (will be replaced by Go)
72 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
73 window.__dumber_dom_zoom_seed = initialZoom;
74
75 // Initialize WebView ID and active state (injected by Go at document-start)
76 // Only set fallback values if not already injected
77 if (typeof window.__dumber_webview_id === 'undefined') {
78 window.__dumber_webview_id = "__WEBVIEW_ID__";
79 }
80 if (typeof window.__dumber_is_active === 'undefined') {
81 window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
82 }
83
84 // Request color palettes from Go backend
85 try {
86 console.log("[dumber-palette] Initializing color palette loading");
87 const bridge = window.webkit?.messageHandlers?.dumber;
88
89 if (!bridge || typeof bridge.postMessage !== "function") {
90 console.error("[dumber-palette] ERROR: No webkit message bridge available");
91 return;
92 }
93
94 console.log("[dumber-palette] WebKit bridge found, setting up handlers");
95
96 // Set up callback handler before requesting
97 window.__dumber_color_palettes = (palettes: unknown) => {
98 console.log("[dumber-palette] Received palettes from backend:", palettes);
99 try {
100 // Type guard to ensure palettes is a valid object
101 if (typeof palettes !== "object" || palettes === null) {
102 console.error("[dumber-palette] ERROR: Invalid palettes received - type:", typeof palettes);
103 return;
104 }
105
106 window.__dumber_palette = palettes as Record<string, Record<string, string>>;
107 console.log("[dumber-palette] Palette keys:", Object.keys(window.__dumber_palette || {}));
108
109 // Normalize palette keys to lowercase
110 const normalized: Record<string, Record<string, string>> = {};
111 if (window.__dumber_palette) {
112 for (const key in window.__dumber_palette) {
113 if (Object.prototype.hasOwnProperty.call(window.__dumber_palette, key)) {
114 const paletteValue = window.__dumber_palette[key];
115 if (paletteValue) {
116 normalized[key.toLowerCase()] = paletteValue;
117 console.log(`[dumber-palette] Normalized: ${key} -> ${key.toLowerCase()}, colors:`, Object.keys(paletteValue));
118 }
119 }
120 }
121 window.__dumber_palette = normalized;
122 }
123
124 // Apply palette now that we have it
125 const initialTheme: "light" | "dark" = document.documentElement.classList.contains("dark") ? "dark" : "light";
126 console.log(`[dumber-palette] Initial theme detected: ${initialTheme}`);
127 window.__dumber_initial_theme = initialTheme;
128
129 if (window.__dumber_applyPalette) {
130 console.log("[dumber-palette] Applying palette...");
131 window.__dumber_applyPalette(initialTheme);
132 } else {
133 console.error("[dumber-palette] ERROR: __dumber_applyPalette function not found");
134 }
135 } catch (err) {
136 console.error("[dumber-palette] ERROR: Failed to apply received palette:", err);
137 }
138 };
139
140 window.__dumber_color_palettes_error = (error: string) => {
141 console.error("[dumber-palette] ERROR from backend:", error);
142 };
143
144 // Request color palettes
145 console.log("[dumber-palette] Sending get_color_palettes request to backend");
146 bridge.postMessage(JSON.stringify({ type: "get_color_palettes" }));
147 console.log("[dumber-palette] Request sent, waiting for response...");
148 } catch (err) {
149 console.error("[dumber-palette] EXCEPTION requesting color palettes:", err);
150 }
151
152 // Palette application function
153 window.__dumber_applyPalette = (theme: "light" | "dark") => {
154 console.log(`[dumber-palette] applyPalette called with theme: ${theme}`);
155 try {
156 const palette = window.__dumber_palette?.[theme] || window.__dumber_palette?.["light"];
157 if (!palette) {
158 console.error(`[dumber-palette] ERROR: No palette found for theme "${theme}". Available:`, Object.keys(window.__dumber_palette || {}));
159 return;
160 }
161 console.log(`[dumber-palette] Found palette for theme "${theme}":`, palette);
162
163 const tokenMap: Record<string, string> = {
164 background: "--color-browser-bg",
165 surface: "--color-browser-surface",
166 surface_variant: "--color-browser-surface-variant",
167 text: "--color-browser-text",
168 muted: "--color-browser-muted",
169 accent: "--color-browser-accent",
170 border: "--color-browser-border",
171 };
172
173 const dynamicMap: Record<string, string> = {
174 background: "--dynamic-bg",
175 surface: "--dynamic-surface",
176 surface_variant: "--dynamic-surface-variant",
177 text: "--dynamic-text",
178 muted: "--dynamic-muted",
179 accent: "--dynamic-accent",
180 border: "--dynamic-border",
181 };
182
183 const root = document.documentElement;
184 let appliedCount = 0;
185 for (const key in tokenMap) {
186 if (Object.prototype.hasOwnProperty.call(tokenMap, key)) {
187 const colorValue = palette[key];
188 const tokenVar = tokenMap[key];
189 const dynamicVar = dynamicMap[key];
190 if (colorValue && typeof colorValue === "string" && tokenVar) {
191 root.style.setProperty(tokenVar, colorValue);
192 if (dynamicVar) {
193 root.style.setProperty(dynamicVar, colorValue);
194 }
195 console.log(`[dumber-palette] Applied ${key}: ${colorValue} -> ${tokenVar}`);
196 appliedCount++;
197 } else {
198 console.warn(`[dumber-palette] Skipped ${key}: colorValue=${colorValue}, tokenVar=${tokenVar}`);
199 }
200 }
201 }
202
203 console.log(`[dumber-palette] Applied ${appliedCount} colors successfully`);
204 document.dispatchEvent(
205 new CustomEvent("dumber:palette-updated", { detail: { theme } })
206 );
207 } catch (err) {
208 console.error("[dumber-palette] EXCEPTION applying palette:", err);
209 }
210 };
211
212 // Theme setter function for GTK theme integration
213 window.__dumber_setTheme = (theme: "light" | "dark") => {
214 window.__dumber_initial_theme = theme;
215 console.log("[dumber] Setting theme to:", theme);
216 if (theme === "dark") {
217 document.documentElement.classList.add("dark");
218 } else {
219 document.documentElement.classList.remove("dark");
220 }
221 try {
222 if (typeof window.__dumber_applyPalette === "function") {
223 window.__dumber_applyPalette(theme);
224 }
225 document.dispatchEvent(
226 new CustomEvent("dumber:theme-change", { detail: { theme } }),
227 );
228 } catch (error) {
229 console.warn("[dumber] Failed to apply runtime palette", error);
230 }
231 };
232
233 // Initialize unified API object (toast functions are exposed by ToastContainer.svelte)
234 window.__dumber = window.__dumber || ({} as DumberAPI);
235
236 // DOM zoom functionality
237 if (typeof window.__dumber_dom_zoom_level !== "number") {
238 window.__dumber_dom_zoom_level = initialZoom;
239 }
240
241 const applyZoomStyles = (node: HTMLElement, level: number): void => {
242 if (!node) return;
243
244 if (Math.abs(level - 1.0) < 1e-6) {
245 // Reset zoom
246 node.style.removeProperty("zoom");
247 node.style.removeProperty("transform");
248 node.style.removeProperty("transform-origin");
249 node.style.removeProperty("width");
250 node.style.removeProperty("min-width");
251 node.style.removeProperty("height");
252 node.style.removeProperty("min-height");
253 return;
254 }
255
256 const scale = level;
257 const inversePercent = 100 / scale;
258 const widthValue = `${inversePercent}%`;
259
260 node.style.removeProperty("zoom");
261 node.style.transform = `scale(${scale})`;
262 node.style.transformOrigin = "0 0";
263 node.style.width = widthValue;
264 node.style.minWidth = widthValue;
265 node.style.minHeight = "100%";
266 };
267
268 window.__dumber_applyDomZoom = (level: number) => {
269 try {
270 window.__dumber_dom_zoom_level = level;
271 window.__dumber_dom_zoom_seed = level;
272 applyZoomStyles(document.documentElement, level);
273 if (document.body) {
274 applyZoomStyles(document.body, level);
275 }
276 } catch (err) {
277 console.error("[dumber] DOM zoom error", err);
278 }
279 };
280
281 // Apply initial zoom
282 window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
283
284 if (!document.body) {
285 document.addEventListener(
286 "DOMContentLoaded",
287 () => {
288 if (typeof window.__dumber_dom_zoom_level === "number") {
289 window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level);
290 }
291 },
292 { once: true },
293 );
294 }
295
296 /**
297 * ============================================================================
298 * MAIN-WORLD TO ISOLATED-WORLD BRIDGE SYSTEM
299 * ============================================================================
300 *
301 * Architecture Overview:
302 * ----------------------
303 * WebKit runs scripts in different "worlds" for security:
304 *
305 * - MAIN WORLD: Where page scripts run
306 * - Can access window.webkit.messageHandlers (backend communication)
307 * - Can be accessed/modified by page JavaScript
308 * - Security risk: page can interfere with our code
309 *
310 * - ISOLATED WORLD: Where our UI components run (Svelte)
311 * - Cannot access window.webkit.messageHandlers directly
312 * - Protected from page JavaScript interference
313 * - Security benefit: page cannot read/modify our UI state
314 *
315 * Communication Patterns:
316 * -----------------------
317 *
318 * 1. WINDOW FUNCTIONS (require bridge):
319 * Backend → window.__dumber_xxx() in main world
320 * Main world → CustomEvent to isolated world
321 * Example: __dumber_omnibox_suggestions(), __dumber_favorites()
322 *
323 * 2. CUSTOMEVENT DISPATCH (no bridge needed):
324 * Backend → document.dispatchEvent() directly
325 * Both worlds can listen (shared DOM)
326 * Example: dumber:ui:shortcut (keyboard shortcuts)
327 *
328 * 3. ISOLATED TO BACKEND (reverse bridge):
329 * Isolated world → CustomEvent "dumber:isolated-message"
330 * Main world listener → window.webkit.messageHandlers.dumber
331 * Example: navigation, omnibox queries
332 *
333 * ============================================================================
334 */
335
336 /**
337 * Unified dispatcher for forwarding main-world window calls to isolated world
338 * Only use this for window function bridges, NOT for events backend already dispatches
339 */
340 const bridgeDispatch = (eventName: string, detail: Record<string, unknown>) => {
341 try {
342 document.dispatchEvent(new CustomEvent(eventName, { detail }));
343 } catch (err) {
344 console.error(`[dumber-bridge] Failed to dispatch ${eventName}:`, err);
345 }
346 };
347
348 // ============================================================================
349 // WINDOW FUNCTION BRIDGES
350 // These convert window.__dumber_xxx() calls to CustomEvents for isolated world
351 // ============================================================================
352
353 // Omnibox suggestions bridge (queues suggestions until component is ready)
354 const omniboxQueue: Suggestion[] = [];
355 let omniboxReady = false;
356
357 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
358 if (omniboxReady) {
359 bridgeDispatch("dumber:omnibox:suggestions", { suggestions });
360 bridgeDispatch("dumber:omnibox-suggestions", { suggestions }); // Legacy support
361 } else {
362 try {
363 omniboxQueue.push(...suggestions);
364 } catch (err) {
365 console.error("[dumber-bridge] Omnibox queue error", err);
366 }
367 }
368 };
369
370 // Favorites bridge
371 window.__dumber_favorites = (favorites: unknown[]) => {
372 bridgeDispatch("dumber:favorites", { favorites });
373 };
374
375 // Toast notification bridges
376 window.__dumber_showToast = (message: string, duration?: number, type?: "info" | "success" | "error") => {
377 bridgeDispatch("dumber:toast", { message, duration, type });
378 };
379
380 window.__dumber_showZoomToast = (level: number) => {
381 bridgeDispatch("dumber:toast:zoom", { level });
382 };
383
384 // Handle omnibox ready signal and flush queued suggestions
385 const handleOmniboxReady = () => {
386 omniboxReady = true;
387 if (omniboxQueue && omniboxQueue.length) {
388 const items = omniboxQueue.slice();
389 omniboxQueue.length = 0;
390 items.forEach((s) => {
391 bridgeDispatch("dumber:omnibox:suggestions", { suggestions: [s] });
392 bridgeDispatch("dumber:omnibox-suggestions", { suggestions: [s] });
393 });
394 }
395 };
396
397 document.addEventListener("dumber:omnibox-ready", handleOmniboxReady);
398 cleanupHandlers.push(() => {
399 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
400 });
401
402 // Unified omnibox API for backward compatibility
403 window.__dumber.omnibox = window.__dumber.omnibox || {
404 suggestions: (suggestions: Suggestion[]) => {
405 window.__dumber_omnibox_suggestions!(suggestions);
406 },
407 };
408
409 let popupMessageInterval: ReturnType<typeof setInterval> | null = null;
410 let oauthUrlCheckInterval: ReturnType<typeof setInterval> | null = null;
411
412 // Popup window.opener bridge using shared localStorage
413 const setupPopupOpenerBridge = () => {
414 try {
415 // Check if this is a popup by looking for parent info in localStorage
416 const findParentPopupId = () => {
417 const keys = Object.keys(localStorage);
418 for (const key of keys) {
419 if (key.startsWith("popup_") && key.endsWith("_parent_info")) {
420 const popupId = key
421 .replace("popup_", "")
422 .replace("_parent_info", "");
423 const parentInfo = JSON.parse(localStorage.getItem(key) || "{}");
424
425 // Check if this popup info is recent (within last 30 seconds)
426 const age = Date.now() - (parentInfo.timestamp || 0);
427 if (age < 30000) {
428 console.log(
429 `[dumber-popup] Found parent info for popup ID: ${popupId}`,
430 parentInfo,
431 );
432 return { popupId, parentInfo };
433 }
434 }
435 }
436 return null;
437 };
438
439 const parentData = findParentPopupId();
440 if (parentData && !window.opener) {
441 const { popupId, parentInfo } = parentData;
442
443 console.log(
444 `[dumber-popup] Setting up window.opener bridge for popup ID: ${popupId}`,
445 );
446
447 // Create window.opener proxy that uses localStorage for communication
448 window.opener = {
449 postMessage: (data: unknown, targetOrigin?: string) => {
450 try {
451 localStorage.setItem(
452 `popup_${popupId}_message_to_parent`,
453 JSON.stringify({
454 data,
455 origin: targetOrigin || "*",
456 timestamp: Date.now(),
457 source: "popup",
458 }),
459 );
460 console.log(
461 `[dumber-popup] Sent message to parent via localStorage:`,
462 data,
463 );
464 } catch (err) {
465 console.warn(
466 `[dumber-popup] Failed to send message to parent:`,
467 err,
468 );
469 }
470 },
471
472 focus: () => {
473 try {
474 localStorage.setItem(
475 `popup_${popupId}_parent_action`,
476 JSON.stringify({
477 action: "focus",
478 timestamp: Date.now(),
479 }),
480 );
481 } catch (err) {
482 console.warn(
483 `[dumber-popup] Failed to request parent focus:`,
484 err,
485 );
486 }
487 },
488
489 blur: () => {
490 try {
491 localStorage.setItem(
492 `popup_${popupId}_parent_action`,
493 JSON.stringify({
494 action: "blur",
495 timestamp: Date.now(),
496 }),
497 );
498 } catch (err) {
499 console.warn(
500 `[dumber-popup] Failed to request parent blur:`,
501 err,
502 );
503 }
504 },
505
506 location: {
507 href: parentInfo.parentUrl || "",
508 origin: new URL(parentInfo.parentUrl || "about:blank").origin,
509 },
510
511 closed: false,
512
513 // Support for custom properties/methods that websites might use
514 [Symbol.for("popup.bridge")]: true,
515 } as unknown as Window;
516
517 // Set up message polling to receive messages from parent
518 const pollForParentMessages = () => {
519 try {
520 const messageKey = `popup_${popupId}_message_to_popup`;
521 const messageData = localStorage.getItem(messageKey);
522
523 if (messageData) {
524 const { data, origin, timestamp } = JSON.parse(messageData);
525
526 // Check if message is recent (within last 5 seconds)
527 if (Date.now() - timestamp < 5000) {
528 console.log(
529 `[dumber-popup] Received message from parent:`,
530 data,
531 );
532
533 // Dispatch as MessageEvent to window
534 const event = new MessageEvent("message", {
535 data,
536 origin: origin || parentInfo.parentUrl || "",
537 source: window.opener,
538 });
539 window.dispatchEvent(event);
540 }
541
542 // Clean up message
543 localStorage.removeItem(messageKey);
544 }
545 } catch (err) {
546 console.warn(
547 `[dumber-popup] Failed to poll for parent messages:`,
548 err,
549 );
550 }
551 };
552
553 // Poll for messages every 100ms
554 popupMessageInterval = setInterval(pollForParentMessages, 100);
555 cleanupHandlers.push(() => {
556 if (popupMessageInterval) {
557 clearInterval(popupMessageInterval);
558 popupMessageInterval = null;
559 }
560 });
561
562 console.log(
563 `[dumber-popup] window.opener bridge established successfully`,
564 );
565 }
566 } catch (err) {
567 console.warn(
568 `[dumber-popup] Failed to setup window.opener bridge:`,
569 err,
570 );
571 }
572 };
573
574 // Setup popup bridge if this appears to be a popup
575 setupPopupOpenerBridge();
576
577 // Parent window message polling - only for OAuth scenarios
578 let parentPollingInterval: ReturnType<typeof setInterval> | null = null;
579 let parentPollingHeartbeat: ReturnType<typeof setInterval> | null = null;
580
581 const setupParentMessagePolling = () => {
582 // Only start polling if we have popup mappings or OAuth callbacks
583 const hasRelevantData = () => {
584 try {
585 const keys = Object.keys(localStorage);
586 return keys.some(
587 (key) =>
588 key.startsWith("popup_mapping_") ||
589 key.startsWith("oauth_callback_") ||
590 key.includes("message_to_parent"),
591 );
592 } catch {
593 return false;
594 }
595 };
596
597 const pollForPopupMessages = () => {
598 try {
599 // Stop polling if no relevant data exists
600 if (!hasRelevantData()) {
601 return;
602 }
603
604 const keys = Object.keys(localStorage);
605
606 for (const key of keys) {
607 // Handle popup messages to parent
608 if (key.includes("message_to_parent")) {
609 const messageData = localStorage.getItem(key);
610 if (!messageData) continue;
611
612 try {
613 const { data, origin, timestamp, source } =
614 JSON.parse(messageData);
615
616 // Check if message is recent (within last 5 seconds) and from a popup
617 if (Date.now() - timestamp < 5000 && source === "popup") {
618 console.log(
619 `[dumber-parent] Received message from popup:`,
620 data,
621 );
622
623 // Dispatch as MessageEvent to parent window
624 const event = new MessageEvent("message", {
625 data,
626 origin: origin || window.location.origin,
627 source: null, // popup reference would be here in real browser
628 });
629 window.dispatchEvent(event);
630
631 // Clean up message
632 localStorage.removeItem(key);
633 }
634 } catch (err) {
635 console.warn(
636 `[dumber-parent] Failed to parse popup message:`,
637 err,
638 );
639 localStorage.removeItem(key); // Clean up invalid message
640 }
641 }
642
643 // Handle OAuth callback detection from popups
644 if (key.startsWith("oauth_callback_")) {
645 const callbackData = localStorage.getItem(key);
646 if (!callbackData) continue;
647
648 try {
649 const { url, webviewId, timestamp, isOAuthCallback } =
650 JSON.parse(callbackData);
651
652 // Check if callback is recent (within last 10 seconds) and is an OAuth callback
653 if (Date.now() - timestamp < 10000 && isOAuthCallback) {
654 console.log(
655 `[dumber-parent] OAuth callback detected for webview ${webviewId}:`,
656 url,
657 );
658
659 // Send close request to backend for this popup webview
660 const bridge = window.webkit?.messageHandlers?.dumber;
661 if (bridge && typeof bridge.postMessage === "function") {
662 try {
663 const closeMessage = {
664 type: "close-popup",
665 webviewId,
666 reason: "oauth-callback-success",
667 timestamp: Date.now(),
668 };
669
670 console.log(
671 `[dumber-parent] Sending popup close request:`,
672 closeMessage,
673 );
674 bridge.postMessage(JSON.stringify(closeMessage));
675 } catch (err) {
676 console.warn(
677 `[dumber-parent] Failed to send popup close request:`,
678 err,
679 );
680 }
681 } else {
682 console.warn(
683 `[dumber-parent] No webkit bridge available for popup close request`,
684 );
685 }
686
687 // Clean up OAuth callback data
688 localStorage.removeItem(key);
689 }
690 } catch (err) {
691 console.warn(
692 `[dumber-parent] Failed to parse OAuth callback data:`,
693 err,
694 );
695 localStorage.removeItem(key); // Clean up invalid data
696 }
697 }
698 }
699 } catch (err) {
700 console.warn(
701 `[dumber-parent] Failed to poll for popup messages:`,
702 err,
703 );
704 }
705 };
706
707 // Start polling only when needed
708 const startPollingForOAuthCallbacks = () => {
709 if (!parentPollingInterval && hasRelevantData()) {
710 console.log(`[dumber-parent] Starting OAuth/popup message polling`);
711 parentPollingInterval = setInterval(pollForPopupMessages, 100);
712 cleanupHandlers.push(() => {
713 if (parentPollingInterval) {
714 clearInterval(parentPollingInterval);
715 parentPollingInterval = null;
716 }
717 });
718 }
719 };
720
721 // Check periodically if polling should start
722 parentPollingHeartbeat = setInterval(() => {
723 if (hasRelevantData()) {
724 startPollingForOAuthCallbacks();
725 }
726 }, 1000);
727 cleanupHandlers.push(() => {
728 if (parentPollingHeartbeat) {
729 clearInterval(parentPollingHeartbeat);
730 parentPollingHeartbeat = null;
731 }
732 });
733 };
734
735 // Setup conditional parent message polling
736 setupParentMessagePolling();
737
738 // OAuth callback detection for auto-close
739 const setupOAuthCallbackDetection = () => {
740 let oauthCallbackHandled = false;
741
742 const detectOAuthCallback = () => {
743 if (oauthCallbackHandled) {
744 return;
745 }
746
747 const url = window.location.href.toLowerCase();
748
749 // Check for OAuth callback patterns - be more specific to avoid false positives
750 const isCallback =
751 // OAuth callback URLs typically have these parameters
752 url.includes("code=") ||
753 url.includes("access_token=") ||
754 url.includes("id_token=") ||
755 // OAuth error responses
756 url.includes("error=access_denied") ||
757 url.includes("error=unauthorized") ||
758 // OAuth callback paths (more specific than just "callback" or "redirect")
759 url.includes("/oauth/callback") ||
760 url.includes("/auth/callback") ||
761 url.includes("oauth2callback") ||
762 url.includes("googlepopupcallback");
763
764 if (isCallback) {
765 oauthCallbackHandled = true;
766 try {
767 // Determine which WebView should close as part of this callback
768 let targetWebViewId = window.__dumber_webview_id;
769
770 // When the parent window detects the callback, look up its popup mapping
771 const popupMappingKey = `popup_mapping_${window.__dumber_webview_id}`;
772 try {
773 const popupMappingData = localStorage.getItem(popupMappingKey);
774 if (popupMappingData) {
775 const mapping = JSON.parse(popupMappingData);
776 const age = Date.now() - (mapping.timestamp || 0);
777 if (age < 60000 && mapping.popupId) {
778 targetWebViewId = mapping.popupId;
779 console.log(
780 `[oauth-callback] Parent detected OAuth callback, targeting popup webview: ${targetWebViewId}`,
781 );
782 localStorage.removeItem(popupMappingKey);
783 }
784 }
785 } catch (err) {
786 console.warn(
787 `[oauth-callback] Failed to resolve popup mapping:`,
788 err,
789 );
790 }
791
792 console.log(
793 `[oauth-callback] Detected OAuth callback, closing popup webview: ${targetWebViewId}`,
794 );
795
796 // Send close request to backend for this popup webview
797 const bridge = window.webkit?.messageHandlers?.dumber;
798 if (bridge && typeof bridge.postMessage === "function") {
799 try {
800 const closeMessage = {
801 type: "close-popup",
802 webviewId: targetWebViewId,
803 reason: "oauth-callback-success",
804 timestamp: Date.now(),
805 };
806
807 console.log(
808 `[oauth-callback] Sending popup close request:`,
809 closeMessage,
810 );
811 bridge.postMessage(JSON.stringify(closeMessage));
812 } catch (err) {
813 console.warn(
814 `[oauth-callback] Failed to send popup close request:`,
815 err,
816 );
817 }
818 } else {
819 console.warn(
820 `[oauth-callback] No webkit bridge available for popup close request`,
821 );
822 }
823 } catch (err) {
824 console.warn(
825 `[oauth-callback] Failed to handle OAuth callback:`,
826 err,
827 );
828 }
829 }
830 };
831
832 // Check immediately
833 detectOAuthCallback();
834
835 // Monitor for URL changes (for SPAs that don't reload)
836 let lastUrl = window.location.href;
837 oauthUrlCheckInterval = setInterval(() => {
838 if (window.location.href !== lastUrl) {
839 lastUrl = window.location.href;
840 detectOAuthCallback();
841 }
842 }, 500);
843 cleanupHandlers.push(() => {
844 if (oauthUrlCheckInterval) {
845 clearInterval(oauthUrlCheckInterval);
846 oauthUrlCheckInterval = null;
847 }
848 });
849
850 // Also check on navigation events
851 window.addEventListener("popstate", detectOAuthCallback);
852 window.addEventListener("hashchange", detectOAuthCallback);
853 cleanupHandlers.push(() => {
854 window.removeEventListener("popstate", detectOAuthCallback);
855 window.removeEventListener("hashchange", detectOAuthCallback);
856 });
857 };
858
859 // Setup OAuth callback detection
860 setupOAuthCallbackDetection();
861
862 const teardown = () => {
863 if (teardownExecuted) {
864 return;
865 }
866 teardownExecuted = true;
867
868 while (cleanupHandlers.length) {
869 const cleanup = cleanupHandlers.pop();
870 if (!cleanup) {
871 continue;
872 }
873 try {
874 cleanup();
875 } catch (cleanupErr) {
876 console.warn("[dumber] Cleanup handler failed", cleanupErr);
877 }
878 }
879
880 omniboxQueue.length = 0;
881 omniboxReady = false;
882
883 window.__dumber_page_bridge_installed = false;
884 window.__dumber_teardown = undefined;
885 window.__dumber_omnibox_suggestions = undefined;
886 window.__dumber_favorites = undefined;
887 window.__dumber_showToast = undefined;
888 window.__dumber_showZoomToast = undefined;
889 window.__dumber_applyDomZoom = undefined;
890
891 const globalState = window.__dumber;
892 if (globalState) {
893 const mutableState = globalState as unknown as Record<string, unknown>;
894 delete mutableState.omnibox;
895 }
896 };
897
898 window.__dumber_teardown = teardown;
899 window.addEventListener("pagehide", teardown, { once: true });
900 window.addEventListener("beforeunload", teardown, { once: true });
901 cleanupHandlers.push(() => {
902 window.removeEventListener("pagehide", teardown);
903 window.removeEventListener("beforeunload", teardown);
904 window.__dumber_teardown = undefined;
905 });
906
907 // Console capture functionality
908 const setupConsoleCapture = () => {
909 const originalConsole = {
910 log: console.log,
911 warn: console.warn,
912 error: console.error,
913 info: console.info,
914 debug: console.debug,
915 };
916
917 const sendConsoleMessage = (level: string, args: unknown[]) => {
918 try {
919 const message = args
920 .map((arg) =>
921 typeof arg === "object" ? JSON.stringify(arg) : String(arg),
922 )
923 .join(" ");
924
925 const formattedMessage = `${window.location.href} ${message}`;
926
927 // Send to Go via existing message handler
928 if (window.webkit?.messageHandlers?.dumber) {
929 window.webkit.messageHandlers.dumber.postMessage(
930 JSON.stringify({
931 type: "console-message",
932 payload: {
933 level,
934 message: formattedMessage,
935 url: window.location.href,
936 webviewId: window.__dumber_webview_id,
937 },
938 }),
939 );
940 }
941 } catch {
942 // Silently ignore errors in console capture to avoid infinite loops
943 }
944 };
945
946 const createConsoleWrapper = (
947 originalMethod: (...args: unknown[]) => void,
948 level: string,
949 ) => {
950 return function (...args: unknown[]) {
951 originalMethod.apply(console, args);
952 sendConsoleMessage(level, args);
953 };
954 };
955
956 // Override console methods to capture messages
957 console.log = createConsoleWrapper(originalConsole.log, "LOG");
958 console.warn = createConsoleWrapper(originalConsole.warn, "WARN");
959 console.error = createConsoleWrapper(originalConsole.error, "ERROR");
960 console.info = createConsoleWrapper(originalConsole.info, "INFO");
961 console.debug = createConsoleWrapper(originalConsole.debug, "DEBUG");
962
963 cleanupHandlers.push(() => {
964 console.log = originalConsole.log;
965 console.warn = originalConsole.warn;
966 console.error = originalConsole.error;
967 console.info = originalConsole.info;
968 console.debug = originalConsole.debug;
969 });
970 };
971
972 // Bridge for isolated world communication
973 // Listen for messages from isolated world and forward to backend via webkit.messageHandlers
974 const setupIsolatedWorldBridge = () => {
975 document.addEventListener("dumber:isolated-message", ((event: CustomEvent) => {
976 try {
977 const { payload } = event.detail;
978 if (window.webkit?.messageHandlers?.dumber) {
979 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify(payload));
980 console.log("[dumber-bridge] Forwarded isolated world message:", payload.type);
981 } else {
982 console.warn("[dumber-bridge] webkit message handler not available");
983 }
984 } catch (error) {
985 console.error("[dumber-bridge] Failed to forward message:", error);
986 }
987 }) as EventListener);
988
989 console.log("[dumber-bridge] Isolated world message bridge initialized");
990 };
991
992 // Initialize isolated world bridge
993 setupIsolatedWorldBridge();
994
995 // Initialize console capture
996 setupConsoleCapture();
997 } catch (err) {
998 console.warn("[dumber] unified bridge init failed", err);
999 }
1000})();