diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 0f4b05dd2..b2359909b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,4 +1,14 @@ /// +import type { + StorageFolderId, + StorageFolderMutationResponse, + StorageFoldersResponse, +} from "../src/types/storageFolders"; +import type { + StorageFolderId, + StorageFolderMutationResponse, + StorageFoldersResponse, +} from "./ipc/types"; declare namespace NodeJS { interface ProcessEnv { @@ -1275,6 +1285,14 @@ interface Window { message?: string; error?: string; }>; + getStorageFolders: () => Promise; + chooseStorageFolder: (id: StorageFolderId) => Promise; + openStorageFolder: (id: StorageFolderId) => Promise<{ + success: boolean; + error?: string; + }>; + resetStorageFolder: (id: StorageFolderId) => Promise; + resetAllStorageFolders: () => Promise; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; getEditorStartupView: () => "home" | "editor"; diff --git a/electron/ipc/captions/generate.ts b/electron/ipc/captions/generate.ts index 9258d1f18..79433f676 100644 --- a/electron/ipc/captions/generate.ts +++ b/electron/ipc/captions/generate.ts @@ -3,7 +3,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { app } from "electron"; + import type { CaptionModelId } from "../../../src/shared/captionModels"; import { getFfmpegBinaryPath } from "../ffmpeg/binary"; import { getBundledWhisperExecutableCandidates } from "../paths/binaries"; @@ -22,6 +22,7 @@ import { getParakeetModelStatus } from "./parakeetModel"; import { shouldRetryWhisperWithoutJson } from "./parser"; import { isMissingWindowsWhisperRuntimeDependency } from "./runtimeErrors"; import { segmentCuesIntoPhrases } from "./segment"; +import { getTempStorageDir } from "../../storageLocations"; import { parseSilenceIntervals, SILENCE_DETECT_MIN_S, @@ -285,7 +286,7 @@ async function generateCaptionsForSource( } const tempBase = path.join( - app.getPath("temp"), + getTempStorageDir(), `recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ); const wavPath = `${tempBase}.wav`; diff --git a/electron/ipc/captions/parakeetModel.ts b/electron/ipc/captions/parakeetModel.ts index 4bb1f2dba..951986fc8 100644 --- a/electron/ipc/captions/parakeetModel.ts +++ b/electron/ipc/captions/parakeetModel.ts @@ -5,7 +5,8 @@ import { get as httpsGet } from "node:https"; import path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { app, type WebContents } from "electron"; +import { type WebContents } from "electron"; +import { getCaptionModelsDir } from "../../storageLocations"; export const PARAKEET_MODEL_REVISION = "2bda32ec70b097a55adaa07d9a7173915b43cc78"; export const PARAKEET_MODEL_FILES = [ @@ -42,7 +43,7 @@ type Progress = { }; function modelDirectory() { - return path.join(app.getPath("userData"), "captions", "parakeet-tdt-0.6b-v3-int8"); + return path.join(getCaptionModelsDir(), "parakeet-tdt-0.6b-v3-int8"); } export function sendParakeetModelDownloadProgress(webContents: WebContents, payload: Progress) { diff --git a/electron/ipc/captions/whisper.ts b/electron/ipc/captions/whisper.ts index 67b04cd58..72dd74d26 100644 --- a/electron/ipc/captions/whisper.ts +++ b/electron/ipc/captions/whisper.ts @@ -2,12 +2,18 @@ import { createWriteStream } from "node:fs"; import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import { get as httpsGet } from "node:https"; +import path from "node:path"; import type Electron from "electron"; -import { - WHISPER_MODEL_DIR, - WHISPER_MODEL_DOWNLOAD_URL, - WHISPER_SMALL_MODEL_PATH, -} from "../constants"; +import { getCaptionModelsDir } from "../../storageLocations"; +import { WHISPER_MODEL_DOWNLOAD_URL } from "../constants"; + +/** + * Resolved on every call so a folder change applies without a restart. The small + * model keeps its own subfolder because Whisper installs are interchangeable. + */ +function whisperSmallModelPath() { + return path.join(getCaptionModelsDir(), "whisper", "ggml-small.bin"); +} export function sendWhisperModelDownloadProgress( webContents: Electron.WebContents, @@ -22,12 +28,13 @@ export function sendWhisperModelDownloadProgress( } export async function getWhisperSmallModelStatus() { + const modelPath = whisperSmallModelPath(); try { - await fs.access(WHISPER_SMALL_MODEL_PATH, fsConstants.R_OK); + await fs.access(modelPath, fsConstants.R_OK); return { success: true, exists: true, - path: WHISPER_SMALL_MODEL_PATH, + path: modelPath, }; } catch { return { @@ -113,8 +120,9 @@ export function downloadFileWithProgress( export async function downloadWhisperSmallModel( webContents: Electron.WebContents, ): Promise { - await fs.mkdir(WHISPER_MODEL_DIR, { recursive: true }); - const tempPath = `${WHISPER_SMALL_MODEL_PATH}.download`; + const modelPath = whisperSmallModelPath(); + await fs.mkdir(path.dirname(modelPath), { recursive: true }); + const tempPath = `${modelPath}.download`; sendWhisperModelDownloadProgress(webContents, { status: "downloading", @@ -131,13 +139,13 @@ export async function downloadWhisperSmallModel( path: null, }); }); - await fs.rename(tempPath, WHISPER_SMALL_MODEL_PATH); + await fs.rename(tempPath, modelPath); sendWhisperModelDownloadProgress(webContents, { status: "downloaded", progress: 100, - path: WHISPER_SMALL_MODEL_PATH, + path: modelPath, }); - return WHISPER_SMALL_MODEL_PATH; + return modelPath; } catch (error) { await fs.rm(tempPath, { force: true }).catch(() => undefined); sendWhisperModelDownloadProgress(webContents, { @@ -151,5 +159,5 @@ export async function downloadWhisperSmallModel( } export async function deleteWhisperSmallModel(): Promise { - await fs.rm(WHISPER_SMALL_MODEL_PATH, { force: true }); + await fs.rm(whisperSmallModelPath(), { force: true }); } diff --git a/electron/ipc/constants.ts b/electron/ipc/constants.ts index 2cc7597cb..cddcbc18f 100644 --- a/electron/ipc/constants.ts +++ b/electron/ipc/constants.ts @@ -16,8 +16,8 @@ export const ALLOW_RECORDLY_WINDOW_CAPTURE = Boolean(process.env["VITE_DEV_SERVE export const RECORDING_SESSION_MANIFEST_SUFFIX = ".recordly-session.json"; export const WHISPER_MODEL_DOWNLOAD_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin"; +/** Legacy fallback used only when the configured caption folder is unavailable. */ export const WHISPER_MODEL_DIR = path.join(USER_DATA_PATH, "whisper"); -export const WHISPER_SMALL_MODEL_PATH = path.join(WHISPER_MODEL_DIR, "ggml-small.bin"); export const COMPANION_AUDIO_LAYOUTS = [ { platform: "mac" as const, systemSuffix: ".system.m4a", micSuffix: ".mic.m4a" }, { platform: "win" as const, systemSuffix: ".system.wav", micSuffix: ".mic.wav" }, diff --git a/electron/ipc/export/exportStream.ts b/electron/ipc/export/exportStream.ts index c34be8556..0ff441dea 100644 --- a/electron/ipc/export/exportStream.ts +++ b/electron/ipc/export/exportStream.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { app } from "electron"; +import { getTempStorageDir } from "../../storageLocations"; type ExportStreamSession = { streamId: string; @@ -60,7 +60,7 @@ export async function openExportStream(options?: { extension?: string }): Promis // Per-session 0700 directory defeats TOCTOU/symlink races on shared // tempdirs (e.g. /tmp on Linux): only the current user can enter the dir, // so an adversary cannot pre-plant a symlink at the file path. - const sessionDir = await fsp.mkdtemp(path.join(app.getPath("temp"), SESSION_DIR_PREFIX)); + const sessionDir = await fsp.mkdtemp(path.join(getTempStorageDir(), SESSION_DIR_PREFIX)); try { await fsp.chmod(sessionDir, 0o700); } catch { diff --git a/electron/ipc/export/nativeVideo/audio.ts b/electron/ipc/export/nativeVideo/audio.ts index 4b032bb77..7e745c16f 100644 --- a/electron/ipc/export/nativeVideo/audio.ts +++ b/electron/ipc/export/nativeVideo/audio.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { app } from "electron"; + import { getFfmpegBinaryPath } from "../../ffmpeg/binary"; import { replaceShareExportSource } from "../../growingShareUpload"; import { @@ -13,6 +13,7 @@ import { import { formatFfmpegSeconds, getNowMs } from "./constants"; import { runFfmpegAudioMux } from "./process"; import { removeTemporaryExportFile } from "./stream"; +import { getTempStorageDir } from "../../../storageLocations"; import { type NativeStaticLayoutExportSession, type NativeVideoAudioMuxArgsOptions, @@ -140,7 +141,7 @@ export async function muxNativeVideoExportAudio( const extension = getEditedAudioExtension(options.editedAudioMimeType); audioInputPath = path.join( - app.getPath("temp"), + getTempStorageDir(), `recordly-export-audio-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${extension}`, ); const tempAudioWriteStartedAt = getNowMs(); @@ -209,7 +210,7 @@ export async function muxExportedVideoAudioBuffer( options: NativeVideoExportFinishOptions, ) { const tempVideoPath = path.join( - app.getPath("temp"), + getTempStorageDir(), `recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`, ); const metrics: NativeVideoAudioMuxMetrics = {}; diff --git a/electron/ipc/export/nativeVideo/encoder.ts b/electron/ipc/export/nativeVideo/encoder.ts index 265401325..f0532166b 100644 --- a/electron/ipc/export/nativeVideo/encoder.ts +++ b/electron/ipc/export/nativeVideo/encoder.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import path from "node:path"; -import { app } from "electron"; + import { buildNativeVideoExportArgs, getNativeVideoInputByteSize, @@ -11,6 +11,7 @@ import { import { cachedNativeVideoEncoder, setCachedNativeVideoEncoder } from "../../state"; import { execFileAsync } from "./constants"; import { removeTemporaryExportFile } from "./stream"; +import { getTempStorageDir } from "../../../storageLocations"; export async function getAvailableNativeVideoEncoders(ffmpegPath: string) { const { stdout } = await execFileAsync(ffmpegPath, ["-hide_banner", "-encoders"], { @@ -27,7 +28,7 @@ export async function probeNativeVideoEncoder( encodingMode: NativeExportEncodingMode, ) { const outputPath = path.join( - app.getPath("temp"), + getTempStorageDir(), `recordly-export-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`, ); const args = buildNativeVideoExportArgs( diff --git a/electron/ipc/export/nativeVideo/staticLayout.ts b/electron/ipc/export/nativeVideo/staticLayout.ts index f1c550551..830b501a0 100644 --- a/electron/ipc/export/nativeVideo/staticLayout.ts +++ b/electron/ipc/export/nativeVideo/staticLayout.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { app } from "electron"; + import { getFfprobeBinaryPath } from "../../ffmpeg/binary"; import { buildNativeStaticLayoutChunks, @@ -20,6 +20,7 @@ import { prepareNativeStaticLayoutSourceInput } from "./sourceProxy"; import { renderNativeStaticFfmpeg } from "./staticFfmpeg"; import { removeTemporaryExportFile } from "./stream"; import { prepareNativeStaticLayoutTimelineMap } from "./timeline"; +import { getTempStorageDir } from "../../../storageLocations"; import { type NativeStaticLayoutExportMetrics, type NativeStaticLayoutExportOptions, @@ -75,9 +76,9 @@ export async function exportNativeStaticLayoutVideo( terminating: false, currentProcess: null, }; - const chunkDirectory = path.join(app.getPath("temp"), sessionId); + const chunkDirectory = path.join(getTempStorageDir(), sessionId); const concatListPath = path.join(chunkDirectory, "chunks.txt"); - const videoOnlyPath = path.join(app.getPath("temp"), `${sessionId}.mp4`); + const videoOnlyPath = path.join(getTempStorageDir(), `${sessionId}.mp4`); onOutputFile?.(videoOnlyPath); const ffprobePath = getFfprobeBinaryPath(); let outputPathToKeep: string | null = null; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 30f975de3..fe60e100f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -15,6 +15,7 @@ import { registerProjectHandlers } from "./register/project"; import { registerRecordingHandlers } from "./register/recording"; import { registerSettingsHandlers } from "./register/settings"; import { registerSourceHandlers } from "./register/sources"; +import { registerStorageLocationHandlers } from "./register/storageLocations"; import { selectedSource, setNativeScreenRecordingActive, @@ -86,4 +87,5 @@ export function registerIpcHandlers( registerCloudBillingHandlers(); registerProjectHandlers(); registerSettingsHandlers(); + registerStorageLocationHandlers(); } diff --git a/electron/ipc/project/manager.ts b/electron/ipc/project/manager.ts index 8c787bba7..7f98dd14d 100644 --- a/electron/ipc/project/manager.ts +++ b/electron/ipc/project/manager.ts @@ -35,6 +35,8 @@ import { } from "../state"; import type { ProjectLibraryEntry, RecordingSessionData } from "../types"; import { canonicalMediaSources } from "./canonicalMediaSources"; +import { getConfiguredStorageLocations, getTempStorageDir } from "../../storageLocations"; +import { getCacheDir } from "../../storageLocations"; import { getRecordingsDir, normalizePath, @@ -66,11 +68,14 @@ export function isPathInsideDirectory(candidatePath: string, directoryPath: stri } export function isAllowedLocalReadPath(candidatePath: string) { + // User-chosen storage folders must stay readable: exports land there, and the + // editor reopens them from the media server after a restart. const allowedPrefixes = [ RECORDINGS_DIR, USER_DATA_PATH, getAssetRootPath(), - app.getPath("temp"), + getTempStorageDir(), + ...getConfiguredStorageLocations(), ]; const normalizedCandidatePath = normalizePath(candidatePath); @@ -290,7 +295,7 @@ export async function getProjectThumbnailPath(projectPath: string) { function projectThumbnailStoragePath(key: string) { const digest = createHash("sha256").update(key).digest("hex"); - return path.join(USER_DATA_PATH, "project-thumbnails", `${digest}.png`); + return path.join(getCacheDir("project-thumbnails"), `${digest}.png`); } export function getLegacyProjectThumbnailPath(projectPath: string) { diff --git a/electron/ipc/recording/handlers/startWindowsCapture.ts b/electron/ipc/recording/handlers/startWindowsCapture.ts index dfedeeeb5..37508890b 100644 --- a/electron/ipc/recording/handlers/startWindowsCapture.ts +++ b/electron/ipc/recording/handlers/startWindowsCapture.ts @@ -1,6 +1,6 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import path from "node:path"; -import { app } from "electron"; + import { getMonitorHandles } from "../../monitorResolver"; import { getWindowsCaptureExePath } from "../../paths/binaries"; import { @@ -37,6 +37,7 @@ import { } from "../windowsFallbacks"; import type { RecordingHandlerContext } from "./handlerContext"; +import { getTempStorageDir } from "../../../storageLocations"; export async function startWindowsCapture( context: Pick< @@ -77,7 +78,7 @@ export async function startWindowsCapture( const recordingsDir = await getRecordingsDir(); const timestamp = Date.now(); const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`); - tempVideoPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mp4`); + tempVideoPath = path.join(getTempStorageDir(), `recordly-native-${timestamp}.mp4`); let captureOutput = ""; let systemAudioPath: string | null = null; @@ -133,7 +134,7 @@ export async function startWindowsCapture( if (options?.capturesSystemAudio) { systemAudioPath = path.join(recordingsDir, `recording-${timestamp}.system.wav`); tempSystemAudioPath = path.join( - app.getPath("temp"), + getTempStorageDir(), `recordly-native-${timestamp}.system.wav`, ); config.captureSystemAudio = true; @@ -145,7 +146,7 @@ export async function startWindowsCapture( if (options?.capturesMicrophone && !browserMicFallbackRequested) { microphonePath = path.join(recordingsDir, `recording-${timestamp}.mic.wav`); - tempMicPath = path.join(app.getPath("temp"), `recordly-native-${timestamp}.mic.wav`); + tempMicPath = path.join(getTempStorageDir(), `recordly-native-${timestamp}.mic.wav`); config.captureMic = true; config.micOutputPath = tempMicPath; if (options.microphoneDeviceId) { diff --git a/electron/ipc/register/assets.ts b/electron/ipc/register/assets.ts index a383afbde..129a02ace 100644 --- a/electron/ipc/register/assets.ts +++ b/electron/ipc/register/assets.ts @@ -4,11 +4,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { app, ipcMain } from "electron"; -import { USER_DATA_PATH } from "../../appPaths"; import { getAssetRootPath, isAllowedLocalReadPath } from "../project/manager"; import { isTrustedRenderer } from "../../trustedRenderer"; import { normalizePath } from "../utils"; import { registerDesktopWallpaperHandler } from "./desktopWallpaper"; +import { getCacheDir } from "../../storageLocations"; export function registerAssetHandlers() { registerDesktopWallpaperHandler(); @@ -30,7 +30,7 @@ export function registerAssetHandlers() { // Returns the cached thumbnail as raw JPEG bytes for fast grid rendering. // Serialized to prevent concurrent nativeImage operations from eating memory. const THUMB_SIZE = 96; - const thumbCacheDir = path.join(USER_DATA_PATH, "wallpaper-thumbs"); + const thumbCacheDir = getCacheDir("wallpaper-thumbs"); let thumbGenerationQueue: Promise = Promise.resolve(); ipcMain.handle("generate-wallpaper-thumbnail", async (event, filePath: unknown) => { diff --git a/electron/ipc/register/export.ts b/electron/ipc/register/export.ts index 7e732f13a..f66d2e62b 100644 --- a/electron/ipc/register/export.ts +++ b/electron/ipc/register/export.ts @@ -6,7 +6,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SaveDialogOptions } from "electron"; -import { app, BrowserWindow, dialog, ipcMain } from "electron"; +import { BrowserWindow, dialog, ipcMain } from "electron"; import { getAvailableExportPath, getSelectedExportPath, @@ -53,6 +53,7 @@ import { } from "../nativeVideoExport"; import { isAllowedLocalReadPath, resolveApprovedLocalMediaPath } from "../project/manager"; import { approveUserPath } from "../utils"; +import { getExportsDir, getTempStorageDir } from "../../storageLocations"; import { type CaptionSidecarPayload, getCaptionSidecarExtensions, @@ -245,7 +246,7 @@ async function sanitizeNativeStaticLayoutExportOptions( } function isTempPathSafe(tempPath: string): boolean { - const tempRoot = path.resolve(app.getPath("temp")); + const tempRoot = path.resolve(getTempStorageDir()); const candidate = path.resolve(tempPath); if (candidate === tempRoot) { return false; @@ -255,7 +256,7 @@ function isTempPathSafe(tempPath: string): boolean { } function getExportSaveDialogDefaultPath(fileName: string, sidecarExtensions: readonly string[]) { - const requestedPath = path.join(app.getPath("downloads"), fileName); + const requestedPath = path.join(getExportsDir(), fileName); return getAvailableExportPath(requestedPath, sidecarExtensions).catch(() => requestedPath); } @@ -301,7 +302,7 @@ export function registerExportHandlers() { const ffmpegPath = getFfmpegBinaryPath(); const inputMode = options.inputMode ?? "rawvideo"; const sessionId = `recordly-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const outputPath = path.join(app.getPath("temp"), `${sessionId}.mp4`); + const outputPath = path.join(getTempStorageDir(), `${sessionId}.mp4`); observeShareExportSource(event.sender.id, outputPath); let encoderName: string; diff --git a/electron/ipc/register/storageLocations.ts b/electron/ipc/register/storageLocations.ts new file mode 100644 index 000000000..ab3ec5234 --- /dev/null +++ b/electron/ipc/register/storageLocations.ts @@ -0,0 +1,198 @@ +import fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import path from "node:path"; +import { BrowserWindow, dialog, ipcMain, shell } from "electron"; +import { RECORDINGS_DIR } from "../../appPaths"; +import { PROJECTS_DIRECTORY_NAME } from "../constants"; +import { + getProjectsDir, + persistRecordingsDirectorySetting, +} from "../project/manager"; +import { + describeStorageLocations, + getStorageLocation, + isStorageLocationKey, + resetStorageLocations, + STORAGE_LOCATION_KEYS, + type StorageLocationKey, + updateStorageLocations, +} from "../../storageLocations"; +import { isTrustedRenderer } from "../../trustedRenderer"; +import { nativeT } from "../../nativeI18n"; +import type { StorageFolder, StorageFolderId } from "../../../src/types/storageFolders"; +import { getRecordingsDir } from "../utils"; + +/** + * A single settings surface for every folder Recordly writes into. + * + * `recordings` and `projects` keep their existing persistence path: the + * recordings settings file plus the runtime cache that the library and project + * manager already validate against. `projects` is not a separate location - it + * is the `Projects` folder inside the recordings folder, so picking a projects + * folder moves its parent. + * + * The remaining keys (exports, temp, caption models, cache) are owned by + * `storageLocations.ts`. Both halves are merged here so the renderer handles one + * list and one "change folder" action. + */ + +export const STORAGE_FOLDER_IDS = [ + "recordings", + "projects", + ...STORAGE_LOCATION_KEYS, +] as const; + +export type { StorageFolder, StorageFolderId } from "../../../src/types/storageFolders"; + +export function isStorageFolderId(value: unknown): value is StorageFolderId { + return ( + typeof value === "string" && + (STORAGE_FOLDER_IDS as readonly string[]).includes(value) + ); +} + +export async function listStorageFolders(): Promise> { + const recordingsDir = await getRecordingsDir(); + const projectsDir = await getProjectsDir(); + // `describeStorageLocations` reports the same shape under `value`; map it to + // the `path` field so the renderer sees one folder representation. + const described = describeStorageLocations(); + const asFolder = ( + id: Exclude, + ): StorageFolder => ({ + id, + path: described[id].value, + fallback: described[id].fallback, + configured: described[id].configured, + }); + return { + recordings: { + id: "recordings", + path: recordingsDir, + fallback: RECORDINGS_DIR, + configured: path.resolve(recordingsDir) !== path.resolve(RECORDINGS_DIR), + }, + projects: { + id: "projects", + path: projectsDir, + fallback: path.join(RECORDINGS_DIR, PROJECTS_DIRECTORY_NAME), + configured: + path.resolve(path.dirname(projectsDir)) !== path.resolve(RECORDINGS_DIR), + }, + exports: asFolder("exports"), + temp: asFolder("temp"), + captionModels: asFolder("captionModels"), + cache: asFolder("cache"), + }; +} + +async function assertWritableDirectory(directory: string) { + await fs.mkdir(directory, { recursive: true }); + await fs.access(directory, fsConstants.W_OK); +} + +async function applyFolderSelection(id: StorageFolderId, directory: string) { + const resolved = path.resolve(directory); + await assertWritableDirectory(resolved); + if (id === "projects") { + // The library always reads `/Projects`, so adopt the parent + // folder the user actually picked and let the managed name stay in place. + await persistRecordingsDirectorySetting(path.dirname(resolved)); + return; + } + if (id === "recordings") { + await persistRecordingsDirectorySetting(resolved); + return; + } + const result = await updateStorageLocations({ [id]: resolved }); + if (result.warnings.length) throw new Error(result.warnings.join("; ")); +} + +export function registerStorageLocationHandlers() { + const handle = (channel: string, listener: Parameters[1]) => { + ipcMain.handle(channel, (event, ...args) => { + if (!isTrustedRenderer(event)) return { success: false }; + return listener(event, ...args); + }); + }; + + handle("storage-locations:get", async () => { + try { + return { success: true, folders: await listStorageFolders() }; + } catch (error) { + console.error("Could not read storage locations:", error); + return { success: false, error: String(error) }; + } + }); + + handle("storage-locations:choose", async (event, id: unknown) => { + if (!isStorageFolderId(id)) + return { success: false, error: "Unknown storage location" }; + try { + const folders = await listStorageFolders(); + const options: Electron.OpenDialogOptions = { + title: nativeT("chooseFolder"), + defaultPath: folders[id].path, + properties: ["openDirectory", "createDirectory", "promptToCreate"], + }; + const window = BrowserWindow.fromWebContents(event.sender); + const result = window + ? await dialog.showOpenDialog(window, options) + : await dialog.showOpenDialog(options); + if (result.canceled || !result.filePaths.length) + return { success: false, canceled: true, path: folders[id].path }; + + const selected = path.resolve(result.filePaths[0]); + await applyFolderSelection(id, selected); + return { + success: true, + path: selected, + folders: await listStorageFolders(), + }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + handle("storage-locations:open", async (_, id: unknown) => { + if (!isStorageFolderId(id)) + return { success: false, error: "Unknown storage location" }; + try { + const folders = await listStorageFolders(); + await fs.mkdir(folders[id].path, { recursive: true }); + const message = await shell.openPath(folders[id].path); + return message ? { success: false, error: message } : { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + handle("storage-locations:reset", async (_, id: unknown) => { + if (!isStorageFolderId(id)) + return { success: false, error: "Unknown storage location" }; + try { + if (id === "recordings" || id === "projects") { + await persistRecordingsDirectorySetting(RECORDINGS_DIR); + } else if (isStorageLocationKey(id)) { + await updateStorageLocations({ [id]: null }); + } + return { success: true, folders: await listStorageFolders() }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + handle("storage-locations:reset-all", async () => { + try { + await persistRecordingsDirectorySetting(RECORDINGS_DIR); + await resetStorageLocations(); + return { success: true, folders: await listStorageFolders() }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); +} + +/** Kept exported so other modules can resolve a location without re-reading. */ +export { getStorageLocation }; +export type { StorageLocationKey }; diff --git a/electron/preload.ts b/electron/preload.ts index 2100858ae..31f447fa6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1252,6 +1252,21 @@ contextBridge.exposeInMainWorld("electronAPI", { chooseRecordingsDirectory: () => { return ipcRenderer.invoke("choose-recordings-directory"); }, + getStorageFolders: () => { + return ipcRenderer.invoke("storage-locations:get"); + }, + chooseStorageFolder: (id: string) => { + return ipcRenderer.invoke("storage-locations:choose", id); + }, + openStorageFolder: (id: string) => { + return ipcRenderer.invoke("storage-locations:open", id); + }, + resetStorageFolder: (id: string) => { + return ipcRenderer.invoke("storage-locations:reset", id); + }, + resetAllStorageFolders: () => { + return ipcRenderer.invoke("storage-locations:reset-all"); + }, getShortcuts: () => { return ipcRenderer.invoke("get-shortcuts"); }, diff --git a/electron/storageLocations.test.ts b/electron/storageLocations.test.ts new file mode 100644 index 000000000..1a71deb87 --- /dev/null +++ b/electron/storageLocations.test.ts @@ -0,0 +1,115 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const userDataPath = path.join(os.tmpdir(), `recordly-storage-${process.pid}`); +const systemPaths: Record = { + temp: path.join(os.tmpdir(), "recordly-system-temp"), + downloads: path.join(os.tmpdir(), "recordly-system-downloads"), +}; + +// `vi.hoisted` keeps the mocked module in place before the import graph runs. +const mocked = vi.hoisted(() => ({ userDataPath: "", systemTemp: "", downloads: "" })); + +vi.mock("electron", () => ({ + app: { + getPath: (name: string) => { + if (name === "userData") return mocked.userDataPath; + if (name === "temp") return mocked.systemTemp; + if (name === "downloads") return mocked.downloads; + return path.join(mocked.userDataPath, name); + }, + }, +})); + +mocked.userDataPath = userDataPath; +mocked.systemTemp = systemPaths.temp; +mocked.downloads = systemPaths.downloads; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => + fs.rm(directory, { recursive: true, force: true }), + ), + ); +}); + +async function freshModule() { + vi.resetModules(); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-storage-locations-")); + temporaryDirectories.push(directory); + mocked.userDataPath = directory; + mocked.systemTemp = path.join(directory, "system-temp"); + mocked.downloads = path.join(directory, "system-downloads"); + return import("./storageLocations"); +} + +describe("storage locations", () => { + it("falls back to the OS locations Recordly used before this setting", async () => { + const storage = await freshModule(); + const folders = storage.describeStorageLocations(); + expect(folders.exports.value).toBe(mocked.downloads); + expect(folders.temp.value).toBe(mocked.systemTemp); + expect(folders.captionModels.value).toBe(path.join(mocked.userDataPath, "captions")); + expect(folders.cache.value).toBe(mocked.userDataPath); + for (const value of Object.values(folders)) expect(value.configured).toBe(false); + }); + + it("persists a chosen folder and reads it back on a later load", async () => { + const storage = await freshModule(); + const target = path.join(mocked.userDataPath, "custom", "exports"); + const result = await storage.updateStorageLocations({ exports: target }); + + expect(result.warnings).toEqual([]); + expect(result.values.exports).toMatchObject({ + value: path.resolve(target), + configured: true, + }); + + // A new module instance (next app launch) must see the stored value. + vi.resetModules(); + const reloaded = await import("./storageLocations"); + expect(reloaded.getStorageLocation("exports")).toBe(path.resolve(target)); + }); + + it("reports a folder it cannot create and keeps the previous value", async () => { + const storage = await freshModule(); + const good = path.join(mocked.userDataPath, "good-temp"); + await storage.updateStorageLocations({ temp: good }); + + // A file in the parent path makes creating the child directory impossible. + const blocker = path.join(mocked.userDataPath, "blocker"); + await fs.writeFile(blocker, "not-a-directory"); + const result = await storage.updateStorageLocations({ + temp: path.join(blocker, "nested"), + }); + + expect(result.warnings).toHaveLength(1); + expect(result.values.temp.value).toBe(path.resolve(good)); + }); + + it("clears a single key and every key without touching the others", async () => { + const storage = await freshModule(); + await storage.updateStorageLocations({ + exports: path.join(mocked.userDataPath, "out"), + temp: path.join(mocked.userDataPath, "scratch"), + }); + + const single = await storage.updateStorageLocations({ exports: null }); + expect(single.values.exports.configured).toBe(false); + expect(single.values.temp.configured).toBe(true); + + const reset = await storage.resetStorageLocations(); + expect(Object.values(reset.values).every((value) => !value.configured)).toBe(true); + }); + + it("exposes configured folders so local reads stay allowlisted", async () => { + const storage = await freshModule(); + const target = path.join(mocked.userDataPath, "media"); + await storage.updateStorageLocations({ exports: target }); + expect(storage.getConfiguredStorageLocations()).toContain(path.resolve(target)); + }); +}); diff --git a/electron/storageLocations.ts b/electron/storageLocations.ts new file mode 100644 index 000000000..a00272c3d --- /dev/null +++ b/electron/storageLocations.ts @@ -0,0 +1,241 @@ +import { + closeSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import path from "node:path"; +import { app } from "electron"; +import { USER_DATA_PATH } from "./appPaths"; + +/** + * User-configurable storage locations. + * + * Recordly writes several kinds of files: exports, scratch data while encoding, + * downloaded caption models and thumbnail caches. All of them used to live in + * fixed OS locations, which forced users with a small system drive to move data + * by hand. Recordings and projects already had their own setting, so this module + * owns the remaining four locations and keeps the whole set in one file. + * + * Only the four keys below are stored here. `recordings` and `projects` stay + * owned by `RECORDINGS_SETTINGS_FILE` so the existing safety rules around the + * recordings library keep working unchanged; the UI reads both sources and + * presents one list. + */ + +export const STORAGE_LOCATION_KEYS = [ + "exports", + "temp", + "captionModels", + "cache", +] as const; + +export type StorageLocationKey = (typeof STORAGE_LOCATION_KEYS)[number]; + +export type StorageLocationSetting = { + /** Location in use; may be a default until the user picks a folder. */ + value: string; + /** Fallback used when the key has no stored value. */ + fallback: string; + /** True when the value comes from the stored settings file. */ + configured: boolean; +}; + +const STORAGE_LOCATIONS_FILE = path.join(USER_DATA_PATH, "storage-locations.json"); + +let loaded = false; +let stored: Partial> = {}; + +export function isStorageLocationKey(value: unknown): value is StorageLocationKey { + return ( + typeof value === "string" && + (STORAGE_LOCATION_KEYS as readonly string[]).includes(value) + ); +} + +/** Defaults mirror the OS locations Recordly relied on before this setting existed. */ +export function getStorageLocationFallback(key: StorageLocationKey): string { + switch (key) { + case "exports": + return app.getPath("downloads"); + case "temp": + return app.getPath("temp"); + case "captionModels": + return path.join(USER_DATA_PATH, "captions"); + case "cache": + return USER_DATA_PATH; + } +} + +function normalizeDirectory(candidate: string) { + return path.resolve(candidate); +} + +function readStore() { + try { + const parsed = JSON.parse(readFileSync(STORAGE_LOCATIONS_FILE, "utf-8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const next: Partial> = {}; + for (const key of STORAGE_LOCATION_KEYS) { + const value = Reflect.get(parsed, key); + if (typeof value === "string" && value.trim()) next[key] = normalizeDirectory(value); + } + return next; + } catch (error) { + // A first run has no settings file; any other failure means "use defaults". + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + console.error("Could not read storage locations:", error); + } + return {}; + } +} + +/** + * Writes atomically so a crash mid-write cannot leave the app pointing at a + * half-written path list that would silently fall back to defaults. + */ +function writeStore(next: Partial>) { + mkdirSync(path.dirname(STORAGE_LOCATIONS_FILE), { recursive: true }); + const temporary = `${STORAGE_LOCATIONS_FILE}.${process.pid}.tmp`; + const fd = openSync(temporary, "wx", 0o600); + let closed = false; + try { + writeFileSync(fd, JSON.stringify(next, null, 2), "utf-8"); + fsyncSync(fd); + closeSync(fd); + closed = true; + renameSync(temporary, STORAGE_LOCATIONS_FILE); + } finally { + if (!closed) { + try { + closeSync(fd); + } catch { + /* Preserve the original write failure. */ + } + } + try { + unlinkSync(temporary); + } catch { + /* The rename may have consumed the temporary file. */ + } + } +} + +export function loadStorageLocations() { + if (loaded) return; + loaded = true; + stored = readStore(); +} + +export function getStorageLocation(key: StorageLocationKey): string { + loadStorageLocations(); + return stored[key] ?? getStorageLocationFallback(key); +} + +/** Configured locations only; used to extend local-path read allowlists. */ +export function getConfiguredStorageLocations(): string[] { + loadStorageLocations(); + return STORAGE_LOCATION_KEYS.flatMap((key) => { + const value = stored[key]; + return value ? [value] : []; + }); +} + +export function describeStorageLocations(): Record { + loadStorageLocations(); + const result = {} as Record; + for (const key of STORAGE_LOCATION_KEYS) { + const configured = stored[key]; + result[key] = { + value: configured ?? getStorageLocationFallback(key), + fallback: getStorageLocationFallback(key), + configured: configured !== undefined, + }; + } + return result; +} + +export type StorageLocationPatch = Partial>; + +export type StorageLocationUpdateResult = { + values: Record; + warnings: string[]; +}; + +/** + * Applies a patch, skipping keys whose folder cannot be created or written. + * Validation is per key so one bad folder does not discard the other edits. + */ +export async function updateStorageLocations( + patch: StorageLocationPatch, +): Promise { + loadStorageLocations(); + const next: Partial> = { ...stored }; + const warnings: string[] = []; + + for (const key of STORAGE_LOCATION_KEYS) { + // `Object.hasOwn` needs ES2022; the project targets ES2020, so use the + // descriptor check the rest of the main process already relies on. + if (Reflect.getOwnPropertyDescriptor(patch, key) === undefined) continue; + const requested = patch[key]; + if (requested === null || requested === undefined || !String(requested).trim()) { + Reflect.deleteProperty(next, key); + continue; + } + const candidate = normalizeDirectory(String(requested).trim()); + try { + await fs.mkdir(candidate, { recursive: true }); + await fs.access(candidate, fsConstants.W_OK); + } catch (error) { + warnings.push(`${key}: ${candidate} is not writable (${String(error)})`); + continue; + } + next[key] = candidate; + } + + writeStore(next); + stored = next; + await Promise.all( + STORAGE_LOCATION_KEYS.map((key) => fs.mkdir(getStorageLocation(key), { recursive: true }).catch(() => {})), + ); + return { values: describeStorageLocations(), warnings }; +} + +export async function resetStorageLocations(): Promise { + loadStorageLocations(); + writeStore({}); + stored = {}; + await Promise.all( + STORAGE_LOCATION_KEYS.map((key) => fs.mkdir(getStorageLocation(key), { recursive: true }).catch(() => {})), + ); + return { values: describeStorageLocations(), warnings: [] }; +} + +// ── Convenience accessors used across the main process ──────────────────────── + +/** Scratch space for export sessions and intermediate encoders. */ +export function getTempStorageDir(): string { + return getStorageLocation("temp"); +} + +/** Default folder for the export save dialog. */ +export function getExportsDir(): string { + return getStorageLocation("exports"); +} + +/** Root for downloaded caption models. Each model keeps its own subfolder. */ +export function getCaptionModelsDir(): string { + return getStorageLocation("captionModels"); +} + +/** Root for derived caches such as wallpaper and project thumbnails. */ +export function getCacheDir(subdirectory?: string): string { + const root = getStorageLocation("cache"); + return subdirectory ? path.join(root, subdirectory) : root; +} diff --git a/src/components/video-editor/SettingsSections.tsx b/src/components/video-editor/SettingsSections.tsx index 117af100a..79b478f6a 100644 --- a/src/components/video-editor/SettingsSections.tsx +++ b/src/components/video-editor/SettingsSections.tsx @@ -21,6 +21,7 @@ export type SettingsSection = | "permissions" | "motion" | "recording" + | "storage" | "files" | "updates" | "developer"; @@ -53,6 +54,7 @@ export function SettingsSections({ permissions: t("settings.categories.permissions", "Permissions"), motion: t("settings.categories.motion", "Motion"), recording: t("settings.categories.recording", "Recording"), + storage: t("settings.storage.title", "Storage locations"), files: t("settings.appPreferences.storage", "Storage"), updates: t("settings.updates.title", "Updates"), developer: t("settings.effects.devSection", "Dev"), diff --git a/src/components/video-editor/dashboard/DashboardSettings.tsx b/src/components/video-editor/dashboard/DashboardSettings.tsx index dee8ddccd..5e8776ac9 100644 --- a/src/components/video-editor/dashboard/DashboardSettings.tsx +++ b/src/components/video-editor/dashboard/DashboardSettings.tsx @@ -8,6 +8,7 @@ import { supportsHudCaptureProtection } from "@/lib/hudCaptureProtection"; import { SettingsRow } from "../SettingsRow"; import { SettingsInfo } from "../SettingsInfo"; import { SettingsCategory, SettingsSections, type SettingsSection } from "../SettingsSections"; +import { StorageLocationsSettings } from "../settings/StorageLocationsSettings"; import { ThemeAppearancePicker } from "./ThemeAppearancePicker"; import { AppUpdateSettings } from "./AppUpdateSettings"; import { SettingsSidebarFooter } from "./SettingsSidebarFooter"; @@ -21,14 +22,11 @@ export function DashboardSettings({ }) { const { t } = useI18n(); const settingsContent = useContext(DashboardSettingsContext); - const [directory, setDirectory] = useState(""); - const [recordings, setRecordings] = useState(""); const [hideHud, setHideHud] = useState(); const [captureSupported, setCaptureSupported] = useState(false); const [busy, setBusy] = useState(); const [feedback, setFeedback] = useState<{ area: string; error?: string; message?: string }>(); const [reload, setReload] = useState(0); - const [folderErrors, setFolderErrors] = useState>({}); const run = async (area: string, action: () => Promise) => { if (busy) return; setBusy(area); @@ -41,41 +39,9 @@ export function DashboardSettings({ setBusy(undefined); } }; - // biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries location reads after a failure. + // biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries the HUD probe after a failure. useEffect(() => { let active = true; - const loadFolder = async ( - area: string, - get: () => Promise<{ success: boolean; path?: string }>, - set: (path: string) => void, - ) => { - try { - const result = await get(); - if (!result.success || !result.path) - throw Error( - t( - "settings.appPreferences.folderUnavailable", - "Folder location is unavailable.", - ), - ); - if (active) { - set(result.path); - setFolderErrors((errors) => ({ ...errors, [area]: "" })); - } - } catch (error) { - if (active) - setFolderErrors((errors) => ({ - ...errors, - [area]: error instanceof Error ? error.message : String(error), - })); - } - }; - void loadFolder( - "recordings", - () => window.electronAPI.getRecordingsDirectory(), - setRecordings, - ); - void loadFolder("projects", () => window.electronAPI.getProjectsDirectory(), setDirectory); void (async () => { try { const [protection, platform] = await Promise.all([ @@ -114,15 +80,6 @@ export function DashboardSettings({ {feedback.error && }

) : null; - const openFolder = async (kind: "recordings" | "projects") => { - const result = await (kind === "recordings" - ? window.electronAPI.openRecordingsFolder() - : window.electronAPI.openProjectsDirectory()); - if (result && !result.success) - throw Error( - t("settings.appPreferences.openFolderFailed", "Could not open this folder."), - ); - }; return (
)} - +
-

