diff --git a/apps/browser/package.json b/apps/browser/package.json index 93ed62e..0bb8a87 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -21,6 +21,7 @@ "evs:verify": "node scripts/evs-sign.js --verify", "check-types": "tsc --noEmit", "lint": "tsc --noEmit --pretty false", + "test": "vitest run", "audit": "npm audit", "audit:fix": "npm audit fix", "outdated": "npm outdated" @@ -48,7 +49,8 @@ "esbuild": "^0.28.0", "tailwindcss": "^4.3.0", "typescript": "5.9.2", - "vite": "^8.0.13" + "vite": "^8.0.13", + "vitest": "^3.2.4" }, "build": { "appId": "com.aka-browser.app", diff --git a/apps/browser/src/main/__tests__/browsing-data-manager.test.ts b/apps/browser/src/main/__tests__/browsing-data-manager.test.ts new file mode 100644 index 0000000..a6b9dd0 --- /dev/null +++ b/apps/browser/src/main/__tests__/browsing-data-manager.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { BrowsingDataManager } from "../browsing-data-manager"; + +function createManager() { + const history = { clear: vi.fn() }; + const permissions = { clear: vi.fn() }; + const sessionState = { clear: vi.fn() }; + const favicon = { clearCache: vi.fn() }; + const theme = { clear: vi.fn() }; + const electronSession = { + clearCache: vi.fn(async () => undefined), + clearStorageData: vi.fn(async () => undefined), + }; + + return { + electronSession, + favicon, + history, + manager: new BrowsingDataManager({ + electronSession, + faviconCache: favicon, + historyManager: history, + permissionManager: permissions, + sessionManager: sessionState, + themeColorCache: theme, + }), + permissions, + sessionState, + theme, + }; +} + +describe("BrowsingDataManager", () => { + it("clears history", async () => { + const { history, manager } = createManager(); + + await expect(manager.clearHistory()).resolves.toEqual({ + ok: true, + target: "history", + }); + expect(history.clear).toHaveBeenCalledOnce(); + }); + + it("clears cookies and cache through Electron session", async () => { + const { electronSession, manager } = createManager(); + + await manager.clearCookies(); + await manager.clearCache(); + + expect(electronSession.clearStorageData).toHaveBeenCalledWith({ + storages: ["cookies"], + }); + expect(electronSession.clearCache).toHaveBeenCalledOnce(); + }); + + it("clears all app-owned site data", async () => { + const { favicon, history, manager, permissions, sessionState, theme } = + createManager(); + + const results = await manager.clearAll(); + + expect(results.every((result) => result.ok)).toBe(true); + expect(history.clear).toHaveBeenCalledOnce(); + expect(permissions.clear).toHaveBeenCalledOnce(); + expect(sessionState.clear).toHaveBeenCalledOnce(); + expect(favicon.clearCache).toHaveBeenCalledOnce(); + expect(theme.clear).toHaveBeenCalledOnce(); + }); + + it("returns structured failures", async () => { + const { manager, history } = createManager(); + history.clear.mockImplementation(() => { + throw new Error("disk failed"); + }); + + await expect(manager.clearHistory()).resolves.toEqual({ + error: "disk failed", + ok: false, + target: "history", + }); + }); +}); diff --git a/apps/browser/src/main/__tests__/download-manager.test.ts b/apps/browser/src/main/__tests__/download-manager.test.ts new file mode 100644 index 0000000..6d8d04a --- /dev/null +++ b/apps/browser/src/main/__tests__/download-manager.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { DownloadManager } from "../download-manager"; + +describe("DownloadManager", () => { + it("starts downloads and lists newest first", () => { + const manager = new DownloadManager(); + + const first = manager.startDownload({ + filename: "a.txt", + savePath: "/tmp/a.txt", + totalBytes: 10, + url: "https://example.com/a.txt", + }, 100); + const second = manager.startDownload({ + filename: "b.txt", + savePath: "/tmp/b.txt", + totalBytes: 20, + url: "https://example.com/b.txt", + }, 200); + + expect(manager.list().map((item) => item.id)).toEqual([second.id, first.id]); + }); + + it("tracks progress", () => { + const manager = new DownloadManager(); + const item = manager.startDownload({ + filename: "a.txt", + savePath: "/tmp/a.txt", + totalBytes: 10, + url: "https://example.com/a.txt", + }, 100); + + manager.updateProgress(item.id, 5, 10); + + expect(manager.get(item.id)?.receivedBytes).toBe(5); + expect(manager.get(item.id)?.totalBytes).toBe(10); + }); + + it("marks completed downloads", () => { + const manager = new DownloadManager(); + const item = manager.startDownload({ + filename: "a.txt", + savePath: "/tmp/a.txt", + totalBytes: 10, + url: "https://example.com/a.txt", + }, 100); + + manager.finishDownload(item.id, "completed", 200); + + expect(manager.get(item.id)?.state).toBe("completed"); + expect(manager.get(item.id)?.endedAt).toBe(200); + }); + + it("clears completed downloads only", () => { + const manager = new DownloadManager(); + const completed = manager.startDownload({ + filename: "a.txt", + savePath: "/tmp/a.txt", + totalBytes: 10, + url: "https://example.com/a.txt", + }, 100); + const active = manager.startDownload({ + filename: "b.txt", + savePath: "/tmp/b.txt", + totalBytes: 20, + url: "https://example.com/b.txt", + }, 200); + manager.finishDownload(completed.id, "completed", 300); + + manager.clearCompleted(); + + expect(manager.list().map((item) => item.id)).toEqual([active.id]); + }); +}); diff --git a/apps/browser/src/main/__tests__/external-protocol.test.ts b/apps/browser/src/main/__tests__/external-protocol.test.ts new file mode 100644 index 0000000..9aa866c --- /dev/null +++ b/apps/browser/src/main/__tests__/external-protocol.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + buildExternalProtocolPrompt, + getExternalProtocol, + isConfirmableExternalProtocol, +} from "../external-protocol"; + +describe("external protocol helpers", () => { + it("extracts the protocol from app links", () => { + expect(getExternalProtocol("mailto:test@example.com")).toBe("mailto:"); + expect(getExternalProtocol("tel:+15551234567")).toBe("tel:"); + }); + + it("rejects web URLs as external protocols", () => { + expect(getExternalProtocol("https://example.com")).toBeNull(); + }); + + it("allows only confirmable external protocols", () => { + expect(isConfirmableExternalProtocol("mailto:")).toBe(true); + expect(isConfirmableExternalProtocol("tel:")).toBe(true); + expect(isConfirmableExternalProtocol("unknown:")).toBe(false); + }); + + it("builds a user-facing prompt", () => { + expect( + buildExternalProtocolPrompt("mailto:test@example.com", "https://example.com") + ).toEqual({ + detail: + "https://example.com wants to open mailto:test@example.com outside aka-browser.", + message: "Open mailto: link?", + }); + }); +}); diff --git a/apps/browser/src/main/__tests__/history-manager.test.ts b/apps/browser/src/main/__tests__/history-manager.test.ts new file mode 100644 index 0000000..d12c0d4 --- /dev/null +++ b/apps/browser/src/main/__tests__/history-manager.test.ts @@ -0,0 +1,81 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HistoryManager } from "../history-manager"; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aka-history-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { force: true, recursive: true }); +}); + +describe("HistoryManager", () => { + it("records new visits", () => { + const manager = new HistoryManager(tempDir); + + manager.recordVisit("https://example.com", "Example", 100); + + expect(manager.list()).toEqual([ + { + title: "Example", + url: "https://example.com", + visitCount: 1, + visitedAt: 100, + }, + ]); + }); + + it("deduplicates by URL and increments visit count", () => { + const manager = new HistoryManager(tempDir); + + manager.recordVisit("https://example.com", "Old", 100); + manager.recordVisit("https://example.com", "New", 200); + + expect(manager.list()).toEqual([ + { + title: "New", + url: "https://example.com", + visitCount: 2, + visitedAt: 200, + }, + ]); + }); + + it("sorts newest visits first and applies limits", () => { + const manager = new HistoryManager(tempDir); + + manager.recordVisit("https://a.example", "A", 100); + manager.recordVisit("https://b.example", "B", 300); + manager.recordVisit("https://c.example", "C", 200); + + expect(manager.list(2).map((entry) => entry.url)).toEqual([ + "https://b.example", + "https://c.example", + ]); + }); + + it("clears history", () => { + const manager = new HistoryManager(tempDir); + manager.recordVisit("https://example.com", "Example", 100); + + manager.clear(); + + expect(manager.list()).toEqual([]); + }); + + it("recovers from corrupt storage", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + fs.writeFileSync(path.join(tempDir, "history.json"), "{", "utf-8"); + + const manager = new HistoryManager(tempDir); + + expect(manager.list()).toEqual([]); + expect(errorSpy).toHaveBeenCalledOnce(); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/browser/src/main/__tests__/permission-manager.test.ts b/apps/browser/src/main/__tests__/permission-manager.test.ts new file mode 100644 index 0000000..eb4d35b --- /dev/null +++ b/apps/browser/src/main/__tests__/permission-manager.test.ts @@ -0,0 +1,77 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + PermissionManager, + SitePermission, +} from "../permission-manager"; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aka-permissions-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { force: true, recursive: true }); +}); + +describe("PermissionManager", () => { + it("returns prompt for supported permissions without a saved decision", () => { + const manager = new PermissionManager(tempDir); + + expect(manager.getDecision("https://example.com", "media")).toBe("prompt"); + }); + + it("persists allow and block decisions by origin and permission", () => { + const manager = new PermissionManager(tempDir); + + manager.setDecision("https://example.com/path", "media", "allow"); + manager.setDecision("https://example.com/path", "fullscreen", "block"); + + const reloaded = new PermissionManager(tempDir); + expect(reloaded.getDecision("https://example.com", "media")).toBe("allow"); + expect(reloaded.getDecision("https://example.com", "fullscreen")).toBe( + "block" + ); + }); + + it("lists decisions in updated order", () => { + const manager = new PermissionManager(tempDir); + + manager.setDecision("https://a.example", "media", "allow", 10); + manager.setDecision("https://b.example", "fullscreen", "block", 20); + + expect(manager.list().map((entry) => entry.origin)).toEqual([ + "https://b.example", + "https://a.example", + ]); + }); + + it("clears one origin or all decisions", () => { + const manager = new PermissionManager(tempDir); + const permission: SitePermission = "media"; + + manager.setDecision("https://a.example", permission, "allow"); + manager.setDecision("https://b.example", permission, "block"); + manager.clear("https://a.example/path"); + + expect(manager.getDecision("https://a.example", permission)).toBe("prompt"); + expect(manager.getDecision("https://b.example", permission)).toBe("block"); + + manager.clear(); + expect(manager.list()).toEqual([]); + }); + + it("recovers from corrupt storage", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + fs.writeFileSync(path.join(tempDir, "site-permissions.json"), "{", "utf-8"); + + const manager = new PermissionManager(tempDir); + + expect(manager.list()).toEqual([]); + expect(errorSpy).toHaveBeenCalledOnce(); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/browser/src/main/__tests__/security-policy.test.ts b/apps/browser/src/main/__tests__/security-policy.test.ts new file mode 100644 index 0000000..6206f16 --- /dev/null +++ b/apps/browser/src/main/__tests__/security-policy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + classifyNavigationTarget, + isNavigableWebUrl, + sanitizeNavigationInput, +} from "../security-policy"; + +describe("security policy", () => { + it("normalizes bare external domains to https", () => { + expect(sanitizeNavigationInput("example.com")).toBe("https://example.com"); + }); + + it("normalizes localhost to http", () => { + expect(sanitizeNavigationInput("localhost:5173")).toBe( + "http://localhost:5173" + ); + }); + + it("removes control characters from user input", () => { + expect(sanitizeNavigationInput("\nexample.com\t")).toBe( + "https://example.com" + ); + }); + + it("allows http and https web URLs", () => { + expect(isNavigableWebUrl("https://example.com", "production")).toBe(true); + expect(isNavigableWebUrl("http://localhost:5173", "production")).toBe( + true + ); + }); + + it("allows file URLs only in development", () => { + expect(isNavigableWebUrl("file:///tmp/page.html", "development")).toBe( + true + ); + expect(isNavigableWebUrl("file:///tmp/page.html", "production")).toBe( + false + ); + }); + + it("blocks dangerous protocols", () => { + expect(classifyNavigationTarget("javascript:alert(1)", "production")).toEqual( + { + kind: "blocked", + reason: "Blocked protocol javascript:", + url: "javascript:alert(1)", + } + ); + expect(classifyNavigationTarget("data:text/html,hi", "production").kind).toBe( + "blocked" + ); + }); + + it("classifies external app protocols", () => { + const result = classifyNavigationTarget("mailto:test@example.com", "production"); + expect(result).toEqual({ + kind: "external", + protocol: "mailto:", + url: "mailto:test@example.com", + }); + }); + + it("blocks unsupported protocols", () => { + expect(classifyNavigationTarget("ftp://example.com/file", "production")).toEqual( + { + kind: "blocked", + reason: "Unsupported protocol ftp:", + url: "ftp://example.com/file", + } + ); + }); +}); diff --git a/apps/browser/src/main/__tests__/session-manager.test.ts b/apps/browser/src/main/__tests__/session-manager.test.ts new file mode 100644 index 0000000..e1b198a --- /dev/null +++ b/apps/browser/src/main/__tests__/session-manager.test.ts @@ -0,0 +1,71 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../session-manager"; + +let tempDir: string; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aka-session-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { force: true, recursive: true }); +}); + +describe("SessionManager", () => { + it("saves and loads session snapshots", () => { + const manager = new SessionManager(tempDir); + + manager.save({ + activeTabId: "tab-1", + orientation: "landscape", + savedAt: 100, + tabs: [{ id: "tab-1", title: "Example", url: "https://example.com" }], + }); + + expect(new SessionManager(tempDir).load()).toEqual({ + activeTabId: "tab-1", + orientation: "landscape", + savedAt: 100, + tabs: [{ id: "tab-1", title: "Example", url: "https://example.com" }], + }); + }); + + it("normalizes internal and invalid restore URLs to blank tabs", () => { + const manager = new SessionManager(tempDir); + + expect(manager.normalizeUrlForRestore("/")).toBe(""); + expect(manager.normalizeUrlForRestore("file:///tmp/blank-page-tab-1.html")).toBe(""); + expect(manager.normalizeUrlForRestore("notaurl")).toBe(""); + expect(manager.normalizeUrlForRestore("https://example.com")).toBe( + "https://example.com" + ); + }); + + it("returns null for corrupt storage", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + fs.writeFileSync(path.join(tempDir, "session.json"), "{", "utf-8"); + + const manager = new SessionManager(tempDir); + + expect(manager.load()).toBeNull(); + expect(errorSpy).toHaveBeenCalledOnce(); + errorSpy.mockRestore(); + }); + + it("clears saved sessions", () => { + const manager = new SessionManager(tempDir); + manager.save({ + activeTabId: "tab-1", + orientation: "portrait", + savedAt: 100, + tabs: [{ id: "tab-1", title: "Example", url: "https://example.com" }], + }); + + manager.clear(); + + expect(manager.load()).toBeNull(); + }); +}); diff --git a/apps/browser/src/main/__tests__/smoke.test.ts b/apps/browser/src/main/__tests__/smoke.test.ts new file mode 100644 index 0000000..7b5d5ef --- /dev/null +++ b/apps/browser/src/main/__tests__/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from "vitest"; + +describe("test foundation", () => { + it("runs main-process unit tests", () => { + expect(true).toBe(true); + }); +}); diff --git a/apps/browser/src/main/__tests__/theme-cache.test.ts b/apps/browser/src/main/__tests__/theme-cache.test.ts new file mode 100644 index 0000000..c12a301 --- /dev/null +++ b/apps/browser/src/main/__tests__/theme-cache.test.ts @@ -0,0 +1,58 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const electronMock = vi.hoisted(() => ({ + beforeQuit: undefined as (() => void) | undefined, + userDataPath: "", +})); + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => electronMock.userDataPath), + on: vi.fn((event: string, callback: () => void) => { + if (event === "before-quit") { + electronMock.beforeQuit = callback; + } + }), + }, +})); + +import { ThemeColorCache } from "../theme-cache"; + +describe("ThemeColorCache", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aka-theme-cache-")); + electronMock.userDataPath = tempDir; + electronMock.beforeQuit = undefined; + }); + + afterEach(() => { + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + it("moves corrupt cache files aside and saves valid JSON afterward", () => { + const cachePath = path.join(tempDir, "theme-colors.json"); + fs.writeFileSync(cachePath, "{", "utf-8"); + + const cache = new ThemeColorCache(); + + expect(cache.get("example.com")).toBeNull(); + expect(fs.existsSync(cachePath)).toBe(false); + expect(fs.existsSync(`${cachePath}.corrupt`)).toBe(true); + + cache.set("example.com", "#ffffff"); + electronMock.beforeQuit?.(); + + expect(JSON.parse(fs.readFileSync(cachePath, "utf-8"))).toEqual({ + "example.com": { + color: "#ffffff", + timestamp: expect.any(Number), + }, + }); + expect(fs.existsSync(`${cachePath}.${process.pid}.tmp`)).toBe(false); + }); +}); diff --git a/apps/browser/src/main/app-lifecycle.ts b/apps/browser/src/main/app-lifecycle.ts index 261bc73..520b34a 100644 --- a/apps/browser/src/main/app-lifecycle.ts +++ b/apps/browser/src/main/app-lifecycle.ts @@ -37,8 +37,6 @@ export class AppLifecycle { // Enable Widevine features and DRM app.commandLine.appendSwitch("enable-features", "PlatformEncryptedDolbyVision"); - app.commandLine.appendSwitch("ignore-certificate-errors"); - app.commandLine.appendSwitch("allow-running-insecure-content"); } /** diff --git a/apps/browser/src/main/browsing-data-manager.ts b/apps/browser/src/main/browsing-data-manager.ts new file mode 100644 index 0000000..b6acffd --- /dev/null +++ b/apps/browser/src/main/browsing-data-manager.ts @@ -0,0 +1,109 @@ +import { FaviconCache } from "./favicon-cache"; +import { HistoryManager } from "./history-manager"; +import { PermissionManager } from "./permission-manager"; +import { SessionManager } from "./session-manager"; +import { ThemeColorCache } from "./theme-cache"; + +interface ElectronSessionLike { + clearCache(): Promise; + clearStorageData(options?: { storages?: string[] }): Promise; +} + +export interface ClearResult { + error?: string; + ok: boolean; + target: string; +} + +interface BrowsingDataManagerOptions { + electronSession: ElectronSessionLike; + faviconCache: Pick; + historyManager: Pick; + permissionManager: Pick; + sessionManager: Pick; + themeColorCache: Pick; +} + +export class BrowsingDataManager { + private readonly electronSession: ElectronSessionLike; + private readonly faviconCache: Pick; + private readonly historyManager: Pick; + private readonly permissionManager: Pick; + private readonly sessionManager: Pick; + private readonly themeColorCache: Pick; + + constructor(options: BrowsingDataManagerOptions) { + this.electronSession = options.electronSession; + this.faviconCache = options.faviconCache; + this.historyManager = options.historyManager; + this.permissionManager = options.permissionManager; + this.sessionManager = options.sessionManager; + this.themeColorCache = options.themeColorCache; + } + + clearHistory(): Promise { + return this.run("history", () => this.historyManager.clear()); + } + + clearPermissions(): Promise { + return this.run("permissions", () => this.permissionManager.clear()); + } + + clearSessionRestore(): Promise { + return this.run("session", () => this.sessionManager.clear()); + } + + clearFavicons(): Promise { + return this.run("favicons", () => this.faviconCache.clearCache()); + } + + clearThemeColors(): Promise { + return this.run("theme-colors", () => this.themeColorCache.clear()); + } + + clearCache(): Promise { + return this.run("cache", () => this.electronSession.clearCache()); + } + + clearCookies(): Promise { + return this.run("cookies", () => + this.electronSession.clearStorageData({ storages: ["cookies"] }) + ); + } + + clearSiteData(): Promise { + return this.run("site-data", () => + this.electronSession.clearStorageData({ + storages: ["cookies", "localstorage", "indexdb", "cachestorage"], + }) + ); + } + + async clearAll(): Promise { + return Promise.all([ + this.clearHistory(), + this.clearPermissions(), + this.clearSessionRestore(), + this.clearFavicons(), + this.clearThemeColors(), + this.clearCache(), + this.clearSiteData(), + ]); + } + + private async run( + target: string, + operation: () => Promise | void + ): Promise { + try { + await operation(); + return { ok: true, target }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + ok: false, + target, + }; + } + } +} diff --git a/apps/browser/src/main/download-manager.ts b/apps/browser/src/main/download-manager.ts new file mode 100644 index 0000000..2e284a8 --- /dev/null +++ b/apps/browser/src/main/download-manager.ts @@ -0,0 +1,113 @@ +import { shell } from "electron"; + +export type DownloadState = + | "active" + | "cancelled" + | "completed" + | "interrupted"; + +export interface DownloadItem { + endedAt?: number; + filename: string; + id: string; + receivedBytes: number; + savePath: string; + startedAt: number; + state: DownloadState; + totalBytes: number; + url: string; +} + +export interface StartDownloadInput { + filename: string; + savePath: string; + totalBytes: number; + url: string; +} + +export class DownloadManager { + private downloads: DownloadItem[] = []; + private sequence = 0; + + startDownload(input: StartDownloadInput, startedAt = Date.now()): DownloadItem { + const item: DownloadItem = { + endedAt: undefined, + filename: input.filename, + id: `download-${startedAt}-${this.sequence++}`, + receivedBytes: 0, + savePath: input.savePath, + startedAt, + state: "active", + totalBytes: input.totalBytes, + url: input.url, + }; + this.downloads.unshift(item); + return item; + } + + updateProgress(id: string, receivedBytes: number, totalBytes: number): void { + const item = this.get(id); + if (!item || item.state !== "active") return; + item.receivedBytes = receivedBytes; + item.totalBytes = totalBytes; + } + + finishDownload(id: string, state: DownloadState, endedAt = Date.now()): void { + const item = this.get(id); + if (!item) return; + item.state = state; + item.endedAt = endedAt; + } + + get(id: string): DownloadItem | undefined { + return this.downloads.find((item) => item.id === id); + } + + list(): DownloadItem[] { + return [...this.downloads]; + } + + clearCompleted(): DownloadItem[] { + this.downloads = this.downloads.filter((item) => item.state === "active"); + return this.list(); + } + + openInFolder(id: string): boolean { + const item = this.get(id); + if (!item?.savePath) return false; + shell.showItemInFolder(item.savePath); + return true; + } + + registerSession(electronSession: any, onUpdated: () => void): void { + electronSession.on("will-download", (_event: any, item: any) => { + const download = this.startDownload({ + filename: item.getFilename(), + savePath: item.getSavePath(), + totalBytes: item.getTotalBytes(), + url: item.getURL(), + }); + onUpdated(); + + item.on("updated", () => { + this.updateProgress( + download.id, + item.getReceivedBytes(), + item.getTotalBytes() + ); + onUpdated(); + }); + + item.once("done", (_doneEvent: any, state: string) => { + this.finishDownload(download.id, toDownloadState(state)); + onUpdated(); + }); + }); + } +} + +function toDownloadState(state: string): DownloadState { + if (state === "completed") return "completed"; + if (state === "cancelled") return "cancelled"; + return "interrupted"; +} diff --git a/apps/browser/src/main/external-protocol.ts b/apps/browser/src/main/external-protocol.ts new file mode 100644 index 0000000..80d5904 --- /dev/null +++ b/apps/browser/src/main/external-protocol.ts @@ -0,0 +1,40 @@ +export interface ExternalProtocolPrompt { + detail: string; + message: string; +} + +const confirmableExternalProtocols = new Set([ + "mailto:", + "tel:", + "sms:", + "facetime:", +]); + +export function getExternalProtocol(urlString: string): string | null { + try { + const protocol = new URL(urlString).protocol; + if (protocol === "http:" || protocol === "https:" || protocol === "file:") { + return null; + } + return protocol; + } catch { + return null; + } +} + +export function isConfirmableExternalProtocol(protocol: string): boolean { + return confirmableExternalProtocols.has(protocol); +} + +export function buildExternalProtocolPrompt( + targetUrl: string, + sourceUrl: string +): ExternalProtocolPrompt { + const protocol = getExternalProtocol(targetUrl) ?? "external:"; + const source = sourceUrl || "This page"; + + return { + detail: `${source} wants to open ${targetUrl} outside aka-browser.`, + message: `Open ${protocol} link?`, + }; +} diff --git a/apps/browser/src/main/history-manager.ts b/apps/browser/src/main/history-manager.ts new file mode 100644 index 0000000..addc05b --- /dev/null +++ b/apps/browser/src/main/history-manager.ts @@ -0,0 +1,96 @@ +import fs from "fs"; +import path from "path"; + +export interface HistoryEntry { + title: string; + url: string; + visitCount: number; + visitedAt: number; +} + +export class HistoryManager { + private readonly historyPath: string; + private entries: HistoryEntry[] = []; + + constructor(basePath: string) { + this.historyPath = path.join(basePath, "history.json"); + this.load(); + } + + recordVisit(url: string, title: string, visitedAt: number = Date.now()): void { + if (!isRecordableHistoryUrl(url)) return; + + const existing = this.entries.find((entry) => entry.url === url); + if (existing) { + existing.title = title || url; + existing.visitedAt = visitedAt; + existing.visitCount += 1; + } else { + this.entries.push({ + title: title || url, + url, + visitCount: 1, + visitedAt, + }); + } + + this.sort(); + this.save(); + } + + list(limit?: number): HistoryEntry[] { + const entries = [...this.entries].sort( + (left, right) => right.visitedAt - left.visitedAt + ); + return typeof limit === "number" ? entries.slice(0, limit) : entries; + } + + clear(): void { + this.entries = []; + this.save(); + } + + private load(): void { + try { + if (!fs.existsSync(this.historyPath)) { + this.entries = []; + return; + } + + const parsed = JSON.parse( + fs.readFileSync(this.historyPath, "utf-8") + ) as HistoryEntry[]; + this.entries = Array.isArray(parsed) ? parsed : []; + this.sort(); + } catch (error) { + console.error("[HistoryManager] Failed to load history:", error); + this.entries = []; + } + } + + private save(): void { + try { + fs.mkdirSync(path.dirname(this.historyPath), { recursive: true }); + fs.writeFileSync( + this.historyPath, + JSON.stringify(this.entries, null, 2), + "utf-8" + ); + } catch (error) { + console.error("[HistoryManager] Failed to save history:", error); + } + } + + private sort(): void { + this.entries.sort((left, right) => right.visitedAt - left.visitedAt); + } +} + +export function isRecordableHistoryUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} diff --git a/apps/browser/src/main/index.ts b/apps/browser/src/main/index.ts index b692d48..c47324e 100644 --- a/apps/browser/src/main/index.ts +++ b/apps/browser/src/main/index.ts @@ -2,13 +2,18 @@ * Main entry point for the Electron application */ -import { app } from "electron"; +import { app, session } from "electron"; import { AppState } from "./types"; import { ThemeColorCache } from "./theme-cache"; import { TabManager } from "./tab-manager"; import { WindowManager } from "./window-manager"; import { BookmarkManager } from "./bookmark-manager"; import { FaviconCache } from "./favicon-cache"; +import { BrowsingDataManager } from "./browsing-data-manager"; +import { DownloadManager } from "./download-manager"; +import { HistoryManager } from "./history-manager"; +import { PermissionManager } from "./permission-manager"; +import { SessionManager } from "./session-manager"; import { IPCHandlers } from "./ipc-handlers"; import { TrayManager } from "./tray-manager"; import { AppLifecycle } from "./app-lifecycle"; @@ -36,10 +41,19 @@ const appState: AppState = { const themeColorCache = new ThemeColorCache(); const bookmarkManager = new BookmarkManager(); const faviconCache = new FaviconCache(); -const tabManager = new TabManager(appState, themeColorCache); -const windowManager = new WindowManager(appState, tabManager); +const permissionManager = new PermissionManager(app.getPath("userData")); +const historyManager = new HistoryManager(app.getPath("userData")); +const sessionManager = new SessionManager(app.getPath("userData")); +const downloadManager = new DownloadManager(); +const tabManager = new TabManager( + appState, + themeColorCache, + permissionManager, + historyManager, + sessionManager +); +const windowManager = new WindowManager(appState, tabManager, sessionManager); const trayManager = new TrayManager(appState, windowManager); -const ipcHandlers = new IPCHandlers(appState, tabManager, windowManager, bookmarkManager, faviconCache, themeColorCache, languageManager); const appLifecycle = new AppLifecycle(appState, windowManager, trayManager); // Initialize Widevine @@ -52,6 +66,36 @@ app.on("ready", async () => { // Setup application when ready app.whenReady().then(async () => { + const webSession = session.fromPartition("persist:main"); + const browsingDataManager = new BrowsingDataManager({ + electronSession: webSession, + faviconCache, + historyManager, + permissionManager, + sessionManager, + themeColorCache, + }); + downloadManager.registerSession(webSession, () => { + if (appState.mainWindow && !appState.mainWindow.isDestroyed()) { + appState.mainWindow.webContents.send( + "downloads-updated", + downloadManager.list() + ); + } + }); + const ipcHandlers = new IPCHandlers( + appState, + tabManager, + windowManager, + bookmarkManager, + faviconCache, + themeColorCache, + languageManager, + permissionManager, + browsingDataManager, + downloadManager + ); + await appLifecycle.setupApp(); ipcHandlers.registerHandlers(); }); diff --git a/apps/browser/src/main/ipc-handlers.ts b/apps/browser/src/main/ipc-handlers.ts index 47496fc..23a15f4 100644 --- a/apps/browser/src/main/ipc-handlers.ts +++ b/apps/browser/src/main/ipc-handlers.ts @@ -4,16 +4,23 @@ import { ipcMain, app, nativeTheme } from "electron"; import { AppState } from "./types"; +import { BrowsingDataManager } from "./browsing-data-manager"; +import { DownloadManager } from "./download-manager"; import { TabManager } from "./tab-manager"; import { WindowManager } from "./window-manager"; import { BookmarkManager } from "./bookmark-manager"; import { FaviconCache } from "./favicon-cache"; import { isValidUrl, sanitizeUrl, getUserAgentForUrl, logSecurityEvent } from "./security"; import { ThemeColorCache } from "./theme-cache"; +import { PermissionManager } from "./permission-manager"; import { registerBookmarkHandlers, registerFaviconHandlers, } from "./ipc/bookmark-favicon-handlers"; +import { registerPermissionHandlers } from "./ipc/permission-handlers"; +import { registerBrowsingDataHandlers } from "./ipc/browsing-data-handlers"; +import { registerDownloadHandlers } from "./ipc/download-handlers"; +import { registerPageToolHandlers } from "./ipc/page-tool-handlers"; import { LanguageManager } from "./language-manager"; export class IPCHandlers { @@ -24,6 +31,9 @@ export class IPCHandlers { private faviconCache: FaviconCache; private themeColorCache: ThemeColorCache; private languageManager: LanguageManager; + private permissionManager: PermissionManager; + private browsingDataManager: BrowsingDataManager; + private downloadManager: DownloadManager; constructor( state: AppState, @@ -32,7 +42,10 @@ export class IPCHandlers { bookmarkManager: BookmarkManager, faviconCache: FaviconCache, themeColorCache: ThemeColorCache, - languageManager: LanguageManager + languageManager: LanguageManager, + permissionManager: PermissionManager, + browsingDataManager: BrowsingDataManager, + downloadManager: DownloadManager ) { this.state = state; this.tabManager = tabManager; @@ -41,6 +54,9 @@ export class IPCHandlers { this.faviconCache = faviconCache; this.themeColorCache = themeColorCache; this.languageManager = languageManager; + this.permissionManager = permissionManager; + this.browsingDataManager = browsingDataManager; + this.downloadManager = downloadManager; } /** @@ -53,6 +69,10 @@ export class IPCHandlers { this.registerThemeHandlers(); this.registerOrientationHandlers(); this.registerAppHandlers(); + registerPermissionHandlers(this.state, this.permissionManager); + registerBrowsingDataHandlers(this.state, this.browsingDataManager); + registerDownloadHandlers(this.state, this.downloadManager); + registerPageToolHandlers(this.state); registerBookmarkHandlers(this.state, this.bookmarkManager); registerFaviconHandlers(this.faviconCache); } diff --git a/apps/browser/src/main/ipc/browsing-data-handlers.ts b/apps/browser/src/main/ipc/browsing-data-handlers.ts new file mode 100644 index 0000000..b8c2244 --- /dev/null +++ b/apps/browser/src/main/ipc/browsing-data-handlers.ts @@ -0,0 +1,36 @@ +import { BrowserWindow, ipcMain } from "electron"; +import { BrowsingDataManager } from "../browsing-data-manager"; +import { logSecurityEvent } from "../security"; + +interface BrowsingDataHandlersState { + mainWindow: BrowserWindow | null; +} + +export function registerBrowsingDataHandlers( + state: BrowsingDataHandlersState, + browsingDataManager: BrowsingDataManager +): void { + const guarded = (event: Electron.IpcMainInvokeEvent, action: () => T): T => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to browsing data"); + throw new Error("Unauthorized"); + } + return action(); + }; + + ipcMain.handle("browsing-data-clear-history", (event) => + guarded(event, () => browsingDataManager.clearHistory()) + ); + ipcMain.handle("browsing-data-clear-cookies", (event) => + guarded(event, () => browsingDataManager.clearCookies()) + ); + ipcMain.handle("browsing-data-clear-cache", (event) => + guarded(event, () => browsingDataManager.clearCache()) + ); + ipcMain.handle("browsing-data-clear-site-data", (event) => + guarded(event, () => browsingDataManager.clearSiteData()) + ); + ipcMain.handle("browsing-data-clear-all", (event) => + guarded(event, () => browsingDataManager.clearAll()) + ); +} diff --git a/apps/browser/src/main/ipc/download-handlers.ts b/apps/browser/src/main/ipc/download-handlers.ts new file mode 100644 index 0000000..2cca726 --- /dev/null +++ b/apps/browser/src/main/ipc/download-handlers.ts @@ -0,0 +1,45 @@ +import { BrowserWindow, ipcMain } from "electron"; +import { DownloadManager } from "../download-manager"; +import { logSecurityEvent } from "../security"; + +interface DownloadHandlersState { + mainWindow: BrowserWindow | null; +} + +export function notifyDownloadsUpdated( + state: DownloadHandlersState, + downloadManager: DownloadManager +): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send("downloads-updated", downloadManager.list()); + } +} + +export function registerDownloadHandlers( + state: DownloadHandlersState, + downloadManager: DownloadManager +): void { + ipcMain.handle("downloads-list", (event) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to downloads-list"); + return []; + } + return downloadManager.list(); + }); + + ipcMain.handle("downloads-clear-completed", (event) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to downloads-clear-completed"); + throw new Error("Unauthorized"); + } + return downloadManager.clearCompleted(); + }); + + ipcMain.handle("downloads-open-in-folder", (event, id: string) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to downloads-open-in-folder"); + throw new Error("Unauthorized"); + } + return downloadManager.openInFolder(id); + }); +} diff --git a/apps/browser/src/main/ipc/page-tool-handlers.ts b/apps/browser/src/main/ipc/page-tool-handlers.ts new file mode 100644 index 0000000..1580096 --- /dev/null +++ b/apps/browser/src/main/ipc/page-tool-handlers.ts @@ -0,0 +1,66 @@ +import { BrowserWindow, ipcMain, WebContentsView } from "electron"; +import { logSecurityEvent } from "../security"; + +interface PageToolHandlersState { + mainWindow: BrowserWindow | null; + webContentsView: WebContentsView | null; +} + +export function registerPageToolHandlers(state: PageToolHandlersState): void { + const getActiveContents = (event: Electron.IpcMainInvokeEvent) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to page tools"); + throw new Error("Unauthorized"); + } + const contents = state.webContentsView?.webContents; + if (!contents || contents.isDestroyed()) { + throw new Error("No active page"); + } + return contents; + }; + + ipcMain.handle("page-find", (event, text: string, forward = true) => { + const contents = getActiveContents(event); + if (!text.trim()) return; + contents.findInPage(text, { forward, findNext: false }); + }); + + ipcMain.handle("page-find-next", (event, text: string) => { + const contents = getActiveContents(event); + if (!text.trim()) return; + contents.findInPage(text, { findNext: true, forward: true }); + }); + + ipcMain.handle("page-find-previous", (event, text: string) => { + const contents = getActiveContents(event); + if (!text.trim()) return; + contents.findInPage(text, { findNext: true, forward: false }); + }); + + ipcMain.handle("page-stop-find", (event) => { + getActiveContents(event).stopFindInPage("clearSelection"); + }); + + ipcMain.handle("page-zoom-in", async (event) => { + const contents = getActiveContents(event); + const zoom = contents.getZoomLevel() + 0.5; + contents.setZoomLevel(zoom); + return zoom; + }); + + ipcMain.handle("page-zoom-out", async (event) => { + const contents = getActiveContents(event); + const zoom = contents.getZoomLevel() - 0.5; + contents.setZoomLevel(zoom); + return zoom; + }); + + ipcMain.handle("page-zoom-reset", async (event) => { + getActiveContents(event).setZoomLevel(0); + return 0; + }); + + ipcMain.handle("page-print", async (event) => { + getActiveContents(event).print({}); + }); +} diff --git a/apps/browser/src/main/ipc/permission-handlers.ts b/apps/browser/src/main/ipc/permission-handlers.ts new file mode 100644 index 0000000..7779dd7 --- /dev/null +++ b/apps/browser/src/main/ipc/permission-handlers.ts @@ -0,0 +1,50 @@ +import { BrowserWindow, ipcMain } from "electron"; +import { + PermissionDecision, + PermissionManager, + SitePermission, +} from "../permission-manager"; +import { logSecurityEvent } from "../security"; + +interface PermissionHandlersState { + mainWindow: BrowserWindow | null; +} + +export function registerPermissionHandlers( + state: PermissionHandlersState, + permissionManager: PermissionManager +): void { + ipcMain.handle("permissions-list", (event) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to permissions-list"); + return []; + } + return permissionManager.list(); + }); + + ipcMain.handle( + "permissions-set", + ( + event, + origin: string, + permission: SitePermission, + decision: PermissionDecision + ) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to permissions-set"); + throw new Error("Unauthorized"); + } + permissionManager.setDecision(origin, permission, decision); + return permissionManager.list(); + } + ); + + ipcMain.handle("permissions-clear", (event, origin?: string) => { + if (event.sender !== state.mainWindow?.webContents) { + logSecurityEvent("Unauthorized IPC call to permissions-clear"); + throw new Error("Unauthorized"); + } + permissionManager.clear(origin); + return permissionManager.list(); + }); +} diff --git a/apps/browser/src/main/permission-manager.ts b/apps/browser/src/main/permission-manager.ts new file mode 100644 index 0000000..e1f41d5 --- /dev/null +++ b/apps/browser/src/main/permission-manager.ts @@ -0,0 +1,133 @@ +import fs from "fs"; +import path from "path"; + +export type SitePermission = + | "media" + | "clipboard-read" + | "clipboard-write" + | "fullscreen"; + +export type PermissionDecision = "allow" | "block" | "prompt"; + +export interface SitePermissionEntry { + origin: string; + permission: SitePermission; + decision: Exclude; + updatedAt: number; +} + +type StoredPermissions = Record; + +const supportedPermissions = new Set([ + "media", + "clipboard-read", + "clipboard-write", + "fullscreen", +]); + +export class PermissionManager { + private readonly permissionsPath: string; + private permissions: StoredPermissions = {}; + + constructor(basePath: string) { + this.permissionsPath = path.join(basePath, "site-permissions.json"); + this.load(); + } + + getDecision(originOrUrl: string, permission: SitePermission): PermissionDecision { + if (!supportedPermissions.has(permission)) return "block"; + + const origin = normalizeOrigin(originOrUrl); + if (!origin) return "block"; + + return this.permissions[toKey(origin, permission)]?.decision ?? "prompt"; + } + + setDecision( + originOrUrl: string, + permission: SitePermission, + decision: PermissionDecision, + updatedAt: number = Date.now() + ): void { + if (!supportedPermissions.has(permission)) return; + + const origin = normalizeOrigin(originOrUrl); + if (!origin) return; + + const key = toKey(origin, permission); + if (decision === "prompt") { + delete this.permissions[key]; + } else { + this.permissions[key] = { decision, origin, permission, updatedAt }; + } + + this.save(); + } + + list(): SitePermissionEntry[] { + return Object.values(this.permissions).sort( + (left, right) => right.updatedAt - left.updatedAt + ); + } + + clear(originOrUrl?: string): void { + if (!originOrUrl) { + this.permissions = {}; + this.save(); + return; + } + + const origin = normalizeOrigin(originOrUrl); + if (!origin) return; + + for (const key of Object.keys(this.permissions)) { + if (this.permissions[key].origin === origin) { + delete this.permissions[key]; + } + } + + this.save(); + } + + private load(): void { + try { + if (!fs.existsSync(this.permissionsPath)) { + this.permissions = {}; + return; + } + + const parsed = JSON.parse( + fs.readFileSync(this.permissionsPath, "utf-8") + ) as StoredPermissions; + this.permissions = parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + console.error("[PermissionManager] Failed to load permissions:", error); + this.permissions = {}; + } + } + + private save(): void { + try { + fs.mkdirSync(path.dirname(this.permissionsPath), { recursive: true }); + fs.writeFileSync( + this.permissionsPath, + JSON.stringify(this.permissions, null, 2), + "utf-8" + ); + } catch (error) { + console.error("[PermissionManager] Failed to save permissions:", error); + } + } +} + +export function normalizeOrigin(originOrUrl: string): string | null { + try { + return new URL(originOrUrl).origin; + } catch { + return null; + } +} + +function toKey(origin: string, permission: SitePermission): string { + return `${origin}::${permission}`; +} diff --git a/apps/browser/src/main/security-policy.ts b/apps/browser/src/main/security-policy.ts new file mode 100644 index 0000000..2eba2da --- /dev/null +++ b/apps/browser/src/main/security-policy.ts @@ -0,0 +1,93 @@ +export type RuntimeEnvironment = "development" | "production"; + +export type NavigationDecision = + | { kind: "web"; url: string } + | { kind: "external"; protocol: string; url: string } + | { kind: "blocked"; reason: string; url: string }; + +const dangerousProtocols = new Set([ + "javascript:", + "data:", + "vbscript:", + "about:", + "blob:", +]); + +const externalProtocols = new Set(["mailto:", "tel:", "sms:", "facetime:"]); + +export function getRuntimeEnvironment(): RuntimeEnvironment { + return process.env.NODE_ENV === "development" ? "development" : "production"; +} + +export function sanitizeNavigationInput(input: string): string { + const url = input.trim().replace(/[\x00-\x1F\x7F]/g, ""); + + const isLocalUrl = + /^(localhost|127\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?/i.test( + url + ); + + if (isLocalUrl) { + return `http://${url}`; + } + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) { + return url; + } + + return `https://${url}`; +} + +export function isNavigableWebUrl( + urlString: string, + environment: RuntimeEnvironment = getRuntimeEnvironment() +): boolean { + try { + const url = new URL(urlString); + if (url.protocol === "http:" || url.protocol === "https:") { + return true; + } + return environment === "development" && url.protocol === "file:"; + } catch { + return false; + } +} + +export function classifyNavigationTarget( + input: string, + environment: RuntimeEnvironment = getRuntimeEnvironment() +): NavigationDecision { + const sanitized = sanitizeNavigationInput(input); + + try { + const url = new URL(sanitized); + + if (dangerousProtocols.has(url.protocol)) { + return { + kind: "blocked", + reason: `Blocked protocol ${url.protocol}`, + url: sanitized, + }; + } + + if (isNavigableWebUrl(sanitized, environment)) { + return { kind: "web", url: sanitized }; + } + + if (externalProtocols.has(url.protocol)) { + return { + kind: "external", + protocol: url.protocol, + url: sanitized, + }; + } + + return { + kind: "blocked", + reason: `Unsupported protocol ${url.protocol}`, + url: sanitized, + }; + } catch { + return { kind: "blocked", reason: "Invalid URL", url: sanitized }; + } +} diff --git a/apps/browser/src/main/security.ts b/apps/browser/src/main/security.ts index b1cd6fd..054cf52 100644 --- a/apps/browser/src/main/security.ts +++ b/apps/browser/src/main/security.ts @@ -3,12 +3,14 @@ */ import { - ALLOWED_PROTOCOLS, - DANGEROUS_PROTOCOLS, BLOCKED_DOMAINS, IPHONE_USER_AGENT, DESKTOP_USER_AGENT } from "./constants"; +import { + classifyNavigationTarget, + sanitizeNavigationInput, +} from "./security-policy"; /** * Log security events @@ -28,23 +30,15 @@ export function logSecurityEvent(message: string, details?: any): void { */ export function isValidUrl(urlString: string): boolean { try { - const url = new URL(urlString); - - // Block dangerous protocols - if (DANGEROUS_PROTOCOLS.includes(url.protocol)) { - logSecurityEvent(`Blocked dangerous protocol: ${url.protocol}`, { - url: urlString, - }); + const decision = classifyNavigationTarget(urlString); + if (decision.kind !== "web") { + if (decision.kind === "blocked") { + logSecurityEvent(decision.reason, { url: urlString }); + } return false; } - // Only allow http and https protocols - if (!ALLOWED_PROTOCOLS.includes(url.protocol)) { - logSecurityEvent(`Blocked invalid protocol: ${url.protocol}`, { - url: urlString, - }); - return false; - } + const url = new URL(urlString); // Check against blocked domains (exact match or subdomain) const isBlocked = BLOCKED_DOMAINS.some( @@ -72,33 +66,7 @@ export function isValidUrl(urlString: string): boolean { * Sanitize URL by adding appropriate protocol */ export function sanitizeUrl(urlString: string): string { - let url = urlString.trim(); - - // If already has a valid protocol, return as-is - if ( - url.startsWith("http://") || - url.startsWith("https://") || - url.startsWith("file://") - ) { - return url; - } - - // If no protocol, add appropriate protocol - // Check if it's a local URL (localhost or private IP) - const isLocalUrl = - /^(localhost|127\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?/i.test( - url - ); - - if (isLocalUrl) { - // Use http:// for local development servers - url = "http://" + url; - } else { - // Use https:// for external sites - url = "https://" + url; - } - - return url; + return sanitizeNavigationInput(urlString); } /** diff --git a/apps/browser/src/main/session-manager.ts b/apps/browser/src/main/session-manager.ts new file mode 100644 index 0000000..5713e74 --- /dev/null +++ b/apps/browser/src/main/session-manager.ts @@ -0,0 +1,90 @@ +import fs from "fs"; +import path from "path"; + +export interface SessionTabSnapshot { + id: string; + title: string; + url: string; +} + +export interface BrowserSessionSnapshot { + activeTabId: string | null; + orientation: "portrait" | "landscape"; + savedAt: number; + tabs: SessionTabSnapshot[]; +} + +export class SessionManager { + private readonly sessionPath: string; + + constructor(basePath: string) { + this.sessionPath = path.join(basePath, "session.json"); + } + + save(snapshot: BrowserSessionSnapshot): void { + try { + fs.mkdirSync(path.dirname(this.sessionPath), { recursive: true }); + fs.writeFileSync( + this.sessionPath, + JSON.stringify(snapshot, null, 2), + "utf-8" + ); + } catch (error) { + console.error("[SessionManager] Failed to save session:", error); + } + } + + load(): BrowserSessionSnapshot | null { + try { + if (!fs.existsSync(this.sessionPath)) return null; + + const parsed = JSON.parse( + fs.readFileSync(this.sessionPath, "utf-8") + ) as BrowserSessionSnapshot; + if (!parsed || !Array.isArray(parsed.tabs)) return null; + + return { + activeTabId: parsed.activeTabId ?? null, + orientation: parsed.orientation === "landscape" ? "landscape" : "portrait", + savedAt: parsed.savedAt || 0, + tabs: parsed.tabs.map((tab) => ({ + id: tab.id, + title: tab.title || "New Tab", + url: this.normalizeUrlForRestore(tab.url), + })), + }; + } catch (error) { + console.error("[SessionManager] Failed to load session:", error); + return null; + } + } + + clear(): void { + try { + if (fs.existsSync(this.sessionPath)) { + fs.unlinkSync(this.sessionPath); + } + } catch (error) { + console.error("[SessionManager] Failed to clear session:", error); + } + } + + normalizeUrlForRestore(urlString: string): string { + if (!urlString || urlString === "/") return ""; + if ( + urlString.includes("blank-page-tab-") || + urlString.includes("error-page-tab-") + ) { + return ""; + } + + try { + const url = new URL(urlString); + return url.protocol === "http:" || url.protocol === "https:" + ? urlString + : ""; + } catch { + return ""; + } + } +} diff --git a/apps/browser/src/main/tab-manager.ts b/apps/browser/src/main/tab-manager.ts index 2054162..f25c22a 100644 --- a/apps/browser/src/main/tab-manager.ts +++ b/apps/browser/src/main/tab-manager.ts @@ -12,12 +12,16 @@ import { WebContentsView, Menu } from "electron"; import path from "path"; import fs from "fs"; import { Tab, AppState } from "./types"; +import { HistoryManager } from "./history-manager"; import { isValidUrl, sanitizeUrl, getUserAgentForUrl, logSecurityEvent, } from "./security"; +import { classifyNavigationTarget } from "./security-policy"; +import { PermissionManager } from "./permission-manager"; +import { SessionManager } from "./session-manager"; import { ThemeColorCache } from "./theme-cache"; import { loadBlankPage } from "./tabs/tab-page-loader"; import { @@ -25,14 +29,28 @@ import { exitFullscreen as doExitFullscreen, } from "./tabs/tab-fullscreen"; import { setupNavigationHandlers } from "./tabs/tab-navigation"; +import { shouldGrantPermissionRequest } from "./tabs/tab-permissions"; +import { confirmAndOpenExternalProtocol } from "./tabs/tab-external-protocol"; export class TabManager { private state: AppState; private themeColorCache: ThemeColorCache; - - constructor(state: AppState, themeColorCache: ThemeColorCache) { + private permissionManager: PermissionManager; + private historyManager: HistoryManager; + private sessionManager: SessionManager; + + constructor( + state: AppState, + themeColorCache: ThemeColorCache, + permissionManager: PermissionManager, + historyManager: HistoryManager, + sessionManager: SessionManager + ) { this.state = state; this.themeColorCache = themeColorCache; + this.permissionManager = permissionManager; + this.historyManager = historyManager; + this.sessionManager = sessionManager; } // ── Public API ───────────────────────────────────────────────────────────── @@ -65,16 +83,15 @@ export class TabManager { // Enable Widevine CDM for this webContents view.webContents.session.setPermissionRequestHandler( - ( - _webContents: any, - permission: string, - callback: (result: boolean) => void - ) => { - if (permission === "media" || permission === "fullscreen") { - callback(true); - } else { - callback(false); - } + (webContents: any, permission: string, callback: (result: boolean) => void, details?: any) => { + callback( + shouldGrantPermissionRequest( + this.permissionManager, + webContents, + permission, + details + ) + ); } ); @@ -90,6 +107,7 @@ export class TabManager { this.state.tabs.push(tab); this.setupWebContentsViewHandlers(view, tabId); + this.saveSession(); // Load URL or blank page if (!url || url.trim() === "") { @@ -150,6 +168,7 @@ export class TabManager { preview: t.preview, })), }); + this.saveSession(); } /** Close a tab and switch to an adjacent one (or create a new tab if last). */ @@ -187,6 +206,7 @@ export class TabManager { activeTabId: this.state.activeTabId, }); } + this.saveSession(); } /** Close every open tab and open a single new blank tab. */ @@ -202,6 +222,7 @@ export class TabManager { this.state.tabs.length = 0; const newTab = this.createTab(); this.switchToTab(newTab.id); + this.saveSession(); } /** Exit fullscreen for a tab (ESC-key handler entry point). */ @@ -292,6 +313,19 @@ export class TabManager { } } + private saveSession(): void { + this.sessionManager.save({ + activeTabId: this.state.activeTabId, + orientation: this.state.isLandscape ? "landscape" : "portrait", + savedAt: Date.now(), + tabs: this.state.tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + url: tab.url, + })), + }); + } + /** * Wire up all WebContentsView event listeners: context-menu, security * checks, window-open interception, fullscreen, and navigation. @@ -345,11 +379,16 @@ export class TabManager { // Block invalid navigation URLs contents.on("will-navigate", (_event: any, navigationUrl: string) => { - if (!isValidUrl(navigationUrl)) { + const decision = classifyNavigationTarget(navigationUrl); + if (decision.kind === "external") { _event.preventDefault(); - logSecurityEvent("Navigation blocked to invalid URL", { - url: navigationUrl, - }); + confirmAndOpenExternalProtocol(this.state, decision.url, contents.getURL()); + } else if (decision.kind !== "web" || !isValidUrl(decision.url)) { + _event.preventDefault(); + logSecurityEvent( + decision.kind === "blocked" ? decision.reason : "Invalid navigation", + { url: navigationUrl } + ); if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { this.state.mainWindow.webContents.send( "navigation-blocked", @@ -357,17 +396,25 @@ export class TabManager { ); } } else { - contents.setUserAgent(getUserAgentForUrl(navigationUrl)); + contents.setUserAgent(getUserAgentForUrl(decision.url)); } }); // 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 }); + const decision = classifyNavigationTarget(url); + if (decision.kind === "external") { + confirmAndOpenExternalProtocol(this.state, decision.url, contents.getURL()); return { action: "deny" }; } - const newTab = this.createTab(url); + if (decision.kind !== "web" || !isValidUrl(decision.url)) { + logSecurityEvent( + decision.kind === "blocked" ? decision.reason : "Invalid new window", + { url } + ); + return { action: "deny" }; + } + const newTab = this.createTab(decision.url); this.switchToTab(newTab.id); return { action: "deny" }; }); @@ -383,7 +430,12 @@ export class TabManager { tabId, this.state, this.themeColorCache, - (id) => this.captureTabPreview(id) + (id) => this.captureTabPreview(id), + (url, title) => { + this.historyManager.recordVisit(url, title); + this.saveSession(); + } ); } + } diff --git a/apps/browser/src/main/tabs/tab-external-protocol.ts b/apps/browser/src/main/tabs/tab-external-protocol.ts new file mode 100644 index 0000000..5308a2a --- /dev/null +++ b/apps/browser/src/main/tabs/tab-external-protocol.ts @@ -0,0 +1,39 @@ +import { dialog, shell } from "electron"; +import { AppState } from "../types"; +import { + buildExternalProtocolPrompt, + getExternalProtocol, + isConfirmableExternalProtocol, +} from "../external-protocol"; +import { logSecurityEvent } from "../security"; + +export function confirmAndOpenExternalProtocol( + state: AppState, + targetUrl: string, + sourceUrl: string +): void { + const protocol = getExternalProtocol(targetUrl); + if (!protocol || !isConfirmableExternalProtocol(protocol)) { + logSecurityEvent("Blocked unsupported external protocol", { targetUrl }); + return; + } + + const prompt = buildExternalProtocolPrompt(targetUrl, sourceUrl); + const choice = state.mainWindow + ? dialog.showMessageBoxSync(state.mainWindow, { + buttons: ["Open", "Cancel"], + cancelId: 1, + defaultId: 1, + detail: prompt.detail, + message: prompt.message, + noLink: true, + type: "question", + }) + : 1; + + if (choice === 0) { + shell.openExternal(targetUrl).catch((error) => { + console.error("[TabManager] Failed to open external URL:", error); + }); + } +} diff --git a/apps/browser/src/main/tabs/tab-navigation.ts b/apps/browser/src/main/tabs/tab-navigation.ts index 80de156..1a6c067 100644 --- a/apps/browser/src/main/tabs/tab-navigation.ts +++ b/apps/browser/src/main/tabs/tab-navigation.ts @@ -109,7 +109,8 @@ export function setupNavigationHandlers( tabId: string, state: AppState, themeColorCache: ThemeColorCache, - captureTabPreview: (tabId: string) => Promise + captureTabPreview: (tabId: string) => Promise, + onSuccessfulNavigation?: (url: string, title: string) => void ): void { // ── Loading start / stop ────────────────────────────────────────────────── @@ -144,6 +145,9 @@ export function setupNavigationHandlers( activeTabId: state.activeTabId, }); } + if (displayUrl !== "/") { + onSuccessfulNavigation?.(displayUrl, contents.getTitle() || displayUrl); + } }); contents.on("did-navigate-in-page", (_event: any, url: string) => { @@ -159,6 +163,9 @@ export function setupNavigationHandlers( activeTabId: state.activeTabId, }); } + if (displayUrl !== "/") { + onSuccessfulNavigation?.(displayUrl, contents.getTitle() || displayUrl); + } }); contents.on("dom-ready", () => { diff --git a/apps/browser/src/main/tabs/tab-permissions.ts b/apps/browser/src/main/tabs/tab-permissions.ts new file mode 100644 index 0000000..c8ccbbf --- /dev/null +++ b/apps/browser/src/main/tabs/tab-permissions.ts @@ -0,0 +1,54 @@ +import { + normalizeOrigin, + PermissionManager, + SitePermission, +} from "../permission-manager"; +import { logSecurityEvent } from "../security"; + +interface PermissionRequestDetails { + embeddingOrigin?: string; + requestingUrl?: string; +} + +export function shouldGrantPermissionRequest( + permissionManager: PermissionManager, + webContents: Electron.WebContents, + permission: string, + details?: PermissionRequestDetails +): boolean { + const sitePermission = toSitePermission(permission); + if (!sitePermission) { + logSecurityEvent(`Permission denied: ${permission}`); + return false; + } + + const origin = normalizeOrigin( + details?.requestingUrl || details?.embeddingOrigin || webContents.getURL() + ); + if (!origin) { + return isLegacyAllowedPrompt(sitePermission); + } + + const decision = permissionManager.getDecision(origin, sitePermission); + if (decision === "allow") return true; + if (decision === "block") return false; + + return isLegacyAllowedPrompt(sitePermission); +} + +function isLegacyAllowedPrompt(permission: SitePermission): boolean { + return permission === "media" || permission === "fullscreen"; +} + +function toSitePermission(permission: string): SitePermission | null { + if (permission === "clipboard-sanitized-write") return "clipboard-write"; + if ( + permission === "media" || + permission === "clipboard-read" || + permission === "clipboard-write" || + permission === "fullscreen" + ) { + return permission; + } + return null; +} diff --git a/apps/browser/src/main/theme-cache.ts b/apps/browser/src/main/theme-cache.ts index 771a5ff..5027b2a 100644 --- a/apps/browser/src/main/theme-cache.ts +++ b/apps/browser/src/main/theme-cache.ts @@ -52,6 +52,7 @@ export class ThemeColorCache { } catch (error) { console.error("[ThemeColorCache] Failed to load cache:", error); this.cache.clear(); + this.moveCorruptCacheAside(); } } @@ -59,6 +60,7 @@ export class ThemeColorCache { * Save cache to disk immediately (synchronous) */ private saveCacheImmediate(): void { + let tempPath = ""; try { // Clear any pending debounced save if (this.saveTimeout) { @@ -71,13 +73,32 @@ export class ThemeColorCache { for (const [domain, entry] of this.cache.entries()) { cacheData[domain] = entry; } - - fs.writeFileSync(this.cachePath, JSON.stringify(cacheData, null, 2), "utf-8"); + + fs.mkdirSync(path.dirname(this.cachePath), { recursive: true }); + tempPath = `${this.cachePath}.${process.pid}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(cacheData, null, 2), "utf-8"); + fs.renameSync(tempPath, this.cachePath); } catch (error) { + if (tempPath && fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } console.error("[ThemeColorCache] Failed to save cache:", error); } } + private moveCorruptCacheAside(): void { + try { + if (!fs.existsSync(this.cachePath)) return; + fs.renameSync(this.cachePath, `${this.cachePath}.corrupt`); + } catch { + try { + fs.unlinkSync(this.cachePath); + } catch { + // Best effort cleanup only. + } + } + } + /** * Save cache to disk with debouncing */ diff --git a/apps/browser/src/main/window-manager.ts b/apps/browser/src/main/window-manager.ts index 4544bfa..d1114e2 100644 --- a/apps/browser/src/main/window-manager.ts +++ b/apps/browser/src/main/window-manager.ts @@ -13,16 +13,22 @@ import { STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, } from "./constants"; -import { logSecurityEvent } from "./security"; +import { BrowserSessionSnapshot, SessionManager } from "./session-manager"; import { TabManager } from "./tab-manager"; export class WindowManager { private state: AppState; private tabManager: TabManager; + private sessionManager: SessionManager; - constructor(state: AppState, tabManager: TabManager) { + constructor( + state: AppState, + tabManager: TabManager, + sessionManager: SessionManager + ) { this.state = state; this.tabManager = tabManager; + this.sessionManager = sessionManager; } /** @@ -167,6 +173,11 @@ export class WindowManager { * Create the main browser window */ createWindow(): void { + const restoredSession = this.sessionManager.load(); + if (restoredSession) { + this.state.isLandscape = restoredSession.orientation === "landscape"; + } + const dimensions = this.getWindowDimensions(); this.state.mainWindow = new BrowserWindow({ @@ -217,31 +228,7 @@ export class WindowManager { // Register local keyboard shortcuts (only work when window is focused) this.registerLocalShortcuts(); - // Create initial blank tab with start page - const initialTab = this.tabManager.createTab(""); - this.tabManager.switchToTab(initialTab.id); - - // Set permission request handler - if (this.state.webContentsView) { - this.state.webContentsView.webContents.session.setPermissionRequestHandler( - (webContents, permission, callback) => { - const allowedPermissions = [ - "clipboard-read", - "clipboard-write", - "media", - "fullscreen", // Allow fullscreen - handled by Electron native events - ]; - - if (allowedPermissions.includes(permission)) { - logSecurityEvent(`Permission granted: ${permission}`); - callback(true); - } else { - logSecurityEvent(`Permission denied: ${permission}`); - callback(false); - } - } - ); - } + this.restoreTabsOrCreateBlank(restoredSession); // Set security headers this.setupSecurityHeaders(); @@ -296,6 +283,27 @@ export class WindowManager { }); } + private restoreTabsOrCreateBlank( + restoredSession: BrowserSessionSnapshot | null + ): void { + const restorableTabs = restoredSession?.tabs ?? []; + if (restorableTabs.length === 0) { + const initialTab = this.tabManager.createTab(""); + this.tabManager.switchToTab(initialTab.id); + return; + } + + let activeTabId: string | null = null; + for (const tab of restorableTabs) { + const created = this.tabManager.createTab(tab.url); + if (tab.id === restoredSession?.activeTabId) { + activeTabId = created.id; + } + } + + this.tabManager.switchToTab(activeTabId ?? this.state.tabs[0].id); + } + /** * Register local keyboard shortcuts (only active when window is focused) */ diff --git a/apps/browser/src/preload.ts b/apps/browser/src/preload.ts index 6283203..1f79d86 100644 --- a/apps/browser/src/preload.ts +++ b/apps/browser/src/preload.ts @@ -216,4 +216,44 @@ contextBridge.exposeInMainWorld("electronAPI", { clearCache: () => ipcRenderer.invoke("favicon-clear-cache"), getCacheSize: () => ipcRenderer.invoke("favicon-get-cache-size"), }, + + // Site permission APIs + permissions: { + list: () => ipcRenderer.invoke("permissions-list"), + set: (origin: string, permission: string, decision: string) => + ipcRenderer.invoke("permissions-set", origin, permission, decision), + clear: (origin?: string) => ipcRenderer.invoke("permissions-clear", origin), + }, + + browsingData: { + clearHistory: () => ipcRenderer.invoke("browsing-data-clear-history"), + clearCookies: () => ipcRenderer.invoke("browsing-data-clear-cookies"), + clearCache: () => ipcRenderer.invoke("browsing-data-clear-cache"), + clearSiteData: () => ipcRenderer.invoke("browsing-data-clear-site-data"), + clearAll: () => ipcRenderer.invoke("browsing-data-clear-all"), + }, + + downloads: { + list: () => ipcRenderer.invoke("downloads-list"), + clearCompleted: () => ipcRenderer.invoke("downloads-clear-completed"), + openInFolder: (id: string) => ipcRenderer.invoke("downloads-open-in-folder", id), + onUpdated: (callback: (items: any[]) => void) => { + const listener = (_event: any, items: any[]) => callback(items); + ipcRenderer.on("downloads-updated", listener); + return () => ipcRenderer.removeListener("downloads-updated", listener); + }, + }, + + pageTools: { + find: (text: string, forward?: boolean) => + ipcRenderer.invoke("page-find", text, forward), + findNext: (text: string) => ipcRenderer.invoke("page-find-next", text), + findPrevious: (text: string) => + ipcRenderer.invoke("page-find-previous", text), + stopFind: () => ipcRenderer.invoke("page-stop-find"), + zoomIn: () => ipcRenderer.invoke("page-zoom-in"), + zoomOut: () => ipcRenderer.invoke("page-zoom-out"), + zoomReset: () => ipcRenderer.invoke("page-zoom-reset"), + print: () => ipcRenderer.invoke("page-print"), + }, }); diff --git a/apps/browser/src/renderer/app.tsx b/apps/browser/src/renderer/app.tsx index e071ed9..b3c59f6 100644 --- a/apps/browser/src/renderer/app.tsx +++ b/apps/browser/src/renderer/app.tsx @@ -5,6 +5,7 @@ import PhoneFrame from "./components/phone-frame"; import TabOverview from "./components/tab-overview"; import Settings from "./components/settings"; import MenuOverlay from "./components/menu-overlay"; +import { FindInPage } from "./components/find-in-page"; import { useBrowserPageState } from "./hooks/use-browser-page-state"; import { getWebContentsBounds, normalizeNavigationUrl } from "./lib/app-utils"; @@ -17,6 +18,7 @@ function App() { const [showTabOverview, setShowTabOverview] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showMenu, setShowMenu] = useState(false); + const [showFind, setShowFind] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const webContainerRef = useRef(null); const pageState = useBrowserPageState(orientation); @@ -157,14 +159,30 @@ function App() { window.electronAPI?.webContents.setVisible(true); }; + const handleCloseMenu = () => { + setShowMenu(false); + syncWebContentsBounds(); + window.electronAPI?.webContents.setVisible(true); + }; + + const handleOpenSettingsFromMenu = () => { + setShowSettings(true); + window.electronAPI?.webContents.setVisible(false); + }; + const handleShowMenu = () => { if (showSettings) { handleCloseSettings(); return; } + if (showMenu) { + handleCloseMenu(); + return; + } + window.electronAPI?.webContents.setVisible(false); - setShowSettings(true); + setShowMenu(true); }; return ( @@ -209,10 +227,14 @@ function App() { theme={systemTheme} currentUrl={pageState.currentUrl} currentTitle={pageState.pageTitle} - onClose={() => setShowMenu(false)} - onOpenSettings={() => setShowSettings(true)} + onClose={handleCloseMenu} + onOpenFind={() => setShowFind(true)} + onOpenSettings={handleOpenSettingsFromMenu} /> )} + {showFind && ( + setShowFind(false)} /> + )} ); } diff --git a/apps/browser/src/renderer/components/find-in-page.tsx b/apps/browser/src/renderer/components/find-in-page.tsx new file mode 100644 index 0000000..78f3b1a --- /dev/null +++ b/apps/browser/src/renderer/components/find-in-page.tsx @@ -0,0 +1,71 @@ +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface FindInPageProps { + onClose: () => void; + theme: "light" | "dark"; +} + +export function FindInPage({ onClose, theme }: FindInPageProps) { + const [query, setQuery] = useState(""); + const isDark = theme === "dark"; + + useEffect(() => { + return () => { + window.electronAPI?.pageTools.stopFind(); + }; + }, []); + + const runFind = (nextQuery: string) => { + setQuery(nextQuery); + window.electronAPI?.pageTools.find(nextQuery, true); + }; + + const close = () => { + window.electronAPI?.pageTools.stopFind(); + onClose(); + }; + + return ( +
+
+ runFind(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") close(); + if (event.key === "Enter" && event.shiftKey) { + window.electronAPI?.pageTools.findPrevious(query); + } else if (event.key === "Enter") { + window.electronAPI?.pageTools.findNext(query); + } + }} + className={`w-44 bg-transparent px-2 text-sm outline-none ${ + isDark ? "placeholder:text-zinc-500" : "placeholder:text-zinc-400" + }`} + placeholder="Find" + /> + + + +
+
+ ); +} diff --git a/apps/browser/src/renderer/components/menu-overlay.tsx b/apps/browser/src/renderer/components/menu-overlay.tsx index 39a8a43..20a4167 100644 --- a/apps/browser/src/renderer/components/menu-overlay.tsx +++ b/apps/browser/src/renderer/components/menu-overlay.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import { Star, Settings } from "lucide-react"; +import type { ReactNode } from "react"; +import { Printer, Search, Settings, Star, ZoomIn, ZoomOut } from "lucide-react"; import { useI18n } from "../i18n/i18n-context"; interface MenuOverlayProps { @@ -7,6 +8,7 @@ interface MenuOverlayProps { currentUrl: string; currentTitle: string; onClose: () => void; + onOpenFind: () => void; onOpenSettings: () => void; } @@ -15,6 +17,7 @@ function MenuOverlay({ currentUrl, currentTitle, onClose, + onOpenFind, onOpenSettings, }: MenuOverlayProps) { const [isBookmarked, setIsBookmarked] = useState(false); @@ -77,11 +80,16 @@ function MenuOverlay({ onOpenSettings(); }; + const runPageTool = (action: () => void) => { + action(); + onClose(); + }; + const isBlankPage = currentUrl.startsWith("file://") && currentUrl.includes("blank-page.html"); return (
{!isBlankPage && ( - + + runPageTool(onOpenFind)}> + + {t("findInPage")} + + + runPageTool(() => window.electronAPI?.pageTools.zoomIn()) + } + > + + {t("zoomIn")} + + + runPageTool(() => window.electronAPI?.pageTools.zoomOut()) + } + > + + {t("zoomOut")} + + + runPageTool(() => window.electronAPI?.pageTools.zoomReset()) + } + > + + {t("resetZoom")} + + + runPageTool(() => window.electronAPI?.pageTools.print()) + } + > + + {t("print")} + + )} + ); +} + export default MenuOverlay; diff --git a/apps/browser/src/renderer/components/settings.tsx b/apps/browser/src/renderer/components/settings.tsx index 511510a..8454074 100644 --- a/apps/browser/src/renderer/components/settings.tsx +++ b/apps/browser/src/renderer/components/settings.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from "react"; -import { Info, Star } from "lucide-react"; +import { Database, Download, Info, Shield, Star } from "lucide-react"; import appIcon from "../../../assets/icon.png"; import { AboutView } from "./settings/about-view"; import { BookmarkDialog } from "./settings/bookmark-dialog"; import { BookmarksView } from "./settings/bookmarks-view"; +import { BrowsingDataView } from "./settings/browsing-data-view"; +import { DownloadsView } from "./settings/downloads-view"; import { LanguageView } from "./settings/language-view"; import { MainView } from "./settings/main-view"; +import { SitePermissionsView } from "./settings/site-permissions-view"; import { Bookmark, SettingsSection, SettingsView } from "./settings/types"; import { useBookmarks } from "./settings/use-bookmarks"; import { useI18n } from "../i18n/i18n-context"; @@ -57,6 +60,27 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { hasDetail: true, onClick: () => setCurrentView("language"), }, + { + id: "browsing-data", + label: t("browsingData"), + icon: , + hasDetail: true, + onClick: () => setCurrentView("browsing-data"), + }, + { + id: "site-permissions", + label: t("sitePermissions"), + icon: , + hasDetail: true, + onClick: () => setCurrentView("site-permissions"), + }, + { + id: "downloads", + label: t("downloads"), + icon: , + hasDetail: true, + onClick: () => setCurrentView("downloads"), + }, { id: "about", label: t("about"), @@ -151,6 +175,21 @@ function Settings({ theme, orientation, onClose }: SettingsProps) { {currentView === "language" && ( setCurrentView("main")} /> )} + {currentView === "browsing-data" && ( + setCurrentView("main")} + /> + )} + {currentView === "downloads" && ( + setCurrentView("main")} /> + )} + {currentView === "site-permissions" && ( + setCurrentView("main")} + /> + )} {showBookmarkDialog && ( void; +} + +type ClearAction = { + action: () => Promise; + id: string; + label: string; +}; + +export function BrowsingDataView({ isDark, onBack }: BrowsingDataViewProps) { + const { t } = useI18n(); + const [message, setMessage] = useState(""); + const [runningId, setRunningId] = useState(null); + + const actions: ClearAction[] = [ + { + action: () => window.electronAPI!.browsingData.clearHistory(), + id: "history", + label: t("clearHistory"), + }, + { + action: () => window.electronAPI!.browsingData.clearCookies(), + id: "cookies", + label: t("clearCookies"), + }, + { + action: () => window.electronAPI!.browsingData.clearCache(), + id: "cache", + label: t("clearCache"), + }, + { + action: () => window.electronAPI!.browsingData.clearSiteData(), + id: "site-data", + label: t("clearSiteData"), + }, + { + action: () => window.electronAPI!.browsingData.clearAll(), + id: "all", + label: t("clearAllBrowsingData"), + }, + ]; + + const runAction = async (clearAction: ClearAction) => { + setRunningId(clearAction.id); + setMessage(""); + try { + const result = await clearAction.action(); + const results = Array.isArray(result) ? result : [result]; + const failed = results.filter((item) => !item.ok); + setMessage(failed.length > 0 ? t("clearDataFailed") : t("clearDataDone")); + } catch { + setMessage(t("clearDataFailed")); + } finally { + setRunningId(null); + } + }; + + return ( + <> +
+ +

+ {t("browsingData")} +

+
+
+ +
+
+ {actions.map((item) => ( + + ))} +
+ + {message && ( +

+ {message} +

+ )} +
+ + ); +} diff --git a/apps/browser/src/renderer/components/settings/downloads-view.tsx b/apps/browser/src/renderer/components/settings/downloads-view.tsx new file mode 100644 index 0000000..a6a2c75 --- /dev/null +++ b/apps/browser/src/renderer/components/settings/downloads-view.tsx @@ -0,0 +1,133 @@ +import { ChevronLeft, Download, FolderOpen, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useI18n } from "../../i18n/i18n-context"; + +interface DownloadsViewProps { + isDark: boolean; + onBack: () => void; +} + +interface DownloadItem { + filename: string; + id: string; + receivedBytes: number; + savePath: string; + state: "active" | "cancelled" | "completed" | "interrupted"; + totalBytes: number; +} + +export function DownloadsView({ isDark, onBack }: DownloadsViewProps) { + const { t } = useI18n(); + const [items, setItems] = useState([]); + + useEffect(() => { + window.electronAPI?.downloads.list().then(setItems); + const cleanup = window.electronAPI?.downloads.onUpdated(setItems); + return () => cleanup?.(); + }, []); + + const clearCompleted = async () => { + const nextItems = await window.electronAPI?.downloads.clearCompleted(); + setItems(nextItems ?? []); + }; + + return ( + <> +
+ +

+ {t("downloads")} +

+ +
+ +
+ {items.length === 0 ? ( +
+ + {t("noDownloads")} +
+ ) : ( +
+ {items.map((item) => ( +
+
+
+
+ {item.filename} +
+
+ {formatDownloadStatus(item)} +
+
+ {item.state === "completed" && ( + + )} +
+
+ ))} +
+ )} +
+ + ); +} + +function formatDownloadStatus(item: DownloadItem): string { + if (item.state !== "active") return item.state; + if (item.totalBytes <= 0) return `${item.receivedBytes} bytes`; + return `${Math.round((item.receivedBytes / item.totalBytes) * 100)}%`; +} diff --git a/apps/browser/src/renderer/components/settings/site-permissions-view.tsx b/apps/browser/src/renderer/components/settings/site-permissions-view.tsx new file mode 100644 index 0000000..fd7d3bd --- /dev/null +++ b/apps/browser/src/renderer/components/settings/site-permissions-view.tsx @@ -0,0 +1,113 @@ +import { ChevronLeft, Shield, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useI18n } from "../../i18n/i18n-context"; + +interface SitePermissionsViewProps { + isDark: boolean; + onBack: () => void; +} + +interface SitePermissionEntry { + decision: "allow" | "block"; + origin: string; + permission: string; +} + +export function SitePermissionsView({ + isDark, + onBack, +}: SitePermissionsViewProps) { + const { t } = useI18n(); + const [items, setItems] = useState([]); + + const load = async () => { + const permissions = await window.electronAPI?.permissions.list(); + setItems(permissions ?? []); + }; + + useEffect(() => { + load(); + }, []); + + const clearAll = async () => { + const nextItems = await window.electronAPI?.permissions.clear(); + setItems(nextItems ?? []); + }; + + return ( + <> +
+ +

+ {t("sitePermissions")} +

+ +
+ +
+ {items.length === 0 ? ( +
+ + {t("noSitePermissions")} +
+ ) : ( +
+ {items.map((item) => ( +
+
+ {item.origin} +
+
+ {item.permission}: {item.decision} +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/apps/browser/src/renderer/components/settings/types.ts b/apps/browser/src/renderer/components/settings/types.ts index 42cf0c0..461cfdc 100644 --- a/apps/browser/src/renderer/components/settings/types.ts +++ b/apps/browser/src/renderer/components/settings/types.ts @@ -1,6 +1,13 @@ import type { ReactNode } from "react"; -export type SettingsView = "main" | "about" | "bookmarks" | "language"; +export type SettingsView = + | "main" + | "about" + | "bookmarks" + | "language" + | "browsing-data" + | "downloads" + | "site-permissions"; export interface SettingsSection { id: string; diff --git a/apps/browser/src/renderer/hooks/use-browser-page-state.ts b/apps/browser/src/renderer/hooks/use-browser-page-state.ts index 8c11f09..d1dbef2 100644 --- a/apps/browser/src/renderer/hooks/use-browser-page-state.ts +++ b/apps/browser/src/renderer/hooks/use-browser-page-state.ts @@ -116,6 +116,8 @@ export function useBrowserPageState(orientation: "portrait" | "landscape") { const cleanupTabChanged = window.electronAPI?.tabs.onTabChanged((data) => { setTabCount(data.tabs.length); + updatePageInfo(); + startThemeColorMonitoring(); }); const cleanupTabsUpdated = window.electronAPI?.tabs.onTabsUpdated( (data: TabListData) => { diff --git a/apps/browser/src/renderer/i18n/translations.ts b/apps/browser/src/renderer/i18n/translations.ts index 13a62a3..e5867f8 100644 --- a/apps/browser/src/renderer/i18n/translations.ts +++ b/apps/browser/src/renderer/i18n/translations.ts @@ -8,30 +8,50 @@ export const translations = { about: "About", back: "Back", cancel: "Cancel", + browsingData: "Browsing Data", + clearAllBrowsingData: "Clear All Browsing Data", + clearCache: "Clear Cache", + clear: "Clear", + clearCookies: "Clear Cookies", + clearDataDone: "Browsing data cleared.", + clearDataFailed: "Could not clear all requested data.", + clearCompleted: "Clear Completed", + clearHistory: "Clear History", + clearSiteData: "Clear Site Data", contributionHelp: "Do not see your language? Help translate aka-browser on GitHub.", description: "Description", done: "Done", + downloads: "Downloads", editFavorite: "Edit Favorite", englishName: "English name", enterTitle: "Enter title", enterUrl: "Enter URL...", favorites: "Favorites", favoritesCount: "{count} items", + findInPage: "Find in Page", general: "General", information: "Information", language: "Language", languageContribution: "Translation guide", name: "Name", noFavorites: "No favorites yet.", + noDownloads: "No downloads yet.", noFavoritesHint: "Add your favorite sites from the menu.", + noSitePermissions: "No site permissions saved.", removeFromFavorites: "Remove from Favorites", + openInFolder: "Open in Folder", + print: "Print", + resetZoom: "Reset Zoom", save: "Save", settings: "Settings", + sitePermissions: "Site Permissions", systemDefault: "System Default", title: "Title", url: "URL", version: "Version", + zoomIn: "Zoom In", + zoomOut: "Zoom Out", }, ko: { addFavorite: "즐겨찾기 추가", @@ -40,30 +60,50 @@ export const translations = { about: "정보", back: "뒤로", cancel: "취소", + browsingData: "브라우징 데이터", + clearAllBrowsingData: "모든 브라우징 데이터 지우기", + clearCache: "캐시 지우기", + clear: "지우기", + clearCookies: "쿠키 지우기", + clearDataDone: "브라우징 데이터를 지웠습니다.", + clearDataFailed: "요청한 데이터를 모두 지우지 못했습니다.", + clearCompleted: "완료 항목 지우기", + clearHistory: "방문 기록 지우기", + clearSiteData: "사이트 데이터 지우기", contributionHelp: "원하는 언어가 없나요? GitHub에서 aka-browser 번역에 참여해 주세요.", description: "설명", done: "완료", + downloads: "다운로드", editFavorite: "즐겨찾기 편집", englishName: "영문 이름", enterTitle: "제목 입력", enterUrl: "URL 입력...", favorites: "즐겨찾기", favoritesCount: "{count}개", + findInPage: "페이지에서 찾기", general: "일반", information: "정보", language: "언어", languageContribution: "번역 참여 안내", name: "이름", noFavorites: "아직 즐겨찾기가 없습니다.", + noDownloads: "아직 다운로드가 없습니다.", noFavoritesHint: "메뉴에서 자주 쓰는 사이트를 추가하세요.", + noSitePermissions: "저장된 사이트 권한이 없습니다.", removeFromFavorites: "즐겨찾기에서 제거", + openInFolder: "폴더에서 보기", + print: "인쇄", + resetZoom: "확대/축소 초기화", save: "저장", settings: "설정", + sitePermissions: "사이트 권한", systemDefault: "시스템 기본값", title: "제목", url: "URL", version: "버전", + zoomIn: "확대", + zoomOut: "축소", }, } satisfies Record>; diff --git a/apps/browser/src/types/electron-api.d.ts b/apps/browser/src/types/electron-api.d.ts index 1b23eb7..8a064dd 100644 --- a/apps/browser/src/types/electron-api.d.ts +++ b/apps/browser/src/types/electron-api.d.ts @@ -44,6 +44,40 @@ interface LanguageState { systemLanguage: EffectiveLanguage; } +type SitePermission = + | "media" + | "clipboard-read" + | "clipboard-write" + | "fullscreen"; +type PermissionDecision = "allow" | "block" | "prompt"; + +interface SitePermissionEntry { + origin: string; + permission: SitePermission; + decision: Exclude; + updatedAt: number; +} + +interface ClearResult { + error?: string; + ok: boolean; + target: string; +} + +type DownloadState = "active" | "cancelled" | "completed" | "interrupted"; + +interface DownloadItem { + endedAt?: number; + filename: string; + id: string; + receivedBytes: number; + savePath: string; + startedAt: number; + state: DownloadState; + totalBytes: number; + url: string; +} + export interface ElectronAPI { platform: NodeJS.Platform; closeWindow: () => void; @@ -146,6 +180,43 @@ export interface ElectronAPI { clearCache: () => Promise; getCacheSize: () => Promise; }; + + // Site permission APIs + permissions: { + list: () => Promise; + set: ( + origin: string, + permission: SitePermission, + decision: PermissionDecision + ) => Promise; + clear: (origin?: string) => Promise; + }; + + browsingData: { + clearHistory: () => Promise; + clearCookies: () => Promise; + clearCache: () => Promise; + clearSiteData: () => Promise; + clearAll: () => Promise; + }; + + downloads: { + list: () => Promise; + clearCompleted: () => Promise; + openInFolder: (id: string) => Promise; + onUpdated: (callback: (items: DownloadItem[]) => void) => () => void; + }; + + pageTools: { + find: (text: string, forward?: boolean) => Promise; + findNext: (text: string) => Promise; + findPrevious: (text: string) => Promise; + stopFind: () => Promise; + zoomIn: () => Promise; + zoomOut: () => Promise; + zoomReset: () => Promise; + print: () => Promise; + }; } declare global { diff --git a/apps/browser/vitest.config.ts b/apps/browser/vitest.config.ts new file mode 100644 index 0000000..dac8e55 --- /dev/null +++ b/apps/browser/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + globals: false, + include: ["src/**/*.test.ts"], + }, +}); diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index b6c3578..062381a 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -24,6 +24,7 @@ pnpm release:check The command verifies: - TypeScript checks for every workspace task. +- Unit tests for browser policy and persistence managers. - The repository lint task. - Production build output. - `pnpm audit --prod=false`. @@ -63,16 +64,17 @@ Last checked: 2026-05-19 KST. 1. Start from a clean `main` branch. 2. Run `pnpm install --frozen-lockfile`. 3. Run `pnpm release:check`. -4. Run `pnpm --filter @aka-browser/browser evs:verify`. -5. Build the app with `pnpm --filter @aka-browser/browser package`. -6. Verify notarization with `spctl`. -7. Launch the packaged app. -8. Confirm a blank tab opens and Settings is reachable. -9. Confirm Settings > Language shows System Default, English, and Korean. -10. Confirm Widevine CDM is present in startup logs. -11. Confirm at least one Widevine-protected streaming service starts playback. -12. Upload release assets and checksums to GitHub Releases. -13. Publish release notes with any skipped manual gates called out explicitly. +4. Run the Stage 3 smoke checklist in `docs/STAGE_3_SMOKE_CHECKLIST.md`. +5. Run `pnpm --filter @aka-browser/browser evs:verify`. +6. Build the app with `pnpm --filter @aka-browser/browser package`. +7. Verify notarization with `spctl`. +8. Launch the packaged app. +9. Confirm a blank tab opens and Settings is reachable. +10. Confirm Settings > Language shows System Default, English, and Korean. +11. Confirm Widevine CDM is present in startup logs. +12. Confirm at least one Widevine-protected streaming service starts playback. +13. Upload release assets and checksums to GitHub Releases. +14. Publish release notes with any skipped manual gates called out explicitly. ## Blocking Rules diff --git a/docs/STAGE_3_SMOKE_CHECKLIST.md b/docs/STAGE_3_SMOKE_CHECKLIST.md new file mode 100644 index 0000000..a2371b7 --- /dev/null +++ b/docs/STAGE_3_SMOKE_CHECKLIST.md @@ -0,0 +1,38 @@ +# Stage 3 Smoke Checklist + +Run this checklist before merging Stage 3 browser stability work. + +## Automated Gates + +- [ ] `pnpm test` passes. +- [ ] `pnpm release:check` passes. +- [ ] `git diff --check` passes. + +## Manual App Smoke + +- [ ] Launch aka-browser from the Stage 3 branch. +- [ ] Confirm a blank tab opens. +- [ ] Navigate to `https://example.com`. +- [ ] Confirm `javascript:alert(1)` is blocked. +- [ ] Open a `mailto:` link and confirm aka-browser asks before opening the + external app. +- [ ] Create, switch, and close tabs. +- [ ] Restart the app and confirm the last web tab restores. +- [ ] Open Settings > Browsing Data and clear history, cookies, cache, site + data, and all browsing data. +- [ ] Download a small file and confirm Settings > Downloads shows progress or + completion. +- [ ] Use Find in Page from the page menu. +- [ ] Use Zoom In, Zoom Out, and Reset Zoom from the page menu. +- [ ] Use Print from the page menu and confirm the print dialog opens. +- [ ] Confirm Favorites and Language settings still open. + +## Release Notes Evidence + +Record the following in release notes or the release checklist: + +- Automated gate output. +- Whether EVS signing was available. +- Whether notarization was checked. +- Whether Widevine playback was checked in a packaged app. +- Any skipped manual gate and the reason it was skipped. diff --git a/docs/superpowers/plans/2026-05-19-stage-3-browser-stability.md b/docs/superpowers/plans/2026-05-19-stage-3-browser-stability.md new file mode 100644 index 0000000..7cfb425 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-stage-3-browser-stability.md @@ -0,0 +1,651 @@ +# Stage 3 Browser Stability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Stage 3 companion-browser stability layer: safer production defaults, site controls, recovery, downloads, page tools, and tests. + +**Architecture:** Keep the current Electron main/renderer/preload split. Add small state-focused main-process managers, expose narrow IPC methods, and add testable pure logic around policy and persistence before wiring UI. Each task must keep `pnpm release:check` green. + +**Tech Stack:** Electron, React 18, TypeScript, Vite, pnpm, Turbo, Vitest. + +--- + +## File Structure + +- Modify `package.json`: add root `test` script and include tests in `release:check`. +- Modify `scripts/release-check.sh`: run `pnpm test` after build/type checks. +- Modify `apps/browser/package.json`: add `test` script and Vitest dependency. +- Create `apps/browser/vitest.config.ts`: Node-environment unit test config. +- Create `apps/browser/src/main/security-policy.ts`: protocol classification and navigation policy. +- Modify `apps/browser/src/main/security.ts`: delegate policy decisions to `security-policy.ts`. +- Modify `apps/browser/src/main/app-lifecycle.ts`: remove unsafe production switches. +- Create `apps/browser/src/main/permission-manager.ts`: site permission persistence and decisions. +- Create `apps/browser/src/main/history-manager.ts`: history persistence and visit updates. +- Create `apps/browser/src/main/session-manager.ts`: tab/session persistence and restore normalization. +- Create `apps/browser/src/main/download-manager.ts`: download state tracking around Electron download events. +- Create `apps/browser/src/main/browsing-data-manager.ts`: app-owned and Electron session data clearing. +- Create `apps/browser/src/main/external-protocol.ts`: external protocol classification and confirmation. +- Modify `apps/browser/src/main/index.ts`: instantiate new managers. +- Modify `apps/browser/src/main/ipc-handlers.ts`: register narrow IPC APIs for new managers and page tools. +- Modify `apps/browser/src/main/tab-manager.ts`: record history/session changes and use security policy. +- Modify `apps/browser/src/main/window-manager.ts`: wire permission manager and page tool shortcuts. +- Modify `apps/browser/src/preload.ts`: expose new IPC APIs. +- Modify `apps/browser/src/types/electron-api.d.ts`: type new APIs. +- Modify `apps/browser/src/renderer/components/menu-overlay.tsx`: add page tools and downloads/settings entry points. +- Modify `apps/browser/src/renderer/components/settings.tsx`: add Site Permissions, Browsing Data, History, and Downloads views. +- Create focused renderer components under `apps/browser/src/renderer/components/settings/`. +- Create unit tests under `apps/browser/src/main/__tests__/`. +- Create `docs/STAGE_3_SMOKE_CHECKLIST.md`: manual smoke checklist. + +## Task 1: Test Foundation + +**Files:** +- Modify: `package.json` +- Modify: `apps/browser/package.json` +- Modify: `scripts/release-check.sh` +- Create: `apps/browser/vitest.config.ts` +- Create: `apps/browser/src/main/__tests__/smoke.test.ts` + +- [ ] **Step 1: Add failing package scripts** + +Update root `package.json` scripts: + +```json +{ + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "check-types": "turbo run check-types", + "test": "turbo run test", + "release:check": "bash scripts/release-check.sh" + } +} +``` + +Update `apps/browser/package.json` scripts and devDependencies: + +```json +{ + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} +``` + +- [ ] **Step 2: Add Vitest config** + +Create `apps/browser/vitest.config.ts`: + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + globals: false, + }, +}); +``` + +- [ ] **Step 3: Add initial test** + +Create `apps/browser/src/main/__tests__/smoke.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +describe("test foundation", () => { + it("runs main-process unit tests", () => { + expect(true).toBe(true); + }); +}); +``` + +- [ ] **Step 4: Wire release check** + +Edit `scripts/release-check.sh` after the TypeScript/lint/build block: + +```bash +echo +echo "== Unit tests ==" +pnpm test +``` + +- [ ] **Step 5: Verify** + +Run: + +```bash +pnpm install --frozen-lockfile +pnpm test +pnpm release:check +``` + +Expected: + +- `pnpm test` exits 0. +- `pnpm release:check` exits 0 and prints `== Unit tests ==`. + +- [ ] **Step 6: Commit** + +```bash +git add package.json apps/browser/package.json pnpm-lock.yaml scripts/release-check.sh apps/browser/vitest.config.ts apps/browser/src/main/__tests__/smoke.test.ts +git commit -m "test: add browser unit test foundation" +``` + +## Task 2: Security Policy and Production Defaults + +**Files:** +- Create: `apps/browser/src/main/security-policy.ts` +- Create: `apps/browser/src/main/__tests__/security-policy.test.ts` +- Modify: `apps/browser/src/main/security.ts` +- Modify: `apps/browser/src/main/app-lifecycle.ts` +- Modify: `apps/browser/src/main/tab-manager.ts` + +- [ ] **Step 1: Write policy tests** + +Create `apps/browser/src/main/__tests__/security-policy.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + classifyNavigationTarget, + isNavigableWebUrl, + sanitizeNavigationInput, +} from "../security-policy"; + +describe("security policy", () => { + it("normalizes bare external domains to https", () => { + expect(sanitizeNavigationInput("example.com")).toBe("https://example.com"); + }); + + it("normalizes localhost to http", () => { + expect(sanitizeNavigationInput("localhost:5173")).toBe("http://localhost:5173"); + }); + + it("allows http and https web URLs", () => { + expect(isNavigableWebUrl("https://example.com", "production")).toBe(true); + expect(isNavigableWebUrl("http://localhost:5173", "production")).toBe(true); + }); + + it("allows file URLs only in development", () => { + expect(isNavigableWebUrl("file:///tmp/page.html", "development")).toBe(true); + expect(isNavigableWebUrl("file:///tmp/page.html", "production")).toBe(false); + }); + + it("blocks dangerous protocols", () => { + expect(classifyNavigationTarget("javascript:alert(1)", "production").kind).toBe("blocked"); + expect(classifyNavigationTarget("data:text/html,hi", "production").kind).toBe("blocked"); + }); + + it("classifies external app protocols", () => { + const result = classifyNavigationTarget("mailto:test@example.com", "production"); + expect(result.kind).toBe("external"); + expect(result.protocol).toBe("mailto:"); + }); +}); +``` + +- [ ] **Step 2: Implement policy** + +Create `apps/browser/src/main/security-policy.ts`: + +```ts +export type RuntimeEnvironment = "development" | "production"; + +export type NavigationDecision = + | { kind: "web"; url: string } + | { kind: "external"; url: string; protocol: string } + | { kind: "blocked"; url: string; reason: string }; + +const dangerousProtocols = new Set(["javascript:", "data:", "vbscript:", "about:", "blob:"]); +const externalProtocols = new Set(["mailto:", "tel:", "sms:", "facetime:"]); + +export function getRuntimeEnvironment(): RuntimeEnvironment { + return process.env.NODE_ENV === "development" ? "development" : "production"; +} + +export function sanitizeNavigationInput(input: string): string { + let url = input.trim().replace(/[\x00-\x1F\x7F]/g, ""); + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return url; + + const isLocalUrl = + /^(localhost|127\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)(:\d+)?/i.test( + url + ); + + return `${isLocalUrl ? "http" : "https"}://${url}`; +} + +export function isNavigableWebUrl( + urlString: string, + environment: RuntimeEnvironment = getRuntimeEnvironment() +): boolean { + try { + const url = new URL(urlString); + if (url.protocol === "http:" || url.protocol === "https:") return true; + if (environment === "development" && url.protocol === "file:") return true; + return false; + } catch { + return false; + } +} + +export function classifyNavigationTarget( + input: string, + environment: RuntimeEnvironment = getRuntimeEnvironment() +): NavigationDecision { + const sanitized = sanitizeNavigationInput(input); + + try { + const url = new URL(sanitized); + if (dangerousProtocols.has(url.protocol)) { + return { kind: "blocked", url: sanitized, reason: `Blocked protocol ${url.protocol}` }; + } + if (isNavigableWebUrl(sanitized, environment)) { + return { kind: "web", url: sanitized }; + } + if (externalProtocols.has(url.protocol)) { + return { kind: "external", url: sanitized, protocol: url.protocol }; + } + return { kind: "blocked", url: sanitized, reason: `Unsupported protocol ${url.protocol}` }; + } catch { + return { kind: "blocked", url: sanitized, reason: "Invalid URL" }; + } +} +``` + +- [ ] **Step 3: Delegate existing security helpers** + +Update `apps/browser/src/main/security.ts` so `sanitizeUrl()` calls +`sanitizeNavigationInput()` and `isValidUrl()` returns true only for +`classifyNavigationTarget(url).kind === "web"`. + +- [ ] **Step 4: Remove unsafe production switches** + +In `apps/browser/src/main/app-lifecycle.ts`, remove: + +```ts +app.commandLine.appendSwitch("ignore-certificate-errors"); +app.commandLine.appendSwitch("allow-running-insecure-content"); +``` + +Keep the Widevine feature switch. + +- [ ] **Step 5: Use policy in new-window handling** + +In `apps/browser/src/main/tab-manager.ts`, use `classifyNavigationTarget()` in +`will-navigate` and `setWindowOpenHandler`. Web decisions load normally, +blocked decisions are denied, and external decisions are denied until Task 4 +adds confirmation. + +- [ ] **Step 6: Verify** + +Run: + +```bash +pnpm test +pnpm release:check +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/browser/src/main/security-policy.ts apps/browser/src/main/__tests__/security-policy.test.ts apps/browser/src/main/security.ts apps/browser/src/main/app-lifecycle.ts apps/browser/src/main/tab-manager.ts +git commit -m "security: add explicit navigation policy" +``` + +## Task 3: Site Permission Manager + +**Files:** +- Create: `apps/browser/src/main/permission-manager.ts` +- Create: `apps/browser/src/main/__tests__/permission-manager.test.ts` +- Modify: `apps/browser/src/main/index.ts` +- Modify: `apps/browser/src/main/window-manager.ts` +- Modify: `apps/browser/src/main/ipc-handlers.ts` +- Modify: `apps/browser/src/preload.ts` +- Modify: `apps/browser/src/types/electron-api.d.ts` + +- [ ] **Step 1: Write permission manager tests** + +Create tests for: + +- default decision is `prompt` for supported permissions. +- saved `allow` and `block` decisions are returned by origin. +- `clear()` removes decisions. +- corrupt JSON starts with empty state. + +- [ ] **Step 2: Implement permission manager** + +Create a class with this public API: + +```ts +export type SitePermission = "media" | "clipboard-read" | "clipboard-write" | "fullscreen"; +export type PermissionDecision = "allow" | "block" | "prompt"; + +export interface SitePermissionEntry { + origin: string; + permission: SitePermission; + decision: PermissionDecision; + updatedAt: number; +} + +export class PermissionManager { + constructor(basePath?: string); + getDecision(origin: string, permission: SitePermission): PermissionDecision; + setDecision(origin: string, permission: SitePermission, decision: PermissionDecision): void; + list(): SitePermissionEntry[]; + clear(origin?: string): void; +} +``` + +- [ ] **Step 3: Wire Electron permission handler** + +Use `PermissionManager` in the session permission handler. Apply saved allow or +block decisions. For unknown media/fullscreen/clipboard requests, deny by +default in main-process logic until the renderer prompt is added in the next +UI task. + +- [ ] **Step 4: Expose IPC APIs** + +Add narrow IPC handlers: + +- `permissions-list` +- `permissions-set` +- `permissions-clear` + +- [ ] **Step 5: Verify and commit** + +Run `pnpm test && pnpm release:check`, then commit: + +```bash +git add apps/browser/src/main/permission-manager.ts apps/browser/src/main/__tests__/permission-manager.test.ts apps/browser/src/main/index.ts apps/browser/src/main/window-manager.ts apps/browser/src/main/ipc-handlers.ts apps/browser/src/preload.ts apps/browser/src/types/electron-api.d.ts +git commit -m "feat: add site permission manager" +``` + +## Task 4: External Protocol Confirmation + +**Files:** +- Create: `apps/browser/src/main/external-protocol.ts` +- Create: `apps/browser/src/main/__tests__/external-protocol.test.ts` +- Modify: `apps/browser/src/main/tab-manager.ts` +- Modify: `apps/browser/src/main/ipc-handlers.ts` +- Modify: `apps/browser/src/preload.ts` +- Modify: `apps/browser/src/renderer/app.tsx` + +- [ ] **Step 1: Test protocol confirmation classification** + +Cover mailto/tel as external and unknown protocols as blocked. + +- [ ] **Step 2: Implement external protocol helper** + +Create helpers that extract protocol, origin URL, and display labels for +confirmation prompts. + +- [ ] **Step 3: Wire request flow** + +When external navigation is attempted, send an IPC event to the renderer with +the protocol and URL. Renderer shows a compact confirmation dialog. Confirmed +opens call Electron `shell.openExternal`; cancelled does nothing. + +- [ ] **Step 4: Verify and commit** + +Run `pnpm test && pnpm release:check`, then commit: + +```bash +git add apps/browser/src/main/external-protocol.ts apps/browser/src/main/__tests__/external-protocol.test.ts apps/browser/src/main/tab-manager.ts apps/browser/src/main/ipc-handlers.ts apps/browser/src/preload.ts apps/browser/src/renderer/app.tsx +git commit -m "feat: confirm external protocol launches" +``` + +## Task 5: History and Session Restore + +**Files:** +- Create: `apps/browser/src/main/history-manager.ts` +- Create: `apps/browser/src/main/session-manager.ts` +- Create: `apps/browser/src/main/__tests__/history-manager.test.ts` +- Create: `apps/browser/src/main/__tests__/session-manager.test.ts` +- Modify: `apps/browser/src/main/index.ts` +- Modify: `apps/browser/src/main/tab-manager.ts` +- Modify: `apps/browser/src/main/tabs/tab-navigation.ts` +- Modify: `apps/browser/src/main/window-manager.ts` + +- [ ] **Step 1: Write history tests** + +Cover new visit, repeated URL visit count increment, title update, max entry +ordering, and corrupt JSON recovery. + +- [ ] **Step 2: Write session tests** + +Cover tab serialization, active tab persistence, orientation persistence, and +normalization of internal temp-file URLs to blank tabs. + +- [ ] **Step 3: Implement managers** + +History manager API: + +```ts +recordVisit(url: string, title: string, visitedAt?: number): void; +list(limit?: number): HistoryEntry[]; +clear(): void; +``` + +Session manager API: + +```ts +save(snapshot: BrowserSessionSnapshot): void; +load(): BrowserSessionSnapshot | null; +clear(): void; +normalizeUrlForRestore(url: string): string; +``` + +- [ ] **Step 4: Wire tab lifecycle** + +Record history on main-frame successful navigation. Save session on tab create, +close, switch, navigation complete, orientation change, and app quit. + +- [ ] **Step 5: Restore on launch** + +In `WindowManager.createWindow()`, load saved tabs if available. Fall back to a +single blank tab when no valid tabs exist. + +- [ ] **Step 6: Verify and commit** + +Run `pnpm test && pnpm release:check`, then commit: + +```bash +git add apps/browser/src/main/history-manager.ts apps/browser/src/main/session-manager.ts apps/browser/src/main/__tests__/history-manager.test.ts apps/browser/src/main/__tests__/session-manager.test.ts apps/browser/src/main/index.ts apps/browser/src/main/tab-manager.ts apps/browser/src/main/tabs/tab-navigation.ts apps/browser/src/main/window-manager.ts +git commit -m "feat: add history and session restore" +``` + +## Task 6: Browsing Data Settings + +**Files:** +- Create: `apps/browser/src/main/browsing-data-manager.ts` +- Create: `apps/browser/src/main/__tests__/browsing-data-manager.test.ts` +- Modify: `apps/browser/src/main/ipc-handlers.ts` +- Modify: `apps/browser/src/preload.ts` +- Modify: `apps/browser/src/types/electron-api.d.ts` +- Modify: `apps/browser/src/renderer/components/settings.tsx` +- Create: `apps/browser/src/renderer/components/settings/browsing-data-view.tsx` + +- [ ] **Step 1: Test app-owned clear operations** + +Use temp directories and stub session methods to verify history, permissions, +favicon cache, and theme cache clear requests call the expected manager methods. + +- [ ] **Step 2: Implement browsing data manager** + +Expose: + +```ts +clearHistory(): Promise; +clearCache(): Promise; +clearCookies(): Promise; +clearSiteData(): Promise; +clearAll(): Promise; +``` + +- [ ] **Step 3: Add Settings UI** + +Add a Browsing Data view with buttons for Clear History, Clear Cookies, Clear +Cache, Clear Site Data, and Clear All. Show success/failure text from structured +results. + +- [ ] **Step 4: Verify and commit** + +Run `pnpm test && pnpm release:check`, then commit: + +```bash +git add apps/browser/src/main/browsing-data-manager.ts apps/browser/src/main/__tests__/browsing-data-manager.test.ts apps/browser/src/main/ipc-handlers.ts apps/browser/src/preload.ts apps/browser/src/types/electron-api.d.ts apps/browser/src/renderer/components/settings.tsx apps/browser/src/renderer/components/settings/browsing-data-view.tsx +git commit -m "feat: add browsing data controls" +``` + +## Task 7: Downloads + +**Files:** +- Create: `apps/browser/src/main/download-manager.ts` +- Create: `apps/browser/src/main/__tests__/download-manager.test.ts` +- Modify: `apps/browser/src/main/index.ts` +- Modify: `apps/browser/src/main/ipc-handlers.ts` +- Modify: `apps/browser/src/preload.ts` +- Modify: `apps/browser/src/types/electron-api.d.ts` +- Modify: `apps/browser/src/renderer/components/settings.tsx` +- Create: `apps/browser/src/renderer/components/settings/downloads-view.tsx` + +- [ ] **Step 1: Test download transitions** + +Cover created, progress, completed, cancelled, interrupted, clear completed, +and list ordering. + +- [ ] **Step 2: Implement download manager** + +Track: + +```ts +id, filename, url, savePath, receivedBytes, totalBytes, state, startedAt, endedAt +``` + +States: + +```ts +"active" | "completed" | "cancelled" | "interrupted" +``` + +- [ ] **Step 3: Wire Electron session** + +Attach to `session.fromPartition("persist:main").on("will-download", ...)`. +Emit renderer updates as items change. + +- [ ] **Step 4: Add Downloads view** + +Display active and recent downloads. Add Clear Completed and Open in Finder +actions where path exists. + +- [ ] **Step 5: Verify and commit** + +Run `pnpm test && pnpm release:check`, then commit: + +```bash +git add apps/browser/src/main/download-manager.ts apps/browser/src/main/__tests__/download-manager.test.ts apps/browser/src/main/index.ts apps/browser/src/main/ipc-handlers.ts apps/browser/src/preload.ts apps/browser/src/types/electron-api.d.ts apps/browser/src/renderer/components/settings.tsx apps/browser/src/renderer/components/settings/downloads-view.tsx +git commit -m "feat: add download manager" +``` + +## Task 8: Page Tools + +**Files:** +- Modify: `apps/browser/src/main/ipc-handlers.ts` +- Modify: `apps/browser/src/main/window-manager.ts` +- Modify: `apps/browser/src/preload.ts` +- Modify: `apps/browser/src/types/electron-api.d.ts` +- Modify: `apps/browser/src/renderer/components/menu-overlay.tsx` +- Create: `apps/browser/src/renderer/components/find-in-page.tsx` + +- [ ] **Step 1: Add IPC methods** + +Expose active-tab methods: + +- `page-find` +- `page-find-next` +- `page-find-previous` +- `page-stop-find` +- `page-zoom-in` +- `page-zoom-out` +- `page-zoom-reset` +- `page-print` + +- [ ] **Step 2: Implement active-tab calls** + +Use Electron `webContents.findInPage`, `stopFindInPage`, `setZoomLevel`, +`getZoomLevel`, and `print`. + +- [ ] **Step 3: Add renderer controls** + +Add menu actions for Find, Zoom In, Zoom Out, Reset Zoom, and Print. Find opens +a compact input overlay. + +- [ ] **Step 4: Verify and commit** + +Run `pnpm test && pnpm release:check`, then commit: + +```bash +git add apps/browser/src/main/ipc-handlers.ts apps/browser/src/main/window-manager.ts apps/browser/src/preload.ts apps/browser/src/types/electron-api.d.ts apps/browser/src/renderer/components/menu-overlay.tsx apps/browser/src/renderer/components/find-in-page.tsx +git commit -m "feat: add page tools" +``` + +## Task 9: Manual Smoke Checklist and Completion Audit + +**Files:** +- Create: `docs/STAGE_3_SMOKE_CHECKLIST.md` +- Modify: `docs/RELEASE_READINESS.md` + +- [ ] **Step 1: Add smoke checklist** + +Create checklist covering: + +- launch app +- create/switch/close tabs +- navigate to `https://example.com` +- block `javascript:` navigation +- external `mailto:` confirmation +- permission decision display +- history entry creation +- session restore after app restart +- clear browsing data +- download a small file +- find in page +- zoom in/out/reset +- print dialog opens +- release check passes + +- [ ] **Step 2: Update release readiness docs** + +Mention `pnpm test` and Stage 3 manual smoke gates. + +- [ ] **Step 3: Final verification** + +Run: + +```bash +pnpm test +pnpm release:check +git diff --check +git status -sb +``` + +- [ ] **Step 4: Completion audit** + +Map every Stage 3 design deliverable to files, tests, and manual smoke evidence. +Do not mark the goal complete if any deliverable is missing or weakly verified. + +- [ ] **Step 5: Commit** + +```bash +git add docs/STAGE_3_SMOKE_CHECKLIST.md docs/RELEASE_READINESS.md +git commit -m "docs: add stage 3 smoke checklist" +``` diff --git a/docs/superpowers/specs/2026-05-19-stage-3-browser-stability-design.md b/docs/superpowers/specs/2026-05-19-stage-3-browser-stability-design.md new file mode 100644 index 0000000..a427972 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-stage-3-browser-stability-design.md @@ -0,0 +1,219 @@ +# Stage 3 Browser Stability Design + +## Summary + +Stage 3 turns aka-browser from a release-ready beta into a stable companion +browser. The goal is not to compete with Chrome, Safari, or Arc as a primary +browser. The goal is to make the existing side-browser experience safer, +recoverable, and predictable for day-to-day use with streaming, social feeds, +docs, tutorials, and chat windows. + +## Product Boundary + +aka-browser remains a focused always-on-top companion browser. Stage 3 includes +the browser controls users naturally need while using that companion: safe +navigation defaults, site permissions, history, session restore, downloads, +site data cleanup, page tools, and regression tests. + +Stage 3 excludes password management, autofill, extension support, multi-profile +sync, ad blocking, tracking protection engines, and full default-browser +replacement behavior. Those features would turn aka-browser into a general +browser platform and are outside this stage. + +## Current Evidence + +- Tabs, tab previews, new-tab creation, tab switching, and tab closing are + implemented in `apps/browser/src/main/tab-manager.ts`. +- Navigation controls, reload, URL entry, context menu actions, and DevTools + access exist in the main and renderer layers. +- Bookmarks, default start-page links, favicon cache, language settings, and + app settings exist. +- Release readiness passes through `pnpm release:check`. +- There is no current test framework or test command beyond the release gate. +- Production security is not ready because `ignore-certificate-errors` and + `allow-running-insecure-content` are globally enabled during Widevine setup. +- There are no dedicated managers for history, session restore, downloads, + site permissions, browsing-data cleanup, find-in-page, zoom, print, or + external protocol confirmation. + +## Deliverables + +### 1. Security Baseline + +Production builds must not ignore certificate errors globally and must not allow +insecure content globally. Any Widevine-specific compatibility switches must be +kept narrow, documented, and environment-aware. + +Navigation policy must cover: + +- `http:` and `https:` as normal navigable protocols. +- `file:` only in development or for generated internal pages. +- dangerous protocols such as `javascript:`, `data:`, `vbscript:`, `about:`, + and `blob:` as blocked navigations unless explicitly handled internally. +- external protocols such as `mailto:`, `tel:`, and app-specific links through + a user-confirmed external-open path. +- `window.open` and target-blank requests opening as tabs only after the same + policy check. + +Certificate and security failures must land on an in-app error page with a +clear reason and a safe recovery action. The app must not silently downgrade +security in production. + +### 2. Site Permission Controls + +The app must store site-level permission decisions in user data. The first +supported permissions are media, clipboard read/write, and fullscreen because +they map to current Electron permission calls and companion-browser use cases. + +Permission behavior: + +- Known site decision: apply saved allow/block value. +- Unknown site decision: deny by default where silent denial is acceptable, or + show a small in-app prompt when the feature needs user intent. +- Settings must expose a Site Permissions view where users can inspect and + clear stored decisions. +- Permission storage must be testable without Electron UI. + +### 3. Browsing Data and Recovery + +The app must record enough browsing data to recover normal companion-browser +sessions: + +- History entries with URL, title, timestamp, and visit count. +- Session state with open tabs, active tab, and orientation. +- Restore on app launch, with invalid or internal temp-file pages normalized to + a blank tab. +- Settings actions to clear history, cookies, cache, and all site data. + +Browsing data storage must use JSON files under Electron `userData` for local +state that belongs to aka-browser, and Electron session APIs for cookies/cache. + +### 4. Downloads + +The app must handle Electron download events for the persistent browser session. +Users must be able to see active, completed, cancelled, and failed downloads. + +Download behavior: + +- Record filename, source URL, save path, received bytes, total bytes, state, + and timestamps. +- Expose download state to the renderer through IPC. +- Provide a Downloads settings view. +- Provide actions to open the downloaded file location and clear completed + entries. +- Avoid adding a custom downloader; use Electron's `will-download` flow. + +### 5. Page Tools + +The app must provide common page-level tools that are useful in a compact +browser: + +- Find in page with next/previous result navigation and close behavior. +- Zoom in, zoom out, and reset zoom for the active tab. +- Print active page using Electron's print flow. + +These controls should live in the existing menu/settings surface without +turning the top bar into a full desktop browser toolbar. + +### 6. External Protocol UX + +External protocol launches must be explicit. When a page tries to open an +external app link, aka-browser should show a confirmation prompt naming the +target protocol and origin. The user can open once or cancel. Persistent +remembered allow rules are out of scope for Stage 3. + +### 7. Test Foundation + +Stage 3 must add a real test command and wire it into release readiness. + +Initial test coverage must include: + +- URL/security policy. +- permission decision storage. +- history recording and deduplication. +- session serialization and restoration normalization. +- download state transitions. +- browsing-data clear operations for app-owned stores. + +Renderer-only visual testing is not required in this stage, but smoke coverage +must prove the TypeScript build, unit tests, and release gate run together. + +## Architecture + +Stage 3 keeps the existing Electron main/renderer/preload split. + +New main-process managers should be small and state-focused: + +- `permission-manager.ts` owns per-origin permission decisions. +- `history-manager.ts` owns history persistence and update rules. +- `session-manager.ts` owns save/restore tab state. +- `download-manager.ts` owns download item state and Electron download events. +- `browsing-data-manager.ts` owns user-triggered clearing of history, cookies, + cache, permissions, and app-owned data. +- `external-protocol.ts` owns protocol classification and confirmation flow. + +IPC should expose narrow methods instead of raw Electron APIs. Renderer +components should follow the existing Settings view pattern and should not +directly infer filesystem or Electron session state. + +Shared pure logic should live in small testable modules where possible. Electron +integration should be thin and explicit. + +## Data Flow + +Navigation starts in the renderer URL bar, page links, or `window.open`. +The main process sanitizes and classifies the target. Normal web URLs load in +the active tab or a new tab. External protocols trigger a confirmation flow. +Blocked URLs report a security event and show a safe error state. + +Page load completion updates tab metadata, history, previews, and session +state. Tab create, close, switch, orientation change, and app quit also trigger +session persistence. + +Downloads start through the persistent browser session. The download manager +assigns an ID, tracks progress events, emits updates to the renderer, and keeps +recent completed entries until the user clears them. + +Settings screens read state through IPC and request mutations through dedicated +commands. Mutations update disk-backed stores and notify active renderer views. + +## Error Handling + +Corrupt JSON stores must be treated as recoverable. The manager should log the +problem, rename or ignore the corrupt data where appropriate, and continue with +empty state instead of crashing the app. + +Electron session operations can fail. Clear-data actions must return structured +success or failure results so the UI can show a clear outcome. + +Downloads can be cancelled, interrupted, or fail after completion has started. +Download state must reflect Electron's final event state instead of assuming a +successful save. + +## Verification + +Completion requires all of the following: + +- `pnpm test` passes. +- `pnpm release:check` passes and includes tests. +- Unit tests cover security, permissions, history, session restore, downloads, + and browsing-data manager logic. +- `git diff --check` passes. +- The final worktree has no unintended generated files. +- A manual smoke checklist confirms launch, new tab, navigation, permission + prompt, session restore, downloads view, data clearing, find, zoom, and print + do not regress the core companion browser flow. + +## Rollout + +Implement in small PR-sized slices: + +1. Test foundation and security baseline. +2. Site permissions and external protocol policy. +3. History and session restore. +4. Browsing-data cleanup. +5. Downloads. +6. Find, zoom, and print. +7. Final release-readiness and manual smoke pass. + +Each slice must leave `main` releasable. diff --git a/package.json b/package.json index ab89da5..57aed3e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"", "check-types": "turbo run check-types", + "test": "turbo run test", "release:check": "bash scripts/release-check.sh" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0a7b82..f998814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: vite: specifier: ^8.0.13 version: 8.0.13(@types/node@20.19.21)(esbuild@0.28.0)(jiti@2.6.1) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0) packages: @@ -162,156 +165,312 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.0': resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.0': resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.0': resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.0': resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.0': resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.0': resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.0': resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.0': resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.0': resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.0': resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.0': resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.0': resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.0': resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.0': resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.0': resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.0': resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.0': resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.0': resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.0': resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} @@ -447,6 +606,131 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -581,9 +865,21 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -635,6 +931,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -680,6 +1005,10 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -738,6 +1067,10 @@ packages: builder-util@26.8.1: resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -750,10 +1083,18 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -849,6 +1190,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -951,6 +1296,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -962,6 +1310,11 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.0: resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} @@ -975,6 +1328,13 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} @@ -1174,6 +1534,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1281,6 +1644,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} @@ -1411,6 +1777,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} @@ -1514,6 +1887,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -1561,6 +1939,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -1593,10 +1974,16 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1605,6 +1992,9 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -1638,6 +2028,12 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1646,6 +2042,18 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -1701,6 +2109,51 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1744,6 +2197,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1759,6 +2240,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1925,81 +2411,159 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/aix-ppc64@0.28.0': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm64@0.28.0': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-arm@0.28.0': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/android-x64@0.28.0': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.28.0': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.28.0': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.28.0': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.28.0': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.28.0': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-arm@0.28.0': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-ia32@0.28.0': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-loong64@0.28.0': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.28.0': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.28.0': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.28.0': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-s390x@0.28.0': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/linux-x64@0.28.0': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.28.0': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.28.0': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.28.0': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.28.0': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.28.0': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.28.0': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.28.0': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-ia32@0.28.0': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@esbuild/win32-x64@0.28.0': optional: true @@ -2099,6 +2663,81 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + '@sindresorhus/is@4.6.0': {} '@szmarczak/http-timer@4.0.6': @@ -2203,10 +2842,21 @@ snapshots: '@types/node': 20.19.21 '@types/responselike': 1.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + '@types/fs-extra@9.0.13': dependencies: '@types/node': 20.19.21 @@ -2258,6 +2908,48 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.0.13(@types/node@20.19.21)(esbuild@0.28.0)(jiti@2.6.1) + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@xmldom/xmldom@0.8.13': {} abbrev@4.0.0: {} @@ -2331,6 +3023,8 @@ snapshots: assert-plus@1.0.0: optional: true + assertion-error@2.0.1: {} + astral-regex@2.0.0: optional: true @@ -2402,6 +3096,8 @@ snapshots: transitivePeerDependencies: - supports-color + cac@6.7.14: {} + cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -2419,11 +3115,21 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + check-error@2.1.3: {} + chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} @@ -2515,6 +3221,8 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -2663,6 +3371,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2677,6 +3387,35 @@ snapshots: es6-error@4.1.1: optional: true + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.0: optionalDependencies: '@esbuild/aix-ppc64': 0.28.0 @@ -2711,6 +3450,12 @@ snapshots: escape-string-regexp@4.0.0: optional: true + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} extract-zip@2.0.1: @@ -2939,6 +3684,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3023,6 +3770,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowercase-keys@2.0.0: {} lru-cache@6.0.0: @@ -3135,6 +3884,10 @@ snapshots: path-key@3.1.1: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + pe-library@0.4.1: {} pend@1.2.0: {} @@ -3251,6 +4004,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -3289,6 +4073,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} simple-update-notifier@2.0.0: @@ -3319,8 +4105,12 @@ snapshots: sprintf-js@1.1.3: optional: true + stackback@0.0.2: {} + stat-mode@1.0.0: {} + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3331,6 +4121,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -3371,6 +4165,10 @@ snapshots: dependencies: semver: 5.7.2 + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -3381,6 +4179,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -3430,6 +4234,41 @@ snapshots: extsprintf: 1.4.1 optional: true + vite-node@3.2.4(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.3(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 20.19.21 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + vite@8.0.13(@types/node@20.19.21)(esbuild@0.28.0)(jiti@2.6.1): dependencies: lightningcss: 1.32.0 @@ -3443,6 +4282,48 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@20.19.21)(jiti@2.6.1)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.21 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3455,6 +4336,11 @@ snapshots: dependencies: isexe: 4.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/scripts/release-check.sh b/scripts/release-check.sh index f92dae3..befcad6 100755 --- a/scripts/release-check.sh +++ b/scripts/release-check.sh @@ -7,6 +7,10 @@ cd "$ROOT_DIR" echo "== TypeScript, lint, and build ==" pnpm exec turbo run check-types lint build --force +echo +echo "== Unit tests ==" +pnpm test + echo echo "== Dependency audit ==" pnpm audit --prod=false diff --git a/turbo.json b/turbo.json index db837f5..68f7414 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,9 @@ "check-types": { "dependsOn": ["^check-types"] }, + "test": { + "dependsOn": ["^test"] + }, "dev": { "cache": false, "persistent": true