bump
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-20 17:09:04 -05:00
parent 82f8a12e27
commit b83360db35
48 changed files with 6079 additions and 49 deletions

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage } = require('electron');
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage, Tray, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
@@ -32,9 +32,21 @@ const DEFAULT_SETTINGS = {
windowHeight: 800,
isMaximized: false,
theme: 'theme-dark',
// Power features. All default-off so users aren't surprised by
// invisible state on first upgrade; the Launch tab exposes toggles
// for each.
launchAtStartup: false,
startMinimized: false,
minimizeToTrayOnClose: false,
};
let mainWindow = null;
let tray = null;
// Flipped to true by the tray's Quit action (and any other explicit
// quit path) so the `close` handler knows to actually exit instead of
// hiding the window. Without this, tray users who pick Quit would
// just hide the window again.
let isQuitting = false;
// Screen-share source picked by the renderer right before LiveKit's
// setScreenShareEnabled(true) call triggers getDisplayMedia. The
@@ -235,8 +247,22 @@ function createWindow() {
try { app.setBadgeCount(0); } catch {}
});
// Intercept close when the user has opted into minimize-to-tray —
// the tray still shows the app and a Show / Quit menu keeps the
// window accessible. Triggered before the normal close-cleanup
// below so the window stays alive.
mainWindow.on('close', (event) => {
const current = loadSettings();
if (current.minimizeToTrayOnClose && !isQuitting && tray) {
event.preventDefault();
mainWindow.hide();
return;
}
});
// Save window state on close
mainWindow.on('close', () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
// Flush localStorage/sessionStorage to disk before renderer is destroyed
mainWindow.webContents.session.flushStorageData();
@@ -270,6 +296,81 @@ function createWindow() {
}
}
function showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
function toggleMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isVisible() && mainWindow.isFocused()) {
mainWindow.hide();
} else {
showMainWindow();
}
}
// Tray icon + menu. The menu mirrors what users expect from a chat
// app that runs in the background: quick window toggle, a mute /
// deafen pair that just routes through to the renderer via IPC (so
// the existing keybind handlers do the work), and an explicit Quit
// that sets `isQuitting` so the `close` interceptor doesn't fight us.
function createTray() {
if (tray) return;
try {
const iconPath = path.join(__dirname, 'icon.png');
const img = nativeImage.createFromPath(iconPath);
tray = new Tray(img.isEmpty() ? nativeImage.createEmpty() : img);
tray.setToolTip('Brycord');
const menu = Menu.buildFromTemplate([
{
label: 'Show Brycord',
click: () => showMainWindow(),
},
{ type: 'separator' },
{
label: 'Toggle Mute',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('tray:action', 'toggle-mute');
}
},
},
{
label: 'Toggle Deafen',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('tray:action', 'toggle-deafen');
}
},
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(menu);
tray.on('click', () => toggleMainWindow());
tray.on('double-click', () => showMainWindow());
} catch (err) {
console.error('Failed to create tray:', err);
tray = null;
}
}
function destroyTray() {
if (tray) {
try { tray.destroy(); } catch {}
tray = null;
}
}
function createSplashWindow() {
const splash = new BrowserWindow({
width: 300,
@@ -312,6 +413,82 @@ app.whenReady().then(async () => {
ipcMain.handle('update:get-status', () => getUpdateStatus());
ipcMain.handle('update:download-and-install', () => downloadAndInstallUpdate());
// ── Lifecycle / power features ──────────────────────────────
// Auto-start is implemented via Electron's cross-platform
// `setLoginItemSettings`. Works on Windows + macOS natively; on
// Linux it expects a .desktop file, which electron-builder ships
// with the packaged app. Unpackaged dev builds just flip the flag
// in-memory and don't actually register with the OS.
const applyLaunchAtStartup = (enabled, startMinimized) => {
try {
if (app.isPackaged) {
app.setLoginItemSettings({
openAtLogin: !!enabled,
openAsHidden: !!startMinimized,
args: startMinimized ? ['--start-minimized'] : [],
});
}
} catch (err) {
console.warn('setLoginItemSettings failed:', err);
}
};
// Apply whatever's in settings.json on every launch so a change
// persists across upgrades even if the OS forgot the entry.
const bootSettings = loadSettings();
applyLaunchAtStartup(bootSettings.launchAtStartup, bootSettings.startMinimized);
if (bootSettings.launchAtStartup && bootSettings.startMinimized &&
(process.argv.includes('--start-minimized') || process.argv.includes('--hidden'))) {
// Honour the hidden-launch arg: if the tray option is on,
// keep the window hidden until the user clicks the tray. If
// the tray option is off, still hide briefly so the first
// paint doesn't flash.
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();
}
ipcMain.handle('lifecycle:get', () => {
const current = loadSettings();
return {
launchAtStartup: !!current.launchAtStartup,
startMinimized: !!current.startMinimized,
minimizeToTrayOnClose: !!current.minimizeToTrayOnClose,
};
});
ipcMain.handle('lifecycle:set', (_event, patch) => {
const current = loadSettings();
const next = { ...current };
if (typeof patch?.launchAtStartup === 'boolean') next.launchAtStartup = patch.launchAtStartup;
if (typeof patch?.startMinimized === 'boolean') next.startMinimized = patch.startMinimized;
if (typeof patch?.minimizeToTrayOnClose === 'boolean') next.minimizeToTrayOnClose = patch.minimizeToTrayOnClose;
saveSettings(next);
applyLaunchAtStartup(next.launchAtStartup, next.startMinimized);
// Tray is only required when minimize-to-tray is on — destroy
// it when turned off to free the icon slot, recreate on next
// enable. It's cheap either way.
if (next.minimizeToTrayOnClose) createTray();
else destroyTray();
return {
launchAtStartup: !!next.launchAtStartup,
startMinimized: !!next.startMinimized,
minimizeToTrayOnClose: !!next.minimizeToTrayOnClose,
};
});
// Expose a way for the renderer to show the window programmatically
// (e.g. after a desktop notification click) — complements the tray.
ipcMain.on('window:show', () => showMainWindow());
// Create the tray up front if the user has minimize-to-tray enabled
// so their first close works as expected after a cold launch.
if (bootSettings.minimizeToTrayOnClose) {
createTray();
}
app.on('before-quit', () => {
isQuitting = true;
});
ipcMain.on('window-minimize', () => {
const win = BrowserWindow.getFocusedWindow();
if (win) win.minimize();
@@ -1122,6 +1299,84 @@ app.whenReady().then(async () => {
}
});
// --- Discord backup importer ---
// The renderer picks the backup SQLite file, then we hand back
// its bytes + the parent directory that contains the
// `attachments/<channel>/<message>/<att>/` tree. sql.js parses
// the db in-renderer so we don't need a native sqlite binding
// (Electron 33 ships Node 20, which predates node:sqlite).
ipcMain.handle('importer:pick-database', async () => {
try {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win ?? undefined, {
title: 'Choose Discord backup database',
properties: ['openFile'],
filters: [
{ name: 'SQLite database', extensions: ['db', 'sqlite', 'sqlite3'] },
{ name: 'All files', extensions: ['*'] },
],
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, path: null };
}
return { ok: true, path: result.filePaths[0] };
} catch (err) {
return { ok: false, error: err?.message ?? 'pick failed' };
}
});
ipcMain.handle('importer:read-database', async (_event, dbPath) => {
if (typeof dbPath !== 'string' || !dbPath) {
return { ok: false, error: 'missing path' };
}
try {
const buf = await fs.promises.readFile(dbPath);
// The backup bot writes attachments next to the db in a
// sibling `attachments/` folder — surface the db's dir so
// the renderer can resolve relative `local_path` values
// from the `attachments` table.
const dataDir = path.dirname(dbPath);
return {
ok: true,
bytes: buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
),
dataDir,
};
} catch (err) {
return { ok: false, error: err?.message ?? 'read failed' };
}
});
ipcMain.handle('importer:read-attachment', async (_event, payload) => {
const { dataDir, localPath } = payload || {};
if (typeof dataDir !== 'string' || typeof localPath !== 'string') {
return { ok: false, error: 'missing dataDir/localPath' };
}
try {
// Defence-in-depth: resolve the absolute path and refuse
// anything that escapes `dataDir`. The renderer is trusted
// but a corrupt backup.db with `..`-laden `local_path`
// values could otherwise cough up arbitrary host files.
const abs = path.resolve(dataDir, localPath);
const rootWithSep = path.resolve(dataDir) + path.sep;
if (!abs.startsWith(rootWithSep) && abs !== path.resolve(dataDir)) {
return { ok: false, error: 'path escapes dataDir' };
}
const buf = await fs.promises.readFile(abs);
return {
ok: true,
bytes: buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
),
};
} catch (err) {
return { ok: false, error: err?.message ?? 'read failed' };
}
});
// --- Auto-idle detection ---
const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes
let wasIdle = false;

View File

@@ -51,6 +51,17 @@ contextBridge.exposeInMainWorld('updateAPI', {
},
});
contextBridge.exposeInMainWorld('lifecycleAPI', {
get: () => ipcRenderer.invoke('lifecycle:get'),
set: (patch) => ipcRenderer.invoke('lifecycle:set', patch),
show: () => ipcRenderer.send('window:show'),
onTrayAction: (callback) => {
const handler = (_event, action) => callback(action);
ipcRenderer.on('tray:action', handler);
return () => ipcRenderer.removeListener('tray:action', handler);
},
});
contextBridge.exposeInMainWorld('sessionPersistence', {
save: (data) => ipcRenderer.invoke('save-session', data),
load: () => ipcRenderer.invoke('load-session'),
@@ -72,6 +83,16 @@ contextBridge.exposeInMainWorld('idleAPI', {
// 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.
// Discord backup importer — exposes a native file picker for the
// backup SQLite file plus file-read helpers the renderer uses to
// load the db bytes (parsed in-renderer with sql.js) and to read
// individual attachment files off disk during import.
contextBridge.exposeInMainWorld('importerAPI', {
pickDatabase: () => ipcRenderer.invoke('importer:pick-database'),
readDatabase: (dbPath) => ipcRenderer.invoke('importer:read-database', dbPath),
readAttachment: (payload) => ipcRenderer.invoke('importer:read-attachment', payload),
});
contextBridge.exposeInMainWorld('recordingAPI', {
getDefaultFolder: () => ipcRenderer.invoke('recording-get-default-folder'),
pickFolder: () => ipcRenderer.invoke('recording-pick-folder'),

View File

@@ -81,8 +81,27 @@ const electronPlatform = {
downloadAndInstall: () => window.updateAPI.downloadAndInstall(),
onStatusChanged: (cb) => window.updateAPI.onStatusChanged(cb),
},
lifecycle: {
get: () => window.lifecycleAPI.get(),
set: (patch) => window.lifecycleAPI.set(patch),
show: () => window.lifecycleAPI.show(),
onTrayAction: (cb) => window.lifecycleAPI.onTrayAction(cb),
},
systemBars: null,
searchDB,
// The importer bridge is gated on `window.importerAPI` being
// present — it arrives via preload.cjs, which only reloads on a
// full Electron restart (not Ctrl+R / Vite HMR). If the user is
// running an older preload the whole surface falls back to `null`
// so the Import tab renders its "restart the desktop app"
// placeholder instead of throwing.
importer: window.importerAPI
? {
pickDatabase: () => window.importerAPI.pickDatabase(),
readDatabase: (dbPath) => window.importerAPI.readDatabase(dbPath),
readAttachment: (payload) => window.importerAPI.readAttachment(payload),
}
: null,
features: {
hasWindowControls: true,
hasScreenCapture: true,
@@ -91,6 +110,8 @@ const electronPlatform = {
hasSystemBars: false,
hasRecording: true,
hasNotifications: true,
hasLifecycle: true,
hasBackupImporter: !!window.importerAPI,
},
};