This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron');
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -36,6 +36,83 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
let mainWindow = null;
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Voice recording — per-participant audio capture
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each active session owns:
|
||||
// - A root directory (user-configurable, defaults to
|
||||
// `userData/recordings`).
|
||||
// - A manifest file (session.json) atomically rewritten whenever
|
||||
// participants join/leave or the session ends.
|
||||
// - One append-only WebM file per participant.
|
||||
//
|
||||
// `recordingSessions` keyed by sessionId → { rootDir, dir, streams }
|
||||
// tracks per-session state. `streams` is a map keyed by
|
||||
// participantId → fs.WriteStream so append/close can find the
|
||||
// right handle. Crash safety: every 20 chunks we call stream.sync
|
||||
// via fsync for the open fd, so at most ~10 seconds of audio is
|
||||
// unflushed at any moment.
|
||||
|
||||
const DEFAULT_RECORDING_ROOT = path.join(app.getPath('userData'), 'recordings');
|
||||
const RECORDING_FSYNC_INTERVAL = 20; // chunks between fsyncs
|
||||
|
||||
const recordingSessions = new Map();
|
||||
|
||||
function sanitizeFilenameSegment(s) {
|
||||
return String(s || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
|
||||
}
|
||||
|
||||
async function atomicWriteJson(filePath, obj) {
|
||||
// Write to a sibling tempfile first, then rename — on a crash
|
||||
// we either see the previous valid JSON or the new valid JSON,
|
||||
// never a half-written file.
|
||||
const tmp = `${filePath}.tmp-${Date.now()}`;
|
||||
await fs.promises.writeFile(tmp, JSON.stringify(obj, null, 2), 'utf8');
|
||||
await fs.promises.rename(tmp, filePath);
|
||||
}
|
||||
|
||||
async function readManifest(sessionDir) {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(path.join(sessionDir, 'session.json'), 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(sessionDir, manifest) {
|
||||
await atomicWriteJson(path.join(sessionDir, 'session.json'), manifest);
|
||||
}
|
||||
|
||||
async function updateManifest(sessionDir, updater) {
|
||||
const current = await readManifest(sessionDir);
|
||||
if (!current) return;
|
||||
const next = updater(current) ?? current;
|
||||
await writeManifest(sessionDir, next);
|
||||
}
|
||||
|
||||
async function isWritableDir(dir) {
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
const probe = path.join(dir, `.brycord-write-test-${Date.now()}`);
|
||||
await fs.promises.writeFile(probe, 'ok');
|
||||
await fs.promises.unlink(probe);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeSessionStreamsSync(sessionId) {
|
||||
const session = recordingSessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const [, entry] of session.streams) {
|
||||
try { entry.stream.end(); } catch {}
|
||||
}
|
||||
session.streams.clear();
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||||
@@ -119,6 +196,13 @@ function createWindow() {
|
||||
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
||||
mainWindow.webContents.session.flushStorageData();
|
||||
|
||||
// Close any still-open voice recording streams so buffered
|
||||
// chunks are flushed. Manifests will still show endedAt:
|
||||
// null (the recovery modal will catch them on next launch).
|
||||
for (const [sessionId] of recordingSessions) {
|
||||
closeSessionStreamsSync(sessionId);
|
||||
}
|
||||
|
||||
const current = loadSettings(); // re-read to preserve theme changes
|
||||
if (!mainWindow.isMaximized()) {
|
||||
const bounds = mainWindow.getBounds();
|
||||
@@ -683,6 +767,258 @@ app.whenReady().then(async () => {
|
||||
// AFK voice channel: expose system idle time to renderer
|
||||
ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime());
|
||||
|
||||
// ── Voice recording IPC ─────────────────────────────────────
|
||||
// See the module-level comment block for the session / manifest
|
||||
// model. All handlers are per-session keyed by sessionId; the
|
||||
// root directory is chosen on `start-session` and remembered
|
||||
// for the lifetime of the session so later calls can resolve
|
||||
// file paths without re-reading the user's settings.
|
||||
|
||||
ipcMain.handle('recording-get-default-folder', () => DEFAULT_RECORDING_ROOT);
|
||||
|
||||
ipcMain.handle('recording-pick-folder', async () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const result = await dialog.showOpenDialog(win ?? undefined, {
|
||||
title: 'Choose recording folder',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: DEFAULT_RECORDING_ROOT,
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { ok: false, path: null };
|
||||
}
|
||||
return { ok: true, path: result.filePaths[0] };
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-validate-folder', async (_event, dir) => {
|
||||
try {
|
||||
const ok = await isWritableDir(dir);
|
||||
return { valid: ok, error: ok ? null : 'Folder is not writable.' };
|
||||
} catch (err) {
|
||||
return { valid: false, error: err?.message ?? 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-open-folder', async (_event, dir) => {
|
||||
const target = dir || DEFAULT_RECORDING_ROOT;
|
||||
try {
|
||||
await fs.promises.mkdir(target, { recursive: true });
|
||||
} catch {}
|
||||
const err = await shell.openPath(target);
|
||||
return { ok: !err, error: err || null };
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-open-session-folder', async (_event, { rootDir, sessionId }) => {
|
||||
const root = rootDir || DEFAULT_RECORDING_ROOT;
|
||||
const sessionDir = path.join(root, sanitizeFilenameSegment(sessionId));
|
||||
const err = await shell.openPath(sessionDir);
|
||||
return { ok: !err, error: err || null };
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-start-session', async (_event, payload) => {
|
||||
const { sessionId, rootDir, channelId, channelName, startedAt } = payload;
|
||||
if (!sessionId) return { ok: false, error: 'missing sessionId' };
|
||||
const safeSession = sanitizeFilenameSegment(sessionId);
|
||||
const root = rootDir || DEFAULT_RECORDING_ROOT;
|
||||
const writable = await isWritableDir(root);
|
||||
const resolvedRoot = writable ? root : DEFAULT_RECORDING_ROOT;
|
||||
const sessionDir = path.join(resolvedRoot, safeSession);
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true });
|
||||
const manifest = {
|
||||
sessionId,
|
||||
channelId: channelId ?? null,
|
||||
channelName: channelName ?? null,
|
||||
startedAt: startedAt ?? Date.now(),
|
||||
endedAt: null,
|
||||
participants: [],
|
||||
};
|
||||
await writeManifest(sessionDir, manifest);
|
||||
recordingSessions.set(sessionId, {
|
||||
rootDir: resolvedRoot,
|
||||
dir: sessionDir,
|
||||
streams: new Map(),
|
||||
});
|
||||
return { ok: true, rootDir: resolvedRoot, sessionDir };
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-open-track', async (_event, payload) => {
|
||||
const { sessionId, participantId, displayName, joinedOffsetMs } = payload;
|
||||
const session = recordingSessions.get(sessionId);
|
||||
if (!session) return { ok: false, error: 'session not started' };
|
||||
const safePart = sanitizeFilenameSegment(participantId);
|
||||
// Unique filename if the same participant rejoined mid-session.
|
||||
let baseName = safePart;
|
||||
let suffix = 1;
|
||||
let fileName = `${baseName}.webm`;
|
||||
while (session.streams.has(`${participantId}::${suffix}`) ||
|
||||
fs.existsSync(path.join(session.dir, fileName))) {
|
||||
suffix += 1;
|
||||
fileName = `${baseName}-${suffix}.webm`;
|
||||
}
|
||||
const filePath = path.join(session.dir, fileName);
|
||||
const stream = fs.createWriteStream(filePath, { flags: 'a' });
|
||||
const key = `${participantId}::${suffix}`;
|
||||
session.streams.set(key, {
|
||||
stream,
|
||||
filePath,
|
||||
fileName,
|
||||
participantId,
|
||||
chunkCount: 0,
|
||||
});
|
||||
await updateManifest(session.dir, (m) => {
|
||||
m.participants.push({
|
||||
id: participantId,
|
||||
displayName: displayName ?? null,
|
||||
file: fileName,
|
||||
joinedOffsetMs: joinedOffsetMs ?? 0,
|
||||
leftOffsetMs: null,
|
||||
});
|
||||
return m;
|
||||
});
|
||||
return { ok: true, trackKey: key, fileName };
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-append', async (_event, payload) => {
|
||||
const { sessionId, trackKey, chunk } = payload;
|
||||
const session = recordingSessions.get(sessionId);
|
||||
if (!session) return { ok: false, error: 'session not started' };
|
||||
const entry = session.streams.get(trackKey);
|
||||
if (!entry) return { ok: false, error: 'track not open' };
|
||||
return new Promise((resolve) => {
|
||||
entry.stream.write(Buffer.from(chunk), (err) => {
|
||||
if (err) {
|
||||
resolve({ ok: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
entry.chunkCount += 1;
|
||||
if (entry.chunkCount % RECORDING_FSYNC_INTERVAL === 0) {
|
||||
// Best-effort fsync on the underlying fd so that a
|
||||
// power-cut after this return gives us up-to-date
|
||||
// bytes on disk. The `fd` property is set once the
|
||||
// write stream's 'open' event fires; ignore the
|
||||
// call if it isn't ready yet.
|
||||
const fd = entry.stream.fd;
|
||||
if (typeof fd === 'number') {
|
||||
fs.fsync(fd, () => {});
|
||||
}
|
||||
}
|
||||
resolve({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-close-track', async (_event, payload) => {
|
||||
const { sessionId, trackKey, leftOffsetMs } = payload;
|
||||
const session = recordingSessions.get(sessionId);
|
||||
if (!session) return { ok: false, error: 'session not started' };
|
||||
const entry = session.streams.get(trackKey);
|
||||
if (!entry) return { ok: false, error: 'track not open' };
|
||||
await new Promise((resolve) => entry.stream.end(resolve));
|
||||
session.streams.delete(trackKey);
|
||||
await updateManifest(session.dir, (m) => {
|
||||
// Match the last participant row with this id that has
|
||||
// no leftOffsetMs yet (handles rejoin entries).
|
||||
for (let i = m.participants.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
m.participants[i].id === entry.participantId &&
|
||||
m.participants[i].file === entry.fileName &&
|
||||
m.participants[i].leftOffsetMs == null
|
||||
) {
|
||||
m.participants[i].leftOffsetMs = leftOffsetMs ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-finalize', async (_event, payload) => {
|
||||
const { sessionId, endedAt } = payload;
|
||||
const session = recordingSessions.get(sessionId);
|
||||
if (!session) return { ok: false, error: 'session not started' };
|
||||
// Close any stragglers.
|
||||
for (const [key, entry] of session.streams) {
|
||||
await new Promise((resolve) => entry.stream.end(resolve));
|
||||
session.streams.delete(key);
|
||||
}
|
||||
await updateManifest(session.dir, (m) => {
|
||||
m.endedAt = endedAt ?? Date.now();
|
||||
return m;
|
||||
});
|
||||
const result = { ok: true, dir: session.dir };
|
||||
recordingSessions.delete(sessionId);
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-list-recoverable', async (_event, payload) => {
|
||||
const { rootDir } = payload ?? {};
|
||||
const root = rootDir || DEFAULT_RECORDING_ROOT;
|
||||
try {
|
||||
const entries = await fs.promises.readdir(root, { withFileTypes: true });
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const sessionDir = path.join(root, entry.name);
|
||||
const manifest = await readManifest(sessionDir);
|
||||
if (!manifest) continue;
|
||||
if (manifest.endedAt != null) continue;
|
||||
// Only surface sessions that actually have at least
|
||||
// one track file — empty dirs are noise.
|
||||
const files = await fs.promises.readdir(sessionDir);
|
||||
const tracks = files.filter((f) => f.endsWith('.webm'));
|
||||
if (tracks.length === 0) continue;
|
||||
out.push({
|
||||
sessionId: manifest.sessionId,
|
||||
sessionDir,
|
||||
channelId: manifest.channelId,
|
||||
channelName: manifest.channelName,
|
||||
startedAt: manifest.startedAt,
|
||||
participantCount: manifest.participants?.length ?? tracks.length,
|
||||
trackCount: tracks.length,
|
||||
});
|
||||
}
|
||||
// Newest first.
|
||||
out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
|
||||
return { ok: true, sessions: out };
|
||||
} catch (err) {
|
||||
if (err?.code === 'ENOENT') return { ok: true, sessions: [] };
|
||||
return { ok: false, error: err?.message ?? 'unknown', sessions: [] };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('recording-recover-session', async (_event, payload) => {
|
||||
// Caller already decided what to do: 'keep' stamps endedAt
|
||||
// and leaves files in place; 'delete' removes the session
|
||||
// directory entirely.
|
||||
const { sessionDir, action } = payload;
|
||||
if (!sessionDir) return { ok: false, error: 'missing sessionDir' };
|
||||
if (action === 'delete') {
|
||||
try {
|
||||
await fs.promises.rm(sessionDir, { recursive: true, force: true });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'delete failed' };
|
||||
}
|
||||
}
|
||||
// Keep: stamp endedAt to the newest mtime among track files.
|
||||
try {
|
||||
const files = await fs.promises.readdir(sessionDir);
|
||||
let newest = 0;
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.webm')) continue;
|
||||
const st = await fs.promises.stat(path.join(sessionDir, f));
|
||||
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
||||
}
|
||||
await updateManifest(sessionDir, (m) => {
|
||||
m.endedAt = newest || Date.now();
|
||||
return m;
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'recover failed' };
|
||||
}
|
||||
});
|
||||
|
||||
// --- Auto-idle detection ---
|
||||
const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes
|
||||
let wasIdle = false;
|
||||
|
||||
Reference in New Issue
Block a user