From 9de2c368bf74fdd58b02d0beb122ac819a00df62 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 17:17:59 +0200 Subject: [PATCH] feat(desktop): optional wipe-on-close (Settings -> Sicherheit) --- apps/desktop/electron/ipc-types.ts | 8 +++++ apps/desktop/electron/main.ts | 29 ++++++++++++++++++- apps/desktop/electron/preload-types.d.ts | 4 +++ apps/desktop/electron/preload.ts | 18 ++++++++++++ .../desktop/src/components/SecurityCenter.tsx | 23 +++++++++++++++ apps/desktop/src/context/AuthContext.tsx | 13 +++++++++ apps/desktop/src/lib/memoryWipe.ts | 1 + apps/desktop/src/lib/memoryWipeSettings.ts | 24 +++++++++++++++ 8 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/memoryWipeSettings.ts diff --git a/apps/desktop/electron/ipc-types.ts b/apps/desktop/electron/ipc-types.ts index 0258b24..98527ad 100644 --- a/apps/desktop/electron/ipc-types.ts +++ b/apps/desktop/electron/ipc-types.ts @@ -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 ------------------------------------------------------ diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c4caeef..cc450ac 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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((resolve) => { + ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve()); + }); + void Promise.race([done, new Promise((r) => setTimeout(r, 2000))]).finally(() => { + app.quit(); + }); + }); + app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); diff --git a/apps/desktop/electron/preload-types.d.ts b/apps/desktop/electron/preload-types.d.ts index 6af34cd..ee02da7 100644 --- a/apps/desktop/electron/preload-types.d.ts +++ b/apps/desktop/electron/preload-types.d.ts @@ -95,6 +95,10 @@ export interface ElectronAPI { setAutoStart: (enabled: boolean) => Promise; setFullscreen: (enabled: boolean) => Promise; + + /** Subscribe to the main-process pre-quit notification. Used by the + * "Cache beim Schließen leeren" Settings toggle. */ + onWipeBeforeQuit: (cb: () => Promise) => () => void; } declare global { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 55da108..5855d02 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -159,6 +159,24 @@ const api = { // Window fullscreen ------------------------------------------------------ setFullscreen: (enabled: boolean): Promise => 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): Unsubscribe => { + const handler = async (_evt: Electron.IpcRendererEvent): Promise => { + 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; diff --git a/apps/desktop/src/components/SecurityCenter.tsx b/apps/desktop/src/components/SecurityCenter.tsx index 551a27a..1be195a 100644 --- a/apps/desktop/src/components/SecurityCenter.tsx +++ b/apps/desktop/src/components/SecurityCenter.tsx @@ -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(null); const [recovery, setRecovery] = useState(null); const [migration, setMigration] = useState(null); + const [wipeOnClose, setWipeOnCloseState] = useState(() => isWipeOnCloseEnabled()); async function handleRetryMigration() { setBusy(true); setMsg(null); setMigration(null); @@ -126,6 +128,27 @@ export function SecurityCenter({ userId }: Props) { )} +
+

+ Cache beim Schließen leeren +

+

+ 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. +

+ +
+

Identität zurücksetzen

Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.

diff --git a/apps/desktop/src/context/AuthContext.tsx b/apps/desktop/src/context/AuthContext.tsx index f7ca700..d554126 100644 --- a/apps/desktop/src/context/AuthContext.tsx +++ b/apps/desktop/src/context/AuthContext.tsx @@ -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 diff --git a/apps/desktop/src/lib/memoryWipe.ts b/apps/desktop/src/lib/memoryWipe.ts index 40ffd59..a469a2b 100644 --- a/apps/desktop/src/lib/memoryWipe.ts +++ b/apps/desktop/src/lib/memoryWipe.ts @@ -15,6 +15,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([ 'chatapp.theme', 'chatapp.locale', 'chatapp.installId', + 'chatapp.wipeOnClose.v1', 'i18nextLng', ]); diff --git a/apps/desktop/src/lib/memoryWipeSettings.ts b/apps/desktop/src/lib/memoryWipeSettings.ts new file mode 100644 index 0000000..d199478 --- /dev/null +++ b/apps/desktop/src/lib/memoryWipeSettings.ts @@ -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 */ + } +}