7 Commits

Author SHA1 Message Date
schalli 645c5e5887 docs(changelog): Version 1.9.1 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m47s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 12m17s
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:30:17 +02:00
schalli 2dd11b439d fix(favorites): Logo auch fuer Seiten, die es per JavaScript setzen
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m28s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m18s
hosteurope.de liefert im HTML nur einen leeren data:-Platzhalter, das
echte Symbol setzt erst JavaScript; /favicon.ico antwortet mit HTML. Die
Kachel zeigte deshalb nur den Buchstaben. Scheitert das gespeicherte
Symbol, fragt getIconBytes jetzt einmal den DuckDuckGo-Symboldienst -
nur fuer oeffentlich erreichbare Seiten, interne Hostnamen verlassen das
Haus nicht; kennt der Dienst nichts (404), bleibt es beim Buchstaben.
Wirkt auch fuer bestehende Favoriten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:28:32 +02:00
schalli 59b8cd43fc fix(reminders): Cursor springt beim Schreiben nicht mehr in den Titel
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m24s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Der Fokus auf das Titelfeld lag im selben Effekt wie der Escape-Listener
mit Abhaengigkeit onClose. Die Kachel uebergibt onClose inline und
zeichnet alle 10 s neu (NOW_TICK_MS) - der Effekt lief jedes Mal erneut
und holte den Cursor aus der Beschreibung zurueck in den Titel. Fokus
jetzt nur beim Oeffnen, Escape ueber eine Ref.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:38:58 +02:00
schalli 5919a55cbf fix(desktop): Link-Klicks vor dem Opener-Skript des Clients abfangen
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m33s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m13s
Die erste Fassung lauschte auf window und lief damit nach dem von
tauri-plugin-opener eingeschleusten Link-Skript. Dieses ruft bei
target=_blank preventDefault und plugin:opener|open_url auf, was von der
Server-Seite aus nicht freigegeben ist - der Klick verpuffte weiter
(auf VM 8233 per Klick-Protokoll gemessen). Der Helfer lauscht jetzt auf
document: nach Reacts Handlern, vor dem Opener-Skript.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 10:20:42 +02:00
schalli 7edaf8c00b docs(quick-261001-cxo): Summary und STATE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 09:20:49 +02:00
schalli 61a971ccc0 fix(desktop): Links mit target=_blank im Client ueber window.open oeffnen
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m36s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m32s
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) <noreply@anthropic.com>
2026-10-01 09:20:11 +02:00
schalli 471cfbf98b wip: pausiert nach Freigabe 1.9.0
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m5s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:21:01 +02:00
19 changed files with 504 additions and 12 deletions
+34
View File
@@ -0,0 +1,34 @@
---
context: default
phase: quick-auftraege-1.9.x (keine GSD-Phase)
task: 0
total_tasks: 0
status: paused
last_updated: 2026-09-30T18:00:00.000Z
---
# BLOCKING CONSTRAINTS — Read Before Anything Else
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
- [ ] CONSTRAINT: Nie Wichtiges (Logo, Text) nur in Mailbilder packen – OWA zeigt eingebettete Bilder nicht.
<current_state>
main = live = v1.9.0 (a257bc3), CI + Release grün. alpha zuletzt 714f731 (Inhalt identisch). Arbeitsbaum sauber.
</current_state>
<completed_work>
- 30.09.: Windows-Test bestanden, Review seit 26.09. + Fixes, v1.8.0.
- Dashboard-Skalierung (nie scrollen), eigene Module Keep-Alive, Favoriten enger.
- Sicherheit: Rolle/Aktiv-Status je Anfrage aus DB; /login-Weiterleitung.
- Willkommensmail + eigene Vorlage (Platzhalter, Vorschau, Testmail, Anmeldehinweise), Spalte Letzte Anmeldung; v1.9.0.
</completed_work>
<remaining_work>
- User: live auf 1.9.0 ziehen (df -h / vorher), Willkommensmail in OWA prüfen.
</remaining_work>
<next_action>
Nachfragen, ob live gezogen und OWA ok; sonst neuen Auftrag abwarten.
</next_action>
+33
View File
@@ -0,0 +1,33 @@
{
"version": "1.0",
"timestamp": "2026-09-30T18:00:00.000Z",
"phase": null,
"phase_name": "Quick-Auftraege nach Freigabe 1.9.0 (keine GSD-Phase)",
"phase_dir": null,
"plan": null,
"task": 0,
"total_tasks": 0,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "30.09.: Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. + Fixes; Freigabe 1.8.0 (af78157)", "status": "done"},
{"id": 2, "name": "Dashboard 1:1-Skalierung (__canvas, passt Inhalt ein = nie scrollen), eigene Module Keep-Alive (max 5), Favoriten-Kachelansicht enger + lange Namen klein/zweizeilig", "status": "done"},
{"id": 3, "name": "Sicherheit: JwtStrategy liest Rolle/isActive/mustChangePassword je Anfrage aus DB; /login leitet Angemeldete aufs Dashboard", "status": "done"},
{"id": 4, "name": "Willkommensmail (Briefsymbol Benutzerliste, jederzeit an jeden) + Spalte Letzte Anmeldung + eigene Vorlage je Mandant (Admin -> Willkommensmail, Platzhalter, Vorschau, Testmail, Anmeldehinweise editierbar); Freigabe 1.9.0 (a257bc3)", "status": "done"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [
{"action": "Live-Server auf v1.9.0 ziehen (vorher df -h /, ggf. docker image prune -f, nie -a)", "context": "CI 11 Laeufe gruen, Release Tessera 1.9.0 mit Setup.exe + AppImage", "blocking": false},
{"action": "Willkommensmail in OWA (owa.ctl.de) pruefen: dunkler Kopf ohne weisse Luecke", "context": "OWA zeigt CID-Bilder nicht, Outlook-Programm schon", "blocking": false}
],
"decisions": [
{"decision": "Dashboard: alles mitskalieren inkl. Schrift, nie scrollen; Leinwand = Flaeche beim ersten Oeffnen je Reiter", "rationale": "AskUserQuestion 30.09.", "phase": "quick"},
{"decision": "Keine naechtliche Docker-Aufraeumung auf alpha", "rationale": "User 30.09.: passt so", "phase": "quick"},
{"decision": "Willkommensmail jederzeit an jeden Benutzer; Link Passwort festlegen 7 Tage", "rationale": "User 30.09.", "phase": "quick"},
{"decision": "PMG ohne API-Token (Proxmox kennt keine), eigener Auditor-Benutzer; Anleitung im Admin-Handbuch", "rationale": "Proxmox Bugzilla 5849 offen", "phase": "quick"}
],
"uncommitted_files": [],
"next_action": "Nichts offen von Claudes Seite. Beim Start: fragen, ob live auf 1.9.0 gezogen ist und ob die Willkommensmail in OWA passt; sonst neuen Auftrag abwarten.",
"context_notes": "main = live = v1.9.0 (a257bc3). alpha lief zuletzt auf 714f731 (= Inhalt 1.9.0). Lokaler Stack aus 714f731 gebaut. Test-Postfach MailHog nur bei Bedarf: docker run -d --rm --name mailhog --network tessera-ctl_backend-net --network-alias mailhog -p 127.0.0.1:8025:8025 mailhog/mailhog. Diagnose auf alpha ohne Passwort: JWT im api-Container mit crypto + process.env.JWT_SECRET signieren (nur lesend, kurzlebig)."
}
+5 -2
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren." stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-23T15:30:00.000Z" last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-09-30 last_activity: 2026-10-01
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0 last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9) Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -483,6 +483,9 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) | | 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) | | 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) | | 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
## Deferred Items ## Deferred Items
@@ -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 (`<a target="_blank">`) 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.
@@ -0,0 +1,19 @@
---
quick_id: 261001-cxo
status: complete
date: 2026-10-01
commit: 61a971c
---
# Summary: Desktop-Client – Links mit target=_blank
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
- Offen: Nachweis auf VM nach alpha-Pull (User).
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
@@ -0,0 +1,14 @@
---
quick_id: 261001-g68
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
date: 2026-10-01
---
# Erinnerung: Cursor springt aus Beschreibung in Titel
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
## Task 1
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
- CHANGELOG „Unveröffentlicht → Behoben“.
@@ -0,0 +1,10 @@
---
quick_id: 261001-g68
status: complete
date: 2026-10-01
---
# Summary
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
@@ -0,0 +1,14 @@
---
quick_id: 261001-hbi
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
date: 2026-10-01
---
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
## Task 1
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
- Tests in beiden Specs; CHANGELOG.
@@ -0,0 +1,10 @@
---
quick_id: 261001-hbi
status: complete
date: 2026-10-01
---
# Summary
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
+8
View File
@@ -4,6 +4,14 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
## 1.9.1 – 2026-10-01
### 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.
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
## 1.9.0 – 2026-09-30 ## 1.9.0 – 2026-09-30
### Neu ### Neu
@@ -220,7 +220,11 @@ function expectBoundCall(
} }
function makeIconDiscovery( function makeIconDiscovery(
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {}, overrides: Partial<{
discoverFavoriteIconUrl: any;
fetchIconBytes: any;
fetchPublicServiceIconBytes: any;
}> = {},
) { ) {
return { return {
discoverFavoriteIconUrl: discoverFavoriteIconUrl:
@@ -229,6 +233,11 @@ function makeIconDiscovery(
fetchIconBytes: fetchIconBytes:
overrides.fetchIconBytes ?? overrides.fetchIconBytes ??
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })), vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
fetchPublicServiceIconBytes:
overrides.fetchPublicServiceIconBytes ??
vi.fn(async () => {
throw new Error('icon service: unknown');
}),
}; };
} }
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException); await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
}); });
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({
fetchIconBytes: vi.fn(async () => {
throw new Error('not an image');
}),
fetchPublicServiceIconBytes: vi.fn(async () => ({
contentType: 'image/png',
body: Buffer.from('ddg'),
})),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
expect(result.body).toEqual(Buffer.from('ddg'));
});
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
});
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => { it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
const prisma = makeFakePrisma([baseRow]); const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({ const iconDiscovery = makeIconDiscovery({
+11 -2
View File
@@ -497,7 +497,9 @@ export class FavoritesService {
* Throws NotFoundException (404) if the row doesn't exist, isn't owned * Throws NotFoundException (404) if the row doesn't exist, isn't owned
* by the caller, or has neither an uploaded icon nor a stored iconUrl. * by the caller, or has neither an uploaded icon nor a stored iconUrl.
* Throws a 502 HttpException if the upstream fetch fails (unreachable, * Throws a 502 HttpException if the upstream fetch fails (unreachable,
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image. * timeout, non-image, or SSRF-blocked) AND the public icon service fallback
* (quick-261001-hbi, public pages only) has no icon either -- never returns
* a placeholder image.
*/ */
async getIconBytes( async getIconBytes(
tenantId: string, tenantId: string,
@@ -536,7 +538,14 @@ export class FavoritesService {
try { try {
return await this.iconDiscovery.fetchIconBytes(link.iconUrl); return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
} catch { } catch {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY); // quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
// nur fuer oeffentlich erreichbare Seiten.
try {
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
} catch {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
}
} }
} }
} }
@@ -238,6 +238,57 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
}); });
}); });
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
expect(result.contentType).toBe('image/png');
});
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
await expect(
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
).rejects.toThrow(/not public/);
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
/not public/,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
...mockResponse({ contentType: 'image/png' }),
ok: false,
status: 404,
}),
);
const service = new IconDiscoveryService();
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
/blocked or failed/,
);
});
});
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => { describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
const MAX_HTML_CHARS = 200000; const MAX_HTML_CHARS = 200000;
const MAX_ICON_BYTES = 1_000_000; const MAX_ICON_BYTES = 1_000_000;
/**
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
* verlassen; der Dienst erfaehrt nur den Hostnamen.
*/
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
/** /**
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte, * 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines * abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
@@ -426,6 +438,22 @@ export class IconDiscoveryService {
} }
} }
/**
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
*/
async fetchPublicServiceIconBytes(
pageUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
const page = new URL(normalizeUrl(pageUrl));
if (!(await isPublicHttpUrl(page))) {
throw new Error('Page is not public, icon service not asked');
}
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
}
/** /**
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming * Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
* back to the browser from Tessera's own origin (avoids Cross-Origin- * back to the browser from Tessera's own origin (avoids Cross-Origin-
+3 -5
View File
@@ -3,6 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server'; import { getLocale, getMessages } from 'next-intl/server';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard'; import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
import { DesktopExternalLinks } from '@/components/desktop/desktop-external-links';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -10,11 +11,7 @@ export const metadata: Metadata = {
description: 'Modulare Workflow-Plattform', description: 'Modulare Workflow-Plattform',
}; };
export default async function RootLayout({ export default async function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const locale = await getLocale(); const locale = await getLocale();
const messages = await getMessages(); const messages = await getMessages();
@@ -29,6 +26,7 @@ export default async function RootLayout({
> >
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<DesktopContextMenuGuard /> <DesktopContextMenuGuard />
<DesktopExternalLinks />
{children} {children}
</NextIntlClientProvider> </NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
@@ -65,14 +65,23 @@ export function ReminderFormModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null); const titleRef = useRef<HTMLInputElement>(null);
// Fokus nur EINMAL beim Oeffnen (quick-261001-g68): `onClose` kommt aus der
// Kachel als neue Funktion bei jedem Neuzeichnen — die Kachel zeichnet alle
// 10 s neu (NOW_TICK_MS). Hing der Fokus mit am `onClose`-Effekt, sprang der
// Cursor beim Schreiben der Beschreibung immer wieder in den Titel.
useEffect(() => { useEffect(() => {
titleRef.current?.focus(); titleRef.current?.focus();
}, []);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onCloseRef.current();
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [onClose]); }, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -349,6 +349,32 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
}); });
}); });
describe('ReminderWidget — Formular behaelt den Fokus (quick-261001-g68)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('der 10-s-Takt der Kachel holt den Cursor nicht aus der Beschreibung zurueck in den Titel', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
fireEvent.click(screen.getByText('reminder.add'));
expect(document.activeElement).toBe(screen.getByLabelText('reminder.titleLabel'));
const description = screen.getByLabelText('reminder.descriptionLabel');
description.focus();
fireEvent.change(description, { target: { value: 'Unterlagen mitnehmen' } });
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(document.activeElement).toBe(description);
});
});
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => { describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@@ -0,0 +1,110 @@
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<typeof vi.spyOn>;
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(<DesktopExternalLinks />);
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: kommt dem Link-Skript des Clients auf window zuvor', async () => {
setCookie();
render(<DesktopExternalLinks />);
await act(async () => {});
// Nachbau von tauri-plugin-opener (init-iife.js): bricht ab, wenn der
// Klick schon verhindert ist, sonst preventDefault + IPC-Aufruf.
const openerSaw: boolean[] = [];
const opener = (e: MouseEvent) => openerSaw.push(e.defaultPrevented);
window.addEventListener('click', opener);
try {
fire(link('https://chatgpt.com/', '_blank'));
} finally {
window.removeEventListener('click', opener);
}
expect(openSpy).toHaveBeenCalledTimes(1);
expect(openerSaw).toEqual([true]);
});
it('mit Cookie: Mittelklick auf _blank-Link oeffnet ebenfalls', async () => {
setCookie();
render(<DesktopExternalLinks />);
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(<DesktopExternalLinks />);
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(<DesktopExternalLinks />);
await act(async () => {});
expect(fire(link('https://chatgpt.com/', '_blank'))).toBe(true);
expect(openSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,61 @@
'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 beim Klick nichts, waehrend
* `window.open` (Such-Widget) ueber `on_new_window` im System-Browser landet.
*
* Ursache (VM 8233, 01.10.2026, gemessen): tauri-plugin-opener schleust ein
* Skript ein, das auf `window` solche Klicks abfaengt, `preventDefault()`
* ruft und `plugin:opener|open_url` aufruft -- von der Server-Seite aus ist
* dieser Aufruf nicht freigegeben, der Klick verpufft. Dieser Helfer kommt
* ihm zuvor und oeffnet per `window.open`; das Opener-Skript sieht den Klick
* dann als verhindert und tut nichts.
*
* Lauscht deshalb auf `document` in der Bubble-Phase: NACH Reacts Handlern
* (Wurzel `document`, frueher registriert) -- ein Klick, den die Seite selbst
* verhindert (Favoriten im Bearbeiten-Modus), bleibt verhindert --, aber VOR
* dem Opener-Skript auf `window`. Nur http/https.
*/
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');
};
document.addEventListener('click', handler);
document.addEventListener('auxclick', handler);
return () => {
document.removeEventListener('click', handler);
document.removeEventListener('auxclick', handler);
};
}, [isDesktop]);
return null;
}