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