Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ interface Window {
revealInFolder: (
filePath: string,
) => Promise<{ success: boolean; error?: string; message?: string }>;
getRecordingsDir: () => Promise<{ path: string; isDefault: boolean }>;
chooseRecordingsDir: () => Promise<
{ success: true; path: string } | { success: false; canceled?: boolean; message?: string }
>;
resetRecordingsDir: () => Promise<
{ success: true; path: string } | { success: false; message?: string }
>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
updateGlobalShortcut: (binding: {
Expand Down
44 changes: 43 additions & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import { DocumentService } from "../ai-edition/document-service";
import { LlmConfigStore } from "../ai-edition/llm-config-store";
import { mainLogBuffer } from "../diagnostics/main-log-buffer";
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";
import { getRecordingsDirInfo, RECORDINGS_DIR, setRecordingsDir } from "../main";
import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks";
import {
readCursorRecordingFile as readCursorRecordingFileFrom,
Expand Down Expand Up @@ -1758,6 +1758,48 @@ export function registerIpcHandlers(
return recordingPrefs;
});

ipcMain.handle("get-recordings-dir", () => {
return getRecordingsDirInfo();
});

ipcMain.handle("choose-recordings-dir", async () => {
const dialogOptions = buildDialogOptions(
{
title: mainT("dialogs", "fileDialogs.selectRecordingsFolder"),
defaultPath: RECORDINGS_DIR,
properties: ["openDirectory", "createDirectory"] as Array<
"openDirectory" | "createDirectory"
>,
},
getMainWindow(),
);
const result = await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
try {
const resolved = await setRecordingsDir(result.filePaths[0]);
return { success: true, path: resolved };
} catch (error) {
console.error("Failed to switch recordings folder:", error);
return {
success: false,
message: "Failed to switch recordings folder",
error: String(error),
};
}
});

ipcMain.handle("reset-recordings-dir", async () => {
try {
const resolved = await setRecordingsDir(null);
return { success: true, path: resolved };
} catch (error) {
console.error("Failed to reset recordings folder:", error);
return { success: false, message: "Failed to reset recordings folder", error: String(error) };
}
});

ipcMain.handle("request-camera-access", async () => {
if (process.platform !== "darwin") {
return { success: true, granted: true, status: "granted" };
Expand Down
27 changes: 26 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { mainT, setMainLocale } from "./i18n";
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
import { installMainProcessErrorGuards } from "./main-process-errors";
import { RecordingsLocationStore } from "./recording/recordingsLocationStore";
import { registerSttIpc } from "./stt";
import {
createCountdownOverlayWindow,
Expand Down Expand Up @@ -64,7 +65,14 @@ if (process.platform === "linux") {

installMainProcessErrorGuards();

export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
export const DEFAULT_RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");

const recordingsLocationStore = new RecordingsLocationStore(app.getPath("userData"));

// Mutable: reassigned by setRecordingsDir() when the user picks a custom
// location in settings. `handlers.ts` imports this as a live named binding,
// so every call site there sees the change immediately — no restart needed.
export let RECORDINGS_DIR = recordingsLocationStore.getCustomDir() ?? DEFAULT_RECORDINGS_DIR;

async function ensureRecordingsDir() {
try {
Expand All @@ -76,6 +84,23 @@ async function ensureRecordingsDir() {
}
}

/**
* Switches where recordings are read from and written to, going forward.
* Pass `null` to reset to the default (userData/recordings). Does not move
* any existing files — the old location is left untouched.
*/
export async function setRecordingsDir(customDir: string | null): Promise<string> {
const resolved = customDir ? path.resolve(customDir) : DEFAULT_RECORDINGS_DIR;
await fs.mkdir(resolved, { recursive: true });
RECORDINGS_DIR = resolved;
await recordingsLocationStore.setCustomDir(customDir ? resolved : null);
return RECORDINGS_DIR;
}

export function getRecordingsDirInfo() {
return { path: RECORDINGS_DIR, isDefault: RECORDINGS_DIR === DEFAULT_RECORDINGS_DIR };
}

// The built directory structure
//
// ├─┬─┬ dist
Expand Down
16 changes: 16 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,22 @@ contextBridge.exposeInMainWorld("electronAPI", {
revealInFolder: (filePath: string) => {
return ipcRenderer.invoke("reveal-in-folder", filePath);
},
getRecordingsDir: () => {
return ipcRenderer.invoke("get-recordings-dir") as Promise<{
path: string;
isDefault: boolean;
}>;
},
chooseRecordingsDir: () => {
return ipcRenderer.invoke("choose-recordings-dir") as Promise<
{ success: true; path: string } | { success: false; canceled?: boolean; message?: string }
>;
},
resetRecordingsDir: () => {
return ipcRenderer.invoke("reset-recordings-dir") as Promise<
{ success: true; path: string } | { success: false; message?: string }
>;
},
getShortcuts: () => {
return ipcRenderer.invoke("get-shortcuts");
},
Expand Down
42 changes: 42 additions & 0 deletions electron/recording/recordingsLocationStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Persists the user's chosen recordings folder across restarts. Stored next to
// llm-config.json in userData rather than inside the recordings folder itself,
// since the whole point is that folder can move.
import { readFileSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";

interface RecordingsLocationConfig {
recordingsDir: string | null;
}

export class RecordingsLocationStore {
private readonly configPath: string;
private config: RecordingsLocationConfig = { recordingsDir: null };

constructor(userDataPath: string) {
this.configPath = path.join(userDataPath, "recordings-location.json");
this.loadSync();
}

private loadSync(): void {
try {
const raw = readFileSync(this.configPath, "utf8");
const parsed = JSON.parse(raw);
this.config = {
recordingsDir: typeof parsed.recordingsDir === "string" ? parsed.recordingsDir : null,
};
} catch {
this.config = { recordingsDir: null };
}
}

/** The user's custom folder, or null to use the default (userData/recordings). */
getCustomDir(): string | null {
return this.config.recordingsDir;
}

async setCustomDir(dir: string | null): Promise<void> {
this.config = { recordingsDir: dir };
await fs.writeFile(this.configPath, JSON.stringify(this.config, null, 2), "utf8");
}
}
107 changes: 105 additions & 2 deletions src/components/launch/HudDeviceSettings.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,8 +22,109 @@ export interface HudDeviceSettingsLabels {
cameraUnavailable: string;
preview: string;
previewUnavailable: string;
storage: string;
storageHint: string;
chooseFolder: string;
resetToDefault: string;
changingFolder: string;
changeFolderFailed: string;
}

/** Where recordings are cached and saved, with folder-picker and reset. */
const RecordingsLocationSetting = memo(function RecordingsLocationSetting({
labels,
}: {
labels: HudDeviceSettingsLabels;
}) {
const [info, setInfo] = useState<{ path: string; isDefault: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);

useEffect(() => {
let cancelled = false;
window.electronAPI
?.getRecordingsDir?.()
.then((result) => {
if (!cancelled) setInfo(result);
})
.catch(() => {
// Nothing to show if this fails; the picker still works on demand.
});
return () => {
cancelled = true;
};
}, []);

const handleChoose = async () => {
setBusy(true);
setError(false);
try {
const result = await window.electronAPI?.chooseRecordingsDir?.();
if (result?.success) {
setInfo({ path: result.path, isDefault: false });
} else if (result && !result.canceled) {
setError(true);
}
} catch {
setError(true);
} finally {
setBusy(false);
}
};

const handleReset = async () => {
setBusy(true);
setError(false);
try {
const result = await window.electronAPI?.resetRecordingsDir?.();
if (result?.success) {
setInfo({ path: result.path, isDefault: true });
} else {
setError(true);
}
} catch {
setError(true);
} finally {
setBusy(false);
}
};

return (
<>
<div className={styles.hudMenuSectionLabel}>{labels.storage}</div>
<div className={styles.hudModalHint}>{labels.storageHint}</div>
{info ? (
<div className={styles.hudStoragePath} title={info.path}>
{info.path}
</div>
) : null}
<div className={styles.hudStorageActionRow}>
<button
type="button"
className={styles.hudStorageActionButton}
onClick={handleChoose}
disabled={busy}
>
<FolderOpen size={12} />
<span className="truncate">{busy ? labels.changingFolder : labels.chooseFolder}</span>
</button>
{info && !info.isDefault ? (
<button
type="button"
className={styles.hudStorageActionButton}
onClick={handleReset}
disabled={busy}
>
<RotateCcw size={12} />
<span className="truncate">{labels.resetToDefault}</span>
</button>
) : null}
</div>
{error ? <div className={styles.hudModalHint}>{labels.changeFolderFailed}</div> : 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);
Expand Down Expand Up @@ -204,6 +305,8 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({
/>
</>
) : null}

<RecordingsLocationSetting labels={labels} />
</div>
);
});
45 changes: 45 additions & 0 deletions src/components/launch/LaunchWindow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,51 @@
color: rgba(255, 255, 255, 0.4);
}

/* Storage-location action buttons (choose folder / reset to default): same
visual language as .languageMenuItem but inline, side by side, not full-width. */
.hudStorageActionRow {
display: flex;
gap: 6px;
padding: 0 10px 8px;
}

.hudStorageActionButton {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0.425rem 0.5rem;
border-radius: 0.45rem;
font-size: 11px;
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.05);
border: 0;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}

.hudStorageActionButton:hover,
.hudStorageActionButton:focus-visible {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
outline: none;
}

.hudStorageActionButton:disabled {
opacity: 0.5;
cursor: default;
}

.hudStoragePath {
padding: 0 10px 4px;
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

/* Thin separators between HUD toolbar control groups. */
.hudDivider {
flex-shrink: 0;
Expand Down
6 changes: 6 additions & 0 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,12 @@ export function LaunchWindow() {
cameraUnavailable: t("webcam.unavailable"),
preview: t("deviceSettings.preview"),
previewUnavailable: t("deviceSettings.previewUnavailable"),
storage: t("deviceSettings.storage"),
storageHint: t("deviceSettings.storageHint"),
chooseFolder: t("deviceSettings.chooseFolder"),
resetToDefault: t("deviceSettings.resetToDefault"),
changingFolder: t("deviceSettings.changingFolder"),
changeFolderFailed: t("deviceSettings.changeFolderFailed"),
}),
[t],
);
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en/dialogs.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"mp4Video": "MP4 Video",
"videoFiles": "Video Files",
"openscreenProject": "OpenScreen Project",
"allFiles": "All Files"
"allFiles": "All Files",
"selectRecordingsFolder": "Select Recordings Folder"
}
}
Loading
Loading