This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user