diff --git a/apps/desktop/features/editor/home.js b/apps/desktop/features/editor/home.js index 71c42542..92d4e4c2 100644 --- a/apps/desktop/features/editor/home.js +++ b/apps/desktop/features/editor/home.js @@ -388,6 +388,24 @@ const init = async () => { debug && console.log('[editor:vim] :e — opening file'); }); + // Shadow-aware "is a plain editable element focused?" check (peek 51798bae). + // Mirrors renderer/page/overlay.js `shouldBlockOverlayDrag` and the universal + // content preload's predicate: input(text-ish)/textarea/select/contenteditable/ + // role=textbox, piercing shadow roots via each root's activeElement. + const isPlainEditableFocused = () => { + let el = document.activeElement; + while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement; + if (!el || el.nodeType !== 1) return false; + if (el.isContentEditable) return true; + const tag = (el.tagName || '').toLowerCase(); + if (tag === 'textarea' || tag === 'select') return true; + if (tag === 'input') { + const type = (el.getAttribute('type') || 'text').toLowerCase(); + return !['button', 'checkbox', 'radio', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden'].includes(type); + } + return (el.getAttribute('role') || '').toLowerCase() === 'textbox'; + }; + // Set up escape handler // // ESC cascade (preload intercepts ESC via before-input-event, DOM keydown never fires): @@ -433,6 +451,17 @@ const init = async () => { editorLayout.exitFocusMode(); return { handled: true }; } + + // Peek editor requirement (51798bae): ESC while a plain editable is focused + // (the CodeMirror text area / any input/textarea/contenteditable) must NOT + // fall through to the backend switcher route. Consume it so ESC stays in + // the editing context. Vim insert/visual and focus-mode are handled above; + // this catches ordinary editing (incl. vim normal mode with the editor + // focused), which previously fell through and blasted open the switcher. + if (isPlainEditableFocused()) { + debug && console.log('[editor:esc] Editable focused — consuming ESC (no switcher)'); + return { handled: true }; + } return { handled: false }; }); } diff --git a/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs b/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs index 1f7b54ea..0e7a0ca6 100644 --- a/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs +++ b/apps/desktop/main/chrome-api-polyfills/runtime-external-preload.cjs @@ -29,6 +29,60 @@ const IPC_CS_RELAY_TO_PAGE = 'peek:cs-relay:to-page'; console.log('[peek:runtime-external:preload] loaded for', location.href); +// === Universal editable-focus reporting (peek 51798bae) ===================== +// This preload is now applied to EVERY http(s) page-host content WC (not just +// extension-matched URLs — see getRuntimeExternalWebPrefsForUrl). Its universal +// job is to give the main process a deterministic "is an editable element +// focused?" signal so ESC can blur/dismiss the field instead of being stolen to +// open the Windows switcher. FSM-clean: the boolean is recomputed on focus +// TRANSITIONS (focusin/focusout) and pushed only on change — no timers, no +// polling. The predicate mirrors renderer/page/overlay.js `shouldBlockOverlayDrag` +// (shadow-DOM-aware via the active element of each shadow root). +const IPC_EDITABLE_FOCUS = 'page:editable-focus'; + +function _peekDeepActiveElement() { + let el = document.activeElement; + // Pierce shadow roots: a focused element inside a shadow tree is that root's + // activeElement, chained from the host down. + while (el && el.shadowRoot && el.shadowRoot.activeElement) { + el = el.shadowRoot.activeElement; + } + return el; +} + +function _peekIsEditableElement(el) { + if (!el || el.nodeType !== 1) return false; + if (el.isContentEditable) return true; + const tag = (el.tagName || '').toLowerCase(); + if (tag === 'textarea' || tag === 'select') return true; + if (tag === 'input') { + // Non-text input types (button/checkbox/radio/etc.) don't own ESC. + const type = (el.getAttribute('type') || 'text').toLowerCase(); + const nonEditable = ['button', 'checkbox', 'radio', 'submit', 'reset', 'image', 'file', 'range', 'color', 'hidden']; + return !nonEditable.includes(type); + } + if ((el.getAttribute('role') || '').toLowerCase() === 'textbox') return true; + return false; +} + +let _peekLastEditable = null; +function _peekReportEditableFocus() { + const editable = _peekIsEditableElement(_peekDeepActiveElement()); + if (editable === _peekLastEditable) return; + _peekLastEditable = editable; + try { ipcRenderer.send(IPC_EDITABLE_FOCUS, editable); } catch (_) {} +} +// focusin/focusout bubble (unlike focus/blur) so ONE document-level capture +// listener sees focus moving anywhere in the page, incl. across shadow +// boundaries. Chromium/DOM boundary: on focusout, document.activeElement has +// not yet settled to the post-transition target, so read it on a MICROTASK — +// a deterministic ordering primitive (runs after the synchronous blur→focus +// sequence completes), NOT a timing race. Dedup makes the double-fire (focusout +// then focusin on a field-to-field move) a no-op. +function _peekScheduleReport() { Promise.resolve().then(_peekReportEditableFocus); } +document.addEventListener('focusin', _peekScheduleReport, true); +document.addEventListener('focusout', _peekScheduleReport, true); + /** * MAIN-world polyfill source. Installed via webFrame.executeJavaScript so * page scripts (in the page's main JS world) can call chrome.runtime.sendMessage. @@ -199,38 +253,44 @@ const _extIdArg = (process.argv || []).find(a => typeof a === 'string' && a.star const _extId = _extIdArg ? _extIdArg.slice('--peek-runtime-external-ext-id='.length) : ''; const PAGE_POLYFILL_SRC_RESOLVED = PAGE_POLYFILL_SRC.replace(/__PEEK_EXT_ID__/g, JSON.stringify(_extId)); -try { - // Inject the polyfill into the page's MAIN world. webFrame.executeJavaScript - // runs synchronously in MAIN world during preload, before page scripts run, - // so chrome.runtime.sendMessage is defined when the page boots. - webFrame.executeJavaScript(PAGE_POLYFILL_SRC_RESOLVED, false); - console.log('[peek:runtime-external:preload] MAIN-world polyfill injected, extId=', _extId || '(missing)'); -} catch (err) { - console.error('[peek:runtime-external:preload] failed to inject MAIN-world polyfill:', err && err.message); -} +// Chrome-extension messaging polyfill — GATED on a matched extension id. When +// this preload is applied universally for focus tracking (no --peek-runtime- +// external-ext-id arg), _extId is '' and the whole chrome.runtime.sendMessage +// bridge stays dormant; only the focus reporter above runs. +if (_extId) { + try { + // Inject the polyfill into the page's MAIN world. webFrame.executeJavaScript + // runs synchronously in MAIN world during preload, before page scripts run, + // so chrome.runtime.sendMessage is defined when the page boots. + webFrame.executeJavaScript(PAGE_POLYFILL_SRC_RESOLVED, false); + console.log('[peek:runtime-external:preload] MAIN-world polyfill injected, extId=', _extId || '(missing)'); + } catch (err) { + console.error('[peek:runtime-external:preload] failed to inject MAIN-world polyfill:', err && err.message); + } -// Bridge MAIN-world postMessage requests → main-process IPC → response back. -window.addEventListener('message', function (ev) { - if (ev.source !== window) return; - const d = ev.data; - if (!d || d.__peekRuntimeExternal !== 'request') return; - const { reqId, extId, message, origin, internal } = d; - console.log('[peek:runtime-external:preload] req reqId=', reqId, 'extId=', extId, 'type=', message && message.type, 'internal=', !!internal); - ipcRenderer.invoke(IPC_SEND_FROM_PAGE, { extId, message, origin, internal: !!internal }) - .then(function (response) { - if (response && response.__peekRuntimeExternalError) { - console.error('[peek:runtime-external:preload] resp ERROR reqId=', reqId, 'err=', response.__peekRuntimeExternalError); - window.postMessage({ __peekRuntimeExternal: 'response', reqId, error: response.__peekRuntimeExternalError }, location.origin); - } else { - console.log('[peek:runtime-external:preload] resp ok reqId=', reqId, 'response=', JSON.stringify(response).slice(0, 120)); - window.postMessage({ __peekRuntimeExternal: 'response', reqId, response }, location.origin); - } - }) - .catch(function (err) { - console.error('[peek:runtime-external:preload] ipc THREW reqId=', reqId, 'err=', err && err.message); - window.postMessage({ __peekRuntimeExternal: 'response', reqId, error: (err && err.message) || String(err) }, location.origin); - }); -}, true); + // Bridge MAIN-world postMessage requests → main-process IPC → response back. + window.addEventListener('message', function (ev) { + if (ev.source !== window) return; + const d = ev.data; + if (!d || d.__peekRuntimeExternal !== 'request') return; + const { reqId, extId, message, origin, internal } = d; + console.log('[peek:runtime-external:preload] req reqId=', reqId, 'extId=', extId, 'type=', message && message.type, 'internal=', !!internal); + ipcRenderer.invoke(IPC_SEND_FROM_PAGE, { extId, message, origin, internal: !!internal }) + .then(function (response) { + if (response && response.__peekRuntimeExternalError) { + console.error('[peek:runtime-external:preload] resp ERROR reqId=', reqId, 'err=', response.__peekRuntimeExternalError); + window.postMessage({ __peekRuntimeExternal: 'response', reqId, error: response.__peekRuntimeExternalError }, location.origin); + } else { + console.log('[peek:runtime-external:preload] resp ok reqId=', reqId, 'response=', JSON.stringify(response).slice(0, 120)); + window.postMessage({ __peekRuntimeExternal: 'response', reqId, response }, location.origin); + } + }) + .catch(function (err) { + console.error('[peek:runtime-external:preload] ipc THREW reqId=', reqId, 'err=', err && err.message); + window.postMessage({ __peekRuntimeExternal: 'response', reqId, error: (err && err.message) || String(err) }, location.origin); + }); + }, true); +} // === Content-script relay: page postMessage ⇄ extension bridge window === // diff --git a/apps/desktop/main/hybrid-editable-focus.ts b/apps/desktop/main/hybrid-editable-focus.ts new file mode 100644 index 00000000..09f5eff6 --- /dev/null +++ b/apps/desktop/main/hybrid-editable-focus.ts @@ -0,0 +1,50 @@ +/** + * Editable-focus tracking for hybrid page-host content (peek 51798bae). + * + * The universal content preload (chrome-api-polyfills/runtime-external-preload.cjs) + * reports, on every focus TRANSITION inside an http(s) page, whether an editable + * element is currently focused — via `ipcRenderer.send('page:editable-focus', bool)`. + * This module caches that per content webContents so the hybrid ESC handler + * (wireHybridContentEvents' before-input-event in ipc.ts) can make a synchronous, + * deterministic decision: if an editable is focused, ESC belongs to the editing + * context (blur/dismiss the field) and must NOT be stolen to open the Windows + * switcher. + * + * Keyed by webContents.id (the content WC's own id) — that's exactly what the + * before-input-event closure has in scope (`contentWC.id`) and what + * `event.sender.id` is on the IPC, so no host-window mapping is needed. + * + * FSM-clean: no timers/polling. The boolean is a lifecycle state pushed on focus + * transitions and read at ESC time. + */ +import { ipcMain } from 'electron'; + +const editableByWcId = new Map(); +let registered = false; + +/** + * Register the `page:editable-focus` IPC receiver exactly once. Idempotent — + * safe to call from every `wireHybridContentEvents` invocation. + */ +export function initHybridEditableFocusTracking(): void { + if (registered) return; + registered = true; + ipcMain.on('page:editable-focus', (event, editable: unknown) => { + editableByWcId.set(event.sender.id, editable === true); + }); +} + +/** True iff the content WC last reported an editable element focused. */ +export function isEditableFocusedForWc(wcId: number): boolean { + return editableByWcId.get(wcId) === true; +} + +/** Drop cached state when a content WC is destroyed. */ +export function clearEditableFocusForWc(wcId: number): void { + editableByWcId.delete(wcId); +} + +/** Test-only: set the cached state directly without the IPC round-trip. */ +export function __setEditableFocusForWcForTest(wcId: number, editable: boolean): void { + editableByWcId.set(wcId, editable); +} diff --git a/apps/desktop/main/ipc.ts b/apps/desktop/main/ipc.ts index 46ef3564..0a91493c 100644 --- a/apps/desktop/main/ipc.ts +++ b/apps/desktop/main/ipc.ts @@ -316,6 +316,11 @@ import { installMainWorldContentScriptInjection, installRuntimeExternalCdpPolyfill, } from './page-host-compat.js'; +import { + initHybridEditableFocusTracking, + isEditableFocusedForWc, + clearEditableFocusForWc, +} from './hybrid-editable-focus.js'; import { getIzuiCoordinator, @@ -1346,6 +1351,12 @@ export function wireHybridContentEvents( // redirect-driven did-navigate, mirroring the canvas path (ipc.ts ~2465). let skipTracking = options?.skipTracking === true; + // Editable-focus tracking (peek 51798bae): register the `page:editable-focus` + // receiver once (idempotent), and drop this content WC's cached focus state on + // teardown so a recycled webContents id never inherits a stale editable flag. + initHybridEditableFocusTracking(); + contentWC.once('destroyed', () => clearEditableFocusForWc(contentWC.id)); + // ── Loading-timeout safety net (Stage B) ──────────────────────────────── // Armed on `did-start-loading`, cleared on `did-finish-load` / `did-stop-loading`. // Fires for hung loads (server accepts connection but never responds — the @@ -1565,6 +1576,18 @@ export function wireHybridContentEvents( // source of truth). The early `!modifier` return below would otherwise drop // ESC entirely, so this MUST sit above it. if (input.type === 'keyDown' && input.key === 'Escape') { + // Editable-target guard (peek 51798bae): while a text field / textarea / + // contenteditable inside the page is focused, ESC belongs to the editing + // context (blur/dismiss the field), NOT the global switcher. The universal + // content preload reports focus transitions via page:editable-focus. When + // editable is focused, let ESC fall through to the page UNSTOLEN — do NOT + // preventDefault and do NOT route to policy — so the page blurs the field + // natively. A second ESC (nothing focused now) reaches the switcher route. + // Deterministic: a cached focus boolean read synchronously, no timer. + if (isEditableFocusedForWc(contentWC.id)) { + DEBUG && console.log(`[esc] hybrid ESC deferred to page — editable focused in content wc ${contentWC.id}`); + return; + } const now = Date.now(); if (now - lastHybridEscTime < 200) return; lastHybridEscTime = now; diff --git a/apps/desktop/main/page-host-compat.ts b/apps/desktop/main/page-host-compat.ts index 71faef6c..6bdcd33a 100644 --- a/apps/desktop/main/page-host-compat.ts +++ b/apps/desktop/main/page-host-compat.ts @@ -92,49 +92,54 @@ export function getRuntimeExternalWebPrefsForUrl( logChannel: 'webview' | 'hybrid' = 'webview' ): RuntimeExternalWebPrefs | null { if (!url || !(url.startsWith('http://') || url.startsWith('https://'))) { + // Non-http(s) (peek:// tiles, about:blank, etc.) get no preload — they run + // their own tile-preload and never need the editable-focus signal. return null; } + + // UNIVERSAL preload (peek 51798bae): every http(s) page-host content WC loads + // runtime-external-preload.cjs so the main process gets a deterministic + // "editable focused?" signal (focusin/focusout → page:editable-focus), used + // by the hybrid ESC handler to blur/dismiss the field instead of stealing ESC + // to open the Windows switcher. The chrome-extension messaging polyfill inside + // that preload is GATED on the --peek-runtime-external-ext-id arg, so a page + // with no matching extension (or with the compat polyfill disabled via env) + // loads ONLY the focus-tracking half — the messaging bridge stays dormant. + const additionalArguments: string[] = []; + if (process.env.PEEK_DISABLE_RUNTIME_EXTERNAL_POLYFILL === '1') { - // Diagnostic parity with the old inline path (Proton no-match note). + // Env disables only the chrome-compat half (no ext-id arg → dormant bridge); + // the focus-tracking preload is still applied so ESC-in-editable stays fixed. if (DEBUG && (url.includes('proton.me') || url.includes('proton.dev'))) { - console.log(`[peek:runtime-external:${logChannel}] runtime-external polyfill disabled via env for ${url}`); + console.log(`[peek:runtime-external:${logChannel}] compat polyfill disabled via env for ${url} (focus-tracking preload still applied)`); } - return null; - } - - const matchedExtId = findExtensionByExternallyConnectableUrl(url); - if (matchedExtId) { - const extraArgs = [`--peek-runtime-external-ext-id=${matchedExtId}`]; - if (getMatchingIsolatedContentScripts(url).length > 0) { - extraArgs.push('--peek-cs-relay-enabled'); + } else { + const matchedExtId = findExtensionByExternallyConnectableUrl(url); + if (matchedExtId) { + additionalArguments.push(`--peek-runtime-external-ext-id=${matchedExtId}`); + if (getMatchingIsolatedContentScripts(url).length > 0) { + additionalArguments.push('--peek-cs-relay-enabled'); + } + console.log(`[peek:runtime-external:${logChannel}] Injecting runtime-external preload for ${url} (ext=${matchedExtId}, sandbox=true)`); + } else { + // Fallback: ISOLATED-world content-script match (no externally_connectable). + const csMatches = getMatchingIsolatedContentScripts(url); + if (csMatches.length > 0) { + const csExtId = csMatches[0].runtimeExtId; + additionalArguments.push(`--peek-runtime-external-ext-id=${csExtId}`); + console.log(`[peek:runtime-external:${logChannel}] Injecting runtime-external preload for content-script URL ${url} (ext=${csExtId})`); + } else if (DEBUG && (url.includes('proton.me') || url.includes('proton.dev'))) { + console.log(`[peek:runtime-external:${logChannel}] No extension matched ${url} — focus-tracking preload only`); + } } - console.log(`[peek:runtime-external:${logChannel}] Injecting runtime-external preload for ${url} (ext=${matchedExtId}, sandbox=true)`); - return { - preload: getRuntimeExternalPreloadPath(), - additionalArguments: extraArgs, - sandbox: true, - contextIsolation: true, - }; - } - - if (DEBUG && (url.includes('proton.me') || url.includes('proton.dev'))) { - console.log(`[peek:runtime-external:${logChannel}] No extension matched ${url} — extension probably not loaded yet, or pattern mismatch`); } - // Fallback: ISOLATED-world content-script match (no externally_connectable). - const csMatches = getMatchingIsolatedContentScripts(url); - if (csMatches.length > 0) { - const csExtId = csMatches[0].runtimeExtId; - console.log(`[peek:runtime-external:${logChannel}] Injecting runtime-external preload for content-script URL ${url} (ext=${csExtId})`); - return { - preload: getRuntimeExternalPreloadPath(), - additionalArguments: [`--peek-runtime-external-ext-id=${csExtId}`], - sandbox: true, - contextIsolation: true, - }; - } - - return null; + return { + preload: getRuntimeExternalPreloadPath(), + additionalArguments, + sandbox: true, + contextIsolation: true, + }; } /**