feat(web): Desktop-Client per Cookie erkennen — Download-Links und Browser-Kontextmenü in der App aus
- withDesktopCookie in middleware.ts setzt tessera_desktop=1 auf JEDER Antwort (Fruehausstieg, Redirects, next()), wenn ?desktop=1 anliegt - desktop-client.ts: isDesktopClient() liest das Cookie, useIsDesktopClient() kapselt es hydration-sicher per useEffect - DesktopDownloadLinks fragt /desktop/latest im Desktop-Client gar nicht erst an und rendert nichts - DesktopContextMenuGuard unterdrueckt das WebView2-Kontextmenue ausserhalb von Eingabefeldern/contenteditable, in layout.tsx eingebunden - middleware.test.ts (neu), desktop-client.test.ts (neu), desktop-context-menu-guard.test.tsx (neu), Test 4 in desktop-download-links.test.tsx — alle 417 Web-Tests und type-check gruen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { DesktopContextMenuGuard } from './desktop-context-menu-guard';
|
||||
import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client';
|
||||
|
||||
/**
|
||||
* desktop-context-menu-guard.test (260917-h2s) — unterdrueckt das
|
||||
* WebView2-Browser-Kontextmenue im Desktop-Client, laesst es in
|
||||
* Eingabefeldern/contenteditable-Bereichen zu. `fire` gibt den
|
||||
* `dispatchEvent`-Rueckgabewert zurueck: `false` bedeutet, `preventDefault()`
|
||||
* wurde aufgerufen.
|
||||
*/
|
||||
function setCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`;
|
||||
}
|
||||
|
||||
function clearCookie() {
|
||||
document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||
}
|
||||
|
||||
function fire(el: Element) {
|
||||
return el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearCookie();
|
||||
});
|
||||
|
||||
describe('DesktopContextMenuGuard', () => {
|
||||
it('mit Cookie: unterdrueckt ausserhalb von Eingabefeldern, laesst sie darin zu', async () => {
|
||||
setCookie();
|
||||
render(<DesktopContextMenuGuard />);
|
||||
await act(async () => {});
|
||||
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
expect(fire(div)).toBe(false);
|
||||
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
expect(fire(input)).toBe(true);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
expect(fire(textarea)).toBe(true);
|
||||
|
||||
const select = document.createElement('select');
|
||||
document.body.appendChild(select);
|
||||
expect(fire(select)).toBe(true);
|
||||
|
||||
const editable = document.createElement('div');
|
||||
editable.setAttribute('contenteditable', 'true');
|
||||
const span = document.createElement('span');
|
||||
editable.appendChild(span);
|
||||
document.body.appendChild(editable);
|
||||
expect(fire(span)).toBe(true);
|
||||
|
||||
document.body.removeChild(div);
|
||||
document.body.removeChild(input);
|
||||
document.body.removeChild(textarea);
|
||||
document.body.removeChild(select);
|
||||
document.body.removeChild(editable);
|
||||
});
|
||||
|
||||
it('ohne Cookie: Kontextmenue bleibt erlaubt', async () => {
|
||||
render(<DesktopContextMenuGuard />);
|
||||
await act(async () => {});
|
||||
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
expect(fire(div)).toBe(true);
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
it('nach unmount: Kontextmenue bleibt wieder erlaubt', async () => {
|
||||
setCookie();
|
||||
const { unmount } = render(<DesktopContextMenuGuard />);
|
||||
await act(async () => {});
|
||||
unmount();
|
||||
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
expect(fire(div)).toBe(true);
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIsDesktopClient } from '@/lib/desktop-client';
|
||||
|
||||
/**
|
||||
* DesktopContextMenuGuard (260917-h2s) — unterdrueckt das
|
||||
* WebView2-Browser-Kontextmenue (Zurueck, Aktualisieren, Speichern unter,
|
||||
* Drucken …) im Desktop-Client; in Eingabefeldern und contenteditable-
|
||||
* Bereichen bleibt es erhalten, damit Kopieren/Einfuegen erreichbar bleibt.
|
||||
* jsdom kennt `isContentEditable` nicht -- deshalb zusaetzlich der Selektor.
|
||||
*/
|
||||
const EDITABLE_SELECTOR =
|
||||
'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
|
||||
|
||||
export function DesktopContextMenuGuard() {
|
||||
const isDesktop = useIsDesktopClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest(EDITABLE_SELECTOR) || (target as HTMLElement).isContentEditable === true) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('contextmenu', handler);
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handler);
|
||||
};
|
||||
}, [isDesktop]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DesktopDownloadLinks } from './desktop-download-links';
|
||||
|
||||
@@ -35,9 +35,14 @@ vi.mock('@/lib/desktop', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
function clearDesktopCookie() {
|
||||
document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
clearDesktopCookie();
|
||||
});
|
||||
|
||||
const windowsFile = {
|
||||
@@ -94,4 +99,19 @@ describe('DesktopDownloadLinks (18-03)', () => {
|
||||
expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
|
||||
expect(screen.queryByText('Linux-Version')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 4 (im Desktop-Client): rendert nichts und fragt /desktop/latest gar nicht erst an', async () => {
|
||||
document.cookie = 'tessera_desktop=1; path=/';
|
||||
loadDesktopLatest.mockResolvedValue({
|
||||
version: '1.1.0',
|
||||
channel: 'beta',
|
||||
commit: 'abc1234',
|
||||
buildTime: 'x',
|
||||
files: { windows: windowsFile, linux: linuxFile },
|
||||
});
|
||||
const { container } = render(<DesktopDownloadLinks />);
|
||||
await act(async () => {});
|
||||
expect(loadDesktopLatest).not.toHaveBeenCalled();
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { desktopDownloadUrl, type DesktopLatestInfo, loadDesktopLatest } from '@/lib/desktop';
|
||||
import { isDesktopClient, useIsDesktopClient } from '@/lib/desktop-client';
|
||||
|
||||
/**
|
||||
* Unauffaelliger Download-Link auf der Anmeldeseite (D-12): erscheint nur,
|
||||
* wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger,
|
||||
* nichts, solange nichts geladen ist. Windows fuehrt (Hauptlink), Linux
|
||||
* folgt als kleiner zweiter Link, wenn beide Pakete vorliegen.
|
||||
* folgt als kleiner zweiter Link, wenn beide Pakete vorliegen. Im
|
||||
* Desktop-Client entfaellt der Block komplett (260917-h2s) -- die Kennung
|
||||
* kommt ueber das Cookie `tessera_desktop`.
|
||||
*/
|
||||
export function DesktopDownloadLinks() {
|
||||
const t = useTranslations('auth');
|
||||
const [info, setInfo] = useState<DesktopLatestInfo | null>(null);
|
||||
const isDesktop = useIsDesktopClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktopClient()) return;
|
||||
let active = true;
|
||||
loadDesktopLatest().then((data) => {
|
||||
if (active) setInfo(data);
|
||||
@@ -24,6 +29,10 @@ export function DesktopDownloadLinks() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isDesktop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = info?.files;
|
||||
const windows = files?.windows;
|
||||
const linux = files?.linux;
|
||||
|
||||
Reference in New Issue
Block a user