fix(view-once): hold-to-view pattern + content-protection during reveal
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -96,6 +96,12 @@ export interface ElectronAPI {
|
||||
|
||||
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
|
||||
* "Cache beim Schließen leeren" Settings toggle. */
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||
|
||||
@@ -160,6 +160,10 @@ const api = {
|
||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||
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 ------------------------------------------------------------
|
||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
// 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 (
|
||||
<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 handleOpen = async (): Promise<void> => {
|
||||
const startReveal = 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 {
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpen()}
|
||||
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"
|
||||
onPointerDown={() => void startReveal()}
|
||||
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" />
|
||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
||||
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
||||
</button>
|
||||
{fullscreen && (
|
||||
{revealing && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
||||
onClick={() => setFullscreen(false)}
|
||||
aria-label="Einmal-ansehen Bild"
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user