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