experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(web-ext): add bundled web extensions infrastructure

Implement support for bundled web extensions:

1. Ad Blocker (@cliqz/adblocker-electron):
- Native ad blocking using EasyList/EasyPrivacy filter lists
- Runtime enable/disable support
- Blocked request counting
- No webRequest API conflicts with other extensions

2. Chrome Extension Manager:
- Load unpacked Chrome extensions from resources/chrome-extensions/
- Enable/disable per extension (persisted in datastore)
- MV2 and MV3 support via Electron's native API
- Ready for bundling Proton Pass

3. Settings UI:
- New "Privacy" section in Settings
- Toggle for Ad Blocker with blocked count stats
- List of bundled extensions with enable/disable toggles
- Attribution section with license info

4. Infrastructure:
- IPC handlers for all operations
- Preload API (api.adblocker, api.chromeExtensions)
- Database table for extension settings
- Cleanup on app quit

Dependencies added (run yarn install):
- @cliqz/adblocker-electron@^1.27.6
- cross-fetch@^4.0.0

+1091 -2
+208 -1
app/settings/settings.js
··· 587 587 return container; 588 588 }; 589 589 590 + // Render privacy & web extensions settings (adblocker + bundled chrome extensions) 591 + const renderPrivacySettings = async () => { 592 + const container = document.createElement('div'); 593 + 594 + // ========== Ad Blocker Section ========== 595 + const adBlockerSection = document.createElement('div'); 596 + adBlockerSection.className = 'form-section'; 597 + 598 + const adBlockerTitle = document.createElement('h3'); 599 + adBlockerTitle.className = 'form-section-title'; 600 + adBlockerTitle.textContent = 'Ad Blocker'; 601 + adBlockerSection.appendChild(adBlockerTitle); 602 + 603 + const adBlockerDesc = document.createElement('p'); 604 + adBlockerDesc.className = 'help-text'; 605 + adBlockerDesc.style.marginBottom = '12px'; 606 + adBlockerDesc.textContent = 'Block ads and trackers for faster, cleaner browsing. Powered by EasyList and EasyPrivacy filter lists.'; 607 + adBlockerSection.appendChild(adBlockerDesc); 608 + 609 + // Load adblocker status 610 + let adBlockerEnabled = true; 611 + let blockedCount = 0; 612 + 613 + try { 614 + const statusResult = await api.adblocker.getStatus(); 615 + if (statusResult.success && statusResult.data) { 616 + adBlockerEnabled = statusResult.data.enabled; 617 + blockedCount = statusResult.data.blockedCount || 0; 618 + } 619 + } catch (err) { 620 + console.error('[settings] Failed to load adblocker status:', err); 621 + } 622 + 623 + // Stats display 624 + const statsDiv = document.createElement('div'); 625 + statsDiv.className = 'help-text'; 626 + statsDiv.style.marginBottom = '12px'; 627 + statsDiv.innerHTML = `<strong>${blockedCount.toLocaleString()}</strong> requests blocked this session`; 628 + adBlockerSection.appendChild(statsDiv); 629 + 630 + // Toggle checkbox 631 + const adBlockerToggle = createCheckbox('Enable Ad Blocker', adBlockerEnabled, async (newVal) => { 632 + try { 633 + if (newVal) { 634 + await api.adblocker.enable(); 635 + } else { 636 + await api.adblocker.disable(); 637 + } 638 + } catch (err) { 639 + console.error('[settings] Failed to toggle adblocker:', err); 640 + } 641 + }); 642 + adBlockerSection.appendChild(adBlockerToggle); 643 + 644 + container.appendChild(adBlockerSection); 645 + 646 + // ========== Bundled Extensions Section ========== 647 + const chromeExtSection = document.createElement('div'); 648 + chromeExtSection.className = 'form-section'; 649 + chromeExtSection.style.marginTop = '24px'; 650 + 651 + const chromeExtTitle = document.createElement('h3'); 652 + chromeExtTitle.className = 'form-section-title'; 653 + chromeExtTitle.textContent = 'Bundled Extensions'; 654 + chromeExtSection.appendChild(chromeExtTitle); 655 + 656 + const chromeExtDesc = document.createElement('p'); 657 + chromeExtDesc.className = 'help-text'; 658 + chromeExtDesc.style.marginBottom = '12px'; 659 + chromeExtDesc.textContent = 'Chrome extensions bundled with Peek. These run in isolated processes for security.'; 660 + chromeExtSection.appendChild(chromeExtDesc); 661 + 662 + // Load chrome extensions list 663 + const extListDiv = document.createElement('div'); 664 + extListDiv.className = 'extension-list'; 665 + 666 + const refreshExtList = async () => { 667 + extListDiv.innerHTML = ''; 668 + 669 + try { 670 + const result = await api.chromeExtensions.list(); 671 + if (result.success && result.data && result.data.length > 0) { 672 + for (const ext of result.data) { 673 + const extItem = document.createElement('div'); 674 + extItem.className = 'form-group-inline'; 675 + extItem.style.padding = '8px 0'; 676 + extItem.style.borderBottom = '1px solid var(--border-color, #333)'; 677 + 678 + const extInfo = document.createElement('div'); 679 + extInfo.style.flex = '1'; 680 + 681 + const extName = document.createElement('div'); 682 + extName.style.fontWeight = 'bold'; 683 + extName.textContent = ext.name; 684 + extInfo.appendChild(extName); 685 + 686 + if (ext.description) { 687 + const extDescText = document.createElement('div'); 688 + extDescText.className = 'help-text'; 689 + extDescText.style.fontSize = '12px'; 690 + extDescText.textContent = ext.description; 691 + extInfo.appendChild(extDescText); 692 + } 693 + 694 + const extVersion = document.createElement('div'); 695 + extVersion.className = 'help-text'; 696 + extVersion.style.fontSize = '11px'; 697 + extVersion.textContent = `v${ext.version}`; 698 + extInfo.appendChild(extVersion); 699 + 700 + extItem.appendChild(extInfo); 701 + 702 + // Toggle 703 + const toggleWrapper = document.createElement('div'); 704 + toggleWrapper.className = 'checkbox-wrapper'; 705 + 706 + const toggle = document.createElement('input'); 707 + toggle.type = 'checkbox'; 708 + toggle.checked = ext.enabled; 709 + toggle.addEventListener('change', async (e) => { 710 + try { 711 + if (e.target.checked) { 712 + await api.chromeExtensions.enable(ext.id); 713 + } else { 714 + await api.chromeExtensions.disable(ext.id); 715 + } 716 + } catch (err) { 717 + console.error('[settings] Failed to toggle chrome extension:', err); 718 + // Revert toggle 719 + e.target.checked = !e.target.checked; 720 + } 721 + }); 722 + 723 + toggleWrapper.appendChild(toggle); 724 + extItem.appendChild(toggleWrapper); 725 + 726 + extListDiv.appendChild(extItem); 727 + } 728 + } else { 729 + const noExt = document.createElement('p'); 730 + noExt.className = 'help-text'; 731 + noExt.textContent = 'No bundled extensions installed. Add unpacked extensions to resources/chrome-extensions/'; 732 + extListDiv.appendChild(noExt); 733 + } 734 + } catch (err) { 735 + console.error('[settings] Failed to load chrome extensions:', err); 736 + const errorMsg = document.createElement('p'); 737 + errorMsg.className = 'help-text'; 738 + errorMsg.style.color = 'var(--error-color, #f44)'; 739 + errorMsg.textContent = 'Failed to load extensions: ' + err.message; 740 + extListDiv.appendChild(errorMsg); 741 + } 742 + }; 743 + 744 + await refreshExtList(); 745 + chromeExtSection.appendChild(extListDiv); 746 + 747 + container.appendChild(chromeExtSection); 748 + 749 + // ========== Attribution Section ========== 750 + const attrSection = document.createElement('div'); 751 + attrSection.className = 'form-section'; 752 + attrSection.style.marginTop = '24px'; 753 + 754 + const attrTitle = document.createElement('h3'); 755 + attrTitle.className = 'form-section-title'; 756 + attrTitle.textContent = 'Attribution'; 757 + attrSection.appendChild(attrTitle); 758 + 759 + const attrText = document.createElement('p'); 760 + attrText.className = 'help-text'; 761 + attrText.innerHTML = ` 762 + Ad blocking powered by <a href="https://github.com/nickshanks/adblocker" target="_blank" style="color: var(--link-color, #88f);">@cliqz/adblocker</a> (MPL-2.0).<br> 763 + Filter lists: <a href="https://easylist.to/" target="_blank" style="color: var(--link-color, #88f);">EasyList</a> and <a href="https://easylist.to/easylist/easyprivacy.txt" target="_blank" style="color: var(--link-color, #88f);">EasyPrivacy</a>. 764 + `; 765 + attrSection.appendChild(attrText); 766 + 767 + container.appendChild(attrSection); 768 + 769 + return container; 770 + }; 771 + 590 772 // Render sync settings 591 773 const renderSyncSettings = async () => { 592 774 const container = document.createElement('div'); ··· 2233 2415 } 2234 2416 }, api.scopes.GLOBAL); 2235 2417 2236 - // Add Sync management section (between Extensions and Themes) 2418 + // Add Privacy section (adblocker + bundled chrome extensions) 2419 + const privacyNav = document.createElement('a'); 2420 + privacyNav.className = 'nav-item'; 2421 + privacyNav.textContent = 'Privacy'; 2422 + privacyNav.dataset.section = 'privacy'; 2423 + privacyNav.addEventListener('click', () => showSection('privacy')); 2424 + sidebarNav.appendChild(privacyNav); 2425 + 2426 + // Create privacy section with async content 2427 + const privacySection = document.createElement('div'); 2428 + privacySection.className = 'section'; 2429 + privacySection.id = 'section-privacy'; 2430 + 2431 + const privacyTitle = document.createElement('h2'); 2432 + privacyTitle.className = 'section-title'; 2433 + privacyTitle.textContent = 'Privacy'; 2434 + privacySection.appendChild(privacyTitle); 2435 + 2436 + // Load privacy content async 2437 + renderPrivacySettings().then(content => { 2438 + privacySection.appendChild(content); 2439 + }); 2440 + 2441 + contentArea.appendChild(privacySection); 2442 + 2443 + // Add Sync management section (between Privacy and Themes) 2237 2444 const syncNav = document.createElement('a'); 2238 2445 syncNav.className = 'nav-item'; 2239 2446 syncNav.textContent = 'Sync';
+203
backend/electron/adblocker.ts
··· 1 + /** 2 + * Ad Blocker Module for Electron 3 + * 4 + * Provides native ad blocking using @cliqz/adblocker-electron. 5 + * This approach avoids webRequest API conflicts with browser extensions. 6 + * 7 + * Features: 8 + * - Pre-built filter lists for ads and trackers (EasyList, EasyPrivacy) 9 + * - Runtime enable/disable support 10 + * - Configurable via settings 11 + * - Attaches to Electron sessions 12 + */ 13 + 14 + import { session, Session } from 'electron'; 15 + import { ElectronBlocker, Request } from '@cliqz/adblocker-electron'; 16 + import fetch from 'cross-fetch'; 17 + 18 + const DEBUG = !!process.env.DEBUG; 19 + 20 + // Module state 21 + let blocker: ElectronBlocker | null = null; 22 + let isEnabled = false; 23 + const attachedSessions: Set<Session> = new Set(); 24 + 25 + // Stats tracking 26 + let blockedCount = 0; 27 + 28 + /** 29 + * Adblocker configuration options 30 + */ 31 + export interface AdblockerConfig { 32 + /** Whether ad blocking is enabled */ 33 + enabled: boolean; 34 + } 35 + 36 + /** 37 + * Initialize the adblocker engine 38 + * Downloads and compiles filter lists on first run, then caches for subsequent starts 39 + */ 40 + export async function initAdblocker(): Promise<void> { 41 + if (blocker) { 42 + DEBUG && console.log('[adblocker] Already initialized'); 43 + return; 44 + } 45 + 46 + DEBUG && console.log('[adblocker] Initializing...'); 47 + const startTime = Date.now(); 48 + 49 + try { 50 + blocker = await ElectronBlocker.fromPrebuiltAdsAndTracking(fetch, { 51 + enableCompression: true, 52 + }); 53 + 54 + blocker.on('request-blocked', (request: Request) => { 55 + blockedCount++; 56 + DEBUG && console.log('[adblocker] Blocked:', request.url); 57 + }); 58 + 59 + const elapsedMs = Date.now() - startTime; 60 + DEBUG && console.log(`[adblocker] Initialized in ${elapsedMs}ms`); 61 + } catch (error) { 62 + console.error('[adblocker] Failed to initialize:', error); 63 + throw error; 64 + } 65 + } 66 + 67 + /** 68 + * Enable ad blocking on a specific session 69 + */ 70 + export function enableBlockingInSession(sess: Session): void { 71 + if (!blocker) { 72 + console.error('[adblocker] Cannot enable blocking: not initialized'); 73 + return; 74 + } 75 + if (attachedSessions.has(sess)) { 76 + DEBUG && console.log('[adblocker] Session already has blocking enabled'); 77 + return; 78 + } 79 + 80 + blocker.enableBlockingInSession(sess); 81 + attachedSessions.add(sess); 82 + isEnabled = true; 83 + DEBUG && console.log('[adblocker] Enabled blocking in session'); 84 + } 85 + 86 + /** 87 + * Disable ad blocking on a specific session 88 + */ 89 + export function disableBlockingInSession(sess: Session): void { 90 + if (!blocker || !attachedSessions.has(sess)) { 91 + return; 92 + } 93 + 94 + blocker.disableBlockingInSession(sess); 95 + attachedSessions.delete(sess); 96 + isEnabled = attachedSessions.size > 0; 97 + DEBUG && console.log('[adblocker] Disabled blocking in session'); 98 + } 99 + 100 + /** 101 + * Enable ad blocking on the default session 102 + */ 103 + export function enableBlocking(): void { 104 + enableBlockingInSession(session.defaultSession); 105 + } 106 + 107 + /** 108 + * Disable ad blocking on the default session 109 + */ 110 + export function disableBlocking(): void { 111 + disableBlockingInSession(session.defaultSession); 112 + } 113 + 114 + /** 115 + * Toggle ad blocking on the default session 116 + * @returns New enabled state 117 + */ 118 + export function toggleBlocking(): boolean { 119 + if (isEnabled) { 120 + disableBlocking(); 121 + } else { 122 + enableBlocking(); 123 + } 124 + return isEnabled; 125 + } 126 + 127 + /** 128 + * Check if ad blocking is currently enabled 129 + */ 130 + export function isBlockingEnabled(): boolean { 131 + return isEnabled; 132 + } 133 + 134 + /** 135 + * Get the count of blocked requests since startup 136 + */ 137 + export function getBlockedCount(): number { 138 + return blockedCount; 139 + } 140 + 141 + /** 142 + * Reset the blocked request counter 143 + */ 144 + export function resetBlockedCount(): void { 145 + blockedCount = 0; 146 + } 147 + 148 + /** 149 + * Get current adblocker status 150 + */ 151 + export function getAdblockerStatus(): { 152 + initialized: boolean; 153 + enabled: boolean; 154 + blockedCount: number; 155 + attachedSessionCount: number; 156 + } { 157 + return { 158 + initialized: blocker !== null, 159 + enabled: isEnabled, 160 + blockedCount, 161 + attachedSessionCount: attachedSessions.size, 162 + }; 163 + } 164 + 165 + /** 166 + * Apply adblocker configuration 167 + * Initializes if needed and enables/disables based on config 168 + */ 169 + export async function applyAdblockerConfig(config: AdblockerConfig): Promise<void> { 170 + DEBUG && console.log('[adblocker] Applying config:', config); 171 + 172 + if (config.enabled) { 173 + if (!blocker) { 174 + await initAdblocker(); 175 + } 176 + enableBlocking(); 177 + } else { 178 + disableBlocking(); 179 + } 180 + } 181 + 182 + /** 183 + * Clean up adblocker resources 184 + * Should be called before app quit 185 + */ 186 + export function cleanupAdblocker(): void { 187 + DEBUG && console.log('[adblocker] Cleaning up...'); 188 + 189 + for (const sess of attachedSessions) { 190 + if (blocker) { 191 + try { 192 + blocker.disableBlockingInSession(sess); 193 + } catch (error) { 194 + DEBUG && console.log('[adblocker] Error disabling session:', error); 195 + } 196 + } 197 + } 198 + 199 + attachedSessions.clear(); 200 + blocker = null; 201 + isEnabled = false; 202 + blockedCount = 0; 203 + }
+392
backend/electron/chrome-extensions.ts
··· 1 + /** 2 + * Chrome Extension Manager for Electron 3 + * 4 + * Provides infrastructure for loading bundled Chrome/MV3 web extensions. 5 + * Extensions are loaded from a bundled directory using Electron's native 6 + * session.loadExtension() API. 7 + * 8 + * Features: 9 + * - Load unpacked Chrome extensions from bundled directory 10 + * - Enable/disable extensions via settings (persisted in datastore) 11 + * - Compatible with electron-chrome-extensions for enhanced API support 12 + */ 13 + 14 + import { session, Extension } from 'electron'; 15 + import fs from 'node:fs'; 16 + import path from 'node:path'; 17 + 18 + import { getDb } from './datastore.js'; 19 + 20 + const DEBUG = !!process.env.DEBUG; 21 + 22 + /** 23 + * Chrome extension manifest.json structure (MV3) 24 + */ 25 + export interface ChromeManifest { 26 + manifest_version: number; 27 + name: string; 28 + version: string; 29 + description?: string; 30 + icons?: Record<string, string>; 31 + permissions?: string[]; 32 + host_permissions?: string[]; 33 + background?: { 34 + service_worker?: string; 35 + scripts?: string[]; 36 + type?: string; 37 + }; 38 + content_scripts?: Array<{ 39 + matches: string[]; 40 + js?: string[]; 41 + css?: string[]; 42 + run_at?: string; 43 + }>; 44 + action?: { 45 + default_popup?: string; 46 + default_icon?: string | Record<string, string>; 47 + default_title?: string; 48 + }; 49 + } 50 + 51 + /** 52 + * Discovered extension info (before loading) 53 + */ 54 + export interface ChromeExtensionInfo { 55 + id: string; 56 + name: string; 57 + version: string; 58 + description: string; 59 + path: string; 60 + manifest: ChromeManifest; 61 + } 62 + 63 + /** 64 + * Loaded extension state 65 + */ 66 + export interface LoadedChromeExtension { 67 + id: string; 68 + name: string; 69 + version: string; 70 + path: string; 71 + electronExtension: Extension; 72 + } 73 + 74 + /** 75 + * Extension enable/disable setting (persisted) 76 + */ 77 + export interface ChromeExtensionSetting { 78 + extensionId: string; 79 + enabled: boolean; 80 + updatedAt: number; 81 + } 82 + 83 + // Module state 84 + let extensionsDir: string | null = null; 85 + const discoveredExtensions: Map<string, ChromeExtensionInfo> = new Map(); 86 + const loadedExtensions: Map<string, LoadedChromeExtension> = new Map(); 87 + 88 + /** 89 + * Initialize the chrome_extensions table in the database 90 + */ 91 + function initChromeExtensionsTable(): void { 92 + const db = getDb(); 93 + if (!db) { 94 + console.error('[chrome-ext] Database not initialized'); 95 + return; 96 + } 97 + 98 + db.exec(` 99 + CREATE TABLE IF NOT EXISTS chrome_extensions ( 100 + extensionId TEXT PRIMARY KEY, 101 + enabled INTEGER NOT NULL DEFAULT 1, 102 + updatedAt INTEGER NOT NULL 103 + ) 104 + `); 105 + 106 + DEBUG && console.log('[chrome-ext] Database table initialized'); 107 + } 108 + 109 + /** 110 + * Get extension setting from database 111 + */ 112 + function getExtensionSetting(extensionId: string): ChromeExtensionSetting | null { 113 + const db = getDb(); 114 + if (!db) return null; 115 + 116 + const row = db.prepare( 117 + 'SELECT extensionId, enabled, updatedAt FROM chrome_extensions WHERE extensionId = ?' 118 + ).get(extensionId) as { extensionId: string; enabled: number; updatedAt: number } | undefined; 119 + 120 + if (!row) return null; 121 + 122 + return { 123 + extensionId: row.extensionId, 124 + enabled: row.enabled === 1, 125 + updatedAt: row.updatedAt, 126 + }; 127 + } 128 + 129 + /** 130 + * Save extension setting to database 131 + */ 132 + function saveExtensionSetting(extensionId: string, enabled: boolean): void { 133 + const db = getDb(); 134 + if (!db) return; 135 + 136 + const now = Date.now(); 137 + db.prepare(` 138 + INSERT INTO chrome_extensions (extensionId, enabled, updatedAt) 139 + VALUES (?, ?, ?) 140 + ON CONFLICT(extensionId) DO UPDATE SET 141 + enabled = excluded.enabled, 142 + updatedAt = excluded.updatedAt 143 + `).run(extensionId, enabled ? 1 : 0, now); 144 + 145 + DEBUG && console.log(`[chrome-ext] Saved setting: ${extensionId} = ${enabled}`); 146 + } 147 + 148 + /** 149 + * Initialize the chrome extension manager 150 + * @param bundledExtensionsDir Path to the bundled extensions directory 151 + */ 152 + export function initChromeExtensionManager(bundledExtensionsDir: string): void { 153 + extensionsDir = bundledExtensionsDir; 154 + 155 + // Initialize database table 156 + initChromeExtensionsTable(); 157 + 158 + // Discover extensions in the directory 159 + discoverExtensions(); 160 + 161 + DEBUG && console.log(`[chrome-ext] Manager initialized with ${discoveredExtensions.size} extensions`); 162 + } 163 + 164 + /** 165 + * Discover extensions in the bundled directory 166 + */ 167 + function discoverExtensions(): void { 168 + if (!extensionsDir) return; 169 + 170 + if (!fs.existsSync(extensionsDir)) { 171 + DEBUG && console.log(`[chrome-ext] Extensions directory does not exist: ${extensionsDir}`); 172 + return; 173 + } 174 + 175 + const entries = fs.readdirSync(extensionsDir, { withFileTypes: true }); 176 + 177 + for (const entry of entries) { 178 + if (!entry.isDirectory()) continue; 179 + 180 + const extPath = path.join(extensionsDir, entry.name); 181 + const manifestPath = path.join(extPath, 'manifest.json'); 182 + 183 + if (!fs.existsSync(manifestPath)) { 184 + DEBUG && console.log(`[chrome-ext] Skipping ${entry.name}: no manifest.json`); 185 + continue; 186 + } 187 + 188 + try { 189 + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); 190 + const manifest = JSON.parse(manifestContent) as ChromeManifest; 191 + 192 + // Use directory name as ID (standard for unpacked extensions) 193 + const extId = entry.name; 194 + 195 + const extInfo: ChromeExtensionInfo = { 196 + id: extId, 197 + name: manifest.name || extId, 198 + version: manifest.version || '0.0.0', 199 + description: manifest.description || '', 200 + path: extPath, 201 + manifest, 202 + }; 203 + 204 + discoveredExtensions.set(extId, extInfo); 205 + DEBUG && console.log(`[chrome-ext] Discovered: ${extInfo.name} v${extInfo.version}`); 206 + } catch (error) { 207 + console.error(`[chrome-ext] Failed to read manifest for ${entry.name}:`, error); 208 + } 209 + } 210 + } 211 + 212 + /** 213 + * Load a chrome extension into the session 214 + */ 215 + async function loadExtension(extInfo: ChromeExtensionInfo): Promise<LoadedChromeExtension | null> { 216 + if (loadedExtensions.has(extInfo.id)) { 217 + DEBUG && console.log(`[chrome-ext] Extension already loaded: ${extInfo.id}`); 218 + return loadedExtensions.get(extInfo.id) || null; 219 + } 220 + 221 + try { 222 + const ext = await session.defaultSession.loadExtension(extInfo.path, { 223 + allowFileAccess: true, 224 + }); 225 + 226 + const loaded: LoadedChromeExtension = { 227 + id: extInfo.id, 228 + name: extInfo.name, 229 + version: extInfo.version, 230 + path: extInfo.path, 231 + electronExtension: ext, 232 + }; 233 + 234 + loadedExtensions.set(extInfo.id, loaded); 235 + DEBUG && console.log(`[chrome-ext] Loaded: ${extInfo.name}`); 236 + 237 + return loaded; 238 + } catch (error) { 239 + console.error(`[chrome-ext] Failed to load ${extInfo.id}:`, error); 240 + return null; 241 + } 242 + } 243 + 244 + /** 245 + * Unload a chrome extension from the session 246 + */ 247 + async function unloadExtension(extId: string): Promise<boolean> { 248 + const loaded = loadedExtensions.get(extId); 249 + if (!loaded) { 250 + DEBUG && console.log(`[chrome-ext] Extension not loaded: ${extId}`); 251 + return false; 252 + } 253 + 254 + try { 255 + await session.defaultSession.removeExtension(loaded.electronExtension.id); 256 + loadedExtensions.delete(extId); 257 + DEBUG && console.log(`[chrome-ext] Unloaded: ${loaded.name}`); 258 + return true; 259 + } catch (error) { 260 + console.error(`[chrome-ext] Failed to unload ${extId}:`, error); 261 + return false; 262 + } 263 + } 264 + 265 + /** 266 + * Load all enabled chrome extensions 267 + */ 268 + export async function loadEnabledChromeExtensions(): Promise<void> { 269 + DEBUG && console.log('[chrome-ext] Loading enabled extensions...'); 270 + 271 + for (const [extId, extInfo] of discoveredExtensions) { 272 + const setting = getExtensionSetting(extId); 273 + 274 + // Default to enabled if no setting exists 275 + const enabled = setting ? setting.enabled : true; 276 + 277 + if (enabled) { 278 + await loadExtension(extInfo); 279 + } 280 + } 281 + 282 + DEBUG && console.log(`[chrome-ext] Loaded ${loadedExtensions.size} extensions`); 283 + } 284 + 285 + /** 286 + * Get list of all chrome extensions with their status 287 + */ 288 + export function getChromeExtensions(): Array<{ 289 + id: string; 290 + name: string; 291 + version: string; 292 + description: string; 293 + enabled: boolean; 294 + loaded: boolean; 295 + }> { 296 + const result: Array<{ 297 + id: string; 298 + name: string; 299 + version: string; 300 + description: string; 301 + enabled: boolean; 302 + loaded: boolean; 303 + }> = []; 304 + 305 + for (const [extId, extInfo] of discoveredExtensions) { 306 + const setting = getExtensionSetting(extId); 307 + const enabled = setting ? setting.enabled : true; 308 + const loaded = loadedExtensions.has(extId); 309 + 310 + result.push({ 311 + id: extId, 312 + name: extInfo.name, 313 + version: extInfo.version, 314 + description: extInfo.description, 315 + enabled, 316 + loaded, 317 + }); 318 + } 319 + 320 + return result; 321 + } 322 + 323 + /** 324 + * Enable a chrome extension 325 + */ 326 + export async function enableChromeExtension(extId: string): Promise<boolean> { 327 + const extInfo = discoveredExtensions.get(extId); 328 + if (!extInfo) { 329 + console.error(`[chrome-ext] Unknown extension: ${extId}`); 330 + return false; 331 + } 332 + 333 + saveExtensionSetting(extId, true); 334 + 335 + const loaded = await loadExtension(extInfo); 336 + return loaded !== null; 337 + } 338 + 339 + /** 340 + * Disable a chrome extension 341 + */ 342 + export async function disableChromeExtension(extId: string): Promise<boolean> { 343 + saveExtensionSetting(extId, false); 344 + return await unloadExtension(extId); 345 + } 346 + 347 + /** 348 + * Check if a chrome extension is enabled 349 + */ 350 + export function isChromeExtensionEnabled(extId: string): boolean { 351 + const setting = getExtensionSetting(extId); 352 + return setting ? setting.enabled : true; // Default to enabled 353 + } 354 + 355 + /** 356 + * Check if a chrome extension is loaded 357 + */ 358 + export function isChromeExtensionLoaded(extId: string): boolean { 359 + return loadedExtensions.has(extId); 360 + } 361 + 362 + /** 363 + * Get chrome extension manager status 364 + */ 365 + export function getChromeExtensionStatus(): { 366 + initialized: boolean; 367 + extensionsDir: string | null; 368 + discoveredCount: number; 369 + loadedCount: number; 370 + } { 371 + return { 372 + initialized: extensionsDir !== null, 373 + extensionsDir, 374 + discoveredCount: discoveredExtensions.size, 375 + loadedCount: loadedExtensions.size, 376 + }; 377 + } 378 + 379 + /** 380 + * Clean up chrome extension manager 381 + */ 382 + export async function cleanupChromeExtensions(): Promise<void> { 383 + DEBUG && console.log('[chrome-ext] Cleaning up...'); 384 + 385 + for (const extId of loadedExtensions.keys()) { 386 + await unloadExtension(extId); 387 + } 388 + 389 + discoveredExtensions.clear(); 390 + loadedExtensions.clear(); 391 + extensionsDir = null; 392 + }
+31 -1
backend/electron/entry.ts
··· 63 63 import { startHotReload, stopHotReload } from './hotreload.js'; 64 64 import { checkAndRunDailyBackup } from './backup.js'; 65 65 import { 66 + applyAdblockerConfig, 67 + cleanupAdblocker, 68 + } from './adblocker.js'; 69 + import { 70 + initChromeExtensionManager, 71 + loadEnabledChromeExtensions, 72 + cleanupChromeExtensions, 73 + } from './chrome-extensions.js'; 74 + import { 66 75 initProfilesDb, 67 76 migrateExistingProfiles, 68 77 ensureDefaultProfile, ··· 228 237 setPrefsGetter(() => _prefs); 229 238 230 239 // Define onQuit for use in IPC handlers and shortcuts 231 - const onQuit = () => { 240 + const onQuit = async () => { 232 241 // Clean up dev extensions before quitting 233 242 cleanupDevExtensions(); 243 + // Clean up web extensions 244 + cleanupAdblocker(); 245 + await cleanupChromeExtensions(); 234 246 stopHotReload(); 235 247 quitApp(); 236 248 }; ··· 291 303 // Discover and register built-in themes from themes/ folder 292 304 discoverBuiltinThemes(path.join(ROOT_DIR, 'themes')); 293 305 306 + // Initialize bundled web extensions infrastructure 307 + const chromeExtensionsDir = path.join(ROOT_DIR, 'resources', 'chrome-extensions'); 308 + initChromeExtensionManager(chromeExtensionsDir); 309 + 294 310 // Restore saved theme preference (must be after themes are discovered) 295 311 restoreSavedTheme(); 296 312 ··· 400 416 const devCount = await loadDevExtensions(); 401 417 DEBUG && console.log(`[ext:dev] Loaded ${devCount} dev extension(s)`); 402 418 } 419 + 420 + // Load bundled web extensions (chrome extensions + adblocker) 421 + // Adblocker: enabled by default, check prefs for override 422 + const adBlockerEnabled = prefsMsg.prefs.adBlockerEnabled !== false; 423 + if (adBlockerEnabled) { 424 + applyAdblockerConfig({ enabled: true }).catch(err => { 425 + console.error('[startup] Adblocker init failed:', err); 426 + }); 427 + } 428 + 429 + // Load enabled chrome extensions 430 + loadEnabledChromeExtensions().catch(err => { 431 + console.error('[startup] Chrome extensions init failed:', err); 432 + }); 403 433 404 434 const extTime = Date.now() - extStart; 405 435 const totalTime = Date.now() - ((global as Record<string, unknown>).__startupStart as number);
+38
backend/electron/index.ts
··· 246 246 ISettingsApi, 247 247 IEscapeApi, 248 248 } from '../types/api.js'; 249 + 250 + // Adblocker module 251 + export { 252 + initAdblocker, 253 + enableBlocking, 254 + disableBlocking, 255 + toggleBlocking, 256 + isBlockingEnabled, 257 + getBlockedCount, 258 + resetBlockedCount, 259 + getAdblockerStatus, 260 + applyAdblockerConfig, 261 + cleanupAdblocker, 262 + enableBlockingInSession, 263 + disableBlockingInSession, 264 + } from './adblocker.js'; 265 + 266 + export type { AdblockerConfig } from './adblocker.js'; 267 + 268 + // Chrome extension manager 269 + export { 270 + initChromeExtensionManager, 271 + loadEnabledChromeExtensions, 272 + getChromeExtensions, 273 + enableChromeExtension, 274 + disableChromeExtension, 275 + isChromeExtensionEnabled, 276 + isChromeExtensionLoaded, 277 + getChromeExtensionStatus, 278 + cleanupChromeExtensions, 279 + } from './chrome-extensions.js'; 280 + 281 + export type { 282 + ChromeManifest, 283 + ChromeExtensionInfo, 284 + LoadedChromeExtension, 285 + ChromeExtensionSetting, 286 + } from './chrome-extensions.js';
+110
backend/electron/ipc.ts
··· 3061 3061 } 3062 3062 3063 3063 /** 3064 + * Register IPC handlers for bundled web extensions (adblocker + chrome extensions) 3065 + */ 3066 + export function registerWebExtensionHandlers(): void { 3067 + const DEBUG = !!process.env.DEBUG; 3068 + 3069 + // ========== Adblocker Handlers ========== 3070 + 3071 + // Get adblocker status 3072 + ipcMain.handle('adblocker:getStatus', async () => { 3073 + try { 3074 + const { getAdblockerStatus } = await import('./adblocker.js'); 3075 + const status = getAdblockerStatus(); 3076 + return { success: true, data: status }; 3077 + } catch (error) { 3078 + const message = error instanceof Error ? error.message : String(error); 3079 + return { success: false, error: message }; 3080 + } 3081 + }); 3082 + 3083 + // Enable adblocker 3084 + ipcMain.handle('adblocker:enable', async () => { 3085 + try { 3086 + const { applyAdblockerConfig } = await import('./adblocker.js'); 3087 + await applyAdblockerConfig({ enabled: true }); 3088 + return { success: true }; 3089 + } catch (error) { 3090 + const message = error instanceof Error ? error.message : String(error); 3091 + return { success: false, error: message }; 3092 + } 3093 + }); 3094 + 3095 + // Disable adblocker 3096 + ipcMain.handle('adblocker:disable', async () => { 3097 + try { 3098 + const { disableBlocking } = await import('./adblocker.js'); 3099 + disableBlocking(); 3100 + return { success: true }; 3101 + } catch (error) { 3102 + const message = error instanceof Error ? error.message : String(error); 3103 + return { success: false, error: message }; 3104 + } 3105 + }); 3106 + 3107 + // Get blocked count 3108 + ipcMain.handle('adblocker:getBlockedCount', async () => { 3109 + try { 3110 + const { getBlockedCount } = await import('./adblocker.js'); 3111 + const count = getBlockedCount(); 3112 + return { success: true, data: count }; 3113 + } catch (error) { 3114 + const message = error instanceof Error ? error.message : String(error); 3115 + return { success: false, error: message }; 3116 + } 3117 + }); 3118 + 3119 + // ========== Chrome Extension Handlers ========== 3120 + 3121 + // Get all chrome extensions 3122 + ipcMain.handle('chrome-ext:list', async () => { 3123 + try { 3124 + const { getChromeExtensions } = await import('./chrome-extensions.js'); 3125 + const extensions = getChromeExtensions(); 3126 + return { success: true, data: extensions }; 3127 + } catch (error) { 3128 + const message = error instanceof Error ? error.message : String(error); 3129 + return { success: false, error: message }; 3130 + } 3131 + }); 3132 + 3133 + // Enable a chrome extension 3134 + ipcMain.handle('chrome-ext:enable', async (_ev, data: { id: string }) => { 3135 + try { 3136 + const { enableChromeExtension } = await import('./chrome-extensions.js'); 3137 + const result = await enableChromeExtension(data.id); 3138 + return { success: result }; 3139 + } catch (error) { 3140 + const message = error instanceof Error ? error.message : String(error); 3141 + return { success: false, error: message }; 3142 + } 3143 + }); 3144 + 3145 + // Disable a chrome extension 3146 + ipcMain.handle('chrome-ext:disable', async (_ev, data: { id: string }) => { 3147 + try { 3148 + const { disableChromeExtension } = await import('./chrome-extensions.js'); 3149 + const result = await disableChromeExtension(data.id); 3150 + return { success: result }; 3151 + } catch (error) { 3152 + const message = error instanceof Error ? error.message : String(error); 3153 + return { success: false, error: message }; 3154 + } 3155 + }); 3156 + 3157 + // Get chrome extension manager status 3158 + ipcMain.handle('chrome-ext:getStatus', async () => { 3159 + try { 3160 + const { getChromeExtensionStatus } = await import('./chrome-extensions.js'); 3161 + const status = getChromeExtensionStatus(); 3162 + return { success: true, data: status }; 3163 + } catch (error) { 3164 + const message = error instanceof Error ? error.message : String(error); 3165 + return { success: false, error: message }; 3166 + } 3167 + }); 3168 + 3169 + DEBUG && console.log('[ipc] Web extension handlers registered'); 3170 + } 3171 + 3172 + /** 3064 3173 * Register all IPC handlers 3065 3174 */ 3066 3175 export function registerAllHandlers(onQuit: () => void): void { ··· 3072 3181 registerBackupHandlers(); 3073 3182 registerProfileHandlers(); 3074 3183 registerModesHandlers(); 3184 + registerWebExtensionHandlers(); 3075 3185 registerMiscHandlers(onQuit); 3076 3186 }
+2
package.json
··· 147 147 "lint": "echo \"No linting configured\"" 148 148 }, 149 149 "dependencies": { 150 + "@cliqz/adblocker-electron": "^1.27.6", 150 151 "archiver": "^7.0.0", 151 152 "better-sqlite3": "^12.5.0", 153 + "cross-fetch": "^4.0.0", 152 154 "electron-unhandled": "^5.0.0", 153 155 "lit": "^3.3.2" 154 156 },
+78
preload.js
··· 724 724 } 725 725 }; 726 726 727 + // ========== Bundled Web Extensions API ========== 728 + 729 + /** 730 + * Adblocker API - Native ad blocking powered by @cliqz/adblocker-electron 731 + */ 732 + api.adblocker = { 733 + /** 734 + * Get adblocker status 735 + * @returns {Promise<{success: boolean, data?: {initialized: boolean, enabled: boolean, blockedCount: number}, error?: string}>} 736 + */ 737 + getStatus: () => { 738 + return ipcRenderer.invoke('adblocker:getStatus'); 739 + }, 740 + 741 + /** 742 + * Enable ad blocking 743 + * @returns {Promise<{success: boolean, error?: string}>} 744 + */ 745 + enable: () => { 746 + return ipcRenderer.invoke('adblocker:enable'); 747 + }, 748 + 749 + /** 750 + * Disable ad blocking 751 + * @returns {Promise<{success: boolean, error?: string}>} 752 + */ 753 + disable: () => { 754 + return ipcRenderer.invoke('adblocker:disable'); 755 + }, 756 + 757 + /** 758 + * Get count of blocked requests 759 + * @returns {Promise<{success: boolean, data?: number, error?: string}>} 760 + */ 761 + getBlockedCount: () => { 762 + return ipcRenderer.invoke('adblocker:getBlockedCount'); 763 + } 764 + }; 765 + 766 + /** 767 + * Chrome Extensions API - Bundled Chrome extension management 768 + */ 769 + api.chromeExtensions = { 770 + /** 771 + * List all bundled chrome extensions 772 + * @returns {Promise<{success: boolean, data?: Array<{id: string, name: string, version: string, description: string, enabled: boolean, loaded: boolean}>, error?: string}>} 773 + */ 774 + list: () => { 775 + return ipcRenderer.invoke('chrome-ext:list'); 776 + }, 777 + 778 + /** 779 + * Enable a chrome extension 780 + * @param {string} id - Extension ID 781 + * @returns {Promise<{success: boolean, error?: string}>} 782 + */ 783 + enable: (id) => { 784 + return ipcRenderer.invoke('chrome-ext:enable', { id }); 785 + }, 786 + 787 + /** 788 + * Disable a chrome extension 789 + * @param {string} id - Extension ID 790 + * @returns {Promise<{success: boolean, error?: string}>} 791 + */ 792 + disable: (id) => { 793 + return ipcRenderer.invoke('chrome-ext:disable', { id }); 794 + }, 795 + 796 + /** 797 + * Get chrome extension manager status 798 + * @returns {Promise<{success: boolean, data?: {initialized: boolean, discoveredCount: number, loadedCount: number}, error?: string}>} 799 + */ 800 + getStatus: () => { 801 + return ipcRenderer.invoke('chrome-ext:getStatus'); 802 + } 803 + }; 804 + 727 805 // Track per-window color scheme override (null = use global) 728 806 let windowColorSchemeOverride = null; 729 807
+29
resources/chrome-extensions/README.md
··· 1 + # Bundled Chrome Extensions 2 + 3 + This directory contains unpacked Chrome extensions that are bundled with Peek. 4 + 5 + ## Adding an Extension 6 + 7 + 1. Download the extension source (unpacked format) 8 + 2. Create a subdirectory with a unique ID (e.g., `proton-pass`) 9 + 3. Place the extension files inside, including `manifest.json` 10 + 11 + ## Directory Structure 12 + 13 + ``` 14 + chrome-extensions/ 15 + ├── README.md 16 + ├── proton-pass/ # Example extension 17 + │ ├── manifest.json 18 + │ ├── background.js 19 + │ └── ... 20 + └── other-extension/ 21 + └── ... 22 + ``` 23 + 24 + ## Notes 25 + 26 + - Extensions must have a valid `manifest.json` 27 + - The directory name is used as the extension ID 28 + - MV2 and MV3 extensions are supported (via Electron's native API) 29 + - Extensions are loaded on startup if enabled in Settings