From d8badee2181afea390c3c9d3e3d8c6c8ba4f0433 Mon Sep 17 00:00:00 2001 From: abduznik <85239936+abduznik@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:19:04 +0300 Subject: [PATCH] feat: add configurable recordings save location --- electron/electron-env.d.ts | 7 ++ electron/ipc/handlers.ts | 44 ++++++- electron/main.ts | 27 ++++- electron/preload.ts | 16 +++ electron/recording/recordingsLocationStore.ts | 42 +++++++ src/components/launch/HudDeviceSettings.tsx | 107 +++++++++++++++++- src/components/launch/LaunchWindow.module.css | 45 ++++++++ src/components/launch/LaunchWindow.tsx | 6 + src/i18n/locales/en/dialogs.json | 3 +- src/i18n/locales/en/launch.json | 8 +- 10 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 electron/recording/recordingsLocationStore.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 9e1fb7d0d..91173cb55 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -384,6 +384,13 @@ interface Window { revealInFolder: ( filePath: string, ) => Promise<{ success: boolean; error?: string; message?: string }>; + getRecordingsDir: () => Promise<{ path: string; isDefault: boolean }>; + chooseRecordingsDir: () => Promise< + { success: true; path: string } | { success: false; canceled?: boolean; message?: string } + >; + resetRecordingsDir: () => Promise< + { success: true; path: string } | { success: false; message?: string } + >; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; updateGlobalShortcut: (binding: { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7d63b934b..ee8a2e53c 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -54,7 +54,7 @@ import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; import { mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; -import { RECORDINGS_DIR } from "../main"; +import { getRecordingsDirInfo, RECORDINGS_DIR, setRecordingsDir } from "../main"; import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks"; import { readCursorRecordingFile as readCursorRecordingFileFrom, @@ -1758,6 +1758,48 @@ export function registerIpcHandlers( return recordingPrefs; }); + ipcMain.handle("get-recordings-dir", () => { + return getRecordingsDirInfo(); + }); + + ipcMain.handle("choose-recordings-dir", async () => { + const dialogOptions = buildDialogOptions( + { + title: mainT("dialogs", "fileDialogs.selectRecordingsFolder"), + defaultPath: RECORDINGS_DIR, + properties: ["openDirectory", "createDirectory"] as Array< + "openDirectory" | "createDirectory" + >, + }, + getMainWindow(), + ); + const result = await dialog.showOpenDialog(dialogOptions); + if (result.canceled || result.filePaths.length === 0) { + return { success: false, canceled: true }; + } + try { + const resolved = await setRecordingsDir(result.filePaths[0]); + return { success: true, path: resolved }; + } catch (error) { + console.error("Failed to switch recordings folder:", error); + return { + success: false, + message: "Failed to switch recordings folder", + error: String(error), + }; + } + }); + + ipcMain.handle("reset-recordings-dir", async () => { + try { + const resolved = await setRecordingsDir(null); + return { success: true, path: resolved }; + } catch (error) { + console.error("Failed to reset recordings folder:", error); + return { success: false, message: "Failed to reset recordings folder", error: String(error) }; + } + }); + ipcMain.handle("request-camera-access", async () => { if (process.platform !== "darwin") { return { success: true, granted: true, status: "granted" }; diff --git a/electron/main.ts b/electron/main.ts index 5c1388407..16d5dfb9e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,6 +23,7 @@ import { import { mainT, setMainLocale } from "./i18n"; import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers"; import { installMainProcessErrorGuards } from "./main-process-errors"; +import { RecordingsLocationStore } from "./recording/recordingsLocationStore"; import { registerSttIpc } from "./stt"; import { createCountdownOverlayWindow, @@ -64,7 +65,14 @@ if (process.platform === "linux") { installMainProcessErrorGuards(); -export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); +export const DEFAULT_RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); + +const recordingsLocationStore = new RecordingsLocationStore(app.getPath("userData")); + +// Mutable: reassigned by setRecordingsDir() when the user picks a custom +// location in settings. `handlers.ts` imports this as a live named binding, +// so every call site there sees the change immediately — no restart needed. +export let RECORDINGS_DIR = recordingsLocationStore.getCustomDir() ?? DEFAULT_RECORDINGS_DIR; async function ensureRecordingsDir() { try { @@ -76,6 +84,23 @@ async function ensureRecordingsDir() { } } +/** + * Switches where recordings are read from and written to, going forward. + * Pass `null` to reset to the default (userData/recordings). Does not move + * any existing files — the old location is left untouched. + */ +export async function setRecordingsDir(customDir: string | null): Promise { + const resolved = customDir ? path.resolve(customDir) : DEFAULT_RECORDINGS_DIR; + await fs.mkdir(resolved, { recursive: true }); + RECORDINGS_DIR = resolved; + await recordingsLocationStore.setCustomDir(customDir ? resolved : null); + return RECORDINGS_DIR; +} + +export function getRecordingsDirInfo() { + return { path: RECORDINGS_DIR, isDefault: RECORDINGS_DIR === DEFAULT_RECORDINGS_DIR }; +} + // The built directory structure // // ├─┬─┬ dist diff --git a/electron/preload.ts b/electron/preload.ts index 8e018ed8e..b6618c625 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -359,6 +359,22 @@ contextBridge.exposeInMainWorld("electronAPI", { revealInFolder: (filePath: string) => { return ipcRenderer.invoke("reveal-in-folder", filePath); }, + getRecordingsDir: () => { + return ipcRenderer.invoke("get-recordings-dir") as Promise<{ + path: string; + isDefault: boolean; + }>; + }, + chooseRecordingsDir: () => { + return ipcRenderer.invoke("choose-recordings-dir") as Promise< + { success: true; path: string } | { success: false; canceled?: boolean; message?: string } + >; + }, + resetRecordingsDir: () => { + return ipcRenderer.invoke("reset-recordings-dir") as Promise< + { success: true; path: string } | { success: false; message?: string } + >; + }, getShortcuts: () => { return ipcRenderer.invoke("get-shortcuts"); }, diff --git a/electron/recording/recordingsLocationStore.ts b/electron/recording/recordingsLocationStore.ts new file mode 100644 index 000000000..0d6750e8b --- /dev/null +++ b/electron/recording/recordingsLocationStore.ts @@ -0,0 +1,42 @@ +// Persists the user's chosen recordings folder across restarts. Stored next to +// llm-config.json in userData rather than inside the recordings folder itself, +// since the whole point is that folder can move. +import { readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +interface RecordingsLocationConfig { + recordingsDir: string | null; +} + +export class RecordingsLocationStore { + private readonly configPath: string; + private config: RecordingsLocationConfig = { recordingsDir: null }; + + constructor(userDataPath: string) { + this.configPath = path.join(userDataPath, "recordings-location.json"); + this.loadSync(); + } + + private loadSync(): void { + try { + const raw = readFileSync(this.configPath, "utf8"); + const parsed = JSON.parse(raw); + this.config = { + recordingsDir: typeof parsed.recordingsDir === "string" ? parsed.recordingsDir : null, + }; + } catch { + this.config = { recordingsDir: null }; + } + } + + /** The user's custom folder, or null to use the default (userData/recordings). */ + getCustomDir(): string | null { + return this.config.recordingsDir; + } + + async setCustomDir(dir: string | null): Promise { + this.config = { recordingsDir: dir }; + await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), "utf8"); + } +} diff --git a/src/components/launch/HudDeviceSettings.tsx b/src/components/launch/HudDeviceSettings.tsx index dcd44fb86..bb51e46f1 100644 --- a/src/components/launch/HudDeviceSettings.tsx +++ b/src/components/launch/HudDeviceSettings.tsx @@ -1,5 +1,5 @@ -import { Check, X } from "lucide-react"; -import { memo, useEffect, useRef } from "react"; +import { Check, FolderOpen, RotateCcw, X } from "lucide-react"; +import { memo, useEffect, useRef, useState } from "react"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import type { CameraDevice } from "../../hooks/useCameraDevices"; import { useCameraPreviewStream } from "../../hooks/useCameraPreviewStream"; @@ -22,8 +22,109 @@ export interface HudDeviceSettingsLabels { cameraUnavailable: string; preview: string; previewUnavailable: string; + storage: string; + storageHint: string; + chooseFolder: string; + resetToDefault: string; + changingFolder: string; + changeFolderFailed: string; } +/** Where recordings are cached and saved, with folder-picker and reset. */ +const RecordingsLocationSetting = memo(function RecordingsLocationSetting({ + labels, +}: { + labels: HudDeviceSettingsLabels; +}) { + const [info, setInfo] = useState<{ path: string; isDefault: boolean } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + window.electronAPI + ?.getRecordingsDir?.() + .then((result) => { + if (!cancelled) setInfo(result); + }) + .catch(() => { + // Nothing to show if this fails; the picker still works on demand. + }); + return () => { + cancelled = true; + }; + }, []); + + const handleChoose = async () => { + setBusy(true); + setError(false); + try { + const result = await window.electronAPI?.chooseRecordingsDir?.(); + if (result?.success) { + setInfo({ path: result.path, isDefault: false }); + } else if (result && !result.canceled) { + setError(true); + } + } catch { + setError(true); + } finally { + setBusy(false); + } + }; + + const handleReset = async () => { + setBusy(true); + setError(false); + try { + const result = await window.electronAPI?.resetRecordingsDir?.(); + if (result?.success) { + setInfo({ path: result.path, isDefault: true }); + } else { + setError(true); + } + } catch { + setError(true); + } finally { + setBusy(false); + } + }; + + return ( + <> +
{labels.storage}
+
{labels.storageHint}
+ {info ? ( +
+ {info.path} +
+ ) : null} +
+ + {info && !info.isDefault ? ( + + ) : null} +
+ {error ?
{labels.changeFolderFailed}
: null} + + ); +}); + /** Segmented input-level bar, driven by the live analyser. */ const LevelMeter = memo(function LevelMeter({ level }: { level: number }) { const lit = Math.round((Math.min(100, Math.max(0, level)) / 100) * LEVEL_SEGMENTS); @@ -204,6 +305,8 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ /> ) : null} + + ); }); diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 6756e3764..032dd8c1e 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -299,6 +299,51 @@ color: rgba(255, 255, 255, 0.4); } +/* Storage-location action buttons (choose folder / reset to default): same + visual language as .languageMenuItem but inline, side by side, not full-width. */ +.hudStorageActionRow { + display: flex; + gap: 6px; + padding: 0 10px 8px; +} + +.hudStorageActionButton { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0.425rem 0.5rem; + border-radius: 0.45rem; + font-size: 11px; + color: rgba(255, 255, 255, 0.72); + background: rgba(255, 255, 255, 0.05); + border: 0; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.hudStorageActionButton:hover, +.hudStorageActionButton:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + outline: none; +} + +.hudStorageActionButton:disabled { + opacity: 0.5; + cursor: default; +} + +.hudStoragePath { + padding: 0 10px 4px; + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* Thin separators between HUD toolbar control groups. */ .hudDivider { flex-shrink: 0; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 71d9eda27..dad95c3b6 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -773,6 +773,12 @@ export function LaunchWindow() { cameraUnavailable: t("webcam.unavailable"), preview: t("deviceSettings.preview"), previewUnavailable: t("deviceSettings.previewUnavailable"), + storage: t("deviceSettings.storage"), + storageHint: t("deviceSettings.storageHint"), + chooseFolder: t("deviceSettings.chooseFolder"), + resetToDefault: t("deviceSettings.resetToDefault"), + changingFolder: t("deviceSettings.changingFolder"), + changeFolderFailed: t("deviceSettings.changeFolderFailed"), }), [t], ); diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 90599af16..ebfb735a0 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -86,6 +86,7 @@ "mp4Video": "MP4 Video", "videoFiles": "Video Files", "openscreenProject": "OpenScreen Project", - "allFiles": "All Files" + "allFiles": "All Files", + "selectRecordingsFolder": "Select Recordings Folder" } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 8f7e8b7f5..dcacd0dec 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -93,6 +93,12 @@ "micHint": "Speak to check your microphone", "noMicrophones": "No microphone found", "preview": "Preview", - "previewUnavailable": "Preview unavailable" + "previewUnavailable": "Preview unavailable", + "storage": "Storage", + "storageHint": "Where recordings are cached while capturing and saved when you stop.", + "chooseFolder": "Choose folder", + "resetToDefault": "Reset to default", + "changingFolder": "Moving…", + "changeFolderFailed": "Couldn't switch to that folder" } }