v0.10.0 0f4e9eb

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

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