diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6de7a3cc4..ef2dbf971 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -416,6 +416,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 7f837ae19..dc5670f35 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -59,7 +59,7 @@ import { LlmConfigStore } from "../ai-edition/llm-config-store"; import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; -import { RECORDINGS_DIR } from "../main"; +import { getRecordingsDirInfo, RECORDINGS_DIR, setRecordingsDir } from "../main"; import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks"; import { readCursorRecordingFile as readCursorRecordingFileFrom, @@ -81,6 +81,7 @@ import { findPipeWireCursorHelperPath } from "../native-bridge/cursor/recording/ import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import { toHelperRect } from "../native-bridge/helperCoordinates"; import { scoreDeviceNameMatch } from "../recording/deviceNameMatching"; +import { checkDiskSpace } from "../recording/diskSpaceCheck"; import { isSalvageableFragmentedCapture, NATIVE_WINDOWS_SALVAGEABLE_OUTPUT_BYTES, @@ -194,6 +195,23 @@ function hasAllowedImportVideoExtension(filePath: string): boolean { return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +/** + * Refuses to start a recording when the recordings directory's filesystem is + * critically low on space. Before this, low disk space was only discovered + * once the user tried to save — after the take was already lost. Returns + * `null` when there is enough room (or the check itself couldn't run, which + * must never block a recording that would otherwise have worked). + */ +async function lowDiskSpaceStartError(): Promise<{ success: false; error: string } | null> { + const status = await checkDiskSpace(RECORDINGS_DIR); + if (!status.low) return null; + const availableMb = Math.max(0, Math.round(status.availableBytes / (1024 * 1024))); + return { + success: false, + error: mainT("dialogs", "recording.lowDiskSpace", { availableMb }), + }; +} + // Imported audio (issue #350). Kept separate from the video set so the two // pickers stay honest — an audio picker must not approve a video path and vice // versa. A SUBSET of SUPPORTED_AUDIO_EXTENSIONS in the document service, which @@ -1957,6 +1975,48 @@ export function registerIpcHandlers( registerRecordingPrefsHandlers(defaultRecordingPrefs, getMainWindow); + 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" }; @@ -2302,6 +2362,8 @@ export function registerIpcHandlers( if (!findPipeWireCursorHelperPath()) { return { success: false, error: "Native Linux capture helper is not available." }; } + const diskSpaceError = await lowDiskSpaceStartError(); + if (diskSpaceError) return diskSpaceError; const recordingId = typeof request?.recordingId === "number" && Number.isFinite(request.recordingId) @@ -2500,6 +2562,8 @@ export function registerIpcHandlers( error: "Native Windows capture request is missing a source.", }; } + const diskSpaceError = await lowDiskSpaceStartError(); + if (diskSpaceError) return diskSpaceError; const recordingId = typeof request.recordingId === "number" && Number.isFinite(request.recordingId) @@ -2725,6 +2789,8 @@ export function registerIpcHandlers( if (!request?.source?.sourceId) { return { success: false, error: "Native macOS capture request is missing a source." }; } + const diskSpaceError = await lowDiskSpaceStartError(); + if (diskSpaceError) return diskSpaceError; const recordingId = typeof request.recordingId === "number" && Number.isFinite(request.recordingId) diff --git a/electron/main.ts b/electron/main.ts index ec961aead..1d4456980 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -59,6 +58,7 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { installMainProcessErrorGuards } from "./main-process-errors"; +import { RecordingsDirManager } from "./recording/recordingsDirManager"; import { registerSttIpc, shutdownStt } from "./stt"; import { checkLatestRelease } from "./update-checker"; import { loadUpdateMode, saveUpdateMode } from "./update-settings"; @@ -102,11 +102,18 @@ if (process.platform === "linux") { installMainProcessErrorGuards(); -export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); +const recordingsDirManager = new RecordingsDirManager(app.getPath("userData"), () => isRecording); + +export const DEFAULT_RECORDINGS_DIR = recordingsDirManager.defaultDir; + +// 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 = recordingsDirManager.dir; async function ensureRecordingsDir() { try { - await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + await recordingsDirManager.ensureExists(); console.log("RECORDINGS_DIR:", RECORDINGS_DIR); console.log("User Data Path:", app.getPath("userData")); } catch (error) { @@ -114,6 +121,25 @@ 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. + * + * Refuses to run while a recording is active: a capture in progress builds + * its output path from `RECORDINGS_DIR` up front, so swapping it mid-take + * would split one session's video and manifest across two directories. See + * RecordingsDirManager.setDir for the tested guard/ordering logic. + */ +export async function setRecordingsDir(customDir: string | null): Promise { + RECORDINGS_DIR = await recordingsDirManager.setDir(customDir); + return RECORDINGS_DIR; +} + +export function getRecordingsDirInfo() { + return recordingsDirManager.getInfo(); +} + // The built directory structure // // ├─┬─┬ dist diff --git a/electron/preload.ts b/electron/preload.ts index 7873bef90..adac990e1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -403,6 +403,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/diskSpaceCheck.test.ts b/electron/recording/diskSpaceCheck.test.ts new file mode 100644 index 000000000..55970b60b --- /dev/null +++ b/electron/recording/diskSpaceCheck.test.ts @@ -0,0 +1,33 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { checkDiskSpace } from "./diskSpaceCheck"; + +describe("checkDiskSpace", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "openscreen-disk-space-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reports the real filesystem as not low, using a near-zero threshold", async () => { + const status = await checkDiskSpace(dir, 1); + expect(status.low).toBe(false); + expect(status.availableBytes).toBeGreaterThan(0); + }); + + it("reports low when the threshold is set far above any real free space", async () => { + const status = await checkDiskSpace(dir, Number.MAX_SAFE_INTEGER); + expect(status.low).toBe(true); + }); + + it("does not throw and reports not-low for a directory that doesn't exist", async () => { + const status = await checkDiskSpace(path.join(dir, "does-not-exist")); + expect(status.low).toBe(false); + }); +}); diff --git a/electron/recording/diskSpaceCheck.ts b/electron/recording/diskSpaceCheck.ts new file mode 100644 index 000000000..c94b07597 --- /dev/null +++ b/electron/recording/diskSpaceCheck.ts @@ -0,0 +1,34 @@ +// Pre-flight free-space check for recording start. Before this, the app had +// no disk-space awareness anywhere: a recording could run for its full +// duration and only fail once the user tried to save it, discarding the +// take. Catching it before capture starts costs one statfs() call and saves +// a wasted recording. +import fs from "node:fs/promises"; + +/** Recording output is usually well under this; below it, a take is likely to run out mid-capture. */ +export const LOW_DISK_SPACE_THRESHOLD_BYTES = 500 * 1024 * 1024; + +export interface DiskSpaceStatus { + /** Bytes free on the filesystem backing the recordings directory. */ + availableBytes: number; + low: boolean; +} + +/** + * Checks free space on the filesystem that backs `dir`. Never throws — a + * platform or filesystem that doesn't support statfs (or a directory that + * doesn't exist yet) reports as not-low, since a bad check must never block + * a recording that would otherwise have worked. + */ +export async function checkDiskSpace( + dir: string, + thresholdBytes: number = LOW_DISK_SPACE_THRESHOLD_BYTES, +): Promise { + try { + const stats = await fs.statfs(dir); + const availableBytes = stats.bavail * stats.bsize; + return { availableBytes, low: availableBytes < thresholdBytes }; + } catch { + return { availableBytes: Number.POSITIVE_INFINITY, low: false }; + } +} diff --git a/electron/recording/recordingsDirManager.test.ts b/electron/recording/recordingsDirManager.test.ts new file mode 100644 index 000000000..73c0c7236 --- /dev/null +++ b/electron/recording/recordingsDirManager.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RecordingsDirManager } from "./recordingsDirManager"; + +describe("RecordingsDirManager", () => { + let userDataDir: string; + + beforeEach(async () => { + userDataDir = await mkdtemp(path.join(tmpdir(), "openscreen-recordings-dir-manager-")); + }); + + afterEach(async () => { + await rm(userDataDir, { recursive: true, force: true }); + }); + + it("starts at the default dir when nothing was persisted before", () => { + const manager = new RecordingsDirManager(userDataDir, () => false); + expect(manager.dir).toBe(manager.defaultDir); + expect(manager.getInfo()).toEqual({ path: manager.defaultDir, isDefault: true }); + }); + + it("switches to a custom dir and reports it as non-default", async () => { + const manager = new RecordingsDirManager(userDataDir, () => false); + const customDir = path.join(userDataDir, "custom-recordings"); + + const resolved = await manager.setDir(customDir); + + expect(resolved).toBe(customDir); + expect(manager.dir).toBe(customDir); + expect(manager.getInfo()).toEqual({ path: customDir, isDefault: false }); + }); + + it("resets to the default dir when passed null", async () => { + const manager = new RecordingsDirManager(userDataDir, () => false); + await manager.setDir(path.join(userDataDir, "custom-recordings")); + + const resolved = await manager.setDir(null); + + expect(resolved).toBe(manager.defaultDir); + expect(manager.getInfo().isDefault).toBe(true); + }); + + it("refuses to switch while a recording is in progress", async () => { + const manager = new RecordingsDirManager(userDataDir, () => true); + const before = manager.dir; + + await expect(manager.setDir(path.join(userDataDir, "custom-recordings"))).rejects.toThrow( + /recording is in progress/i, + ); + expect(manager.dir).toBe(before); + }); + + it("persists the choice so a fresh manager picks it up", async () => { + const customDir = path.join(userDataDir, "custom-recordings"); + const manager = new RecordingsDirManager(userDataDir, () => false); + await manager.setDir(customDir); + + const reloaded = new RecordingsDirManager(userDataDir, () => false); + expect(reloaded.dir).toBe(customDir); + }); + + // The core ordering fix: if persistence fails, the in-memory directory must + // stay on the old (already-saved) value rather than silently running on an + // unsaved one until restart. + it("does not update the live dir when persistence fails", async () => { + const manager = new RecordingsDirManager(userDataDir, () => false); + const originalDir = manager.dir; + + // biome-ignore lint/suspicious/noExplicitAny: reaching into the private store to force a write failure + const store = (manager as any).store; + vi.spyOn(store, "setCustomDir").mockRejectedValueOnce(new Error("disk full")); + + await expect(manager.setDir(path.join(userDataDir, "custom-recordings"))).rejects.toThrow( + "disk full", + ); + expect(manager.dir).toBe(originalDir); + }); + + // A recording that starts mid-switch (after the initial guard, during the + // mkdir/persist awaits) must not leave the manager pointed at a directory + // that was never actually committed — memory and disk must both roll back. + it("rolls back memory and disk if a recording starts during the persist await", async () => { + let recording = false; + const manager = new RecordingsDirManager(userDataDir, () => recording); + const originalDir = manager.dir; + + // biome-ignore lint/suspicious/noExplicitAny: reaching into the private store to flip recording state mid-await + const store = (manager as any).store; + const realSetCustomDir = store.setCustomDir.bind(store); + vi.spyOn(store, "setCustomDir").mockImplementation(async (...args: unknown[]) => { + recording = true; + return realSetCustomDir(...(args as [string | null])); + }); + + await expect(manager.setDir(path.join(userDataDir, "custom-recordings"))).rejects.toThrow( + /recording is in progress/i, + ); + expect(manager.dir).toBe(originalDir); + expect(store.getCustomDir()).toBeNull(); + }); + + it("serializes overlapping calls instead of interleaving their writes", async () => { + const manager = new RecordingsDirManager(userDataDir, () => false); + const dirA = path.join(userDataDir, "dir-a"); + const dirB = path.join(userDataDir, "dir-b"); + + const [resultA, resultB] = await Promise.all([manager.setDir(dirA), manager.setDir(dirB)]); + + // Whichever wins the race, the manager must land on exactly one of them — + // not a half-applied mix of both calls' state. + expect([dirA, dirB]).toContain(resultA); + expect(resultB).toBe(dirB); + expect(manager.dir).toBe(dirB); + }); +}); diff --git a/electron/recording/recordingsDirManager.ts b/electron/recording/recordingsDirManager.ts new file mode 100644 index 000000000..ba77fd3e4 --- /dev/null +++ b/electron/recording/recordingsDirManager.ts @@ -0,0 +1,85 @@ +// Owns the mutable, persisted "where do recordings live" state so it can be +// unit-tested without importing all of electron/main.ts (a top-level Electron +// entrypoint with process-wide side effects — single-instance lock, tray, +// menus — that a test has no business triggering). +import fs from "node:fs/promises"; +import path from "node:path"; +import { RecordingsLocationStore } from "./recordingsLocationStore"; + +export class RecordingsDirManager { + readonly defaultDir: string; + private readonly store: RecordingsLocationStore; + private readonly isRecording: () => boolean; + private current: string; + // Serializes setDir() calls (e.g. a doubled-up click) so two in-flight + // switches can't interleave their filesystem/persistence writes. + private pending: Promise = Promise.resolve(); + + constructor(userDataPath: string, isRecording: () => boolean) { + this.defaultDir = path.join(userDataPath, "recordings"); + this.store = new RecordingsLocationStore(userDataPath); + this.isRecording = isRecording; + this.current = this.store.getCustomDir() ?? this.defaultDir; + } + + get dir(): string { + return this.current; + } + + getInfo() { + return { path: this.current, isDefault: this.current === this.defaultDir }; + } + + async ensureExists(): Promise { + await fs.mkdir(this.current, { recursive: true }); + } + + /** + * Switches where recordings are read from and written to, going forward. + * Pass `null` to reset to the default. Does not move any existing files — + * the old location is left untouched. + * + * Refuses to run while a recording is active: a capture in progress builds + * its output path from the current directory up front, so swapping it + * mid-take would split one session's video and manifest across two + * directories. The check runs both before and after the filesystem/persist + * awaits (rolling back the persisted value if the second check trips), which + * closes the window for a switch that started before a recording did. It + * does NOT close the reverse case — a recording that starts, computes its + * output path, and flips `isRecording` to true during this function's + * awaits — because recording-start in handlers.ts reads `RECORDINGS_DIR` + * directly and does not coordinate with this class. Fixing that fully means + * every native capture-start path taking the same lock; out of scope here + * (see PR discussion). In practice the window is one `fs.mkdir` + one small + * JSON write, and a settings change racing the exact instant a recording is + * being kicked off is an edge case, not the common "changed the folder + * mid-recording" mistake this guard exists to prevent. + */ + async setDir(customDir: string | null): Promise { + const run = this.pending.then(() => this.setDirUnserialized(customDir)); + // Never let a rejection here poison the chain for the next caller. + this.pending = run.catch(() => undefined); + return run; + } + + private async setDirUnserialized(customDir: string | null): Promise { + if (this.isRecording()) { + throw new Error("Cannot change the recordings folder while a recording is in progress."); + } + const previousCustomDir = this.store.getCustomDir(); + const resolved = customDir ? path.resolve(customDir) : this.defaultDir; + await fs.mkdir(resolved, { recursive: true }); + // Persist before committing in-memory state: if this write fails, keep + // using the old (already-saved) directory rather than silently running + // on an unsaved one until restart. + await this.store.setCustomDir(customDir ? resolved : null); + if (this.isRecording()) { + // A recording started while the above awaited — undo the persisted + // change too, so disk and memory don't disagree about the directory. + await this.store.setCustomDir(previousCustomDir); + throw new Error("Cannot change the recordings folder while a recording is in progress."); + } + this.current = resolved; + return this.current; + } +} diff --git a/electron/recording/recordingsLocationStore.test.ts b/electron/recording/recordingsLocationStore.test.ts new file mode 100644 index 000000000..3969aec38 --- /dev/null +++ b/electron/recording/recordingsLocationStore.test.ts @@ -0,0 +1,116 @@ +import fsPromises, { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RecordingsLocationStore } from "./recordingsLocationStore"; + +describe("RecordingsLocationStore", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "openscreen-recordings-location-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("has no custom dir before anything is saved", () => { + const store = new RecordingsLocationStore(dir); + expect(store.getCustomDir()).toBeNull(); + }); + + it("persists a custom dir and reloads it in a fresh instance", async () => { + const store = new RecordingsLocationStore(dir); + const customDir = path.join(dir, "my-recordings"); + await store.setCustomDir(customDir); + expect(store.getCustomDir()).toBe(customDir); + + const reloaded = new RecordingsLocationStore(dir); + expect(reloaded.getCustomDir()).toBe(customDir); + }); + + it("clears the custom dir when set back to null", async () => { + const store = new RecordingsLocationStore(dir); + await store.setCustomDir(path.join(dir, "my-recordings")); + await store.setCustomDir(null); + + const reloaded = new RecordingsLocationStore(dir); + expect(reloaded.getCustomDir()).toBeNull(); + }); + + it("ignores an empty string left in the config file", async () => { + await writeFile( + path.join(dir, "recordings-location.json"), + JSON.stringify({ recordingsDir: "" }), + "utf8", + ); + const store = new RecordingsLocationStore(dir); + expect(store.getCustomDir()).toBeNull(); + }); + + it("ignores a relative path left in the config file", async () => { + await writeFile( + path.join(dir, "recordings-location.json"), + JSON.stringify({ recordingsDir: "relative/recordings" }), + "utf8", + ); + const store = new RecordingsLocationStore(dir); + expect(store.getCustomDir()).toBeNull(); + }); + + it("ignores a malformed config file", async () => { + await writeFile(path.join(dir, "recordings-location.json"), "not json", "utf8"); + const store = new RecordingsLocationStore(dir); + expect(store.getCustomDir()).toBeNull(); + }); + + it("writes valid JSON that round-trips through readFile", async () => { + const store = new RecordingsLocationStore(dir); + const customDir = path.join(dir, "my-recordings"); + await store.setCustomDir(customDir); + + const raw = await readFile(path.join(dir, "recordings-location.json"), "utf8"); + expect(JSON.parse(raw)).toEqual({ recordingsDir: customDir }); + }); + + // The atomicity fix: a crash or power loss can only ever be caught before + // the temp file exists or after the rename lands — never partway through + // recordings-location.json itself, since the write goes to a distinct + // temp path first. + it("writes through a temp file and leaves no temp file behind on success", async () => { + const store = new RecordingsLocationStore(dir); + await store.setCustomDir(path.join(dir, "my-recordings")); + + const entries = await readdir(dir); + expect(entries).toEqual(["recordings-location.json"]); + }); + + // getCustomDir() must never report a directory that failed to reach disk — + // a caller (RecordingsDirManager's rollback path in particular) relies on + // this to reflect what was actually persisted. + it("does not report a new dir as current if the write fails", async () => { + const store = new RecordingsLocationStore(dir); + const renameSpy = vi.spyOn(fsPromises, "rename").mockRejectedValueOnce(new Error("disk full")); + + await expect(store.setCustomDir(path.join(dir, "my-recordings"))).rejects.toThrow("disk full"); + expect(store.getCustomDir()).toBeNull(); + + renameSpy.mockRestore(); + }); + + // A failed write must not leave a .tmp-* file behind — repeated failures + // (e.g. a persistently full disk) would otherwise accumulate garbage in + // the user-data directory forever. + it("cleans up the temp file when the rename fails", async () => { + const store = new RecordingsLocationStore(dir); + const renameSpy = vi.spyOn(fsPromises, "rename").mockRejectedValueOnce(new Error("disk full")); + + await expect(store.setCustomDir(path.join(dir, "my-recordings"))).rejects.toThrow("disk full"); + + const entries = await readdir(dir); + expect(entries).toEqual([]); + + renameSpy.mockRestore(); + }); +}); diff --git a/electron/recording/recordingsLocationStore.ts b/electron/recording/recordingsLocationStore.ts new file mode 100644 index 000000000..390c249e9 --- /dev/null +++ b/electron/recording/recordingsLocationStore.ts @@ -0,0 +1,68 @@ +// 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); + const candidate = parsed.recordingsDir; + // An empty or relative string would make fs.mkdir("") fail at startup, + // or resolve recordings relative to the process's working directory — + // neither is a state a hand-edited or corrupted config file should be + // able to force. setRecordingsDir() only ever writes an absolute path. + this.config = { + recordingsDir: + typeof candidate === "string" && candidate.length > 0 && path.isAbsolute(candidate) + ? candidate + : 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 { + // Build the next config but don't commit it to `this.config` until the + // write has actually landed — otherwise getCustomDir() could report a + // directory that was never persisted (e.g. RecordingsDirManager's + // rollback path reads this value expecting it to reflect disk). + const nextConfig: RecordingsLocationConfig = { recordingsDir: dir }; + // Write-then-rename, not a direct write: fs.rename is atomic on the same + // filesystem, so a crash or power loss mid-write can never leave + // recordings-location.json truncated or malformed. Same pattern as + // mediaLinksRegistry.ts's writeRegistry(). + const tmpPath = `${this.configPath}.tmp-${process.pid}-${Date.now()}`; + try { + await fs.writeFile(tmpPath, JSON.stringify(nextConfig, null, 2), "utf8"); + await fs.rename(tmpPath, this.configPath); + this.config = nextConfig; + } catch (error) { + // Best-effort: don't let a cleanup failure hide the real error, and + // don't leave a stale .tmp-* file behind for every failed attempt. + await fs.rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } + } +} diff --git a/src/components/launch/HudDeviceSettings.tsx b/src/components/launch/HudDeviceSettings.tsx index 4b40206d7..dcc7f580d 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"; @@ -25,8 +25,109 @@ export interface HudDeviceSettingsLabels { about: string; checkForUpdates: string; checkingForUpdates: 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); @@ -241,6 +342,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 8821194b1..95dee1a6e 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -338,6 +338,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.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 0632d524d..ce74baf67 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -171,6 +171,13 @@ vi.mock("@/contexts/I18nContext", () => ({ "deviceSettings.version": "Version {{version}}", "actions.checkForUpdates": "Check for updates", "deviceSettings.checkingForUpdates": "Checking…", + "deviceSettings.storage": "Storage", + "deviceSettings.storageHint": + "Where recordings are cached while capturing and saved when you stop.", + "deviceSettings.chooseFolder": "Choose folder", + "deviceSettings.resetToDefault": "Reset to default", + "deviceSettings.changingFolder": "Switching…", + "deviceSettings.changeFolderFailed": "Couldn't switch to that folder", "audio.inputDevice": "Input device", "webcam.cameraDevice": "Camera device", "cursor.useEditableCursor": "Use editable cursor", @@ -264,6 +271,9 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo ); }; }), + getRecordingsDir: vi.fn(async () => ({ path: "/default/recordings", isDefault: true })), + chooseRecordingsDir: vi.fn(async () => ({ success: true, path: "/chosen/recordings" })), + resetRecordingsDir: vi.fn(async () => ({ success: true, path: "/default/recordings" })), } as typeof window.electronAPI; } @@ -1208,6 +1218,91 @@ describe("LaunchWindow device settings", () => { expect(button).toBeEnabled(); expect(button).toHaveTextContent("Check for updates"); }); + + it("shows the current recordings folder once the main process answers", async () => { + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-device-settings-button")); + const panel = await screen.findByTestId("hud-device-settings"); + + expect(await within(panel).findByText("/default/recordings")).toBeInTheDocument(); + expect( + within(panel).queryByRole("button", { name: /reset to default/i }), + ).not.toBeInTheDocument(); + }); + + it("switches to a chosen folder and offers a reset once it is no longer the default", async () => { + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-device-settings-button")); + const panel = await screen.findByTestId("hud-device-settings"); + await within(panel).findByText("/default/recordings"); + + fireEvent.click(within(panel).getByRole("button", { name: /choose folder/i })); + + expect(await within(panel).findByText("/chosen/recordings")).toBeInTheDocument(); + expect( + await within(panel).findByRole("button", { name: /reset to default/i }), + ).toBeInTheDocument(); + }); + + it("does nothing when the folder picker is canceled", async () => { + window.electronAPI.chooseRecordingsDir = vi.fn(async () => ({ + success: false, + canceled: true, + })) as unknown as Window["electronAPI"]["chooseRecordingsDir"]; + + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-device-settings-button")); + const panel = await screen.findByTestId("hud-device-settings"); + await within(panel).findByText("/default/recordings"); + + fireEvent.click(within(panel).getByRole("button", { name: /choose folder/i })); + + await waitFor(() => { + expect(within(panel).getByRole("button", { name: /choose folder/i })).toBeEnabled(); + }); + expect(within(panel).getByText("/default/recordings")).toBeInTheDocument(); + }); + + it("resets to the default folder and hides the reset control again", async () => { + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-device-settings-button")); + const panel = await screen.findByTestId("hud-device-settings"); + await within(panel).findByText("/default/recordings"); + + fireEvent.click(within(panel).getByRole("button", { name: /choose folder/i })); + await within(panel).findByRole("button", { name: /reset to default/i }); + + fireEvent.click(within(panel).getByRole("button", { name: /reset to default/i })); + + await waitFor(() => { + expect( + within(panel).queryByRole("button", { name: /reset to default/i }), + ).not.toBeInTheDocument(); + }); + expect(within(panel).getByText("/default/recordings")).toBeInTheDocument(); + }); + + it("reports a failure when switching to the chosen folder is refused", async () => { + window.electronAPI.chooseRecordingsDir = vi.fn(async () => ({ + success: false, + message: "Cannot change the recordings folder while a recording is in progress.", + })) as unknown as Window["electronAPI"]["chooseRecordingsDir"]; + + renderLaunchWindow(); + + fireEvent.click(await screen.findByTestId("launch-device-settings-button")); + const panel = await screen.findByTestId("hud-device-settings"); + await within(panel).findByText("/default/recordings"); + + fireEvent.click(within(panel).getByRole("button", { name: /choose folder/i })); + + expect(await within(panel).findByText(/couldn.t switch to that folder/i)).toBeInTheDocument(); + expect(within(panel).getByText("/default/recordings")).toBeInTheDocument(); + }); }); describe("LaunchWindow HUD drag", () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index ea1573028..1c13f2c58 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -919,6 +919,12 @@ export function LaunchWindow() { about: t("deviceSettings.about"), checkForUpdates: tCommon("actions.checkForUpdates"), checkingForUpdates: t("deviceSettings.checkingForUpdates"), + 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, tCommon], ); diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json index cf7c6f554..e65a06b09 100644 --- a/src/i18n/locales/ar/dialogs.json +++ b/src/i18n/locales/ar/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "ملفات فيديو", "audioFiles": "ملفات الصوت", "openscreenProject": "مشروع OpenScreen", - "allFiles": "جميع الملفات" + "allFiles": "جميع الملفات", + "selectRecordingsFolder": "اختر مجلد التسجيلات" + }, + "recording": { + "lowDiskSpace": "لا توجد مساحة تخزين كافية لبدء التسجيل (لا يتوفر سوى {{availableMb}} ميجابايت). حرر بعض المساحة أو اختر مجلد تسجيلات آخر، ثم حاول مرة أخرى." } } diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index d632953b3..fa952beb3 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "المعاينة غير متاحة", "about": "حول", "version": "الإصدار {{version}}", - "checkingForUpdates": "جارٍ التحقق…" + "checkingForUpdates": "جارٍ التحقق…", + "storage": "التخزين", + "storageHint": "أين يتم تخزين التسجيلات مؤقتًا أثناء الالتقاط وحفظها عند التوقف.", + "chooseFolder": "اختر مجلدًا", + "resetToDefault": "إعادة التعيين إلى الافتراضي", + "changingFolder": "جارٍ التبديل…", + "changeFolderFailed": "تعذّر التبديل إلى هذا المجلد" } } diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 9ce8e3ded..5c5b1524a 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Video Files", "audioFiles": "Audio Files", "openscreenProject": "OpenScreen Project", - "allFiles": "All Files" + "allFiles": "All Files", + "selectRecordingsFolder": "Select Recordings Folder" + }, + "recording": { + "lowDiskSpace": "Not enough disk space to start recording (only {{availableMb}} MB free). Free up space or choose a different recordings folder, then try again." } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 3c60aad22..74f900861 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Preview unavailable", "about": "About", "version": "Version {{version}}", - "checkingForUpdates": "Checking…" + "checkingForUpdates": "Checking…", + "storage": "Storage", + "storageHint": "Where recordings are cached while capturing and saved when you stop.", + "chooseFolder": "Choose folder", + "resetToDefault": "Reset to default", + "changingFolder": "Switching…", + "changeFolderFailed": "Couldn't switch to that folder" } } diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index 8f26a3aff..0c0621c94 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Archivos de video", "audioFiles": "Archivos de audio", "openscreenProject": "Proyecto OpenScreen", - "allFiles": "Todos los archivos" + "allFiles": "Todos los archivos", + "selectRecordingsFolder": "Seleccionar carpeta de grabaciones" + }, + "recording": { + "lowDiskSpace": "No hay suficiente espacio en disco para iniciar la grabación (solo quedan {{availableMb}} MB libres). Libera espacio o elige otra carpeta de grabaciones e inténtalo de nuevo." } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 7fe44a485..9f3497aac 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Vista previa no disponible", "about": "Acerca de", "version": "Versión {{version}}", - "checkingForUpdates": "Buscando…" + "checkingForUpdates": "Buscando…", + "storage": "Almacenamiento", + "storageHint": "Dónde se almacenan en caché las grabaciones mientras capturas y dónde se guardan al detener.", + "chooseFolder": "Elegir carpeta", + "resetToDefault": "Restablecer valor predeterminado", + "changingFolder": "Cambiando…", + "changeFolderFailed": "No se pudo cambiar a esa carpeta" } } diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index ad5b4edf6..22550fc72 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Fichiers vidéo", "audioFiles": "Fichiers audio", "openscreenProject": "Projet OpenScreen", - "allFiles": "Tous les fichiers" + "allFiles": "Tous les fichiers", + "selectRecordingsFolder": "Sélectionner le dossier des enregistrements" + }, + "recording": { + "lowDiskSpace": "Espace disque insuffisant pour démarrer l'enregistrement (seulement {{availableMb}} Mo disponibles). Libérez de l'espace ou choisissez un autre dossier d'enregistrements, puis réessayez." } } diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index a1819e350..643985504 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Aperçu indisponible", "about": "À propos", "version": "Version {{version}}", - "checkingForUpdates": "Recherche…" + "checkingForUpdates": "Recherche…", + "storage": "Stockage", + "storageHint": "Emplacement de mise en cache des enregistrements pendant la capture, et d'enregistrement à l'arrêt.", + "chooseFolder": "Choisir un dossier", + "resetToDefault": "Réinitialiser par défaut", + "changingFolder": "Changement…", + "changeFolderFailed": "Impossible de passer à ce dossier" } } diff --git a/src/i18n/locales/it/dialogs.json b/src/i18n/locales/it/dialogs.json index e8e326c91..e345dc61b 100644 --- a/src/i18n/locales/it/dialogs.json +++ b/src/i18n/locales/it/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "File video", "audioFiles": "File audio", "openscreenProject": "Progetto OpenScreen", - "allFiles": "Tutti i file" + "allFiles": "Tutti i file", + "selectRecordingsFolder": "Seleziona cartella registrazioni" + }, + "recording": { + "lowDiskSpace": "Spazio su disco insufficiente per avviare la registrazione (solo {{availableMb}} MB disponibili). Libera spazio o scegli un'altra cartella per le registrazioni, quindi riprova." } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index e4adbc465..37e351399 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Anteprima non disponibile", "about": "Info", "version": "Versione {{version}}", - "checkingForUpdates": "Controllo…" + "checkingForUpdates": "Controllo…", + "storage": "Archiviazione", + "storageHint": "Dove vengono memorizzate nella cache le registrazioni durante la cattura e salvate all'arresto.", + "chooseFolder": "Scegli cartella", + "resetToDefault": "Ripristina predefinito", + "changingFolder": "Cambio in corso…", + "changeFolderFailed": "Impossibile passare a quella cartella" } } diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index c0c00ad80..d81237246 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "動画ファイル", "audioFiles": "オーディオファイル", "openscreenProject": "OpenScreen プロジェクト", - "allFiles": "すべてのファイル" + "allFiles": "すべてのファイル", + "selectRecordingsFolder": "録画フォルダーを選択" + }, + "recording": { + "lowDiskSpace": "録画を開始するのに十分なディスク容量がありません(空き容量は{{availableMb}} MBのみ)。空き容量を確保するか、別の録画フォルダーを選択してから、もう一度お試しください。" } } diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index bf940cc04..7c28525cf 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "プレビューを利用できません", "about": "情報", "version": "バージョン {{version}}", - "checkingForUpdates": "確認中…" + "checkingForUpdates": "確認中…", + "storage": "保存先", + "storageHint": "キャプチャ中の録画のキャッシュ先と、停止時の保存先です。", + "chooseFolder": "フォルダーを選択", + "resetToDefault": "デフォルトに戻す", + "changingFolder": "切り替え中…", + "changeFolderFailed": "そのフォルダーに切り替えられませんでした" } } diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json index 2b64240ae..4324f1c63 100644 --- a/src/i18n/locales/ko-KR/dialogs.json +++ b/src/i18n/locales/ko-KR/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "비디오 파일", "audioFiles": "오디오 파일", "openscreenProject": "OpenScreen 프로젝트", - "allFiles": "모든 파일" + "allFiles": "모든 파일", + "selectRecordingsFolder": "녹화 폴더 선택" + }, + "recording": { + "lowDiskSpace": "녹화를 시작할 디스크 공간이 부족합니다(사용 가능한 공간 {{availableMb}}MB). 공간을 확보하거나 다른 녹화 폴더를 선택한 후 다시 시도하세요." } } diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 6e13f7ba5..e1d52fc55 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "미리 보기를 사용할 수 없습니다", "about": "정보", "version": "버전 {{version}}", - "checkingForUpdates": "확인 중…" + "checkingForUpdates": "확인 중…", + "storage": "저장 위치", + "storageHint": "캡처 중 녹화가 캐시되고 중지 시 저장되는 위치입니다.", + "chooseFolder": "폴더 선택", + "resetToDefault": "기본값으로 재설정", + "changingFolder": "전환 중…", + "changeFolderFailed": "해당 폴더로 전환하지 못했습니다" } } diff --git a/src/i18n/locales/pt-BR/dialogs.json b/src/i18n/locales/pt-BR/dialogs.json index 88f163d99..33c744eb1 100644 --- a/src/i18n/locales/pt-BR/dialogs.json +++ b/src/i18n/locales/pt-BR/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Arquivos de Vídeo", "audioFiles": "Arquivos de áudio", "openscreenProject": "Projeto OpenScreen", - "allFiles": "Todos os Arquivos" + "allFiles": "Todos os Arquivos", + "selectRecordingsFolder": "Selecionar pasta de gravações" + }, + "recording": { + "lowDiskSpace": "Espaço em disco insuficiente para iniciar a gravação (apenas {{availableMb}} MB livres). Libere espaço ou escolha outra pasta de gravações e tente novamente." } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index a10645385..25c4695cc 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Pré-visualização indisponível", "about": "Sobre", "version": "Versão {{version}}", - "checkingForUpdates": "Verificando…" + "checkingForUpdates": "Verificando…", + "storage": "Armazenamento", + "storageHint": "Onde as gravações são armazenadas em cache durante a captura e salvas ao parar.", + "chooseFolder": "Escolher pasta", + "resetToDefault": "Redefinir para o padrão", + "changingFolder": "Alternando…", + "changeFolderFailed": "Não foi possível mudar para essa pasta" } } diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index 1f46a14fc..c25a4e035 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Видеофайлы", "audioFiles": "Аудиофайлы", "openscreenProject": "Проект OpenScreen", - "allFiles": "Все файлы" + "allFiles": "Все файлы", + "selectRecordingsFolder": "Выбрать папку для записей" + }, + "recording": { + "lowDiskSpace": "Недостаточно места на диске для начала записи (доступно только {{availableMb}} МБ). Освободите место или выберите другую папку для записей и повторите попытку." } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 67fb6f637..074b83e96 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Предпросмотр недоступен", "about": "О программе", "version": "Версия {{version}}", - "checkingForUpdates": "Проверка…" + "checkingForUpdates": "Проверка…", + "storage": "Хранилище", + "storageHint": "Где записи кэшируются во время захвата и сохраняются при остановке.", + "chooseFolder": "Выбрать папку", + "resetToDefault": "Сбросить по умолчанию", + "changingFolder": "Переключение…", + "changeFolderFailed": "Не удалось переключиться на эту папку" } } diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 6b01da744..5dcdb4705 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Video Dosyaları", "audioFiles": "Ses dosyaları", "openscreenProject": "OpenScreen Projesi", - "allFiles": "Tüm Dosyalar" + "allFiles": "Tüm Dosyalar", + "selectRecordingsFolder": "Kayıt klasörünü seç" + }, + "recording": { + "lowDiskSpace": "Kaydı başlatmak için yeterli disk alanı yok (yalnızca {{availableMb}} MB boş). Alan boşaltın veya farklı bir kayıt klasörü seçip tekrar deneyin." } } diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index a5b84df60..c434d1145 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Önizleme kullanılamıyor", "about": "Hakkında", "version": "Sürüm {{version}}", - "checkingForUpdates": "Denetleniyor…" + "checkingForUpdates": "Denetleniyor…", + "storage": "Depolama", + "storageHint": "Kayıtların yakalama sırasında önbelleğe alındığı ve durdurulduğunda kaydedildiği yer.", + "chooseFolder": "Klasör seç", + "resetToDefault": "Varsayılana sıfırla", + "changingFolder": "Değiştiriliyor…", + "changeFolderFailed": "O klasöre geçilemedi" } } diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json index 452e80e2f..e6c130389 100644 --- a/src/i18n/locales/vi/dialogs.json +++ b/src/i18n/locales/vi/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "Tệp Video", "audioFiles": "Tệp âm thanh", "openscreenProject": "Dự án OpenScreen", - "allFiles": "Tất cả các tệp" + "allFiles": "Tất cả các tệp", + "selectRecordingsFolder": "Chọn thư mục bản ghi" + }, + "recording": { + "lowDiskSpace": "Không đủ dung lượng ổ đĩa để bắt đầu quay (chỉ còn {{availableMb}} MB trống). Hãy giải phóng dung lượng hoặc chọn thư mục bản ghi khác rồi thử lại." } } diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index e4997af28..a78a11ece 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "Không thể xem trước", "about": "Giới thiệu", "version": "Phiên bản {{version}}", - "checkingForUpdates": "Đang kiểm tra…" + "checkingForUpdates": "Đang kiểm tra…", + "storage": "Lưu trữ", + "storageHint": "Nơi bản ghi được lưu tạm trong khi quay và được lưu khi dừng.", + "chooseFolder": "Chọn thư mục", + "resetToDefault": "Đặt lại về mặc định", + "changingFolder": "Đang chuyển…", + "changeFolderFailed": "Không thể chuyển sang thư mục đó" } } diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index db4f12730..1683c5776 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "视频文件", "audioFiles": "音频文件", "openscreenProject": "OpenScreen 项目", - "allFiles": "所有文件" + "allFiles": "所有文件", + "selectRecordingsFolder": "选择录制文件夹" + }, + "recording": { + "lowDiskSpace": "磁盘空间不足,无法开始录制(仅剩 {{availableMb}} MB 可用空间)。请释放空间或选择其他录制文件夹,然后重试。" } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 521591aaa..9814115d4 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "预览不可用", "about": "关于", "version": "版本 {{version}}", - "checkingForUpdates": "正在检查…" + "checkingForUpdates": "正在检查…", + "storage": "存储位置", + "storageHint": "录制过程中缓存的位置,以及停止后保存的位置。", + "chooseFolder": "选择文件夹", + "resetToDefault": "恢复默认设置", + "changingFolder": "正在切换…", + "changeFolderFailed": "无法切换到该文件夹" } } diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index f4830a611..8ee82a4cd 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -87,6 +87,10 @@ "videoFiles": "影片檔案", "audioFiles": "音訊檔案", "openscreenProject": "OpenScreen 專案", - "allFiles": "所有檔案" + "allFiles": "所有檔案", + "selectRecordingsFolder": "選擇錄製資料夾" + }, + "recording": { + "lowDiskSpace": "磁碟空間不足,無法開始錄製(僅剩 {{availableMb}} MB 可用空間)。請釋放空間或選擇其他錄製資料夾,然後再試一次。" } } diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 9e6361bd8..e40397f03 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -99,6 +99,12 @@ "previewUnavailable": "預覽無法使用", "about": "關於", "version": "版本 {{version}}", - "checkingForUpdates": "檢查中…" + "checkingForUpdates": "檢查中…", + "storage": "儲存位置", + "storageHint": "錄製期間快取的位置,以及停止後儲存的位置。", + "chooseFolder": "選擇資料夾", + "resetToDefault": "重設為預設值", + "changingFolder": "正在切換…", + "changeFolderFailed": "無法切換到該資料夾" } }