diff --git a/forge.config.ts b/forge.config.ts index 017a6aa7b3..9deff6e69a 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -36,7 +36,7 @@ const config: ForgeConfig = { name: '@electron-forge/plugin-webpack', config: { devContentSecurityPolicy: - "default-src 'none'; img-src 'self' https: data:; media-src 'none'; child-src 'self'; object-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; font-src 'self' https:;", + "default-src 'none'; img-src 'self' https: data:; media-src 'none'; child-src 'self' isolated-actions:; object-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; font-src 'self' https:;", devServer: { // Disallow browser from opening/reloading with HMR in development mode. open: false, @@ -55,6 +55,11 @@ const config: ForgeConfig = { js: path.join(root, 'src/preload/preload.ts'), }, }, + { + html: path.join(root, './static/isolated-run-button.html'), + js: path.join(root, './src/isolated-run-button.ts'), + name: 'isolated_run_button', + }, ], }, }, diff --git a/src/ambient.d.ts b/src/ambient.d.ts index 3dca546b18..02e1544306 100644 --- a/src/ambient.d.ts +++ b/src/ambient.d.ts @@ -167,7 +167,6 @@ declare global { setShowMeTemplate(template?: string): void; showWarningDialog(messageOptions: MessageOptions): void; showWindow(): void; - startFiddle(): Promise; stopFiddle(): void; themePath: string; uncacheTypes(ver: RunnableVersion): Promise; diff --git a/src/constants.ts b/src/constants.ts index 343829722d..6fff843c2d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,3 +7,5 @@ export const ELECTRON_DTS = 'electron.d.ts'; // We use these to fail fast locally when creating/updating a new gist. export const GIST_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file export const GIST_MAX_FILE_COUNT = 300; + +export const PREFERS_DARK_MEDIA_QUERY = '(prefers-color-scheme: dark)'; diff --git a/src/interfaces.ts b/src/interfaces.ts index 967fde54bf..35d82541b8 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -188,6 +188,7 @@ export type FiddleEvent = | 'select-all-in-editor' | 'set-show-me-template' | 'show-welcome-tour' + | 'theme-loaded' | 'toggle-bisect' | 'toggle-monaco-option' | 'undo-in-editor' diff --git a/src/ipc-events.ts b/src/ipc-events.ts index 61a32467f1..186d04f2d2 100644 --- a/src/ipc-events.ts +++ b/src/ipc-events.ts @@ -38,6 +38,7 @@ export enum IpcEvents { OPEN_THEME_FOLDER = 'OPEN_THEME_FOLDER', READ_THEME_FILE = 'READ_THEME_FILE', GET_THEME_PATH = 'GET_THEME_PATH', + THEME_LOADED = 'THEME_LOADED', IS_DEV_MODE = 'IS_DEV_MODE', NPM_ADD_MODULES = 'NPM_ADD_MODULES', NPM_IS_PM_INSTALLED = 'NPM_IS_PM_INSTALLED', diff --git a/src/isolated-run-button.ts b/src/isolated-run-button.ts new file mode 100644 index 0000000000..e5253f0678 --- /dev/null +++ b/src/isolated-run-button.ts @@ -0,0 +1,353 @@ +// This file runs privileged code inside an Out-of-Process iframe (OOPIF) +// Do NOT import any third-party code here, and keep the code to the minimum + +import './less/run-button.less'; + +import { PREFERS_DARK_MEDIA_QUERY } from './constants'; +import { + InstallState, + type InstallStateEvent, + type ProgressObject, +} from './interfaces'; +import { + type LoadedFiddleTheme, + defaultDark, + defaultLight, +} from './themes-defaults'; +import { getCssStringForTheme } from './utils/theme'; + +type IsolatedRunButtonEvent = + | 'run-fiddle' + | 'fiddle-stopped' + | 'fiddle-modules-installed' + | 'version-state-changed' + | 'version-download-progress' + | 'theme-loaded'; + +declare global { + interface Window { + IsolatedActionsElectronFiddle: { + startFiddle(): void; + stopFiddle(): void; + readThemeFile(name: string): Promise; + addEventListener( + type: IsolatedRunButtonEvent, + listener: (...args: any[]) => void, + options?: { signal: AbortSignal }, + ): void; + removeAllListeners(type: IsolatedRunButtonEvent): void; + }; + } +} + +type ButtonMode = + | 'run' + | 'stop' + | 'installing-modules' + | 'downloading' + | 'unzipping' + | 'checking'; + +interface ButtonAppearance { + text: string; + iconClass: string; + showSpinner: boolean; + disabled: boolean; + active: boolean; + action: 'start' | 'stop' | null; +} + +interface State { + isRunning: boolean; + installingModules: boolean; + installState: InstallState | undefined; + downloadProgress: number | undefined; + currentAppearance: ButtonAppearance; +} + +const APPEARANCES: Record = { + run: { + text: 'Run', + iconClass: 'bp3-icon-play', + showSpinner: false, + disabled: false, + active: false, + action: 'start', + }, + stop: { + text: 'Stop', + iconClass: 'bp3-icon-stop', + showSpinner: false, + disabled: false, + active: true, + action: 'stop', + }, + 'installing-modules': { + text: 'Installing modules', + iconClass: '', + showSpinner: true, + disabled: true, + active: false, + action: null, + }, + downloading: { + text: 'Downloading', + iconClass: '', + showSpinner: true, + disabled: true, + active: false, + action: null, + }, + unzipping: { + text: 'Unzipping', + iconClass: '', + showSpinner: true, + disabled: true, + active: false, + action: null, + }, + checking: { + text: 'Checking status', + iconClass: '', + showSpinner: true, + disabled: true, + active: false, + action: null, + }, +}; + +const api = window.IsolatedActionsElectronFiddle; + +const state: State = { + isRunning: false, + installingModules: false, + installState: undefined, + downloadProgress: undefined, + currentAppearance: APPEARANCES.checking, +}; + +function computeMode(): ButtonMode { + switch (state.installState) { + case 'downloading': + return 'downloading'; + case 'installing': + return 'unzipping'; + case 'missing': + return 'checking'; + case 'downloaded': + case 'installed': + default: + if (state.installingModules) return 'installing-modules'; + if (state.isRunning) return 'stop'; + return 'run'; + } +} + +const button = document.getElementById('run-button') as HTMLButtonElement; +const iconEl = document.getElementById('run-button-icon') as HTMLSpanElement; +const textEl = document.getElementById('run-button-text') as HTMLSpanElement; + +// Match Blueprint's +const SPINNER_SIZE = 16; +const SPINNER_STROKE = 2; +const SPINNER_R = (SPINNER_SIZE - SPINNER_STROKE) / 2; +const SPINNER_C = 2 * Math.PI * SPINNER_R; + +function buildSpinnerHtml(): string { + const path = `M 8,8 m 0,-${SPINNER_R} a ${SPINNER_R},${SPINNER_R} 0 1,1 0,${ + SPINNER_R * 2 + } a ${SPINNER_R},${SPINNER_R} 0 1,1 0,-${SPINNER_R * 2}`; + return ` + + `; +} + +function updateSpinner(progress?: number) { + // Only rebuild the DOM when transitioning into spinner mode — + // otherwise the existing `.bp3-spinner-head` keeps its identity and + // its `stroke-dashoffset` transition stays continuous. + if (!iconEl.querySelector('.bp3-spinner')) { + iconEl.className = ''; + iconEl.innerHTML = buildSpinnerHtml(); + } + const spinner = iconEl.querySelector('.bp3-spinner'); + const head = iconEl.querySelector('.bp3-spinner-head'); + if (!spinner || !head) return; + + const clamped = + typeof progress === 'number' ? Math.max(0, Math.min(1, progress)) : null; + const offset = + clamped === null ? SPINNER_C * 0.25 : SPINNER_C * (1 - clamped); + head.setAttribute('stroke-dashoffset', String(offset)); + // Blueprint's spinner rotates by default via the `.bp3-spinner-animation` + // keyframes. When we have a concrete progress value we want the + // determinate look — head sits still and fills via the + // `stroke-dashoffset` CSS transition — so add `bp3-no-spin` to the + // parent to disable the rotation keyframes. + spinner.classList.toggle('bp3-no-spin', clamped !== null); +} + +function render() { + const mode = computeMode(); + const appearance = APPEARANCES[mode]; + state.currentAppearance = appearance; + + textEl.textContent = appearance.text; + + if (appearance.showSpinner) { + const progress = + mode === 'downloading' ? state.downloadProgress : undefined; + updateSpinner(progress); + } else { + iconEl.innerHTML = ''; + iconEl.className = `bp3-icon ${appearance.iconClass}`.trim(); + } + + button.disabled = appearance.disabled; + button.setAttribute('aria-disabled', String(appearance.disabled)); + button.classList.toggle('bp3-disabled', appearance.disabled); + button.classList.toggle('bp3-active', appearance.active); + button.setAttribute('aria-label', appearance.text); +} + +button.addEventListener('click', () => { + if (state.currentAppearance.action === 'start') { + api.startFiddle(); + } else if (state.currentAppearance.action === 'stop') { + api.stopFiddle(); + } +}); + +// Forward focus state to the parent frame so it can put a focus ring on the iframe +const FOCUS_MESSAGE = 'isolated-run-button-focus'; +button.addEventListener('focus', () => { + window.parent.postMessage({ type: FOCUS_MESSAGE, value: true }, '*'); +}); +button.addEventListener('blur', () => { + window.parent.postMessage({ type: FOCUS_MESSAGE, value: false }, '*'); +}); + +api.addEventListener( + 'run-fiddle', + ({ installingModules }: { installingModules?: boolean }) => { + state.isRunning = true; + state.installingModules = !!installingModules; + render(); + }, +); + +api.addEventListener('fiddle-stopped', () => { + state.isRunning = false; + state.installingModules = false; + render(); +}); + +api.addEventListener('fiddle-modules-installed', () => { + state.installingModules = false; + render(); +}); + +api.addEventListener('version-state-changed', (event: InstallStateEvent) => { + state.installState = event.state as InstallState; + if (event.state !== 'downloading') { + state.downloadProgress = undefined; + } + render(); +}); + +api.addEventListener( + 'version-download-progress', + (_version: string, progress: ProgressObject) => { + state.downloadProgress = progress?.percent; + // A progress event implies we're in the middle of a download, so + // surface that even if VERSION_STATE_CHANGED hasn't arrived yet. + state.installState = InstallState.downloading; + render(); + }, +); + +// Tell the parent how wide the button is so the iframe element matches +const RESIZE_MESSAGE = 'isolated-run-button-resize'; +const reportSize = () => { + const rect = button.getBoundingClientRect(); + window.parent.postMessage({ type: RESIZE_MESSAGE, width: rect.width }, '*'); +}; + +new ResizeObserver(reportSize).observe(button); + +const themeStyle = document.createElement('style'); +themeStyle.id = 'fiddle-theme'; +document.head.appendChild(themeStyle); + +function applyTheme(theme: LoadedFiddleTheme) { + themeStyle.textContent = getCssStringForTheme(theme); + const isDark = !!theme.isDark || theme.name.toLowerCase().includes('dark'); + document.body.classList.toggle('bp3-dark', isDark); +} + +const themesByName = new Map([ + [defaultDark.file, defaultDark], + [defaultLight.file, defaultLight], +]); + +async function applyThemeByName(name: string | null) { + if (!name) { + applyTheme(defaultDark); + return; + } + let theme = themesByName.get(name); + if (!theme) { + const loaded = await api.readThemeFile(name); + if (loaded) { + themesByName.set(loaded.file, loaded); + theme = loaded; + } + } + applyTheme(theme ?? defaultDark); +} + +api.addEventListener('theme-loaded', (theme: LoadedFiddleTheme) => { + themesByName.set(theme.file, theme); +}); + +// Initial theme state is passed as query parameters +const initialParams = new URL(location.href).searchParams; +let isUsingSystemTheme = + initialParams.get('initialUsingSystemTheme') !== 'false'; + +window + .matchMedia(PREFERS_DARK_MEDIA_QUERY) + .addEventListener('change', ({ matches: prefersDark }) => { + if (isUsingSystemTheme) { + applyTheme(prefersDark ? defaultDark : defaultLight); + } + }); + +window.addEventListener('message', (event: MessageEvent) => { + if (event.source !== window.parent) return; + const data = event.data as { + type?: unknown; + value?: unknown; + themeName?: unknown; + } | null; + if (!data || typeof data.type !== 'string') return; + if (data.type === 'isolated-run-button-using-system-theme') { + isUsingSystemTheme = !!data.value; + return; + } + if (data.type === 'isolated-run-button-theme') { + const name = typeof data.themeName === 'string' ? data.themeName : null; + void applyThemeByName(name); + } +}); + +void applyThemeByName(initialParams.get('initialTheme')); + +render(); diff --git a/src/less/components/commands.less b/src/less/components/commands.less index 68f220a072..11fd47a2bc 100644 --- a/src/less/components/commands.less +++ b/src/less/components/commands.less @@ -73,6 +73,23 @@ header { white-space: nowrap; } + iframe.run-button-frame { + -webkit-app-region: no-drag; + border: 0; + background: transparent; + color-scheme: inherit; + width: 86px; + height: 30px; + display: inline-block; + + // Manually apply the focus ring on the iframe + &.has-focus { + outline: @blue3 solid 2px !important; + outline-offset: 2px !important; + z-index: 5 !important; + } + } + select { width: 100px; margin-left: 5px; @@ -134,3 +151,12 @@ header { margin: auto 10px; } } + +// In dark mode we set an outline on the iframe to be the +// border so that it matches the height of other buttons +.bp3-dark .commands { + iframe.run-button-frame { + margin-left: 1px; + outline: 1px solid rgba(16, 22, 26, 0.4); + } +} diff --git a/src/less/run-button.less b/src/less/run-button.less new file mode 100644 index 0000000000..1934ffb621 --- /dev/null +++ b/src/less/run-button.less @@ -0,0 +1,25 @@ +@import 'blueprint.less'; +@import 'variables.less'; + +:root { + color-scheme: light dark; +} + +html, +body { + margin: 0; + background: transparent; +} + +#run-button { + white-space: nowrap; + margin-bottom: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +// In dark themes, disable the box shadow, we'll use +// an outline around the iframe for these instead. +.bp3-dark #run-button { + box-shadow: none; +} diff --git a/src/main/fiddle-core.ts b/src/main/fiddle-core.ts index 012ea80ced..eeda31e9f0 100644 --- a/src/main/fiddle-core.ts +++ b/src/main/fiddle-core.ts @@ -14,6 +14,10 @@ import { import { ELECTRON_DOWNLOAD_PATH, ELECTRON_INSTALL_PATH } from './constants'; import { cleanupDirectory, deleteUserData, saveFilesToTemp } from './files'; import { ipcMainManager } from './ipc'; +import { + ISOLATED_ACTIONS_SCHEME, + getIsolatedRunButtonFrame, +} from './isolated-actions'; import { addModules, getIsPackageManagerInstalled } from './npm'; import { getFiles } from './utils/get-files'; import { getStartFiddleOptions } from './utils/get-start-fiddle-options'; @@ -167,7 +171,11 @@ async function startFiddleImpl(webContents: WebContents): Promise { version, } = options; - ipcMainManager.send(IpcEvents.FIDDLE_RUN, [], webContents); + ipcMainManager.send( + IpcEvents.FIDDLE_RUN, + [{ installingModules: modules.length > 0 }], + [webContents, getIsolatedRunButtonFrame(webContents)], + ); // Look up local Electron builds by version string. Local builds use a // version of the form `0.0.0-local.`, so only consult the @@ -231,7 +239,11 @@ async function startFiddleImpl(webContents: WebContents): Promise { pushError(webContents, 'Could not install modules', error); await cleanup(); - ipcMainManager.send(IpcEvents.FIDDLE_STOPPED, [null, null], webContents); + ipcMainManager.send( + IpcEvents.FIDDLE_STOPPED, + [null, null], + [webContents, getIsolatedRunButtonFrame(webContents)], + ); return RunResult.FAILURE; } @@ -268,7 +280,11 @@ async function startFiddleImpl(webContents: WebContents): Promise { } catch (error: any) { pushError(webContents, 'Failed to spawn Fiddle', error); await cleanup(); - ipcMainManager.send(IpcEvents.FIDDLE_STOPPED, [null, null], webContents); + ipcMainManager.send( + IpcEvents.FIDDLE_STOPPED, + [null, null], + [webContents, getIsolatedRunButtonFrame(webContents)], + ); return RunResult.FAILURE; } fiddleProcesses.set(webContents, child); @@ -276,7 +292,11 @@ async function startFiddleImpl(webContents: WebContents): Promise { // Signal the renderer that module installation is done and the process is // running. Sent after fiddleProcesses.set() so that stopFiddle() will work // as soon as the button transitions to "Stop". - ipcMainManager.send(IpcEvents.FIDDLE_MODULES_INSTALLED, [], webContents); + ipcMainManager.send( + IpcEvents.FIDDLE_MODULES_INSTALLED, + [], + [webContents, getIsolatedRunButtonFrame(webContents)], + ); pushOutputLine(webContents, `Electron v${version} started as "${appName}"`); @@ -298,7 +318,7 @@ async function startFiddleImpl(webContents: WebContents): Promise { ipcMainManager.send( IpcEvents.FIDDLE_STOPPED, [code, signal], - webContents, + [webContents, getIsolatedRunButtonFrame(webContents)], ); resolve(result); @@ -337,7 +357,7 @@ export async function setupFiddleCore(versions: ElectronVersions) { ipcMainManager.send( IpcEvents.VERSION_STATE_CHANGED, [event], - window.webContents, + [window.webContents, getIsolatedRunButtonFrame(window.webContents)], ); } }); @@ -374,7 +394,7 @@ export async function setupFiddleCore(versions: ElectronVersions) { ipcMainManager.send( IpcEvents.VERSION_DOWNLOAD_PROGRESS, [version, progress], - webContents, + [webContents, getIsolatedRunButtonFrame(webContents)], ); }, }); @@ -411,7 +431,15 @@ export async function setupFiddleCore(versions: ElectronVersions) { ipcMainManager.handle( IpcEvents.START_FIDDLE, async (event: IpcMainInvokeEvent) => { - return await startFiddle(event.sender); + const { sender, senderFrame } = event; + + // START_FIDDLE is only valid when it originates from isolated-actions:// + if ( + senderFrame && + new URL(senderFrame.url).protocol === `${ISOLATED_ACTIONS_SCHEME}:` + ) { + return await startFiddle(sender); + } }, ); ipcMainManager.on(IpcEvents.STOP_FIDDLE, (event: IpcMainEvent) => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b7ba3d30e4..d35e602de5 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,11 @@ import { EventEmitter } from 'node:events'; -import { BrowserWindow, MessagePortMain, ipcMain } from 'electron'; +import { + BrowserWindow, + MessagePortMain, + WebFrameMain, + ipcMain, +} from 'electron'; import { getOrCreateMainWindow } from './windows'; import { @@ -10,6 +15,12 @@ import { ipcMainEvents, } from '../ipc-events'; +type IpcSendTarget = Electron.WebContents | WebFrameMain; + +function isWebContents(target: IpcSendTarget): target is Electron.WebContents { + return 'mainFrame' in target; +} + /** * The main purpose of this class is to be the central * gathering place for IPC calls the main process sends @@ -71,30 +82,55 @@ class IpcMainManager extends EventEmitter { } /** - * Send an IPC message to an instance of Electron.WebContents. - * If none is specified, we'll automatically go with the main window. + * Send an IPC message to one or more targets — either a + * `WebContents` (which sends to its main frame) or a + * `WebFrameMain` (sends to that specific sub-frame). If no target + * is provided, falls back to the main window. Targets may be a + * single value or an array; nullish entries are skipped so callers + * can pass results of optional lookups (e.g. a frame that + * may not exist yet) without filtering first. */ public send( channel: IpcEvents, args?: Array, - target?: Electron.WebContents, + target?: IpcSendTarget | Array | null, ) { - const _target = target; - if (!_target) { + if (target === undefined || target === null) { getOrCreateMainWindow().then((window) => { this.send(channel, args, window.webContents); }); return; } + if (Array.isArray(target)) { + for (const t of target) { + if (t) this.sendOne(channel, args, t); + } + return; + } + + this.sendOne(channel, args, target); + } + + private sendOne( + channel: IpcEvents, + args: Array | undefined, + target: IpcSendTarget, + ) { const _args = args || []; - if (!this.readyWebContents.has(_target)) { - const existing = this.messageQueue.get(_target) || []; - this.messageQueue.set(_target, [...existing, [channel, args]]); + + // Queue messages to WebContents until the ready signal + if (isWebContents(target)) { + if (!this.readyWebContents.has(target)) { + const existing = this.messageQueue.get(target) || []; + this.messageQueue.set(target, [...existing, [channel, args]]); + return; + } + target.isDestroyed() || target.send(channel, ..._args); return; } - _target.isDestroyed() || _target.send(channel, ..._args); + target.send(channel, ..._args); } public handle( diff --git a/src/main/isolated-actions.ts b/src/main/isolated-actions.ts new file mode 100644 index 0000000000..171f2f0bcd --- /dev/null +++ b/src/main/isolated-actions.ts @@ -0,0 +1,95 @@ +import { WebContents, WebFrameMain, net, protocol } from 'electron'; + +export const ISOLATED_ACTIONS_SCHEME = 'isolated-actions'; +export const ISOLATED_ACTIONS_RUN_BUTTON_HOST = 'run-button'; +export const ISOLATED_ACTIONS_RUN_BUTTON_URL = `${ISOLATED_ACTIONS_SCHEME}://${ISOLATED_ACTIONS_RUN_BUTTON_HOST}/`; + +const RUN_BUTTON_ENTRY_NAME = 'isolated_run_button'; + +declare const ISOLATED_RUN_BUTTON_WEBPACK_ENTRY: string; + +/** + * Register `isolated-actions://` as a scheme. This call + * MUST happen before `app.whenReady()` resolves. + */ +export function registerIsolatedActionsScheme() { + protocol.registerSchemesAsPrivileged([ + { + scheme: ISOLATED_ACTIONS_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: false, + codeCache: true, + }, + }, + ]); +} + +export function setupIsolatedActionsProtocol() { + // The webpack entry points at the entry's own index.html. Walk up + // one level so we have a base that the entry-name-prefixed asset + // paths slot into directly. + const entryDirSuffix = `/${RUN_BUTTON_ENTRY_NAME}/`; + const entryDirIndex = + ISOLATED_RUN_BUTTON_WEBPACK_ENTRY.lastIndexOf(entryDirSuffix); + if (entryDirIndex < 0) { + throw new Error( + `Unexpected webpack entry shape: ${ISOLATED_RUN_BUTTON_WEBPACK_ENTRY}`, + ); + } + const upstreamRoot = ISOLATED_RUN_BUTTON_WEBPACK_ENTRY.slice( + 0, + entryDirIndex, + ); + + protocol.handle(ISOLATED_ACTIONS_SCHEME, async (request) => { + const url = new URL(request.url); + + if (url.host !== ISOLATED_ACTIONS_RUN_BUTTON_HOST) { + return new Response(null, { status: 404 }); + } + + let pathname = url.pathname; + + // Refuse path traversal — anything with `..` could escape the + // upstream output directory once we resolve it. + if (pathname.includes('..')) { + return new Response(null, { status: 400 }); + } + + // The iframe loads `isolated-actions://run-button/`. The HTML it + // pulls back references its bundle as `/isolated_run_button/...`; + // those resolve back through this handler and need the upstream + // entry's index.html for the bare root. + if (pathname === '/' || pathname === '') { + pathname = `${entryDirSuffix}index.html`; + } + + // Anything else is a webpack-emitted absolute path + // (`/isolated_run_button/`) that already lines up with the + // upstream layout — pass it through unchanged. + return net.fetch(`${upstreamRoot}${pathname}`, { + bypassCustomProtocolHandlers: true, + }); + }); +} + +/** + * Find the isolated run button frame inside `webContents`. + */ +export function getIsolatedRunButtonFrame( + webContents: WebContents, +): WebFrameMain | null { + const frames = webContents.mainFrame?.framesInSubtree; + if (!frames) return null; + return ( + frames.find((frame) => { + try { + return new URL(frame.url).protocol === `${ISOLATED_ACTIONS_SCHEME}:`; + } catch { + return false; + } + }) ?? null + ); +} diff --git a/src/main/main.ts b/src/main/main.ts index 2093eabe59..3e68659e1f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -22,6 +22,10 @@ import { setupFiddleCore } from './fiddle-core'; import { onFirstRunMaybe } from './first-run'; import { setupGitHub } from './github'; import { ipcMainManager } from './ipc'; +import { + registerIsolatedActionsScheme, + setupIsolatedActionsProtocol, +} from './isolated-actions'; import { setupNpm } from './npm'; import { listenForProtocolHandler, setupProtocolHandler } from './protocol'; import { shouldQuit } from './squirrel'; @@ -51,6 +55,7 @@ export async function onReady() { setupMenu(); setupMenuHandler(); setupProtocolHandler(); + setupIsolatedActionsProtocol(); setupFileListeners(); setupUpdates(); setupDialogs(); @@ -210,6 +215,10 @@ export function main() { // Set the app's name app.name = 'Electron Fiddle'; + // Register the isolated-actions:// scheme as privileged. Must happen + // before `app.whenReady()` resolves. + registerIsolatedActionsScheme(); + // Ensure that there's only ever one Fiddle running listenForProtocolHandler(); diff --git a/src/main/themes.ts b/src/main/themes.ts index 6e38e6b096..adc048b287 100644 --- a/src/main/themes.ts +++ b/src/main/themes.ts @@ -1,10 +1,11 @@ import * as path from 'node:path'; -import { IpcMainInvokeEvent, app, shell } from 'electron'; +import { BrowserWindow, IpcMainInvokeEvent, app, shell } from 'electron'; import fs from 'fs-extra'; import namor from 'namor'; import { ipcMainManager } from './ipc'; +import { getIsolatedRunButtonFrame } from './isolated-actions'; import { IpcEvents } from '../ipc-events'; import { FiddleTheme, @@ -125,7 +126,19 @@ export async function openThemeFolder() { export function setupThemes() { ipcMainManager.handle( IpcEvents.READ_THEME_FILE, - (_: IpcMainInvokeEvent, name: string) => readThemeFile(name), + async (_: IpcMainInvokeEvent, name: string) => { + const theme = await readThemeFile(name); + // Hand the loaded theme to every isolated run-button OOPIF + // so it can cache it for later — this keeps the renderer off + // the CSS path while still letting the iframe apply by name. + if (theme) { + for (const window of BrowserWindow.getAllWindows()) { + const frame = getIsolatedRunButtonFrame(window.webContents); + if (frame) frame.send(IpcEvents.THEME_LOADED, theme); + } + } + return theme; + }, ); ipcMainManager.handle( IpcEvents.GET_AVAILABLE_THEMES, diff --git a/src/main/windows.ts b/src/main/windows.ts index df28d6655a..9bdeeb2fc8 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -58,6 +58,9 @@ export function getMainWindowOptions(): Electron.BrowserWindowConstructorOptions preload: !!process.env.VITEST ? path.join(process.cwd(), './.webpack/renderer/main_window/preload.js') : MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY, + nodeIntegration: false, + // Run the preload script in subframes + nodeIntegrationInSubFrames: true, }, }; } diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 1108986bf9..dfb515cd62 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -44,6 +44,7 @@ const channelMapping: Record = { 'select-all-in-editor': IpcEvents.SELECT_ALL_IN_EDITOR, 'set-show-me-template': IpcEvents.SET_SHOW_ME_TEMPLATE, 'show-welcome-tour': IpcEvents.SHOW_WELCOME_TOUR, + 'theme-loaded': IpcEvents.THEME_LOADED, 'toggle-bisect': IpcEvents.BISECT_COMMANDS_TOGGLE, 'toggle-monaco-option': IpcEvents.MONACO_TOGGLE_OPTION, 'undo-in-editor': IpcEvents.UNDO_IN_EDITOR, @@ -51,30 +52,62 @@ const channelMapping: Record = { 'version-state-changed': IpcEvents.VERSION_STATE_CHANGED, } as const; +function addEventListener( + type: FiddleEvent, + listener: (...args: any[]) => void, + options?: { signal: AbortSignal }, +) { + const channel = channelMapping[type]; + if (!channel) return; + const ipcListener = (_event: IpcRendererEvent, ...args: any[]) => { + listener(...args); + }; + ipcRenderer.on(channel, ipcListener); + if (options?.signal) { + options.signal.addEventListener('abort', () => { + ipcRenderer.off(channel, ipcListener); + }); + } +} + +function removeAllListeners(type: FiddleEvent) { + const channel = channelMapping[type]; + if (channel) { + ipcRenderer.removeAllListeners(channel); + } +} + +const ISOLATED_ACTIONS_PROTOCOL = 'isolated-actions:'; + +// This preload runs in every frame in the default session, so +// expose a different API surface depending on the protocol. async function preload() { - await setupFiddleGlobal(); + if (location.protocol === ISOLATED_ACTIONS_PROTOCOL) { + setupIsolatedActionsGlobal(); + } else { + await setupFiddleGlobal(); + } +} + +function setupIsolatedActionsGlobal() { + contextBridge.exposeInMainWorld('IsolatedActionsElectronFiddle', { + startFiddle() { + ipcRenderer.invoke(IpcEvents.START_FIDDLE); + }, + stopFiddle() { + ipcRenderer.send(IpcEvents.STOP_FIDDLE); + }, + readThemeFile(name: string) { + return ipcRenderer.invoke(IpcEvents.READ_THEME_FILE, name); + }, + addEventListener, + removeAllListeners, + }); } export async function setupFiddleGlobal() { contextBridge.exposeInMainWorld('ElectronFiddle', { - addEventListener( - type: FiddleEvent, - listener: (...args: any[]) => void, - options?: { signal: AbortSignal }, - ) { - const channel = channelMapping[type]; - if (channel) { - const ipcListener = (_event: IpcRendererEvent, ...args: any[]) => { - listener(...args); - }; - ipcRenderer.on(channel, ipcListener); - if (options?.signal) { - options.signal.addEventListener('abort', () => { - ipcRenderer.off(channel, ipcListener); - }); - } - } - }, + addEventListener, addModules( { dir, packageManager, useSocketFirewall }: PMOperationOptions, ...names: Array @@ -233,12 +266,7 @@ export async function setupFiddleGlobal() { readThemeFile(name?: string) { return ipcRenderer.invoke(IpcEvents.READ_THEME_FILE, name); }, - removeAllListeners(type: FiddleEvent) { - const channel = channelMapping[type]; - if (channel) { - ipcRenderer.removeAllListeners(channel); - } - }, + removeAllListeners, async removeVersion(version: string) { return ipcRenderer.invoke(IpcEvents.REMOVE_VERSION, version); }, @@ -265,9 +293,6 @@ export async function setupFiddleGlobal() { showWindow() { ipcRenderer.send(IpcEvents.SHOW_WINDOW); }, - async startFiddle() { - await ipcRenderer.invoke(IpcEvents.START_FIDDLE); - }, stopFiddle() { ipcRenderer.send(IpcEvents.STOP_FIDDLE); }, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 7c237ef8ab..ec72a3d87c 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,6 +1,5 @@ import { autorun, reaction, when } from 'mobx'; -import { PREFERS_DARK_MEDIA_QUERY } from './constants'; import { ElectronTypes } from './electron-types'; import { FileManager } from './file-manager'; import { RemoteLoader } from './remote-loader'; @@ -12,6 +11,7 @@ import { getElectronVersions, migrateLocalVersionsFromLocalStorage, } from './versions'; +import { PREFERS_DARK_MEDIA_QUERY } from '../constants'; import { EditorId, EditorValues, @@ -184,13 +184,28 @@ export class App { // match theme to system when box is ticked reaction( () => this.state.isUsingSystemTheme, - () => { - if (this.state.isUsingSystemTheme) { + (isUsingSystemTheme) => { + if (isUsingSystemTheme) { window.ElectronFiddle.setNativeTheme('system'); this.loadTheme(getCurrentTheme().file); } else { this.loadTheme(this.state.theme); } + + // Tell every isolated-actions:// iframe whether we're using system theme + for (const iframe of Array.from( + document.querySelectorAll( + 'iframe[src^="isolated-actions://"]', + ), + )) { + iframe.contentWindow?.postMessage( + { + type: 'isolated-run-button-using-system-theme', + value: isUsingSystemTheme, + }, + new URL(iframe.src).origin, + ); + } }, ); @@ -245,6 +260,18 @@ export class App { window.ElectronFiddle.setNativeTheme('light'); } } + + // Tell every isolated-actions:// iframe the selected theme name + for (const iframe of Array.from( + document.querySelectorAll( + 'iframe[src^="isolated-actions://"]', + ), + )) { + iframe.contentWindow?.postMessage( + { type: 'isolated-run-button-theme', themeName: theme.file }, + new URL(iframe.src).origin, + ); + } } public setupOfflineListener(): void { diff --git a/src/renderer/components/commands-runner.tsx b/src/renderer/components/commands-runner.tsx index d0ed61d4d6..ef2b82c931 100644 --- a/src/renderer/components/commands-runner.tsx +++ b/src/renderer/components/commands-runner.tsx @@ -1,84 +1,73 @@ import * as React from 'react'; -import { Button, ButtonProps, Spinner } from '@blueprintjs/core'; -import { observer } from 'mobx-react'; - -import { InstallState, VersionSource } from '../../interfaces'; -import { AppState } from '../state'; - -interface RunnerProps { - appState: AppState; -} +const ISOLATED_RUN_BUTTON_ORIGIN = 'isolated-actions://run-button'; +const RESIZE_MESSAGE = 'isolated-run-button-resize'; +const FOCUS_MESSAGE = 'isolated-run-button-focus'; /** - * The runner component is responsible for actually launching the fiddle - * with Electron. It also renders the button that does so. + * Renders the run button as a borderless cross-origin iframe served + * via the `isolated-actions://` scheme. This ensures the button is + * protected from a compromised renderer and fiddles cannot be started + * programmatically without user interaction. */ -export const Runner = observer( - class Runner extends React.Component { - public render() { - const { downloaded, downloading, missing, installing, installed } = - InstallState; - const { - isRunning, - isInstallingModules, - currentElectronVersion, - isOnline, - } = this.props.appState; +export class Runner extends React.Component> { + private iframeRef = React.createRef(); + private readonly src: string; - const { downloadProgress, source, state } = currentElectronVersion; - const props: ButtonProps = { disabled: true }; + constructor(props: Record) { + super(props); + const initialTheme = window.app?.state?.theme ?? ''; + const initialUsingSystemTheme = + window.app?.state?.isUsingSystemTheme ?? true; + this.src = + `${ISOLATED_RUN_BUTTON_ORIGIN}/` + + `?initialTheme=${encodeURIComponent(initialTheme)}` + + `&initialUsingSystemTheme=${initialUsingSystemTheme}`; + } - if ([downloading, missing].includes(state) && !isOnline) { - props.text = 'Offline'; - props.icon = 'satellite'; - return