{t("settings.appPreferences.storage", "Storage")}

+

{t("settings.storage.title", "Storage locations")}

{t( - "settings.appPreferences.folderScope", - "Changing this location affects future recordings. Existing files stay in their current folders.", + "settings.storage.description", + "Choose where Recordly saves each kind of file. New files use the folder you pick; existing files stay where they are.", )}

-
-
- - {folderErrors.recordings ? ( - {folderErrors.recordings} - ) : ( - recordings || - t( - "settings.appPreferences.loadingFolder", - "Loading folder location…", - ) - )} - - } - help={ - <> - {t( - "settings.appPreferences.folderScope", - "Changing this location affects future recordings. Existing files stay in their current folders.", - )} - - } - > -
- - -
-
- {status("recordings")} -
-
- - {folderErrors.projects ? ( - {folderErrors.projects} - ) : ( - directory || - t( - "settings.appPreferences.loadingFolder", - "Loading folder location…", - ) - )} - - } - help={ - <> - {t( - "settings.dashboard.autosave", - "Named projects save automatically.", - )} - - } - > - - - {status("projects")} -
-
- {(folderErrors.recordings || folderErrors.projects) && ( - - )} +
diff --git a/src/components/video-editor/settings/AppSettings.tsx b/src/components/video-editor/settings/AppSettings.tsx index 8fa126bb4..ede7dd572 100644 --- a/src/components/video-editor/settings/AppSettings.tsx +++ b/src/components/video-editor/settings/AppSettings.tsx @@ -14,6 +14,7 @@ import { SUPPORTED_LOCALES } from "@/i18n/config"; import { DeveloperMotionSettings, developerMotionSettingsKeys } from "./DeveloperMotionSettings"; import { MotionPresetCards } from "./MotionPresetCards"; import { ShortcutsSettingsSection } from "./ShortcutsSettingsSection"; +import { StorageLocationsSettings } from "./StorageLocationsSettings"; import { SectionLabel } from "./SettingsSectionLabel"; import type { ResolvedSettingsPanelProps } from "./settingsPanelDefaults"; import { pickSettingsProps } from "./settingsPanelDefaults"; @@ -77,6 +78,7 @@ export function AppSettings(props: Props) { advanced ? [ "general", + "storage", "shortcuts", "account", "permissions", @@ -84,7 +86,7 @@ export function AppSettings(props: Props) { "updates", ...(showDevMotionControls ? ["developer" as const] : []), ] - : ["general", "shortcuts", "account", "permissions", "motion"] + : ["general", "storage", "shortcuts", "account", "permissions", "motion"] } > @@ -246,6 +248,18 @@ export function AppSettings(props: Props) { />
+ +
+ {tSettings("storage.title", "Storage locations")} +

+ {tSettings( + "storage.description", + "Choose where Recordly saves each kind of file. New files use the folder you pick; existing files stay where they are.", + )} +

+ +
+
diff --git a/src/components/video-editor/settings/StorageLocationsSettings.tsx b/src/components/video-editor/settings/StorageLocationsSettings.tsx new file mode 100644 index 000000000..d5129ba15 --- /dev/null +++ b/src/components/video-editor/settings/StorageLocationsSettings.tsx @@ -0,0 +1,227 @@ +import { Button } from "@/components/ui/button"; +import { useI18n } from "@/contexts/I18nContext"; +import { useEffect, useState } from "react"; +import type { + StorageFolder, + StorageFolderId, + StorageFoldersResponse, +} from "@/types/storageFolders"; +import { SettingsRow } from "../SettingsRow"; + +type FolderRow = { + id: StorageFolderId; + titleKey: string; + titleFallback: string; + descriptionKey: string; + descriptionFallback: string; +}; + +/** + * Listed in the order a creator meets them: where recordings land first, then + * the files produced from a recording, then background data. + */ +const FOLDER_ROWS: FolderRow[] = [ + { + id: "recordings", + titleKey: "settings.storage.recordings", + titleFallback: "Recordings", + descriptionKey: "settings.storage.recordingsDescription", + descriptionFallback: + "Screen captures, system audio, microphone tracks and cursor data. Existing recordings stay where they are.", + }, + { + id: "projects", + titleKey: "settings.storage.projects", + titleFallback: "Projects and drafts", + descriptionKey: "settings.storage.projectsDescription", + descriptionFallback: + "Saved .recordly projects, their previews and autosaves. Kept inside the recordings folder.", + }, + { + id: "exports", + titleKey: "settings.storage.exports", + titleFallback: "Exported videos", + descriptionKey: "settings.storage.exportsDescription", + descriptionFallback: + "Folder the save dialog opens in when you export an MP4 or GIF.", + }, + { + id: "temp", + titleKey: "settings.storage.temp", + titleFallback: "Temporary files", + descriptionKey: "settings.storage.tempDescription", + descriptionFallback: + "Scratch data while recording and exporting. Point this at a larger drive if your system drive is small.", + }, + { + id: "captionModels", + titleKey: "settings.storage.captionModels", + titleFallback: "Caption models", + descriptionKey: "settings.storage.captionModelsDescription", + descriptionFallback: + "Downloaded speech-to-text models used for automatic captions.", + }, + { + id: "cache", + titleKey: "settings.storage.cache", + titleFallback: "Cache", + descriptionKey: "settings.storage.cacheDescription", + descriptionFallback: + "Regenerated data such as wallpaper thumbnails and project previews. Safe to relocate or clear.", + }, +]; + +/** + * Every folder Recordly writes into, in one place. + * + * The main process owns validation and persistence; this component only reflects + * the returned folder map so the UI can never show a path the app did not accept. + */ +export function StorageLocationsSettings() { + const { t } = useI18n(); + const [folders, setFolders] = useState(); + const [error, setError] = useState(); + const [busy, setBusy] = useState(); + + const applyResponse = (result: StorageFoldersResponse) => { + if (!result.success || !result.folders) { + setError( + result.error || + t("settings.storage.loadFailed", "Could not read storage locations."), + ); + return; + } + setError(undefined); + setFolders(result.folders); + }; + + useEffect(() => { + let active = true; + void window.electronAPI + .getStorageFolders() + .then((result) => { + if (active) applyResponse(result); + }) + .catch((failure: unknown) => { + if (active) setError(failure instanceof Error ? failure.message : String(failure)); + }); + return () => { + active = false; + }; + // applyResponse only closes over setState and translators, which are stable. + // biome-ignore lint/correctness/useExhaustiveDependencies: load once on mount. + }, [t]); + + const run = async (id: StorageFolderId | "all", action: () => Promise) => { + if (busy) return; + setBusy(id); + setError(undefined); + try { + applyResponse(await action()); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setBusy(undefined); + } + }; + + if (error && !folders) { + return ( +

+ {error} +

+ ); + } + + return ( +
+ {FOLDER_ROWS.map((row) => { + const folder: StorageFolder | undefined = folders?.[row.id]; + return ( +
+ + {folder?.path ?? + t("settings.storage.loading", "Loading folder location…")} + + } + help={ + <> + {t(row.descriptionKey, row.descriptionFallback)} + {folder && !folder.configured ? ( + <> + {" "} + {t( + "settings.storage.usingDefault", + "Currently the default location.", + )} + + ) : null} + + } + > +
+ + + +
+
+
+ ); + })} + {error && ( +

+ {error} +

+ )} +
+ +
+
+ ); +} diff --git a/src/i18n/locales/de/native.json b/src/i18n/locales/de/native.json index 378553cdc..485f3edc3 100644 --- a/src/i18n/locales/de/native.json +++ b/src/i18n/locales/de/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "Projekt konnte nicht gespeichert werden", "saveFailedMessage": "Das Speichern ist fehlgeschlagen oder reagiert nicht. Ungespeicherte Änderungen verwerfen und schließen?", "welcome": "Willkommen bei Recordly", + "chooseFolder": "Ordner auswählen", "chooseRecordings": "Aufnahmeordner auswählen", "saveRecordlyProject": "Recordly-Projekt speichern", "recordlyProject": "Recordly-Projekt", diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index a65c877d2..395887977 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Anmerkung löschen" + }, + "storage": { + "title": "Speicherorte", + "description": "Lege fest, wo Recordly welche Dateien ablegt. Neue Dateien nutzen den gewählten Ordner; vorhandene bleiben, wo sie sind.", + "recordings": "Aufnahmen", + "recordingsDescription": "Bildschirmaufnahmen mit Systemton, Mikrofonspuren und Cursordaten.", + "projects": "Projekte und Entwürfe", + "projectsDescription": "Gespeicherte Projekte, ihre Vorschauen und automatische Sicherungen. Liegt im Aufnahmeordner.", + "exports": "Exportierte Videos", + "exportsDescription": "Ordner, den der Speichern-Dialog beim Export von MP4 oder GIF öffnet.", + "temp": "Temporäre Dateien", + "tempDescription": "Zwischendaten beim Aufnehmen und Exportieren. Wähle ein größeres Laufwerk, wenn die Systemplatte knapp ist.", + "captionModels": "Untertitelmodelle", + "captionModelsDescription": "Heruntergeladene Spracherkennungsmodelle für automatische Untertitel.", + "cache": "Cache", + "cacheDescription": "Neu erzeugbare Daten wie Hintergrundminiaturen und Projektvorschauen. Kann verschoben oder geleert werden.", + "change": "Ändern…", + "open": "Öffnen", + "reset": "Zurücksetzen", + "resetAll": "Alle Standardorte wiederherstellen", + "usingDefault": "Derzeit der Standardort.", + "loading": "Ordnerpfad wird geladen…", + "loadFailed": "Speicherorte konnten nicht gelesen werden." } } diff --git a/src/i18n/locales/en/native.json b/src/i18n/locales/en/native.json index a9a00b2eb..43ffe9a39 100644 --- a/src/i18n/locales/en/native.json +++ b/src/i18n/locales/en/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "Project could not be saved", "saveFailedMessage": "Saving failed or did not respond. Discard unsaved changes and close?", "welcome": "Welcome to Recordly", + "chooseFolder": "Choose folder", "chooseRecordings": "Choose recordings folder", "saveRecordlyProject": "Save Recordly Project", "recordlyProject": "Recordly Project", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index dd2762750..84ce9d643 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Delete Annotation" + }, + "storage": { + "title": "Storage locations", + "description": "Choose where Recordly saves each kind of file. New files use the folder you pick; existing files stay where they are.", + "recordings": "Recordings", + "recordingsDescription": "Screen captures with their system audio, microphone tracks and cursor data.", + "projects": "Projects and drafts", + "projectsDescription": "Saved projects, their previews and autosaves. Lives inside the recordings folder.", + "exports": "Exported videos", + "exportsDescription": "Folder the save dialog opens in when you export an MP4 or GIF.", + "temp": "Temporary files", + "tempDescription": "Scratch data while recording and exporting. Point this at a larger drive if your system drive is small.", + "captionModels": "Caption models", + "captionModelsDescription": "Downloaded speech-to-text models used for automatic captions.", + "cache": "Cache", + "cacheDescription": "Regenerated data such as wallpaper thumbnails and project previews. Safe to relocate or clear.", + "change": "Change…", + "open": "Open", + "reset": "Reset", + "resetAll": "Restore all default locations", + "usingDefault": "Currently the default location.", + "loading": "Loading folder location…", + "loadFailed": "Could not read storage locations." } } diff --git a/src/i18n/locales/es/native.json b/src/i18n/locales/es/native.json index 3f8b1bbf6..219564dd9 100644 --- a/src/i18n/locales/es/native.json +++ b/src/i18n/locales/es/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "No se pudo guardar el proyecto", "saveFailedMessage": "El guardado falló o no respondió. ¿Descartar los cambios sin guardar y cerrar?", "welcome": "Te damos la bienvenida a Recordly", + "chooseFolder": "Elegir carpeta", "chooseRecordings": "Elegir carpeta de grabaciones", "saveRecordlyProject": "Guardar proyecto de Recordly", "recordlyProject": "Proyecto de Recordly", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index df6e3af22..6f1eda6d9 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Eliminar anotación" + }, + "storage": { + "title": "Ubicaciones de almacenamiento", + "description": "Elige dónde guarda Recordly cada tipo de archivo. Los archivos nuevos usan la carpeta que elijas; los existentes se quedan donde están.", + "recordings": "Grabaciones", + "recordingsDescription": "Grabaciones de pantalla con audio del sistema, pistas de micrófono y datos del cursor.", + "projects": "Proyectos y borradores", + "projectsDescription": "Proyectos guardados, sus vistas previas y guardados automáticos. Vive dentro de la carpeta de grabaciones.", + "exports": "Vídeos exportados", + "exportsDescription": "Carpeta que se abre al guardar cuando exportas un MP4 o un GIF.", + "temp": "Archivos temporales", + "tempDescription": "Datos temporales durante la grabación y la exportación. Úsalo en un disco más grande si el sistema está lleno.", + "captionModels": "Modelos de subtítulos", + "captionModelsDescription": "Modelos de voz a texto descargados para los subtítulos automáticos.", + "cache": "Caché", + "cacheDescription": "Datos que se regeneran, como miniaturas de fondos y vistas previas. Puedes moverlo o vaciarlo.", + "change": "Cambiar…", + "open": "Abrir", + "reset": "Restablecer", + "resetAll": "Restaurar todas las ubicaciones predeterminadas", + "usingDefault": "Actualmente es la ubicación predeterminada.", + "loading": "Cargando la ubicación de la carpeta…", + "loadFailed": "No se pudieron leer las ubicaciones de almacenamiento." } } diff --git a/src/i18n/locales/fr/native.json b/src/i18n/locales/fr/native.json index 81a82b1de..8960a043f 100644 --- a/src/i18n/locales/fr/native.json +++ b/src/i18n/locales/fr/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "Le projet n’a pas pu être enregistré", "saveFailedMessage": "L’enregistrement a échoué ou ne répond pas. Ignorer les modifications non enregistrées et fermer ?", "welcome": "Bienvenue dans Recordly", + "chooseFolder": "Choisir un dossier", "chooseRecordings": "Choisir le dossier des enregistrements", "saveRecordlyProject": "Enregistrer un projet Recordly", "recordlyProject": "Projet Recordly", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f98b18bb1..cbc3efc0e 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Supprimer l’annotation" + }, + "storage": { + "title": "Emplacements de stockage", + "description": "Choisissez où Recordly enregistre chaque type de fichier. Les nouveaux fichiers utilisent le dossier choisi ; les fichiers existants restent en place.", + "recordings": "Enregistrements", + "recordingsDescription": "Captures d'écran avec le son du système, les pistes micro et les données du curseur.", + "projects": "Projets et brouillons", + "projectsDescription": "Projets enregistrés, leurs aperçus et les sauvegardes automatiques. Se trouve dans le dossier des enregistrements.", + "exports": "Vidéos exportées", + "exportsDescription": "Dossier ouvert par la boîte d'enregistrement lors d'un export MP4 ou GIF.", + "temp": "Fichiers temporaires", + "tempDescription": "Données temporaires pendant l'enregistrement et l'export. Choisissez un disque plus grand si votre disque système est petit.", + "captionModels": "Modèles de sous-titres", + "captionModelsDescription": "Modèles de reconnaissance vocale téléchargés pour les sous-titres automatiques.", + "cache": "Cache", + "cacheDescription": "Données régénérées comme les miniatures de fond d'écran et les aperçus. Vous pouvez les déplacer ou les vider.", + "change": "Modifier…", + "open": "Ouvrir", + "reset": "Réinitialiser", + "resetAll": "Rétablir tous les emplacements par défaut", + "usingDefault": "Emplacement par défaut actuellement.", + "loading": "Chargement de l'emplacement…", + "loadFailed": "Impossible de lire les emplacements de stockage." } } diff --git a/src/i18n/locales/it/native.json b/src/i18n/locales/it/native.json index eb7300ef5..b61b68cc4 100644 --- a/src/i18n/locales/it/native.json +++ b/src/i18n/locales/it/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "Impossibile salvare il progetto", "saveFailedMessage": "Il salvataggio non è riuscito o non risponde. Scartare le modifiche non salvate e chiudere?", "welcome": "Ti diamo il benvenuto in Recordly", + "chooseFolder": "Scegli cartella", "chooseRecordings": "Scegli la cartella delle registrazioni", "saveRecordlyProject": "Salva progetto Recordly", "recordlyProject": "Progetto Recordly", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 4ba04ae62..0c8f56d0e 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Elimina annotazione" + }, + "storage": { + "title": "Percorsi di archiviazione", + "description": "Scegli dove Recordly salva ogni tipo di file. I nuovi file usano la cartella scelta; quelli esistenti restano dove sono.", + "recordings": "Registrazioni", + "recordingsDescription": "Registrazioni dello schermo con audio di sistema, tracce microfono e dati del cursore.", + "projects": "Progetti e bozze", + "projectsDescription": "Progetti salvati, anteprime e salvataggi automatici. Si trova nella cartella delle registrazioni.", + "exports": "Video esportati", + "exportsDescription": "Cartella aperta dalla finestra di salvataggio quando esporti MP4 o GIF.", + "temp": "File temporanei", + "tempDescription": "Dati temporanei durante registrazione ed esportazione. Scegli un disco più grande se quello di sistema è piccolo.", + "captionModels": "Modelli per i sottotitoli", + "captionModelsDescription": "Modelli di riconoscimento vocale scaricati per i sottotitoli automatici.", + "cache": "Cache", + "cacheDescription": "Dati rigenerabili come miniature degli sfondi e anteprime dei progetti. Puoi spostarli o svuotarli.", + "change": "Cambia…", + "open": "Apri", + "reset": "Ripristina", + "resetAll": "Ripristina tutti i percorsi predefiniti", + "usingDefault": "Attualmente è il percorso predefinito.", + "loading": "Caricamento del percorso…", + "loadFailed": "Impossibile leggere i percorsi di archiviazione." } } diff --git a/src/i18n/locales/ja/native.json b/src/i18n/locales/ja/native.json index a069093ea..38a8f12d2 100644 --- a/src/i18n/locales/ja/native.json +++ b/src/i18n/locales/ja/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "プロジェクトを保存できませんでした", "saveFailedMessage": "保存に失敗したか、応答がありません。未保存の変更を破棄して閉じますか?", "welcome": "Recordlyへようこそ", + "chooseFolder": "フォルダを選択", "chooseRecordings": "録画フォルダを選択", "saveRecordlyProject": "Recordlyプロジェクトを保存", "recordlyProject": "Recordlyプロジェクト", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 0d1e59c0c..7a8d5ada9 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "注釈を削除" + }, + "storage": { + "title": "保存場所", + "description": "Recordly がファイルの種類ごとに保存する場所を選択します。新しいファイルは選んだフォルダーに保存され、既存のファイルはそのまま残ります。", + "recordings": "録画", + "recordingsDescription": "システム音声、マイク音声、カーソルデータを含む画面録画です。", + "projects": "プロジェクトと下書き", + "projectsDescription": "保存したプロジェクト、プレビュー、自動保存です。録画フォルダー内にあります。", + "exports": "書き出した動画", + "exportsDescription": "MP4 や GIF を書き出すときに保存ダイアログが開くフォルダーです。", + "temp": "一時ファイル", + "tempDescription": "録画と書き出し中の一時データです。システムドライブが小さい場合は大きいドライブを指定してください。", + "captionModels": "字幕モデル", + "captionModelsDescription": "自動字幕に使用する音声認識モデルです。", + "cache": "キャッシュ", + "cacheDescription": "壁紙のサムネイルやプロジェクトのプレビューなど再生成できるデータです。移動や削除が可能です。", + "change": "変更…", + "open": "開く", + "reset": "リセット", + "resetAll": "すべて既定の場所に戻す", + "usingDefault": "現在は既定の場所です。", + "loading": "フォルダーの場所を読み込み中…", + "loadFailed": "保存場所を読み取れませんでした。" } } diff --git a/src/i18n/locales/ko/native.json b/src/i18n/locales/ko/native.json index 5f567eff4..c75f8f997 100644 --- a/src/i18n/locales/ko/native.json +++ b/src/i18n/locales/ko/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "프로젝트를 저장할 수 없습니다", "saveFailedMessage": "저장에 실패했거나 응답이 없습니다. 저장하지 않은 변경 사항을 버리고 닫을까요?", "welcome": "Recordly에 오신 것을 환영합니다", + "chooseFolder": "폴더 선택", "chooseRecordings": "녹화 폴더 선택", "saveRecordlyProject": "Recordly 프로젝트 저장", "recordlyProject": "Recordly 프로젝트", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 6828404a6..2eb23d197 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "주석 삭제" + }, + "storage": { + "title": "저장 위치", + "description": "Recordly가 파일 종류별로 저장할 위치를 선택하세요. 새 파일은 선택한 폴더를 사용하며 기존 파일은 그대로 남습니다.", + "recordings": "녹화", + "recordingsDescription": "시스템 오디오, 마이크 트랙, 커서 데이터가 포함된 화면 녹화입니다.", + "projects": "프로젝트 및 초안", + "projectsDescription": "저장된 프로젝트, 미리보기, 자동 저장 파일입니다. 녹화 폴더 안에 있습니다.", + "exports": "내보낸 동영상", + "exportsDescription": "MP4 또는 GIF를 내보낼 때 저장 창이 열리는 폴더입니다.", + "temp": "임시 파일", + "tempDescription": "녹화와 내보내기 중 사용하는 임시 데이터입니다. 시스템 드라이브가 작으면 더 큰 드라이브를 지정하세요.", + "captionModels": "자막 모델", + "captionModelsDescription": "자동 자막에 사용하는 음성 인식 모델입니다.", + "cache": "캐시", + "cacheDescription": "배경 미리보기와 프로젝트 미리보기처럼 다시 생성되는 데이터입니다. 이동하거나 비워도 됩니다.", + "change": "변경…", + "open": "열기", + "reset": "초기화", + "resetAll": "모든 기본 위치 복원", + "usingDefault": "현재 기본 위치입니다.", + "loading": "폴더 위치를 불러오는 중…", + "loadFailed": "저장 위치를 읽을 수 없습니다." } } diff --git a/src/i18n/locales/nl/native.json b/src/i18n/locales/nl/native.json index b8529ad5b..5c625a69c 100644 --- a/src/i18n/locales/nl/native.json +++ b/src/i18n/locales/nl/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "Project kon niet worden opgeslagen", "saveFailedMessage": "Opslaan is mislukt of reageert niet. Niet-opgeslagen wijzigingen verwerpen en sluiten?", "welcome": "Welkom bij Recordly", + "chooseFolder": "Map kiezen", "chooseRecordings": "Opnamemap kiezen", "saveRecordlyProject": "Recordly-project opslaan", "recordlyProject": "Recordly-project", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 738194eda..3aae200e1 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Annotatie verwijderen" + }, + "storage": { + "title": "Opslaglocaties", + "description": "Kies waar Recordly elk soort bestand opslaat. Nieuwe bestanden gebruiken de gekozen map; bestaande bestanden blijven staan.", + "recordings": "Opnames", + "recordingsDescription": "Schermopnames met systeemgeluid, microfoonsporen en cursorwaarden.", + "projects": "Projecten en concepten", + "projectsDescription": "Opgeslagen projecten, hun voorbeelden en automatische opslag. Staat in de opnamemap.", + "exports": "Geëxporteerde video’s", + "exportsDescription": "Map die het opslaanvenster opent bij het exporteren van MP4 of GIF.", + "temp": "Tijdelijke bestanden", + "tempDescription": "Tijdelijke data tijdens opnemen en exporteren. Kies een grotere schijf als je systeemschijf klein is.", + "captionModels": "Ondertitelmodellen", + "captionModelsDescription": "Gedownloade spraak-naar-tekstmodellen voor automatische ondertitels.", + "cache": "Cache", + "cacheDescription": "Opnieuw te maken data zoals achtergrondminiaturen en projectvoorbeelden. Veilig te verplaatsen of te wissen.", + "change": "Wijzigen…", + "open": "Openen", + "reset": "Herstellen", + "resetAll": "Alle standaardlocaties herstellen", + "usingDefault": "Dit is momenteel de standaardlocatie.", + "loading": "Maplocatie laden…", + "loadFailed": "Opslaglocaties konden niet worden gelezen." } } diff --git a/src/i18n/locales/pt-BR/native.json b/src/i18n/locales/pt-BR/native.json index 3afb806bd..bf0f2861a 100644 --- a/src/i18n/locales/pt-BR/native.json +++ b/src/i18n/locales/pt-BR/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "Não foi possível salvar o projeto", "saveFailedMessage": "O salvamento falhou ou não respondeu. Descartar as alterações não salvas e fechar?", "welcome": "Boas-vindas ao Recordly", + "chooseFolder": "Escolher pasta", "chooseRecordings": "Escolher pasta de gravações", "saveRecordlyProject": "Salvar projeto do Recordly", "recordlyProject": "Projeto do Recordly", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 5bf7aedb2..5bf6fd33d 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "Excluir anotação" + }, + "storage": { + "title": "Locais de armazenamento", + "description": "Escolha onde o Recordly salva cada tipo de arquivo. Arquivos novos usam a pasta escolhida; os existentes ficam onde estão.", + "recordings": "Gravações", + "recordingsDescription": "Gravações de tela com áudio do sistema, faixas de microfone e dados do cursor.", + "projects": "Projetos e rascunhos", + "projectsDescription": "Projetos salvos, suas prévias e salvamentos automáticos. Fica dentro da pasta de gravações.", + "exports": "Vídeos exportados", + "exportsDescription": "Pasta que a janela de salvar abre ao exportar um MP4 ou GIF.", + "temp": "Arquivos temporários", + "tempDescription": "Dados temporários durante a gravação e a exportação. Use um disco maior se o disco do sistema for pequeno.", + "captionModels": "Modelos de legenda", + "captionModelsDescription": "Modelos de fala para texto baixados para legendas automáticas.", + "cache": "Cache", + "cacheDescription": "Dados regeneráveis como miniaturas de papel de parede e prévias de projetos. Pode mover ou limpar.", + "change": "Alterar…", + "open": "Abrir", + "reset": "Redefinir", + "resetAll": "Restaurar todos os locais padrão", + "usingDefault": "Atualmente é o local padrão.", + "loading": "Carregando o local da pasta…", + "loadFailed": "Não foi possível ler os locais de armazenamento." } } diff --git a/src/i18n/locales/zh-CN/native.json b/src/i18n/locales/zh-CN/native.json index db76741c0..25fea40e0 100644 --- a/src/i18n/locales/zh-CN/native.json +++ b/src/i18n/locales/zh-CN/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "无法保存项目", "saveFailedMessage": "保存失败或未响应。要放弃未保存的更改并关闭吗?", "welcome": "欢迎使用 Recordly", + "chooseFolder": "选择文件夹", "chooseRecordings": "选择录制文件夹", "saveRecordlyProject": "保存 Recordly 项目", "recordlyProject": "Recordly 项目", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index e23159388..091e7d09b 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "删除注释" + }, + "storage": { + "title": "存储位置", + "description": "选择 Recordly 各类文件的保存位置。新文件使用你选择的文件夹,已有文件保持原地不动。", + "recordings": "录制视频", + "recordingsDescription": "屏幕录制,含系统声音、麦克风音轨与光标数据。", + "projects": "项目与草稿", + "projectsDescription": "保存的项目、预览图与自动保存,位于录制文件夹内。", + "exports": "导出视频", + "exportsDescription": "导出 MP4 或 GIF 时保存对话框默认打开的文件夹。", + "temp": "临时文件", + "tempDescription": "录制与导出过程中的中间数据。系统盘空间不足时可指向更大的磁盘。", + "captionModels": "字幕模型", + "captionModelsDescription": "自动字幕使用的语音识别模型。", + "cache": "缓存", + "cacheDescription": "可重新生成的数据,如壁纸缩略图与项目预览。可迁移或清理。", + "change": "更改…", + "open": "打开", + "reset": "重置", + "resetAll": "全部恢复默认位置", + "usingDefault": "当前为默认位置。", + "loading": "正在读取文件夹位置…", + "loadFailed": "无法读取存储位置。" } } diff --git a/src/i18n/locales/zh-TW/native.json b/src/i18n/locales/zh-TW/native.json index b8401a319..5c6951616 100644 --- a/src/i18n/locales/zh-TW/native.json +++ b/src/i18n/locales/zh-TW/native.json @@ -18,6 +18,7 @@ "saveFailedTitle": "無法儲存專案", "saveFailedMessage": "儲存失敗或沒有回應。要捨棄未儲存的變更並關閉嗎?", "welcome": "歡迎使用 Recordly", + "chooseFolder": "選擇資料夾", "chooseRecordings": "選擇錄製資料夾", "saveRecordlyProject": "儲存 Recordly 專案", "recordlyProject": "Recordly 專案", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 3d9533759..dc329e7b6 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -598,5 +598,28 @@ }, "annotation": { "delete": "刪除註釋" + }, + "storage": { + "title": "儲存位置", + "description": "選擇 Recordly 各類檔案的儲存位置。新檔案使用你選擇的資料夾,既有檔案維持在原地。", + "recordings": "錄製影片", + "recordingsDescription": "螢幕錄製,包含系統聲音、麥克風音軌與游標資料。", + "projects": "專案與草稿", + "projectsDescription": "已儲存的專案、預覽圖與自動儲存,位於錄製資料夾內。", + "exports": "匯出影片", + "exportsDescription": "匯出 MP4 或 GIF 時儲存對話框預設開啟的資料夾。", + "temp": "暫存檔案", + "tempDescription": "錄製與匯出過程中的暫存資料。系統碟空間不足時可指向更大的磁碟。", + "captionModels": "字幕模型", + "captionModelsDescription": "自動字幕使用的語音辨識模型。", + "cache": "快取", + "cacheDescription": "可重新產生的資料,如桌布縮圖與專案預覽。可搬移或清理。", + "change": "變更…", + "open": "開啟", + "reset": "重設", + "resetAll": "全部恢復預設位置", + "usingDefault": "目前為預設位置。", + "loading": "正在讀取資料夾位置…", + "loadFailed": "無法讀取儲存位置。" } } diff --git a/src/types/storageFolders.ts b/src/types/storageFolders.ts new file mode 100644 index 000000000..90a2df638 --- /dev/null +++ b/src/types/storageFolders.ts @@ -0,0 +1,39 @@ +/** + * Storage folders as the renderer sees them. + * + * Declared here rather than under `electron/` so the settings UI can import the + * shape without reaching into the main-process tree, matching how other shared + * renderer types (for example `recordingLibrary`) are organised. + */ + +export const STORAGE_FOLDER_IDS = [ + "recordings", + "projects", + "exports", + "temp", + "captionModels", + "cache", +] as const; + +export type StorageFolderId = (typeof STORAGE_FOLDER_IDS)[number]; + +export type StorageFolder = { + id: StorageFolderId; + /** Folder currently in use. */ + path: string; + /** Folder used when nothing is configured. */ + fallback: string; + /** True when `path` comes from the user's settings. */ + configured: boolean; +}; + +export type StorageFoldersResponse = { + success: boolean; + folders?: Record; + error?: string; +}; + +export type StorageFolderMutationResponse = StorageFoldersResponse & { + canceled?: boolean; + path?: string; +};