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.
|
// 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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user