From 61a971ccc0ca1e71355c20382faf8f4c830d8adc Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 1 Oct 2026 09:20:11 +0200 Subject: [PATCH] fix(desktop): Links mit target=_blank im Client ueber window.open oeffnen Im Desktop-Client unter Windows tat ein Klick auf Favoriten und andere Links mit target=_blank nichts, waehrend window.open (Such-Widget) ueber on_new_window im System-Browser landete (VM 8233 nachgestellt). DesktopExternalLinks leitet im Client Links- und Mittelklicks auf solche http/https-Links auf window.open um; von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../261001-cxo-PLAN.md | 17 ++++ CHANGELOG.md | 4 + apps/web/src/app/layout.tsx | 8 +- .../desktop/desktop-external-links.test.tsx | 91 +++++++++++++++++++ .../desktop/desktop-external-links.tsx | 57 ++++++++++++ 5 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 .planning/quick/261001-cxo-desktop-client-links-mit-target-blank-oe/261001-cxo-PLAN.md create mode 100644 apps/web/src/components/desktop/desktop-external-links.test.tsx create mode 100644 apps/web/src/components/desktop/desktop-external-links.tsx diff --git a/.planning/quick/261001-cxo-desktop-client-links-mit-target-blank-oe/261001-cxo-PLAN.md b/.planning/quick/261001-cxo-desktop-client-links-mit-target-blank-oe/261001-cxo-PLAN.md new file mode 100644 index 0000000..a08ca38 --- /dev/null +++ b/.planning/quick/261001-cxo-desktop-client-links-mit-target-blank-oe/261001-cxo-PLAN.md @@ -0,0 +1,17 @@ +--- +quick_id: 261001-cxo +description: "Desktop-Client: Links mit target=_blank oeffnen" +date: 2026-10-01 +--- + +# Desktop-Client: Links mit target=_blank oeffnen + +**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (``) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht. + +## Task 1 — DesktopExternalLinks +- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert. +- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen. +- Test `desktop-external-links.test.tsx`. +- CHANGELOG „Unveröffentlicht → Behoben“. + +**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge. diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e2262..8fc988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ## Unveröffentlicht +### Behoben + +- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts. + ## 1.9.0 – 2026-09-30 ### Neu diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a4011f7..ea8814c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl'; import { getLocale, getMessages } from 'next-intl/server'; import { ThemeProvider } from 'next-themes'; import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard'; +import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links'; import './globals.css'; export const metadata: Metadata = { @@ -10,11 +11,7 @@ export const metadata: Metadata = { description: 'Modulare Workflow-Plattform', }; -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { const locale = await getLocale(); const messages = await getMessages(); @@ -29,6 +26,7 @@ export default async function RootLayout({ > + {children} diff --git a/apps/web/src/components/desktop/desktop-external-links.test.tsx b/apps/web/src/components/desktop/desktop-external-links.test.tsx new file mode 100644 index 0000000..9697de8 --- /dev/null +++ b/apps/web/src/components/desktop/desktop-external-links.test.tsx @@ -0,0 +1,91 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client'; +import { DesktopExternalLinks } from './desktop-external-links'; + +/** + * desktop-external-links.test (quick-261001-cxo) — im Desktop-Client gehen + * Klicks auf Links mit `target="_blank"` ueber `window.open`; ohne Cookie, + * bei verhinderten Klicks, ohne `_blank` und bei Nicht-http(s) bleibt alles + * unberuehrt. `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 link(href: string, target?: string) { + const a = document.createElement('a'); + a.href = href; + if (target) a.target = target; + const span = document.createElement('span'); + a.appendChild(span); + document.body.appendChild(a); + return span; +} + +function fire(el: Element, type: 'click' | 'auxclick' = 'click', button = 0) { + return el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button })); +} + +let openSpy: ReturnType; + +beforeEach(() => { + openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); +}); + +afterEach(() => { + cleanup(); + clearCookie(); + openSpy.mockRestore(); + document.body.innerHTML = ''; +}); + +describe('DesktopExternalLinks', () => { + it('mit Cookie: Klick auf _blank-Link oeffnet per window.open', async () => { + setCookie(); + render(); + await act(async () => {}); + + const inner = link('https://chatgpt.com/', '_blank'); + expect(fire(inner)).toBe(false); + expect(openSpy).toHaveBeenCalledWith('https://chatgpt.com/', '_blank', 'noopener,noreferrer'); + }); + + it('mit Cookie: Mittelklick auf _blank-Link oeffnet ebenfalls', async () => { + setCookie(); + render(); + await act(async () => {}); + + const inner = link('http://intranet.local/', '_blank'); + expect(fire(inner, 'auxclick', 1)).toBe(false); + expect(openSpy).toHaveBeenCalledWith('http://intranet.local/', '_blank', 'noopener,noreferrer'); + }); + + it('mit Cookie: verhinderter Klick, Link ohne _blank und mailto bleiben unberuehrt', async () => { + setCookie(); + render(); + await act(async () => {}); + + const prevented = link('https://chatgpt.com/', '_blank'); + prevented.addEventListener('click', (e) => e.preventDefault()); + fire(prevented); + + expect(fire(link('https://example.com/'))).toBe(true); + expect(fire(link('mailto:a@example.com', '_blank'))).toBe(true); + expect(fire(link('https://example.com/', '_blank'), 'click', 2)).toBe(true); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('ohne Cookie: keine Umleitung', async () => { + render(); + await act(async () => {}); + + expect(fire(link('https://chatgpt.com/', '_blank'))).toBe(true); + expect(openSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/desktop/desktop-external-links.tsx b/apps/web/src/components/desktop/desktop-external-links.tsx new file mode 100644 index 0000000..1cba46c --- /dev/null +++ b/apps/web/src/components/desktop/desktop-external-links.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useEffect } from 'react'; +import { useIsDesktopClient } from '@/lib/desktop-client'; + +/** + * DesktopExternalLinks (quick-261001-cxo) — Links mit `target="_blank"` + * (Favoriten, „In neuem Tab öffnen“ bei XFrame/eigenen Modulen, Quellen im + * Ausschreibungs-Radar) taten im Desktop-Client unter Windows beim Klick + * nichts, `window.open` (Such-Widget) dagegen landet ueber `on_new_window` + * im System-Browser (VM 8233, 01.10.2026: Favorit ohne Wirkung, Suche oeffnet + * Edge). Im Client leitet dieser Helfer solche Klicks deshalb auf + * `window.open` um. + * + * Lauscht auf `window` in der Bubble-Phase, also NACH Reacts Handlern am + * Dokument: ein Klick, den die Seite selbst verhindert (Favoriten im + * Bearbeiten-Modus), bleibt verhindert. Nur http/https -- alles andere + * verwirft der Client ohnehin. + */ +function externalTarget(event: MouseEvent): HTMLAnchorElement | null { + if (event.defaultPrevented) return null; + // Linksklick und Mittelklick oeffnen beide ein neues Fenster. + if (event.button !== 0 && event.button !== 1) return null; + const target = event.target; + if (!(target instanceof Element)) return null; + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return null; + if (anchor.target !== '_blank') return null; + if (anchor.protocol !== 'http:' && anchor.protocol !== 'https:') return null; + return anchor; +} + +export function DesktopExternalLinks() { + const isDesktop = useIsDesktopClient(); + + useEffect(() => { + if (!isDesktop) return; + + const handler = (event: MouseEvent) => { + if (event.type === 'auxclick' && event.button !== 1) return; + if (event.type === 'click' && event.button !== 0) return; + const anchor = externalTarget(event); + if (!anchor) return; + event.preventDefault(); + window.open(anchor.href, '_blank', 'noopener,noreferrer'); + }; + + window.addEventListener('click', handler); + window.addEventListener('auxclick', handler); + return () => { + window.removeEventListener('click', handler); + window.removeEventListener('auxclick', handler); + }; + }, [isDesktop]); + + return null; +}