diff --git a/apps/desktop/chrome-extensions/proton-pass/peek-permissions.js b/apps/desktop/chrome-extensions/proton-pass/peek-permissions.js index 1d1402d8..0b939ad6 100644 --- a/apps/desktop/chrome-extensions/proton-pass/peek-permissions.js +++ b/apps/desktop/chrome-extensions/proton-pass/peek-permissions.js @@ -10,7 +10,16 @@ window.addEventListener('error', function(ev) { try { var stack = ev.error && ev.error.stack ? ev.error.stack : '(no stack)'; - console.error('[peek:popup-error]', ev.message, '@', ev.filename + ':' + ev.lineno + ':' + ev.colno, '\n', stack); + // For resource-load errors (img/script/link), ev.message is undefined. + // ev.target identifies the failed element — dump its src/href + tag. + var t = ev.target; + var resourceInfo = ''; + if (t && t !== window && t.nodeType === 1) { + var src = t.src || t.href || '(no src/href)'; + var tag = t.tagName || '(no tag)'; + resourceInfo = ' resource=' + tag + ' src=' + src; + } + console.error('[peek:popup-error]', ev.message, '@', ev.filename + ':' + ev.lineno + ':' + ev.colno, resourceInfo, '\n', stack); } catch(_) {} }, true); window.addEventListener('unhandledrejection', function(ev) { diff --git a/apps/desktop/main/chrome-api-polyfills/peek-bridge.html b/apps/desktop/main/chrome-api-polyfills/peek-bridge.html index ba1c1011..7d6b0b75 100644 --- a/apps/desktop/main/chrome-api-polyfills/peek-bridge.html +++ b/apps/desktop/main/chrome-api-polyfills/peek-bridge.html @@ -6,6 +6,10 @@ scripts/patch-chrome-extensions.js and loaded as a hidden BrowserWindow at chrome-extension:///peek-bridge.html by chrome-extensions.ts. + The bridge logic lives in peek-bridge.js (separate file, not inline) because + MV3 extensions ship a strict CSP `script-src 'self' 'wasm-unsafe-eval'` that + blocks inline + diff --git a/apps/desktop/main/chrome-api-polyfills/peek-bridge.js b/apps/desktop/main/chrome-api-polyfills/peek-bridge.js new file mode 100644 index 00000000..ae7f63bf --- /dev/null +++ b/apps/desktop/main/chrome-api-polyfills/peek-bridge.js @@ -0,0 +1,160 @@ +/* peek-bridge.js — extracted from peek-bridge.html to satisfy MV3 CSP + * (`script-src 'self'`). See peek-bridge.html for the architecture comment. + */ +(function () { + 'use strict'; + + var PORT_NAME = 'peek-auth-relay'; + var RECONNECT_DELAY_MS = 1000; + var MAX_RECONNECT_ATTEMPTS = 10; + + var _port = null; + var _reconnectAttempts = 0; + var _pendingByReqId = {}; + + function log() { + var args = Array.prototype.slice.call(arguments); + try { + if (window.peekBridge && window.peekBridge.log) { + window.peekBridge.log.apply(null, args); + } else { + console.error.apply(console, ['[peek:bridge:page]'].concat(args)); + } + } catch (_) {} + } + + function connect() { + try { + _port = chrome.runtime.connect({ name: PORT_NAME }); + _reconnectAttempts = 0; + log('chrome.runtime.connect() opened port name=', PORT_NAME, 'extId=', chrome.runtime.id); + + _port.onMessage.addListener(function (msg) { + log('port.onMessage reqId=', msg && msg.reqId, 'type=', msg && msg.type); + var reqId = msg && msg.reqId; + var pending = reqId && _pendingByReqId[reqId]; + if (pending) { + delete _pendingByReqId[reqId]; + pending.sendResponse(msg && msg.response !== undefined ? msg.response : msg); + } else { + log('port.onMessage: no pending reqId=', reqId, '(unsolicited or already resolved)'); + } + }); + + _port.onDisconnect.addListener(function () { + var err = chrome.runtime.lastError; + log('port disconnected:', err && err.message || '(no error)'); + _port = null; + // Reject any in-flight requests whose port just dropped. + var pendingIds = Object.keys(_pendingByReqId); + for (var i = 0; i < pendingIds.length; i++) { + var p = _pendingByReqId[pendingIds[i]]; + if (p && p.sendResponse) { + try { p.sendResponse({ __peekBridgeError: 'port disconnected' }); } catch (_) {} + } + delete _pendingByReqId[pendingIds[i]]; + } + // Attempt reconnect with backoff. + scheduleReconnect(); + }); + + return true; + } catch (err) { + log('chrome.runtime.connect() threw:', err && err.message); + _port = null; + return false; + } + } + + function scheduleReconnect() { + if (_reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + log('max reconnect attempts reached — bridge port permanently unavailable'); + return; + } + _reconnectAttempts++; + var delay = RECONNECT_DELAY_MS * _reconnectAttempts; + log('scheduling reconnect attempt', _reconnectAttempts, 'in', delay, 'ms'); + setTimeout(function () { + log('reconnect attempt', _reconnectAttempts); + connect(); + }, delay); + } + + function ensurePort() { + if (_port) return true; + return connect(); + } + + function onForward(payload, sendResponse) { + var reqId = payload && payload.reqId; + log('onForward reqId=', reqId, 'type=', payload && payload.message && payload.message.type); + + if (!ensurePort()) { + sendResponse({ __peekBridgeError: 'bridge port not available' }); + return; + } + + // Track the pending request so port.onMessage can resolve it. + _pendingByReqId[reqId] = { sendResponse: sendResponse }; + + // Set a local timeout to clean up if the BG SW never responds. + var timer = setTimeout(function () { + if (_pendingByReqId[reqId]) { + delete _pendingByReqId[reqId]; + log('timeout waiting for BG SW response reqId=', reqId); + sendResponse({ __peekBridgeError: 'BG SW response timeout (bridge)' }); + } + }, 28000); // slightly under the 30s main-process timeout + + // Override sendResponse to also clear the timer. + var originalSendResponse = sendResponse; + _pendingByReqId[reqId] = { + sendResponse: function (r) { + clearTimeout(timer); + originalSendResponse(r); + }, + }; + + // Forward the message through the port to the BG SW. + try { + _port.postMessage({ + reqId: reqId, + message: payload && payload.message, + sender: payload && payload.sender, + }); + log('port.postMessage dispatched reqId=', reqId); + } catch (err) { + delete _pendingByReqId[reqId]; + clearTimeout(timer); + log('port.postMessage threw reqId=', reqId, 'err=', err && err.message); + sendResponse({ __peekBridgeError: 'port.postMessage failed: ' + (err && err.message || String(err)) }); + } + } + + // Wire up the preload bridge once the DOM is ready. + function init() { + if (!window.peekBridge) { + log('ERROR: window.peekBridge not exposed — peek-bridge-preload.cjs not loaded'); + return; + } + if (!chrome || !chrome.runtime) { + log('ERROR: chrome.runtime not available — not running as extension page'); + return; + } + + window.peekBridge.onForward(function (payload, sendResponse) { + onForward(payload, sendResponse); + }); + + // Open the port eagerly so it's ready before the first forward arrives. + connect(); + + log('bridge initialized extId=', chrome.runtime.id); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/apps/desktop/main/chrome-extensions.ts b/apps/desktop/main/chrome-extensions.ts index 0ec35db8..eb689c9e 100644 --- a/apps/desktop/main/chrome-extensions.ts +++ b/apps/desktop/main/chrome-extensions.ts @@ -745,25 +745,26 @@ export function openChromeExtensionPage( DEBUG && console.log(`[chrome-ext] UI window closed: ${windowKey}`); }); - if (DEBUG) { - // Forward renderer console + JS errors to main stderr so popup-side - // failures (which would otherwise live only in the popup's DevTools) - // surface in the same log stream as the rest of the extension wiring. - // NOTE: console-message does NOT fire when DevTools is attached, so we - // do not auto-open DevTools here. If you need to inspect manually, - // right-click → Inspect inside the popup. - win.webContents.on('console-message', (event) => { - try { - const e = event as unknown as { level?: unknown; message?: unknown; lineNumber?: unknown; sourceId?: unknown }; - console.log(`[ext-popup:${windowKey}:${String(e.level)}] ${String(e.message)} (${String(e.sourceId)}:${String(e.lineNumber)})`); - } catch { - /* logging must never throw */ + // Always forward extension UI console errors/warnings + render-process + // crashes to main stderr — these surfaces have no other diagnostic path + // (Electron's native extension popup doesn't expose devtools through our + // Cmd+Option+I bridge), so a black popup leaves no trace otherwise. + // Info/log levels still gated on DEBUG to keep the stream usable. + win.webContents.on('console-message', (event) => { + try { + const e = event as unknown as { level?: unknown; message?: unknown; lineNumber?: unknown; sourceId?: unknown }; + const level = String(e.level); + const isErrorish = level === 'error' || level === 'warning' || level === 'warn'; + if (isErrorish || DEBUG) { + console.log(`[ext-popup:${windowKey}:${level}] ${String(e.message)} (${String(e.sourceId)}:${String(e.lineNumber)})`); } - }); - win.webContents.on('render-process-gone', (_e, details) => { - console.error(`[ext-popup:${windowKey}] render-process-gone:`, details); - }); - } + } catch { + /* logging must never throw */ + } + }); + win.webContents.on('render-process-gone', (_e, details) => { + console.error(`[ext-popup:${windowKey}] render-process-gone:`, details); + }); // Route window.open(url) from the popup (and from our chrome.tabs.create // shim, which delegates to window.open) through Peek's normal window diff --git a/apps/desktop/main/entry.ts b/apps/desktop/main/entry.ts index 3ed24b07..279d7df6 100644 --- a/apps/desktop/main/entry.ts +++ b/apps/desktop/main/entry.ts @@ -375,6 +375,26 @@ app.on('web-contents-created', (_event, contents) => { } } + // Forward console errors/warnings from EVERY chrome-extension:// webContents + // to main stderr. Extension popups, options pages, BG SW, etc. have no + // user-visible diagnostic path when they fail to render (a black screen + // tells you nothing). Centralised here at the web-contents-created chokepoint + // so it covers all callsites: chrome-extensions.ts openChromeExtensionPage, + // api.window.open path, harness-driven opens, etc. + contents.on('console-message', (event) => { + try { + const url = contents.getURL(); + if (!url.startsWith('chrome-extension://')) return; + const e = event as unknown as { level?: unknown; message?: unknown; lineNumber?: unknown; sourceId?: unknown }; + const level = String(e.level); + const isErrorish = level === 'error' || level === 'warning' || level === 'warn'; + if (isErrorish) { + const extId = url.slice('chrome-extension://'.length).split('/')[0].slice(0, 8); + console.log(`[ext:${extId}:${level}] ${String(e.message)} (${String(e.sourceId)}:${String(e.lineNumber)})`); + } + } catch { /* logging must never throw */ } + }); + // DevTools shortcut: Cmd+Alt+I (mac) / Ctrl+Shift+I (linux/win). Gated by // the runtime debug toggle (Settings → Diagnostics). Every window is // created with `webPreferences.devTools: true`, so this handler can open diff --git a/apps/desktop/tests/manual/proton-popup-diag.harness.ts b/apps/desktop/tests/manual/proton-popup-diag.harness.ts new file mode 100644 index 00000000..4bb2b011 --- /dev/null +++ b/apps/desktop/tests/manual/proton-popup-diag.harness.ts @@ -0,0 +1,89 @@ +/** + * Manual harness: diagnose Proton Pass popup black-screen. + * + * Launches Peek, opens the Proton Pass popup, captures ALL console output + + * page errors + failed network requests, then dumps a summary to stdout. + * + * Run via: yarn harness apps/desktop/tests/manual/proton-popup-diag.harness.ts + */ + +import { test } from '../fixtures/desktop-app'; +import { discoverPopup } from './harness/popup'; + +test.describe('proton-popup-diag @manual', () => { + test.setTimeout(60_000); + + test('open popup and capture diagnostics', async ({ desktopApp }) => { + const bg = await desktopApp.getBackgroundWindow(); + + // Locate main.log in the test profile's userData dir; tail it after the + // popup boots so [chrome-ext], [ext:...], [peek:...] lines surface here. + const userDataPath = await desktopApp.evaluateMain!(async (electronMod) => { + return electronMod.app.getPath('userData'); + }); + console.log(`[diag] userDataPath=${userDataPath}`); + + // Attach listeners BEFORE opening so we don't miss boot-time errors. + const events: Array<{ kind: string; text: string }> = []; + + const { popup, entry } = await discoverPopup(desktopApp, bg, 'proton', { + width: 600, + height: 430, + timeout: 20_000, + }); + console.log(`[diag] popup opened url=${entry.url}`); + + popup.on('console', (msg) => { + events.push({ kind: `console:${msg.type()}`, text: msg.text() }); + }); + popup.on('pageerror', (err) => { + events.push({ kind: 'pageerror', text: `${err.name}: ${err.message}\n${err.stack || ''}` }); + }); + popup.on('requestfailed', (req) => { + events.push({ kind: 'requestfailed', text: `${req.method()} ${req.url()} :: ${req.failure()?.errorText || 'unknown'}` }); + }); + + // Give the popup time to attempt loading scripts + render. + await popup.waitForTimeout(8000); + + // Dump the DOM state too — black screen often means root mount never happened. + const domState = await popup.evaluate(() => { + const root = document.querySelector('.app-root'); + return { + readyState: document.readyState, + bodyChildCount: document.body ? document.body.childElementCount : 0, + appRootExists: !!root, + appRootChildCount: root ? root.childElementCount : -1, + appRootHTML: root ? (root as HTMLElement).innerHTML.slice(0, 500) : '(missing)', + scriptsLoaded: Array.from(document.querySelectorAll('script')).map((s) => ({ + src: (s as HTMLScriptElement).src || '(inline)', + })), + }; + }); + + console.log('[diag] DOM snapshot:', JSON.stringify(domState, null, 2)); + console.log(`[diag] captured ${events.length} events:`); + for (const ev of events) { + console.log(` [${ev.kind}] ${ev.text}`); + } + + // Read main.log from the profile dir and surface ext-related lines. + try { + const logPath = `${userDataPath}/logs/main.log`; + const raw = (await import('fs')).readFileSync(logPath, 'utf8'); + const lines = raw.split('\n').filter((l) => + l.includes('[chrome-ext]') || + l.includes('[ext:') || + l.includes('[ext-popup') || + l.includes('[bridge:') || + l.includes('[peek:popup') || + l.includes('[peek:permissions') || + l.includes('[peek:runtime-external') + ); + console.log(`[diag] main.log relevant lines (${lines.length}):`); + for (const line of lines) console.log(` ${line}`); + } catch (err) { + console.log(`[diag] main.log read failed: ${(err as Error).message}`); + } + }); +}); diff --git a/scripts/patch-chrome-extensions.js b/scripts/patch-chrome-extensions.js index 26f6076b..a5d506b3 100644 --- a/scripts/patch-chrome-extensions.js +++ b/scripts/patch-chrome-extensions.js @@ -208,7 +208,16 @@ const PEEK_PERMISSIONS_JS = `/* Peek: chrome.permissions early-install shim. See window.addEventListener('error', function(ev) { try { var stack = ev.error && ev.error.stack ? ev.error.stack : '(no stack)'; - console.error('[peek:popup-error]', ev.message, '@', ev.filename + ':' + ev.lineno + ':' + ev.colno, '\\n', stack); + // For resource-load errors (img/script/link), ev.message is undefined. + // ev.target identifies the failed element — dump its src/href + tag. + var t = ev.target; + var resourceInfo = ''; + if (t && t !== window && t.nodeType === 1) { + var src = t.src || t.href || '(no src/href)'; + var tag = t.tagName || '(no tag)'; + resourceInfo = ' resource=' + tag + ' src=' + src; + } + console.error('[peek:popup-error]', ev.message, '@', ev.filename + ':' + ev.lineno + ':' + ev.colno, resourceInfo, '\\n', stack); } catch(_) {} }, true); window.addEventListener('unhandledrejection', function(ev) { @@ -619,20 +628,26 @@ function patchProtonPass() { fs.writeFileSync(peekJsPath, PEEK_PERMISSIONS_JS); console.log('[patch] Wrote peek-permissions.js'); - // --- Copy peek-bridge.html into the extension directory --- + // --- Copy peek-bridge.html + peek-bridge.js into the extension directory --- // The bridge page is loaded as a hidden BrowserWindow at // chrome-extension:///peek-bridge.html by chrome-extensions.ts. // Extension pages (chrome-extension:// origin) have native same-extension // chrome.runtime.connect() which reaches the BG SW — the cross-origin path // that Electron 40 does NOT implement for external pages. - // Source lives in apps/desktop/main/chrome-api-polyfills/peek-bridge.html. - const bridgeSrcPath = path.join(ROOT, 'apps', 'desktop', 'main', 'chrome-api-polyfills', 'peek-bridge.html'); - const bridgeDstPath = path.join(extPath, 'peek-bridge.html'); - if (fs.existsSync(bridgeSrcPath)) { - fs.copyFileSync(bridgeSrcPath, bridgeDstPath); - console.log('[patch] Copied peek-bridge.html to extension directory'); - } else { - console.warn('[patch] WARNING: peek-bridge.html source not found at', bridgeSrcPath); + // + // The script lives in a separate .js file (referenced via