From 6cac24b232965d7760d976b0deca38dd487570e6 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 5 May 2026 14:43:41 +0900 Subject: [PATCH] refactor(main): split tab-manager.ts for 450-line rule --- apps/browser/src/main/tab-manager.ts | 1145 ++--------------- apps/browser/src/main/tabs/tab-error-codes.ts | 197 +++ apps/browser/src/main/tabs/tab-fullscreen.ts | 177 +++ apps/browser/src/main/tabs/tab-navigation.ts | 277 ++++ apps/browser/src/main/tabs/tab-page-loader.ts | 133 ++ 5 files changed, 907 insertions(+), 1022 deletions(-) create mode 100644 apps/browser/src/main/tabs/tab-error-codes.ts create mode 100644 apps/browser/src/main/tabs/tab-fullscreen.ts create mode 100644 apps/browser/src/main/tabs/tab-navigation.ts create mode 100644 apps/browser/src/main/tabs/tab-page-loader.ts diff --git a/apps/browser/src/main/tab-manager.ts b/apps/browser/src/main/tab-manager.ts index cbbff32..2054162 100644 --- a/apps/browser/src/main/tab-manager.ts +++ b/apps/browser/src/main/tab-manager.ts @@ -1,5 +1,11 @@ /** - * Tab management functionality + * Tab management — orchestrator. + * + * Delegates: + * - Error-code lookups → tabs/tab-error-codes.ts + * - Blank/error page loading → tabs/tab-page-loader.ts + * - Fullscreen handlers → tabs/tab-fullscreen.ts + * - Navigation handlers → tabs/tab-navigation.ts */ import { WebContentsView, Menu } from "electron"; @@ -13,7 +19,12 @@ import { logSecurityEvent, } from "./security"; import { ThemeColorCache } from "./theme-cache"; -import { generateBlankPageHtml, generateErrorPageHtml } from "./html-generator"; +import { loadBlankPage } from "./tabs/tab-page-loader"; +import { + setupFullscreenHandlers, + exitFullscreen as doExitFullscreen, +} from "./tabs/tab-fullscreen"; +import { setupNavigationHandlers } from "./tabs/tab-navigation"; export class TabManager { private state: AppState; @@ -24,9 +35,9 @@ export class TabManager { this.themeColorCache = themeColorCache; } - /** - * Create a new tab - */ + // ── Public API ───────────────────────────────────────────────────────────── + + /** Create a new tab, optionally loading the given URL. */ createTab(url: string = ""): Tab { const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -37,12 +48,12 @@ export class TabManager { console.log("[TabManager] Preload exists:", hasWebviewPreload); const isDev = process.env.NODE_ENV === "development"; - + const view = new WebContentsView({ webPreferences: { nodeIntegration: false, contextIsolation: true, - webSecurity: !isDev, // Disable webSecurity in dev mode to allow loading from Vite dev server + webSecurity: !isDev, // Allow Vite dev server in dev mode allowRunningInsecureContent: false, sandbox: false, // Widevine requires sandbox: false partition: "persist:main", @@ -60,7 +71,7 @@ export class TabManager { callback: (result: boolean) => void ) => { if (permission === "media" || permission === "fullscreen") { - callback(true); // Allow media and fullscreen permissions + callback(true); } else { callback(false); } @@ -68,8 +79,7 @@ export class TabManager { ); // Set initial user agent based on URL - const userAgent = getUserAgentForUrl(url); - view.webContents.setUserAgent(userAgent); + view.webContents.setUserAgent(getUserAgentForUrl(url)); const tab: Tab = { id: tabId, @@ -83,7 +93,7 @@ export class TabManager { // Load URL or blank page if (!url || url.trim() === "") { - // Immediately set blank-page theme color before loading + // Pre-apply blank-page theme color const blankPageThemeColor = "#1c1c1e"; this.state.latestThemeColor = blankPageThemeColor; if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { @@ -92,66 +102,7 @@ export class TabManager { blankPageThemeColor ); } - - // Load blank page for blank tabs - const { app } = require("electron"); - - if (isDev) { - // In dev mode, use temporary file with Vite dev server URLs - const devHtml = ` - - - - - - Blank Page - - - - -
- - -`; - - // Write to temporary file - const tmpDir = path.join(app.getPath("temp"), "aka-browser"); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const tmpHtmlPath = path.join(tmpDir, `blank-page-${tabId}.html`); - fs.writeFileSync(tmpHtmlPath, devHtml, "utf-8"); - - // Load from temporary file (file:// protocol with webSecurity disabled allows loading from http://) - view.webContents.loadFile(tmpHtmlPath).catch((err) => { - console.error("[TabManager] Failed to load blank page:", err); - }); - } else { - // In production, generate HTML and load from temporary file - const distPath = path.join(app.getAppPath(), "dist-renderer"); - const scriptPath = path.join(distPath, "pages", "blank-page.js"); - - const html = generateBlankPageHtml(scriptPath, undefined, false); - - // Write to temporary file - const tmpDir = path.join(app.getPath("temp"), "aka-browser"); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const tmpHtmlPath = path.join(tmpDir, `blank-page-${tabId}.html`); - fs.writeFileSync(tmpHtmlPath, html, "utf-8"); - - // Load from temporary file - view.webContents.loadFile(tmpHtmlPath).catch((err) => { - console.error("[TabManager] Failed to load blank page:", err); - }); - } + loadBlankPage(view.webContents, tabId); } else { const sanitized = sanitizeUrl(url); if (isValidUrl(sanitized)) { @@ -162,20 +113,17 @@ export class TabManager { return tab; } - /** - * Switch to a specific tab - */ + /** Switch the active tab, updating the window's child-view stack. */ switchToTab(tabId: string): void { const tab = this.state.tabs.find((t) => t.id === tabId); if (!tab || !this.state.mainWindow) return; - // Hide current active tab and capture its preview + // Capture preview of the currently-active tab before hiding it if (this.state.activeTabId && this.state.activeTabId !== tabId) { const currentTab = this.state.tabs.find( (t) => t.id === this.state.activeTabId ); if (currentTab) { - // Capture preview before hiding this.captureTabPreview(this.state.activeTabId).catch((err) => { console.error("Failed to capture preview on tab switch:", err); }); @@ -183,58 +131,16 @@ export class TabManager { } } - // Update webContentsView reference BEFORE adding view this.state.webContentsView = tab.view; this.state.activeTabId = tabId; - // Immediately apply theme color for the switched tab - const url = tab.view.webContents.getURL(); - if (url) { - // Check if it's blank-page - if (url.includes("blank-page.html")) { - const blankPageThemeColor = "#1c1c1e"; - this.state.latestThemeColor = blankPageThemeColor; - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - blankPageThemeColor - ); - tab.title = "Blank Page"; - } else if (url.startsWith("data:text/html")) { - // Error page - apply error-page theme color - const errorPageThemeColor = "#2d2d2d"; - this.state.latestThemeColor = errorPageThemeColor; - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - errorPageThemeColor - ); - } else { - // Try to get cached theme color for regular pages - try { - const domain = new URL(url).hostname; - const cachedColor = this.themeColorCache.get(domain); - if (cachedColor) { - this.state.latestThemeColor = cachedColor; - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - cachedColor - ); - } else { - this.state.latestThemeColor = null; - } - } catch (error) { - this.state.latestThemeColor = null; - } - } - } else { - this.state.latestThemeColor = null; - } + // Apply theme color for the incoming tab immediately + this.applyThemeColorOnSwitch(tab); - // Show new tab if (!this.state.mainWindow.contentView.children.includes(tab.view)) { this.state.mainWindow.contentView.addChildView(tab.view); } - // Notify renderer about tab change this.state.mainWindow.webContents.send("tab-changed", { tabId, tabs: this.state.tabs.map((t) => ({ @@ -246,102 +152,79 @@ export class TabManager { }); } - /** - * Close a tab - */ + /** Close a tab and switch to an adjacent one (or create a new tab if last). */ closeTab(tabId: string): void { const tabIndex = this.state.tabs.findIndex((t) => t.id === tabId); if (tabIndex === -1) return; const tab = this.state.tabs[tabIndex]; - // Remove from window if (this.state.mainWindow) { this.state.mainWindow.contentView.removeChildView(tab.view); } - - // Destroy the view if (!tab.view.webContents.isDestroyed()) { tab.view.webContents.close(); } - // Remove from tabs array this.state.tabs.splice(tabIndex, 1); - // If this was the active tab, switch to another if (this.state.activeTabId === tabId) { if (this.state.tabs.length > 0) { - // Switch to the previous tab or the first tab - const newActiveTab = this.state.tabs[Math.max(0, tabIndex - 1)]; - this.switchToTab(newActiveTab.id); + const newActive = this.state.tabs[Math.max(0, tabIndex - 1)]; + this.switchToTab(newActive.id); } else { - // No tabs left, create a new one const newTab = this.createTab(); this.switchToTab(newTab.id); } - } else { - // Just notify renderer about tab list change - if (this.state.mainWindow) { - this.state.mainWindow.webContents.send("tabs-updated", { - tabs: this.state.tabs.map((t) => ({ - id: t.id, - title: t.title, - url: t.url, - preview: t.preview, - })), - activeTabId: this.state.activeTabId, - }); - } + } else if (this.state.mainWindow) { + this.state.mainWindow.webContents.send("tabs-updated", { + tabs: this.state.tabs.map((t) => ({ + id: t.id, + title: t.title, + url: t.url, + preview: t.preview, + })), + activeTabId: this.state.activeTabId, + }); } } - /** - * Close all tabs and create a new one - */ + /** Close every open tab and open a single new blank tab. */ closeAllTabs(): void { - // Close all tabs const tabsToClose = [...this.state.tabs]; tabsToClose.forEach((tab) => { - // Remove from window - if (this.state.mainWindow) { - this.state.mainWindow.contentView.removeChildView(tab.view); - } - - // Destroy the view + this.state.mainWindow?.contentView.removeChildView(tab.view); if (!tab.view.webContents.isDestroyed()) { tab.view.webContents.close(); } }); - // Clear tabs array this.state.tabs.length = 0; - - // Create a new tab const newTab = this.createTab(); this.switchToTab(newTab.id); } - /** - * Capture tab preview - */ - private async captureTabPreview(tabId: string): Promise { + /** Exit fullscreen for a tab (ESC-key handler entry point). */ + exitFullscreen(tabId: string): void { + doExitFullscreen(tabId, this.state); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + /** Capture a low-res preview screenshot of `tabId` and store on the tab. */ + async captureTabPreview(tabId: string): Promise { const tab = this.state.tabs.find((t) => t.id === tabId); if (!tab || tab.view.webContents.isDestroyed()) return; try { - // Capture screenshot at a reasonable size for preview const image = await tab.view.webContents.capturePage({ x: 0, y: 0, width: 800, height: 1200, }); + tab.preview = image.toDataURL(); - // Convert to base64 data URL - const dataUrl = image.toDataURL(); - tab.preview = dataUrl; - - // Notify renderer about updated tabs if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { this.state.mainWindow.webContents.send("tabs-updated", { tabs: this.state.tabs.map((t) => ({ @@ -359,7 +242,59 @@ export class TabManager { } /** - * Setup WebContentsView event handlers + * Apply the correct theme color when switching to `tab`. + * Reads from the theme-color cache for known domains, falls back to null. + */ + private applyThemeColorOnSwitch(tab: Tab): void { + if (!this.state.mainWindow) return; + + const url = tab.view.webContents.getURL(); + if (!url) { + this.state.latestThemeColor = null; + return; + } + + if (url.includes("blank-page.html")) { + const color = "#1c1c1e"; + this.state.latestThemeColor = color; + this.state.mainWindow.webContents.send( + "webcontents-theme-color-updated", + color + ); + tab.title = "Blank Page"; + return; + } + + if (url.startsWith("data:text/html")) { + const color = "#2d2d2d"; + this.state.latestThemeColor = color; + this.state.mainWindow.webContents.send( + "webcontents-theme-color-updated", + color + ); + return; + } + + try { + const domain = new URL(url).hostname; + const cached = this.themeColorCache.get(domain); + if (cached) { + this.state.latestThemeColor = cached; + this.state.mainWindow.webContents.send( + "webcontents-theme-color-updated", + cached + ); + } else { + this.state.latestThemeColor = null; + } + } catch { + this.state.latestThemeColor = null; + } + } + + /** + * Wire up all WebContentsView event listeners: context-menu, security + * checks, window-open interception, fullscreen, and navigation. */ private setupWebContentsViewHandlers( view: WebContentsView, @@ -367,14 +302,14 @@ export class TabManager { ): void { const contents = view.webContents; - // Send initial orientation to the new webview when DOM is ready + // Send initial orientation when DOM is ready contents.on("dom-ready", () => { const orientation = this.state.isLandscape ? "landscape" : "portrait"; contents.send("orientation-changed", orientation); }); - // Enable context menu (right-click) - contents.on("context-menu", (event: any, params: any) => { + // Right-click context menu + contents.on("context-menu", (_event: any, params: any) => { const menu = Menu.buildFromTemplate([ { label: "Back", @@ -408,10 +343,11 @@ export class TabManager { menu.popup(); }); - contents.on("will-navigate", (event: any, navigationUrl: string) => { + // Block invalid navigation URLs + contents.on("will-navigate", (_event: any, navigationUrl: string) => { if (!isValidUrl(navigationUrl)) { - event.preventDefault(); - logSecurityEvent(`Navigation blocked to invalid URL`, { + _event.preventDefault(); + logSecurityEvent("Navigation blocked to invalid URL", { url: navigationUrl, }); if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { @@ -421,868 +357,33 @@ export class TabManager { ); } } else { - const userAgent = getUserAgentForUrl(navigationUrl); - contents.setUserAgent(userAgent); + contents.setUserAgent(getUserAgentForUrl(navigationUrl)); } }); + // Intercept new-window requests and open them as tabs instead contents.setWindowOpenHandler(({ url }: { url: string }) => { if (!isValidUrl(url)) { - logSecurityEvent(`Blocked new window with invalid URL`, { url }); + logSecurityEvent("Blocked new window with invalid URL", { url }); return { action: "deny" }; } - const newTab = this.createTab(url); this.switchToTab(newTab.id); - return { action: "deny" }; }); - contents.on("render-process-gone", (event: any, details: any) => { + contents.on("render-process-gone", (_event: any, details: any) => { console.error("Render process crashed:", details); }); - this.setupNavigationHandlers(contents, tabId); - this.setupFullscreenHandlers(contents, tabId); - } - - /** - * Setup fullscreen event handlers using Electron's native events (Plan 1.5 - Correct approach) - * Note: We update bounds with gaps and hide status bar in fullscreen mode - */ - private setupFullscreenHandlers( - contents: Electron.WebContents, - tabId: string - ): void { - // Listen for HTML fullscreen API events from Electron - contents.on("enter-html-full-screen", () => { - const tab = this.state.tabs.find((t) => t.id === tabId); - if (!tab) return; - - const timestamp = new Date().toISOString().split("T")[1].slice(0, -1); - console.log( - `[Fullscreen][${timestamp}] enter-html-full-screen event received` - ); - - // Mark tab as fullscreen (for state tracking) - tab.isFullscreen = true; - - // Update bounds with gaps and hide status bar - if (this.state.mainWindow) { - const windowBounds = this.state.mainWindow.getBounds(); - const topBarHeight = 40; // TOP_BAR_HEIGHT - const deviceFramePadding = 15; // Device frame outer padding - const deviceBorderRadius = 32; // Device frame border radius - - // Calculate safe gap to avoid rounded corners - // Adjust these values to fine-tune fullscreen positioning: - // - Increase to move content away from frame edges - // - Decrease to make content larger (closer to frame edges) - const fullscreenGapVertical = - deviceFramePadding + deviceBorderRadius + 20; // ~67px (Portrait: top/bottom gap) - const fullscreenGapHorizontal = - deviceFramePadding + deviceBorderRadius + 10; // ~57px (Landscape: left/right gap) - - // Determine orientation based on actual window dimensions (not cached state) - const isCurrentlyLandscape = windowBounds.width > windowBounds.height; - - if (isCurrentlyLandscape) { - // Landscape: gap on left and right to avoid rounded corners - // Note: We ignore status bar space in fullscreen mode - const bounds = { - x: fullscreenGapHorizontal - 30, - y: topBarHeight + deviceFramePadding, - width: windowBounds.width - fullscreenGapHorizontal * 2, - height: windowBounds.height - topBarHeight - deviceFramePadding * 2, - }; - tab.view.setBounds(bounds); - } else { - // Portrait: gap on top and bottom to avoid rounded corners - const bounds = { - x: deviceFramePadding, - y: topBarHeight + fullscreenGapVertical - 30, - width: windowBounds.width - deviceFramePadding * 2, - height: - windowBounds.height - - topBarHeight - - fullscreenGapVertical - - fullscreenGapVertical, - }; - tab.view.setBounds(bounds); - } - - // Notify renderer to hide status bar - this.state.mainWindow.webContents.send("fullscreen-mode-changed", true); - - // Force a layout recalculation by resizing the main window - // This ensures WebContentsView properly recalculates its size - const windowBoundsNow = this.state.mainWindow.getBounds(); - this.state.mainWindow.setBounds({ - ...windowBoundsNow, - height: windowBoundsNow.height + 1, - }); - - // Immediately restore to correct size and reapply adjusted bounds - this.state.mainWindow.setBounds(windowBoundsNow); - - // Reapply the adjusted bounds after window resize - if (isCurrentlyLandscape) { - const adjustedBounds = { - x: fullscreenGapHorizontal - 30, - y: topBarHeight + deviceFramePadding, - width: windowBounds.width - fullscreenGapHorizontal * 2, - height: windowBounds.height - topBarHeight - deviceFramePadding * 2, - }; - tab.view.setBounds(adjustedBounds); - } else { - const adjustedBounds = { - x: deviceFramePadding, - y: topBarHeight + fullscreenGapVertical - 30, - width: windowBounds.width - deviceFramePadding * 2, - height: - windowBounds.height - - topBarHeight - - fullscreenGapVertical - - fullscreenGapVertical, - }; - tab.view.setBounds(adjustedBounds); - } - - // Send fullscreen state immediately - if (!tab.view.webContents.isDestroyed()) { - tab.view.webContents.send("set-fullscreen-state", true); - } - } - - }); - - contents.on("leave-html-full-screen", () => { - const tab = this.state.tabs.find((t) => t.id === tabId); - if (!tab) return; - - // Clear fullscreen state - tab.isFullscreen = false; - - // Restore normal bounds - if (this.state.mainWindow) { - this.state.mainWindow.webContents.send( - "fullscreen-mode-changed", - false - ); - - // Restore normal WebContentsView bounds FIRST - const windowBounds = this.state.mainWindow.getBounds(); - const topBarHeight = 40; // TOP_BAR_HEIGHT - const statusBarHeight = 58; - const statusBarWidth = 58; - const frameHalf = 15 / 2; // Device frame padding (half on each side) - - // Determine orientation based on actual window dimensions (not cached state) - const isCurrentlyLandscape = windowBounds.width > windowBounds.height; - - if (isCurrentlyLandscape) { - // Landscape mode: status bar is on the LEFT side - const bounds = { - x: statusBarWidth, - y: Math.round(topBarHeight + frameHalf), - width: Math.round(windowBounds.width - statusBarWidth - frameHalf), - height: Math.round( - windowBounds.height - topBarHeight - frameHalf * 2 - ), - }; - tab.view.setBounds(bounds); - } else { - // Portrait mode: status bar is on the TOP - const bounds = { - x: Math.round(frameHalf), - y: Math.round(topBarHeight + statusBarHeight + frameHalf), - width: Math.round(windowBounds.width - frameHalf * 2), - height: Math.round( - windowBounds.height - - topBarHeight - - statusBarHeight - - frameHalf * 2 - ), - }; - tab.view.setBounds(bounds); - } - - // Force a layout recalculation by resizing the main window - const windowBoundsNow = this.state.mainWindow.getBounds(); - this.state.mainWindow.setBounds({ - ...windowBoundsNow, - height: windowBoundsNow.height + 1, - }); - - // Immediately restore to correct size and reapply adjusted bounds - this.state.mainWindow.setBounds(windowBoundsNow); - - // Reapply the adjusted bounds after window resize - if (isCurrentlyLandscape) { - const adjustedBounds = { - x: statusBarWidth, - y: Math.round(topBarHeight + frameHalf), - width: Math.round(windowBounds.width - statusBarWidth - frameHalf), - height: Math.round( - windowBounds.height - topBarHeight - frameHalf * 2 - ), - }; - tab.view.setBounds(adjustedBounds); - } else { - const adjustedBounds = { - x: Math.round(frameHalf), - y: Math.round(topBarHeight + statusBarHeight + frameHalf), - width: Math.round(windowBounds.width - frameHalf * 2), - height: Math.round( - windowBounds.height - - topBarHeight - - statusBarHeight - - frameHalf * 2 - ), - }; - tab.view.setBounds(adjustedBounds); - } - - // Send fullscreen state immediately - if (!tab.view.webContents.isDestroyed()) { - tab.view.webContents.send("set-fullscreen-state", false); - } - } - - }); - } - - /** - * Exit fullscreen for a specific tab (called by ESC key handler) - */ - exitFullscreen(tabId: string): void { - const tab = this.state.tabs.find((t) => t.id === tabId); - if (!tab || !tab.isFullscreen) return; - - // Execute JavaScript to exit fullscreen in the web page - tab.view.webContents - .executeJavaScript( - ` - if (document.exitFullscreen) { - document.exitFullscreen(); - } else if (document.webkitExitFullscreen) { - document.webkitExitFullscreen(); - } else if (document.mozCancelFullScreen) { - document.mozCancelFullScreen(); - } else if (document.msExitFullscreen) { - document.msExitFullscreen(); - } - ` - ) - .catch((err) => { - console.error("[Fullscreen] Failed to exit fullscreen:", err); - }); - - // Notify webview-preload to update state - if (!tab.view.webContents.isDestroyed()) { - tab.view.webContents.send("webview-fullscreen-exited"); - } - } - - /** - * Setup navigation event handlers - */ - private setupNavigationHandlers( - contents: Electron.WebContents, - tabId: string - ): void { - contents.on("did-start-loading", () => { - try { - const url = contents.getURL(); - if (url) { - const domain = new URL(url).hostname; - const cachedColor = this.themeColorCache.get(domain); - if (cachedColor) { - this.state.latestThemeColor = cachedColor; - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - cachedColor - ); - } - } else { - this.state.latestThemeColor = null; - } - } else { - this.state.latestThemeColor = null; - } - } catch (error) { - this.state.latestThemeColor = null; - } - this.state.mainWindow?.webContents.send("webcontents-did-start-loading"); - }); - - contents.on("did-stop-loading", () => { - this.state.mainWindow?.webContents.send("webcontents-did-stop-loading"); - setTimeout(() => { - this.captureTabPreview(tabId).catch((err) => { - console.error("Failed to capture preview after loading:", err); - }); - }, 500); - }); - - contents.on("did-navigate", (event: any, url: string) => { - const tab = this.state.tabs.find((t) => t.id === tabId); - let displayUrl = url; - - if (tab) { - // Set "/" URL and "Blank Page" title for blank-page - if (url.includes("blank-page-tab-")) { - tab.url = "/"; - tab.title = "Blank Page"; - displayUrl = "/"; - } else if (url.includes("error-page-tab-")) { - // Error page - set URL to "/" and use actual title - tab.url = "/"; - tab.title = contents.getTitle() || "Aka Browser cannot open the page"; - displayUrl = "/"; - } else { - tab.url = url; - tab.title = contents.getTitle() || url; - } - } - - this.state.mainWindow?.webContents.send("webcontents-did-navigate", displayUrl); - - if (this.state.activeTabId === tabId && this.state.mainWindow) { - this.state.mainWindow.webContents.send("tabs-updated", { - tabs: this.state.tabs.map((t) => ({ - id: t.id, - title: t.title, - url: t.url, - preview: t.preview, - })), - activeTabId: this.state.activeTabId, - }); - } - }); - - contents.on("did-navigate-in-page", (event: any, url: string) => { - const tab = this.state.tabs.find((t) => t.id === tabId); - let displayUrl = url; - - if (tab) { - // Set "/" URL and "Blank Page" title for blank-page - if (url.includes("blank-page-tab-")) { - tab.url = "/"; - tab.title = "Blank Page"; - displayUrl = "/"; - } else if (url.includes("error-page-tab-")) { - // Error page - set URL to "/" and use actual title - tab.url = "/"; - tab.title = contents.getTitle() || "Aka Browser cannot open the page"; - displayUrl = "/"; - } else { - tab.url = url; - tab.title = contents.getTitle() || url; - } - } - - this.state.mainWindow?.webContents.send( - "webcontents-did-navigate-in-page", - displayUrl - ); - - if (this.state.activeTabId === tabId && this.state.mainWindow) { - this.state.mainWindow.webContents.send("tabs-updated", { - tabs: this.state.tabs.map((t) => ({ - id: t.id, - title: t.title, - url: t.url, - preview: t.preview, - })), - activeTabId: this.state.activeTabId, - }); - } - }); - - contents.on("dom-ready", () => { - this.state.mainWindow?.webContents.send("webcontents-dom-ready"); - }); - - contents.on( - "did-fail-load", - (event: any, errorCode: number, errorDescription: string, validatedURL: string, isMainFrame: boolean) => { - // Ignore errorCode -3 (ERR_ABORTED) as it's usually from user navigation - // Also ignore if it's not the main frame - if (errorCode === -3 || !isMainFrame) { - return; - } - - console.log( - `[TabManager] Page load failed: ${errorCode} (${errorDescription}) for ${validatedURL}` - ); - - // Load error page with details - const { app } = require("electron"); - const statusText = this.getNetworkErrorText(errorCode, errorDescription); - const isDev = process.env.NODE_ENV === "development"; - - console.log(`[TabManager] Loading error page for error ${errorCode}`); - - // Use setTimeout with a longer delay to ensure the failed load is completely finished - setTimeout(() => { - if (!contents.isDestroyed()) { - console.log(`[TabManager] Attempting to load error page now`); - - // Create query params object for error details - const queryParamsObj = { - statusCode: Math.abs(errorCode).toString(), - statusText: statusText, - url: validatedURL, - }; - - if (isDev) { - // In dev mode, use temporary file with Vite dev server URLs - const devHtml = ` - - - - - - Error - - - - - -
- - -`; - - // Write to temporary file - const tmpDir = path.join(app.getPath("temp"), "aka-browser"); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const tmpHtmlPath = path.join(tmpDir, `error-page-${tabId}.html`); - fs.writeFileSync(tmpHtmlPath, devHtml, "utf-8"); - - // Load from temporary file (file:// protocol with webSecurity disabled allows loading from http://) - contents.loadFile(tmpHtmlPath).then(() => { - console.log(`[TabManager] Error page loaded successfully`); - - // Update tab info - const tab = this.state.tabs.find((t) => t.id === tabId); - if (tab) { - tab.url = "/"; - tab.title = "Aka Browser cannot open the page"; - } - - // Apply error-page theme color immediately - const errorPageThemeColor = "#2d2d2d"; - this.state.latestThemeColor = errorPageThemeColor; - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - errorPageThemeColor - ); - } - }).catch((err) => { - console.error(`[TabManager] Failed to load error page:`, err); - }); - } else { - // In production, generate HTML and load from temporary file - const distPath = path.join(app.getAppPath(), "dist-renderer"); - const scriptPath = path.join(distPath, "pages", "error-page.js"); - const html = generateErrorPageHtml(scriptPath, undefined, queryParamsObj, false); - - // Write to temporary file - const tmpDir = path.join(app.getPath("temp"), "aka-browser"); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const tmpHtmlPath = path.join(tmpDir, `error-page-${tabId}.html`); - fs.writeFileSync(tmpHtmlPath, html, "utf-8"); - - // Load error page from temporary file - contents.loadFile(tmpHtmlPath).then(() => { - console.log(`[TabManager] Error page loaded successfully`); - - // Update tab info - const tab = this.state.tabs.find((t) => t.id === tabId); - if (tab) { - tab.url = "/"; - tab.title = "Aka Browser cannot open the page"; - } - - // Apply error-page theme color immediately - const errorPageThemeColor = "#2d2d2d"; - this.state.latestThemeColor = errorPageThemeColor; - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - errorPageThemeColor - ); - } - }).catch((err) => { - console.error(`[TabManager] Failed to load error page:`, err); - }); - } - } else { - console.log(`[TabManager] Contents destroyed, cannot load error page`); - } - }, 100); - - // Notify renderer about the error - this.state.mainWindow?.webContents.send( - "webcontents-did-fail-load", - errorCode, - errorDescription - ); - } - ); - - contents.on("render-process-gone", (event: any, details: any) => { - this.state.mainWindow?.webContents.send( - "webcontents-render-process-gone", - details - ); - }); - - // Monitor HTTP response codes and show error page for non-200 responses - (contents as any).on( - "did-get-response-details", - ( - event: any, - status: boolean, - newURL: string, - originalURL: string, - httpResponseCode: number, - requestMethod: string, - referrer: string, - headers: Record, - resourceType: string - ) => { - // Only handle main frame navigation responses (not images, scripts, etc.) - if (resourceType !== "mainFrame") { - return; - } - - // Check if response code is not in the 2xx success range - if (httpResponseCode < 200 || httpResponseCode >= 300) { - console.log( - `[TabManager] Non-success HTTP response: ${httpResponseCode} for ${originalURL}` - ); - - // Load error page with details - const { app } = require("electron"); - const statusText = this.getStatusText(httpResponseCode); - const isDev = process.env.NODE_ENV === "development"; - - // Create query params object for error details - const queryParamsObj = { - statusCode: httpResponseCode.toString(), - statusText: statusText, - url: originalURL, - }; - - if (isDev) { - // In dev mode, use temporary file with Vite dev server URLs - const devHtml = ` - - - - - - Error - - - - - -
- - -`; - - // Write to temporary file - const tmpDir = path.join(app.getPath("temp"), "aka-browser"); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const tmpHtmlPath = path.join(tmpDir, `error-page-http-${tabId}.html`); - fs.writeFileSync(tmpHtmlPath, devHtml, "utf-8"); - - // Load from temporary file (file:// protocol with webSecurity disabled allows loading from http://) - contents.loadFile(tmpHtmlPath).then(() => { - // Update tab info - const tab = this.state.tabs.find((t) => t.id === tabId); - if (tab) { - tab.url = "/"; - tab.title = "Aka Browser cannot open the page"; - } - - // Apply error-page theme color immediately - const errorPageThemeColor = "#2d2d2d"; - this.state.latestThemeColor = errorPageThemeColor; - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - errorPageThemeColor - ); - } - }).catch((err) => { - console.error("Failed to load error page from data URL:", err); - }); - } else { - // In production, generate HTML and load from temporary file - const distPath = path.join(app.getAppPath(), "dist-renderer"); - const scriptPath = path.join(distPath, "pages", "error-page.js"); - const html = generateErrorPageHtml(scriptPath, undefined, queryParamsObj, false); - - // Write to temporary file - const tmpDir = path.join(app.getPath("temp"), "aka-browser"); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const tmpHtmlPath = path.join(tmpDir, `error-page-${tabId}.html`); - fs.writeFileSync(tmpHtmlPath, html, "utf-8"); - - // Load the error page from temporary file - contents.loadFile(tmpHtmlPath).then(() => { - // Update tab info - const tab = this.state.tabs.find((t) => t.id === tabId); - if (tab) { - tab.url = "/"; - tab.title = "Aka Browser cannot open the page"; - } - - // Apply error-page theme color immediately - const errorPageThemeColor = "#2d2d2d"; - this.state.latestThemeColor = errorPageThemeColor; - if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { - this.state.mainWindow.webContents.send( - "webcontents-theme-color-updated", - errorPageThemeColor - ); - } - }).catch((err) => { - console.error("Failed to load error page:", err); - }); - } - - // Notify renderer about the error - this.state.mainWindow?.webContents.send( - "webcontents-http-error", - httpResponseCode, - statusText, - originalURL - ); - } - } + // Delegate fullscreen and navigation to sub-modules + setupFullscreenHandlers(contents, tabId, this.state); + setupNavigationHandlers( + contents, + tabId, + this.state, + this.themeColorCache, + (id) => this.captureTabPreview(id) ); } - - /** - * Get human-readable text for network errors - */ - private getNetworkErrorText(errorCode: number, errorDescription: string): string { - // Common Chromium network error codes - const networkErrors: Record = { - [-1]: "Unknown Error", - [-2]: "Failed", - [-3]: "Aborted", - [-4]: "Invalid Argument", - [-5]: "Invalid Handle", - [-6]: "File Not Found", - [-7]: "Timed Out", - [-10]: "Access Denied", - [-21]: "Network Changed", - [-23]: "Data Error", - [-100]: "Connection Closed", - [-101]: "Connection Reset", - [-102]: "Connection Refused", - [-103]: "Connection Aborted", - [-104]: "Connection Failed", - [-105]: "Name Not Resolved", - [-106]: "Internet Disconnected", - [-107]: "SSL Protocol Error", - [-108]: "Address Invalid", - [-109]: "Address Unreachable", - [-110]: "SSL Client Auth Cert Needed", - [-111]: "Tunnel Connection Failed", - [-112]: "No SSL Versions Enabled", - [-113]: "SSL Version or Cipher Mismatch", - [-114]: "SSL Renegotiation Requested", - [-115]: "Proxy Auth Unsupported", - [-116]: "Cert Error in SSL Renegotiation", - [-117]: "Bad SSL Client Auth Cert", - [-118]: "Connection Timed Out", - [-119]: "Host Resolver Queue Too Large", - [-120]: "SOCKS Connection Failed", - [-121]: "SOCKS Connection Host Unreachable", - [-200]: "Cert Common Name Invalid", - [-201]: "Cert Date Invalid", - [-202]: "Cert Authority Invalid", - [-203]: "Cert Contains Errors", - [-204]: "Cert No Revocation Mechanism", - [-205]: "Cert Unable to Check Revocation", - [-206]: "Cert Revoked", - [-207]: "Cert Invalid", - [-208]: "Cert Weak Signature Algorithm", - [-210]: "Cert Non Unique Name", - [-211]: "Cert Weak Key", - [-212]: "Cert Name Constraint Violation", - [-213]: "Cert Validity Too Long", - [-300]: "Invalid URL", - [-301]: "Disallowed URL Scheme", - [-302]: "Unknown URL Scheme", - [-310]: "Too Many Redirects", - [-320]: "Unsafe Redirect", - [-321]: "Unsafe Port", - [-322]: "Invalid Response", - [-323]: "Invalid Chunked Encoding", - [-324]: "Method Not Supported", - [-325]: "Unexpected Proxy Auth", - [-326]: "Empty Response", - [-327]: "Response Headers Too Big", - [-328]: "PAC Script Failed", - [-329]: "Request Range Not Satisfiable", - [-330]: "Malformed Identity", - [-331]: "Content Decoding Failed", - [-332]: "Network IO Suspended", - [-333]: "SYN Reply Not Received", - [-334]: "Encoding Conversion Failed", - [-335]: "Unrecognized FTP Directory Listing Format", - [-336]: "Invalid SPDY Stream", - [-337]: "No Supported Proxies", - [-338]: "SPDY Session Already Exists", - [-339]: "Limit Violation", - [-340]: "SPDY Protocol Error", - [-341]: "Invalid Auth Credentials", - [-342]: "Unsupported Auth Scheme", - [-343]: "Encoding Detection Failed", - [-344]: "Missing Auth Credentials", - [-345]: "Unexpected Security Library Status", - [-346]: "Misconfigured Auth Environment", - [-347]: "Undocumented Security Library Status", - [-348]: "Response Body Too Big Drain", - [-349]: "Response Headers Multiple Content Length", - [-350]: "Incomplete SPDY Headers", - [-351]: "PAC Not In DHCP", - [-352]: "Response Headers Multiple Content Disposition", - [-353]: "Response Headers Multiple Location", - [-354]: "SPDY Server Refused Stream", - [-355]: "SPDY Ping Failed", - [-356]: "Content Length Mismatch", - [-357]: "Incomplete Chunked Encoding", - [-358]: "QUIC Protocol Error", - [-359]: "Response Headers Truncated", - [-360]: "QUIC Handshake Failed", - [-361]: "SPDY Inadequate Transport Security", - [-362]: "SPDY Flow Control Error", - [-363]: "SPDY Stream Closed", - [-364]: "SPDY Frame Size Error", - [-365]: "SPDY Compression Error", - [-366]: "Proxy HTTP 1.1 Required", - [-367]: "Proxy HTTP2 or QUIC Required", - [-368]: "PAC Script Terminated", - [-370]: "Invalid HTTP Response", - [-371]: "Content Decoding Init Failed", - [-372]: "HTTP2 Compression Error", - [-373]: "HTTP2 Flow Control Error", - [-374]: "HTTP2 Frame Size Error", - [-375]: "HTTP2 Compression Error", - [-376]: "HTTP2 RST Stream No Error Received", - [-377]: "HTTP2 Pushed Stream Not Available", - [-378]: "HTTP2 Claimed Pushed Stream Reset By Server", - [-379]: "Too Many Retries", - [-380]: "HTTP2 Stream Closed", - [-381]: "HTTP2 Client Refused Stream", - [-382]: "HTTP2 Pushed Response Does Not Match", - [-400]: "Cache Miss", - [-401]: "Cache Read Failure", - [-402]: "Cache Write Failure", - [-403]: "Cache Operation Not Supported", - [-404]: "Cache Open Failure", - [-405]: "Cache Create Failure", - [-406]: "Cache Race", - [-407]: "Cache Checksum Read Failure", - [-408]: "Cache Checksum Mismatch", - [-409]: "Cache Lock Timeout", - [-501]: "Insecure Response", - [-502]: "No Private Key for Cert", - [-503]: "Add User Cert Failed", - [-800]: "DNS Malformed Response", - [-801]: "DNS Server Requires TCP", - [-802]: "DNS Server Failed", - [-803]: "DNS Transaction ID Mismatch", - [-804]: "DNS Name HTTPS Only", - [-805]: "DNS Request Cancelled", - }; - - return networkErrors[errorCode] || errorDescription || "Network Error"; - } - - /** - * Get human-readable status text for HTTP status codes - */ - private getStatusText(statusCode: number): string { - const statusTexts: Record = { - // 4xx Client Errors - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Payload Too Large", - 414: "URI Too Long", - 415: "Unsupported Media Type", - 416: "Range Not Satisfiable", - 417: "Expectation Failed", - 418: "I'm a teapot", - 421: "Misdirected Request", - 422: "Unprocessable Entity", - 423: "Locked", - 424: "Failed Dependency", - 425: "Too Early", - 426: "Upgrade Required", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - // 5xx Server Errors - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 506: "Variant Also Negotiates", - 507: "Insufficient Storage", - 508: "Loop Detected", - 510: "Not Extended", - 511: "Network Authentication Required", - }; - - return statusTexts[statusCode] || "Unknown Error"; - } - } diff --git a/apps/browser/src/main/tabs/tab-error-codes.ts b/apps/browser/src/main/tabs/tab-error-codes.ts new file mode 100644 index 0000000..e09a762 --- /dev/null +++ b/apps/browser/src/main/tabs/tab-error-codes.ts @@ -0,0 +1,197 @@ +/** + * Error code lookup tables for tab navigation failures. + * Covers Chromium network error codes and HTTP status codes. + */ + +/** Common Chromium network error codes → human-readable strings */ +const NETWORK_ERRORS: Record = { + [-1]: "Unknown Error", + [-2]: "Failed", + [-3]: "Aborted", + [-4]: "Invalid Argument", + [-5]: "Invalid Handle", + [-6]: "File Not Found", + [-7]: "Timed Out", + [-10]: "Access Denied", + [-21]: "Network Changed", + [-23]: "Data Error", + [-100]: "Connection Closed", + [-101]: "Connection Reset", + [-102]: "Connection Refused", + [-103]: "Connection Aborted", + [-104]: "Connection Failed", + [-105]: "Name Not Resolved", + [-106]: "Internet Disconnected", + [-107]: "SSL Protocol Error", + [-108]: "Address Invalid", + [-109]: "Address Unreachable", + [-110]: "SSL Client Auth Cert Needed", + [-111]: "Tunnel Connection Failed", + [-112]: "No SSL Versions Enabled", + [-113]: "SSL Version or Cipher Mismatch", + [-114]: "SSL Renegotiation Requested", + [-115]: "Proxy Auth Unsupported", + [-116]: "Cert Error in SSL Renegotiation", + [-117]: "Bad SSL Client Auth Cert", + [-118]: "Connection Timed Out", + [-119]: "Host Resolver Queue Too Large", + [-120]: "SOCKS Connection Failed", + [-121]: "SOCKS Connection Host Unreachable", + [-200]: "Cert Common Name Invalid", + [-201]: "Cert Date Invalid", + [-202]: "Cert Authority Invalid", + [-203]: "Cert Contains Errors", + [-204]: "Cert No Revocation Mechanism", + [-205]: "Cert Unable to Check Revocation", + [-206]: "Cert Revoked", + [-207]: "Cert Invalid", + [-208]: "Cert Weak Signature Algorithm", + [-210]: "Cert Non Unique Name", + [-211]: "Cert Weak Key", + [-212]: "Cert Name Constraint Violation", + [-213]: "Cert Validity Too Long", + [-300]: "Invalid URL", + [-301]: "Disallowed URL Scheme", + [-302]: "Unknown URL Scheme", + [-310]: "Too Many Redirects", + [-320]: "Unsafe Redirect", + [-321]: "Unsafe Port", + [-322]: "Invalid Response", + [-323]: "Invalid Chunked Encoding", + [-324]: "Method Not Supported", + [-325]: "Unexpected Proxy Auth", + [-326]: "Empty Response", + [-327]: "Response Headers Too Big", + [-328]: "PAC Script Failed", + [-329]: "Request Range Not Satisfiable", + [-330]: "Malformed Identity", + [-331]: "Content Decoding Failed", + [-332]: "Network IO Suspended", + [-333]: "SYN Reply Not Received", + [-334]: "Encoding Conversion Failed", + [-335]: "Unrecognized FTP Directory Listing Format", + [-336]: "Invalid SPDY Stream", + [-337]: "No Supported Proxies", + [-338]: "SPDY Session Already Exists", + [-339]: "Limit Violation", + [-340]: "SPDY Protocol Error", + [-341]: "Invalid Auth Credentials", + [-342]: "Unsupported Auth Scheme", + [-343]: "Encoding Detection Failed", + [-344]: "Missing Auth Credentials", + [-345]: "Unexpected Security Library Status", + [-346]: "Misconfigured Auth Environment", + [-347]: "Undocumented Security Library Status", + [-348]: "Response Body Too Big Drain", + [-349]: "Response Headers Multiple Content Length", + [-350]: "Incomplete SPDY Headers", + [-351]: "PAC Not In DHCP", + [-352]: "Response Headers Multiple Content Disposition", + [-353]: "Response Headers Multiple Location", + [-354]: "SPDY Server Refused Stream", + [-355]: "SPDY Ping Failed", + [-356]: "Content Length Mismatch", + [-357]: "Incomplete Chunked Encoding", + [-358]: "QUIC Protocol Error", + [-359]: "Response Headers Truncated", + [-360]: "QUIC Handshake Failed", + [-361]: "SPDY Inadequate Transport Security", + [-362]: "SPDY Flow Control Error", + [-363]: "SPDY Stream Closed", + [-364]: "SPDY Frame Size Error", + [-365]: "SPDY Compression Error", + [-366]: "Proxy HTTP 1.1 Required", + [-367]: "Proxy HTTP2 or QUIC Required", + [-368]: "PAC Script Terminated", + [-370]: "Invalid HTTP Response", + [-371]: "Content Decoding Init Failed", + [-372]: "HTTP2 Compression Error", + [-373]: "HTTP2 Flow Control Error", + [-374]: "HTTP2 Frame Size Error", + [-375]: "HTTP2 Compression Error", + [-376]: "HTTP2 RST Stream No Error Received", + [-377]: "HTTP2 Pushed Stream Not Available", + [-378]: "HTTP2 Claimed Pushed Stream Reset By Server", + [-379]: "Too Many Retries", + [-380]: "HTTP2 Stream Closed", + [-381]: "HTTP2 Client Refused Stream", + [-382]: "HTTP2 Pushed Response Does Not Match", + [-400]: "Cache Miss", + [-401]: "Cache Read Failure", + [-402]: "Cache Write Failure", + [-403]: "Cache Operation Not Supported", + [-404]: "Cache Open Failure", + [-405]: "Cache Create Failure", + [-406]: "Cache Race", + [-407]: "Cache Checksum Read Failure", + [-408]: "Cache Checksum Mismatch", + [-409]: "Cache Lock Timeout", + [-501]: "Insecure Response", + [-502]: "No Private Key for Cert", + [-503]: "Add User Cert Failed", + [-800]: "DNS Malformed Response", + [-801]: "DNS Server Requires TCP", + [-802]: "DNS Server Failed", + [-803]: "DNS Transaction ID Mismatch", + [-804]: "DNS Name HTTPS Only", + [-805]: "DNS Request Cancelled", +}; + +/** HTTP status codes → human-readable strings */ +const HTTP_STATUS_TEXTS: Record = { + // 4xx Client Errors + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Too Early", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + // 5xx Server Errors + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", +}; + +/** Get human-readable text for a Chromium network error code. */ +export function getNetworkErrorText( + errorCode: number, + errorDescription: string +): string { + return NETWORK_ERRORS[errorCode] ?? errorDescription ?? "Network Error"; +} + +/** Get human-readable text for an HTTP status code. */ +export function getStatusText(statusCode: number): string { + return HTTP_STATUS_TEXTS[statusCode] ?? "Unknown Error"; +} diff --git a/apps/browser/src/main/tabs/tab-fullscreen.ts b/apps/browser/src/main/tabs/tab-fullscreen.ts new file mode 100644 index 0000000..397b296 --- /dev/null +++ b/apps/browser/src/main/tabs/tab-fullscreen.ts @@ -0,0 +1,177 @@ +/** + * Fullscreen event handlers for tab WebContentsViews. + * + * Listens for Electron's native enter/leave-html-full-screen events and + * adjusts the view's bounds to account for device-frame rounded corners. + * Also exposes exitFullscreen() for ESC-key handling. + */ + +import { AppState } from "../types"; + +// Layout constants (must match renderer/window-manager constants) +const TOP_BAR_HEIGHT = 40; +const DEVICE_FRAME_PADDING = 15; +const DEVICE_BORDER_RADIUS = 32; +const STATUS_BAR_HEIGHT = 58; +const STATUS_BAR_WIDTH = 58; +const FRAME_HALF = DEVICE_FRAME_PADDING / 2; + +// Gaps applied while in fullscreen to avoid rounded corner artifacts +const FULLSCREEN_GAP_VERTICAL = + DEVICE_FRAME_PADDING + DEVICE_BORDER_RADIUS + 20; // ~67px portrait top/bottom +const FULLSCREEN_GAP_HORIZONTAL = + DEVICE_FRAME_PADDING + DEVICE_BORDER_RADIUS + 10; // ~57px landscape left/right + +function boundsForFullscreenLandscape(windowBounds: Electron.Rectangle) { + return { + x: FULLSCREEN_GAP_HORIZONTAL - 30, + y: TOP_BAR_HEIGHT + DEVICE_FRAME_PADDING, + width: windowBounds.width - FULLSCREEN_GAP_HORIZONTAL * 2, + height: windowBounds.height - TOP_BAR_HEIGHT - DEVICE_FRAME_PADDING * 2, + }; +} + +function boundsForFullscreenPortrait(windowBounds: Electron.Rectangle) { + return { + x: DEVICE_FRAME_PADDING, + y: TOP_BAR_HEIGHT + FULLSCREEN_GAP_VERTICAL - 30, + width: windowBounds.width - DEVICE_FRAME_PADDING * 2, + height: + windowBounds.height - + TOP_BAR_HEIGHT - + FULLSCREEN_GAP_VERTICAL - + FULLSCREEN_GAP_VERTICAL, + }; +} + +function boundsForNormalLandscape(windowBounds: Electron.Rectangle) { + return { + x: STATUS_BAR_WIDTH, + y: Math.round(TOP_BAR_HEIGHT + FRAME_HALF), + width: Math.round(windowBounds.width - STATUS_BAR_WIDTH - FRAME_HALF), + height: Math.round(windowBounds.height - TOP_BAR_HEIGHT - FRAME_HALF * 2), + }; +} + +function boundsForNormalPortrait(windowBounds: Electron.Rectangle) { + return { + x: Math.round(FRAME_HALF), + y: Math.round(TOP_BAR_HEIGHT + STATUS_BAR_HEIGHT + FRAME_HALF), + width: Math.round(windowBounds.width - FRAME_HALF * 2), + height: Math.round( + windowBounds.height - TOP_BAR_HEIGHT - STATUS_BAR_HEIGHT - FRAME_HALF * 2 + ), + }; +} + +/** + * Force a one-pixel window resize trick to make Electron recalculate + * WebContentsView layout after bounds changes. + */ +function forceWindowLayoutRecalc( + mainWindow: Electron.BrowserWindow +): void { + const b = mainWindow.getBounds(); + mainWindow.setBounds({ ...b, height: b.height + 1 }); + mainWindow.setBounds(b); +} + +/** + * Register enter/leave-html-full-screen event handlers on `contents`. + * Mutates `state.tabs` to set the `isFullscreen` flag. + */ +export function setupFullscreenHandlers( + contents: Electron.WebContents, + tabId: string, + state: AppState +): void { + contents.on("enter-html-full-screen", () => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab) return; + + const timestamp = new Date().toISOString().split("T")[1].slice(0, -1); + console.log( + `[Fullscreen][${timestamp}] enter-html-full-screen event received` + ); + + tab.isFullscreen = true; + + if (!state.mainWindow) return; + + const windowBounds = state.mainWindow.getBounds(); + const isLandscape = windowBounds.width > windowBounds.height; + const bounds = isLandscape + ? boundsForFullscreenLandscape(windowBounds) + : boundsForFullscreenPortrait(windowBounds); + + tab.view.setBounds(bounds); + state.mainWindow.webContents.send("fullscreen-mode-changed", true); + + forceWindowLayoutRecalc(state.mainWindow); + + // Reapply after resize trick + tab.view.setBounds(bounds); + + if (!tab.view.webContents.isDestroyed()) { + tab.view.webContents.send("set-fullscreen-state", true); + } + }); + + contents.on("leave-html-full-screen", () => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab) return; + + tab.isFullscreen = false; + + if (!state.mainWindow) return; + + state.mainWindow.webContents.send("fullscreen-mode-changed", false); + + const windowBounds = state.mainWindow.getBounds(); + const isLandscape = windowBounds.width > windowBounds.height; + const bounds = isLandscape + ? boundsForNormalLandscape(windowBounds) + : boundsForNormalPortrait(windowBounds); + + tab.view.setBounds(bounds); + forceWindowLayoutRecalc(state.mainWindow); + + // Reapply after resize trick + tab.view.setBounds(bounds); + + if (!tab.view.webContents.isDestroyed()) { + tab.view.webContents.send("set-fullscreen-state", false); + } + }); +} + +/** + * Programmatically exit fullscreen for a tab (called by ESC-key handler). + * No-op if the tab is not currently fullscreen. + */ +export function exitFullscreen(tabId: string, state: AppState): void { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab || !tab.isFullscreen) return; + + tab.view.webContents + .executeJavaScript( + ` + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } + ` + ) + .catch((err) => { + console.error("[Fullscreen] Failed to exit fullscreen:", err); + }); + + if (!tab.view.webContents.isDestroyed()) { + tab.view.webContents.send("webview-fullscreen-exited"); + } +} diff --git a/apps/browser/src/main/tabs/tab-navigation.ts b/apps/browser/src/main/tabs/tab-navigation.ts new file mode 100644 index 0000000..80de156 --- /dev/null +++ b/apps/browser/src/main/tabs/tab-navigation.ts @@ -0,0 +1,277 @@ +/** + * Navigation event handlers for tab WebContentsViews. + * + * Covers: did-start/stop-loading, did-navigate, did-navigate-in-page, + * dom-ready, did-fail-load, did-get-response-details, render-process-gone. + * Error-page loading is delegated to tab-page-loader. + * Error-code lookup is delegated to tab-error-codes. + */ + +import { AppState } from "../types"; +import { ThemeColorCache } from "../theme-cache"; +import { getNetworkErrorText, getStatusText } from "./tab-error-codes"; +import { loadErrorPage, ErrorPageParams } from "./tab-page-loader"; + +/** Shared tab-list snapshot shape sent to renderer. */ +function tabsSnapshot(state: AppState) { + return state.tabs.map((t) => ({ + id: t.id, + title: t.title, + url: t.url, + preview: t.preview, + })); +} + +/** + * Apply theme color from cache or reset it, then notify renderer. + * Called when navigating to a new URL. + */ +function applyThemeColorForUrl( + url: string, + state: AppState, + themeColorCache: ThemeColorCache +): void { + try { + const domain = new URL(url).hostname; + const cached = themeColorCache.get(domain); + if (cached) { + state.latestThemeColor = cached; + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send( + "webcontents-theme-color-updated", + cached + ); + } + } else { + state.latestThemeColor = null; + } + } catch { + state.latestThemeColor = null; + } +} + +/** Resolve the display URL and update the tab's stored url/title. */ +function resolveNavigatedUrl( + contents: Electron.WebContents, + rawUrl: string, + tabId: string, + state: AppState +): string { + const tab = state.tabs.find((t) => t.id === tabId); + let displayUrl = rawUrl; + + if (tab) { + if (rawUrl.includes("blank-page-tab-")) { + tab.url = "/"; + tab.title = "Blank Page"; + displayUrl = "/"; + } else if (rawUrl.includes("error-page-tab-")) { + tab.url = "/"; + tab.title = + contents.getTitle() || "Aka Browser cannot open the page"; + displayUrl = "/"; + } else { + tab.url = rawUrl; + tab.title = contents.getTitle() || rawUrl; + } + } + + return displayUrl; +} + +/** Apply the error-page theme color to state and notify renderer. */ +function applyErrorPageThemeColor(state: AppState): void { + const errorPageThemeColor = "#2d2d2d"; + state.latestThemeColor = errorPageThemeColor; + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send( + "webcontents-theme-color-updated", + errorPageThemeColor + ); + } +} + +/** Mark tab as error page and update its url/title. */ +function markTabAsErrorPage(tabId: string, state: AppState): void { + const tab = state.tabs.find((t) => t.id === tabId); + if (tab) { + tab.url = "/"; + tab.title = "Aka Browser cannot open the page"; + } +} + +/** + * Register all navigation-related WebContents event handlers. + * `captureTabPreview` is injected to avoid a circular dependency on TabManager. + */ +export function setupNavigationHandlers( + contents: Electron.WebContents, + tabId: string, + state: AppState, + themeColorCache: ThemeColorCache, + captureTabPreview: (tabId: string) => Promise +): void { + // ── Loading start / stop ────────────────────────────────────────────────── + + contents.on("did-start-loading", () => { + const url = contents.getURL(); + if (url) { + applyThemeColorForUrl(url, state, themeColorCache); + } else { + state.latestThemeColor = null; + } + state.mainWindow?.webContents.send("webcontents-did-start-loading"); + }); + + contents.on("did-stop-loading", () => { + state.mainWindow?.webContents.send("webcontents-did-stop-loading"); + setTimeout(() => { + captureTabPreview(tabId).catch((err) => { + console.error("Failed to capture preview after loading:", err); + }); + }, 500); + }); + + // ── Navigation events ───────────────────────────────────────────────────── + + contents.on("did-navigate", (_event: any, url: string) => { + const displayUrl = resolveNavigatedUrl(contents, url, tabId, state); + state.mainWindow?.webContents.send("webcontents-did-navigate", displayUrl); + + if (state.activeTabId === tabId && state.mainWindow) { + state.mainWindow.webContents.send("tabs-updated", { + tabs: tabsSnapshot(state), + activeTabId: state.activeTabId, + }); + } + }); + + contents.on("did-navigate-in-page", (_event: any, url: string) => { + const displayUrl = resolveNavigatedUrl(contents, url, tabId, state); + state.mainWindow?.webContents.send( + "webcontents-did-navigate-in-page", + displayUrl + ); + + if (state.activeTabId === tabId && state.mainWindow) { + state.mainWindow.webContents.send("tabs-updated", { + tabs: tabsSnapshot(state), + activeTabId: state.activeTabId, + }); + } + }); + + contents.on("dom-ready", () => { + state.mainWindow?.webContents.send("webcontents-dom-ready"); + }); + + // ── Load failure ────────────────────────────────────────────────────────── + + contents.on( + "did-fail-load", + ( + _event: any, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => { + // ERR_ABORTED (-3) and sub-frame failures are expected / benign + if (errorCode === -3 || !isMainFrame) return; + + console.log( + `[TabNavigation] Page load failed: ${errorCode} (${errorDescription}) for ${validatedURL}` + ); + + const statusText = getNetworkErrorText(errorCode, errorDescription); + const params: ErrorPageParams = { + statusCode: Math.abs(errorCode).toString(), + statusText, + url: validatedURL, + }; + + setTimeout(() => { + if (contents.isDestroyed()) { + console.log("[TabNavigation] Contents destroyed, skipping error page"); + return; + } + + console.log("[TabNavigation] Loading error page now"); + loadErrorPage(contents, tabId, params) + .then(() => { + console.log("[TabNavigation] Error page loaded successfully"); + markTabAsErrorPage(tabId, state); + applyErrorPageThemeColor(state); + }) + .catch((err) => { + console.error("[TabNavigation] Failed to load error page:", err); + }); + }, 100); + + state.mainWindow?.webContents.send( + "webcontents-did-fail-load", + errorCode, + errorDescription + ); + } + ); + + // ── Render-process crash ────────────────────────────────────────────────── + + contents.on("render-process-gone", (_event: any, details: any) => { + state.mainWindow?.webContents.send( + "webcontents-render-process-gone", + details + ); + }); + + // ── HTTP-level errors (non-2xx main-frame responses) ────────────────────── + + (contents as any).on( + "did-get-response-details", + ( + _event: any, + _status: boolean, + _newURL: string, + originalURL: string, + httpResponseCode: number, + _requestMethod: string, + _referrer: string, + _headers: Record, + resourceType: string + ) => { + if (resourceType !== "mainFrame") return; + if (httpResponseCode >= 200 && httpResponseCode < 300) return; + + console.log( + `[TabNavigation] Non-success HTTP response: ${httpResponseCode} for ${originalURL}` + ); + + const statusText = getStatusText(httpResponseCode); + const params: ErrorPageParams = { + statusCode: httpResponseCode.toString(), + statusText, + url: originalURL, + }; + + loadErrorPage(contents, tabId, params, "http") + .then(() => { + markTabAsErrorPage(tabId, state); + applyErrorPageThemeColor(state); + }) + .catch((err) => { + console.error( + "[TabNavigation] Failed to load HTTP error page:", + err + ); + }); + + state.mainWindow?.webContents.send( + "webcontents-http-error", + httpResponseCode, + statusText, + originalURL + ); + } + ); +} diff --git a/apps/browser/src/main/tabs/tab-page-loader.ts b/apps/browser/src/main/tabs/tab-page-loader.ts new file mode 100644 index 0000000..36ea9ae --- /dev/null +++ b/apps/browser/src/main/tabs/tab-page-loader.ts @@ -0,0 +1,133 @@ +/** + * Helpers for loading blank-page and error-page HTML into a WebContentsView. + * Handles both development (Vite dev server) and production (dist-renderer) modes. + */ + +import { app } from "electron"; +import path from "path"; +import fs from "fs"; +import { generateBlankPageHtml, generateErrorPageHtml } from "../html-generator"; + +/** Params forwarded to the error page's __QUERY_PARAMS__ global. */ +export interface ErrorPageParams extends Record { + statusCode: string; + statusText: string; + url: string; +} + +/** Ensure the per-session temp directory exists and return its path. */ +function ensureTmpDir(): string { + const tmpDir = path.join(app.getPath("temp"), "aka-browser"); + if (!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir, { recursive: true }); + } + return tmpDir; +} + +/** Build the dev-mode blank-page HTML that loads from the Vite dev server. */ +function buildDevBlankHtml(): string { + return ` + + + + + + Blank Page + + + + +
+ + +`; +} + +/** Build the dev-mode error-page HTML that loads from the Vite dev server. */ +function buildDevErrorHtml(params: ErrorPageParams): string { + return ` + + + + + + Error + + + + + +
+ + +`; +} + +/** + * Write the blank-page HTML to a temp file and load it into `contents`. + * Returns the temp file path so callers can clean up later if needed. + */ +export function loadBlankPage( + contents: Electron.WebContents, + tabId: string +): void { + const isDev = process.env.NODE_ENV === "development"; + const tmpDir = ensureTmpDir(); + const tmpHtmlPath = path.join(tmpDir, `blank-page-${tabId}.html`); + + let html: string; + if (isDev) { + html = buildDevBlankHtml(); + } else { + const distPath = path.join(app.getAppPath(), "dist-renderer"); + const scriptPath = path.join(distPath, "pages", "blank-page.js"); + html = generateBlankPageHtml(scriptPath, undefined, false); + } + + fs.writeFileSync(tmpHtmlPath, html, "utf-8"); + contents.loadFile(tmpHtmlPath).catch((err) => { + console.error("[TabPageLoader] Failed to load blank page:", err); + }); +} + +/** + * Write the error-page HTML to a temp file and load it into `contents`. + * Resolves with the tab-info title string once the load completes. + */ +export async function loadErrorPage( + contents: Electron.WebContents, + tabId: string, + params: ErrorPageParams, + suffix: string = "" +): Promise { + const isDev = process.env.NODE_ENV === "development"; + const tmpDir = ensureTmpDir(); + const filename = suffix + ? `error-page-${suffix}-${tabId}.html` + : `error-page-${tabId}.html`; + const tmpHtmlPath = path.join(tmpDir, filename); + + let html: string; + if (isDev) { + html = buildDevErrorHtml(params); + } else { + const distPath = path.join(app.getAppPath(), "dist-renderer"); + const scriptPath = path.join(distPath, "pages", "error-page.js"); + html = generateErrorPageHtml(scriptPath, undefined, params, false); + } + + fs.writeFileSync(tmpHtmlPath, html, "utf-8"); + await contents.loadFile(tmpHtmlPath); +}