Files
ChatApp/apps/desktop/electron/modules/secure-store.ts
T
byGalax 825160ee46 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>
2026-05-06 23:35:01 +02:00

144 lines
4.7 KiB
TypeScript

// 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);
});
}