feat(desktop): optional wipe-on-close (Settings -> Sicherheit)

This commit is contained in:
byGalax
2026-05-16 17:17:59 +02:00
parent 88345420e0
commit 9de2c368bf
8 changed files with 119 additions and 1 deletions
+8
View File
@@ -106,6 +106,14 @@ export const CHANNELS = {
// 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',
// Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
// timeout, whichever comes first).
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
} as const;
// ---- Screen sources ------------------------------------------------------
+28 -1
View File
@@ -6,10 +6,11 @@
// 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 { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CHANNELS } from './ipc-types';
import { register as registerAudioLoopback } from './modules/audio-loopback';
import { register as registerAutostart } from './modules/autostart';
import { register as registerFsScoped } from './modules/fs-scoped';
@@ -263,6 +264,32 @@ if (!gotLock) {
registerAudioLoopback(mainWindow);
});
// Wipe-on-close: when the user enables it in Settings, the renderer is given
// a chance to clear all sensitive caches before the app process exits. If
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
// to lose the wipe than to hang the app shutdown.
//
// Note: there's a separate `before-quit` listener in modules/tray.ts that
// tears down the Tray instance. Electron fires both; the tray listener is
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
// when our own `app.quit()` below fires `before-quit` a second time.
let wipeRequested = false;
app.on('before-quit', (event) => {
if (wipeRequested) return;
if (!mainWindow || mainWindow.isDestroyed()) return;
wipeRequested = true;
event.preventDefault();
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
const done = new Promise<void>((resolve) => {
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
});
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
app.quit();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
+4
View File
@@ -95,6 +95,10 @@ export interface ElectronAPI {
setAutoStart: (enabled: boolean) => Promise<void>;
setFullscreen: (enabled: boolean) => Promise<void>;
/** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
}
declare global {
+18
View File
@@ -159,6 +159,24 @@ const api = {
// Window fullscreen ------------------------------------------------------
setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// Wipe-on-close ----------------------------------------------------------
// Subscribe to the main-process pre-quit notification. The renderer's
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
// unconditionally so main can finish quitting — better to lose the wipe
// than to hang the app shutdown if the callback throws.
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
try {
await cb();
} catch (err) {
console.warn('[wipe] renderer cb failed', err);
}
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
};
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
},
} as const;
export type ElectronAPI = typeof api;
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
type LegacyMigrationReport,
@@ -19,6 +20,7 @@ export function SecurityCenter({ userId }: Props) {
const [msg, setMsg] = useState<string | null>(null);
const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
@@ -126,6 +128,27 @@ export function SecurityCenter({ userId }: Props) {
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Cache beim Schließen leeren
</h3>
<p className="mb-2 text-xs text-fg-muted">
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={wipeOnClose}
onChange={(e) => {
setWipeOnClose(e.target.checked);
setWipeOnCloseState(e.target.checked);
}}
/>
<span>Aktivieren</span>
</label>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
+13
View File
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { ensureInstallId } from '../lib/installId';
import { wipeLocalState } from '../lib/memoryWipe';
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
@@ -195,6 +196,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(installId);
}, [session]);
// Wipe-on-close: register a handler the main process pings on `before-quit`
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
// Electron (web build has no preload bridge) or when the toggle is off.
useEffect(() => {
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
if (!isWipeOnCloseEnabled()) return;
await wipeLocalState(session?.user.id ?? null);
});
return unsub;
}, [session?.user.id]);
// Auto online/offline transition.
//
// - On mount with a session whose last persisted state is `offline`, flip
+1
View File
@@ -15,6 +15,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([
'chatapp.theme',
'chatapp.locale',
'chatapp.installId',
'chatapp.wipeOnClose.v1',
'i18nextLng',
]);
@@ -0,0 +1,24 @@
// Persisted toggle for the "wipe local caches on app close" feature.
// Read by SecurityCenter (UI) and AuthContext (renderer-side IPC handler).
//
// Stored in localStorage so the preference survives sessions without
// touching the secret-store or SQLite. The key is on the preserve
// whitelist in lib/memoryWipe.ts so a sign-out wipe (or a closing wipe)
// doesn't blow away the user's own preference.
const KEY = 'chatapp.wipeOnClose.v1';
export function isWipeOnCloseEnabled(): boolean {
try {
return window.localStorage.getItem(KEY) === '1';
} catch {
return false;
}
}
export function setWipeOnClose(enabled: boolean): void {
try {
window.localStorage.setItem(KEY, enabled ? '1' : '0');
} catch {
/* ignored */
}
}