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:
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user