fix(view-once): hold-to-view pattern + content-protection during reveal

This commit is contained in:
byGalax
2026-05-17 17:15:14 +02:00
parent a7ffcbff83
commit 4ed3f04300
6 changed files with 135 additions and 15 deletions
+11
View File
@@ -107,6 +107,11 @@ export const CHANNELS = {
// OS fullscreen so the Windows taskbar / macOS menubar gets covered. // OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen', 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 // Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The // exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via // renderer clears its sensitive caches (memoryWipe.ts) and acks via
@@ -271,6 +276,12 @@ export interface UpdateProgress {
total: number; total: number;
} }
// ---- Window content protection -------------------------------------------
export interface WindowSetContentProtectionArgs {
enabled: boolean;
}
// ---- Runtime marker ------------------------------------------------------ // ---- Runtime marker ------------------------------------------------------
/** Value exposed on `window.electronAPI.platform`. Used by the renderer /** Value exposed on `window.electronAPI.platform`. Used by the renderer
+2
View File
@@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
import { register as registerSql } from './modules/sql'; import { register as registerSql } from './modules/sql';
import { register as registerTray } from './modules/tray'; import { register as registerTray } from './modules/tray';
import { register as registerUpdater } from './modules/updater'; 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 { register as registerWindowFullscreen } from './modules/window-fullscreen';
import { attach as attachWindowState, loadState } from './window-state'; import { attach as attachWindowState, loadState } from './window-state';
@@ -274,6 +275,7 @@ if (!gotLock) {
registerTray(mainWindow); registerTray(mainWindow);
registerUpdater(mainWindow); registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow); registerWindowFullscreen(mainWindow);
registerWindowContentProtection(mainWindow);
registerAudioLoopback(mainWindow); registerAudioLoopback(mainWindow);
}); });
@@ -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);
}
},
);
}
+6
View File
@@ -96,6 +96,12 @@ export interface ElectronAPI {
setFullscreen: (enabled: boolean) => Promise<void>; setFullscreen: (enabled: boolean) => Promise<void>;
/** 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<void>;
/** Subscribe to the main-process pre-quit notification. Used by the /** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */ * "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void; onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
+4
View File
@@ -160,6 +160,10 @@ const api = {
setFullscreen: (enabled: boolean): Promise<void> => setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled), ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
// Window content protection ----------------------------------------------
setContentProtection: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
// OS hostname ------------------------------------------------------------ // OS hostname ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME), getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
+76 -15
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { markAttachmentViewed } from '@chat-app/shared/chat'; import { markAttachmentViewed } from '@chat-app/shared/chat';
@@ -16,16 +16,34 @@ interface Props {
} }
// Three states: // Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens // 1. viewedAt is null AND user is recipient → blurred lock card. Press-and-
// fullscreen lightbox AND fires the mark-viewed RPC. // hold reveals the image fullscreen; release closes it AND fires the
// mark-viewed RPC.
// 2. viewedAt is set → tombstone "Angesehen am …". // 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) { export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt); const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
const [fullscreen, setFullscreen] = useState(false); const [revealing, setRevealing] = useState(false);
const burnedRef = useRef(false);
const holdingRef = useRef(false);
const burned = revealedAt !== null; 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) { if (burned && !isSender) {
return ( return (
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted"> <div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props)
); );
} }
// Recipient, not yet viewed. const startReveal = async (): Promise<void> => {
const handleOpen = async (): Promise<void> => { 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<void> => {
if (!holdingRef.current && !revealing) return;
holdingRef.current = false;
if (revealing) setRevealing(false);
await teardownReveal();
};
const teardownReveal = async (): Promise<void> => {
try {
await window.electronAPI?.setContentProtection?.(false);
} catch (err) {
console.warn('setContentProtection disable failed', err);
}
try { try {
const res = await markAttachmentViewed(supabase, attachmentId); const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt); if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) { } catch (err) {
console.warn('mark-viewed failed', err); console.warn('mark-viewed failed', err);
burnedRef.current = false;
} }
setFullscreen(true);
}; };
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => void handleOpen()} onPointerDown={() => void startReveal()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40" onPointerUp={() => void endReveal()}
onPointerLeave={() => void endReveal()}
onPointerCancel={() => void endReveal()}
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40 select-none"
> >
<LockIcon className="h-6 w-6 text-accent" /> <LockIcon className="h-6 w-6 text-accent" />
<span className="text-xs font-medium">Einmal ansehen antippen</span> <span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
</button> </button>
{fullscreen && ( {revealing && (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6" aria-label="Einmal-ansehen Bild"
onClick={() => setFullscreen(false)} className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
> >
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" /> <img
src={src}
alt=""
className="max-h-full max-w-full select-none rounded-lg"
draggable={false}
/>
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
Loslassen zum Schließen Aufnahme blockiert
</span>
</div> </div>
)} )}
</> </>