v0.12.1 1453d21

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

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