From 4ed3f04300502b3f12b014e2684aa62bc6949b89 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 17:15:14 +0200 Subject: [PATCH] fix(view-once): hold-to-view pattern + content-protection during reveal --- apps/desktop/electron/ipc-types.ts | 11 +++ apps/desktop/electron/main.ts | 2 + .../modules/window-content-protection.ts | 36 ++++++++ apps/desktop/electron/preload-types.d.ts | 6 ++ apps/desktop/electron/preload.ts | 4 + apps/desktop/src/components/ViewOnceImage.tsx | 91 ++++++++++++++++--- 6 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/electron/modules/window-content-protection.ts diff --git a/apps/desktop/electron/ipc-types.ts b/apps/desktop/electron/ipc-types.ts index 77e982a..4cb8f6d 100644 --- a/apps/desktop/electron/ipc-types.ts +++ b/apps/desktop/electron/ipc-types.ts @@ -107,6 +107,11 @@ export const CHANNELS = { // OS fullscreen so the Windows taskbar / macOS menubar gets covered. WINDOW_SET_FULLSCREEN: 'window:set-fullscreen', + // Content-protection toggle. Enables/disables OS-level screenshot/screen- + // recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS) + // while a view-once image is being revealed. No-op on Linux X11. + WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection', + // 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 @@ -271,6 +276,12 @@ export interface UpdateProgress { total: number; } +// ---- Window content protection ------------------------------------------- + +export interface WindowSetContentProtectionArgs { + enabled: boolean; +} + // ---- Runtime marker ------------------------------------------------------ /** Value exposed on `window.electronAPI.platform`. Used by the renderer diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 6585edf..c86d65b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts'; import { register as registerSql } from './modules/sql'; import { register as registerTray } from './modules/tray'; import { register as registerUpdater } from './modules/updater'; +import { register as registerWindowContentProtection } from './modules/window-content-protection'; import { register as registerWindowFullscreen } from './modules/window-fullscreen'; import { attach as attachWindowState, loadState } from './window-state'; @@ -274,6 +275,7 @@ if (!gotLock) { registerTray(mainWindow); registerUpdater(mainWindow); registerWindowFullscreen(mainWindow); + registerWindowContentProtection(mainWindow); registerAudioLoopback(mainWindow); }); diff --git a/apps/desktop/electron/modules/window-content-protection.ts b/apps/desktop/electron/modules/window-content-protection.ts new file mode 100644 index 0000000..a22b2ea --- /dev/null +++ b/apps/desktop/electron/modules/window-content-protection.ts @@ -0,0 +1,36 @@ +// Window content-protection adapter. Enables / disables OS-level +// screenshot and screen-recording blocking on the host BrowserWindow. +// +// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface +// appears black in any screen capture tool (OBS, Snipping Tool, +// Win+PrtScr, etc.) while protection is enabled. +// macOS: NSWindowSharingNone — equivalent coverage for QuickTime, +// Cmd+Shift+3/4, and external recorders. +// Linux: No-op. Electron exposes the API on all platforms but the +// X11/Wayland compositors don't honour it in Electron 33. +// +// Called by the renderer during view-once image reveals so the image +// cannot be captured by an OS-level screenshot while it is on screen. + +import { BrowserWindow, ipcMain } from 'electron'; + +import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types'; + +export function register(mainWindow: BrowserWindow): void { + ipcMain.handle( + CHANNELS.WINDOW_SET_CONTENT_PROTECTION, + (_evt, args: WindowSetContentProtectionArgs) => { + // Electron's setContentProtection covers Windows (WDA_MONITOR) and + // macOS (NSWindowSharingNone) in one call. No-op on Linux X11. + // Wrapped in try/catch because the window can already be destroyed + // by the time this fires during a teardown. + try { + const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow; + if (!win || win.isDestroyed()) return; + win.setContentProtection(args.enabled); + } catch (err) { + console.warn('setContentProtection failed', err); + } + }, + ); +} diff --git a/apps/desktop/electron/preload-types.d.ts b/apps/desktop/electron/preload-types.d.ts index 7d8e647..5a77e29 100644 --- a/apps/desktop/electron/preload-types.d.ts +++ b/apps/desktop/electron/preload-types.d.ts @@ -96,6 +96,12 @@ export interface ElectronAPI { setFullscreen: (enabled: boolean) => Promise; + /** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a + * view-once image is being revealed. Covers Windows (WDA_MONITOR) and + * macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always + * feature-check because the web build has no preload bridge. */ + setContentProtection?: (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; diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a3fb1c7..b7e46fc 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -160,6 +160,10 @@ const api = { setFullscreen: (enabled: boolean): Promise => ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled), + // Window content protection ---------------------------------------------- + setContentProtection: (enabled: boolean): Promise => + ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }), + // OS hostname ------------------------------------------------------------ getHostname: (): Promise => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME), diff --git a/apps/desktop/src/components/ViewOnceImage.tsx b/apps/desktop/src/components/ViewOnceImage.tsx index f344a07..f3febe4 100644 --- a/apps/desktop/src/components/ViewOnceImage.tsx +++ b/apps/desktop/src/components/ViewOnceImage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { markAttachmentViewed } from '@chat-app/shared/chat'; @@ -16,16 +16,34 @@ interface Props { } // Three states: -// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens -// fullscreen lightbox AND fires the mark-viewed RPC. +// 1. viewedAt is null AND user is recipient → blurred lock card. Press-and- +// hold reveals the image fullscreen; release closes it AND fires the +// mark-viewed RPC. // 2. viewedAt is set → tombstone "Angesehen am …". -// 3. user is sender → normal image, tombstone update appears once recipient burns it. +// 3. user is sender → normal image, tombstone update appears once recipient +// burns it. +// +// While revealed, the renderer window enables content-protection +// (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd +// snipping tools, screen recorders) sees a black/empty surface. Re-enabled +// on release / unmount. export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) { const [revealedAt, setRevealedAt] = useState(viewedAt); - const [fullscreen, setFullscreen] = useState(false); + const [revealing, setRevealing] = useState(false); + const burnedRef = useRef(false); + const holdingRef = useRef(false); const burned = revealedAt !== null; + // Tear down screen-capture protection if the component unmounts mid-reveal. + useEffect(() => { + return () => { + if (revealing || holdingRef.current) { + void window.electronAPI?.setContentProtection?.(false).catch(() => {}); + } + }; + }, [revealing]); + if (burned && !isSender) { return (
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) ); } - // Recipient, not yet viewed. - const handleOpen = async (): Promise => { + const startReveal = async (): Promise => { + if (burnedRef.current) return; + burnedRef.current = true; + holdingRef.current = true; + try { + await window.electronAPI?.setContentProtection?.(true); + } catch (err) { + console.warn('setContentProtection enable failed', err); + } + // The user may have released during the await. If so, skip showing the + // dialog and run the close-path directly so we don't leave the renderer + // in protected mode with no visible UI. + if (!holdingRef.current) { + // User released during the IPC await — endReveal already fired and is + // responsible for teardown (setContentProtection(false) + mark-viewed). + // Skipping teardown here avoids a duplicate markAttachmentViewed RPC. + return; + } + setRevealing(true); + }; + + const endReveal = async (): Promise => { + if (!holdingRef.current && !revealing) return; + holdingRef.current = false; + if (revealing) setRevealing(false); + await teardownReveal(); + }; + + const teardownReveal = async (): Promise => { + try { + await window.electronAPI?.setContentProtection?.(false); + } catch (err) { + console.warn('setContentProtection disable failed', err); + } try { const res = await markAttachmentViewed(supabase, attachmentId); if (res.viewedAt) setRevealedAt(res.viewedAt); } catch (err) { console.warn('mark-viewed failed', err); + burnedRef.current = false; } - setFullscreen(true); }; return ( <> - {fullscreen && ( + {revealing && (
setFullscreen(false)} + aria-label="Einmal-ansehen Bild" + className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6" > - + + + Loslassen zum Schließen — Aufnahme blockiert +
)}