1.0.60
All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s

This commit is contained in:
Bryan1029384756
2026-04-14 20:03:54 -05:00
parent b7a4cf4ce8
commit 965048f7d2
47 changed files with 2558 additions and 135 deletions

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27
versionName "1.0.50"
versionName "1.0.60"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/android",
"private": true,
"version": "1.0.50",
"version": "1.0.60",
"type": "module",
"scripts": {
"cap:sync": "npx cap sync",

View File

@@ -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;

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/electron",
"private": true,
"version": "1.0.50",
"version": "1.0.60",
"description": "Brycord - Electron app",
"author": "Moyettes",
"type": "module",

View File

@@ -54,3 +54,21 @@ contextBridge.exposeInMainWorld('idleAPI', {
removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'),
getSystemIdleTime: () => ipcRenderer.invoke('get-system-idle-time'),
});
// Voice recording — per-participant audio capture that writes
// append-only WebM files to the user's chosen folder. See
// apps/electron/main.cjs for the main-process implementation.
contextBridge.exposeInMainWorld('recordingAPI', {
getDefaultFolder: () => ipcRenderer.invoke('recording-get-default-folder'),
pickFolder: () => ipcRenderer.invoke('recording-pick-folder'),
validateFolder: (dir) => ipcRenderer.invoke('recording-validate-folder', dir),
openFolder: (dir) => ipcRenderer.invoke('recording-open-folder', dir),
openSessionFolder: (payload) => ipcRenderer.invoke('recording-open-session-folder', payload),
startSession: (payload) => ipcRenderer.invoke('recording-start-session', payload),
openTrack: (payload) => ipcRenderer.invoke('recording-open-track', payload),
append: (payload) => ipcRenderer.invoke('recording-append', payload),
closeTrack: (payload) => ipcRenderer.invoke('recording-close-track', payload),
finalize: (payload) => ipcRenderer.invoke('recording-finalize', payload),
listRecoverable: (payload) => ipcRenderer.invoke('recording-list-recoverable', payload),
recoverSession: (payload) => ipcRenderer.invoke('recording-recover-session', payload),
});

View File

@@ -54,6 +54,20 @@ const electronPlatform = {
close: () => window.windowControls.close(),
flashFrame: () => window.windowControls.flashFrame(),
},
recording: {
getDefaultFolder: () => window.recordingAPI.getDefaultFolder(),
pickFolder: () => window.recordingAPI.pickFolder(),
validateFolder: (dir) => window.recordingAPI.validateFolder(dir),
openFolder: (dir) => window.recordingAPI.openFolder(dir),
openSessionFolder: (payload) => window.recordingAPI.openSessionFolder(payload),
startSession: (payload) => window.recordingAPI.startSession(payload),
openTrack: (payload) => window.recordingAPI.openTrack(payload),
append: (payload) => window.recordingAPI.append(payload),
closeTrack: (payload) => window.recordingAPI.closeTrack(payload),
finalize: (payload) => window.recordingAPI.finalize(payload),
listRecoverable: (payload) => window.recordingAPI.listRecoverable(payload),
recoverSession: (payload) => window.recordingAPI.recoverSession(payload),
},
updates: {
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
},
@@ -65,6 +79,7 @@ const electronPlatform = {
hasNativeUpdates: true,
hasSearch: true,
hasSystemBars: false,
hasRecording: true,
},
};

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/web",
"private": true,
"version": "1.0.50",
"version": "1.0.60",
"type": "module",
"scripts": {
"dev": "vite",