├── .npmrc ├── pnpm-workspace.yaml ├── apps └── browser │ ├── scripts │ ├── notarize.d.ts │ ├── download-widevine.d.ts │ ├── before-sign.d.ts │ ├── evs-sign.d.ts │ └── setup-evs.sh │ ├── assets │ ├── icon.ico │ ├── icon.png │ ├── icon.icns │ ├── tray-icon.png │ └── tray-icon@2x.png │ ├── src │ ├── renderer │ │ ├── vite-env.d.ts │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── blank-page-entry.tsx │ │ │ ├── error-page-entry.tsx │ │ │ ├── error-page.tsx │ │ │ └── blank-page.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── components │ │ │ ├── status-bar.tsx │ │ │ ├── window-controls.tsx │ │ │ ├── navigation-controls.tsx │ │ │ ├── top-bar.tsx │ │ │ ├── menu-overlay.tsx │ │ │ ├── phone-frame.tsx │ │ │ └── tab-overview.tsx │ ├── main │ │ ├── types.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── html-generator.ts │ │ ├── theme-cache.ts │ │ ├── overlay-manager.ts │ │ ├── app-lifecycle.ts │ │ ├── tray-manager.ts │ │ ├── security.ts │ │ ├── bookmark-manager.ts │ │ ├── favicon-cache.ts │ │ ├── window-manager.ts │ │ └── ipc-handlers.ts │ ├── types │ │ ├── castlabs.d.ts │ │ └── electron-api.d.ts │ └── preload.ts │ ├── tsconfig.node.json │ ├── .gitignore │ ├── tsconfig.scripts.json │ ├── tsconfig.webview.json │ ├── tsconfig.renderer.json │ ├── tsconfig.json │ ├── vite.config.ts │ ├── scripts-src │ ├── notarize.ts │ ├── download-widevine.ts │ ├── before-sign.ts │ └── evs-sign.ts │ ├── CODESIGN_START.md │ ├── package.json │ ├── SECURITY.md │ ├── CODESIGN_SETUP.md │ └── README.md ├── .vscode └── settings.json ├── .gitattributes ├── .github └── FUNDING.yml ├── turbo.json ├── package.json ├── .gitignore ├── SECURITY.md ├── LICENSE └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /apps/browser/scripts/notarize.d.ts: -------------------------------------------------------------------------------- 1 | export default function notarizing(context: any): Promise; 2 | -------------------------------------------------------------------------------- /apps/browser/assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmmhmmhm/aka-browser/HEAD/apps/browser/assets/icon.ico -------------------------------------------------------------------------------- /apps/browser/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmmhmmhm/aka-browser/HEAD/apps/browser/assets/icon.png -------------------------------------------------------------------------------- /apps/browser/assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmmhmmhm/aka-browser/HEAD/apps/browser/assets/icon.icns -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /apps/browser/assets/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmmhmmhm/aka-browser/HEAD/apps/browser/assets/tray-icon.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force TypeScript detection for script files 2 | apps/browser/scripts-src/*.ts linguist-language=TypeScript 3 | -------------------------------------------------------------------------------- /apps/browser/assets/tray-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hmmhmmhm/aka-browser/HEAD/apps/browser/assets/tray-icon@2x.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: hmmhmmhm 4 | buy_me_a_coffee: hmmhmmhm 5 | custom: ['https://paypal.me/hmmhmmhm'] 6 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module '*.png'; 5 | declare module '*.jpg'; 6 | declare module '*.jpeg'; 7 | declare module '*.svg'; 8 | -------------------------------------------------------------------------------- /apps/browser/scripts/download-widevine.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Download Widevine CDM for castlabs electron-releases 4 | * 5 | * castlabs electron-releases does NOT include Widevine CDM by default. 6 | * This script manually downloads the Widevine CDM component. 7 | */ 8 | export {}; 9 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './app'; 4 | import './index.css'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /apps/browser/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/pages/blank-page-entry.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import BlankPage from './blank-page'; 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/pages/error-page-entry.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import ErrorPage from './error-page'; 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /apps/browser/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dist-renderer 4 | release 5 | *.log 6 | .DS_Store 7 | 8 | # TypeScript incremental build cache 9 | *.tsbuildinfo 10 | 11 | # Compiled scripts (TypeScript sources in scripts-src/) 12 | scripts/*.js 13 | 14 | # Environment variables (contains sensitive data) 15 | .env.local 16 | .env.*.local 17 | -------------------------------------------------------------------------------- /apps/browser/scripts/before-sign.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * EVS VMP signing before Apple codesign 4 | * This runs BEFORE electron-builder's codesign 5 | */ 6 | interface BuildContext { 7 | electronPlatformName: string; 8 | appOutDir: string; 9 | } 10 | /** 11 | * beforeSign hook for electron-builder 12 | */ 13 | export default function (context: BuildContext): Promise; 14 | export {}; 15 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | html { 4 | background: transparent !important; 5 | } 6 | 7 | body { 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 9 | Ubuntu, Cantarell, sans-serif; 10 | background: transparent !important; 11 | color: #ffffff; 12 | overflow: hidden; 13 | user-select: none; 14 | -webkit-user-select: none; 15 | } 16 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | aka-browser 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.com/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 8 | "outputs": [".next/**", "!.next/cache/**"] 9 | }, 10 | "lint": { 11 | "dependsOn": ["^lint"] 12 | }, 13 | "check-types": { 14 | "dependsOn": ["^check-types"] 15 | }, 16 | "dev": { 17 | "cache": false, 18 | "persistent": true 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aka-browser", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo run build", 6 | "dev": "turbo run dev", 7 | "lint": "turbo run lint", 8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 9 | "check-types": "turbo run check-types" 10 | }, 11 | "devDependencies": { 12 | "prettier": "^3.6.2", 13 | "turbo": "^2.5.8", 14 | "typescript": "5.9.2" 15 | }, 16 | "packageManager": "pnpm@9.0.0", 17 | "engines": { 18 | "node": ">=18" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/browser/scripts/evs-sign.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * EVS (Electron for Content Security VMP signing) hook for electron-builder 4 | * This script: 5 | * 1. Copies Widevine CDM to the app bundle 6 | * 2. Signs the application with Widevine VMP signature after packaging 7 | */ 8 | interface BuildContext { 9 | electronPlatformName: string; 10 | appOutDir: string; 11 | } 12 | /** 13 | * Main electron-builder hook 14 | */ 15 | export default function (context: BuildContext): Promise; 16 | export {}; 17 | -------------------------------------------------------------------------------- /apps/browser/tsconfig.scripts.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "target": "ES2020", 6 | "moduleResolution": "node", 7 | "outDir": "./scripts", 8 | "rootDir": "./scripts-src", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "resolveJsonModule": true, 13 | "types": ["node"], 14 | "composite": true 15 | }, 16 | "include": ["scripts-src/**/*"], 17 | "exclude": ["node_modules", "dist", "dist-renderer", "release"] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | # Debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # Misc 36 | .DS_Store 37 | *.pem 38 | -------------------------------------------------------------------------------- /apps/browser/tsconfig.webview.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020", "DOM"], 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "types": ["node"], 14 | "composite": true 15 | }, 16 | "include": ["src/webview-preload.ts"], 17 | "exclude": ["node_modules", "dist", "dist-renderer", "release"] 18 | } 19 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /apps/browser/src/main/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for the application 3 | */ 4 | 5 | import { WebContentsView } from "electron"; 6 | 7 | export interface Tab { 8 | id: string; 9 | view: WebContentsView; 10 | title: string; 11 | url: string; 12 | preview?: string; // Base64 encoded preview image 13 | isFullscreen?: boolean; // Track if this tab is in fullscreen mode 14 | originalBounds?: Electron.Rectangle; // Store original bounds for restoration 15 | } 16 | 17 | export interface AppState { 18 | mainWindow: Electron.BrowserWindow | null; 19 | tray: Electron.Tray | null; 20 | isAlwaysOnTop: boolean; 21 | webContentsView: WebContentsView | null; 22 | isLandscape: boolean; 23 | tabs: Tab[]; 24 | activeTabId: string | null; 25 | latestThemeColor: string | null; 26 | } 27 | -------------------------------------------------------------------------------- /apps/browser/tsconfig.renderer.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2015.Iterable", "ES2015.Core"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/renderer", "src/types/**/*.d.ts", "src/renderer/vite-env.d.ts"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /apps/browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "moduleResolution": "node", 13 | "types": ["node"], 14 | "composite": true 15 | }, 16 | "include": [ 17 | "src/main.ts", 18 | "src/main/**/*.ts", 19 | "src/preload.ts", 20 | "src/types/**/*.d.ts" 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "dist", 25 | "dist-renderer", 26 | "release", 27 | "src/renderer" 28 | ], 29 | "references": [ 30 | { "path": "./tsconfig.node.json" }, 31 | { "path": "./tsconfig.scripts.json" }, 32 | { "path": "./tsconfig.webview.json" } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 hmmhmmhm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/browser/src/types/castlabs.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for castlabs electron-releases 3 | * @see https://github.com/castlabs/electron-releases 4 | */ 5 | 6 | declare module 'electron' { 7 | /** 8 | * Components API for managing Widevine CDM 9 | * Available in castlabs electron-releases 10 | */ 11 | export interface Components { 12 | /** 13 | * Get the current status of components 14 | */ 15 | status(): string; 16 | 17 | /** 18 | * Check if component updates are enabled 19 | */ 20 | updatesEnabled: boolean; 21 | 22 | /** 23 | * Wait for all components to be ready 24 | * @returns Promise that resolves when components are ready 25 | */ 26 | whenReady(): Promise; 27 | } 28 | 29 | /** 30 | * Components instance for Widevine CDM management 31 | * Available in castlabs electron-releases 32 | */ 33 | export const components: Components | undefined; 34 | } 35 | 36 | // Augment the global Electron namespace 37 | declare global { 38 | namespace Electron { 39 | interface App { 40 | /** 41 | * Check if EVS (Electron Verification Service) is enabled 42 | * Available in castlabs electron-releases 43 | */ 44 | isEVSEnabled?(): boolean; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/browser/src/main/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application constants and configuration 3 | */ 4 | 5 | // iPhone 15 Pro dimensions 6 | export const IPHONE_WIDTH = 393; 7 | export const IPHONE_HEIGHT = 852; 8 | export const FRAME_PADDING = 28; // 14px border on each side 9 | export const TOP_BAR_HEIGHT = 52; 10 | export const STATUS_BAR_HEIGHT = 58; // Height of status bar in portrait mode 11 | export const STATUS_BAR_WIDTH = 58; // Width of status bar in landscape mode 12 | 13 | // User Agents 14 | export const IPHONE_USER_AGENT = 15 | "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; 16 | 17 | export const DESKTOP_USER_AGENT = 18 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; 19 | 20 | // URL validation and security 21 | export const ALLOWED_PROTOCOLS = 22 | process.env.NODE_ENV === "development" 23 | ? ["http:", "https:", "file:"] 24 | : ["http:", "https:"]; 25 | 26 | export const DANGEROUS_PROTOCOLS = [ 27 | "javascript:", 28 | "data:", 29 | "vbscript:", 30 | "about:", 31 | "blob:", 32 | ]; 33 | 34 | export const BLOCKED_DOMAINS: string[] = [ 35 | // Add known malicious domains here 36 | // Example: 'malicious.com', 'phishing-site.net' 37 | ]; 38 | -------------------------------------------------------------------------------- /apps/browser/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | import path from 'path'; 5 | 6 | export default defineConfig({ 7 | root: path.join(__dirname, 'src/renderer'), 8 | plugins: [react(), tailwindcss()], 9 | base: './', 10 | build: { 11 | outDir: path.join(__dirname, 'dist-renderer'), 12 | emptyOutDir: true, 13 | rollupOptions: { 14 | input: { 15 | main: path.join(__dirname, 'src/renderer/index.html'), 16 | 'pages/blank-page': path.join(__dirname, 'src/renderer/pages/blank-page-entry.tsx'), 17 | 'pages/error-page': path.join(__dirname, 'src/renderer/pages/error-page-entry.tsx'), 18 | }, 19 | output: { 20 | entryFileNames: (chunkInfo) => { 21 | // Keep page entries in pages/ directory 22 | if (chunkInfo.name.startsWith('pages/')) { 23 | return '[name].js'; 24 | } 25 | return 'assets/[name]-[hash].js'; 26 | }, 27 | }, 28 | }, 29 | }, 30 | server: { 31 | port: 5173, 32 | cors: true, 33 | headers: { 34 | 'Access-Control-Allow-Origin': '*', 35 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 36 | 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', 37 | }, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /apps/browser/scripts-src/notarize.ts: -------------------------------------------------------------------------------- 1 | import { notarize } from '@electron/notarize'; 2 | import * as path from 'path'; 3 | import * as dotenv from 'dotenv'; 4 | 5 | // Load .env.local file 6 | dotenv.config({ path: path.join(__dirname, '..', '.env.local') }); 7 | 8 | export default async function notarizing(context: any) { 9 | const { electronPlatformName, appOutDir } = context; 10 | 11 | // Only notarize on macOS 12 | if (electronPlatformName !== 'darwin') { 13 | return; 14 | } 15 | 16 | // Check environment variables 17 | const appleId = process.env.APPLE_ID; 18 | const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD; 19 | const teamId = process.env.APPLE_TEAM_ID; 20 | 21 | if (!appleId || !appleIdPassword || !teamId) { 22 | console.warn('⚠️ Skipping notarization. Please set environment variables:'); 23 | console.warn(' APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID'); 24 | return; 25 | } 26 | 27 | const appName = context.packager.appInfo.productFilename; 28 | const appPath = path.join(appOutDir, `${appName}.app`); 29 | 30 | console.log(`🔐 Starting notarization: ${appPath}`); 31 | 32 | try { 33 | await notarize({ 34 | appPath, 35 | appleId, 36 | appleIdPassword, 37 | teamId, 38 | }); 39 | console.log('✅ Notarization completed!'); 40 | } catch (error) { 41 | console.error('❌ Notarization failed:', error); 42 | throw error; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/browser/src/main/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry point for the Electron application 3 | */ 4 | 5 | import { app } from "electron"; 6 | import { AppState } from "./types"; 7 | import { ThemeColorCache } from "./theme-cache"; 8 | import { TabManager } from "./tab-manager"; 9 | import { WindowManager } from "./window-manager"; 10 | import { BookmarkManager } from "./bookmark-manager"; 11 | import { FaviconCache } from "./favicon-cache"; 12 | import { IPCHandlers } from "./ipc-handlers"; 13 | import { TrayManager } from "./tray-manager"; 14 | import { AppLifecycle } from "./app-lifecycle"; 15 | 16 | // Initialize application state 17 | const appState: AppState = { 18 | mainWindow: null, 19 | tray: null, 20 | isAlwaysOnTop: false, 21 | webContentsView: null, 22 | isLandscape: false, 23 | tabs: [], 24 | activeTabId: null, 25 | latestThemeColor: null, 26 | }; 27 | 28 | // Initialize managers 29 | const themeColorCache = new ThemeColorCache(); 30 | const bookmarkManager = new BookmarkManager(); 31 | const faviconCache = new FaviconCache(); 32 | const tabManager = new TabManager(appState, themeColorCache); 33 | const windowManager = new WindowManager(appState, tabManager); 34 | const trayManager = new TrayManager(appState, windowManager); 35 | const ipcHandlers = new IPCHandlers(appState, tabManager, windowManager, bookmarkManager, faviconCache, themeColorCache); 36 | const appLifecycle = new AppLifecycle(appState, windowManager, trayManager); 37 | 38 | // Initialize Widevine 39 | appLifecycle.initializeWidevine(); 40 | 41 | // Wait for Widevine components on ready 42 | app.on("ready", async () => { 43 | await appLifecycle.waitForWidevineComponents(); 44 | }); 45 | 46 | // Setup application when ready 47 | app.whenReady().then(async () => { 48 | await appLifecycle.setupApp(); 49 | ipcHandlers.registerHandlers(); 50 | }); 51 | -------------------------------------------------------------------------------- /apps/browser/src/main/html-generator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate HTML templates for renderer pages 3 | */ 4 | 5 | interface HtmlOptions { 6 | title: string; 7 | themeColor: string; 8 | scriptPath: string; 9 | cssPath?: string; 10 | queryParams?: Record; 11 | isDev?: boolean; 12 | } 13 | 14 | export function generateHtml(options: HtmlOptions): string { 15 | const { title, themeColor, scriptPath, cssPath, queryParams, isDev } = options; 16 | 17 | // Inject query parameters as a global variable 18 | const queryParamsScript = queryParams 19 | ? `` 20 | : ''; 21 | 22 | // In dev mode, use Vite's @vite/client for HMR 23 | const viteClient = isDev ? '' : ''; 24 | 25 | return ` 26 | 27 | 28 | 29 | 33 | 34 | ${title} 35 | ${cssPath ? `` : ''} 36 | ${queryParamsScript} 37 | ${viteClient} 38 | 39 | 40 |
41 | 42 | 43 | `; 44 | } 45 | 46 | export function generateBlankPageHtml(scriptPath: string, cssPath?: string, isDev: boolean = false): string { 47 | return generateHtml({ 48 | title: 'Blank Page', 49 | themeColor: '#1c1c1e', 50 | scriptPath, 51 | cssPath, 52 | isDev, 53 | }); 54 | } 55 | 56 | export function generateErrorPageHtml( 57 | scriptPath: string, 58 | cssPath?: string, 59 | queryParams?: Record, 60 | isDev: boolean = false 61 | ): string { 62 | return generateHtml({ 63 | title: 'Error', 64 | themeColor: '#2d2d2d', 65 | scriptPath, 66 | cssPath, 67 | queryParams, 68 | isDev, 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /apps/browser/scripts/setup-evs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # EVS Setup Script 4 | # This script helps set up the EVS (Electron for Content Security VMP signing) environment 5 | 6 | set -e 7 | 8 | echo "" 9 | echo "==========================================" 10 | echo "EVS Setup Script" 11 | echo "==========================================" 12 | echo "" 13 | 14 | # Check Python 3 15 | echo "[1/4] Checking Python 3..." 16 | if ! command -v python3 &> /dev/null; then 17 | echo "❌ Python 3 is not installed" 18 | echo "Please install Python 3 first:" 19 | echo " macOS: brew install python3" 20 | echo " Linux: sudo apt install python3 python3-pip" 21 | exit 1 22 | fi 23 | echo "✓ Python 3 found: $(python3 --version)" 24 | echo "" 25 | 26 | # Install castlabs-evs 27 | echo "[2/4] Installing castlabs-evs..." 28 | echo "Note: Using --break-system-packages flag for Homebrew Python..." 29 | pip3 install --break-system-packages --upgrade castlabs-evs 30 | echo "✓ castlabs-evs installed" 31 | echo "" 32 | 33 | # Check if EVS account exists 34 | EVS_CONFIG="$HOME/.config/evs/config.json" 35 | if [ -f "$EVS_CONFIG" ]; then 36 | echo "[3/4] EVS account already configured" 37 | ACCOUNT_NAME=$(cat "$EVS_CONFIG" | grep -o '"account_name": "[^"]*"' | cut -d'"' -f4) 38 | echo "✓ Account: $ACCOUNT_NAME" 39 | echo "" 40 | 41 | echo "[4/4] Refreshing authorization tokens..." 42 | echo "Please enter your EVS password when prompted:" 43 | python3 -m castlabs_evs.account reauth 44 | else 45 | echo "[3/4] Creating EVS account..." 46 | echo "Please follow the prompts to create your free EVS account:" 47 | echo "" 48 | python3 -m castlabs_evs.account signup 49 | echo "" 50 | 51 | echo "[4/4] Account created successfully!" 52 | fi 53 | 54 | echo "" 55 | echo "==========================================" 56 | echo "✓ EVS Setup Complete!" 57 | echo "==========================================" 58 | echo "" 59 | echo "Verifying setup..." 60 | node scripts/evs-sign.js --verify 61 | 62 | echo "" 63 | echo "You can now build and sign your application:" 64 | echo " $ pnpm run package" 65 | echo "" 66 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/status-bar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | interface StatusBarProps { 4 | themeColor: string; 5 | textColor: string; 6 | orientation: 'portrait' | 'landscape'; 7 | } 8 | 9 | function StatusBar({ themeColor, textColor, orientation }: StatusBarProps) { 10 | const [time, setTime] = useState('9:41'); 11 | const isLandscape = orientation === 'landscape'; 12 | 13 | // Update time 14 | useEffect(() => { 15 | const updateTime = () => { 16 | const now = new Date(); 17 | const hours = now.getHours().toString().padStart(2, '0'); 18 | const minutes = now.getMinutes().toString().padStart(2, '0'); 19 | setTime(`${hours}:${minutes}`); 20 | }; 21 | updateTime(); 22 | const interval = setInterval(updateTime, 60000); 23 | return () => clearInterval(interval); 24 | }, []); 25 | 26 | return ( 27 |
35 | {/* Dynamic Island */} 36 |
43 | 44 | {/* Time Display */} 45 |
53 | {time} 54 |
55 |
56 | ); 57 | } 58 | 59 | export default StatusBar; 60 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/window-controls.tsx: -------------------------------------------------------------------------------- 1 | interface WindowControlsProps { 2 | theme: 'light' | 'dark'; 3 | } 4 | 5 | function WindowControls({ theme }: WindowControlsProps) { 6 | const handleClose = () => { 7 | window.electronAPI?.closeWindow(); 8 | }; 9 | 10 | const handleMinimize = () => { 11 | window.electronAPI?.minimizeWindow(); 12 | }; 13 | 14 | const handleMaximize = () => { 15 | window.electronAPI?.maximizeWindow(); 16 | }; 17 | 18 | const isDark = theme === 'dark'; 19 | const defaultBgColor = isDark ? 'bg-[#575761]' : 'bg-[rgba(0,0,0,0.15)]'; 20 | 21 | return ( 22 |
23 |
36 | ); 37 | } 38 | 39 | export default WindowControls; 40 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/navigation-controls.tsx: -------------------------------------------------------------------------------- 1 | import { MoreVertical } from 'lucide-react'; 2 | 3 | interface NavigationControlsProps { 4 | onShowTabs: () => void; 5 | onRefresh: () => void; 6 | onShowMenu: () => void; 7 | theme: 'light' | 'dark'; 8 | tabCount: number; 9 | } 10 | 11 | function NavigationControls({ 12 | onShowTabs, 13 | onRefresh, 14 | onShowMenu, 15 | theme, 16 | tabCount, 17 | }: NavigationControlsProps) { 18 | const isDark = theme === 'dark'; 19 | const buttonBaseClass = "w-7 h-7 rounded-md border-none cursor-pointer transition-all duration-150 flex items-center justify-center text-base active:scale-95"; 20 | const buttonThemeClass = isDark 21 | ? "bg-[rgba(255,255,255,0.08)] text-[rgba(255,255,255,0.7)] hover:bg-[rgba(255,255,255,0.15)] hover:text-[rgba(255,255,255,0.9)] active:bg-[rgba(255,255,255,0.1)]" 22 | : "bg-[rgba(0,0,0,0.06)] text-[rgba(0,0,0,0.6)] hover:bg-[rgba(0,0,0,0.1)] hover:text-[rgba(0,0,0,0.85)] active:bg-[rgba(0,0,0,0.08)]"; 23 | 24 | return ( 25 |
26 | 33 | 51 | 58 |
59 | ); 60 | } 61 | 62 | export default NavigationControls; 63 | -------------------------------------------------------------------------------- /apps/browser/CODESIGN_START.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | ## 🚀 Get Started Now 4 | 5 | ### 1. Issue Developer ID Application Certificate 6 | 7 | **Takes 5 minutes** 8 | 9 | 1. https://developer.apple.com/account/resources/certificates/list 10 | 2. Click "+" button → Select "Developer ID Application" 11 | 3. Upload CSR file (generate using method below) 12 | 4. Download and double-click to install 13 | 14 | **How to generate CSR:** 15 | ``` 16 | Keychain Access app → Menu → Keychain Access → Certificate Assistant → Request a Certificate from a Certificate Authority... 17 | → Enter email → Select "Saved to disk" → Save 18 | ``` 19 | 20 | ### 2. Generate App-Specific Password 21 | 22 | **Takes 2 minutes** 23 | 24 | 1. https://appleid.apple.com/account/manage 25 | 2. Security → App-Specific Passwords → "+" button 26 | 3. Enter name (e.g., "aka-browser") → Generate 27 | 4. **Copy password** (you won't be able to see it again!) 28 | 29 | ### 3. Find Your Team ID 30 | 31 | **Takes 1 minute** 32 | 33 | 1. https://developer.apple.com/account 34 | 2. Membership Details → Copy Team ID (e.g., `AB12CD34EF`) 35 | 36 | ### 4. Set Environment Variables 37 | 38 | Create a `.env.local` file in the project root: 39 | 40 | ```bash 41 | # /Users/hm/Documents/GitHub/aka-browser/apps/browser/.env.local 42 | APPLE_ID=your-apple-id@example.com 43 | APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop 44 | APPLE_TEAM_ID=AB12CD34EF 45 | ``` 46 | 47 | **Or** run directly in terminal: 48 | 49 | ```bash 50 | export APPLE_ID="your-apple-id@example.com" 51 | export APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop" 52 | export APPLE_TEAM_ID="AB12CD34EF" 53 | ``` 54 | 55 | ### 5. Build and Notarize 56 | 57 | ```bash 58 | cd /Users/hm/Documents/GitHub/aka-browser/apps/browser 59 | pnpm run package 60 | ``` 61 | 62 | **Build process (approx. 5-10 minutes):** 63 | 1. ✅ Build app 64 | 2. ✅ EVS signing (Widevine) 65 | 3. ✅ Code signing 66 | 4. ✅ Notarization (upload to Apple servers) 67 | 5. ✅ DMG creation 68 | 69 | ### 6. Done! 70 | 71 | Generated file: `release/aka-browser-0.1.0-arm64.dmg` 72 | 73 | You can now upload this file to the internet for distribution. 74 | Users won't see the **"damaged"** message when downloading! ✨ 75 | 76 | --- 77 | 78 | ## 🔍 Verify Notarization 79 | 80 | ```bash 81 | spctl -a -vv -t install release/mac-arm64/aka-browser.app 82 | ``` 83 | 84 | On success: 85 | ``` 86 | accepted 87 | source=Notarized Developer ID 88 | ``` 89 | 90 | --- 91 | 92 | ## ❓ Troubleshooting 93 | 94 | ### Cannot Find Certificate 95 | ```bash 96 | security find-identity -v -p codesigning 97 | ``` 98 | → Check if "Developer ID Application" certificate exists 99 | 100 | ### Notarization Failed 101 | - Verify Apple ID, Password, and Team ID are correct 102 | - Ensure two-factor authentication is enabled 103 | 104 | --- 105 | 106 | **For detailed instructions**: See `CODESIGN_SETUP.md` 107 | -------------------------------------------------------------------------------- /apps/browser/scripts-src/download-widevine.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Download Widevine CDM for castlabs electron-releases 5 | * 6 | * castlabs electron-releases does NOT include Widevine CDM by default. 7 | * This script manually downloads the Widevine CDM component. 8 | */ 9 | 10 | import * as path from 'path'; 11 | 12 | interface PackageJson { 13 | devDependencies: { 14 | electron: string; 15 | [key: string]: string; 16 | }; 17 | } 18 | 19 | console.log('[Widevine] Starting Widevine CDM download...\n'); 20 | 21 | // Get Electron version 22 | const packageJsonPath = path.join(__dirname, '..', 'package.json'); 23 | const packageJson: PackageJson = require(packageJsonPath); 24 | const electronVersionMatch = packageJson.devDependencies.electron.match(/v?(\d+\.\d+\.\d+)/); 25 | 26 | if (!electronVersionMatch) { 27 | console.error('[Widevine] ✗ Could not parse Electron version'); 28 | process.exit(1); 29 | } 30 | 31 | const electronVersion = electronVersionMatch[1]; 32 | 33 | console.log(`[Widevine] Electron version: ${electronVersion}`); 34 | console.log(`[Widevine] Platform: ${process.platform}`); 35 | console.log(`[Widevine] Arch: ${process.arch}\n`); 36 | 37 | // Widevine CDM download URLs (from Chrome) 38 | const WIDEVINE_VERSIONS: Record = { 39 | '38.0.0': '4.10.2710.0', 40 | '37.0.0': '4.10.2710.0', 41 | '36.0.0': '4.10.2710.0' 42 | }; 43 | 44 | const majorMinorVersion = electronVersion.split('.').slice(0, 2).join('.'); 45 | const widevineVersion = WIDEVINE_VERSIONS[majorMinorVersion] || '4.10.2710.0'; 46 | 47 | console.log(`[Widevine] Widevine CDM version: ${widevineVersion}\n`); 48 | 49 | console.log('⚠️ IMPORTANT NOTICE:'); 50 | console.log('================================================================================'); 51 | console.log('Widevine CDM is proprietary software owned by Google.'); 52 | console.log(''); 53 | console.log('castlabs electron-releases does NOT include Widevine CDM.'); 54 | console.log('The CDM must be downloaded separately from Chrome or obtained through'); 55 | console.log('official channels.'); 56 | console.log(''); 57 | console.log('For production use, you should:'); 58 | console.log('1. Run the app in development mode first to trigger automatic download'); 59 | console.log('2. Or obtain Widevine CDM through official Google licensing'); 60 | console.log('================================================================================\n'); 61 | 62 | console.log('[Widevine] Recommended approach:'); 63 | console.log(''); 64 | console.log(' 1. Run the app in development mode:'); 65 | console.log(' $ pnpm run dev'); 66 | console.log(''); 67 | console.log(' 2. Widevine CDM will be automatically downloaded to:'); 68 | console.log(' ~/Library/Application Support/Electron/WidevineCdm/ (macOS)'); 69 | console.log(' %LOCALAPPDATA%\\Electron\\WidevineCdm\\ (Windows)'); 70 | console.log(' ~/.config/Electron/WidevineCdm/ (Linux)'); 71 | console.log(''); 72 | console.log(' 3. Then build the production app:'); 73 | console.log(' $ pnpm run package'); 74 | console.log(''); 75 | 76 | process.exit(0); 77 | -------------------------------------------------------------------------------- /apps/browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@aka-browser/browser", 3 | "version": "0.1.0", 4 | "description": "A lightweight, elegant web browser for developers featuring an iPhone frame interface", 5 | "main": "dist/src/main/index.js", 6 | "scripts": { 7 | "dev": "npm run copy:assets && npm run build:scripts && concurrently \"npm run build:main:watch\" \"npm run build:webview:watch\" \"npm run dev:renderer\" \"npm run start\"", 8 | "dev:renderer": "vite", 9 | "build": "npm run copy:assets && npm run build:scripts && npm run build:main && npm run build:webview && npm run build:renderer", 10 | "build:scripts": "tsc --project tsconfig.scripts.json", 11 | "build:main": "tsc", 12 | "build:webview": "tsc --project tsconfig.webview.json", 13 | "build:webview:watch": "tsc --project tsconfig.webview.json --watch", 14 | "build:main:watch": "tsc --watch", 15 | "build:renderer": "vite build", 16 | "copy:assets": "mkdir -p dist/assets && cp -r assets/* dist/assets/", 17 | "start": "cross-env NODE_ENV=development ./node_modules/.bin/electron .", 18 | "start:electron": "cross-env NODE_ENV=production ./node_modules/.bin/electron .", 19 | "package": "npm run build && electron-builder", 20 | "evs:setup": "bash scripts/setup-evs.sh", 21 | "evs:verify": "node scripts/evs-sign.js --verify", 22 | "check-types": "tsc --noEmit", 23 | "audit": "npm audit", 24 | "audit:fix": "npm audit fix", 25 | "outdated": "npm outdated" 26 | }, 27 | "keywords": [ 28 | "electron", 29 | "browser", 30 | "developer-tools" 31 | ], 32 | "author": "hmmhmmhm", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "@electron/notarize": "^2.5.0", 36 | "@tailwindcss/vite": "^4.1.14", 37 | "@types/node": "^20.11.0", 38 | "@types/react": "^19.2.2", 39 | "@types/react-dom": "^19.2.2", 40 | "@vitejs/plugin-react": "^5.0.4", 41 | "concurrently": "^8.2.2", 42 | "cross-env": "^10.1.0", 43 | "dotenv": "^17.2.3", 44 | "electron": "github:castlabs/electron-releases#v38.0.0+wvcus", 45 | "electron-builder": "^26.0.12", 46 | "tailwindcss": "^4.1.14", 47 | "typescript": "5.9.2", 48 | "vite": "^7.1.11" 49 | }, 50 | "build": { 51 | "appId": "com.aka-browser.app", 52 | "productName": "aka-browser", 53 | "electronDist": "node_modules/electron/dist", 54 | "electronVersion": "38.0.0", 55 | "npmRebuild": false, 56 | "asar": true, 57 | "asarUnpack": [ 58 | "**/*.node", 59 | "**/WidevineCdm/**/*" 60 | ], 61 | "directories": { 62 | "output": "release" 63 | }, 64 | "files": [ 65 | "dist/**/*", 66 | "dist-renderer/**/*", 67 | "assets/**/*", 68 | "!node_modules/**/*" 69 | ], 70 | "afterPack": "scripts/evs-sign.js", 71 | "afterSign": "scripts/notarize.js", 72 | "mac": { 73 | "target": [ 74 | { 75 | "target": "dmg", 76 | "arch": [ 77 | "x64", 78 | "arm64" 79 | ] 80 | } 81 | ], 82 | "category": "public.app-category.developer-tools", 83 | "icon": "assets/icon.icns", 84 | "hardenedRuntime": true, 85 | "gatekeeperAssess": false, 86 | "entitlements": "build/entitlements.mac.plist", 87 | "entitlementsInherit": "build/entitlements.mac.plist" 88 | }, 89 | "win": { 90 | "target": "nsis", 91 | "icon": "assets/icon.ico" 92 | }, 93 | "linux": { 94 | "target": "AppImage", 95 | "icon": "assets/icon.png" 96 | } 97 | }, 98 | "dependencies": { 99 | "lucide-react": "^0.546.0", 100 | "react": "^18.3.1", 101 | "react-dom": "^18.3.1" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /apps/browser/scripts-src/before-sign.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * EVS VMP signing before Apple codesign 5 | * This runs BEFORE electron-builder's codesign 6 | */ 7 | 8 | import { execSync } from 'child_process'; 9 | import * as path from 'path'; 10 | import * as os from 'os'; 11 | import * as fs from 'fs'; 12 | 13 | interface EVSConfig { 14 | account_name?: string; 15 | Auth?: { 16 | AccountName?: string; 17 | }; 18 | Account?: { 19 | AccountName?: string; 20 | }; 21 | } 22 | 23 | interface BuildContext { 24 | electronPlatformName: string; 25 | appOutDir: string; 26 | } 27 | 28 | /** 29 | * Verify EVS environment 30 | */ 31 | function verifyEnvironment(): boolean { 32 | const configPath = path.join(os.homedir(), '.config', 'evs', 'config.json'); 33 | 34 | if (!fs.existsSync(configPath)) { 35 | console.error('[EVS] ✗ EVS account not configured'); 36 | return false; 37 | } 38 | 39 | try { 40 | const configContent = fs.readFileSync(configPath, 'utf8'); 41 | const config: EVSConfig = JSON.parse(configContent); 42 | const accountName = config.account_name || config.Auth?.AccountName || config.Account?.AccountName; 43 | if (accountName) { 44 | console.log('[EVS] ✓ EVS account configured:', accountName); 45 | return true; 46 | } 47 | } catch (error) { 48 | const errorMessage = error instanceof Error ? error.message : String(error); 49 | console.error('[EVS] ✗ Failed to read EVS config:', errorMessage); 50 | return false; 51 | } 52 | 53 | return false; 54 | } 55 | 56 | /** 57 | * Sign with EVS VMP 58 | */ 59 | function signWithEVS(appPath: string): boolean { 60 | console.log('[EVS] Signing with VMP before Apple codesign...'); 61 | console.log('[EVS] App path:', appPath); 62 | 63 | const command = `python3 -m castlabs_evs.vmp sign-pkg "${appPath}"`; 64 | 65 | try { 66 | execSync(command, { 67 | encoding: 'utf8', 68 | stdio: 'inherit' 69 | }); 70 | 71 | console.log('[EVS] ✓ VMP signing completed successfully\n'); 72 | return true; 73 | } catch (error) { 74 | const errorMessage = error instanceof Error ? error.message : String(error); 75 | console.error('[EVS] ✗ VMP signing failed:', errorMessage); 76 | return false; 77 | } 78 | } 79 | 80 | /** 81 | * beforeSign hook for electron-builder 82 | */ 83 | export default async function(context: BuildContext): Promise { 84 | const { electronPlatformName, appOutDir } = context; 85 | 86 | console.log('\n[EVS] beforeSign hook triggered'); 87 | console.log('[EVS] Platform:', electronPlatformName); 88 | console.log('[EVS] App directory:', appOutDir); 89 | 90 | // Only sign macOS and Windows builds 91 | if (electronPlatformName !== 'darwin' && electronPlatformName !== 'win32') { 92 | console.log('[EVS] Skipping VMP signing for', electronPlatformName); 93 | return; 94 | } 95 | 96 | // Verify environment 97 | if (!verifyEnvironment()) { 98 | console.warn('[EVS] ⚠ EVS not configured, skipping VMP signing'); 99 | return; 100 | } 101 | 102 | // Determine app path 103 | let appPath: string; 104 | if (electronPlatformName === 'darwin') { 105 | // Find .app bundle 106 | const files = fs.readdirSync(appOutDir); 107 | const appFile = files.find(f => f.endsWith('.app')); 108 | if (!appFile) { 109 | console.error('[EVS] ✗ Could not find .app bundle'); 110 | return; 111 | } 112 | appPath = path.join(appOutDir, appFile); 113 | } else { 114 | appPath = appOutDir; 115 | } 116 | 117 | // Sign with EVS 118 | const success = signWithEVS(appPath); 119 | 120 | if (!success) { 121 | console.warn('[EVS] ⚠ VMP signing failed, but continuing with Apple codesign...'); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /apps/browser/src/main/theme-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Theme color cache with LRU eviction and disk persistence 3 | */ 4 | 5 | import { app } from "electron"; 6 | import path from "path"; 7 | import fs from "fs"; 8 | 9 | interface ThemeColorEntry { 10 | color: string; 11 | timestamp: number; 12 | } 13 | 14 | interface ThemeColorCacheData { 15 | [domain: string]: ThemeColorEntry; 16 | } 17 | 18 | export class ThemeColorCache { 19 | private cache: Map = new Map(); 20 | private readonly maxSize = 100; 21 | private cachePath: string; 22 | private saveTimeout: NodeJS.Timeout | null = null; 23 | private readonly saveDelay = 1000; // Debounce save by 1 second 24 | 25 | constructor() { 26 | const userDataPath = app.getPath("userData"); 27 | this.cachePath = path.join(userDataPath, "theme-colors.json"); 28 | this.loadCache(); 29 | 30 | // Save cache on app quit 31 | app.on("before-quit", () => { 32 | this.saveCacheImmediate(); 33 | }); 34 | } 35 | 36 | /** 37 | * Load cache from disk 38 | */ 39 | private loadCache(): void { 40 | try { 41 | if (fs.existsSync(this.cachePath)) { 42 | const data = fs.readFileSync(this.cachePath, "utf-8"); 43 | const cacheData: ThemeColorCacheData = JSON.parse(data); 44 | 45 | // Convert to Map 46 | for (const [domain, entry] of Object.entries(cacheData)) { 47 | this.cache.set(domain, entry); 48 | } 49 | 50 | console.log(`[ThemeColorCache] Loaded ${this.cache.size} cached theme colors`); 51 | } 52 | } catch (error) { 53 | console.error("[ThemeColorCache] Failed to load cache:", error); 54 | this.cache.clear(); 55 | } 56 | } 57 | 58 | /** 59 | * Save cache to disk immediately (synchronous) 60 | */ 61 | private saveCacheImmediate(): void { 62 | try { 63 | // Clear any pending debounced save 64 | if (this.saveTimeout) { 65 | clearTimeout(this.saveTimeout); 66 | this.saveTimeout = null; 67 | } 68 | 69 | // Convert Map to plain object 70 | const cacheData: ThemeColorCacheData = {}; 71 | for (const [domain, entry] of this.cache.entries()) { 72 | cacheData[domain] = entry; 73 | } 74 | 75 | fs.writeFileSync(this.cachePath, JSON.stringify(cacheData, null, 2), "utf-8"); 76 | } catch (error) { 77 | console.error("[ThemeColorCache] Failed to save cache:", error); 78 | } 79 | } 80 | 81 | /** 82 | * Save cache to disk with debouncing 83 | */ 84 | private saveCache(): void { 85 | // Clear existing timeout 86 | if (this.saveTimeout) { 87 | clearTimeout(this.saveTimeout); 88 | } 89 | 90 | // Schedule save after delay 91 | this.saveTimeout = setTimeout(() => { 92 | this.saveCacheImmediate(); 93 | }, this.saveDelay); 94 | } 95 | 96 | set(domain: string, color: string): void { 97 | // Remove oldest entry if cache is full 98 | if (this.cache.size >= this.maxSize && !this.cache.has(domain)) { 99 | const oldestKey = Array.from(this.cache.entries()).sort( 100 | (a, b) => a[1].timestamp - b[1].timestamp 101 | )[0]?.[0]; 102 | if (oldestKey) { 103 | this.cache.delete(oldestKey); 104 | } 105 | } 106 | 107 | this.cache.set(domain, { 108 | color, 109 | timestamp: Date.now(), 110 | }); 111 | 112 | // Save to disk 113 | this.saveCache(); 114 | } 115 | 116 | get(domain: string): string | null { 117 | const entry = this.cache.get(domain); 118 | if (entry) { 119 | // Update timestamp on access (LRU) - only in memory 120 | entry.timestamp = Date.now(); 121 | // No need to save immediately, will be saved on next set() or app quit 122 | return entry.color; 123 | } 124 | return null; 125 | } 126 | 127 | clear(): void { 128 | this.cache.clear(); 129 | this.saveCache(); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /apps/browser/src/main/overlay-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages overlay views that appear on top of WebContentsView 3 | * Used for menus, dialogs, and other UI elements that need to be above web content 4 | */ 5 | 6 | import { WebContentsView } from "electron"; 7 | import path from "path"; 8 | import { AppState } from "./types"; 9 | 10 | export class OverlayManager { 11 | private appState: AppState; 12 | private overlayView: WebContentsView | null = null; 13 | private isVisible: boolean = false; 14 | 15 | constructor(appState: AppState) { 16 | this.appState = appState; 17 | } 18 | 19 | /** 20 | * Initialize overlay view (called once) 21 | */ 22 | initializeOverlay() { 23 | if (this.overlayView || !this.appState.mainWindow) return; 24 | 25 | console.log("[OverlayManager] Initializing overlay view"); 26 | 27 | this.overlayView = new WebContentsView({ 28 | webPreferences: { 29 | preload: path.join(__dirname, "../preload.js"), 30 | contextIsolation: true, 31 | nodeIntegration: false, 32 | sandbox: false, 33 | }, 34 | }); 35 | 36 | // Load the same renderer but it will show overlay content 37 | const isDev = process.env.NODE_ENV === "development"; 38 | if (isDev) { 39 | this.overlayView.webContents.loadURL("http://localhost:5173/#overlay"); 40 | } else { 41 | this.overlayView.webContents.loadFile( 42 | path.join(__dirname, "../../dist-renderer/index.html"), 43 | { hash: "overlay" } 44 | ); 45 | } 46 | 47 | // Set initial bounds (full window, but invisible) 48 | const bounds = this.appState.mainWindow.getBounds(); 49 | this.overlayView.setBounds({ 50 | x: 0, 51 | y: 0, 52 | width: bounds.width, 53 | height: bounds.height, 54 | }); 55 | 56 | // Start hidden 57 | this.overlayView.setVisible(false); 58 | 59 | // Add to window (will be on top due to order) 60 | this.appState.mainWindow.contentView.addChildView(this.overlayView); 61 | 62 | console.log("[OverlayManager] Overlay view initialized"); 63 | } 64 | 65 | /** 66 | * Show overlay 67 | */ 68 | showOverlay() { 69 | if (!this.overlayView || !this.appState.mainWindow) return; 70 | 71 | console.log("[OverlayManager] Showing overlay"); 72 | 73 | // Update bounds to match current window size 74 | const bounds = this.appState.mainWindow.getBounds(); 75 | this.overlayView.setBounds({ 76 | x: 0, 77 | y: 0, 78 | width: bounds.width, 79 | height: bounds.height, 80 | }); 81 | 82 | // Make visible 83 | this.overlayView.setVisible(true); 84 | this.isVisible = true; 85 | 86 | // Send message to overlay to show menu 87 | this.overlayView.webContents.send("overlay-show-menu"); 88 | } 89 | 90 | /** 91 | * Hide overlay 92 | */ 93 | hideOverlay() { 94 | if (!this.overlayView) return; 95 | 96 | console.log("[OverlayManager] Hiding overlay"); 97 | 98 | this.overlayView.setVisible(false); 99 | this.isVisible = false; 100 | 101 | // Send message to overlay to hide menu 102 | this.overlayView.webContents.send("overlay-hide-menu"); 103 | } 104 | 105 | /** 106 | * Update overlay bounds (called on window resize) 107 | */ 108 | updateBounds() { 109 | if (!this.overlayView || !this.appState.mainWindow || !this.isVisible) return; 110 | 111 | const bounds = this.appState.mainWindow.getBounds(); 112 | this.overlayView.setBounds({ 113 | x: 0, 114 | y: 0, 115 | width: bounds.width, 116 | height: bounds.height, 117 | }); 118 | } 119 | 120 | /** 121 | * Destroy overlay 122 | */ 123 | destroy() { 124 | if (this.overlayView) { 125 | console.log("[OverlayManager] Destroying overlay view"); 126 | this.overlayView.webContents.close(); 127 | this.overlayView = null; 128 | this.isVisible = false; 129 | } 130 | } 131 | 132 | /** 133 | * Check if overlay is visible 134 | */ 135 | isOverlayVisible(): boolean { 136 | return this.isVisible; 137 | } 138 | 139 | /** 140 | * Get overlay view 141 | */ 142 | getOverlayView(): WebContentsView | null { 143 | return this.overlayView; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/top-bar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import WindowControls from "./window-controls"; 3 | import NavigationControls from "./navigation-controls"; 4 | 5 | interface TopBarProps { 6 | pageTitle: string; 7 | pageDomain: string; 8 | currentUrl: string; 9 | onNavigate: (url: string) => void; 10 | onShowTabs: () => void; 11 | onShowMenu: () => void; 12 | onRefresh: () => void; 13 | theme: 'light' | 'dark'; 14 | orientation: 'portrait' | 'landscape'; 15 | tabCount: number; 16 | } 17 | 18 | function TopBar({ 19 | pageTitle, 20 | pageDomain, 21 | currentUrl, 22 | onNavigate, 23 | onShowTabs, 24 | onShowMenu, 25 | onRefresh, 26 | theme, 27 | orientation, 28 | tabCount, 29 | }: TopBarProps) { 30 | const [isEditing, setIsEditing] = useState(false); 31 | const [urlInput, setUrlInput] = useState(""); 32 | 33 | const handleTitleClick = () => { 34 | setIsEditing(true); 35 | setUrlInput(currentUrl); 36 | }; 37 | 38 | const handleKeyDown = (e: React.KeyboardEvent) => { 39 | if (e.key === "Enter") { 40 | // Clean the URL input by trimming and removing any control characters 41 | const cleanUrl = urlInput.trim().replace(/[\x00-\x1F\x7F]/g, ""); 42 | onNavigate(cleanUrl); 43 | setIsEditing(false); 44 | } else if (e.key === "Escape") { 45 | setIsEditing(false); 46 | } 47 | }; 48 | 49 | const handleBlur = () => { 50 | setTimeout(() => setIsEditing(false), 100); 51 | }; 52 | 53 | const isDark = theme === 'dark'; 54 | const isLandscape = orientation === 'landscape'; 55 | 56 | return ( 57 |
62 | 63 | 64 |
80 | {isEditing ? ( 81 | setUrlInput(e.target.value)} 85 | onKeyDown={handleKeyDown} 86 | onBlur={handleBlur} 87 | autoFocus 88 | className={`w-full bg-transparent border-none outline-none text-[13px] font-medium font-[inherit] p-0 m-0 ${ 89 | isDark 90 | ? 'text-[rgba(255,255,255,0.85)] placeholder:text-[rgba(255,255,255,0.4)]' 91 | : 'text-[rgba(0,0,0,0.85)] placeholder:text-[rgba(0,0,0,0.4)]' 92 | }`} 93 | placeholder="Enter URL..." 94 | /> 95 | ) : ( 96 | <> 97 |
98 | {pageTitle} 99 |
100 |
103 | {pageDomain} 104 |
105 | 106 | )} 107 |
108 | 109 | 116 |
117 | ); 118 | } 119 | 120 | export default TopBar; 121 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/menu-overlay.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Star, Settings } from "lucide-react"; 3 | 4 | interface MenuOverlayProps { 5 | theme: "light" | "dark"; 6 | currentUrl: string; 7 | currentTitle: string; 8 | onClose: () => void; 9 | onOpenSettings: () => void; 10 | } 11 | 12 | function MenuOverlay({ 13 | theme, 14 | currentUrl, 15 | currentTitle, 16 | onClose, 17 | onOpenSettings, 18 | }: MenuOverlayProps) { 19 | const [isBookmarked, setIsBookmarked] = useState(false); 20 | const isDark = theme === "dark"; 21 | 22 | useEffect(() => { 23 | checkBookmarkStatus(); 24 | }, [currentUrl]); 25 | 26 | const checkBookmarkStatus = async () => { 27 | if (!currentUrl || currentUrl.startsWith("file://")) { 28 | setIsBookmarked(false); 29 | return; 30 | } 31 | 32 | try { 33 | const bookmarked = await window.electronAPI?.bookmarks?.isBookmarked(currentUrl); 34 | setIsBookmarked(bookmarked || false); 35 | } catch (error) { 36 | console.error("Failed to check bookmark status:", error); 37 | setIsBookmarked(false); 38 | } 39 | }; 40 | 41 | const handleToggleBookmark = async () => { 42 | if (!currentUrl || currentUrl.startsWith("file://")) { 43 | return; 44 | } 45 | 46 | try { 47 | if (isBookmarked) { 48 | await window.electronAPI?.bookmarks?.removeByUrl(currentUrl); 49 | setIsBookmarked(false); 50 | } else { 51 | // Get high-resolution favicon 52 | let favicon: string | undefined; 53 | try { 54 | const domain = new URL(currentUrl).origin; 55 | // Try Google's high-res favicon service first (128x128) 56 | favicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; 57 | } catch { 58 | favicon = undefined; 59 | } 60 | 61 | await window.electronAPI?.bookmarks?.add( 62 | currentTitle || "Untitled", 63 | currentUrl, 64 | favicon 65 | ); 66 | setIsBookmarked(true); 67 | } 68 | } catch (error) { 69 | console.error("Failed to toggle bookmark:", error); 70 | } 71 | }; 72 | 73 | const handleSettingsClick = () => { 74 | onClose(); 75 | onOpenSettings(); 76 | }; 77 | 78 | const isBlankPage = currentUrl.startsWith("file://") && currentUrl.includes("blank-page.html"); 79 | 80 | return ( 81 |
85 |
e.stopPropagation()} 92 | > 93 |
94 | {!isBlankPage && ( 95 | 112 | )} 113 | 124 |
125 |
126 |
127 | ); 128 | } 129 | 130 | export default MenuOverlay; 131 | -------------------------------------------------------------------------------- /apps/browser/src/main/app-lifecycle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application lifecycle management 3 | */ 4 | 5 | import { app, BrowserWindow, nativeImage, components } from "electron"; 6 | import path from "path"; 7 | import { AppState } from "./types"; 8 | import { WindowManager } from "./window-manager"; 9 | import { TrayManager } from "./tray-manager"; 10 | 11 | export class AppLifecycle { 12 | private state: AppState; 13 | private windowManager: WindowManager; 14 | private trayManager: TrayManager; 15 | 16 | constructor(state: AppState, windowManager: WindowManager, trayManager: TrayManager) { 17 | this.state = state; 18 | this.windowManager = windowManager; 19 | this.trayManager = trayManager; 20 | } 21 | 22 | /** 23 | * Initialize Widevine CDM logging 24 | */ 25 | initializeWidevine(): void { 26 | console.log("[Widevine] Electron app path:", app.getAppPath()); 27 | console.log("[Widevine] Electron version:", process.versions.electron); 28 | console.log( 29 | "[Widevine] Process versions:", 30 | JSON.stringify(process.versions, null, 2) 31 | ); 32 | 33 | console.log( 34 | "[Widevine] Using Component Updater for automatic CDM installation" 35 | ); 36 | 37 | // Enable Widevine features and DRM 38 | app.commandLine.appendSwitch("enable-features", "PlatformEncryptedDolbyVision"); 39 | app.commandLine.appendSwitch("ignore-certificate-errors"); 40 | app.commandLine.appendSwitch("allow-running-insecure-content"); 41 | } 42 | 43 | /** 44 | * Wait for Widevine components to be ready 45 | */ 46 | async waitForWidevineComponents(): Promise { 47 | if (typeof components !== "undefined") { 48 | console.log("[Component] Initial status:", components.status()); 49 | console.log("[Component] Updates enabled:", components.updatesEnabled); 50 | 51 | console.log("[Component] Waiting for Widevine CDM..."); 52 | const startTime = Date.now(); 53 | 54 | try { 55 | const results = await components.whenReady(); 56 | const elapsed = Date.now() - startTime; 57 | console.log(`[Component] ✓ Ready after ${elapsed}ms`); 58 | console.log("[Component] Results:", results); 59 | console.log("[Component] Final status:", components.status()); 60 | } catch (error: any) { 61 | console.error("[Component] ✗ Failed:", error); 62 | if (error.errors) { 63 | error.errors.forEach((err: any, i: number) => { 64 | console.error(`[Component] Error ${i + 1}:`, err); 65 | }); 66 | } 67 | } 68 | } else { 69 | console.warn( 70 | "[Component] components API not available - not using castlabs electron?" 71 | ); 72 | } 73 | 74 | if (typeof (app as any).isEVSEnabled === "function") { 75 | console.log("[Widevine] EVS enabled:", (app as any).isEVSEnabled()); 76 | } 77 | 78 | console.log("[Widevine] App path:", app.getAppPath()); 79 | console.log("[Widevine] __dirname:", __dirname); 80 | } 81 | 82 | /** 83 | * Setup application when ready 84 | */ 85 | async setupApp(): Promise { 86 | app.setName("Aka Browser"); 87 | 88 | console.log( 89 | "[Widevine] Using castlabs electron-releases with built-in Widevine CDM" 90 | ); 91 | 92 | // Set dock icon for macOS 93 | if (process.platform === "darwin") { 94 | const iconPath = path.join(__dirname, "../assets/icon.png"); 95 | const dockIcon = nativeImage.createFromPath(iconPath); 96 | if (!dockIcon.isEmpty()) { 97 | app.dock?.setIcon(dockIcon); 98 | } 99 | } 100 | 101 | this.windowManager.createWindow(); 102 | this.trayManager.createTray(); 103 | 104 | this.registerAppEvents(); 105 | } 106 | 107 | /** 108 | * Register application lifecycle events 109 | */ 110 | private registerAppEvents(): void { 111 | app.on("activate", () => { 112 | if (BrowserWindow.getAllWindows().length === 0) { 113 | this.windowManager.createWindow(); 114 | } else if (this.state.mainWindow) { 115 | this.state.mainWindow.show(); 116 | this.state.mainWindow.focus(); 117 | } 118 | }); 119 | 120 | app.on("window-all-closed", () => { 121 | // Keep app running in tray 122 | }); 123 | 124 | app.on("before-quit", () => { 125 | this.trayManager.destroy(); 126 | }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /apps/browser/src/main/tray-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tray icon management 3 | */ 4 | 5 | import { Tray, Menu, nativeImage, app } from "electron"; 6 | import path from "path"; 7 | import { AppState } from "./types"; 8 | import { WindowManager } from "./window-manager"; 9 | 10 | export class TrayManager { 11 | private state: AppState; 12 | private windowManager: WindowManager; 13 | 14 | constructor(state: AppState, windowManager: WindowManager) { 15 | this.state = state; 16 | this.windowManager = windowManager; 17 | } 18 | 19 | /** 20 | * Create tray icon 21 | */ 22 | createTray(): void { 23 | const iconPath = path.join(__dirname, "../../assets/tray-icon.png"); 24 | 25 | let trayIcon = nativeImage.createFromPath(iconPath); 26 | 27 | if (trayIcon.isEmpty()) { 28 | console.error("Tray icon not found at:", iconPath); 29 | trayIcon = nativeImage.createEmpty(); 30 | } 31 | 32 | // For macOS, use template image mode for proper transparency handling 33 | if (process.platform === "darwin") { 34 | trayIcon.setTemplateImage(true); 35 | } 36 | 37 | this.state.tray = new Tray(trayIcon); 38 | this.state.tray.setToolTip("Aka Browser"); 39 | 40 | // Left click: toggle window visibility 41 | this.state.tray.on("click", () => { 42 | if (this.state.mainWindow) { 43 | if (this.state.mainWindow.isVisible()) { 44 | this.state.mainWindow.hide(); 45 | } else { 46 | this.state.mainWindow.show(); 47 | this.state.mainWindow.focus(); 48 | } 49 | } 50 | }); 51 | 52 | // Right click: show context menu 53 | this.state.tray.on("right-click", () => { 54 | this.showContextMenu(); 55 | }); 56 | } 57 | 58 | /** 59 | * Show tray context menu 60 | */ 61 | private showContextMenu(): void { 62 | if (!this.state.tray) return; 63 | 64 | const menuTemplate: Electron.MenuItemConstructorOptions[] = [ 65 | { 66 | label: "Open Browser", 67 | click: () => { 68 | if (this.state.mainWindow) { 69 | this.state.mainWindow.show(); 70 | this.state.mainWindow.focus(); 71 | } 72 | }, 73 | }, 74 | { 75 | label: "Open Settings", 76 | click: () => { 77 | if (this.state.mainWindow) { 78 | this.state.mainWindow.show(); 79 | this.state.mainWindow.focus(); 80 | this.state.mainWindow.webContents.send("open-settings"); 81 | } 82 | }, 83 | }, 84 | { 85 | type: "separator", 86 | }, 87 | { 88 | label: "Always on Top", 89 | type: "checkbox", 90 | checked: this.state.isAlwaysOnTop, 91 | click: () => { 92 | this.state.isAlwaysOnTop = !this.state.isAlwaysOnTop; 93 | if (this.state.mainWindow) { 94 | this.state.mainWindow.setAlwaysOnTop(this.state.isAlwaysOnTop); 95 | } 96 | }, 97 | }, 98 | { 99 | label: "Toggle Orientation", 100 | click: () => { 101 | this.windowManager.toggleOrientation(); 102 | }, 103 | }, 104 | { 105 | type: "separator", 106 | }, 107 | { 108 | label: "Open DevTools", 109 | click: () => { 110 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 111 | this.state.webContentsView.webContents.openDevTools({ mode: "detach" }); 112 | } 113 | }, 114 | }, 115 | ]; 116 | 117 | // Add "Open DevTools (Main)" only in development mode 118 | if (!app.isPackaged) { 119 | menuTemplate.push({ 120 | label: "Open DevTools (Main)", 121 | click: () => { 122 | if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { 123 | this.state.mainWindow.webContents.openDevTools({ mode: "detach" }); 124 | } 125 | }, 126 | }); 127 | } 128 | 129 | menuTemplate.push( 130 | { 131 | type: "separator", 132 | }, 133 | { 134 | label: "Quit", 135 | click: () => { 136 | app.quit(); 137 | }, 138 | } 139 | ); 140 | 141 | const contextMenu = Menu.buildFromTemplate(menuTemplate); 142 | this.state.tray.popUpContextMenu(contextMenu); 143 | } 144 | 145 | /** 146 | * Destroy tray icon 147 | */ 148 | destroy(): void { 149 | if (this.state.tray) { 150 | this.state.tray.destroy(); 151 | this.state.tray = null; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /apps/browser/src/types/electron-api.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for window.electronAPI exposed via preload script 3 | */ 4 | 5 | interface Tab { 6 | id: string; 7 | url: string; 8 | title: string; 9 | favicon?: string; 10 | } 11 | 12 | interface TabsData { 13 | tabs: Tab[]; 14 | activeTabId: string | null; 15 | } 16 | 17 | interface TabChangedData { 18 | tabId: string; 19 | tabs: Tab[]; 20 | } 21 | 22 | interface Bounds { 23 | x: number; 24 | y: number; 25 | width: number; 26 | height: number; 27 | } 28 | 29 | interface Bookmark { 30 | id: string; 31 | title: string; 32 | url: string; 33 | favicon?: string; 34 | createdAt: number; 35 | updatedAt: number; 36 | } 37 | 38 | export interface ElectronAPI { 39 | platform: NodeJS.Platform; 40 | closeWindow: () => void; 41 | minimizeWindow: () => void; 42 | maximizeWindow: () => void; 43 | 44 | // Navigation gesture listeners 45 | onNavigateBack: (callback: () => void) => () => void; 46 | onNavigateForward: (callback: () => void) => () => void; 47 | 48 | // Webview reload listener 49 | onWebviewReload: (callback: () => void) => () => void; 50 | 51 | // Theme detection 52 | getSystemTheme: () => Promise<"light" | "dark">; 53 | onThemeChanged: (callback: (theme: "light" | "dark") => void) => () => void; 54 | 55 | // Orientation APIs 56 | getOrientation: () => Promise<"portrait" | "landscape">; 57 | toggleOrientation: () => Promise; 58 | onOrientationChanged: ( 59 | callback: (orientation: "portrait" | "landscape") => void 60 | ) => () => void; 61 | 62 | // Fullscreen mode listener 63 | onFullscreenModeChanged: ( 64 | callback: (isFullscreen: boolean) => void 65 | ) => () => void; 66 | 67 | // Settings listener 68 | onOpenSettings: (callback: () => void) => () => void; 69 | 70 | // App version 71 | getAppVersion: () => Promise; 72 | 73 | // Tab management APIs 74 | tabs: { 75 | getAll: () => Promise; 76 | create: (url?: string) => Promise; 77 | switch: (tabId: string) => Promise; 78 | close: (tabId: string) => Promise; 79 | closeAll: () => Promise; 80 | onTabChanged: (callback: (data: TabChangedData) => void) => () => void; 81 | onTabsUpdated: (callback: (data: TabsData) => void) => () => void; 82 | }; 83 | 84 | // WebContentsView control APIs 85 | webContents: { 86 | loadURL: (url: string) => Promise; 87 | goBack: () => Promise; 88 | goForward: () => Promise; 89 | reload: () => Promise; 90 | canGoBack: () => Promise; 91 | canGoForward: () => Promise; 92 | getURL: () => Promise; 93 | getTitle: () => Promise; 94 | setVisible: (visible: boolean) => Promise; 95 | getThemeColor: () => Promise; 96 | setBounds: (bounds: Bounds) => Promise; 97 | setStatusBarBounds: (bounds: Bounds) => Promise; 98 | setDeviceFrameBounds: (bounds: Bounds) => Promise; 99 | 100 | // Event listeners 101 | onDidStartLoading: (callback: () => void) => () => void; 102 | onDidStopLoading: (callback: () => void) => () => void; 103 | onDidNavigate: (callback: (url: string) => void) => () => void; 104 | onDidNavigateInPage: (callback: (url: string) => void) => () => void; 105 | onDomReady: (callback: () => void) => () => void; 106 | onDidFailLoad: ( 107 | callback: (errorCode: number, errorDescription: string) => void 108 | ) => () => void; 109 | onRenderProcessGone: (callback: (details: any) => void) => () => void; 110 | onHttpError: ( 111 | callback: (statusCode: number, statusText: string, url: string) => void 112 | ) => () => void; 113 | onThemeColorUpdated: (callback: (color: string) => void) => () => void; 114 | }; 115 | 116 | // Bookmark management APIs 117 | bookmarks: { 118 | getAll: () => Promise; 119 | getById: (id: string) => Promise; 120 | isBookmarked: (url: string) => Promise; 121 | add: (title: string, url: string, favicon?: string) => Promise; 122 | update: (id: string, updates: Partial>) => Promise; 123 | remove: (id: string) => Promise; 124 | removeByUrl: (url: string) => Promise; 125 | clear: () => Promise; 126 | onUpdate: (callback: () => void) => () => void; 127 | }; 128 | 129 | // Favicon cache APIs 130 | favicon: { 131 | get: (url: string) => Promise; 132 | getWithFallback: (pageUrl: string) => Promise; 133 | isCached: (url: string) => Promise; 134 | clearCache: () => Promise; 135 | getCacheSize: () => Promise; 136 | }; 137 | } 138 | 139 | declare global { 140 | interface Window { 141 | electronAPI?: ElectronAPI; 142 | } 143 | } 144 | 145 | export {}; 146 | -------------------------------------------------------------------------------- /apps/browser/src/main/security.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Security utilities for URL validation and sanitization 3 | */ 4 | 5 | import { 6 | ALLOWED_PROTOCOLS, 7 | DANGEROUS_PROTOCOLS, 8 | BLOCKED_DOMAINS, 9 | IPHONE_USER_AGENT, 10 | DESKTOP_USER_AGENT 11 | } from "./constants"; 12 | 13 | /** 14 | * Log security events 15 | */ 16 | export function logSecurityEvent(message: string, details?: any): void { 17 | if (process.env.NODE_ENV === "development") { 18 | console.warn(`[Security] ${message}`, details); 19 | } else { 20 | // In production, log to file or monitoring service 21 | // For now, just log without details to avoid information disclosure 22 | console.warn(`[Security] ${message}`); 23 | } 24 | } 25 | 26 | /** 27 | * Validate if a URL is safe to load 28 | */ 29 | export function isValidUrl(urlString: string): boolean { 30 | try { 31 | const url = new URL(urlString); 32 | 33 | // Block dangerous protocols 34 | if (DANGEROUS_PROTOCOLS.includes(url.protocol)) { 35 | logSecurityEvent(`Blocked dangerous protocol: ${url.protocol}`, { 36 | url: urlString, 37 | }); 38 | return false; 39 | } 40 | 41 | // Only allow http and https protocols 42 | if (!ALLOWED_PROTOCOLS.includes(url.protocol)) { 43 | logSecurityEvent(`Blocked invalid protocol: ${url.protocol}`, { 44 | url: urlString, 45 | }); 46 | return false; 47 | } 48 | 49 | // Check against blocked domains (exact match or subdomain) 50 | const isBlocked = BLOCKED_DOMAINS.some( 51 | (domain) => url.hostname === domain || url.hostname.endsWith("." + domain) 52 | ); 53 | 54 | if (isBlocked) { 55 | logSecurityEvent(`Blocked suspicious domain: ${url.hostname}`, { 56 | url: urlString, 57 | }); 58 | return false; 59 | } 60 | 61 | // Allow localhost and private IPs for developer use 62 | // This browser is designed for developers who need to access local development servers 63 | 64 | return true; 65 | } catch (error) { 66 | logSecurityEvent(`Invalid URL format: ${urlString}`, { error }); 67 | return false; 68 | } 69 | } 70 | 71 | /** 72 | * Sanitize URL by adding appropriate protocol 73 | */ 74 | export function sanitizeUrl(urlString: string): string { 75 | let url = urlString.trim(); 76 | 77 | // If already has a valid protocol, return as-is 78 | if ( 79 | url.startsWith("http://") || 80 | url.startsWith("https://") || 81 | url.startsWith("file://") 82 | ) { 83 | return url; 84 | } 85 | 86 | // If no protocol, add appropriate protocol 87 | // Check if it's a local URL (localhost or private IP) 88 | const isLocalUrl = 89 | /^(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( 90 | url 91 | ); 92 | 93 | if (isLocalUrl) { 94 | // Use http:// for local development servers 95 | url = "http://" + url; 96 | } else { 97 | // Use https:// for external sites 98 | url = "https://" + url; 99 | } 100 | 101 | return url; 102 | } 103 | 104 | /** 105 | * Determine user agent based on URL 106 | */ 107 | export function getUserAgentForUrl(url: string): string { 108 | try { 109 | const urlObj = new URL(url); 110 | const hostname = urlObj.hostname.toLowerCase(); 111 | 112 | // Use desktop user agent for Netflix 113 | if (hostname === "netflix.com" || hostname.endsWith(".netflix.com")) { 114 | return DESKTOP_USER_AGENT; 115 | } 116 | 117 | // Default to mobile user agent 118 | return IPHONE_USER_AGENT; 119 | } catch (error) { 120 | // If URL parsing fails, default to mobile user agent 121 | return IPHONE_USER_AGENT; 122 | } 123 | } 124 | 125 | /** 126 | * Calculate luminance of a color 127 | */ 128 | export function getLuminance(color: string): number { 129 | let r: number, g: number, b: number; 130 | 131 | if (color.startsWith("#")) { 132 | const hex = color.replace("#", ""); 133 | r = parseInt(hex.substr(0, 2), 16); 134 | g = parseInt(hex.substr(2, 2), 16); 135 | b = parseInt(hex.substr(4, 2), 16); 136 | } else if (color.startsWith("rgb")) { 137 | const matches = color.match(/\d+/g); 138 | if (matches) { 139 | r = parseInt(matches[0]); 140 | g = parseInt(matches[1]); 141 | b = parseInt(matches[2]); 142 | } else { 143 | return 0; 144 | } 145 | } else { 146 | return 0; 147 | } 148 | 149 | const rsRGB = r / 255; 150 | const gsRGB = g / 255; 151 | const bsRGB = b / 255; 152 | 153 | const rLinear = 154 | rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4); 155 | const gLinear = 156 | gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4); 157 | const bLinear = 158 | bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4); 159 | 160 | return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear; 161 | } 162 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/phone-frame.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from "react"; 2 | import StatusBar from "./status-bar"; 3 | 4 | interface PhoneFrameProps { 5 | webContainerRef: RefObject; 6 | orientation: "portrait" | "landscape"; 7 | themeColor: string; 8 | textColor: string; 9 | showTabOverview?: boolean; 10 | isFullscreen?: boolean; 11 | tabOverviewContent?: React.ReactNode; 12 | } 13 | 14 | function PhoneFrame({ 15 | webContainerRef, 16 | orientation, 17 | themeColor, 18 | textColor, 19 | showTabOverview, 20 | isFullscreen, 21 | tabOverviewContent, 22 | }: PhoneFrameProps) { 23 | const isLandscape = orientation === "landscape"; 24 | // Update WebContentsView bounds when component mounts or window resizes 25 | useEffect(() => { 26 | const updateBounds = () => { 27 | if (!webContainerRef.current) return; 28 | 29 | const rect = webContainerRef.current.getBoundingClientRect(); 30 | const isLandscape = orientation === 'landscape'; 31 | 32 | // Status bar dimensions 33 | const statusBarHeight = 58; 34 | const statusBarWidth = 58; 35 | 36 | // In fullscreen mode, apply -30px offset 37 | if (isFullscreen) { 38 | // Fullscreen mode: apply offset to move content closer to edges 39 | window.electronAPI?.webContents.setBounds({ 40 | x: Math.round(rect.x + (isLandscape ? statusBarWidth - 30 : 0)), 41 | y: Math.round(rect.y + (isLandscape ? 0 : statusBarHeight - 30)), 42 | width: Math.round(rect.width - (isLandscape ? statusBarWidth : 0)), 43 | height: Math.round(rect.height - (isLandscape ? 0 : statusBarHeight)), 44 | }); 45 | } else { 46 | // Normal mode: standard positioning 47 | window.electronAPI?.webContents.setBounds({ 48 | x: Math.round(rect.x + (isLandscape ? statusBarWidth : 0)), 49 | y: Math.round(rect.y + (isLandscape ? 0 : statusBarHeight)), 50 | width: Math.round(rect.width - (isLandscape ? statusBarWidth : 0)), 51 | height: Math.round(rect.height - (isLandscape ? 0 : statusBarHeight)), 52 | }); 53 | } 54 | }; 55 | 56 | // Initial bounds update with multiple attempts to ensure it's set 57 | setTimeout(updateBounds, 100); 58 | setTimeout(updateBounds, 300); 59 | setTimeout(updateBounds, 500); 60 | 61 | // Update bounds on window resize 62 | window.addEventListener("resize", updateBounds); 63 | 64 | return () => { 65 | window.removeEventListener("resize", updateBounds); 66 | }; 67 | }, [webContainerRef, orientation, isFullscreen]); 68 | 69 | return ( 70 |
78 | {/* Device frame - visual only, clicks pass through */} 79 |
80 |
81 |
82 | {/* Web content area - positioned for WebContentsView */} 83 |
87 | {/* Status bar - React component on top (hidden in fullscreen) */} 88 | {!isFullscreen && ( 89 | 94 | )} 95 | {/* Tab overview overlay - React component */} 96 | {showTabOverview && ( 97 |
98 | {tabOverviewContent} 99 |
100 | )} 101 |
102 |
103 |
104 |
105 |
106 | ); 107 | } 108 | 109 | export default PhoneFrame; 110 | -------------------------------------------------------------------------------- /apps/browser/src/main/bookmark-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Bookmark management functionality 3 | */ 4 | 5 | import { app } from "electron"; 6 | import path from "path"; 7 | import fs from "fs"; 8 | 9 | export interface Bookmark { 10 | id: string; 11 | title: string; 12 | url: string; 13 | favicon?: string; 14 | createdAt: number; 15 | updatedAt: number; 16 | } 17 | 18 | export class BookmarkManager { 19 | private bookmarksPath: string; 20 | private bookmarks: Bookmark[] = []; 21 | 22 | constructor() { 23 | const userDataPath = app.getPath("userData"); 24 | this.bookmarksPath = path.join(userDataPath, "bookmarks.json"); 25 | this.loadBookmarks(); 26 | } 27 | 28 | /** 29 | * Normalize URL by ensuring it has a protocol 30 | */ 31 | private normalizeUrl(url: string): string { 32 | // Trim whitespace 33 | url = url.trim(); 34 | 35 | // If URL already has a protocol, return as is 36 | if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url)) { 37 | return url; 38 | } 39 | 40 | // Add https:// by default 41 | return `https://${url}`; 42 | } 43 | 44 | /** 45 | * Load bookmarks from file 46 | */ 47 | private loadBookmarks(): void { 48 | try { 49 | if (fs.existsSync(this.bookmarksPath)) { 50 | const data = fs.readFileSync(this.bookmarksPath, "utf-8"); 51 | this.bookmarks = JSON.parse(data); 52 | console.log(`[BookmarkManager] Loaded ${this.bookmarks.length} bookmarks`); 53 | } else { 54 | this.bookmarks = []; 55 | console.log("[BookmarkManager] No bookmarks file found, starting fresh"); 56 | } 57 | } catch (error) { 58 | console.error("[BookmarkManager] Failed to load bookmarks:", error); 59 | this.bookmarks = []; 60 | } 61 | } 62 | 63 | /** 64 | * Save bookmarks to file 65 | */ 66 | private saveBookmarks(): void { 67 | try { 68 | const data = JSON.stringify(this.bookmarks, null, 2); 69 | fs.writeFileSync(this.bookmarksPath, data, "utf-8"); 70 | console.log(`[BookmarkManager] Saved ${this.bookmarks.length} bookmarks`); 71 | } catch (error) { 72 | console.error("[BookmarkManager] Failed to save bookmarks:", error); 73 | } 74 | } 75 | 76 | /** 77 | * Get all bookmarks 78 | */ 79 | getAll(): Bookmark[] { 80 | return [...this.bookmarks]; 81 | } 82 | 83 | /** 84 | * Get bookmark by ID 85 | */ 86 | getById(id: string): Bookmark | undefined { 87 | return this.bookmarks.find((b) => b.id === id); 88 | } 89 | 90 | /** 91 | * Check if URL is bookmarked 92 | */ 93 | isBookmarked(url: string): boolean { 94 | const normalizedUrl = this.normalizeUrl(url); 95 | return this.bookmarks.some((b) => b.url === normalizedUrl); 96 | } 97 | 98 | /** 99 | * Add a new bookmark 100 | */ 101 | add(title: string, url: string, favicon?: string): Bookmark { 102 | const normalizedUrl = this.normalizeUrl(url); 103 | const now = Date.now(); 104 | const bookmark: Bookmark = { 105 | id: `bookmark-${now}-${Math.random().toString(36).substr(2, 9)}`, 106 | title, 107 | url: normalizedUrl, 108 | favicon, 109 | createdAt: now, 110 | updatedAt: now, 111 | }; 112 | 113 | this.bookmarks.push(bookmark); 114 | this.saveBookmarks(); 115 | console.log(`[BookmarkManager] Added bookmark: ${title} (${normalizedUrl})`); 116 | return bookmark; 117 | } 118 | 119 | /** 120 | * Update an existing bookmark 121 | */ 122 | update(id: string, updates: Partial>): Bookmark | null { 123 | const index = this.bookmarks.findIndex((b) => b.id === id); 124 | if (index === -1) { 125 | console.error(`[BookmarkManager] Bookmark not found: ${id}`); 126 | return null; 127 | } 128 | 129 | // Normalize URL if it's being updated 130 | if (updates.url) { 131 | updates.url = this.normalizeUrl(updates.url); 132 | } 133 | 134 | this.bookmarks[index] = { 135 | ...this.bookmarks[index], 136 | ...updates, 137 | updatedAt: Date.now(), 138 | }; 139 | 140 | this.saveBookmarks(); 141 | console.log(`[BookmarkManager] Updated bookmark: ${id}`); 142 | return this.bookmarks[index]; 143 | } 144 | 145 | /** 146 | * Remove a bookmark 147 | */ 148 | remove(id: string): boolean { 149 | const index = this.bookmarks.findIndex((b) => b.id === id); 150 | if (index === -1) { 151 | console.error(`[BookmarkManager] Bookmark not found: ${id}`); 152 | return false; 153 | } 154 | 155 | const removed = this.bookmarks.splice(index, 1)[0]; 156 | this.saveBookmarks(); 157 | console.log(`[BookmarkManager] Removed bookmark: ${removed.title}`); 158 | return true; 159 | } 160 | 161 | /** 162 | * Remove bookmark by URL 163 | */ 164 | removeByUrl(url: string): boolean { 165 | const normalizedUrl = this.normalizeUrl(url); 166 | const index = this.bookmarks.findIndex((b) => b.url === normalizedUrl); 167 | if (index === -1) { 168 | console.error(`[BookmarkManager] Bookmark not found for URL: ${normalizedUrl}`); 169 | return false; 170 | } 171 | 172 | const removed = this.bookmarks.splice(index, 1)[0]; 173 | this.saveBookmarks(); 174 | console.log(`[BookmarkManager] Removed bookmark: ${removed.title}`); 175 | return true; 176 | } 177 | 178 | /** 179 | * Clear all bookmarks 180 | */ 181 | clear(): void { 182 | this.bookmarks = []; 183 | this.saveBookmarks(); 184 | console.log("[BookmarkManager] Cleared all bookmarks"); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /apps/browser/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security Features 4 | 5 | This application implements multiple layers of security to protect users: 6 | 7 | ### 1. Process Isolation 8 | - **Context Isolation**: Enabled for all renderer processes 9 | - **Node Integration**: Disabled to prevent direct Node.js API access 10 | - **Sandbox**: Enabled for WebContentsView to isolate web content 11 | - **Separate Preload Scripts**: Main window and web content use different preload scripts 12 | 13 | ### 2. Content Security Policy (CSP) 14 | - **Main Window UI (Development)**: Relaxed CSP with `unsafe-inline` and `unsafe-eval` for Vite HMR and React DevTools 15 | - **Main Window UI (Production)**: Strict CSP without `unsafe-inline` or `unsafe-eval` 16 | - **WebContentsView (External Sites)**: No CSP modification - respects each website's own CSP 17 | - Browser UI is protected while allowing external websites to function normally 18 | - Prevents XSS attacks on the browser UI itself 19 | 20 | ### 3. URL Validation 21 | - Protocol whitelist (only http/https allowed) 22 | - Dangerous protocol blocking (file:, javascript:, data:, etc.) 23 | - Domain blacklist support 24 | - Exact domain matching to prevent subdomain bypasses 25 | - **Localhost and private IPs allowed** - This is a developer browser, local development servers are essential 26 | 27 | ### 4. IPC Security 28 | - Sender verification for all IPC handlers 29 | - Only main window can send IPC messages 30 | - WebContentsView messages are verified separately 31 | - Security event logging for unauthorized attempts 32 | 33 | ### 5. Permission Management 34 | - Restricted permissions (only clipboard access allowed) 35 | - All other permissions (camera, microphone, geolocation) denied by default 36 | - Permission requests are logged 37 | 38 | ### 6. Security Headers 39 | - `X-Content-Type-Options: nosniff` 40 | - `X-Frame-Options: DENY` 41 | - `X-XSS-Protection: 1; mode=block` 42 | - `Referrer-Policy: strict-origin-when-cross-origin` 43 | - `Permissions-Policy` to disable sensitive features 44 | 45 | ### 7. Developer-Friendly Features 46 | - **DevTools always available** - Essential for developers to inspect and debug web applications 47 | - Detailed security logs only in development mode 48 | - Localhost and private network access enabled for local development 49 | 50 | ## Security Best Practices 51 | 52 | ### For Developers 53 | 54 | 1. **Regular Dependency Updates** 55 | ```bash 56 | npm run audit # Check for vulnerabilities 57 | npm run audit:fix # Auto-fix vulnerabilities 58 | npm run outdated # Check for outdated packages 59 | ``` 60 | 61 | 2. **Before Deploying** 62 | - Run `npm audit` to check for vulnerabilities 63 | - Update dependencies to latest stable versions 64 | - Test in production mode: `NODE_ENV=production npm start` 65 | 66 | 3. **Adding New Features** 67 | - Never use `executeJavaScript` from main process 68 | - Always validate IPC message senders 69 | - Use preload scripts for safe web content interaction 70 | - Add new domains to blocklist if needed 71 | 72 | ### For Users 73 | 74 | 1. **Keep the App Updated** 75 | - Always use the latest version 76 | - Enable auto-updates if available 77 | 78 | 2. **Be Cautious with URLs** 79 | - The app blocks dangerous protocols and domains 80 | - If a site is blocked, there's usually a good reason 81 | 82 | 3. **Report Security Issues** 83 | - See "Reporting a Vulnerability" section below 84 | 85 | ## Reporting a Vulnerability 86 | 87 | If you discover a security vulnerability, please email the maintainer directly instead of opening a public issue. 88 | 89 | **DO NOT** create a public GitHub issue for security vulnerabilities. 90 | 91 | ### What to Include 92 | - Description of the vulnerability 93 | - Steps to reproduce 94 | - Potential impact 95 | - Suggested fix (if any) 96 | 97 | ### Response Time 98 | - Initial response: Within 48 hours 99 | - Status update: Within 7 days 100 | - Fix timeline: Depends on severity 101 | 102 | ## Security Checklist for Releases 103 | 104 | - [ ] Run `npm audit` and fix all high/critical vulnerabilities 105 | - [ ] Update all dependencies to latest stable versions 106 | - [ ] Test all security features in production mode 107 | - [ ] Verify CSP headers are correct 108 | - [ ] Confirm DevTools are accessible (required for developers) 109 | - [ ] Test URL validation with various edge cases including localhost 110 | - [ ] Verify IPC sender validation works 111 | - [ ] Check permission requests are properly denied 112 | - [ ] Test local development server access (localhost, 127.0.0.1, etc.) 113 | 114 | ## Developer-Focused Design Decisions 115 | 116 | This browser is specifically designed for developers, which influences some security decisions: 117 | 118 | 1. **DevTools Always Available**: Unlike consumer browsers, DevTools are accessible in all modes because developers need to inspect and debug web applications 119 | 2. **Localhost Access**: Local development servers (localhost, 127.0.0.1, private IPs) are fully accessible - essential for web development 120 | 3. **Clipboard Access**: Allowed by default for better developer experience 121 | 4. **Theme Color Extraction**: Uses preload script injection which is safer than `executeJavaScript` but still requires trust in the preload script 122 | 123 | ## Known Security Trade-offs 124 | 125 | 1. **DevTools in Production**: While DevTools are powerful debugging tools, they can expose sensitive information. Users should be aware of this when browsing sensitive sites 126 | 2. **Local Network Access**: Allowing private IP access means the browser can access internal network resources - useful for development but be cautious on untrusted networks 127 | 128 | ## Security Updates 129 | 130 | This document will be updated as new security features are added or vulnerabilities are discovered. 131 | 132 | Last updated: 2025-10-16 133 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/pages/error-page.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | interface ErrorInfo { 4 | statusCode: string; 5 | statusText: string; 6 | url: string; 7 | } 8 | 9 | interface ErrorMessage { 10 | title: string; 11 | desc: string; 12 | } 13 | 14 | function getErrorInfo(): ErrorInfo { 15 | // Try to get params from injected global variable first 16 | const injectedParams = (window as any).__QUERY_PARAMS__; 17 | if (injectedParams) { 18 | return { 19 | statusCode: injectedParams.statusCode || 'UNKNOWN', 20 | statusText: injectedParams.statusText || 'Unknown Error', 21 | url: injectedParams.url || '', 22 | }; 23 | } 24 | 25 | // Fallback to URL search params (for backward compatibility) 26 | const params = new URLSearchParams(window.location.search); 27 | return { 28 | statusCode: params.get('statusCode') || 'UNKNOWN', 29 | statusText: params.get('statusText') || 'Unknown Error', 30 | url: params.get('url') || window.location.href, 31 | }; 32 | } 33 | 34 | function getErrorMessage(statusCode: string, url: string): ErrorMessage { 35 | const code = parseInt(statusCode); 36 | let domain: string; 37 | 38 | try { 39 | domain = new URL(url).hostname; 40 | } catch { 41 | domain = 'the server'; 42 | } 43 | 44 | // Network errors 45 | if (code === 105) { 46 | return { 47 | title: 'Aka Browser cannot open the page', 48 | desc: `Aka Browser could not open the page "${domain}" because the server could not be found.`, 49 | }; 50 | } 51 | if (code === 106) { 52 | return { 53 | title: 'Aka Browser cannot open the page', 54 | desc: `Aka Browser could not open the page "${domain}" because your device is not connected to the internet.`, 55 | }; 56 | } 57 | if (code === 102) { 58 | return { 59 | title: 'Aka Browser cannot open the page', 60 | desc: `Aka Browser could not open the page "${domain}" because the server refused the connection.`, 61 | }; 62 | } 63 | if (code === 7 || code === 118) { 64 | return { 65 | title: 'Aka Browser cannot open the page', 66 | desc: `Aka Browser could not open the page "${domain}" because the server took too long to respond.`, 67 | }; 68 | } 69 | if (code >= 100 && code < 200) { 70 | return { 71 | title: 'Aka Browser cannot open the page', 72 | desc: `Aka Browser could not open the page "${domain}" because it could not connect to the server.`, 73 | }; 74 | } 75 | if (code >= 200 && code < 300) { 76 | return { 77 | title: 'Aka Browser cannot open the page', 78 | desc: `Aka Browser could not open the page "${domain}" because there is a problem with the website's security certificate.`, 79 | }; 80 | } 81 | 82 | // HTTP errors 83 | if (code === 404) { 84 | return { 85 | title: 'Aka Browser cannot open the page', 86 | desc: `Aka Browser could not open the page "${domain}" because the page could not be found.`, 87 | }; 88 | } 89 | if (code >= 400 && code < 500) { 90 | return { 91 | title: 'Aka Browser cannot open the page', 92 | desc: `Aka Browser could not open the page "${domain}" because of a client error.`, 93 | }; 94 | } 95 | if (code >= 500 && code < 600) { 96 | return { 97 | title: 'Aka Browser cannot open the page', 98 | desc: `Aka Browser could not open the page "${domain}" because the server encountered an error.`, 99 | }; 100 | } 101 | 102 | return { 103 | title: 'Aka Browser cannot open the page', 104 | desc: `Aka Browser could not open the page "${domain}".`, 105 | }; 106 | } 107 | 108 | export default function ErrorPage() { 109 | const [errorInfo, setErrorInfo] = useState({ 110 | statusCode: 'UNKNOWN', 111 | statusText: 'Unknown Error', 112 | url: '', 113 | }); 114 | const [errorMessage, setErrorMessage] = useState({ 115 | title: 'Aka Browser cannot open the page', 116 | desc: 'Aka Browser could not open the page.', 117 | }); 118 | 119 | useEffect(() => { 120 | const info = getErrorInfo(); 121 | const message = getErrorMessage(info.statusCode, info.url); 122 | 123 | setErrorInfo(info); 124 | setErrorMessage(message); 125 | 126 | // Update page title 127 | document.title = message.title; 128 | }, []); 129 | 130 | return ( 131 | <> 132 | 193 |
194 |

{errorMessage.title}

195 |

{errorMessage.desc}

196 |

{decodeURIComponent(errorInfo.url)}

197 |
198 | 199 | ); 200 | } 201 | -------------------------------------------------------------------------------- /apps/browser/CODESIGN_SETUP.md: -------------------------------------------------------------------------------- 1 | # Code Signing and Notarization Setup Guide 2 | 3 | This document explains how to set up code signing and notarization for distributing aka-browser on macOS. 4 | 5 | ## 1. Create Developer ID Application Certificate 6 | 7 | ### 1-1. Generate CSR (Certificate Signing Request) 8 | 9 | 1. Open **Keychain Access** app 10 | 2. Menu: **Keychain Access > Certificate Assistant > Request a Certificate from a Certificate Authority...** 11 | 3. Enter the following information: 12 | - User Email Address: Your Apple Developer account email 13 | - Common Name: Your name 14 | - CA Email Address: Leave empty 15 | - Select **"Saved to disk"** 16 | - Check **"Let me specify key pair information"** 17 | 4. Choose save location (e.g., `~/Desktop/CertificateSigningRequest.certSigningRequest`) 18 | 5. Key Size: **2048 bits**, Algorithm: **RSA** 19 | 6. Click **Continue** 20 | 21 | ### 1-2. Issue Certificate from Apple Developer Website 22 | 23 | 1. Visit https://developer.apple.com/account/resources/certificates/list 24 | 2. Click **"+"** button 25 | 3. Select **"Developer ID Application"** (for distribution outside Mac App Store) 26 | 4. Click **Continue** 27 | 5. Upload the CSR file created above 28 | 6. Click **Continue** 29 | 7. Download certificate (`.cer` file) 30 | 8. **Double-click** the downloaded file to install it in Keychain 31 | 32 | ### 1-3. Verify Certificate 33 | 34 | Verify the certificate is installed by running this command in Terminal: 35 | 36 | ```bash 37 | security find-identity -v -p codesigning 38 | ``` 39 | 40 | You should see output like: 41 | ``` 42 | 1) XXXXXX "Developer ID Application: Your Name (TEAM_ID)" 43 | ``` 44 | 45 | ## 2. Generate App-Specific Password 46 | 47 | An App-Specific Password is required for notarization. 48 | 49 | 1. Visit https://appleid.apple.com/account/manage 50 | 2. **Sign in** (Two-factor authentication required) 51 | 3. Click **App-Specific Passwords** in the **Security** section 52 | 4. Click **"+"** button 53 | 5. Enter a name (e.g., "aka-browser notarization") 54 | 6. Copy the generated password (e.g., `abcd-efgh-ijkl-mnop`) 55 | 7. **Save it securely** (you won't be able to see it again) 56 | 57 | ## 3. Find Your Team ID 58 | 59 | 1. Visit https://developer.apple.com/account 60 | 2. Find your **Team ID** in the **Membership Details** section (e.g., `AB12CD34EF`) 61 | 62 | ## 4. Set Environment Variables 63 | 64 | ### 4-1. Method 1: .env File (Recommended) 65 | 66 | Create a `.env.local` file in the project root: 67 | 68 | ```bash 69 | # /Users/hm/Documents/GitHub/aka-browser/apps/browser/.env.local 70 | APPLE_ID=your-apple-id@example.com 71 | APPLE_APP_SPECIFIC_PASSWORD=abcd-efgh-ijkl-mnop 72 | APPLE_TEAM_ID=AB12CD34EF 73 | ``` 74 | 75 | **Warning**: Add `.env.local` to `.gitignore` to prevent committing it to Git! 76 | 77 | ### 4-2. Method 2: System Environment Variables 78 | 79 | Add to `~/.zshrc` or `~/.bash_profile`: 80 | 81 | ```bash 82 | export APPLE_ID="your-apple-id@example.com" 83 | export APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop" 84 | export APPLE_TEAM_ID="AB12CD34EF" 85 | ``` 86 | 87 | After saving: 88 | ```bash 89 | source ~/.zshrc 90 | ``` 91 | 92 | ### 4-3. Method 3: Specify During Build 93 | 94 | ```bash 95 | APPLE_ID="your@email.com" \ 96 | APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop" \ 97 | APPLE_TEAM_ID="AB12CD34EF" \ 98 | pnpm run package 99 | ``` 100 | 101 | ## 5. Build and Notarize 102 | 103 | ### 5-1. Install Dependencies 104 | 105 | ```bash 106 | cd /Users/hm/Documents/GitHub/aka-browser/apps/browser 107 | pnpm install 108 | ``` 109 | 110 | ### 5-2. Compile Build Scripts 111 | 112 | ```bash 113 | pnpm run build:scripts 114 | ``` 115 | 116 | ### 5-3. Build and Notarize App 117 | 118 | ```bash 119 | pnpm run package 120 | ``` 121 | 122 | Build process: 123 | 1. ✅ Build app 124 | 2. ✅ EVS signing (Widevine CDM) 125 | 3. ✅ Code signing (Developer ID Application) 126 | 4. ✅ Notarization (upload to Apple servers) 127 | 5. ✅ DMG creation 128 | 129 | ### 5-4. Verify Notarization 130 | 131 | After the build completes, verify notarization status with: 132 | 133 | ```bash 134 | spctl -a -vv -t install release/mac-arm64/aka-browser.app 135 | ``` 136 | 137 | Successful output: 138 | ``` 139 | release/mac-arm64/aka-browser.app: accepted 140 | source=Notarized Developer ID 141 | ``` 142 | 143 | ## 6. Troubleshooting 144 | 145 | ### Cannot Find Certificate 146 | 147 | ``` 148 | Error: Cannot find identity matching "Developer ID Application" 149 | ``` 150 | 151 | **Solution**: 152 | 1. Verify the certificate is installed in Keychain 153 | 2. Check if the certificate has expired 154 | 3. Run `security find-identity -v -p codesigning` to verify 155 | 156 | ### Notarization Failed 157 | 158 | ``` 159 | Error: Notarization failed 160 | ``` 161 | 162 | **Solution**: 163 | 1. Verify Apple ID, App-Specific Password, and Team ID are correct 164 | 2. Ensure two-factor authentication is enabled 165 | 3. Check if App-Specific Password is valid (it may have expired) 166 | 167 | ### Check Notarization Logs 168 | 169 | ```bash 170 | xcrun notarytool log --apple-id "your@email.com" \ 171 | --password "abcd-efgh-ijkl-mnop" \ 172 | --team-id "AB12CD34EF" \ 173 | 174 | ``` 175 | 176 | ## 7. Distribution 177 | 178 | Notarized DMG files are created in the `release/` directory: 179 | 180 | - `release/aka-browser-0.1.0-arm64.dmg` (Apple Silicon) 181 | - `release/aka-browser-0.1.0-x64.dmg` (Intel) 182 | - `release/aka-browser-0.1.0-universal.dmg` (Universal) 183 | 184 | You can upload these files to the internet for distribution. Users won't see the "damaged" message when downloading and installing. 185 | 186 | ## 8. Security Precautions 187 | 188 | - ❌ **NEVER commit to Git**: 189 | - App-Specific Password 190 | - `.env.local` file 191 | - Certificate files (`.p12`, `.cer`) 192 | 193 | - ✅ **Store securely**: 194 | - Use password managers like 1Password or Bitwarden 195 | - Use encrypted channels when sharing with team members 196 | 197 | ## 9. CI/CD Setup (GitHub Actions) 198 | 199 | Add the following variables to GitHub Secrets: 200 | 201 | - `APPLE_ID` 202 | - `APPLE_APP_SPECIFIC_PASSWORD` 203 | - `APPLE_TEAM_ID` 204 | - `CSC_LINK` (certificate `.p12` file encoded in base64) 205 | - `CSC_KEY_PASSWORD` (certificate password) 206 | 207 | For detailed setup, refer to the electron-builder documentation: 208 | https://www.electron.build/code-signing 209 | 210 | --- 211 | 212 | **Questions**: Please create an issue if you encounter any problems. 213 | -------------------------------------------------------------------------------- /apps/browser/src/main/favicon-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Favicon caching system 3 | * Downloads and caches favicons locally to avoid repeated network requests 4 | */ 5 | 6 | import { app, net } from "electron"; 7 | import path from "path"; 8 | import fs from "fs"; 9 | import crypto from "crypto"; 10 | 11 | export class FaviconCache { 12 | private cachePath: string; 13 | 14 | constructor() { 15 | const userDataPath = app.getPath("userData"); 16 | this.cachePath = path.join(userDataPath, "favicon-cache"); 17 | this.ensureCacheDirectory(); 18 | } 19 | 20 | /** 21 | * Ensure cache directory exists 22 | */ 23 | private ensureCacheDirectory(): void { 24 | if (!fs.existsSync(this.cachePath)) { 25 | fs.mkdirSync(this.cachePath, { recursive: true }); 26 | console.log("[FaviconCache] Created cache directory"); 27 | } 28 | } 29 | 30 | /** 31 | * Generate cache key from URL 32 | */ 33 | private getCacheKey(url: string): string { 34 | return crypto.createHash("md5").update(url).digest("hex"); 35 | } 36 | 37 | /** 38 | * Get cached favicon path 39 | */ 40 | private getCachedPath(url: string, extension: string = "png"): string { 41 | const key = this.getCacheKey(url); 42 | return path.join(this.cachePath, `${key}.${extension}`); 43 | } 44 | 45 | /** 46 | * Check if favicon is cached 47 | */ 48 | isCached(url: string): boolean { 49 | const cachedPath = this.getCachedPath(url); 50 | return fs.existsSync(cachedPath); 51 | } 52 | 53 | /** 54 | * Get cached favicon as data URL 55 | */ 56 | getCached(url: string): string | null { 57 | try { 58 | const cachedPath = this.getCachedPath(url); 59 | if (fs.existsSync(cachedPath)) { 60 | const data = fs.readFileSync(cachedPath); 61 | const base64 = data.toString("base64"); 62 | const ext = path.extname(cachedPath).slice(1); 63 | const mimeType = ext === "svg" ? "image/svg+xml" : `image/${ext}`; 64 | return `data:${mimeType};base64,${base64}`; 65 | } 66 | return null; 67 | } catch (error) { 68 | console.error("[FaviconCache] Failed to read cached favicon:", error); 69 | return null; 70 | } 71 | } 72 | 73 | /** 74 | * Download and cache favicon 75 | */ 76 | async downloadAndCache(url: string): Promise { 77 | try { 78 | console.log("[FaviconCache] Downloading favicon:", url); 79 | 80 | return new Promise((resolve) => { 81 | const request = net.request(url); 82 | const chunks: Buffer[] = []; 83 | 84 | request.on("response", (response) => { 85 | if (response.statusCode !== 200) { 86 | console.error("[FaviconCache] Failed to download:", response.statusCode); 87 | resolve(null); 88 | return; 89 | } 90 | 91 | response.on("data", (chunk) => { 92 | chunks.push(Buffer.from(chunk)); 93 | }); 94 | 95 | response.on("end", () => { 96 | try { 97 | const buffer = Buffer.concat(chunks); 98 | 99 | // Determine file extension from content-type 100 | const contentType = response.headers["content-type"]; 101 | let extension = "png"; 102 | if (contentType) { 103 | if (contentType.includes("svg")) extension = "svg"; 104 | else if (contentType.includes("jpeg") || contentType.includes("jpg")) extension = "jpg"; 105 | else if (contentType.includes("gif")) extension = "gif"; 106 | else if (contentType.includes("webp")) extension = "webp"; 107 | else if (contentType.includes("ico")) extension = "ico"; 108 | } 109 | 110 | const cachedPath = this.getCachedPath(url, extension); 111 | fs.writeFileSync(cachedPath, buffer); 112 | console.log("[FaviconCache] Cached favicon:", cachedPath); 113 | 114 | // Return as data URL 115 | const base64 = buffer.toString("base64"); 116 | const mimeType = extension === "svg" ? "image/svg+xml" : `image/${extension}`; 117 | resolve(`data:${mimeType};base64,${base64}`); 118 | } catch (error) { 119 | console.error("[FaviconCache] Failed to save favicon:", error); 120 | resolve(null); 121 | } 122 | }); 123 | 124 | response.on("error", (error) => { 125 | console.error("[FaviconCache] Response error:", error); 126 | resolve(null); 127 | }); 128 | }); 129 | 130 | request.on("error", (error) => { 131 | console.error("[FaviconCache] Request error:", error); 132 | resolve(null); 133 | }); 134 | 135 | request.end(); 136 | }); 137 | } catch (error) { 138 | console.error("[FaviconCache] Failed to download favicon:", error); 139 | return null; 140 | } 141 | } 142 | 143 | /** 144 | * Get favicon with caching 145 | * Returns cached version if available, otherwise downloads and caches 146 | */ 147 | async getFavicon(url: string): Promise { 148 | // Check cache first 149 | const cached = this.getCached(url); 150 | if (cached) { 151 | console.log("[FaviconCache] Using cached favicon"); 152 | return cached; 153 | } 154 | 155 | // Download and cache 156 | return this.downloadAndCache(url); 157 | } 158 | 159 | /** 160 | * Get multiple favicon URLs to try (high-res first) 161 | */ 162 | getFaviconUrls(pageUrl: string): string[] { 163 | try { 164 | const url = new URL(pageUrl); 165 | const domain = url.hostname; 166 | 167 | return [ 168 | `https://www.google.com/s2/favicons?domain=${domain}&sz=128`, 169 | `https://www.google.com/s2/favicons?domain=${domain}&sz=64`, 170 | `https://${domain}/favicon.ico`, 171 | ]; 172 | } catch { 173 | return []; 174 | } 175 | } 176 | 177 | /** 178 | * Try multiple favicon sources and return the first successful one 179 | */ 180 | async getFaviconWithFallback(pageUrl: string): Promise { 181 | const urls = this.getFaviconUrls(pageUrl); 182 | 183 | for (const url of urls) { 184 | const favicon = await this.getFavicon(url); 185 | if (favicon) { 186 | return favicon; 187 | } 188 | } 189 | 190 | return null; 191 | } 192 | 193 | /** 194 | * Clear cache 195 | */ 196 | clearCache(): void { 197 | try { 198 | const files = fs.readdirSync(this.cachePath); 199 | for (const file of files) { 200 | fs.unlinkSync(path.join(this.cachePath, file)); 201 | } 202 | console.log("[FaviconCache] Cache cleared"); 203 | } catch (error) { 204 | console.error("[FaviconCache] Failed to clear cache:", error); 205 | } 206 | } 207 | 208 | /** 209 | * Get cache size in bytes 210 | */ 211 | getCacheSize(): number { 212 | try { 213 | const files = fs.readdirSync(this.cachePath); 214 | let totalSize = 0; 215 | for (const file of files) { 216 | const stats = fs.statSync(path.join(this.cachePath, file)); 217 | totalSize += stats.size; 218 | } 219 | return totalSize; 220 | } catch (error) { 221 | console.error("[FaviconCache] Failed to get cache size:", error); 222 | return 0; 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # 📱 aka-browser 4 | 5 | ### _A side browser for PC — always on top, always within reach._ 6 | 7 | **Your companion browser for Netflix, Twitter(X), and everything in between.** 8 | 9 | 🌐 **[Visit our website](https://browser.aka.page)** | 📚 **[Technical Wiki](https://deepwiki.com/hmmhmmhm/aka-browser)** | 🚀 **Currently in Beta** — Stable Release coming in November! 10 | 11 | aka-browser screenshot 12 | 13 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 14 | [![Electron](https://img.shields.io/badge/Electron-Castlabs-47848F.svg)](https://github.com/castlabs/electron-releases) 15 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-3178C6.svg)](https://www.typescriptlang.org/) 16 | [![React](https://img.shields.io/badge/React-18-61DAFB.svg)](https://reactjs.org/) 17 | 18 | [Features](#-key-features) • [Installation](#-installation--development) • [Building](#building-for-production) • [DRM Support](#-drm-content-playback) 19 | 20 |
21 | 22 | --- 23 | 24 | ## 🎯 Why aka-browser? 25 | 26 | **aka-browser** isn't here to replace your main browser—it's designed to work **alongside it**. 27 | 28 | Think of it as your **always-on-top companion** for those moments when you need a second screen but don't have one. Watch Netflix with subtitles (PiP doesn't show them!), keep Twitter open while working, monitor a live stream, or follow a tutorial—all in a beautiful, compact window that stays right where you need it. 29 | 30 | ### Perfect For 31 | 32 | - **Watching Netflix with subtitles** → PiP mode loses subtitles, aka-browser keeps them 33 | - **Following Twitter/X** → Keep your timeline visible while working 34 | - **Monitoring streams** → Twitch, YouTube Live always in view 35 | - **Following tutorials** → Step-by-step guides alongside your code 36 | - **Chat windows** → Discord, Slack, or any web chat always accessible 37 | - **Music controls** → Spotify, YouTube Music at your fingertips 38 | 39 | ### Why Not Just Use Your Main Browser? 40 | 41 | - **Always on top** → Never gets buried under other windows 42 | - **Compact & elegant** → Beautiful iPhone frame that doesn't clutter your screen 43 | - **Purpose-built** → Lightweight, fast, and distraction-free 44 | - **DRM-ready** → Full Widevine support for streaming services 45 | - **Instant access** → Lives in your menu bar, launches immediately 46 | 47 | ## ✨ Key Features 48 | 49 | ### 🖥️ **Browser Essentials** 50 | 51 | - **Multi-tab browsing** with visual switcher 52 | - **Tab previews** via auto-screenshots 53 | - **Trackpad gestures** for navigation 54 | - **Dynamic theme colors** with LRU cache 55 | - **Smart user agent** switching (mobile/desktop) 56 | 57 | ### 🎬 **DRM Content Ready** 58 | 59 | - **Netflix, Disney+, Prime Video** support 60 | - **Widevine CDM** integration 61 | - **Castlabs EVS** signed for production 62 | - **Packaged builds** for DRM validation 63 | 64 | ### 🎨 **Beautiful Interface** 65 | 66 | - **iPhone 15 Pro frame** with Dynamic Island 67 | - **React 18** + Vite + TailwindCSS 68 | - **System theme** detection (light/dark) 69 | - **Smooth animations** with optimized rendering 70 | 71 | ### 🛠️ **Developer Tools** 72 | 73 | - **Chrome DevTools** (Cmd+Option+I) 74 | - **Element inspector** via right-click 75 | - **URL bar** with title/domain display 76 | - **System tray** with always-on-top 77 | 78 | ## 🚀 Quick Start 79 | 80 | ```bash 81 | # Clone the repository 82 | git clone https://github.com/hmmhmmhm/aka-browser.git 83 | cd aka-browser 84 | 85 | # Install dependencies 86 | pnpm install 87 | 88 | # Run in development mode 89 | pnpm run dev 90 | ``` 91 | 92 | That's it! The browser will launch with a beautiful iPhone 15 Pro frame ready for testing. 93 | 94 | ## 🏗️ Technical Stack 95 | 96 | ``` 97 | ┌─────────────────────────────────────────┐ 98 | │ Electron (Castlabs + Widevine CDM) │ ← DRM-ready browser engine 99 | ├─────────────────────────────────────────┤ 100 | │ React 18 + TypeScript │ ← Modern UI framework 101 | ├─────────────────────────────────────────┤ 102 | │ Vite + TailwindCSS │ ← Fast builds, beautiful styles 103 | ├─────────────────────────────────────────┤ 104 | │ electron-builder + EVS signing │ ← Production packaging 105 | └─────────────────────────────────────────┘ 106 | ``` 107 | 108 | ## 📦 Installation & Development 109 | 110 | ### Prerequisites 111 | 112 | | Requirement | Version | Purpose | 113 | | ---------------- | ------- | --------------------------------- | 114 | | **Node.js** | 18+ | Runtime environment | 115 | | **pnpm** | Latest | Package management (recommended) | 116 | | **Python 3** | 3.8+ | EVS signing for DRM builds | 117 | | **Castlabs EVS** | - | Production DRM signing (optional) | 118 | 119 | ### Building for Production 120 | 121 | Want to watch Netflix? You'll need a production build: 122 | 123 | ```bash 124 | # 1️⃣ Setup EVS signing (first time only) 125 | pnpm run evs:setup 126 | 127 | # 2️⃣ Verify your configuration 128 | pnpm run evs:verify 129 | 130 | # 3️⃣ Build the packaged app 131 | pnpm run package 132 | ``` 133 | 134 | > **⚠️ Important**: Netflix and other streaming services **reject development mode** signatures. You **must** use a packaged build for DRM content. 135 | 136 | ## 🎬 DRM Content Playback 137 | 138 | aka-browser supports **Widevine DRM** out of the box: 139 | 140 | ``` 141 | Development Mode → ❌ Netflix won't work 142 | Production Build → ✅ Full DRM support (L3 level, software-based without TEE) 143 | ``` 144 | 145 | ### How It Works 146 | 147 | 1. **Widevine CDM** auto-downloads on first run (via Electron Component Updater) 148 | 2. **Castlabs EVS** signs the app for production-grade DRM validation 149 | 3. **Streaming services** verify the signature and allow playback 150 | 151 | ### Supported Services 152 | 153 | | Service | Status | Notes | 154 | | --------------------- | ------ | ----------------------------- | 155 | | 🍿 **Netflix** | ✅ | Requires production build | 156 | | 🏰 **Disney+** | ✅ | Requires production build | 157 | | 📦 **Prime Video** | ✅ | Requires production build | 158 | | 🎵 **Spotify** | ✅ | Works in dev mode | 159 | | 🎬 **Other Widevine** | ✅ | Most require production build | 160 | 161 | ## 🎯 Who Is This For? 162 | 163 | Perfect for **anyone** who: 164 | 165 | - ✅ Wants to **watch Netflix with subtitles** while working (PiP doesn't show them!) 166 | - ✅ Needs a **second screen** but only has one monitor 167 | - ✅ Likes to **keep Twitter/social media visible** without tab-switching 168 | - ✅ Follows **live streams or tutorials** while multitasking 169 | - ✅ Values a **clean, elegant interface** over browser clutter 170 | - ✅ Wants **always-on-top** functionality with a beautiful design 171 | 172 | **Bonus for developers:** 173 | 174 | - 🛠️ Built-in **Chrome DevTools** for testing mobile sites 175 | - 📱 **Lightweight alternative** to heavy iOS simulators 176 | - 🎨 Perfect for **responsive design** previews 177 | 178 | ## 🛠️ Design Philosophy 179 | 180 | This project prioritizes **simplicity and elegance**: 181 | 182 | - 🎯 **Companion, not replacement** → Works alongside your main browser 183 | - ⚡ **Lightweight & fast** → Instant startup, minimal resource usage 184 | - 🎨 **Beautiful by default** → iPhone 15 Pro frame with attention to detail 185 | - 🪟 **Always accessible** → Menu bar integration, always-on-top support 186 | - 🧩 **Just enough features** → What you need, nothing you don't 187 | 188 | ## 📄 License 189 | 190 | MIT License - feel free to use, modify, and distribute. 191 | 192 | ## 👨‍💻 Author 193 | 194 | **hmmhmmhm** 195 | 196 | --- 197 | 198 |
199 | 200 | **⭐ Star this repo if you find it useful!** 201 | 202 | Made with ❤️ for everyone who needs a better way to multitask 203 | 204 |
205 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/components/tab-overview.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | interface Tab { 4 | id: string; 5 | title: string; 6 | url: string; 7 | preview?: string; 8 | } 9 | 10 | interface TabOverviewProps { 11 | theme: "light" | "dark"; 12 | orientation: "portrait" | "landscape"; 13 | onClose: () => void; 14 | } 15 | 16 | function TabOverview({ theme, orientation, onClose }: TabOverviewProps) { 17 | const [tabs, setTabs] = useState([]); 18 | const [activeTabId, setActiveTabId] = useState(null); 19 | 20 | useEffect(() => { 21 | // Get initial tabs 22 | window.electronAPI?.tabs 23 | .getAll() 24 | .then((data: { tabs: Tab[]; activeTabId: string | null }) => { 25 | setTabs(data.tabs); 26 | setActiveTabId(data.activeTabId); 27 | }); 28 | 29 | // Listen for tab changes 30 | const cleanupTabsUpdated = window.electronAPI?.tabs.onTabsUpdated( 31 | (data: { tabs: Tab[]; activeTabId: string | null }) => { 32 | setTabs(data.tabs); 33 | setActiveTabId(data.activeTabId); 34 | } 35 | ); 36 | 37 | return () => { 38 | if (cleanupTabsUpdated) cleanupTabsUpdated(); 39 | }; 40 | }, []); 41 | 42 | const handleTabClick = (tabId: string) => { 43 | window.electronAPI?.tabs.switch(tabId); 44 | onClose(); 45 | }; 46 | 47 | const handleTabClose = (e: React.MouseEvent, tabId: string) => { 48 | e.stopPropagation(); 49 | 50 | // If this is the last tab or the active tab, close the overview after closing the tab 51 | if (tabs.length === 1 || tabId === activeTabId) { 52 | window.electronAPI?.tabs.close(tabId); 53 | onClose(); 54 | } else { 55 | window.electronAPI?.tabs.close(tabId); 56 | } 57 | }; 58 | 59 | const handleNewTab = () => { 60 | window.electronAPI?.tabs.create(); 61 | onClose(); 62 | }; 63 | 64 | const handleCloseAll = () => { 65 | window.electronAPI?.tabs.closeAll(); 66 | onClose(); 67 | }; 68 | 69 | const getDomainFromUrl = (url: string): string => { 70 | try { 71 | const urlObj = new URL(url); 72 | return urlObj.hostname; 73 | } catch { 74 | return url; 75 | } 76 | }; 77 | 78 | const isDark = theme === "dark"; 79 | const isLandscape = orientation === "landscape"; 80 | 81 | return ( 82 |
88 | {/* Header */} 89 |
94 |

99 | Tabs ({tabs.length}) 100 |

101 | 125 |
126 | 127 | {/* Tab Grid */} 128 |
e.stopPropagation()} 131 | > 132 |
137 | {tabs.map((tab) => ( 138 |
handleTabClick(tab.id)} 141 | className={`relative rounded-xl overflow-hidden cursor-pointer transition-all duration-200 ${ 142 | activeTabId === tab.id 143 | ? isDark 144 | ? "bg-zinc-800 ring-2 ring-white shadow-lg" 145 | : "bg-white ring-2 ring-zinc-600 shadow-lg" 146 | : isDark 147 | ? "bg-zinc-800 hover:bg-zinc-700 shadow-md" 148 | : "bg-white hover:bg-zinc-50 shadow-md" 149 | }`} 150 | style={{ aspectRatio: "3/4" }} 151 | > 152 | {/* Tab Preview Area */} 153 |
158 | {tab.preview ? ( 159 | {tab.title} 164 | ) : ( 165 |
166 |
171 | 🌐 172 |
173 |
178 | {getDomainFromUrl(tab.url)} 179 |
180 |
181 | )} 182 |
183 | 184 | {/* Tab Info */} 185 |
186 |
191 | {tab.title} 192 |
193 |
198 | {getDomainFromUrl(tab.url)} 199 |
200 |
201 | 202 | {/* Close Button */} 203 | 226 |
227 | ))} 228 | 229 | {/* New Tab Card */} 230 |
239 |
240 |
245 | + 246 |
247 |
252 | New Tab 253 |
254 |
255 |
256 |
257 |
258 |
259 | ); 260 | } 261 | 262 | export default TabOverview; 263 | -------------------------------------------------------------------------------- /apps/browser/src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | 3 | // Expose protected methods that allow the renderer process to use 4 | // the ipcRenderer without exposing the entire object 5 | contextBridge.exposeInMainWorld("electronAPI", { 6 | // Add any APIs you want to expose to the renderer process here 7 | platform: process.platform, 8 | closeWindow: () => ipcRenderer.send("window-close"), 9 | minimizeWindow: () => ipcRenderer.send("window-minimize"), 10 | maximizeWindow: () => ipcRenderer.send("window-maximize"), 11 | // Navigation gesture listeners 12 | onNavigateBack: (callback: () => void) => { 13 | ipcRenderer.on("navigate-back", callback); 14 | return () => ipcRenderer.removeListener("navigate-back", callback); 15 | }, 16 | onNavigateForward: (callback: () => void) => { 17 | ipcRenderer.on("navigate-forward", callback); 18 | return () => ipcRenderer.removeListener("navigate-forward", callback); 19 | }, 20 | // Webview reload listener 21 | onWebviewReload: (callback: () => void) => { 22 | ipcRenderer.on("webview-reload", callback); 23 | return () => ipcRenderer.removeListener("webview-reload", callback); 24 | }, 25 | // Theme detection 26 | getSystemTheme: () => ipcRenderer.invoke("get-system-theme"), 27 | onThemeChanged: (callback: (theme: "light" | "dark") => void) => { 28 | ipcRenderer.on("theme-changed", (_event, theme) => callback(theme)); 29 | return () => ipcRenderer.removeAllListeners("theme-changed"); 30 | }, 31 | // Orientation APIs 32 | getOrientation: () => ipcRenderer.invoke("get-orientation"), 33 | toggleOrientation: () => ipcRenderer.invoke("toggle-orientation"), 34 | onOrientationChanged: ( 35 | callback: (orientation: "portrait" | "landscape") => void 36 | ) => { 37 | ipcRenderer.on("orientation-changed", (_event, orientation) => 38 | callback(orientation) 39 | ); 40 | return () => ipcRenderer.removeAllListeners("orientation-changed"); 41 | }, 42 | 43 | // Fullscreen mode listener 44 | onFullscreenModeChanged: (callback: (isFullscreen: boolean) => void) => { 45 | ipcRenderer.on("fullscreen-mode-changed", (_event, isFullscreen) => 46 | callback(isFullscreen) 47 | ); 48 | return () => ipcRenderer.removeAllListeners("fullscreen-mode-changed"); 49 | }, 50 | 51 | // Settings listener 52 | onOpenSettings: (callback: () => void) => { 53 | ipcRenderer.on("open-settings", callback); 54 | return () => ipcRenderer.removeListener("open-settings", callback); 55 | }, 56 | 57 | // App version 58 | getAppVersion: () => ipcRenderer.invoke("get-app-version"), 59 | 60 | // Tab management APIs 61 | tabs: { 62 | getAll: () => ipcRenderer.invoke("tabs-get-all"), 63 | create: (url?: string) => ipcRenderer.invoke("tabs-create", url), 64 | switch: (tabId: string) => ipcRenderer.invoke("tabs-switch", tabId), 65 | close: (tabId: string) => ipcRenderer.invoke("tabs-close", tabId), 66 | closeAll: () => ipcRenderer.invoke("tabs-close-all"), 67 | onTabChanged: (callback: (data: { tabId: string; tabs: any[] }) => void) => { 68 | const listener = (_event: any, data: any) => callback(data); 69 | ipcRenderer.on("tab-changed", listener); 70 | return () => ipcRenderer.removeListener("tab-changed", listener); 71 | }, 72 | onTabsUpdated: (callback: (data: { tabs: any[]; activeTabId: string | null }) => void) => { 73 | const listener = (_event: any, data: any) => callback(data); 74 | ipcRenderer.on("tabs-updated", listener); 75 | return () => ipcRenderer.removeListener("tabs-updated", listener); 76 | }, 77 | }, 78 | 79 | // WebContentsView control APIs 80 | webContents: { 81 | loadURL: (url: string) => ipcRenderer.invoke("webcontents-load-url", url), 82 | goBack: () => ipcRenderer.invoke("webcontents-go-back"), 83 | goForward: () => ipcRenderer.invoke("webcontents-go-forward"), 84 | reload: () => ipcRenderer.invoke("webcontents-reload"), 85 | canGoBack: () => ipcRenderer.invoke("webcontents-can-go-back"), 86 | canGoForward: () => ipcRenderer.invoke("webcontents-can-go-forward"), 87 | getURL: () => ipcRenderer.invoke("webcontents-get-url"), 88 | getTitle: () => ipcRenderer.invoke("webcontents-get-title"), 89 | setVisible: (visible: boolean) => ipcRenderer.invoke("webcontents-set-visible", visible), 90 | // Removed executeJavaScript for security - use specific APIs instead 91 | getThemeColor: () => ipcRenderer.invoke("webcontents-get-theme-color"), 92 | onThemeColorUpdated: (callback: (color: string) => void) => { 93 | const listener = (_event: any, color: string) => callback(color); 94 | ipcRenderer.on("webcontents-theme-color-updated", listener); 95 | return () => ipcRenderer.removeListener("webcontents-theme-color-updated", listener); 96 | }, 97 | setBounds: (bounds: { 98 | x: number; 99 | y: number; 100 | width: number; 101 | height: number; 102 | }) => ipcRenderer.invoke("webcontents-set-bounds", bounds), 103 | setStatusBarBounds: (bounds: { 104 | x: number; 105 | y: number; 106 | width: number; 107 | height: number; 108 | }) => ipcRenderer.invoke("statusbar-set-bounds", bounds), 109 | setDeviceFrameBounds: (bounds: { 110 | x: number; 111 | y: number; 112 | width: number; 113 | height: number; 114 | }) => ipcRenderer.invoke("deviceframe-set-bounds", bounds), 115 | 116 | // Event listeners 117 | onDidStartLoading: (callback: () => void) => { 118 | ipcRenderer.on("webcontents-did-start-loading", callback); 119 | return () => 120 | ipcRenderer.removeListener("webcontents-did-start-loading", callback); 121 | }, 122 | onDidStopLoading: (callback: () => void) => { 123 | ipcRenderer.on("webcontents-did-stop-loading", callback); 124 | return () => 125 | ipcRenderer.removeListener("webcontents-did-stop-loading", callback); 126 | }, 127 | onDidNavigate: (callback: (url: string) => void) => { 128 | const listener = (_event: any, url: string) => callback(url); 129 | ipcRenderer.on("webcontents-did-navigate", listener); 130 | return () => 131 | ipcRenderer.removeListener("webcontents-did-navigate", listener); 132 | }, 133 | onDidNavigateInPage: (callback: (url: string) => void) => { 134 | const listener = (_event: any, url: string) => callback(url); 135 | ipcRenderer.on("webcontents-did-navigate-in-page", listener); 136 | return () => 137 | ipcRenderer.removeListener( 138 | "webcontents-did-navigate-in-page", 139 | listener 140 | ); 141 | }, 142 | onDomReady: (callback: () => void) => { 143 | ipcRenderer.on("webcontents-dom-ready", callback); 144 | return () => 145 | ipcRenderer.removeListener("webcontents-dom-ready", callback); 146 | }, 147 | onDidFailLoad: ( 148 | callback: (errorCode: number, errorDescription: string) => void 149 | ) => { 150 | const listener = ( 151 | _event: any, 152 | errorCode: number, 153 | errorDescription: string 154 | ) => callback(errorCode, errorDescription); 155 | ipcRenderer.on("webcontents-did-fail-load", listener); 156 | return () => 157 | ipcRenderer.removeListener("webcontents-did-fail-load", listener); 158 | }, 159 | onRenderProcessGone: (callback: (details: any) => void) => { 160 | const listener = (_event: any, details: any) => callback(details); 161 | ipcRenderer.on("webcontents-render-process-gone", listener); 162 | return () => 163 | ipcRenderer.removeListener("webcontents-render-process-gone", listener); 164 | }, 165 | onHttpError: ( 166 | callback: ( 167 | statusCode: number, 168 | statusText: string, 169 | url: string 170 | ) => void 171 | ) => { 172 | const listener = ( 173 | _event: any, 174 | statusCode: number, 175 | statusText: string, 176 | url: string 177 | ) => callback(statusCode, statusText, url); 178 | ipcRenderer.on("webcontents-http-error", listener); 179 | return () => 180 | ipcRenderer.removeListener("webcontents-http-error", listener); 181 | }, 182 | }, 183 | 184 | // Bookmark management APIs 185 | bookmarks: { 186 | getAll: () => ipcRenderer.invoke("bookmarks-get-all"), 187 | getById: (id: string) => ipcRenderer.invoke("bookmarks-get-by-id", id), 188 | isBookmarked: (url: string) => ipcRenderer.invoke("bookmarks-is-bookmarked", url), 189 | add: (title: string, url: string, favicon?: string) => 190 | ipcRenderer.invoke("bookmarks-add", title, url, favicon), 191 | update: (id: string, updates: any) => 192 | ipcRenderer.invoke("bookmarks-update", id, updates), 193 | remove: (id: string) => ipcRenderer.invoke("bookmarks-remove", id), 194 | removeByUrl: (url: string) => ipcRenderer.invoke("bookmarks-remove-by-url", url), 195 | clear: () => ipcRenderer.invoke("bookmarks-clear"), 196 | onUpdate: (callback: () => void) => { 197 | const listener = () => callback(); 198 | ipcRenderer.on("bookmarks-updated", listener); 199 | return () => ipcRenderer.removeListener("bookmarks-updated", listener); 200 | }, 201 | }, 202 | 203 | // Favicon cache APIs 204 | favicon: { 205 | get: (url: string) => ipcRenderer.invoke("favicon-get", url), 206 | getWithFallback: (pageUrl: string) => ipcRenderer.invoke("favicon-get-with-fallback", pageUrl), 207 | isCached: (url: string) => ipcRenderer.invoke("favicon-is-cached", url), 208 | clearCache: () => ipcRenderer.invoke("favicon-clear-cache"), 209 | getCacheSize: () => ipcRenderer.invoke("favicon-get-cache-size"), 210 | }, 211 | }); 212 | -------------------------------------------------------------------------------- /apps/browser/README.md: -------------------------------------------------------------------------------- 1 | # aka-browser - Browser App 2 | 3 | A lightweight, elegant web browser built with Electron featuring an iPhone frame interface and DRM support for streaming services. 4 | 5 | ## Overview 6 | 7 | aka-browser is a developer-focused Electron browser that provides a mobile-like browsing experience on desktop. It uses **@castlabs/electron-releases** for Widevine CDM support, enabling playback of DRM-protected content from services like Netflix. 8 | 9 | ## Key Features 10 | 11 | ### 🎨 UI/UX 12 | 13 | - **iPhone Frame Interface**: Simulates an iPhone device with realistic bezels and rounded corners 14 | - **Dynamic Status Bar**: Adapts background color based on webpage theme-color meta tag 15 | - **Theme Color Caching**: LRU cache system prevents white flashes during navigation 16 | - **Safe Area Support**: Polyfills CSS `env(safe-area-inset-*)` for web content 17 | - **Modern Design**: Backdrop blur effects and smooth animations 18 | 19 | ### 🔒 DRM & Media 20 | 21 | - **Widevine CDM Support**: Plays DRM-protected content (Netflix, Disney+, etc.) 22 | - **Automatic CDM Download**: Component Updater handles Widevine installation 23 | - **EVS Signing**: VMP (Verified Media Path) signing for production builds 24 | - **Media Permissions**: Configured for camera, microphone, and DRM playback 25 | 26 | ### 🌐 Browser Features 27 | 28 | - **Multi-Tab Support**: Manage multiple web views with tab switching 29 | - **Smart User Agent**: Automatically switches between mobile/desktop UA based on domain 30 | - Default: iPhone 15 Pro (mobile) 31 | - Netflix: macOS Chrome (desktop) 32 | - **URL Security**: Protocol validation and dangerous URL blocking 33 | - **Navigation Controls**: Back, forward, reload, and URL bar 34 | 35 | ### 🛠️ Developer Tools 36 | 37 | - **TypeScript**: Full type safety across main, renderer, and preload scripts 38 | - **React + Vite**: Modern frontend tooling with hot reload 39 | - **TailwindCSS**: Utility-first styling with v4 40 | - **IPC Communication**: Type-safe inter-process communication 41 | 42 | ## Project Structure 43 | 44 | ``` 45 | apps/browser/ 46 | ├── src/ 47 | │ ├── main.ts # Electron main process (window, tabs, IPC) 48 | │ ├── preload.ts # Main window preload script 49 | │ ├── status-bar-preload.ts # Status bar preload script 50 | │ ├── webview-preload.ts # Web content preload (theme color, safe area) 51 | │ ├── renderer/ # React UI 52 | │ │ ├── src/ 53 | │ │ │ ├── app.tsx # Main React component 54 | │ │ │ └── main.tsx # React entry point 55 | │ │ └── index.html # HTML template 56 | │ └── types/ # TypeScript type definitions 57 | ├── scripts/ 58 | │ ├── evs-sign.js # EVS VMP signing script 59 | │ └── setup-evs.sh # EVS environment setup 60 | ├── assets/ # App icons 61 | ├── dist/ # Compiled main/preload scripts 62 | ├── dist-renderer/ # Built React app 63 | └── package.json 64 | ``` 65 | 66 | ## Development 67 | 68 | ### Prerequisites 69 | 70 | - **Node.js**: v18 or higher 71 | - **pnpm**: v8 or higher 72 | - **Python 3**: Required for EVS signing (production builds only) 73 | 74 | ### Setup 75 | 76 | ```bash 77 | # Install dependencies (from project root) 78 | pnpm install 79 | 80 | # First run: Download Widevine CDM 81 | cd apps/browser 82 | pnpm dev 83 | ``` 84 | 85 | On first launch, Electron will automatically download Widevine CDM to: 86 | 87 | ``` 88 | ~/Library/Application Support/@aka-browser/browser/WidevineCdm/ 89 | ``` 90 | 91 | ### Development Commands 92 | 93 | ```bash 94 | # Run in development mode (hot reload enabled) 95 | pnpm dev 96 | 97 | # Build all components 98 | pnpm build 99 | 100 | # Build individual components 101 | pnpm build:main # Main process TypeScript 102 | pnpm build:webview # Webview preload TypeScript 103 | pnpm build:renderer # React UI 104 | 105 | # Watch mode for development 106 | pnpm build:main:watch 107 | pnpm build:webview:watch 108 | 109 | # Type checking 110 | pnpm check-types 111 | 112 | # Start Electron without rebuilding 113 | pnpm start:electron 114 | ``` 115 | 116 | ### Production Build 117 | 118 | ```bash 119 | # Setup EVS for Widevine signing (one-time) 120 | pnpm evs:setup 121 | 122 | # Verify EVS configuration 123 | pnpm evs:verify 124 | 125 | # Build and package the app 126 | pnpm package 127 | ``` 128 | 129 | The packaged app will be in `release/` directory. 130 | 131 | ## Architecture 132 | 133 | ### Process Model 134 | 135 | aka-browser uses Electron's multi-process architecture: 136 | 137 | 1. **Main Process** (`main.ts`) 138 | - Creates and manages BrowserWindow 139 | - Handles tab creation and switching 140 | - Manages WebContentsView instances 141 | - Implements theme color caching (LRU, max 100 domains) 142 | - Waits for Widevine CDM via `components.whenReady()` 143 | 144 | 2. **Renderer Process** (`renderer/`) 145 | - React-based UI for controls and frame 146 | - Communicates with main process via IPC 147 | - Handles user input (URL, navigation buttons) 148 | 149 | 3. **Preload Scripts** 150 | - `preload.ts`: Exposes IPC APIs to renderer 151 | - `status-bar-preload.ts`: Handles status bar theme updates 152 | - `webview-preload.ts`: Injects safe area polyfill and extracts theme colors 153 | 154 | ### Key Components 155 | 156 | #### Theme Color System 157 | 158 | - Extracts `theme-color` meta tag from web pages 159 | - Caches colors by domain (LRU cache, max 100 entries) 160 | - Applies cached color immediately on navigation start 161 | - Updates when new theme color is detected 162 | 163 | #### Safe Area Polyfill 164 | 165 | - Injects CSS variables: `--safe-area-inset-top/left/bottom/right` 166 | - Overrides `CSS.supports()` for `env()` function 167 | - Patches stylesheets to replace `env()` with pixel values 168 | - Portrait: top=58px, Landscape: left=58px 169 | 170 | #### User Agent Switching 171 | 172 | - Default: iPhone 15 Pro user agent 173 | - Netflix domains: macOS Chrome user agent 174 | - Applied on tab creation, navigation, and programmatic loads 175 | 176 | ## DRM Support (Widevine) 177 | 178 | ### How It Works 179 | 180 | 1. **Electron Distribution**: Uses `@castlabs/electron-releases` instead of standard Electron 181 | 2. **Component Updater**: Automatically downloads Widevine CDM on first run 182 | 3. **EVS Signing**: Production builds are signed with VMP for L3 DRM 183 | 4. **Runtime Verification**: Checks `navigator.requestMediaKeySystemAccess('com.widevine.alpha')` 184 | 185 | ### EVS Setup (Production Only) 186 | 187 | ```bash 188 | # Install castlabs-evs 189 | pip3 install --break-system-packages castlabs-evs 190 | 191 | # Configure EVS account 192 | # Edit ~/.config/evs/config.json: 193 | { 194 | "Auth": { 195 | "AccountName": "your-account-name" 196 | } 197 | } 198 | 199 | # Verify setup 200 | pnpm evs:verify 201 | ``` 202 | 203 | ### Build Process 204 | 205 | 1. **Copy Widevine CDM**: From Application Support to app bundle 206 | 2. **Verify EVS**: Check Python 3, castlabs-evs, and account config 207 | 3. **Sign with VMP**: Apply EVS signature to enable DRM 208 | 209 | ### Troubleshooting 210 | 211 | - **CDM not found**: Run `pnpm dev` first to download CDM 212 | - **EVS signing fails**: Check Python 3 and `~/.config/evs/config.json` 213 | - **DRM playback fails**: Check console for Widevine verification logs 214 | 215 | ## Configuration 216 | 217 | ### Electron Builder 218 | 219 | Key settings in `package.json`: 220 | 221 | ```json 222 | { 223 | "build": { 224 | "electronDist": "node_modules/electron/dist", 225 | "electronVersion": "38.0.0", 226 | "asarUnpack": ["**/WidevineCdm/**/*"], 227 | "afterPack": "scripts/evs-sign.js" 228 | } 229 | } 230 | ``` 231 | 232 | ### Security 233 | 234 | - **Protocol Validation**: Only `http:`, `https:` allowed (+ `file:` in dev) 235 | - **Dangerous Protocols Blocked**: `javascript:`, `data:`, `vbscript:`, `about:`, `blob:` 236 | - **Sandbox**: Disabled for Widevine compatibility 237 | - **Context Isolation**: Enabled with secure IPC bridge 238 | 239 | ## Known Limitations 240 | 241 | 1. **WebContentsView Border Radius**: Cannot set individual corner radius (all corners or none) 242 | 2. **Native View Z-Order**: WebContentsView always renders above HTML layers 243 | 3. **Safe Area Polyfill**: Requires viewport-fit=cover in web pages 244 | 4. **EVS Account**: Required for production DRM builds 245 | 246 | ## Testing 247 | 248 | ### Manual Testing Checklist 249 | 250 | - [ ] Launch app and verify Widevine CDM loads 251 | - [ ] Navigate to Netflix and test video playback 252 | - [ ] Check theme color updates on different websites 253 | - [ ] Test tab creation and switching 254 | - [ ] Verify safe area insets in web content 255 | - [ ] Test back/forward navigation 256 | - [ ] Check URL bar input and validation 257 | 258 | ### Debug Logs 259 | 260 | Enable verbose logging: 261 | 262 | ```bash 263 | # Check Widevine status 264 | # Logs appear in console on app startup: 265 | # [Widevine] Electron version: ... 266 | # [Component] Waiting for Widevine CDM... 267 | # [Component] ✓ Ready after XXXms 268 | ``` 269 | 270 | ## Contributing 271 | 272 | 1. Follow existing code style (TypeScript strict mode) 273 | 2. Test DRM functionality before submitting PRs 274 | 3. Update README for new features 275 | 4. Ensure EVS signing works for production builds 276 | 277 | ## License 278 | 279 | MIT 280 | 281 | ## Resources 282 | 283 | - [Electron Documentation](https://www.electronjs.org/docs) 284 | - [castlabs Electron Releases](https://github.com/castlabs/electron-releases) 285 | - [Widevine CDM Documentation](https://www.widevine.com/) 286 | - [EVS Documentation](https://castlabs.com/evs/) 287 | -------------------------------------------------------------------------------- /apps/browser/src/renderer/pages/blank-page.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | import { useEffect, useState } from 'react'; 3 | 4 | interface Bookmark { 5 | id: string; 6 | title: string; 7 | url: string; 8 | favicon?: string; 9 | displayUrl?: string; 10 | createdAt?: number; 11 | updatedAt?: number; 12 | } 13 | 14 | const defaultBookmarks: Bookmark[] = [ 15 | { 16 | id: 'default-google', 17 | title: 'Google', 18 | url: 'https://www.google.com', 19 | favicon: 'https://www.google.com/s2/favicons?domain=google.com&sz=128', 20 | displayUrl: 'google.com', 21 | }, 22 | { 23 | id: 'default-youtube', 24 | title: 'YouTube', 25 | url: 'https://www.youtube.com', 26 | favicon: 'https://www.google.com/s2/favicons?domain=youtube.com&sz=128', 27 | displayUrl: 'youtube.com', 28 | }, 29 | { 30 | id: 'default-netflix', 31 | title: 'Netflix', 32 | url: 'https://www.netflix.com', 33 | favicon: 'https://www.google.com/s2/favicons?domain=netflix.com&sz=128', 34 | displayUrl: 'netflix.com', 35 | }, 36 | { 37 | id: 'default-x', 38 | title: 'X', 39 | url: 'https://x.com', 40 | favicon: 'https://www.google.com/s2/favicons?domain=x.com&sz=128', 41 | displayUrl: 'x.com', 42 | }, 43 | ]; 44 | 45 | function getHighResFavicon(url: string): string[] { 46 | try { 47 | const domain = new URL(url).origin; 48 | return [ 49 | `${domain}/apple-touch-icon.png`, 50 | `${domain}/apple-touch-icon-precomposed.png`, 51 | `https://www.google.com/s2/favicons?domain=${domain}&sz=128`, 52 | `${domain}/favicon.ico`, 53 | ]; 54 | } catch { 55 | return []; 56 | } 57 | } 58 | 59 | interface BookmarkItemProps { 60 | bookmark: Bookmark; 61 | } 62 | 63 | function BookmarkItem({ bookmark }: BookmarkItemProps) { 64 | const [currentFaviconIndex, setCurrentFaviconIndex] = useState(0); 65 | const [faviconSources, setFaviconSources] = useState([]); 66 | const [showFallback, setShowFallback] = useState(false); 67 | 68 | useEffect(() => { 69 | // Build favicon sources list based on whether bookmark has favicon 70 | let sources: string[]; 71 | if (bookmark.favicon) { 72 | // Try high-res favicon first, fallback to provided favicon 73 | sources = bookmark.id && bookmark.id.startsWith('default-') 74 | ? [bookmark.favicon] // Use direct URL for default bookmarks 75 | : getHighResFavicon(bookmark.url).concat([bookmark.favicon]); 76 | } else { 77 | // Try to get high-res favicon even if not provided 78 | sources = getHighResFavicon(bookmark.url); 79 | } 80 | 81 | setFaviconSources(sources); 82 | setCurrentFaviconIndex(0); 83 | setShowFallback(sources.length === 0); 84 | }, [bookmark.favicon, bookmark.url, bookmark.id]); 85 | 86 | const handleClick = (e: React.MouseEvent) => { 87 | e.preventDefault(); 88 | window.location.href = bookmark.url; 89 | }; 90 | 91 | const handleImageError = () => { 92 | const nextIndex = currentFaviconIndex + 1; 93 | if (nextIndex < faviconSources.length) { 94 | setCurrentFaviconIndex(nextIndex); 95 | } else { 96 | // All sources failed, show first letter 97 | setShowFallback(true); 98 | } 99 | }; 100 | 101 | const displayUrl = bookmark.displayUrl || (() => { 102 | try { 103 | const hostname = new URL(bookmark.url).hostname; 104 | return hostname.replace(/^www\./, ''); 105 | } catch { 106 | return bookmark.url; 107 | } 108 | })(); 109 | 110 | return ( 111 | 112 |
113 | {!showFallback && faviconSources.length > 0 ? ( 114 | {bookmark.title} 119 | ) : ( 120 | bookmark.title.charAt(0).toUpperCase() 121 | )} 122 |
123 |
{bookmark.title}
124 |
{displayUrl}
125 |
126 | ); 127 | } 128 | 129 | export default function BlankPage() { 130 | const [bookmarks, setBookmarks] = useState([]); 131 | const [hiddenDefaults, setHiddenDefaults] = useState([]); 132 | 133 | const loadBookmarks = async () => { 134 | try { 135 | const userBookmarks = await window.electronAPI?.bookmarks?.getAll(); 136 | 137 | // Load hidden default bookmarks from localStorage 138 | let hidden: string[] = []; 139 | try { 140 | const hiddenStr = localStorage.getItem('hiddenDefaultBookmarks'); 141 | if (hiddenStr) { 142 | hidden = JSON.parse(hiddenStr); 143 | } 144 | } catch (error) { 145 | console.error('Failed to load hidden bookmarks:', error); 146 | } 147 | 148 | setHiddenDefaults(hidden); 149 | 150 | // Filter out hidden default bookmarks 151 | const visibleDefaults = defaultBookmarks.filter( 152 | (bookmark) => !hidden.includes(bookmark.id) 153 | ); 154 | 155 | // Combine user bookmarks and visible default bookmarks 156 | const displayBookmarks = [...(userBookmarks || []), ...visibleDefaults]; 157 | 158 | console.log('[Start Page] User bookmarks:', userBookmarks?.length, userBookmarks); 159 | console.log('[Start Page] Visible defaults:', visibleDefaults.length, visibleDefaults); 160 | console.log('[Start Page] Total display bookmarks:', displayBookmarks.length, displayBookmarks); 161 | 162 | setBookmarks(displayBookmarks); 163 | } catch (error) { 164 | console.error('Failed to load bookmarks:', error); 165 | } 166 | }; 167 | 168 | useEffect(() => { 169 | loadBookmarks(); 170 | 171 | // Listen for bookmark updates 172 | if (window.electronAPI?.bookmarks?.onUpdate) { 173 | const cleanup = window.electronAPI.bookmarks.onUpdate(() => { 174 | loadBookmarks(); 175 | }); 176 | 177 | return cleanup; 178 | } 179 | }, []); 180 | 181 | return ( 182 | <> 183 | 329 |
330 |

Favorites

331 | {bookmarks.length === 0 ? ( 332 |
333 |
334 |
335 | No favorites yet. 336 |
337 | Click the menu button to add your favorite sites. 338 |
339 |
340 | ) : ( 341 |
342 | {bookmarks.map((bookmark) => ( 343 | 344 | ))} 345 |
346 | )} 347 |
348 | 349 | ); 350 | } 351 | -------------------------------------------------------------------------------- /apps/browser/scripts-src/evs-sign.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * EVS (Electron for Content Security VMP signing) hook for electron-builder 5 | * This script: 6 | * 1. Copies Widevine CDM to the app bundle 7 | * 2. Signs the application with Widevine VMP signature after packaging 8 | */ 9 | 10 | import { execSync, spawnSync, SpawnSyncReturns } from 'child_process'; 11 | import * as fs from 'fs'; 12 | import * as path from 'path'; 13 | import * as os from 'os'; 14 | 15 | interface EVSConfig { 16 | account_name?: string; 17 | Auth?: { 18 | AccountName?: string; 19 | }; 20 | Account?: { 21 | AccountName?: string; 22 | }; 23 | } 24 | 25 | interface BuildContext { 26 | electronPlatformName: string; 27 | appOutDir: string; 28 | } 29 | 30 | /** 31 | * Check if Python 3 is available 32 | */ 33 | function checkPython(): boolean { 34 | try { 35 | const result: SpawnSyncReturns = spawnSync('python3', ['--version'], { encoding: 'utf8' }); 36 | if (result.status === 0) { 37 | console.log('[EVS] Python 3 found:', result.stdout.trim()); 38 | return true; 39 | } 40 | } catch (error) { 41 | console.error('[EVS] Python 3 not found'); 42 | return false; 43 | } 44 | return false; 45 | } 46 | 47 | /** 48 | * Check if castlabs-evs is installed 49 | */ 50 | function checkEvsInstalled(): boolean { 51 | try { 52 | const result: SpawnSyncReturns = spawnSync('python3', ['-m', 'pip', 'show', 'castlabs-evs'], { encoding: 'utf8' }); 53 | if (result.status === 0 && result.stdout.includes('Name: castlabs-evs')) { 54 | const versionMatch = result.stdout.match(/Version: ([\d.]+)/); 55 | const version = versionMatch ? versionMatch[1] : 'unknown'; 56 | console.log('[EVS] castlabs-evs installed (version:', version + ')'); 57 | return true; 58 | } 59 | } catch (error) { 60 | // Ignore 61 | } 62 | console.error('[EVS] castlabs-evs not installed'); 63 | return false; 64 | } 65 | 66 | /** 67 | * Check if EVS account is configured 68 | */ 69 | function checkEvsAccount(): boolean { 70 | const configPath = path.join(os.homedir(), '.config', 'evs', 'config.json'); 71 | 72 | if (!fs.existsSync(configPath)) { 73 | console.error('[EVS] EVS account not configured (config not found)'); 74 | return false; 75 | } 76 | 77 | try { 78 | const configContent = fs.readFileSync(configPath, 'utf8'); 79 | const config: EVSConfig = JSON.parse(configContent); 80 | // Check for both formats: account_name (old) and Auth.AccountName (new) 81 | const accountName = config.account_name || config.Auth?.AccountName || config.Account?.AccountName; 82 | if (accountName) { 83 | console.log('[EVS] ✓ EVS account configured:', accountName); 84 | return true; 85 | } 86 | } catch (error) { 87 | const errorMessage = error instanceof Error ? error.message : String(error); 88 | console.error('[EVS] ✗ Failed to read EVS config:', errorMessage); 89 | return false; 90 | } 91 | 92 | console.error('[EVS] EVS account not configured'); 93 | return false; 94 | } 95 | 96 | /** 97 | * Print setup instructions 98 | */ 99 | function printSetupInstructions(): void { 100 | console.error('\n[EVS] Setup Instructions:'); 101 | console.error('[EVS] =========================================='); 102 | console.error('[EVS] '); 103 | console.error('[EVS] 1. Install castlabs-evs:'); 104 | console.error('[EVS] $ pip3 install --upgrade castlabs-evs'); 105 | console.error('[EVS] '); 106 | console.error('[EVS] 2. Create an EVS account (if you don\'t have one):'); 107 | console.error('[EVS] $ python3 -m castlabs_evs.account signup'); 108 | console.error('[EVS] '); 109 | console.error('[EVS] 3. Or log in to existing account:'); 110 | console.error('[EVS] $ python3 -m castlabs_evs.account reauth'); 111 | console.error('[EVS] '); 112 | console.error('[EVS] 4. Verify your setup:'); 113 | console.error('[EVS] $ node scripts/evs-sign.js --verify'); 114 | console.error('[EVS] '); 115 | console.error('[EVS] ==========================================\n'); 116 | } 117 | 118 | /** 119 | * Verify EVS environment setup 120 | */ 121 | function verifyEnvironment(): boolean { 122 | console.log('\n[EVS] Verifying EVS environment...'); 123 | console.log('[EVS] =========================================='); 124 | 125 | const pythonOk = checkPython(); 126 | const evsInstalled = pythonOk && checkEvsInstalled(); 127 | const accountConfigured = evsInstalled && checkEvsAccount(); 128 | 129 | console.log('[EVS] =========================================='); 130 | 131 | if (!pythonOk || !evsInstalled || !accountConfigured) { 132 | console.error('[EVS] EVS environment is not properly configured\n'); 133 | printSetupInstructions(); 134 | return false; 135 | } 136 | 137 | console.log('[EVS] EVS environment is ready for signing\n'); 138 | return true; 139 | } 140 | 141 | /** 142 | * Copy Widevine CDM to app bundle 143 | */ 144 | function copyWidevineCdm(appOutDir: string, electronPlatformName: string): boolean { 145 | console.log('[Widevine] Copying Widevine CDM to app bundle...'); 146 | 147 | // Determine source path based on platform 148 | let widevineSrcPath: string | undefined; 149 | let widevineDestPath: string; 150 | 151 | if (electronPlatformName === 'darwin') { 152 | // macOS: Check multiple possible locations 153 | const possiblePaths = [ 154 | path.join(os.homedir(), 'Library/Application Support/@aka-browser/browser/WidevineCdm'), 155 | path.join(os.homedir(), 'Library/Application Support/Electron/WidevineCdm'), 156 | ]; 157 | 158 | for (const p of possiblePaths) { 159 | if (fs.existsSync(p)) { 160 | widevineSrcPath = p; 161 | break; 162 | } 163 | } 164 | 165 | if (!widevineSrcPath) { 166 | console.log('[Widevine] ⚠️ Widevine CDM not found in Application Support'); 167 | console.log('[Widevine] The app will download it on first run'); 168 | return false; 169 | } 170 | 171 | // Find the version directory 172 | const versions = fs.readdirSync(widevineSrcPath).filter(f => f.match(/^\d+\.\d+\.\d+\.\d+$/)); 173 | if (versions.length === 0) { 174 | console.log('[Widevine] ⚠️ No Widevine CDM version found'); 175 | return false; 176 | } 177 | 178 | const latestVersion = versions.sort().reverse()[0]; 179 | widevineSrcPath = path.join(widevineSrcPath, latestVersion); 180 | 181 | // Destination: inside the app bundle 182 | const appName = fs.readdirSync(appOutDir).find(f => f.endsWith('.app')); 183 | if (!appName) { 184 | console.error('[Widevine] ✗ App bundle not found in', appOutDir); 185 | return false; 186 | } 187 | 188 | widevineDestPath = path.join( 189 | appOutDir, 190 | appName, 191 | 'Contents/Frameworks/Electron Framework.framework/Versions/A/Libraries/WidevineCdm', 192 | latestVersion 193 | ); 194 | 195 | console.log('[Widevine] Source:', widevineSrcPath); 196 | console.log('[Widevine] Destination:', widevineDestPath); 197 | 198 | // Create destination directory 199 | fs.mkdirSync(path.dirname(widevineDestPath), { recursive: true }); 200 | 201 | // Copy Widevine CDM 202 | try { 203 | execSync(`cp -R "${widevineSrcPath}" "${widevineDestPath}"`, { encoding: 'utf8' }); 204 | console.log('[Widevine] ✓ Widevine CDM copied successfully'); 205 | 206 | // Verify the copy 207 | const cdmLib = path.join(widevineDestPath, '_platform_specific/mac_arm64/libwidevinecdm.dylib'); 208 | if (fs.existsSync(cdmLib)) { 209 | const stats = fs.statSync(cdmLib); 210 | console.log(`[Widevine] ✓ libwidevinecdm.dylib (${(stats.size / 1024 / 1024).toFixed(2)} MB)`); 211 | 212 | // CRITICAL: Also copy to Resources directory for component updater 213 | const resourcesWidevinePath = path.join( 214 | appOutDir, 215 | appName, 216 | 'Contents/Resources/WidevineCdm', 217 | latestVersion 218 | ); 219 | 220 | console.log('[Widevine] Also copying to Resources:', resourcesWidevinePath); 221 | fs.mkdirSync(path.dirname(resourcesWidevinePath), { recursive: true }); 222 | execSync(`cp -R "${widevineSrcPath}" "${resourcesWidevinePath}"`, { encoding: 'utf8' }); 223 | console.log('[Widevine] ✓ Widevine CDM also copied to Resources'); 224 | 225 | return true; 226 | } else { 227 | console.error('[Widevine] ✗ libwidevinecdm.dylib not found after copy'); 228 | return false; 229 | } 230 | } catch (error) { 231 | const errorMessage = error instanceof Error ? error.message : String(error); 232 | console.error('[Widevine] ✗ Failed to copy Widevine CDM:', errorMessage); 233 | return false; 234 | } 235 | } else if (electronPlatformName === 'win32') { 236 | // Windows implementation (similar logic) 237 | console.log('[Widevine] Windows Widevine CDM copy not implemented yet'); 238 | return false; 239 | } 240 | 241 | return false; 242 | } 243 | 244 | /** 245 | * Sign the application package 246 | */ 247 | function signPackage(appOutDir: string, persistent: boolean = false): boolean { 248 | const persistentFlag = persistent ? '--persistent' : ''; 249 | const command = `python3 -m castlabs_evs.vmp sign-pkg ${persistentFlag} "${appOutDir}"`; 250 | 251 | console.log('[EVS] Running:', command); 252 | 253 | try { 254 | execSync(command, { 255 | encoding: 'utf8', 256 | stdio: 'inherit' 257 | }); 258 | 259 | console.log('[EVS] VMP signing completed successfully\n'); 260 | return true; 261 | } catch (error) { 262 | const errorMessage = error instanceof Error ? error.message : String(error); 263 | console.error('[EVS] VMP signing failed:', errorMessage); 264 | return false; 265 | } 266 | } 267 | 268 | /** 269 | * Main electron-builder hook 270 | */ 271 | export default async function(context: BuildContext): Promise { 272 | const { electronPlatformName, appOutDir } = context; 273 | 274 | console.log('\n[EVS] Starting post-pack process...'); 275 | console.log('[EVS] Platform:', electronPlatformName); 276 | console.log('[EVS] App directory:', appOutDir); 277 | 278 | // Only process macOS and Windows builds 279 | if (electronPlatformName !== 'darwin' && electronPlatformName !== 'win32') { 280 | console.log('[EVS] Skipping for', electronPlatformName); 281 | return; 282 | } 283 | 284 | // Step 1: Verify EVS environment 285 | console.log('\n[EVS] Step 1: Verify EVS environment'); 286 | if (!verifyEnvironment()) { 287 | throw new Error('EVS environment not configured. Please follow the setup instructions above.'); 288 | } 289 | 290 | // Step 2: Sign the package with EVS VMP 291 | console.log('\n[EVS] Step 2: Sign with EVS VMP'); 292 | const success = signPackage(appOutDir, false); 293 | 294 | if (!success) { 295 | throw new Error('EVS signing failed'); 296 | } 297 | 298 | // NOTE: Widevine CDM is NOT manually bundled 299 | // castlabs v16+ uses Component Updater to automatically download and install CDM 300 | // Manual bundling is not recommended and has legal implications 301 | console.log('\n[EVS] Widevine CDM will be downloaded automatically by Component Updater on first run'); 302 | } 303 | 304 | // Allow running this script directly for verification 305 | if (require.main === module) { 306 | const args = process.argv.slice(2); 307 | 308 | if (args.includes('--verify') || args.includes('-v')) { 309 | // Verification mode 310 | const success = verifyEnvironment(); 311 | process.exit(success ? 0 : 1); 312 | } else if (args.includes('--help') || args.includes('-h')) { 313 | // Help mode 314 | console.log('\nEVS Signing Script'); 315 | console.log('=================='); 316 | console.log('\nUsage:'); 317 | console.log(' node scripts/evs-sign.js --verify Verify EVS environment'); 318 | console.log(' node scripts/evs-sign.js --help Show this help'); 319 | console.log(' node scripts/evs-sign.js Sign application package'); 320 | console.log('\n'); 321 | } else if (args.length > 0) { 322 | // Manual signing mode 323 | const appOutDir = args[0]; 324 | 325 | if (!fs.existsSync(appOutDir)) { 326 | console.error('[EVS] Error: Directory not found:', appOutDir); 327 | process.exit(1); 328 | } 329 | 330 | console.log('\n[EVS] Manual signing mode'); 331 | console.log('[EVS] App directory:', appOutDir); 332 | 333 | if (!verifyEnvironment()) { 334 | process.exit(1); 335 | } 336 | 337 | const success = signPackage(appOutDir, false); 338 | process.exit(success ? 0 : 1); 339 | } else { 340 | console.error('\nError: Missing arguments'); 341 | console.error('Run: node scripts/evs-sign.js --help\n'); 342 | process.exit(1); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /apps/browser/src/main/window-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Window management functionality 3 | */ 4 | 5 | import { BrowserWindow, screen, app } from "electron"; 6 | import path from "path"; 7 | import { AppState } from "./types"; 8 | import { 9 | IPHONE_WIDTH, 10 | IPHONE_HEIGHT, 11 | FRAME_PADDING, 12 | TOP_BAR_HEIGHT, 13 | STATUS_BAR_HEIGHT, 14 | STATUS_BAR_WIDTH, 15 | } from "./constants"; 16 | import { logSecurityEvent } from "./security"; 17 | import { TabManager } from "./tab-manager"; 18 | 19 | export class WindowManager { 20 | private state: AppState; 21 | private tabManager: TabManager; 22 | 23 | constructor(state: AppState, tabManager: TabManager) { 24 | this.state = state; 25 | this.tabManager = tabManager; 26 | } 27 | 28 | /** 29 | * Get window dimensions based on orientation 30 | */ 31 | getWindowDimensions() { 32 | if (this.state.isLandscape) { 33 | return { 34 | width: IPHONE_HEIGHT + FRAME_PADDING, 35 | height: IPHONE_WIDTH + FRAME_PADDING + TOP_BAR_HEIGHT, 36 | }; 37 | } else { 38 | return { 39 | width: IPHONE_WIDTH + FRAME_PADDING, 40 | height: IPHONE_HEIGHT + FRAME_PADDING + TOP_BAR_HEIGHT, 41 | }; 42 | } 43 | } 44 | 45 | /** 46 | * Update WebContentsView bounds based on current window size 47 | */ 48 | updateWebContentsViewBounds(): void { 49 | if (!this.state.webContentsView || !this.state.mainWindow) return; 50 | 51 | // Check if active tab is in fullscreen mode (Plan 1.5) 52 | const activeTab = this.state.tabs.find((t) => t.id === this.state.activeTabId); 53 | if (activeTab?.isFullscreen) { 54 | // In fullscreen mode, hide status bar and add gaps to keep within device frame 55 | const windowBounds = this.state.mainWindow.getBounds(); 56 | const topBarHeight = TOP_BAR_HEIGHT; 57 | const deviceFramePadding = FRAME_PADDING / 2; 58 | const fullscreenGapHorizontal = 57; // Match tab-manager 59 | const fullscreenGapVertical = 67; // Match tab-manager 60 | 61 | if (this.state.isLandscape) { 62 | // Landscape: gap on left and right to avoid rounded corners 63 | const bounds = { 64 | x: fullscreenGapHorizontal - 30, 65 | y: topBarHeight + deviceFramePadding, 66 | width: windowBounds.width - fullscreenGapHorizontal * 2, 67 | height: windowBounds.height - topBarHeight - deviceFramePadding * 2, 68 | }; 69 | activeTab.view.setBounds(bounds); 70 | } else { 71 | // Portrait: gap on top and bottom to avoid rounded corners 72 | const bounds = { 73 | x: deviceFramePadding, 74 | y: topBarHeight + fullscreenGapVertical - 30, 75 | width: windowBounds.width - deviceFramePadding * 2, 76 | height: windowBounds.height - topBarHeight - fullscreenGapVertical - fullscreenGapVertical, 77 | }; 78 | activeTab.view.setBounds(bounds); 79 | } 80 | 81 | // Notify renderer to hide status bar in fullscreen mode 82 | this.state.mainWindow.webContents.send("fullscreen-mode-changed", true); 83 | return; 84 | } 85 | 86 | // Not in fullscreen - show status bar 87 | this.state.mainWindow.webContents.send("fullscreen-mode-changed", false); 88 | 89 | const bounds = this.state.mainWindow.getBounds(); 90 | const dimensions = this.getWindowDimensions(); 91 | 92 | // Calculate scale factor 93 | const scaleX = bounds.width / dimensions.width; 94 | const scaleY = bounds.height / dimensions.height; 95 | 96 | if (this.state.isLandscape) { 97 | const statusBarWidth = STATUS_BAR_WIDTH * scaleX; 98 | const frameTop = (FRAME_PADDING / 2) * scaleY; 99 | const frameBottom = (FRAME_PADDING / 2) * scaleY; 100 | const frameRight = (FRAME_PADDING / 2) * scaleX; 101 | const topBarHeight = TOP_BAR_HEIGHT * scaleY; 102 | 103 | this.state.webContentsView.setBounds({ 104 | x: Math.round(statusBarWidth), 105 | y: Math.round(topBarHeight + frameTop), 106 | width: Math.round(bounds.width - statusBarWidth - frameRight), 107 | height: Math.round(bounds.height - topBarHeight - frameTop - frameBottom), 108 | }); 109 | } else { 110 | const statusBarHeight = STATUS_BAR_HEIGHT * scaleY; 111 | const frameTop = (FRAME_PADDING / 2) * scaleY; 112 | const frameBottom = (FRAME_PADDING / 2) * scaleY; 113 | const frameLeft = (FRAME_PADDING / 2) * scaleX; 114 | const frameRight = (FRAME_PADDING / 2) * scaleX; 115 | const topBarHeight = TOP_BAR_HEIGHT * scaleY; 116 | 117 | this.state.webContentsView.setBounds({ 118 | x: Math.round(frameLeft), 119 | y: Math.round(topBarHeight + statusBarHeight + frameTop), 120 | width: Math.round(bounds.width - frameLeft - frameRight), 121 | height: Math.round( 122 | bounds.height - topBarHeight - statusBarHeight - frameTop - frameBottom 123 | ), 124 | }); 125 | } 126 | } 127 | 128 | /** 129 | * Toggle orientation between portrait and landscape 130 | */ 131 | toggleOrientation(): string { 132 | this.state.isLandscape = !this.state.isLandscape; 133 | 134 | if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { 135 | const dimensions = this.getWindowDimensions(); 136 | 137 | // Get current window bounds 138 | const currentBounds = this.state.mainWindow.getBounds(); 139 | 140 | // Calculate new bounds maintaining the center position 141 | const newBounds = { 142 | x: currentBounds.x + (currentBounds.width - dimensions.width) / 2, 143 | y: currentBounds.y + (currentBounds.height - dimensions.height) / 2, 144 | width: dimensions.width, 145 | height: dimensions.height, 146 | }; 147 | 148 | this.state.mainWindow.setBounds(newBounds); 149 | 150 | const orientation = this.state.isLandscape ? "landscape" : "portrait"; 151 | 152 | // Notify renderer about orientation change 153 | this.state.mainWindow.webContents.send("orientation-changed", orientation); 154 | 155 | // Notify all WebContentsViews (tabs) about orientation change 156 | this.state.tabs.forEach((tab) => { 157 | if (!tab.view.webContents.isDestroyed()) { 158 | tab.view.webContents.send("orientation-changed", orientation); 159 | } 160 | }); 161 | } 162 | 163 | return this.state.isLandscape ? "landscape" : "portrait"; 164 | } 165 | 166 | /** 167 | * Create the main browser window 168 | */ 169 | createWindow(): void { 170 | const dimensions = this.getWindowDimensions(); 171 | 172 | this.state.mainWindow = new BrowserWindow({ 173 | width: dimensions.width, 174 | height: dimensions.height, 175 | minWidth: 300, 176 | minHeight: 400, 177 | webPreferences: { 178 | preload: path.join(__dirname, "..", "preload.js"), 179 | nodeIntegration: false, 180 | contextIsolation: true, 181 | }, 182 | transparent: true, 183 | frame: false, 184 | hasShadow: false, 185 | backgroundColor: "#00000000", 186 | roundedCorners: true, 187 | resizable: true, 188 | fullscreenable: false, // Prevent window from going fullscreen (Plan 1.5) 189 | }); 190 | 191 | // Prevent window from entering fullscreen when HTML fullscreen is requested 192 | this.state.mainWindow.on("enter-full-screen", () => { 193 | console.log("[Window] Preventing window fullscreen"); 194 | this.state.mainWindow?.setFullScreen(false); 195 | }); 196 | 197 | // Enable swipe navigation gestures on macOS 198 | if (process.platform === "darwin") { 199 | this.state.mainWindow.on("swipe", (event, direction) => { 200 | if (direction === "left") { 201 | this.state.mainWindow?.webContents.send("navigate-forward"); 202 | } else if (direction === "right") { 203 | this.state.mainWindow?.webContents.send("navigate-back"); 204 | } 205 | }); 206 | } 207 | 208 | // Alternative: Listen for app-command events 209 | this.state.mainWindow.on("app-command", (event, command) => { 210 | if (command === "browser-backward") { 211 | this.state.mainWindow?.webContents.send("navigate-back"); 212 | } else if (command === "browser-forward") { 213 | this.state.mainWindow?.webContents.send("navigate-forward"); 214 | } 215 | }); 216 | 217 | // Register local keyboard shortcuts (only work when window is focused) 218 | this.registerLocalShortcuts(); 219 | 220 | // Create initial blank tab with start page 221 | const initialTab = this.tabManager.createTab(""); 222 | this.tabManager.switchToTab(initialTab.id); 223 | 224 | // Set permission request handler 225 | if (this.state.webContentsView) { 226 | this.state.webContentsView.webContents.session.setPermissionRequestHandler( 227 | (webContents, permission, callback) => { 228 | const allowedPermissions = [ 229 | "clipboard-read", 230 | "clipboard-write", 231 | "media", 232 | "fullscreen", // Allow fullscreen - handled by Electron native events 233 | ]; 234 | 235 | if (allowedPermissions.includes(permission)) { 236 | logSecurityEvent(`Permission granted: ${permission}`); 237 | callback(true); 238 | } else { 239 | logSecurityEvent(`Permission denied: ${permission}`); 240 | callback(false); 241 | } 242 | } 243 | ); 244 | } 245 | 246 | // Set security headers 247 | this.setupSecurityHeaders(); 248 | 249 | // Load the main UI 250 | if (process.env.NODE_ENV === "development") { 251 | this.state.mainWindow.loadURL("http://localhost:5173"); 252 | } else { 253 | // In production, use app.getAppPath() to get the correct base path 254 | // Files are at: app.asar/dist-renderer/index.html 255 | const rendererPath = path.join(app.getAppPath(), "dist-renderer", "index.html"); 256 | console.log(`[WindowManager] Loading renderer from: ${rendererPath}`); 257 | this.state.mainWindow.loadFile(rendererPath); 258 | } 259 | 260 | // Maintain aspect ratio on resize 261 | this.state.mainWindow.on("will-resize", (event, newBounds) => { 262 | const dimensions = this.getWindowDimensions(); 263 | const aspectRatio = dimensions.width / dimensions.height; 264 | 265 | // Get screen dimensions 266 | const display = screen.getDisplayNearestPoint({ 267 | x: newBounds.x, 268 | y: newBounds.y, 269 | }); 270 | const workArea = display.workArea; 271 | 272 | // Calculate new dimensions while maintaining aspect ratio 273 | let newWidth = newBounds.width; 274 | let newHeight = Math.round(newWidth / aspectRatio); 275 | 276 | // Limit to screen size with some padding 277 | const maxWidth = workArea.width - 20; 278 | const maxHeight = workArea.height - 20; 279 | 280 | if (newWidth > maxWidth) { 281 | newWidth = maxWidth; 282 | newHeight = Math.round(newWidth / aspectRatio); 283 | } 284 | 285 | if (newHeight > maxHeight) { 286 | newHeight = maxHeight; 287 | newWidth = Math.round(newHeight * aspectRatio); 288 | } 289 | 290 | event.preventDefault(); 291 | this.state.mainWindow?.setBounds({ 292 | ...newBounds, 293 | width: newWidth, 294 | height: newHeight, 295 | }); 296 | }); 297 | } 298 | 299 | /** 300 | * Register local keyboard shortcuts (only active when window is focused) 301 | */ 302 | private registerLocalShortcuts(): void { 303 | if (!this.state.mainWindow) return; 304 | 305 | this.state.mainWindow.webContents.on("before-input-event", (event, input) => { 306 | // Only handle keyboard events 307 | if (input.type !== "keyDown") return; 308 | 309 | const isMac = process.platform === "darwin"; 310 | const modifierKey = isMac ? input.meta : input.control; 311 | 312 | // Cmd+W / Ctrl+W to hide window 313 | if (modifierKey && input.key.toLowerCase() === "w" && !input.shift && !input.alt) { 314 | event.preventDefault(); 315 | if (this.state.mainWindow && this.state.tray) { 316 | this.state.mainWindow.hide(); 317 | } 318 | return; 319 | } 320 | 321 | // Cmd+R / Ctrl+R to reload 322 | if (modifierKey && input.key.toLowerCase() === "r" && !input.shift && !input.alt) { 323 | event.preventDefault(); 324 | if (this.state.mainWindow && this.state.mainWindow.webContents) { 325 | this.state.mainWindow.webContents.send("webview-reload"); 326 | } 327 | return; 328 | } 329 | 330 | // F5 to reload 331 | if (input.key === "F5" && !modifierKey && !input.shift && !input.alt) { 332 | event.preventDefault(); 333 | if (this.state.mainWindow && this.state.mainWindow.webContents) { 334 | this.state.mainWindow.webContents.send("webview-reload"); 335 | } 336 | return; 337 | } 338 | 339 | // Cmd+Shift+R / Ctrl+Shift+R (hard reload) 340 | if (modifierKey && input.shift && input.key.toLowerCase() === "r" && !input.alt) { 341 | event.preventDefault(); 342 | if (this.state.mainWindow && this.state.mainWindow.webContents) { 343 | this.state.mainWindow.webContents.send("webview-reload"); 344 | } 345 | return; 346 | } 347 | 348 | // Cmd+Shift+I / Ctrl+Shift+I (DevTools) 349 | if (modifierKey && input.shift && input.key.toLowerCase() === "i" && !input.alt) { 350 | event.preventDefault(); 351 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 352 | if (this.state.webContentsView.webContents.isDevToolsOpened()) { 353 | this.state.webContentsView.webContents.closeDevTools(); 354 | } else { 355 | this.state.webContentsView.webContents.openDevTools({ mode: "detach" }); 356 | } 357 | } 358 | return; 359 | } 360 | 361 | // ESC key to exit fullscreen (Plan 1.5) 362 | if (input.key === "Escape" && !modifierKey && !input.shift && !input.alt) { 363 | if (this.state.activeTabId) { 364 | const activeTab = this.state.tabs.find((t) => t.id === this.state.activeTabId); 365 | if (activeTab?.isFullscreen) { 366 | event.preventDefault(); 367 | this.tabManager.exitFullscreen(this.state.activeTabId); 368 | return; 369 | } 370 | } 371 | } 372 | }); 373 | } 374 | 375 | /** 376 | * Setup security headers for the main window 377 | */ 378 | private setupSecurityHeaders(): void { 379 | if (!this.state.mainWindow) return; 380 | 381 | this.state.mainWindow.webContents.session.webRequest.onHeadersReceived( 382 | (details, callback) => { 383 | const isMainWindowResource = 384 | details.url.includes("localhost:5173") || 385 | details.url.startsWith("file://") || 386 | details.url.includes("dist-renderer"); 387 | 388 | if (!isMainWindowResource) { 389 | callback({ responseHeaders: details.responseHeaders }); 390 | return; 391 | } 392 | 393 | const isDevelopment = process.env.NODE_ENV === "development"; 394 | 395 | callback({ 396 | responseHeaders: { 397 | ...details.responseHeaders, 398 | "Content-Security-Policy": [ 399 | isDevelopment 400 | ? "default-src 'self'; " + 401 | "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + 402 | "style-src 'self' 'unsafe-inline'; " + 403 | "img-src 'self' data: https:; " + 404 | "connect-src 'self' http://localhost:* ws://localhost:* https:; " + 405 | "font-src 'self' data:; " + 406 | "object-src 'none'; " + 407 | "base-uri 'self'; " + 408 | "form-action 'self';" 409 | : "default-src 'self'; " + 410 | "script-src 'self'; " + 411 | "style-src 'self'; " + 412 | "img-src 'self' data: https:; " + 413 | "connect-src 'self' http://localhost:* ws://localhost:* https:; " + 414 | "font-src 'self' data:; " + 415 | "object-src 'none'; " + 416 | "base-uri 'self'; " + 417 | "form-action 'self';", 418 | ], 419 | "X-Content-Type-Options": ["nosniff"], 420 | "X-Frame-Options": ["DENY"], 421 | "X-XSS-Protection": ["1; mode=block"], 422 | "Referrer-Policy": ["strict-origin-when-cross-origin"], 423 | "Permissions-Policy": [ 424 | "geolocation=(), microphone=(), camera=(), payment=(), usb=()", 425 | ], 426 | }, 427 | }); 428 | } 429 | ); 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /apps/browser/src/main/ipc-handlers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * IPC handlers for communication between main and renderer processes 3 | */ 4 | 5 | import { ipcMain, app, nativeTheme } from "electron"; 6 | import { AppState } from "./types"; 7 | import { TabManager } from "./tab-manager"; 8 | import { WindowManager } from "./window-manager"; 9 | import { BookmarkManager } from "./bookmark-manager"; 10 | import { FaviconCache } from "./favicon-cache"; 11 | import { isValidUrl, sanitizeUrl, getUserAgentForUrl, logSecurityEvent } from "./security"; 12 | import { ThemeColorCache } from "./theme-cache"; 13 | 14 | export class IPCHandlers { 15 | private state: AppState; 16 | private tabManager: TabManager; 17 | private windowManager: WindowManager; 18 | private bookmarkManager: BookmarkManager; 19 | private faviconCache: FaviconCache; 20 | private themeColorCache: ThemeColorCache; 21 | 22 | constructor( 23 | state: AppState, 24 | tabManager: TabManager, 25 | windowManager: WindowManager, 26 | bookmarkManager: BookmarkManager, 27 | faviconCache: FaviconCache, 28 | themeColorCache: ThemeColorCache 29 | ) { 30 | this.state = state; 31 | this.tabManager = tabManager; 32 | this.windowManager = windowManager; 33 | this.bookmarkManager = bookmarkManager; 34 | this.faviconCache = faviconCache; 35 | this.themeColorCache = themeColorCache; 36 | } 37 | 38 | /** 39 | * Register all IPC handlers 40 | */ 41 | registerHandlers(): void { 42 | this.registerWindowHandlers(); 43 | this.registerTabHandlers(); 44 | this.registerWebContentsHandlers(); 45 | this.registerThemeHandlers(); 46 | this.registerOrientationHandlers(); 47 | this.registerAppHandlers(); 48 | this.registerBookmarkHandlers(); 49 | this.registerFaviconHandlers(); 50 | } 51 | 52 | /** 53 | * Register window control handlers 54 | */ 55 | private registerWindowHandlers(): void { 56 | ipcMain.on("window-close", () => { 57 | if (this.state.mainWindow && this.state.tray) { 58 | this.state.mainWindow.hide(); 59 | } else { 60 | app.quit(); 61 | } 62 | }); 63 | 64 | ipcMain.on("window-minimize", () => { 65 | if (this.state.mainWindow) { 66 | this.state.mainWindow.minimize(); 67 | } 68 | }); 69 | 70 | ipcMain.on("window-maximize", () => { 71 | if (this.state.mainWindow) { 72 | if (this.state.mainWindow.isMaximized()) { 73 | this.state.mainWindow.unmaximize(); 74 | } else { 75 | this.state.mainWindow.maximize(); 76 | } 77 | } 78 | }); 79 | 80 | ipcMain.on("open-webview-devtools", (event) => { 81 | if (event.sender !== this.state.mainWindow?.webContents) { 82 | logSecurityEvent("Unauthorized DevTools access attempt"); 83 | return; 84 | } 85 | 86 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 87 | this.state.webContentsView.webContents.openDevTools({ mode: "detach" }); 88 | } 89 | }); 90 | } 91 | 92 | /** 93 | * Register tab management handlers 94 | */ 95 | private registerTabHandlers(): void { 96 | ipcMain.handle("tabs-get-all", (event) => { 97 | if (event.sender !== this.state.mainWindow?.webContents) { 98 | logSecurityEvent("Unauthorized IPC call to tabs-get-all"); 99 | return { tabs: [], activeTabId: null }; 100 | } 101 | 102 | return { 103 | tabs: this.state.tabs.map((t) => ({ 104 | id: t.id, 105 | title: t.title, 106 | url: t.url, 107 | preview: t.preview, 108 | })), 109 | activeTabId: this.state.activeTabId, 110 | }; 111 | }); 112 | 113 | ipcMain.handle("tabs-create", (event, url?: string) => { 114 | if (event.sender !== this.state.mainWindow?.webContents) { 115 | logSecurityEvent("Unauthorized IPC call to tabs-create"); 116 | throw new Error("Unauthorized"); 117 | } 118 | 119 | const newTab = this.tabManager.createTab(url); 120 | this.tabManager.switchToTab(newTab.id); 121 | 122 | return { 123 | id: newTab.id, 124 | title: newTab.title, 125 | url: newTab.url, 126 | }; 127 | }); 128 | 129 | ipcMain.handle("tabs-switch", (event, tabId: string) => { 130 | if (event.sender !== this.state.mainWindow?.webContents) { 131 | logSecurityEvent("Unauthorized IPC call to tabs-switch"); 132 | throw new Error("Unauthorized"); 133 | } 134 | 135 | this.tabManager.switchToTab(tabId); 136 | }); 137 | 138 | ipcMain.handle("tabs-close", (event, tabId: string) => { 139 | if (event.sender !== this.state.mainWindow?.webContents) { 140 | logSecurityEvent("Unauthorized IPC call to tabs-close"); 141 | throw new Error("Unauthorized"); 142 | } 143 | 144 | this.tabManager.closeTab(tabId); 145 | }); 146 | 147 | ipcMain.handle("tabs-close-all", (event) => { 148 | if (event.sender !== this.state.mainWindow?.webContents) { 149 | logSecurityEvent("Unauthorized IPC call to tabs-close-all"); 150 | throw new Error("Unauthorized"); 151 | } 152 | 153 | this.tabManager.closeAllTabs(); 154 | }); 155 | } 156 | 157 | /** 158 | * Register WebContents control handlers 159 | */ 160 | private registerWebContentsHandlers(): void { 161 | ipcMain.handle("webcontents-set-visible", (event, visible: boolean) => { 162 | if (event.sender !== this.state.mainWindow?.webContents) { 163 | logSecurityEvent("Unauthorized IPC call to webcontents-set-visible"); 164 | throw new Error("Unauthorized"); 165 | } 166 | 167 | if (this.state.webContentsView && this.state.mainWindow) { 168 | if (visible) { 169 | if (!this.state.mainWindow.contentView.children.includes(this.state.webContentsView)) { 170 | this.state.mainWindow.contentView.addChildView(this.state.webContentsView); 171 | } 172 | } else { 173 | if (this.state.mainWindow.contentView.children.includes(this.state.webContentsView)) { 174 | this.state.mainWindow.contentView.removeChildView(this.state.webContentsView); 175 | } 176 | } 177 | } 178 | }); 179 | 180 | ipcMain.handle("webcontents-load-url", (event, url: string) => { 181 | if (event.sender !== this.state.mainWindow?.webContents) { 182 | logSecurityEvent("Unauthorized IPC call to webcontents-load-url"); 183 | throw new Error("Unauthorized"); 184 | } 185 | 186 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 187 | const sanitized = sanitizeUrl(url); 188 | if (isValidUrl(sanitized)) { 189 | const userAgent = getUserAgentForUrl(sanitized); 190 | this.state.webContentsView.webContents.setUserAgent(userAgent); 191 | this.state.webContentsView.webContents.loadURL(sanitized); 192 | } else { 193 | logSecurityEvent(`Rejected invalid URL`, { url }); 194 | throw new Error(`Invalid URL`); 195 | } 196 | } 197 | }); 198 | 199 | ipcMain.handle("webcontents-go-back", (event) => { 200 | if (event.sender !== this.state.mainWindow?.webContents) { 201 | logSecurityEvent("Unauthorized IPC call to webcontents-go-back"); 202 | throw new Error("Unauthorized"); 203 | } 204 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 205 | this.state.webContentsView.webContents.navigationHistory.goBack(); 206 | } 207 | }); 208 | 209 | ipcMain.handle("webcontents-go-forward", (event) => { 210 | if (event.sender !== this.state.mainWindow?.webContents) { 211 | logSecurityEvent("Unauthorized IPC call to webcontents-go-forward"); 212 | throw new Error("Unauthorized"); 213 | } 214 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 215 | this.state.webContentsView.webContents.navigationHistory.goForward(); 216 | } 217 | }); 218 | 219 | ipcMain.handle("webcontents-reload", (event) => { 220 | if (event.sender !== this.state.mainWindow?.webContents) { 221 | logSecurityEvent("Unauthorized IPC call to webcontents-reload"); 222 | throw new Error("Unauthorized"); 223 | } 224 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 225 | this.state.webContentsView.webContents.reload(); 226 | } 227 | }); 228 | 229 | ipcMain.handle("webcontents-can-go-back", (event) => { 230 | if (event.sender !== this.state.mainWindow?.webContents) { 231 | logSecurityEvent("Unauthorized IPC call to webcontents-can-go-back"); 232 | return false; 233 | } 234 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 235 | return this.state.webContentsView.webContents.navigationHistory.canGoBack(); 236 | } 237 | return false; 238 | }); 239 | 240 | ipcMain.handle("webcontents-can-go-forward", (event) => { 241 | if (event.sender !== this.state.mainWindow?.webContents) { 242 | logSecurityEvent("Unauthorized IPC call to webcontents-can-go-forward"); 243 | return false; 244 | } 245 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 246 | return this.state.webContentsView.webContents.navigationHistory.canGoForward(); 247 | } 248 | return false; 249 | }); 250 | 251 | ipcMain.handle("webcontents-get-url", (event) => { 252 | if (event.sender !== this.state.mainWindow?.webContents) { 253 | logSecurityEvent("Unauthorized IPC call to webcontents-get-url"); 254 | return ""; 255 | } 256 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 257 | const url = this.state.webContentsView.webContents.getURL(); 258 | console.log("[IPC] webcontents-get-url raw URL:", url); 259 | // Return "/" for blank-page and error-page 260 | if (url.includes("blank-page-tab-") || url.includes("error-page-tab-")) { 261 | console.log("[IPC] Returning / for temporary file"); 262 | return "/"; 263 | } 264 | console.log("[IPC] Returning original URL:", url); 265 | return url; 266 | } 267 | return ""; 268 | }); 269 | 270 | ipcMain.handle("webcontents-get-title", (event) => { 271 | if (event.sender !== this.state.mainWindow?.webContents) { 272 | logSecurityEvent("Unauthorized IPC call to webcontents-get-title"); 273 | return ""; 274 | } 275 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 276 | const url = this.state.webContentsView.webContents.getURL(); 277 | // Return "Blank Page" for blank-page 278 | if (url.includes("blank-page-tab-")) { 279 | return "Blank Page"; 280 | } 281 | // Return actual title for error-page (it's set in the HTML) 282 | if (url.includes("error-page-tab-")) { 283 | return this.state.webContentsView.webContents.getTitle(); 284 | } 285 | return this.state.webContentsView.webContents.getTitle(); 286 | } 287 | return ""; 288 | }); 289 | 290 | ipcMain.handle( 291 | "webcontents-set-bounds", 292 | (event, bounds: { x: number; y: number; width: number; height: number }) => { 293 | if (event.sender !== this.state.mainWindow?.webContents) { 294 | logSecurityEvent("Unauthorized IPC call to webcontents-set-bounds"); 295 | throw new Error("Unauthorized"); 296 | } 297 | if (this.state.webContentsView) { 298 | this.state.webContentsView.setBounds(bounds); 299 | } 300 | } 301 | ); 302 | 303 | // Handle navigation gestures from WebContentsView 304 | ipcMain.on("webview-navigate-back", (event) => { 305 | if (this.state.webContentsView && event.sender === this.state.webContentsView.webContents) { 306 | if (this.state.webContentsView.webContents.navigationHistory.canGoBack()) { 307 | this.state.webContentsView.webContents.navigationHistory.goBack(); 308 | } 309 | } 310 | }); 311 | 312 | ipcMain.on("webview-navigate-forward", (event) => { 313 | if (this.state.webContentsView && event.sender === this.state.webContentsView.webContents) { 314 | if (this.state.webContentsView.webContents.navigationHistory.canGoForward()) { 315 | this.state.webContentsView.webContents.navigationHistory.goForward(); 316 | } 317 | } 318 | }); 319 | } 320 | 321 | /** 322 | * Register theme-related handlers 323 | */ 324 | private registerThemeHandlers(): void { 325 | ipcMain.handle("get-system-theme", () => { 326 | return nativeTheme.shouldUseDarkColors ? "dark" : "light"; 327 | }); 328 | 329 | ipcMain.handle("webcontents-get-theme-color", async (event) => { 330 | if (event.sender !== this.state.mainWindow?.webContents) { 331 | logSecurityEvent("Unauthorized IPC call to webcontents-get-theme-color"); 332 | return null; 333 | } 334 | return this.state.latestThemeColor; 335 | }); 336 | 337 | // Receive theme color from webview preload script 338 | ipcMain.on( 339 | "webview-theme-color-extracted", 340 | (event, data: { themeColor: string; domain: string }) => { 341 | if (this.state.webContentsView && event.sender === this.state.webContentsView.webContents) { 342 | const { themeColor, domain } = data; 343 | this.state.latestThemeColor = themeColor; 344 | 345 | if (domain && themeColor) { 346 | this.themeColorCache.set(domain, themeColor); 347 | } 348 | 349 | if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { 350 | this.state.mainWindow.webContents.send( 351 | "webcontents-theme-color-updated", 352 | themeColor 353 | ); 354 | } 355 | } 356 | } 357 | ); 358 | 359 | // Listen for system theme changes 360 | nativeTheme.on("updated", () => { 361 | const theme = nativeTheme.shouldUseDarkColors ? "dark" : "light"; 362 | if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { 363 | this.state.mainWindow.webContents.send("theme-changed", theme); 364 | } 365 | }); 366 | } 367 | 368 | /** 369 | * Register orientation handlers 370 | */ 371 | private registerOrientationHandlers(): void { 372 | ipcMain.handle("get-orientation", () => { 373 | return this.state.isLandscape ? "landscape" : "portrait"; 374 | }); 375 | 376 | ipcMain.handle("toggle-orientation", () => { 377 | return this.windowManager.toggleOrientation(); 378 | }); 379 | } 380 | 381 | /** 382 | * Register app-related handlers 383 | */ 384 | private registerAppHandlers(): void { 385 | ipcMain.handle("get-app-version", () => { 386 | return app.getVersion(); 387 | }); 388 | } 389 | 390 | /** 391 | * Notify all windows about bookmark updates 392 | */ 393 | private notifyBookmarkUpdate(): void { 394 | // Notify main window 395 | if (this.state.mainWindow && !this.state.mainWindow.isDestroyed()) { 396 | this.state.mainWindow.webContents.send("bookmarks-updated"); 397 | } 398 | 399 | // Notify WebContentsView 400 | if (this.state.webContentsView && !this.state.webContentsView.webContents.isDestroyed()) { 401 | this.state.webContentsView.webContents.send("bookmarks-updated"); 402 | } 403 | } 404 | 405 | /** 406 | * Register bookmark management handlers 407 | */ 408 | private registerBookmarkHandlers(): void { 409 | // Get all bookmarks 410 | ipcMain.handle("bookmarks-get-all", () => { 411 | return this.bookmarkManager.getAll(); 412 | }); 413 | 414 | // Get bookmark by ID 415 | ipcMain.handle("bookmarks-get-by-id", (_event, id: string) => { 416 | return this.bookmarkManager.getById(id); 417 | }); 418 | 419 | // Check if URL is bookmarked 420 | ipcMain.handle("bookmarks-is-bookmarked", (_event, url: string) => { 421 | return this.bookmarkManager.isBookmarked(url); 422 | }); 423 | 424 | // Add bookmark 425 | ipcMain.handle("bookmarks-add", (_event, title: string, url: string, favicon?: string) => { 426 | const bookmark = this.bookmarkManager.add(title, url, favicon); 427 | this.notifyBookmarkUpdate(); 428 | return bookmark; 429 | }); 430 | 431 | // Update bookmark 432 | ipcMain.handle("bookmarks-update", (_event, id: string, updates: any) => { 433 | const bookmark = this.bookmarkManager.update(id, updates); 434 | this.notifyBookmarkUpdate(); 435 | return bookmark; 436 | }); 437 | 438 | // Remove bookmark 439 | ipcMain.handle("bookmarks-remove", (_event, id: string) => { 440 | const result = this.bookmarkManager.remove(id); 441 | this.notifyBookmarkUpdate(); 442 | return result; 443 | }); 444 | 445 | // Remove bookmark by URL 446 | ipcMain.handle("bookmarks-remove-by-url", (_event, url: string) => { 447 | const result = this.bookmarkManager.removeByUrl(url); 448 | this.notifyBookmarkUpdate(); 449 | return result; 450 | }); 451 | 452 | // Clear all bookmarks 453 | ipcMain.handle("bookmarks-clear", () => { 454 | this.bookmarkManager.clear(); 455 | this.notifyBookmarkUpdate(); 456 | }); 457 | } 458 | 459 | /** 460 | * Register favicon cache handlers 461 | */ 462 | private registerFaviconHandlers(): void { 463 | // Get favicon with caching 464 | ipcMain.handle("favicon-get", async (_event, url: string) => { 465 | return this.faviconCache.getFavicon(url); 466 | }); 467 | 468 | // Get favicon with fallback sources 469 | ipcMain.handle("favicon-get-with-fallback", async (_event, pageUrl: string) => { 470 | return this.faviconCache.getFaviconWithFallback(pageUrl); 471 | }); 472 | 473 | // Check if favicon is cached 474 | ipcMain.handle("favicon-is-cached", (_event, url: string) => { 475 | return this.faviconCache.isCached(url); 476 | }); 477 | 478 | // Clear favicon cache 479 | ipcMain.handle("favicon-clear-cache", () => { 480 | this.faviconCache.clearCache(); 481 | }); 482 | 483 | // Get cache size 484 | ipcMain.handle("favicon-get-cache-size", () => { 485 | return this.faviconCache.getCacheSize(); 486 | }); 487 | } 488 | } 489 | --------------------------------------------------------------------------------