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_window_open_intercepted?: boolean;
18 __dumber_dom_zoom_seed?: number;
19 __dumber_dom_zoom_level?: number;
20 __dumber_initial_theme?: string;
21 __dumber_setTheme?: (theme: "light" | "dark") => void;
22 __dumber_applyDomZoom?: (level: number) => void;
23 __dumber_showToast?: (
24 message: string,
25 duration?: number,
26 type?: "info" | "success" | "error",
27 ) => number | void;
28 __dumber_showZoomToast?: (level: number) => void;
29 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
30 __dumber_webview_id?: string;
31 __dumber_is_active?: boolean;
32 __dumber_teardown?: () => void;
33 webkit?: {
34 messageHandlers?: {
35 dumber?: {
36 postMessage: (message: string) => void;
37 };
38 };
39 };
40 }
41}
42
43interface WindowIntent {
44 url: string;
45 target: string;
46 features: string;
47 timestamp: number;
48 userTriggered: boolean;
49 requestId: string;
50 windowType: string; // "tab", "popup", "unknown"
51}
52
53interface Suggestion {
54 // Define proper suggestion types based on your needs
55 [key: string]: unknown;
56}
57
58interface DumberAPI {
59 toast: {
60 show: (
61 message: string,
62 duration?: number,
63 type?: "info" | "success" | "error",
64 ) => void;
65 zoom: (level: number) => void;
66 };
67 omnibox: {
68 suggestions: (suggestions: Suggestion[]) => void;
69 };
70}
71
72// Function to detect if window.open call is for a popup based on features
73function detectWindowType(features?: string | null): string {
74 if (!features) {
75 return "tab"; // Default to tab if no features specified
76 }
77
78 const featuresStr = features.toLowerCase();
79
80 // Check for typical popup characteristics
81 const hasSize = /width=\d+|height=\d+/.test(featuresStr);
82 const hasNoToolbar = /toolbar=0|toolbar=no/.test(featuresStr);
83 const hasNoMenubar = /menubar=0|menubar=no/.test(featuresStr);
84 const hasNoLocation = /location=0|location=no/.test(featuresStr);
85
86 // OAuth/login popups typically have size constraints and disabled UI elements
87 const popupIndicators = [
88 hasSize,
89 hasNoToolbar,
90 hasNoMenubar,
91 hasNoLocation,
92 ].filter(Boolean).length;
93
94 // If 2 or more popup indicators are present, treat as popup
95 if (popupIndicators >= 2) {
96 return "popup";
97 }
98
99 return "tab";
100}
101
102(() => {
103 try {
104 // Prevent multiple installations
105 if (window.__dumber_page_bridge_installed) {
106 return;
107 }
108 window.__dumber_page_bridge_installed = true;
109
110 const cleanupHandlers: Array<() => void> = [];
111 let teardownExecuted = false;
112
113 // Initialize zoom level placeholder (will be replaced by Go)
114 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
115 window.__dumber_dom_zoom_seed = initialZoom;
116
117 // Initialize WebView ID and active state (will be replaced by Go)
118 window.__dumber_webview_id = "__WEBVIEW_ID__";
119 window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
120
121 // Theme setter function for GTK theme integration
122 window.__dumber_setTheme = (theme: "light" | "dark") => {
123 window.__dumber_initial_theme = theme;
124 console.log("[dumber] Setting theme to:", theme);
125 if (theme === "dark") {
126 document.documentElement.classList.add("dark");
127 } else {
128 document.documentElement.classList.remove("dark");
129 }
130 };
131
132 // Initialize unified API object
133 window.__dumber = window.__dumber || ({} as DumberAPI);
134
135 // Toast bridge
136 window.__dumber.toast = window.__dumber.toast || {
137 show: (message: string, duration?: number, type?: string) => {
138 try {
139 document.dispatchEvent(
140 new CustomEvent("dumber:toast:show", {
141 detail: { message, duration, type },
142 }),
143 );
144 // Legacy compatibility
145 document.dispatchEvent(
146 new CustomEvent("dumber:showToast", {
147 detail: { message, duration, type },
148 }),
149 );
150 } catch {
151 // ignore
152 }
153 },
154 zoom: (level: number) => {
155 try {
156 document.dispatchEvent(
157 new CustomEvent("dumber:toast:zoom", {
158 detail: { level },
159 }),
160 );
161 // Legacy compatibility
162 document.dispatchEvent(
163 new CustomEvent("dumber:showZoomToast", {
164 detail: { level },
165 }),
166 );
167 } catch {
168 // ignore
169 }
170 },
171 };
172
173 // Legacy toast helpers
174 window.__dumber_showToast = (
175 message: string,
176 duration?: number,
177 type?: "info" | "success" | "error",
178 ) => {
179 window.__dumber.toast.show(message, duration, type);
180 };
181 window.__dumber_showZoomToast = (level: number) => {
182 window.__dumber.toast.zoom(level);
183 };
184
185 // DOM zoom functionality
186 if (typeof window.__dumber_dom_zoom_level !== "number") {
187 window.__dumber_dom_zoom_level = initialZoom;
188 }
189
190 const applyZoomStyles = (node: HTMLElement, level: number): void => {
191 if (!node) return;
192
193 if (Math.abs(level - 1.0) < 1e-6) {
194 // Reset zoom
195 node.style.removeProperty("zoom");
196 node.style.removeProperty("transform");
197 node.style.removeProperty("transform-origin");
198 node.style.removeProperty("width");
199 node.style.removeProperty("min-width");
200 node.style.removeProperty("height");
201 node.style.removeProperty("min-height");
202 return;
203 }
204
205 const scale = level;
206 const inversePercent = 100 / scale;
207 const widthValue = `${inversePercent}%`;
208
209 node.style.removeProperty("zoom");
210 node.style.transform = `scale(${scale})`;
211 node.style.transformOrigin = "0 0";
212 node.style.width = widthValue;
213 node.style.minWidth = widthValue;
214 node.style.minHeight = "100%";
215 };
216
217 window.__dumber_applyDomZoom = (level: number) => {
218 try {
219 window.__dumber_dom_zoom_level = level;
220 window.__dumber_dom_zoom_seed = level;
221 applyZoomStyles(document.documentElement, level);
222 if (document.body) {
223 applyZoomStyles(document.body, level);
224 }
225 } catch (err) {
226 console.error("[dumber] DOM zoom error", err);
227 }
228 };
229
230 // Apply initial zoom
231 window.__dumber_applyDomZoom(window.__dumber_dom_zoom_level);
232
233 if (!document.body) {
234 document.addEventListener(
235 "DOMContentLoaded",
236 () => {
237 if (typeof window.__dumber_dom_zoom_level === "number") {
238 window.__dumber_applyDomZoom!(window.__dumber_dom_zoom_level);
239 }
240 },
241 { once: true },
242 );
243 }
244
245 // Omnibox suggestions bridge
246 const omniboxQueue: Suggestion[] = [];
247 let omniboxReady = false;
248
249 const omniboxDispatch = (suggestions: Suggestion[]) => {
250 try {
251 document.dispatchEvent(
252 new CustomEvent("dumber:omnibox:suggestions", {
253 detail: { suggestions },
254 }),
255 );
256 // Legacy compatibility
257 document.dispatchEvent(
258 new CustomEvent("dumber:omnibox-suggestions", {
259 detail: { suggestions },
260 }),
261 );
262 } catch (err) {
263 console.error("[dumber] Omnibox dispatch error", err);
264 }
265 };
266
267 window.__dumber_omnibox_suggestions = (suggestions: Suggestion[]) => {
268 if (omniboxReady) {
269 omniboxDispatch(suggestions);
270 } else {
271 try {
272 omniboxQueue.push(...suggestions);
273 } catch (err) {
274 console.error("[dumber] Omnibox queue error", err);
275 }
276 }
277 };
278
279 const handleOmniboxReady = () => {
280 omniboxReady = true;
281 if (omniboxQueue && omniboxQueue.length) {
282 const items = omniboxQueue.slice();
283 omniboxQueue.length = 0;
284 items.forEach((s) => omniboxDispatch([s]));
285 }
286 };
287
288 document.addEventListener("dumber:omnibox-ready", handleOmniboxReady);
289 cleanupHandlers.push(() => {
290 document.removeEventListener("dumber:omnibox-ready", handleOmniboxReady);
291 });
292
293 // Unified omnibox API
294 window.__dumber.omnibox = window.__dumber.omnibox || {
295 suggestions: (suggestions: Suggestion[]) => {
296 window.__dumber_omnibox_suggestions!(suggestions);
297 },
298 };
299
300 let popupMessageInterval: ReturnType<typeof setInterval> | null = null;
301 let oauthUrlCheckInterval: ReturnType<typeof setInterval> | null = null;
302
303 // Popup window.opener bridge using shared localStorage
304 const setupPopupOpenerBridge = () => {
305 try {
306 // Check if this is a popup by looking for parent info in localStorage
307 const findParentPopupId = () => {
308 const keys = Object.keys(localStorage);
309 for (const key of keys) {
310 if (key.startsWith("popup_") && key.endsWith("_parent_info")) {
311 const popupId = key
312 .replace("popup_", "")
313 .replace("_parent_info", "");
314 const parentInfo = JSON.parse(localStorage.getItem(key) || "{}");
315
316 // Check if this popup info is recent (within last 30 seconds)
317 const age = Date.now() - (parentInfo.timestamp || 0);
318 if (age < 30000) {
319 console.log(
320 `[dumber-popup] Found parent info for popup ID: ${popupId}`,
321 parentInfo,
322 );
323 return { popupId, parentInfo };
324 }
325 }
326 }
327 return null;
328 };
329
330 const parentData = findParentPopupId();
331 if (parentData && !window.opener) {
332 const { popupId, parentInfo } = parentData;
333
334 console.log(
335 `[dumber-popup] Setting up window.opener bridge for popup ID: ${popupId}`,
336 );
337
338 // Create window.opener proxy that uses localStorage for communication
339 window.opener = {
340 postMessage: (data: unknown, targetOrigin?: string) => {
341 try {
342 localStorage.setItem(
343 `popup_${popupId}_message_to_parent`,
344 JSON.stringify({
345 data,
346 origin: targetOrigin || "*",
347 timestamp: Date.now(),
348 source: "popup",
349 }),
350 );
351 console.log(
352 `[dumber-popup] Sent message to parent via localStorage:`,
353 data,
354 );
355 } catch (err) {
356 console.warn(
357 `[dumber-popup] Failed to send message to parent:`,
358 err,
359 );
360 }
361 },
362
363 focus: () => {
364 try {
365 localStorage.setItem(
366 `popup_${popupId}_parent_action`,
367 JSON.stringify({
368 action: "focus",
369 timestamp: Date.now(),
370 }),
371 );
372 } catch (err) {
373 console.warn(
374 `[dumber-popup] Failed to request parent focus:`,
375 err,
376 );
377 }
378 },
379
380 blur: () => {
381 try {
382 localStorage.setItem(
383 `popup_${popupId}_parent_action`,
384 JSON.stringify({
385 action: "blur",
386 timestamp: Date.now(),
387 }),
388 );
389 } catch (err) {
390 console.warn(
391 `[dumber-popup] Failed to request parent blur:`,
392 err,
393 );
394 }
395 },
396
397 location: {
398 href: parentInfo.parentUrl || "",
399 origin: new URL(parentInfo.parentUrl || "about:blank").origin,
400 },
401
402 closed: false,
403
404 // Support for custom properties/methods that websites might use
405 [Symbol.for("popup.bridge")]: true,
406 } as unknown as Window;
407
408 // Set up message polling to receive messages from parent
409 const pollForParentMessages = () => {
410 try {
411 const messageKey = `popup_${popupId}_message_to_popup`;
412 const messageData = localStorage.getItem(messageKey);
413
414 if (messageData) {
415 const { data, origin, timestamp } = JSON.parse(messageData);
416
417 // Check if message is recent (within last 5 seconds)
418 if (Date.now() - timestamp < 5000) {
419 console.log(
420 `[dumber-popup] Received message from parent:`,
421 data,
422 );
423
424 // Dispatch as MessageEvent to window
425 const event = new MessageEvent("message", {
426 data,
427 origin: origin || parentInfo.parentUrl || "",
428 source: window.opener,
429 });
430 window.dispatchEvent(event);
431 }
432
433 // Clean up message
434 localStorage.removeItem(messageKey);
435 }
436 } catch (err) {
437 console.warn(
438 `[dumber-popup] Failed to poll for parent messages:`,
439 err,
440 );
441 }
442 };
443
444 // Poll for messages every 100ms
445 popupMessageInterval = setInterval(pollForParentMessages, 100);
446 cleanupHandlers.push(() => {
447 if (popupMessageInterval) {
448 clearInterval(popupMessageInterval);
449 popupMessageInterval = null;
450 }
451 });
452
453 console.log(
454 `[dumber-popup] window.opener bridge established successfully`,
455 );
456 }
457 } catch (err) {
458 console.warn(
459 `[dumber-popup] Failed to setup window.opener bridge:`,
460 err,
461 );
462 }
463 };
464
465 // Setup popup bridge if this appears to be a popup
466 setupPopupOpenerBridge();
467
468 // Parent window message polling - only for OAuth scenarios
469 let parentPollingInterval: ReturnType<typeof setInterval> | null = null;
470 let parentPollingHeartbeat: ReturnType<typeof setInterval> | null = null;
471
472 const setupParentMessagePolling = () => {
473 // Only start polling if we have popup mappings or OAuth callbacks
474 const hasRelevantData = () => {
475 try {
476 const keys = Object.keys(localStorage);
477 return keys.some(
478 (key) =>
479 key.startsWith("popup_mapping_") ||
480 key.startsWith("oauth_callback_") ||
481 key.includes("message_to_parent"),
482 );
483 } catch {
484 return false;
485 }
486 };
487
488 const pollForPopupMessages = () => {
489 try {
490 // Stop polling if no relevant data exists
491 if (!hasRelevantData()) {
492 return;
493 }
494
495 const keys = Object.keys(localStorage);
496
497 for (const key of keys) {
498 // Handle popup messages to parent
499 if (key.includes("message_to_parent")) {
500 const messageData = localStorage.getItem(key);
501 if (!messageData) continue;
502
503 try {
504 const { data, origin, timestamp, source } =
505 JSON.parse(messageData);
506
507 // Check if message is recent (within last 5 seconds) and from a popup
508 if (Date.now() - timestamp < 5000 && source === "popup") {
509 console.log(
510 `[dumber-parent] Received message from popup:`,
511 data,
512 );
513
514 // Dispatch as MessageEvent to parent window
515 const event = new MessageEvent("message", {
516 data,
517 origin: origin || window.location.origin,
518 source: null, // popup reference would be here in real browser
519 });
520 window.dispatchEvent(event);
521
522 // Clean up message
523 localStorage.removeItem(key);
524 }
525 } catch (err) {
526 console.warn(
527 `[dumber-parent] Failed to parse popup message:`,
528 err,
529 );
530 localStorage.removeItem(key); // Clean up invalid message
531 }
532 }
533
534 // Handle OAuth callback detection from popups
535 if (key.startsWith("oauth_callback_")) {
536 const callbackData = localStorage.getItem(key);
537 if (!callbackData) continue;
538
539 try {
540 const { url, webviewId, timestamp, isOAuthCallback } =
541 JSON.parse(callbackData);
542
543 // Check if callback is recent (within last 10 seconds) and is an OAuth callback
544 if (Date.now() - timestamp < 10000 && isOAuthCallback) {
545 console.log(
546 `[dumber-parent] OAuth callback detected for webview ${webviewId}:`,
547 url,
548 );
549
550 // Send close request to backend for this popup webview
551 const bridge = window.webkit?.messageHandlers?.dumber;
552 if (bridge && typeof bridge.postMessage === "function") {
553 try {
554 const closeMessage = {
555 type: "close-popup",
556 webviewId,
557 reason: "oauth-callback-success",
558 timestamp: Date.now(),
559 };
560
561 console.log(
562 `[dumber-parent] Sending popup close request:`,
563 closeMessage,
564 );
565 bridge.postMessage(JSON.stringify(closeMessage));
566 } catch (err) {
567 console.warn(
568 `[dumber-parent] Failed to send popup close request:`,
569 err,
570 );
571 }
572 } else {
573 console.warn(
574 `[dumber-parent] No webkit bridge available for popup close request`,
575 );
576 }
577
578 // Clean up OAuth callback data
579 localStorage.removeItem(key);
580 }
581 } catch (err) {
582 console.warn(
583 `[dumber-parent] Failed to parse OAuth callback data:`,
584 err,
585 );
586 localStorage.removeItem(key); // Clean up invalid data
587 }
588 }
589 }
590 } catch (err) {
591 console.warn(
592 `[dumber-parent] Failed to poll for popup messages:`,
593 err,
594 );
595 }
596 };
597
598 // Start polling only when needed
599 const startPollingForOAuthCallbacks = () => {
600 if (!parentPollingInterval && hasRelevantData()) {
601 console.log(`[dumber-parent] Starting OAuth/popup message polling`);
602 parentPollingInterval = setInterval(pollForPopupMessages, 100);
603 cleanupHandlers.push(() => {
604 if (parentPollingInterval) {
605 clearInterval(parentPollingInterval);
606 parentPollingInterval = null;
607 }
608 });
609 }
610 };
611
612 // Check periodically if polling should start
613 parentPollingHeartbeat = setInterval(() => {
614 if (hasRelevantData()) {
615 startPollingForOAuthCallbacks();
616 }
617 }, 1000);
618 cleanupHandlers.push(() => {
619 if (parentPollingHeartbeat) {
620 clearInterval(parentPollingHeartbeat);
621 parentPollingHeartbeat = null;
622 }
623 });
624 };
625
626 // Setup conditional parent message polling
627 setupParentMessagePolling();
628
629 // OAuth callback detection for auto-close
630 const setupOAuthCallbackDetection = () => {
631 const detectOAuthCallback = () => {
632 const url = window.location.href.toLowerCase();
633
634 // Check for OAuth callback patterns - be more specific to avoid false positives
635 const isCallback =
636 // OAuth callback URLs typically have these parameters
637 url.includes("code=") ||
638 url.includes("access_token=") ||
639 url.includes("id_token=") ||
640 // OAuth error responses
641 url.includes("error=access_denied") ||
642 url.includes("error=unauthorized") ||
643 // OAuth callback paths (more specific than just "callback" or "redirect")
644 url.includes("/oauth/callback") ||
645 url.includes("/auth/callback") ||
646 url.includes("oauth2callback") ||
647 url.includes("googlepopupcallback");
648
649 if (isCallback) {
650 try {
651 // Determine which webview should be closed
652 let targetWebViewId = window.__dumber_webview_id;
653
654 // If this is a parent window detecting OAuth callback, find the associated popup
655 const popupMappingKey = `popup_mapping_${window.__dumber_webview_id}`;
656 const popupMappingData = localStorage.getItem(popupMappingKey);
657
658 if (popupMappingData) {
659 try {
660 const mapping = JSON.parse(popupMappingData);
661 // Check if mapping is recent (within last 60 seconds)
662 const age = Date.now() - (mapping.timestamp || 0);
663 if (age < 60000 && mapping.popupId) {
664 targetWebViewId = mapping.popupId;
665 console.log(
666 `[oauth-callback] Parent detected OAuth callback, targeting popup webview: ${targetWebViewId}`,
667 );
668
669 // Clean up the mapping since we're using it
670 localStorage.removeItem(popupMappingKey);
671 }
672 } catch (err) {
673 console.warn(
674 `[oauth-callback] Failed to parse popup mapping:`,
675 err,
676 );
677 }
678 }
679
680 const callbackData = {
681 url: window.location.href,
682 webviewId: targetWebViewId,
683 timestamp: Date.now(),
684 isOAuthCallback: true,
685 };
686
687 localStorage.setItem(
688 `oauth_callback_${targetWebViewId}`,
689 JSON.stringify(callbackData),
690 );
691 console.log(
692 `[oauth-callback] Detected OAuth callback, targeting webview ${targetWebViewId}:`,
693 callbackData,
694 );
695
696 // Auto-cleanup sensitive OAuth data after 10 seconds
697 setTimeout(() => {
698 try {
699 localStorage.removeItem(`oauth_callback_${targetWebViewId}`);
700 console.log(
701 `[oauth-callback] Cleaned up OAuth callback data from localStorage`,
702 );
703 } catch (err) {
704 console.warn(
705 `[oauth-callback] Failed to cleanup OAuth callback data:`,
706 err,
707 );
708 }
709 }, 10000);
710 } catch (err) {
711 console.warn(
712 `[oauth-callback] Failed to store OAuth callback data:`,
713 err,
714 );
715 }
716 }
717 };
718
719 // Check immediately
720 detectOAuthCallback();
721
722 // Monitor for URL changes (for SPAs that don't reload)
723 let lastUrl = window.location.href;
724 oauthUrlCheckInterval = setInterval(() => {
725 if (window.location.href !== lastUrl) {
726 lastUrl = window.location.href;
727 detectOAuthCallback();
728 }
729 }, 500);
730 cleanupHandlers.push(() => {
731 if (oauthUrlCheckInterval) {
732 clearInterval(oauthUrlCheckInterval);
733 oauthUrlCheckInterval = null;
734 }
735 });
736
737 // Also check on navigation events
738 window.addEventListener("popstate", detectOAuthCallback);
739 window.addEventListener("hashchange", detectOAuthCallback);
740 cleanupHandlers.push(() => {
741 window.removeEventListener("popstate", detectOAuthCallback);
742 window.removeEventListener("hashchange", detectOAuthCallback);
743 });
744 };
745
746 // Setup OAuth callback detection
747 setupOAuthCallbackDetection();
748
749 // Enhanced window.open deduplication system
750 interface PendingRequest {
751 id: string;
752 url: string;
753 timestamp: number;
754 webviewId: string;
755 resolved: boolean;
756 }
757
758 class WindowOpenDebouncer {
759 private pendingRequests = new Map<string, PendingRequest>();
760 private readonly DEBOUNCE_WINDOW = 150; // Increased from 100ms
761 private readonly CLEANUP_INTERVAL = 1000; // Clean old entries every 1s
762
763 generateRequestId(): string {
764 return `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
765 }
766
767 isDuplicate(url: string, webviewId: string): boolean {
768 const key = this.createKey(url, webviewId);
769 const existing = this.pendingRequests.get(key);
770
771 if (!existing) return false;
772
773 const timeDiff = Date.now() - existing.timestamp;
774 return timeDiff < this.DEBOUNCE_WINDOW && !existing.resolved;
775 }
776
777 registerRequest(url: string, webviewId: string): string {
778 const requestId = this.generateRequestId();
779 const key = this.createKey(url, webviewId);
780
781 this.pendingRequests.set(key, {
782 id: requestId,
783 url,
784 timestamp: Date.now(),
785 webviewId,
786 resolved: false,
787 });
788
789 // Schedule cleanup
790 setTimeout(() => this.markResolved(key), this.DEBOUNCE_WINDOW);
791 return requestId;
792 }
793
794 private createKey(url: string, webviewId: string): string {
795 return `${webviewId}:${url}`;
796 }
797
798 private markResolved(key: string): void {
799 const request = this.pendingRequests.get(key);
800 if (request) {
801 request.resolved = true;
802 // Clean up after additional delay
803 setTimeout(
804 () => this.pendingRequests.delete(key),
805 this.CLEANUP_INTERVAL,
806 );
807 }
808 }
809 }
810
811 // Global debouncer instance
812 const windowOpenDebouncer = new WindowOpenDebouncer();
813
814 // User interaction tracking
815 let lastUserInteraction = 0;
816 const INTERACTION_TIMEOUT = 5000; // 5 seconds
817
818 const trackUserInteraction = () => {
819 lastUserInteraction = Date.now();
820 };
821
822 const isUserInteraction = (): boolean => {
823 const now = Date.now();
824 const timeSinceInteraction = now - lastUserInteraction;
825 return timeSinceInteraction <= INTERACTION_TIMEOUT;
826 };
827
828 const createFakeWindow = (url: string, popupId?: string): WindowProxy => {
829 // Generate unique popup ID if not provided
830 const actualPopupId =
831 popupId ||
832 `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
833
834 // Set up communication channel via shared localStorage
835 try {
836 localStorage.setItem(
837 `popup_${actualPopupId}_parent_info`,
838 JSON.stringify({
839 parentUrl: window.location.href,
840 parentWebViewId: window.__dumber_webview_id,
841 timestamp: Date.now(),
842 popupUrl: url,
843 }),
844 );
845
846 console.log(
847 `[window-open] [WebView ${window.__dumber_webview_id}] Set up shared storage communication for popup ID: ${actualPopupId}`,
848 );
849 } catch (err) {
850 console.warn(
851 `[window-open] [WebView ${window.__dumber_webview_id}] Failed to set up shared storage:`,
852 err,
853 );
854 }
855
856 return {
857 closed: false,
858 location: { href: url },
859 close() {
860 (this as { closed: boolean }).closed = true;
861 },
862 focus: () => {
863 try {
864 localStorage.setItem(
865 `popup_${actualPopupId}_parent_action`,
866 JSON.stringify({
867 action: "focus",
868 timestamp: Date.now(),
869 }),
870 );
871 } catch (err) {
872 console.warn(`[window-open] Failed to store focus action:`, err);
873 }
874 },
875 blur: () => {
876 try {
877 localStorage.setItem(
878 `popup_${actualPopupId}_parent_action`,
879 JSON.stringify({
880 action: "blur",
881 timestamp: Date.now(),
882 }),
883 );
884 } catch (err) {
885 console.warn(`[window-open] Failed to store blur action:`, err);
886 }
887 },
888 postMessage: (data: unknown, targetOrigin?: string) => {
889 try {
890 localStorage.setItem(
891 `popup_${actualPopupId}_message_to_popup`,
892 JSON.stringify({
893 data,
894 origin: targetOrigin || "*",
895 timestamp: Date.now(),
896 source: "parent",
897 }),
898 );
899 console.log(
900 `[window-open] [WebView ${window.__dumber_webview_id}] Stored message for popup ${actualPopupId}:`,
901 data,
902 );
903 } catch (err) {
904 console.warn(`[window-open] Failed to store postMessage:`, err);
905 }
906 },
907 } as unknown as WindowProxy;
908 };
909
910 // Track user interactions for popup validation
911 const interactionEvents = [
912 "click",
913 "mousedown",
914 "keydown",
915 "touchstart",
916 ] as const;
917 interactionEvents.forEach((eventType) => {
918 document.addEventListener(eventType, trackUserInteraction, true);
919 });
920 cleanupHandlers.push(() => {
921 interactionEvents.forEach((eventType) => {
922 document.removeEventListener(eventType, trackUserInteraction, true);
923 });
924 });
925
926 console.log(
927 `[window-open] [WebView ${window.__dumber_webview_id}] User interaction tracking enabled`,
928 );
929
930 // Window.open interceptor - only install once
931 if (!window.__dumber_window_open_intercepted) {
932 try {
933 window.open = function (
934 url?: string | URL | null,
935 target?: string | null,
936 features?: string | null,
937 ): WindowProxy | null {
938 const urlString = url
939 ? typeof url === "string"
940 ? url
941 : url.toString()
942 : "";
943 const webviewId = window.__dumber_webview_id || "unknown";
944
945 console.log(
946 `[window-open] [WebView ${webviewId}] Bridge called with:`,
947 urlString,
948 target,
949 features,
950 );
951
952 // Enhanced duplicate check
953 if (windowOpenDebouncer.isDuplicate(urlString, webviewId)) {
954 console.log(
955 `[window-open] [WebView ${webviewId}] BLOCKED: Duplicate request within debounce window`,
956 );
957 return createFakeWindow(urlString);
958 }
959
960 // Active WebView check
961 if (!window.__dumber_is_active) {
962 console.log(
963 `[window-open] [WebView ${webviewId}] BLOCKED: Inactive WebView`,
964 );
965 return createFakeWindow(urlString);
966 }
967
968 // User interaction check
969 if (!isUserInteraction()) {
970 console.log(
971 `[window-open] [WebView ${webviewId}] BLOCKED: No recent user interaction`,
972 );
973 return createFakeWindow("");
974 }
975
976 // Register request and get unique ID
977 const requestId = windowOpenDebouncer.registerRequest(
978 urlString,
979 webviewId,
980 );
981
982 const intent: WindowIntent = {
983 url: urlString,
984 target: target || "_blank",
985 features: features || "",
986 timestamp: Date.now(),
987 userTriggered: true,
988 requestId, // NEW: Add unique request ID
989 windowType: detectWindowType(features), // Detect popup vs tab
990 };
991
992 // Send to Go backend
993 try {
994 if (window.webkit?.messageHandlers?.dumber) {
995 window.webkit.messageHandlers.dumber.postMessage(
996 JSON.stringify({
997 type: "handle-window-open",
998 payload: intent,
999 }),
1000 );
1001 console.log(
1002 `[window-open] [WebView ${webviewId}] Sent request ${requestId} to Go backend`,
1003 );
1004 }
1005 } catch (err) {
1006 console.error(
1007 `[window-open] [WebView ${webviewId}] Failed to send request ${requestId}:`,
1008 err,
1009 );
1010 }
1011
1012 return createFakeWindow(urlString, requestId);
1013 };
1014
1015 window.__dumber_window_open_intercepted = true;
1016 console.log(
1017 `[window-open] [WebView ${window.__dumber_webview_id}] ✅ Page-world bridge installed`,
1018 );
1019 } catch (error) {
1020 console.error(
1021 `[window-open] [WebView ${window.__dumber_webview_id}] ❌ Failed to install page-world bridge:`,
1022 error,
1023 );
1024 }
1025 }
1026
1027 const teardown = () => {
1028 if (teardownExecuted) {
1029 return;
1030 }
1031 teardownExecuted = true;
1032
1033 while (cleanupHandlers.length) {
1034 const cleanup = cleanupHandlers.pop();
1035 if (!cleanup) {
1036 continue;
1037 }
1038 try {
1039 cleanup();
1040 } catch (cleanupErr) {
1041 console.warn("[dumber] Cleanup handler failed", cleanupErr);
1042 }
1043 }
1044
1045 omniboxQueue.length = 0;
1046 omniboxReady = false;
1047
1048 window.__dumber_window_open_intercepted = false;
1049 window.__dumber_page_bridge_installed = false;
1050 window.__dumber_teardown = undefined;
1051 window.__dumber_omnibox_suggestions = undefined;
1052 window.__dumber_showToast = undefined;
1053 window.__dumber_showZoomToast = undefined;
1054 window.__dumber_applyDomZoom = undefined;
1055
1056 const globalState = window.__dumber;
1057 if (globalState) {
1058 const mutableState = globalState as unknown as Record<string, unknown>;
1059 delete mutableState.omnibox;
1060 delete mutableState.toast;
1061 }
1062 };
1063
1064 window.__dumber_teardown = teardown;
1065 window.addEventListener("pagehide", teardown, { once: true });
1066 window.addEventListener("beforeunload", teardown, { once: true });
1067 cleanupHandlers.push(() => {
1068 window.removeEventListener("pagehide", teardown);
1069 window.removeEventListener("beforeunload", teardown);
1070 window.__dumber_teardown = undefined;
1071 });
1072
1073 // Console capture functionality
1074 const setupConsoleCapture = () => {
1075 const originalConsole = {
1076 log: console.log,
1077 warn: console.warn,
1078 error: console.error,
1079 info: console.info,
1080 debug: console.debug,
1081 };
1082
1083 const sendConsoleMessage = (level: string, args: unknown[]) => {
1084 try {
1085 const message = args
1086 .map((arg) =>
1087 typeof arg === "object" ? JSON.stringify(arg) : String(arg),
1088 )
1089 .join(" ");
1090
1091 const formattedMessage = `${window.location.href} ${message}`;
1092
1093 // Send to Go via existing message handler
1094 if (window.webkit?.messageHandlers?.dumber) {
1095 window.webkit.messageHandlers.dumber.postMessage(
1096 JSON.stringify({
1097 type: "console-message",
1098 payload: {
1099 level,
1100 message: formattedMessage,
1101 url: window.location.href,
1102 webviewId: window.__dumber_webview_id,
1103 },
1104 }),
1105 );
1106 }
1107 } catch {
1108 // Silently ignore errors in console capture to avoid infinite loops
1109 }
1110 };
1111
1112 const createConsoleWrapper = (
1113 originalMethod: (...args: unknown[]) => void,
1114 level: string,
1115 ) => {
1116 return function (...args: unknown[]) {
1117 originalMethod.apply(console, args);
1118 sendConsoleMessage(level, args);
1119 };
1120 };
1121
1122 // Override console methods to capture messages
1123 console.log = createConsoleWrapper(originalConsole.log, "LOG");
1124 console.warn = createConsoleWrapper(originalConsole.warn, "WARN");
1125 console.error = createConsoleWrapper(originalConsole.error, "ERROR");
1126 console.info = createConsoleWrapper(originalConsole.info, "INFO");
1127 console.debug = createConsoleWrapper(originalConsole.debug, "DEBUG");
1128
1129 cleanupHandlers.push(() => {
1130 console.log = originalConsole.log;
1131 console.warn = originalConsole.warn;
1132 console.error = originalConsole.error;
1133 console.info = originalConsole.info;
1134 console.debug = originalConsole.debug;
1135 });
1136 };
1137
1138 // Initialize console capture
1139 setupConsoleCapture();
1140 } catch (err) {
1141 console.warn("[dumber] unified bridge init failed", err);
1142 }
1143})();