825160ee46
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>
144 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
}
|