feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)

Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
+266
View File
@@ -0,0 +1,266 @@
// Single source of truth for the Electron IPC surface. Main process
// registers handlers keyed by the CHANNEL constants; preload exposes a
// typed `window.electronAPI` that mirrors the same shape. Renderer
// imports the type-only declarations via a d.ts published from preload.
//
// Rule: anything that the Rust side of Tauri used to do goes through
// here. If a call is not in this file it should not exist in the
// renderer.
export const CHANNELS = {
// Screen-share — replaces Rust screen_sources + browser getDisplayMedia
// coordination. Video still flows through Chromium's native pipeline
// (LiveKit JS SDK calls getDisplayMedia); we only feed it source ids.
SCREEN_GET_SOURCES: 'screen:get-sources',
SCREEN_GET_THUMBNAIL: 'screen:get-thumbnail',
/** Renderer-driven source selection: the picker modal calls this with
* the chosen sourceId BEFORE invoking getDisplayMedia. The main-process
* display-media handler then routes that source into the LiveKit track
* (instead of silently granting request.frame). Pass null to clear. */
SCREEN_SET_PENDING_SOURCE: 'screen:set-pending-source',
// System audio loopback — Implementation is renderer-driven under Electron.
// Main provides the screen source id (AUDIO_LOOPBACK_RESOLVE_SOURCE); the
// renderer then calls navigator.mediaDevices.getUserMedia with a
// chromeMediaSourceId constraint to obtain the loopback MediaStream
// directly. The legacy START/STOP channels are kept as named constants
// for now to avoid breaking type imports but have no handlers — see
// the renderer's lib/screenAudio.ts for the new flow.
AUDIO_LOOPBACK_RESOLVE_SOURCE: 'audio:loopback:resolve-source',
// Native WASAPI process-loopback path (Windows only). Implemented by
// the @chatapp/audio-loopback-native napi-rs addon; main owns the
// session and forwards PCM chunks to the renderer via the CHUNK event.
// Captures every render session on the box *except* our own PID tree,
// so peers in a video call don't hear themselves echoed back when the
// sharer ticks "include system audio". Falls back to the renderer-
// driven getUserMedia path on macOS/Linux or when the addon isn't
// available (dev builds without `pnpm build:native`, packaged builds
// missing the .node binary, etc.).
AUDIO_LOOPBACK_START: 'audio:loopback:start',
/** Window-share variant: capture audio of ONLY the picked window's
* process tree (INCLUDE_TARGET_PROCESS_TREE). Renderer derives the
* HWND from desktopCapturer's `window:<HWND>:0` source ids. */
AUDIO_LOOPBACK_START_FOR_WINDOW: 'audio:loopback:start-for-window',
AUDIO_LOOPBACK_STOP: 'audio:loopback:stop',
AUDIO_LOOPBACK_CHUNK: 'audio:loopback:chunk',
// Global hotkeys — replaces @tauri-apps/plugin-global-shortcut. Main
// owns the registration; renderer listens to `shortcut:fired` events
// for press and `shortcut:released` for PTT-style release.
SHORTCUT_REGISTER: 'shortcut:register',
SHORTCUT_UNREGISTER: 'shortcut:unregister',
SHORTCUT_IS_REGISTERED: 'shortcut:is-registered',
SHORTCUT_EVT_FIRED: 'shortcut:fired',
SHORTCUT_EVT_RELEASED: 'shortcut:released',
// OS notifications — replaces @tauri-apps/plugin-notification. Permission
// is always granted on desktop Electron; we keep the surface symmetric
// with the Tauri version so renderer code doesn't need to branch.
NOTIFY_SHOW: 'notify:show',
NOTIFY_PERMISSION: 'notify:permission',
// Tray badge — replaces tauri emit('tray-unread-update'). Main owns the
// Tray instance and overlays an unread count; renderer just pushes
// the number.
TRAY_UNREAD: 'tray:unread',
// Secure store — replaces plugin-stronghold + custom file vault. Uses
// Electron safeStorage (DPAPI/Keychain/libsecret) to seal per-user
// blobs on disk. `secure-store:open` establishes a per-user handle;
// subsequent calls use the stringified handle.
SECURE_STORE_OPEN: 'secure-store:open',
SECURE_STORE_GET: 'secure-store:get',
SECURE_STORE_SET: 'secure-store:set',
SECURE_STORE_REMOVE: 'secure-store:remove',
SECURE_STORE_CLOSE: 'secure-store:close',
// Filesystem (scoped to app local data dir) — replaces plugin-fs.
// All paths are relative to appLocalDataDir; main rejects traversal.
FS_READ: 'fs:read',
FS_WRITE: 'fs:write',
FS_EXISTS: 'fs:exists',
FS_MKDIR: 'fs:mkdir',
FS_RENAME: 'fs:rename',
FS_REMOVE: 'fs:remove',
FS_APP_LOCAL_DATA_DIR: 'fs:app-local-data-dir',
// SQLite — replaces plugin-sql. The renderer gets a handle per DB file.
SQL_LOAD: 'sql:load',
SQL_EXECUTE: 'sql:execute',
SQL_SELECT: 'sql:select',
SQL_CLOSE: 'sql:close',
// Auto-updater — replaces plugin-updater. electron-updater feed.
UPDATER_CHECK: 'updater:check',
UPDATER_DOWNLOAD_INSTALL: 'updater:download-install',
UPDATER_EVT_PROGRESS: 'updater:progress',
// Autostart — replaces @tauri-apps/plugin-autostart. Uses Electron's
// built-in app.setLoginItemSettings() to manage OS login items (Windows
// registry Run key, macOS LaunchAgent, Linux .desktop entry).
AUTOSTART_IS_ENABLED: 'autostart:is-enabled',
AUTOSTART_SET: 'autostart:set',
// Window fullscreen — replaces Tauri's `appWindow.setFullscreen(...)`.
// Used by the call cinema mode to flip the host BrowserWindow into real
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
} as const;
// ---- Screen sources ------------------------------------------------------
export interface ScreenSource {
/** Opaque source id. Feed unchanged into getDisplayMedia via the
* chromeMediaSourceId constraint when we want Chromium to capture it. */
id: string;
name: string;
kind: 'screen' | 'window';
/** Display index for monitors (0-based). null for windows. */
displayId: number | null;
/** Pre-fetched thumbnail (data URL). Cheap to get from desktopCapturer
* so we return it inline with the listing — avoids a second round-trip
* per source. */
thumbnailDataUrl: string | null;
/** App icon for windows (data URL); null when unavailable. */
iconDataUrl: string | null;
}
// ---- Audio loopback ------------------------------------------------------
export interface AudioFrame {
captureId: number;
sampleRate: number;
channels: number;
/** Base64 of interleaved f32 LE stereo. Matches the old Tauri
* payload shape so the existing AudioWorklet doesn't change. */
samplesBase64: string;
}
/** Payload shape of `AUDIO_LOOPBACK_CHUNK` events delivered from main
* to the renderer. `samples` is a Float32Array of interleaved f32
* stereo PCM at 48kHz — Electron's structured-clone marshals
* TypedArrays directly, so no base64 hop is needed (unlike the legacy
* Tauri channel). */
export interface AudioLoopbackChunk {
captureId: number;
samples: Float32Array;
}
/** Result of `AUDIO_LOOPBACK_START`. */
export interface AudioLoopbackStartResult {
captureId: number;
}
// ---- Shortcuts -----------------------------------------------------------
export type ShortcutKind = 'press' | 'ptt';
export interface ShortcutRegisterArgs {
/** Accelerator in Electron syntax (e.g. `CommandOrControl+Shift+M`,
* or a bare key code like `F13`). PTT uses a single raw key. */
accelerator: string;
/** Opaque id the renderer picked. Used in `shortcut:fired` events and
* for unregister. Scoping to a logical id (not the accelerator)
* means the same key can be re-bound without double-register. */
id: string;
kind: ShortcutKind;
}
export interface ShortcutEvent {
id: string;
/** Monotonic timestamp (ms since epoch) — useful for PTT to detect
* held-key repeats at the OS level. */
ts: number;
}
// ---- Notifications -------------------------------------------------------
export interface NotifyArgs {
title: string;
body: string;
/** Silent = no system sound. Renderer already owns its own ringtone
* layer, so most notifications are silent. */
silent?: boolean;
}
// ---- Secure store --------------------------------------------------------
export interface SecureStoreOpenArgs {
userId: string;
}
export interface SecureStoreHandle {
handle: string;
/** True if the platform's safeStorage is available (encrypted). False
* means the store falls back to plaintext-on-disk — the renderer
* should warn the user and avoid storing long-lived secrets. */
encrypted: boolean;
}
// ---- Filesystem ----------------------------------------------------------
export type FsPath = string;
export interface FsWriteArgs {
path: FsPath;
/** Base64 of raw bytes. JSON IPC can't carry binary cleanly. */
dataBase64: string;
}
export interface FsRenameArgs {
from: FsPath;
to: FsPath;
}
// ---- SQL -----------------------------------------------------------------
export interface SqlLoadArgs {
/** DB filename (resolved under appLocalDataDir). Tauri's plugin-sql
* calls these `sqlite:<name>`; we strip the prefix on the main side. */
name: string;
}
export interface SqlExecuteArgs {
handle: string;
query: string;
bindings?: unknown[];
}
export interface SqlExecuteResult {
rowsAffected: number;
lastInsertId: number | null;
}
export interface SqlSelectArgs {
handle: string;
query: string;
bindings?: unknown[];
}
export type SqlSelectResult = Record<string, unknown>[];
// ---- Updater -------------------------------------------------------------
export interface UpdateInfo {
version: string;
releaseNotes: string | null;
}
export interface UpdateCheckResult {
available: boolean;
info: UpdateInfo | null;
}
export interface UpdateProgress {
percent: number;
transferred: number;
total: number;
}
// ---- Runtime marker ------------------------------------------------------
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
* to keep the existing `isTauriRuntime()`-style branches but pointed at
* the new runtime. */
export const ELECTRON_RUNTIME_MARKER = 'electron-chatapp-v1' as const;
+232
View File
@@ -0,0 +1,232 @@
// Electron entry point. Creates the single BrowserWindow, wires all IPC
// module registrars, and keeps a single running instance (second launch
// focuses the existing window instead of opening a new one).
//
// Loads the renderer from the Vite dev server in dev (port 1420, matches
// the old Tauri devUrl so nothing in the renderer code needs to change)
// and from the built dist in packaged mode.
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAutostart } from './modules/autostart';
import { register as registerFsScoped } from './modules/fs-scoped';
import { register as registerNotifications } from './modules/notifications';
import { register as registerScreenAudio } from './modules/screen-audio';
import {
consumePendingShareSourceId,
register as registerScreenSources,
} from './modules/screen-sources';
import { register as registerSecureStore } from './modules/secure-store';
import { register as registerShortcuts } from './modules/shortcuts';
import { register as registerSql } from './modules/sql';
import { register as registerTray } from './modules/tray';
import { register as registerUpdater } from './modules/updater';
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
import { attach as attachWindowState, loadState } from './window-state';
// __dirname in an ESM main-process bundle resolves to the out/main dir
// after electron-vite builds. Use a safe helper that works in both CJS
// (electron-vite main target defaults to CJS) and ESM contexts.
const __filenameSafe =
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json';
let mainWindow: BrowserWindow | null = null;
function resolvePreloadPath(): string {
// electron-vite names the preload bundle after the entry filename, so
// electron/preload.ts → out/preload/preload.mjs. ESM preload works in
// modern Electron when sandbox: false (which we have — preload needs
// Node APIs for libsodium init etc.). The previous `index.cjs` lookup
// was a stale leftover that silently never loaded — every IPC call
// (secret store, autostart, native notifications, screen sources,
// pending-share-source) returned undefined and any .x access threw.
return path.join(__dirnameSafe, '..', 'preload', 'preload.mjs');
}
function resolveRendererIndex(): string {
return path.join(__dirnameSafe, '..', 'renderer', 'index.html');
}
function resolveIconPath(): string {
const packaged = path.join(process.resourcesPath || '', 'icon.ico');
const dev = path.join(app.getAppPath(), 'resources', 'icon.ico');
return app.isPackaged ? packaged : dev;
}
async function createWindow(): Promise<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({
title: 'ChatApp',
width: state.width,
height: state.height,
...(state.x !== undefined ? { x: state.x } : {}),
...(state.y !== undefined ? { y: state.y } : {}),
minWidth: 800,
minHeight: 600,
resizable: true,
icon: resolveIconPath(),
show: false,
// Discord-style: no menu bar at all (no File/Edit/View/Window/Help).
// Belt: autoHideMenuBar hides it visually; suspenders: the global
// Menu.setApplicationMenu(null) below removes it entirely so Alt
// can't reveal it either.
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// sandbox: false — preload needs Node APIs (libsodium init etc.)
sandbox: false,
preload: resolvePreloadPath(),
// Strip DevTools from release builds. Disables openDevTools(),
// F12, Ctrl+Shift+I, and the right-click Inspect entry for end
// users. Packaged = release; unpackaged = `electron-vite dev`,
// where we still want the inspector for local debugging.
devTools: !app.isPackaged,
},
});
if (state.maximized) win.maximize();
win.once('ready-to-show', () => win.show());
attachWindowState(win, WINDOW_STATE_FILE);
if (!app.isPackaged) {
await win.loadURL(DEV_URL);
} else {
await win.loadFile(resolveRendererIndex());
}
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
});
return win;
}
function configureDisplayMediaHandler(): void {
// Our custom picker runs in the renderer; when LiveKit (or anything
// else) calls navigator.mediaDevices.getDisplayMedia with a specific
// chromeMediaSourceId constraint, Chromium defers to the handler we
// install here.
//
// Two paths:
// 1. Discord-style picker case — the renderer set a pending source id
// via SCREEN_SET_PENDING_SOURCE before calling getDisplayMedia. We
// resolve that id back to a desktopCapturer Source and pass it as
// the video grant. This is what makes the user's tile choice
// actually take effect.
// 2. Fallback — no pending id (e.g. a stray getDisplayMedia from
// another code path). Grant whatever the renderer's constraint
// selected by passing the frame back, matching the legacy v0.11.x
// behavior so we don't break anything off the picker code path.
//
// Audio: 'loopback' captures all system audio. We tried
// 'loopbackWithMute' (which excludes this app's own audio output from
// the capture) but it ALSO mutes the renderer's audio playback
// locally — so the user couldn't hear remote peers during a share
// and the stream had no audio either when the only thing playing was
// the muted call audio. There is no clean Electron equivalent of
// Tauri's EXCLUDE_TARGET_PROCESS_TREE that affects only the capture
// and not local playback. Trade-off chosen here: keep local audio
// working ('loopback'), let the user opt into the JS-level auto-duck
// (`screenShareSettings.duckRemoteAudioWhileSharing`) when they
// actually need the echo prevention. Default: off.
// https://www.electronjs.org/docs/latest/api/session#sessetdisplaymediarequesthandlerhandler-opts
session.defaultSession.setDisplayMediaRequestHandler(
(request, callback) => {
const pendingId = consumePendingShareSourceId();
if (pendingId) {
void desktopCapturer
.getSources({ types: ['screen', 'window'] })
.then((sources) => {
const source = sources.find((s) => s.id === pendingId);
if (source) {
callback({ video: source, audio: 'loopback' });
} else {
// Source vanished between picker confirm and grant (window
// closed, monitor unplugged). Fall back to the frame grant
// so getDisplayMedia doesn't hang the renderer.
callback({
video: request.frame as unknown as Electron.WebFrameMain,
audio: 'loopback',
});
}
})
.catch(() => {
callback({
video: request.frame as unknown as Electron.WebFrameMain,
audio: 'loopback',
});
});
return;
}
callback({
video: request.frame as unknown as Electron.WebFrameMain,
audio: 'loopback',
});
},
{ useSystemPicker: false },
);
}
// Single-instance lock — second launch focuses existing window instead
// of spawning a duplicate process.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
void app.whenReady().then(async () => {
// Strip the default application menu globally — we don't ship any
// File/Edit/View/Window/Help entries (Discord-style chrome). Must run
// BEFORE the first BrowserWindow is created; setting it later causes
// the menu to flash for a frame on Windows.
Menu.setApplicationMenu(null);
configureDisplayMediaHandler();
mainWindow = await createWindow();
// Stateless registrars first.
registerScreenSources();
registerScreenAudio();
registerNotifications();
registerSecureStore();
registerFsScoped();
registerSql();
registerAutostart();
// Window-dependent registrars — only call once mainWindow exists so
// event emitters have somewhere to send.
registerShortcuts(mainWindow);
registerTray(mainWindow);
registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow);
registerAudioLoopback(mainWindow);
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (!mainWindow) {
void createWindow().then((w) => {
mainWindow = w;
});
}
});
}
@@ -0,0 +1,213 @@
// Native WASAPI process-loopback bridge. Wraps the
// @chatapp/audio-loopback-native napi-rs addon and forwards PCM chunks
// to the renderer over IPC.
//
// Why the addon over Chromium's built-in 'loopback' source: the OS
// process-loopback API supports EXCLUDE_TARGET_PROCESS_TREE, which lets
// us capture every render session *except* our own PID tree. That keeps
// LiveKit's call playback out of the outgoing share so peers don't hear
// themselves echoed back. Chromium's 'loopback' has no such filter.
//
// Windows-only. The addon's start_capture call rejects on macOS/Linux
// with a clear error string and the renderer falls through to the
// existing Chromium getUserMedia path (lib/screenAudio.ts).
import { app, BrowserWindow, ipcMain } from 'electron';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CHANNELS,
type AudioLoopbackChunk,
type AudioLoopbackStartResult,
} from '../ipc-types';
interface NativeAudioLoopback {
startCapture: (callback: (samples: Float32Array) => void) => number;
/** INCLUDE_TARGET_PROCESS_TREE variant — capture only `pid`'s tree.
* Used for window-shares so we get just the picked app's audio. */
startCaptureForPid?: (
pid: number,
callback: (samples: Float32Array) => void,
) => number;
/** Look up the owning process id of a top-level window handle. */
resolveWindowPid?: (hwnd: number) => number;
stopCapture: (captureId: number) => void;
}
// __dirname in an ESM main bundle resolves to out/main after
// electron-vite builds. We need a CJS-style require to load the native
// addon — `import` would trigger ESM resolution which doesn't handle
// .node files cleanly across electron-vite's transform.
const __filenameSafe =
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
const __dirnameSafe = path.dirname(__filenameSafe);
const requireCjs = createRequire(__filenameSafe);
let cachedAddon: NativeAudioLoopback | null | undefined;
function resolveAddon(): NativeAudioLoopback | null {
if (cachedAddon !== undefined) return cachedAddon;
// Production: electron-builder copies the .node into
// resources/native/audio-loopback.node (see extraResources in
// package.json).
// Dev: napi build emits the binary alongside the addon's package.json
// at apps/desktop/native/audio-loopback/audio-loopback.<triple>.node,
// and writes an index.js shim that auto-selects the right triple.
// Loading the shim works in both layouts when the binary sits
// adjacent to it; for the packaged single-file layout we require the
// .node directly.
const candidates: string[] = [];
if (app.isPackaged && process.resourcesPath) {
candidates.push(path.join(process.resourcesPath, 'native', 'audio-loopback.node'));
}
// Dev workspace layout — main bundle lives at out/main/main.js,
// addon lives at native/audio-loopback/. Walk up two levels from the
// bundle dir (out/main → out → apps/desktop) and into native/.
candidates.push(
path.join(__dirnameSafe, '..', '..', 'native', 'audio-loopback', 'index.js'),
// Direct .node fallback in case the JS shim is missing (e.g. user
// ran `cargo build` manually instead of `napi build`).
path.join(
__dirnameSafe,
'..',
'..',
'native',
'audio-loopback',
`audio-loopback.${process.platform}-${process.arch}-msvc.node`,
),
);
for (const candidate of candidates) {
try {
const mod = requireCjs(candidate) as NativeAudioLoopback;
if (
typeof mod.startCapture === 'function' &&
typeof mod.stopCapture === 'function'
) {
cachedAddon = mod;
return mod;
}
} catch {
/* try next candidate */
}
}
cachedAddon = null;
return null;
}
/** Track active capture ids per BrowserWindow so we can tear them down
* if the renderer is destroyed mid-capture (renderer crash, window
* close during share). Without this the WASAPI thread would leak. */
const activeCaptures = new Map<number, Set<number>>();
function registerWindowCleanup(win: BrowserWindow, addon: NativeAudioLoopback): void {
const wcId = win.webContents.id;
if (activeCaptures.has(wcId)) return;
activeCaptures.set(wcId, new Set());
const cleanup = (): void => {
const ids = activeCaptures.get(wcId);
if (!ids) return;
for (const id of ids) {
try {
addon.stopCapture(id);
} catch {
/* best effort */
}
}
activeCaptures.delete(wcId);
};
win.webContents.once('destroyed', cleanup);
win.once('closed', cleanup);
}
export function register(mainWindow: BrowserWindow): void {
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_START,
async (event): Promise<AudioLoopbackStartResult> => {
const addon = resolveAddon();
if (!addon) {
throw new Error('audio-loopback native addon unavailable on this platform');
}
const wc = event.sender;
// captureId is assigned synchronously by addon.startCapture below,
// but the chunk callback needs to reference it — we forward-declare
// via a closure-shared holder. The first chunk can only fire after
// startCapture returns (the worker thread spawn happens inside it).
let assignedId = 0;
const cb = makeChunkCallback(wc, () => assignedId);
assignedId = addon.startCapture(cb);
registerWindowCleanup(mainWindow, addon);
const set = activeCaptures.get(mainWindow.webContents.id);
if (set) set.add(assignedId);
return { captureId: assignedId };
},
);
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW,
async (
event,
args: { hwnd: number },
): Promise<AudioLoopbackStartResult> => {
const addon = resolveAddon();
if (!addon) {
throw new Error('audio-loopback native addon unavailable on this platform');
}
if (!addon.startCaptureForPid || !addon.resolveWindowPid) {
throw new Error(
'audio-loopback native addon is too old: missing startCaptureForPid / resolveWindowPid (rebuild with `pnpm build:native`)',
);
}
if (!args || typeof args.hwnd !== 'number' || !Number.isFinite(args.hwnd)) {
throw new Error('AUDIO_LOOPBACK_START_FOR_WINDOW: hwnd must be a finite number');
}
const pid = addon.resolveWindowPid(args.hwnd);
const wc = event.sender;
let assignedId = 0;
const cb = makeChunkCallback(wc, () => assignedId);
assignedId = addon.startCaptureForPid(pid, cb);
registerWindowCleanup(mainWindow, addon);
const set = activeCaptures.get(mainWindow.webContents.id);
if (set) set.add(assignedId);
return { captureId: assignedId };
},
);
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_STOP,
async (_event, captureId: number): Promise<void> => {
const addon = resolveAddon();
if (!addon) return;
try {
addon.stopCapture(captureId);
} finally {
const set = activeCaptures.get(mainWindow.webContents.id);
if (set) set.delete(captureId);
}
},
);
}
/** Shared chunk-forwarding callback. Both start variants produce the
* same wire format (interleaved f32 stereo @ 48kHz) so the renderer
* doesn't need to know which start path was used — the captureId
* routes the chunks. */
function makeChunkCallback(
wc: Electron.WebContents,
getCaptureId: () => number,
): (samples: Float32Array) => void {
return (samples: Float32Array): void => {
// The addon delivers each chunk on its WASAPI capture thread —
// marshal to the renderer's webContents from the main loop. If the
// webContents has been destroyed (window closed during a share)
// silently drop; the cleanup hook will stop the capture.
if (wc.isDestroyed()) return;
const captureId = getCaptureId();
const payload: AudioLoopbackChunk = { captureId, samples };
wc.send(CHANNELS.AUDIO_LOOPBACK_CHUNK, payload);
};
}
@@ -0,0 +1,29 @@
// Autostart adapter — replaces Tauri's `tauri-plugin-autostart`. Uses
// Electron's built-in `app.setLoginItemSettings()` / `app.getLoginItemSettings()`,
// which manages the OS login-items mechanism on Windows (HKCU registry
// Run key), macOS (LaunchAgent), and Linux (.desktop entry).
import { app, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.AUTOSTART_IS_ENABLED, () => {
try {
const settings = app.getLoginItemSettings();
return settings.openAtLogin;
} catch (err: unknown) {
console.warn('autostart get failed', err);
return false;
}
});
ipcMain.handle(CHANNELS.AUTOSTART_SET, (_evt, enabled: boolean) => {
try {
app.setLoginItemSettings({ openAtLogin: !!enabled });
} catch (err: unknown) {
console.warn('autostart set failed', err);
throw err;
}
});
}
@@ -0,0 +1,81 @@
// Scoped filesystem. Every renderer-supplied path is resolved under
// `app.getPath('userData')`. Post-normalisation we re-check the resolved
// absolute path is still contained in the root; anything that breaks out
// (via .., symlink, absolute path) is rejected. Binary payloads are
// base64 on the wire because JSON IPC can't carry raw bytes cleanly.
import { app, ipcMain } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { CHANNELS, type FsPath, type FsRenameArgs, type FsWriteArgs } from '../ipc-types';
function rootDir(): string {
return app.getPath('userData');
}
function resolveScoped(rel: FsPath): string {
const root = rootDir();
if (path.isAbsolute(rel)) {
throw new Error('fs-scoped: absolute path rejected');
}
const normalised = path.normalize(rel);
if (normalised.split(/[\\/]/).includes('..')) {
throw new Error('fs-scoped: path traversal rejected');
}
const abs = path.resolve(root, normalised);
const withSep = root.endsWith(path.sep) ? root : root + path.sep;
if (abs !== root && !abs.startsWith(withSep)) {
throw new Error('fs-scoped: escaped scope');
}
return abs;
}
export function register(): void {
ipcMain.handle(CHANNELS.FS_APP_LOCAL_DATA_DIR, async (): Promise<string> => rootDir());
ipcMain.handle(CHANNELS.FS_READ, async (_evt, rel: FsPath): Promise<string | null> => {
const abs = resolveScoped(rel);
try {
const buf = await fs.readFile(abs);
return buf.toString('base64');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
});
ipcMain.handle(CHANNELS.FS_WRITE, async (_evt, args: FsWriteArgs): Promise<void> => {
const abs = resolveScoped(args.path);
await fs.mkdir(path.dirname(abs), { recursive: true });
const buf = Buffer.from(args.dataBase64, 'base64');
await fs.writeFile(abs, buf);
});
ipcMain.handle(CHANNELS.FS_EXISTS, async (_evt, rel: FsPath): Promise<boolean> => {
const abs = resolveScoped(rel);
try {
await fs.access(abs);
return true;
} catch {
return false;
}
});
ipcMain.handle(CHANNELS.FS_MKDIR, async (_evt, rel: FsPath): Promise<void> => {
const abs = resolveScoped(rel);
await fs.mkdir(abs, { recursive: true });
});
ipcMain.handle(CHANNELS.FS_RENAME, async (_evt, args: FsRenameArgs): Promise<void> => {
const from = resolveScoped(args.from);
const to = resolveScoped(args.to);
await fs.mkdir(path.dirname(to), { recursive: true });
await fs.rename(from, to);
});
ipcMain.handle(CHANNELS.FS_REMOVE, async (_evt, rel: FsPath): Promise<void> => {
const abs = resolveScoped(rel);
await fs.rm(abs, { recursive: true, force: true });
});
}
@@ -0,0 +1,26 @@
// OS notifications. Electron's Notification API doesn't have a separate
// permission prompt on desktop — permission is implicit and always
// granted — so `notify:permission` is a compatibility shim that keeps
// the renderer's existing plugin-notification call-sites working
// without branching.
import { ipcMain, Notification } from 'electron';
import { CHANNELS, type NotifyArgs } from '../ipc-types';
export function register(): void {
ipcMain.handle(CHANNELS.NOTIFY_SHOW, async (_evt, args: NotifyArgs): Promise<void> => {
if (!Notification.isSupported()) return;
const n = new Notification({
title: args.title,
body: args.body,
silent: args.silent ?? true,
});
n.show();
});
ipcMain.handle(
CHANNELS.NOTIFY_PERMISSION,
async (): Promise<'granted' | 'denied' | 'default'> => 'granted',
);
}
@@ -0,0 +1,38 @@
// System-audio loopback — renderer-driven. Chromium's
// `chromeMediaSource: 'desktop'` constraint on getUserMedia accepts a
// source id and returns a MediaStream that contains the OS mixer output.
// Main's only job is to resolve which source id the renderer should feed
// to getUserMedia (typically the primary screen). See the display-media
// handler in main.ts which grants the request automatically.
import { desktopCapturer, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export interface ResolveLoopbackSourceResult {
sourceId: string;
}
export function register(): void {
ipcMain.handle(
CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE,
async (): Promise<ResolveLoopbackSourceResult | null> => {
// Enumerate only screens; windows don't expose audio loopback on
// Windows and there's no meaningful "system audio" tied to a
// single window anyway. Primary screen is the first entry — the
// id is stable across calls as long as the display config doesn't
// change mid-session.
const sources = await desktopCapturer.getSources({
types: ['screen'],
fetchWindowIcons: false,
});
const first = sources[0];
if (!first) return null;
return { sourceId: first.id };
},
);
// The legacy start/stop channels are left unregistered on purpose —
// their constants still exist in ipc-types.ts for backwards compatible
// import paths, but there is no corresponding handler.
}
@@ -0,0 +1,96 @@
// Screen / window source enumeration + on-demand high-res thumbnail fetch.
// The picker in the renderer calls `SCREEN_GET_SOURCES` once to populate
// the grid (thumbnails come back inline as data URLs from desktopCapturer,
// so no second round-trip is needed for the initial paint). When the user
// hovers a tile we can optionally refresh the thumb at a higher resolution
// via `SCREEN_GET_THUMBNAIL` — same API but a single id and 640x360 size.
import { desktopCapturer, ipcMain } from 'electron';
import { CHANNELS, type ScreenSource } from '../ipc-types';
// Renderer-driven pending source: the picker modal sets this BEFORE calling
// getDisplayMedia so our display-media handler can route the chosen source
// to LiveKit. Stored module-level (single capture in flight at a time —
// the renderer enforces this since only one picker can be open). Cleared
// on consume or on explicit null-set (cancel/error path).
let pendingShareSourceId: string | null = null;
/** Read-and-clear: returns the pending id and resets it to null in one
* step so the main-process display-media handler can't accidentally apply
* the same id twice (e.g. if a stray getDisplayMedia call fires while
* the picker is closed). */
export function consumePendingShareSourceId(): string | null {
const id = pendingShareSourceId;
pendingShareSourceId = null;
return id;
}
/** Non-destructive read for callers that just want to know if a pending
* selection exists. */
export function getPendingShareSourceId(): string | null {
return pendingShareSourceId;
}
function parseDisplayId(raw: string): number | null {
// desktopCapturer ids for screens look like "screen:<display-id>:0". We
// index monitors 0-based in the UI so reduce the opaque id to a small
// integer per primary-display order. For windows there is no display
// association, return null.
if (!raw.startsWith('screen:')) return null;
const parts = raw.split(':');
const n = Number(parts[1]);
return Number.isFinite(n) ? n : null;
}
async function enumerate(thumbWidth: number, thumbHeight: number): Promise<ScreenSource[]> {
const sources = await desktopCapturer.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: thumbWidth, height: thumbHeight },
fetchWindowIcons: true,
});
return sources.map((src): ScreenSource => {
const kind: 'screen' | 'window' = src.id.startsWith('screen:') ? 'screen' : 'window';
const thumbnailDataUrl =
src.thumbnail && !src.thumbnail.isEmpty() ? src.thumbnail.toDataURL() : null;
const iconDataUrl =
src.appIcon && !src.appIcon.isEmpty() ? src.appIcon.toDataURL() : null;
return {
id: src.id,
name: src.name,
kind,
displayId: kind === 'screen' ? parseDisplayId(src.id) : null,
thumbnailDataUrl,
iconDataUrl,
};
});
}
export function register(): void {
ipcMain.handle(CHANNELS.SCREEN_GET_SOURCES, async (): Promise<ScreenSource[]> => {
return enumerate(320, 180);
});
ipcMain.handle(
CHANNELS.SCREEN_GET_THUMBNAIL,
async (_evt, sourceId: string): Promise<string | null> => {
// Re-enumerate — desktopCapturer has no "fetch one by id" API. Done
// at 640x360 so the detail view looks crisp without paying the full
// enumeration cost more than once per hover-debounce.
const list = await enumerate(640, 360);
const found = list.find((s) => s.id === sourceId);
return found?.thumbnailDataUrl ?? null;
},
);
ipcMain.handle(
CHANNELS.SCREEN_SET_PENDING_SOURCE,
(_evt, sourceId: string | null): void => {
// Renderer signals the chosen source id (or null to clear on cancel/
// error). Stored until the next getDisplayMedia request comes in via
// the display-media handler, which calls consumePendingShareSourceId
// to read-and-clear it.
pendingShareSourceId = sourceId;
},
);
}
@@ -0,0 +1,143 @@
// Per-user secure key/value store. Replaces Tauri's plugin-stronghold +
// custom file vault with Electron's safeStorage (DPAPI on Windows,
// Keychain on macOS, libsecret on Linux). Encryption is at the file
// level — the whole entries map is a single encrypted blob — so there's
// no per-set ciphertext rotation to track.
//
// Pre-encrypt JSON: {version:1, entries: { <key>: <utf8-string-value> }}.
// When safeStorage is unavailable we degrade to a `.plaintext` JSON file
// and flag `encrypted: false` back to the renderer so the renderer can
// warn the user and avoid long-lived secrets.
import { app, ipcMain, safeStorage } from 'electron';
import { createHash } from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import {
CHANNELS,
type SecureStoreHandle,
type SecureStoreOpenArgs,
} from '../ipc-types';
interface HandleState {
filePath: string;
encrypted: boolean;
entries: Map<string, string>;
saveTimer: NodeJS.Timeout | null;
}
const handles = new Map<string, HandleState>();
function hashUserId(userId: string): string {
return createHash('sha256').update(userId).digest('hex').slice(0, 16);
}
function filePathFor(userId: string, encrypted: boolean): string {
const suffix = hashUserId(userId);
const ext = encrypted ? 'bin' : 'plaintext';
return path.join(app.getPath('userData'), `chatapp-secure-${suffix}.${ext}`);
}
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
try {
if (encrypted) {
const buf = await fs.readFile(filePath);
const json = safeStorage.decryptString(buf);
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
} else {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
} catch {
// Missing file or malformed contents — start fresh. The next write
// will overwrite with a fresh blob.
return new Map();
}
}
async function writeStateNow(state: HandleState): Promise<void> {
const obj: Record<string, string> = {};
for (const [k, v] of state.entries) obj[k] = v;
const serialised = JSON.stringify({ version: 1, entries: obj });
await fs.mkdir(path.dirname(state.filePath), { recursive: true });
if (state.encrypted) {
const buf = safeStorage.encryptString(serialised);
await fs.writeFile(state.filePath, buf);
} else {
await fs.writeFile(state.filePath, serialised, 'utf8');
}
}
function scheduleSave(state: HandleState): void {
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(() => {
state.saveTimer = null;
void writeStateNow(state).catch((err: unknown) => {
console.warn('[secure-store] persist failed', err);
});
}, 100);
}
function requireState(handle: string): HandleState {
const s = handles.get(handle);
if (!s) throw new Error('secure-store: unknown handle');
return s;
}
export function register(): void {
ipcMain.handle(
CHANNELS.SECURE_STORE_OPEN,
async (_evt, args: SecureStoreOpenArgs): Promise<SecureStoreHandle> => {
const encrypted = safeStorage.isEncryptionAvailable();
const filePath = filePathFor(args.userId, encrypted);
const entries = await loadState(filePath, encrypted);
const handle = hashUserId(args.userId);
handles.set(handle, { filePath, encrypted, entries, saveTimer: null });
return { handle, encrypted };
},
);
ipcMain.handle(
CHANNELS.SECURE_STORE_GET,
async (_evt, handle: string, key: string): Promise<string | null> => {
const state = requireState(handle);
return state.entries.get(key) ?? null;
},
);
ipcMain.handle(
CHANNELS.SECURE_STORE_SET,
async (_evt, handle: string, key: string, value: string): Promise<void> => {
const state = requireState(handle);
state.entries.set(key, value);
scheduleSave(state);
},
);
ipcMain.handle(
CHANNELS.SECURE_STORE_REMOVE,
async (_evt, handle: string, key: string): Promise<void> => {
const state = requireState(handle);
state.entries.delete(key);
scheduleSave(state);
},
);
ipcMain.handle(CHANNELS.SECURE_STORE_CLOSE, async (_evt, handle: string): Promise<void> => {
const state = handles.get(handle);
if (!state) return;
if (state.saveTimer) {
clearTimeout(state.saveTimer);
state.saveTimer = null;
try {
await writeStateNow(state);
} catch (err: unknown) {
console.warn('[secure-store] close-flush failed', err);
}
}
handles.delete(handle);
});
}
@@ -0,0 +1,86 @@
// Global shortcuts — wraps Electron's globalShortcut, tracks registrations
// by an opaque renderer-supplied id so the same accelerator can be
// re-bound without the caller juggling state.
//
// PTT semantics are simulated: Electron's globalShortcut API only delivers
// a "pressed" callback — it has no keyup / release event. We fire
// SHORTCUT_EVT_FIRED on press, then after a 200ms timer fire
// SHORTCUT_EVT_RELEASED. Known limitation; TODO: revisit with
// `uiohook-napi` or `node-global-key-listener` if the press-and-release
// UX is too loose.
import { app, BrowserWindow, globalShortcut, ipcMain } from 'electron';
import {
CHANNELS,
type ShortcutEvent,
type ShortcutKind,
type ShortcutRegisterArgs,
} from '../ipc-types';
interface Entry {
accelerator: string;
kind: ShortcutKind;
}
const registry = new Map<string, Entry>();
export function register(mainWindow: BrowserWindow): void {
const send = (channel: string, payload: ShortcutEvent): void => {
if (mainWindow.isDestroyed()) return;
mainWindow.webContents.send(channel, payload);
};
ipcMain.handle(
CHANNELS.SHORTCUT_REGISTER,
async (_evt, args: ShortcutRegisterArgs): Promise<boolean> => {
const { id, accelerator, kind } = args;
const prev = registry.get(id);
if (prev) {
try {
globalShortcut.unregister(prev.accelerator);
} catch {
/* ignore */
}
registry.delete(id);
}
if (globalShortcut.isRegistered(accelerator)) {
return false;
}
const ok = globalShortcut.register(accelerator, () => {
const ts = Date.now();
send(CHANNELS.SHORTCUT_EVT_FIRED, { id, ts });
if (kind === 'ptt') {
setTimeout(() => {
send(CHANNELS.SHORTCUT_EVT_RELEASED, { id, ts: Date.now() });
}, 200);
}
});
if (!ok) return false;
registry.set(id, { accelerator, kind });
return true;
},
);
ipcMain.handle(CHANNELS.SHORTCUT_UNREGISTER, async (_evt, id: string): Promise<void> => {
const entry = registry.get(id);
if (!entry) return;
try {
globalShortcut.unregister(entry.accelerator);
} catch {
/* already gone */
}
registry.delete(id);
});
ipcMain.handle(CHANNELS.SHORTCUT_IS_REGISTERED, async (_evt, id: string): Promise<boolean> => {
const entry = registry.get(id);
if (!entry) return false;
return globalShortcut.isRegistered(entry.accelerator);
});
app.on('will-quit', () => {
globalShortcut.unregisterAll();
registry.clear();
});
}
+93
View File
@@ -0,0 +1,93 @@
// SQLite via better-sqlite3. One Database instance per renderer-tracked
// handle; handles are keyed by the normalised db name (Tauri's plugin-sql
// uses `sqlite:<name>` — we strip the prefix). Sync API is fine here
// because the main process has its own event loop; better-sqlite3's
// prepare/run/all are blocking but fast for typical chat-cache queries
// (<1ms per op for the current workload).
//
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
// bindings as an array. SQLite natively accepts $N so existing queries
// keep working unmodified.
import { app, ipcMain } from 'electron';
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import {
CHANNELS,
type SqlExecuteArgs,
type SqlExecuteResult,
type SqlLoadArgs,
type SqlSelectArgs,
type SqlSelectResult,
} from '../ipc-types';
interface Handle {
db: Database.Database;
filePath: string;
}
const handles = new Map<string, Handle>();
function stripPrefix(name: string): string {
return name.startsWith('sqlite:') ? name.slice('sqlite:'.length) : name;
}
function requireHandle(h: string): Handle {
const entry = handles.get(h);
if (!entry) throw new Error(`sql: unknown handle ${h}`);
return entry;
}
export function register(): void {
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
const rawName = stripPrefix(args.name);
const fileName = rawName.endsWith('.db') ? rawName : rawName + '.db';
const filePath = path.join(app.getPath('userData'), fileName);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const existing = handles.get(rawName);
if (existing) return rawName;
const db = new Database(filePath);
db.pragma('journal_mode = WAL');
handles.set(rawName, { db, filePath });
return rawName;
});
ipcMain.handle(
CHANNELS.SQL_EXECUTE,
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query);
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
return {
rowsAffected: info.changes,
lastInsertId:
typeof info.lastInsertRowid === 'bigint'
? Number(info.lastInsertRowid)
: (info.lastInsertRowid ?? null),
};
},
);
ipcMain.handle(
CHANNELS.SQL_SELECT,
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query);
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
return rows;
},
);
ipcMain.handle(CHANNELS.SQL_CLOSE, async (_evt, handle: string): Promise<void> => {
const entry = handles.get(handle);
if (!entry) return;
try {
entry.db.close();
} catch (err: unknown) {
console.warn('[sql] close failed', err);
}
handles.delete(handle);
});
}
+109
View File
@@ -0,0 +1,109 @@
// System tray + Windows taskbar overlay badge. Renderer pushes the
// current aggregate unread count via CHANNELS.TRAY_UNREAD; we update the
// tooltip and (on Windows) set an overlay icon on the main window's
// taskbar button.
//
// Overlay image is a pre-rendered 16x16 red dot embedded as base64 so
// the module is self-contained — no runtime canvas dependency.
import {
app,
BrowserWindow,
ipcMain,
Menu,
nativeImage,
type NativeImage,
Tray,
} from 'electron';
import path from 'node:path';
import { CHANNELS } from '../ipc-types';
let trayRef: Tray | null = null;
let overlayImage: NativeImage | null = null;
function resolveIconPath(): string {
return path.join(process.resourcesPath || app.getAppPath(), 'icon.ico');
}
function resolveIconPathDev(): string {
return path.join(app.getAppPath(), 'resources', 'icon.ico');
}
function loadTrayIcon(): NativeImage {
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
try {
const img = nativeImage.createFromPath(p);
if (!img.isEmpty()) return img;
} catch {
/* try next */
}
}
return nativeImage.createEmpty();
}
function buildOverlay(): NativeImage {
if (overlayImage) return overlayImage;
// 16x16 PNG, solid red circle. Inline base64 so no external file lookup.
const base64 =
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAPElEQVR42mNkYGD4z0AGYBxVSF2F//' +
'//Z2BgYGD4/58kBYz4FDAxMDAwMDIwMDD8//+foArGUYWjCoc1AABTgwUBf3lZtAAAAABJRU5ErkJggg==';
overlayImage = nativeImage.createFromBuffer(Buffer.from(base64, 'base64'));
return overlayImage;
}
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('ChatApp');
const menu = Menu.buildFromTemplate([
{
label: 'Open',
click: (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: 'Quit',
click: (): void => {
app.quit();
},
},
]);
trayRef.setContextMenu(menu);
trayRef.on('click', (): void => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (mainWindow.isVisible()) mainWindow.focus();
else mainWindow.show();
});
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
}
if (mainWindow.isDestroyed()) return;
if (process.platform === 'win32') {
if (n > 0) {
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
} else {
mainWindow.setOverlayIcon(null, '');
}
}
});
app.on('before-quit', () => {
try {
trayRef?.destroy();
} catch {
/* already gone */
}
trayRef = null;
});
}
+84
View File
@@ -0,0 +1,84 @@
// Auto-updater. Wraps electron-updater; configuration (feed URL) lives in
// package.json's `build.publish`. In dev (`app.isPackaged === false`) we
// short-circuit everything — the feed server is production-only and
// hitting it every launch from a dev machine just adds noise.
import { app, BrowserWindow, ipcMain } from 'electron';
import electronUpdater, {
type ProgressInfo,
type UpdateInfo as BuilderUpdateInfo,
} from 'electron-updater';
// electron-updater is a CJS module; named ESM imports don't work. Pull
// autoUpdater off the default export instead.
const { autoUpdater } = electronUpdater;
import {
CHANNELS,
type UpdateCheckResult,
type UpdateInfo,
type UpdateProgress,
} from '../ipc-types';
let cached: BuilderUpdateInfo | null = null;
function toPublicInfo(info: BuilderUpdateInfo | null): UpdateInfo | null {
if (!info) return null;
const notes = info.releaseNotes;
let releaseNotes: string | null = null;
if (typeof notes === 'string') releaseNotes = notes;
else if (Array.isArray(notes)) releaseNotes = notes.map((r) => r.note).join('\n\n');
return {
version: info.version ?? '',
releaseNotes,
};
}
export function register(mainWindow: BrowserWindow): void {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('download-progress', (p: ProgressInfo) => {
if (mainWindow.isDestroyed()) return;
const payload: UpdateProgress = {
percent: p.percent ?? 0,
transferred: p.transferred ?? 0,
total: p.total ?? 0,
};
mainWindow.webContents.send(CHANNELS.UPDATER_EVT_PROGRESS, payload);
});
ipcMain.handle(CHANNELS.UPDATER_CHECK, async (): Promise<UpdateCheckResult> => {
if (!app.isPackaged) {
return { available: false, info: null };
}
try {
const result = await autoUpdater.checkForUpdates();
if (!result || !result.updateInfo) {
cached = null;
return { available: false, info: null };
}
const current = app.getVersion();
const remote = result.updateInfo.version;
if (!remote || remote === current) {
cached = null;
return { available: false, info: null };
}
cached = result.updateInfo;
return { available: true, info: toPublicInfo(cached) };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (!/ENOTFOUND|ETIMEDOUT|ECONNRESET|404/i.test(msg)) {
console.warn('[updater] check failed', err);
}
return { available: false, info: null };
}
});
ipcMain.handle(CHANNELS.UPDATER_DOWNLOAD_INSTALL, async (): Promise<void> => {
if (!app.isPackaged) return;
if (!cached) throw new Error('no pending update — call check first');
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall();
});
}
@@ -0,0 +1,25 @@
// Window fullscreen adapter — replaces Tauri's
// `getCurrentWindow().setFullscreen(...)` from `@tauri-apps/api/window`.
// The renderer asks main to flip the OS-level fullscreen flag on the host
// BrowserWindow so cinema mode covers the Windows taskbar / macOS menubar
// the way Tauri's appWindow.setFullscreen used to.
import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups
// affect the right host; fall back to the main window we were
// registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
win.setFullScreen(!!enabled);
} catch (err: unknown) {
console.warn('window setFullscreen failed', err);
throw err;
}
});
}
+106
View File
@@ -0,0 +1,106 @@
// Renderer-visible typing for the preload bridge. Referenced via tsconfig
// `include` so TS sees `window.electronAPI` without needing to import
// from `electron` or drag Node types into the renderer build. The
// ElectronAPI type mirrors the `api` object exported from preload.ts —
// keep them in sync when adding new methods.
import type {
AudioLoopbackChunk,
AudioLoopbackStartResult,
FsPath,
FsRenameArgs,
FsWriteArgs,
NotifyArgs,
ScreenSource,
SecureStoreHandle,
SecureStoreOpenArgs,
ShortcutEvent,
ShortcutRegisterArgs,
SqlExecuteResult,
SqlSelectResult,
UpdateCheckResult,
UpdateProgress,
} from './ipc-types';
type Unsubscribe = () => void;
export interface ElectronAPI {
/** Opaque runtime marker (value: 'electron-chatapp-v1'). */
platform: string;
osPlatform: NodeJS.Platform;
appVersion: string;
getScreenSources: () => Promise<ScreenSource[]>;
getScreenThumbnail: (sourceId: string) => Promise<string | null>;
setPendingShareSource: (sourceId: string | null) => Promise<void>;
resolveLoopbackSource: () => Promise<{ sourceId: string } | null>;
/** Native WASAPI process-loopback bridge — Windows only. `start`
* rejects on macOS/Linux or when the .node binary isn't shipped, so
* callers should catch and fall back to the getUserMedia path. */
audioLoopback: {
start: () => Promise<AudioLoopbackStartResult>;
/** Window-share variant: capture only the picked window's process
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
startForWindow: (hwnd: number) => Promise<AudioLoopbackStartResult>;
stop: (captureId: number) => Promise<void>;
onChunk: (cb: (payload: AudioLoopbackChunk) => void) => Unsubscribe;
};
registerShortcut: (args: ShortcutRegisterArgs) => Promise<boolean>;
unregisterShortcut: (id: string) => Promise<void>;
isShortcutRegistered: (id: string) => Promise<boolean>;
onShortcutFired: (cb: (evt: ShortcutEvent) => void) => Unsubscribe;
onShortcutReleased: (cb: (evt: ShortcutEvent) => void) => Unsubscribe;
notify: (args: NotifyArgs) => Promise<void>;
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
setTrayUnread: (count: number) => Promise<void>;
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
secureStoreSet: (handle: string, key: string, value: string) => Promise<void>;
secureStoreRemove: (handle: string, key: string) => Promise<void>;
secureStoreClose: (handle: string) => Promise<void>;
fsRead: (p: FsPath) => Promise<string | null>;
fsWrite: (args: FsWriteArgs) => Promise<void>;
fsExists: (p: FsPath) => Promise<boolean>;
fsMkdir: (p: FsPath) => Promise<void>;
fsRename: (args: FsRenameArgs) => Promise<void>;
fsRemove: (p: FsPath) => Promise<void>;
fsAppLocalDataDir: () => Promise<string>;
sqlLoad: (args: { name: string }) => Promise<string>;
sqlExecute: (args: {
handle: string;
query: string;
bindings?: unknown[];
}) => Promise<SqlExecuteResult>;
sqlSelect: (args: {
handle: string;
query: string;
bindings?: unknown[];
}) => Promise<SqlSelectResult>;
sqlClose: (handle: string) => Promise<void>;
checkForUpdate: () => Promise<UpdateCheckResult>;
downloadInstallUpdate: () => Promise<void>;
onUpdaterProgress: (cb: (p: UpdateProgress) => void) => Unsubscribe;
isAutoStartEnabled: () => Promise<boolean>;
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};
+154
View File
@@ -0,0 +1,154 @@
// Preload bridge. Exposes a single `window.electronAPI` object to the
// renderer that mirrors the CHANNELS surface from ipc-types.ts. Each
// CHANNEL becomes a typed method that calls `ipcRenderer.invoke`; the
// three event channels (shortcut fired/released + updater progress) get
// `on*` subscribers that return an unsubscribe function.
//
// contextIsolation is ON so the renderer never sees `ipcRenderer` or
// Node directly — only this curated surface.
import { contextBridge, ipcRenderer } from 'electron';
import {
CHANNELS,
ELECTRON_RUNTIME_MARKER,
type AudioLoopbackChunk,
type AudioLoopbackStartResult,
type FsPath,
type FsRenameArgs,
type FsWriteArgs,
type NotifyArgs,
type ScreenSource,
type SecureStoreHandle,
type SecureStoreOpenArgs,
type ShortcutEvent,
type ShortcutRegisterArgs,
type SqlExecuteResult,
type SqlSelectResult,
type UpdateCheckResult,
type UpdateProgress,
} from './ipc-types';
type Unsubscribe = () => void;
function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
const handler = (_evt: Electron.IpcRendererEvent, payload: T): void => cb(payload);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
}
const api = {
platform: ELECTRON_RUNTIME_MARKER,
osPlatform: process.platform as NodeJS.Platform,
appVersion: process.env.npm_package_version ?? '0.0.0',
// Screen sources ---------------------------------------------------------
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
getScreenThumbnail: (sourceId: string): Promise<string | null> =>
ipcRenderer.invoke(CHANNELS.SCREEN_GET_THUMBNAIL, sourceId),
/** Tell main which source the user picked in our Discord-style modal,
* BEFORE calling getDisplayMedia. The display-media handler reads this
* and routes the matching desktopCapturer Source into LiveKit. Pass
* null on cancel/error to clear the pending state. */
setPendingShareSource: (sourceId: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SCREEN_SET_PENDING_SOURCE, sourceId),
// Audio loopback ---------------------------------------------------------
resolveLoopbackSource: (): Promise<{ sourceId: string } | null> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE),
/** Native WASAPI process-loopback (Windows only). Captures all system
* audio EXCEPT our own PID tree so peers don't hear themselves echoed
* back. Throws on platforms where the addon isn't available — caller
* is expected to fall back to the renderer-driven getUserMedia path
* (see lib/screenAudio.ts). */
audioLoopback: {
start: (): Promise<AudioLoopbackStartResult> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START),
/** Window-share variant: capture only the picked window's process
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
startForWindow: (hwnd: number): Promise<AudioLoopbackStartResult> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, { hwnd }),
stop: (captureId: number): Promise<void> =>
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_STOP, captureId),
onChunk: (cb: (payload: AudioLoopbackChunk) => void): Unsubscribe =>
on<AudioLoopbackChunk>(CHANNELS.AUDIO_LOOPBACK_CHUNK, cb),
},
// Shortcuts --------------------------------------------------------------
registerShortcut: (args: ShortcutRegisterArgs): Promise<boolean> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_REGISTER, args),
unregisterShortcut: (id: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_UNREGISTER, id),
isShortcutRegistered: (id: string): Promise<boolean> =>
ipcRenderer.invoke(CHANNELS.SHORTCUT_IS_REGISTERED, id),
onShortcutFired: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_FIRED, cb),
onShortcutReleased: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_RELEASED, cb),
// Notifications ----------------------------------------------------------
notify: (args: NotifyArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.NOTIFY_SHOW, args),
getNotificationPermission: (): Promise<'granted' | 'denied' | 'default'> =>
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray -------------------------------------------------------------------
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
// Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_OPEN, args),
secureStoreGet: (handle: string, key: string): Promise<string | null> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_GET, handle, key),
secureStoreSet: (handle: string, key: string, value: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_SET, handle, key, value),
secureStoreRemove: (handle: string, key: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_REMOVE, handle, key),
secureStoreClose: (handle: string): Promise<void> =>
ipcRenderer.invoke(CHANNELS.SECURE_STORE_CLOSE, handle),
// Filesystem -------------------------------------------------------------
fsRead: (p: FsPath): Promise<string | null> => ipcRenderer.invoke(CHANNELS.FS_READ, p),
fsWrite: (args: FsWriteArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_WRITE, args),
fsExists: (p: FsPath): Promise<boolean> => ipcRenderer.invoke(CHANNELS.FS_EXISTS, p),
fsMkdir: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_MKDIR, p),
fsRename: (args: FsRenameArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_RENAME, args),
fsRemove: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_REMOVE, p),
fsAppLocalDataDir: (): Promise<string> => ipcRenderer.invoke(CHANNELS.FS_APP_LOCAL_DATA_DIR),
// SQL --------------------------------------------------------------------
sqlLoad: (args: { name: string }): Promise<string> =>
ipcRenderer.invoke(CHANNELS.SQL_LOAD, args),
sqlExecute: (args: {
handle: string;
query: string;
bindings?: unknown[];
}): Promise<SqlExecuteResult> => ipcRenderer.invoke(CHANNELS.SQL_EXECUTE, args),
sqlSelect: (args: {
handle: string;
query: string;
bindings?: unknown[];
}): Promise<SqlSelectResult> => ipcRenderer.invoke(CHANNELS.SQL_SELECT, args),
sqlClose: (handle: string): Promise<void> => ipcRenderer.invoke(CHANNELS.SQL_CLOSE, handle),
// Updater ----------------------------------------------------------------
checkForUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(CHANNELS.UPDATER_CHECK),
downloadInstallUpdate: (): Promise<void> =>
ipcRenderer.invoke(CHANNELS.UPDATER_DOWNLOAD_INSTALL),
onUpdaterProgress: (cb: (p: UpdateProgress) => void): Unsubscribe =>
on<UpdateProgress>(CHANNELS.UPDATER_EVT_PROGRESS, cb),
// Autostart --------------------------------------------------------------
isAutoStartEnabled: (): Promise<boolean> => ipcRenderer.invoke(CHANNELS.AUTOSTART_IS_ENABLED),
setAutoStart: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.AUTOSTART_SET, enabled),
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
} as const;
export type ElectronAPI = typeof api;
contextBridge.exposeInMainWorld('electronAPI', api);
+91
View File
@@ -0,0 +1,91 @@
// Persists BrowserWindow bounds + maximized state to userData/<filename>.
// Saves are debounced 500ms on move/resize, and fired synchronously on close.
// Validates loaded bounds against the current display layout to avoid
// restoring a window onto a display that no longer exists.
import { app, BrowserWindow, screen } from 'electron';
import { promises as fs } from 'node:fs';
import path from 'node:path';
interface State {
x?: number;
y?: number;
width: number;
height: number;
maximized?: boolean;
}
const DEFAULT: State = { width: 1200, height: 800 };
function isOnAnyDisplay(bounds: { x: number; y: number; width: number; height: number }): boolean {
for (const d of screen.getAllDisplays()) {
const wa = d.workArea;
if (
bounds.x >= wa.x &&
bounds.y >= wa.y &&
bounds.x + bounds.width <= wa.x + wa.width + 8 &&
bounds.y + bounds.height <= wa.y + wa.height + 8
) {
return true;
}
}
return false;
}
export async function loadState(filename: string): Promise<State> {
const filePath = path.join(app.getPath('userData'), filename);
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Partial<State>;
const width = typeof parsed.width === 'number' ? parsed.width : DEFAULT.width;
const height = typeof parsed.height === 'number' ? parsed.height : DEFAULT.height;
const state: State = { width, height };
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
if (isOnAnyDisplay({ x: parsed.x, y: parsed.y, width, height })) {
state.x = parsed.x;
state.y = parsed.y;
}
}
if (parsed.maximized) state.maximized = true;
return state;
} catch {
return { ...DEFAULT };
}
}
export function attach(win: BrowserWindow, filename: string): void {
const filePath = path.join(app.getPath('userData'), filename);
let saveTimer: NodeJS.Timeout | null = null;
const writeNow = (): void => {
if (win.isDestroyed()) return;
const bounds = win.getNormalBounds();
const state: State = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
maximized: win.isMaximized(),
};
// Fire-and-forget; exceptions are logged but non-fatal.
void fs
.writeFile(filePath, JSON.stringify(state), 'utf8')
.catch((err: unknown) => {
console.warn('[window-state] write failed', err);
});
};
const schedule = (): void => {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(writeNow, 500);
};
win.on('move', schedule);
win.on('resize', schedule);
win.on('maximize', schedule);
win.on('unmaximize', schedule);
win.on('close', () => {
if (saveTimer) clearTimeout(saveTimer);
writeNow();
});
}