v0.9.0 636c44c

main-world.ts RawHistoryFolder

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})();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close