From 3680f98190b00bd9a628d31aafd13c5130cb1e64 Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 5 Aug 2026 20:28:57 -0700 Subject: [PATCH 1/2] Move recent projects into the desktop File menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the desktop the upload screen's recent list duplicated what a native app puts in File › Recent Projects, so drop it there and build the real menu instead: Open Project…, Reopen Last Project and a Recent Projects submenu. The list lives in the renderer's IndexedDB, which the main process can't read, so the renderer publishes an (id, name) snapshot whenever it can have changed and the menu sends commands back. Commands wait for a window: the menu bar outlives the last window on macOS, so a click opens one and the command queues until the renderer announces it is listening, then until the page is cross-origin isolated and can actually take media. Closing the window while a project is open now returns to the upload screen instead of quitting — the autosave is flushed first, and the next close (on the upload screen) is a real one. Co-Authored-By: Claude Opus 5 (1M context) --- components/Editor.tsx | 52 +++++++++++- components/UploadScreen.tsx | 3 +- electron/main.ts | 90 +++++++++++++++++++- electron/menu.ts | 164 ++++++++++++++++++++++++++++++++++++ electron/preload.ts | 21 +++++ hooks/useDesktopMenu.ts | 139 ++++++++++++++++++++++++++++++ types/rescript-desktop.d.ts | 12 +++ 7 files changed, 478 insertions(+), 3 deletions(-) create mode 100644 electron/menu.ts create mode 100644 hooks/useDesktopMenu.ts diff --git a/components/Editor.tsx b/components/Editor.tsx index 02f51f9..1fc6dcb 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { useEditorStore } from "@/lib/store"; import { getCutRanges, isWordCutOut } from "@/lib/edits"; @@ -9,8 +9,11 @@ import { VAD_SAMPLE_RATE } from "@/lib/vad"; import { isElectron } from "@/lib/platform"; import { reportError } from "@/lib/sentry"; import { startSessionReporting } from "@/lib/telemetry"; +import { useCrossOriginIsolated } from "@/hooks/useCrossOriginIsolated"; +import { useDesktopMenu } from "@/hooks/useDesktopMenu"; import { useIsDesktopLayout } from "@/hooks/useIsDesktopLayout"; import { useTranscriber } from "@/hooks/useTranscriber"; +import { detectMediaKind, MEDIA_ACCEPT } from "@/lib/media"; import TopBar from "./TopBar"; import UploadScreen from "./UploadScreen"; import TranscriptPanel from "./TranscriptPanel"; @@ -137,6 +140,38 @@ export default function Editor() { const [modeTransitioning, setModeTransitioning] = useState(false); const wasIdle = useRef(status === "idle"); + // File › Open Project… reaches the same picker the upload screen uses, from + // anywhere in the app. The pipeline needs SharedArrayBuffer, so the menu can + // only load media once the page is confirmed cross-origin isolated. + const menuInputRef = useRef(null); + const isolation = useCrossOriginIsolated(); + const openFilePicker = useCallback(() => menuInputRef.current?.click(), []); + useDesktopMenu(openFilePicker, isolation === "ready"); + + const handleMenuFile = useCallback( + (file: File | undefined) => { + if (!file) return; + if (!detectMediaKind(file)) { + alert("Please choose a video or audio file."); + return; + } + const { source, pendingTranscript } = useEditorStore.getState(); + if (source === "import") { + if (!pendingTranscript) { + alert("Choose a transcript file from the source menu first."); + return; + } + loadVideo(file, { + words: pendingTranscript.words, + speakers: pendingTranscript.speakers, + }); + return; + } + loadVideo(file); + }, + [loadVideo] + ); + // Daily-active signal: reports the launch, then again on each day rollover so // a long-running window doesn't look churned. useEffect(() => startSessionReporting(), []); @@ -335,6 +370,21 @@ export default function Editor() { )} + {isElectron && ( + // Present in the layout tree (not display:none) so the native menu's + // click() reliably opens the picker. + { + handleMenuFile(e.target.files?.[0]); + // Allow re-picking the same file after a "start over". + e.target.value = ""; + }} + /> + )} ); diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 134d3c0..4717ebe 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -387,7 +387,8 @@ export default function UploadScreen({ /> - {ready && ( + {/* On desktop the recent list lives in the native File menu instead. */} + {ready && !isElectron && ( (); +/** Renderers that have mounted and subscribed to menu commands. A freshly + * created (or reloading) window isn't listening yet, so its commands wait. */ +const readyRenderers = new WeakSet(); +const pendingCommands = new WeakMap(); + +function flushPendingCommands(contents: WebContents): void { + const queued = pendingCommands.get(contents); + pendingCommands.delete(contents); + for (const command of queued ?? []) contents.send("menu:command", command); +} + +/** Deliver a File-menu command, launching a window if the app is running + * window-less (macOS keeps the menu bar after the last window closes). */ +function dispatchMenuCommand(command: MenuCommand): void { + const win = + BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? createWindow(); + const contents = win.webContents; + if (readyRenderers.has(contents)) { + contents.send("menu:command", command); + return; + } + const queued = pendingCommands.get(contents) ?? []; + queued.push(command); + pendingCommands.set(contents, queued); +} + function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } @@ -160,6 +201,10 @@ function applyWindowMode(win: BrowserWindow, mode: WindowMode): void { ); } +/** Set once the app is really terminating, so the close interception below + * doesn't swallow the quit. */ +let quitting = false; + function createWindow(): BrowserWindow { const win = new BrowserWindow({ ...WINDOW_SIZES.compact, @@ -192,6 +237,25 @@ function createWindow(): BrowserWindow { win.once("ready-to-show", () => win.show()); + // A reload tears down the listener the renderer registered; make it re-announce. + win.webContents.on("did-start-navigation", (event) => { + if (event.isSameDocument) return; + readyRenderers.delete(win.webContents); + pendingCommands.delete(win.webContents); + }); + + // Closing while the editor is open drops the project rather than the window: + // the renderer returns to the upload screen and the shell shrinks back. The + // next close (already on the upload screen) is a real close. Guarded on the + // renderer being live, so an unresponsive page can still be closed. + win.on("close", (event) => { + if (quitting) return; + if (windowModes.get(win) !== "expanded") return; + if (!readyRenderers.has(win.webContents)) return; + event.preventDefault(); + win.webContents.send("menu:command", { type: "close-project" } satisfies MenuCommand); + }); + // The page pads its top bar for the traffic lights, which macOS hides in // full screen; tell it when that changes so the gap can collapse. const emitFullScreen = () => { @@ -257,9 +321,33 @@ if (!gotLock) { ipcMain.on("telemetry:set-enabled", (_event, value: unknown) => { setMainTelemetryEnabled(value === true); }); + // The saved projects live in the renderer's IndexedDB; it pushes a snapshot + // whenever the list changes so the File menu can list them. + ipcMain.on("menu:set-recents", (_event, value: unknown) => { + if (!Array.isArray(value)) return; + const recents: RecentProject[] = []; + for (const entry of value) { + if (!entry || typeof entry !== "object") continue; + const { id, name } = entry as { id?: unknown; name?: unknown }; + if (typeof id !== "string" || typeof name !== "string") continue; + recents.push({ id, name }); + } + setRecentProjects(recents); + }); + // The renderer announces itself once it is listening for menu commands; until + // then anything the menu fired at a just-opened window is held. + ipcMain.on("menu:renderer-ready", (event) => { + readyRenderers.add(event.sender); + flushPendingCommands(event.sender); + }); + + app.on("before-quit", () => { + quitting = true; + }); app.whenReady().then(() => { if (!isDev) registerAppProtocol(); + buildAppMenu(dispatchMenuCommand); createWindow(); initAutoUpdater(); diff --git a/electron/menu.ts b/electron/menu.ts new file mode 100644 index 0000000..283011b --- /dev/null +++ b/electron/menu.ts @@ -0,0 +1,164 @@ +import { app, Menu, type MenuItemConstructorOptions } from "electron"; + +const isMac = process.platform === "darwin"; + +/** Mirror of the renderer's ProjectMeta, trimmed to what the menu draws. */ +export interface RecentProject { + id: string; + name: string; +} + +/** Anything the menu asks the renderer to do. Mirrored in types/rescript-desktop.d.ts. */ +export type MenuCommand = + | { type: "open-file" } + | { type: "open-project"; id: string } + | { type: "clear-recents" } + /** Leave the editor for the upload screen (a close request, intercepted). */ + | { type: "close-project" }; + +/** The renderer owns the project list (it lives in IndexedDB); this is the last + * snapshot it pushed, kept so the menu can be rebuilt without asking again. */ +let recents: RecentProject[] = []; + +/** How many entries the "Recent Projects" submenu shows before it gets unwieldy. */ +const MAX_RECENT_ITEMS = 10; + +/** Set by main.ts — routes a command to a window, opening one if none is left + * (the menu stays alive on macOS after the last window closes). */ +let dispatch: (command: MenuCommand) => void = () => {}; + +function send(command: MenuCommand): void { + dispatch(command); +} + +function fileMenu(): MenuItemConstructorOptions { + const [last] = recents; + return { + label: "File", + submenu: [ + { + label: "Open Project…", + accelerator: "CmdOrCtrl+O", + click: () => send({ type: "open-file" }), + }, + { + label: "Reopen Last Project", + accelerator: "Shift+CmdOrCtrl+O", + enabled: last !== undefined, + click: () => { + if (last) send({ type: "open-project", id: last.id }); + }, + }, + { + label: "Recent Projects", + submenu: + recents.length === 0 + ? [{ label: "No Recent Projects", enabled: false }] + : [ + ...recents.slice(0, MAX_RECENT_ITEMS).map((p) => ({ + label: p.name, + click: () => send({ type: "open-project", id: p.id }), + })), + { type: "separator" as const }, + { + label: "Clear Recent Projects", + click: () => send({ type: "clear-recents" }), + }, + ], + }, + { type: "separator" }, + isMac ? { role: "close" } : { role: "quit" }, + ], + }; +} + +/** Full application menu. Everything outside File is the Electron default — + * replacing the menu drops the built-in one wholesale, so it has to be restated. */ +function template(): MenuItemConstructorOptions[] { + return [ + ...(isMac + ? ([ + { + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }, + ] satisfies MenuItemConstructorOptions[]) + : []), + fileMenu(), + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + ...(isMac + ? ([ + { role: "pasteAndMatchStyle" }, + { role: "delete" }, + { role: "selectAll" }, + ] satisfies MenuItemConstructorOptions[]) + : ([ + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ] satisfies MenuItemConstructorOptions[])), + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + ...(isMac + ? ([ + { type: "separator" }, + { role: "front" }, + { type: "separator" }, + { role: "window" }, + ] satisfies MenuItemConstructorOptions[]) + : ([{ role: "close" }] satisfies MenuItemConstructorOptions[])), + ], + }, + ]; +} + +export function buildAppMenu( + commandDispatcher?: (command: MenuCommand) => void +): void { + if (commandDispatcher) dispatch = commandDispatcher; + Menu.setApplicationMenu(Menu.buildFromTemplate(template())); +} + +/** Replace the recent list and redraw the menu. Called whenever the renderer's + * IndexedDB project list changes (open, autosave, delete). */ +export function setRecentProjects(next: RecentProject[]): void { + recents = next; + buildAppMenu(); +} diff --git a/electron/preload.ts b/electron/preload.ts index 1f947a4..2b54b7e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -24,6 +24,27 @@ contextBridge.exposeInMainWorld("rescriptDesktop", { setTelemetryEnabled: (enabled: boolean) => { ipcRenderer.send("telemetry:set-enabled", enabled); }, + /** + * Publish the saved-project list (newest first) so the main process can draw + * it under File › Recent Projects. Only id + name are sent. + */ + setRecentProjects: (projects: Array<{ id: string; name: string }>) => { + ipcRenderer.send( + "menu:set-recents", + projects.map(({ id, name }) => ({ id, name })) + ); + }, + /** Subscribe to File-menu actions; returns an unsubscribe function. */ + onMenuCommand: (callback: (command: unknown) => void) => { + const listener = (_event: IpcRendererEvent, command: unknown) => callback(command); + ipcRenderer.on("menu:command", listener); + // Tell the main process the page is listening, so commands fired at a + // window that was opened *by* the menu aren't lost before mount. + ipcRenderer.send("menu:renderer-ready"); + return () => { + ipcRenderer.off("menu:command", listener); + }; + }, isFullScreen: (): Promise => ipcRenderer.invoke("window:is-full-screen"), onFullScreenChange: (callback: (value: boolean) => void) => { const listener = (_event: IpcRendererEvent, value: boolean) => callback(value); diff --git a/hooks/useDesktopMenu.ts b/hooks/useDesktopMenu.ts new file mode 100644 index 0000000..fe7bac2 --- /dev/null +++ b/hooks/useDesktopMenu.ts @@ -0,0 +1,139 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { isElectron } from "@/lib/platform"; +import { deleteProject, listProjects } from "@/lib/projects"; +import { useEditorStore } from "@/lib/store"; +import type { MenuCommand } from "@/types/rescript-desktop"; + +/** + * Desktop-only bridge for the native File menu. + * + * The recent-project list lives in the renderer's IndexedDB, so the menu can't + * read it directly: we push a snapshot up whenever it could have changed, and + * handle the commands the menu sends back (open a file, open/clear recents, + * leave the editor on an intercepted window close). + * + * @param openFilePicker Opens the media picker — the caller owns the so + * the click comes from a real element in the layout tree (detached inputs + * don't reliably open a picker in Chromium). + * @param ready False until the page can accept media (cross-origin isolation). + * Commands that need media wait rather than getting dropped: the menu can + * open a window from scratch, and it lands long before isolation settles. + */ +export function useDesktopMenu(openFilePicker: () => void, ready: boolean): void { + const projectId = useEditorStore((s) => s.projectId); + const status = useEditorStore((s) => s.status); + + const syncRecents = useCallback(async () => { + if (!window.rescriptDesktop) return; + try { + const projects = await listProjects(); + window.rescriptDesktop.setRecentProjects( + projects.map(({ id, name }) => ({ id, name })) + ); + } catch (err) { + console.warn("Failed to publish recent projects to the app menu.", err); + } + }, []); + + // Re-publish on anything that can add, rename or reorder a project: a new + // project id (first autosave / open), a status change, or coming back to the + // window after edits elsewhere. + useEffect(() => { + if (!isElectron) return; + void syncRecents(); + const onFocus = () => void syncRecents(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [syncRecents, projectId, status]); + + const clearRecents = useCallback(async () => { + if (!confirm("Remove all recent projects? Their saved edits are deleted.")) return; + try { + const projects = await listProjects(); + const active = useEditorStore.getState().projectId; + await Promise.all(projects.map((p) => deleteProject(p.id))); + if (active && projects.some((p) => p.id === active)) { + useEditorStore.getState().reset(); + } + } catch (err) { + console.error(err); + alert("Could not clear recent projects."); + } finally { + await syncRecents(); + } + }, [syncRecents]); + + const closeProject = useCallback(async () => { + // The window stays; only the project goes. Flush the debounced autosave + // first so the last edits survive the trip back to the upload screen. + try { + const { flushProjectAutosave } = await import("@/lib/autosave"); + await flushProjectAutosave(); + } catch (err) { + console.warn("Failed to save before closing the project.", err); + } + useEditorStore.getState().reset(); + await syncRecents(); + }, [syncRecents]); + + const run = useCallback( + (command: MenuCommand) => { + switch (command.type) { + case "open-file": + openFilePicker(); + return; + case "open-project": + void useEditorStore + .getState() + .openProject(command.id) + .catch(async (err) => { + console.error(err); + alert(err instanceof Error ? err.message : "Could not open that project."); + await syncRecents(); + }); + return; + case "clear-recents": + void clearRecents(); + return; + case "close-project": + void closeProject(); + } + }, + [clearRecents, closeProject, openFilePicker, syncRecents] + ); + + // Commands that touch media can't run before the page is cross-origin + // isolated; hold the last one and replay it once it is. + const deferred = useRef(null); + const runRef = useRef(run); + const readyRef = useRef(ready); + useEffect(() => { + runRef.current = run; + readyRef.current = ready; + }, [run, ready]); + + useEffect(() => { + if (!ready || !deferred.current) return; + const command = deferred.current; + deferred.current = null; + runRef.current(command); + }, [ready]); + + // Subscribed once: the callback reads the latest handlers through refs, so a + // re-subscribe (which re-announces readiness to the main process) isn't + // needed every time a dependency changes. + useEffect(() => { + const desktop = window.rescriptDesktop; + if (!desktop) return; + return desktop.onMenuCommand((command: MenuCommand) => { + const needsMedia = command.type === "open-file" || command.type === "open-project"; + if (needsMedia && !readyRef.current) { + deferred.current = command; + return; + } + runRef.current(command); + }); + }, []); +} diff --git a/types/rescript-desktop.d.ts b/types/rescript-desktop.d.ts index f1d0afc..6aa537a 100644 --- a/types/rescript-desktop.d.ts +++ b/types/rescript-desktop.d.ts @@ -1,6 +1,14 @@ /** Resting sizes the Electron shell switches between. */ export type WindowMode = "compact" | "expanded"; +/** Actions the native File menu delegates to the renderer. */ +export type MenuCommand = + | { type: "open-file" } + | { type: "open-project"; id: string } + | { type: "clear-recents" } + /** Leave the editor for the upload screen (an intercepted window close). */ + | { type: "close-project" }; + /** Desktop bridge exposed by electron/preload.ts when running inside Electron. */ export interface RescriptDesktop { platform: NodeJS.Platform; @@ -13,6 +21,10 @@ export interface RescriptDesktop { setWindowMode: (mode: WindowMode) => void; /** Mirror the telemetry opt-out to the main process, which gates its own reporting. */ setTelemetryEnabled: (enabled: boolean) => void; + /** Publish the saved-project list (newest first) for File › Recent Projects. */ + setRecentProjects: (projects: Array<{ id: string; name: string }>) => void; + /** Subscribe to File-menu actions; returns an unsubscribe function. */ + onMenuCommand: (callback: (command: MenuCommand) => void) => () => void; isFullScreen: () => Promise; /** Subscribe to full-screen changes; returns an unsubscribe function. */ onFullScreenChange: (callback: (value: boolean) => void) => () => void; From 71d4168641bb7727744a8cceff4b06afe9b8251d Mon Sep 17 00:00:00 2001 From: Wassim Gharbi Date: Wed, 5 Aug 2026 20:34:09 -0700 Subject: [PATCH 2/2] =?UTF-8?q?Drive=20the=20Open=20Project=E2=80=A6=20pic?= =?UTF-8?q?ker=20through=20a=20user=20gesture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium only shows a file chooser under user activation, and an IPC message doesn't carry one — the input.click() the menu triggered was dropped without a word. The main process now calls window.rescriptOpenFilePicker via executeJavaScript with userGesture, which grants the activation, so opening the picker leaves the command channel entirely. A file chosen before the page is cross-origin isolated waits until it is rather than failing in the pipeline. Co-Authored-By: Claude Opus 5 (1M context) --- components/Editor.tsx | 45 ++++++++++++++++++++++++++++--------- electron/main.ts | 17 ++++++++++++-- hooks/useDesktopMenu.ts | 33 ++++++++++++++++----------- types/rescript-desktop.d.ts | 8 +++++-- 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/components/Editor.tsx b/components/Editor.tsx index 1fc6dcb..e7a20b4 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -141,20 +141,15 @@ export default function Editor() { const wasIdle = useRef(status === "idle"); // File › Open Project… reaches the same picker the upload screen uses, from - // anywhere in the app. The pipeline needs SharedArrayBuffer, so the menu can - // only load media once the page is confirmed cross-origin isolated. + // anywhere in the app. const menuInputRef = useRef(null); const isolation = useCrossOriginIsolated(); + const isolated = isolation === "ready"; const openFilePicker = useCallback(() => menuInputRef.current?.click(), []); - useDesktopMenu(openFilePicker, isolation === "ready"); + useDesktopMenu(openFilePicker, isolated); - const handleMenuFile = useCallback( - (file: File | undefined) => { - if (!file) return; - if (!detectMediaKind(file)) { - alert("Please choose a video or audio file."); - return; - } + const startMenuFile = useCallback( + (file: File) => { const { source, pendingTranscript } = useEditorStore.getState(); if (source === "import") { if (!pendingTranscript) { @@ -172,6 +167,36 @@ export default function Editor() { [loadVideo] ); + // The pipeline needs SharedArrayBuffer, so a file picked before the page is + // cross-origin isolated waits here instead of failing on load. + const deferredFile = useRef(null); + const startMenuFileRef = useRef(startMenuFile); + useEffect(() => { + startMenuFileRef.current = startMenuFile; + }, [startMenuFile]); + useEffect(() => { + if (!isolated || !deferredFile.current) return; + const file = deferredFile.current; + deferredFile.current = null; + startMenuFileRef.current(file); + }, [isolated]); + + const handleMenuFile = useCallback( + (file: File | undefined) => { + if (!file) return; + if (!detectMediaKind(file)) { + alert("Please choose a video or audio file."); + return; + } + if (!isolated) { + deferredFile.current = file; + return; + } + startMenuFile(file); + }, + [isolated, startMenuFile] + ); + // Daily-active signal: reports the launch, then again on each day rollover so // a long-running window doesn't look churned. useEffect(() => startSessionReporting(), []); diff --git a/electron/main.ts b/electron/main.ts index 4554e4c..3440e55 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -141,10 +141,23 @@ const windowModes = new WeakMap(); const readyRenderers = new WeakSet(); const pendingCommands = new WeakMap(); +function deliverMenuCommand(contents: WebContents, command: MenuCommand): void { + if (command.type === "open-file") { + // Chromium only opens a file chooser under user activation, which an IPC + // message doesn't carry — the click() is silently dropped. executeJavaScript + // can grant one, so the picker is driven that way instead. + void contents + .executeJavaScript("window.rescriptOpenFilePicker?.()", true) + .catch((err: unknown) => console.error("Failed to open the file picker.", err)); + return; + } + contents.send("menu:command", command); +} + function flushPendingCommands(contents: WebContents): void { const queued = pendingCommands.get(contents); pendingCommands.delete(contents); - for (const command of queued ?? []) contents.send("menu:command", command); + for (const command of queued ?? []) deliverMenuCommand(contents, command); } /** Deliver a File-menu command, launching a window if the app is running @@ -154,7 +167,7 @@ function dispatchMenuCommand(command: MenuCommand): void { BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0] ?? createWindow(); const contents = win.webContents; if (readyRenderers.has(contents)) { - contents.send("menu:command", command); + deliverMenuCommand(contents, command); return; } const queued = pendingCommands.get(contents) ?? []; diff --git a/hooks/useDesktopMenu.ts b/hooks/useDesktopMenu.ts index fe7bac2..da2dab1 100644 --- a/hooks/useDesktopMenu.ts +++ b/hooks/useDesktopMenu.ts @@ -14,12 +14,13 @@ import type { MenuCommand } from "@/types/rescript-desktop"; * handle the commands the menu sends back (open a file, open/clear recents, * leave the editor on an intercepted window close). * - * @param openFilePicker Opens the media picker — the caller owns the so - * the click comes from a real element in the layout tree (detached inputs - * don't reliably open a picker in Chromium). + * @param openFilePicker Opens the media picker. Published on `window` because + * the main process has to invoke it through executeJavaScript to give the + * call the user activation a file chooser requires — over plain IPC Chromium + * drops the dialog. * @param ready False until the page can accept media (cross-origin isolation). - * Commands that need media wait rather than getting dropped: the menu can - * open a window from scratch, and it lands long before isolation settles. + * Opening a project waits rather than getting dropped: the menu can open a + * window from scratch, and it lands long before isolation settles. */ export function useDesktopMenu(openFilePicker: () => void, ready: boolean): void { const projectId = useEditorStore((s) => s.projectId); @@ -78,12 +79,19 @@ export function useDesktopMenu(openFilePicker: () => void, ready: boolean): void await syncRecents(); }, [syncRecents]); + // File › Open Project… reaches the picker here rather than over the command + // channel; see the note on the parameter. + useEffect(() => { + if (!isElectron) return; + window.rescriptOpenFilePicker = openFilePicker; + return () => { + delete window.rescriptOpenFilePicker; + }; + }, [openFilePicker]); + const run = useCallback( (command: MenuCommand) => { switch (command.type) { - case "open-file": - openFilePicker(); - return; case "open-project": void useEditorStore .getState() @@ -101,11 +109,11 @@ export function useDesktopMenu(openFilePicker: () => void, ready: boolean): void void closeProject(); } }, - [clearRecents, closeProject, openFilePicker, syncRecents] + [clearRecents, closeProject, syncRecents] ); - // Commands that touch media can't run before the page is cross-origin - // isolated; hold the last one and replay it once it is. + // Opening a project can't run before the page is cross-origin isolated; hold + // the last such command and replay it once it is. const deferred = useRef(null); const runRef = useRef(run); const readyRef = useRef(ready); @@ -128,8 +136,7 @@ export function useDesktopMenu(openFilePicker: () => void, ready: boolean): void const desktop = window.rescriptDesktop; if (!desktop) return; return desktop.onMenuCommand((command: MenuCommand) => { - const needsMedia = command.type === "open-file" || command.type === "open-project"; - if (needsMedia && !readyRef.current) { + if (command.type === "open-project" && !readyRef.current) { deferred.current = command; return; } diff --git a/types/rescript-desktop.d.ts b/types/rescript-desktop.d.ts index 6aa537a..afeaf4f 100644 --- a/types/rescript-desktop.d.ts +++ b/types/rescript-desktop.d.ts @@ -1,9 +1,10 @@ /** Resting sizes the Electron shell switches between. */ export type WindowMode = "compact" | "expanded"; -/** Actions the native File menu delegates to the renderer. */ +/** Actions the native File menu delegates to the renderer over IPC. Opening the + * file picker isn't one of them — a file chooser needs user activation, so the + * main process calls `window.rescriptOpenFilePicker` instead. */ export type MenuCommand = - | { type: "open-file" } | { type: "open-project"; id: string } | { type: "clear-recents" } /** Leave the editor for the upload screen (an intercepted window close). */ @@ -33,6 +34,9 @@ export interface RescriptDesktop { declare global { interface Window { rescriptDesktop?: RescriptDesktop; + /** Opens the media picker. Set by the renderer, called by the main process + * through executeJavaScript so the dialog gets a user activation. */ + rescriptOpenFilePicker?: () => void; } }