diff --git a/components/Editor.tsx b/components/Editor.tsx index 02f51f9..e7a20b4 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,63 @@ 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. + const menuInputRef = useRef(null); + const isolation = useCrossOriginIsolated(); + const isolated = isolation === "ready"; + const openFilePicker = useCallback(() => menuInputRef.current?.click(), []); + useDesktopMenu(openFilePicker, isolated); + + const startMenuFile = useCallback( + (file: File) => { + 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] + ); + + // 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(), []); @@ -335,6 +395,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 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 ?? []) deliverMenuCommand(contents, 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)) { + deliverMenuCommand(contents, 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 +214,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 +250,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 +334,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..da2dab1 --- /dev/null +++ b/hooks/useDesktopMenu.ts @@ -0,0 +1,146 @@ +"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. 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). + * 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); + 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]); + + // 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-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, syncRecents] + ); + + // 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); + 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) => { + if (command.type === "open-project" && !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..afeaf4f 100644 --- a/types/rescript-desktop.d.ts +++ b/types/rescript-desktop.d.ts @@ -1,6 +1,15 @@ /** Resting sizes the Electron shell switches between. */ export type WindowMode = "compact" | "expanded"; +/** 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-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 +22,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; @@ -21,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; } }