v0.14.0 76d1892

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

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