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