From 5eb689e4c2c0bd79709a778797ac3a9672eef021 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Wed, 9 Sep 2026 08:41:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(links):=20v=C3=A6lg=20hvilken=20browser=20?= =?UTF-8?q?et=20link=20=C3=A5bner=20i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ønsket var en liste over de installerede browsere. Den findes ikke: en webside kan hverken se, hvilke browsere der er på maskinen — listen er lukket land, fordi den er et fingeraftryk, der ville følge folk rundt på nettet — eller bede systemet om at bruge en bestemt af dem. Det eneste håndtag er de URL-skemaer, browserne selv registrerer: firefox://open-url, googlechrome://, microsoft-edge:. Dem kan man prøve, og man kan ikke spørge, om de findes. Så indstillingen er skrevet som det, den er — et forsøg med et faldback — frem for som et løfte, der ville holde på telefonen og ikke på en Mac. Valget står i localStorage, ikke på kontoen. Samme grund som sidebjælkens bredde: det er en egenskab ved maskinen, man sidder ved. firefox:// virker på telefonen og gør ingenting på Mac'en, så et valg, der fulgte kontoen, ville være forkert det ene af de to steder hver gang. Ét sted i koden frem for ét pr. komponent. Links står i opgavetitler, i beskrivelser, i noter og i indstillingerne, og en regel, der skulle gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på. Kun udadgående links; programmets egne ruter går gennem SvelteKit som før. To ting, målingen rettede undervejs: Det skjulte iframe — den kendte opskrift — bliver blokeret af appens egen Content-Security-Policy, som siger default-src 'self'. Uden e2e-prøven, der fangede CSP-fejlen i konsollen, ville skemaet aldrig være blevet forsøgt i produktion, og indstillingen ville have set ud til at virke, fordi faldbacket åbnede linket alligevel. Nu sættes location i stedet: et ukendt skema afbryder navigationen frem for at forlade siden. Og faldbacket troede sig blokeret hver eneste gang. window.open med noopener returnerer altid null — det står i specifikationen — så kontrollen af, om fanen blev åbnet, faldt altid ud til "blokeret", og den aktuelle fane blev navigeret væk i stedet. Håndtaget er det eneste, der kan kende en blokeret fane fra en åbnet, så det bruges nu, og opener ryddes bagefter. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 53 +++++++ web/src/lib/linkopen.js | 172 ++++++++++++++++++++++ web/src/lib/locales/da.js | 17 +++ web/src/lib/locales/en.js | 17 +++ web/src/routes/+layout.svelte | 35 ++++- web/src/routes/indstillinger/+page.svelte | 63 ++++++++ 6 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/linkopen.js diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index a44eadc..0beff49 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -4272,3 +4272,56 @@ test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', asyn expect(trouble).toEqual([]); }); + +test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke findes', async ({ + page +}) => { + const trouble = watchForTrouble(page); + + await page.goto('/indstillinger'); + await expect(page.getByRole('heading', { name: 'Links' })).toBeVisible(); + + // Som udgangspunkt gør indstillingen ingenting, og der er intet skemafelt at + // skrive i: et felt til en adresse, der ikke bliver brugt, er en invitation + // til at udfylde den og undre sig. + await expect(page.getByLabel('Skema')).toBeHidden(); + + // Et skema, der med sikkerhed ikke findes på nogen maskine. Pointen er ikke, + // at det virker — pointen er, hvad der sker, når det ikke gør. + await page.getByLabel('Åbn links i').selectOption('custom'); + await page.getByLabel('Skema').fill('verdande-findes-ikke://{url}'); + await page.getByLabel('Skema').blur(); + + // Valget hører til maskinen, ikke kontoen, så det står i localStorage. + const stored = await page.evaluate(() => localStorage.getItem('verdande:link-opener')); + expect(stored, 'valget blev ikke gemt på apparatet').toContain('verdande-findes-ikke'); + + // Om browseren rent faktisk tegner en fane, er browserens sag og ikke appens — + // en headless Chromium åbner den uden at fortælle nogen om det. Det, der er + // vores, er beslutningen: prøv skemaet, og åbn linket alligevel, når det ikke + // findes. Så det er dét, der bliver målt. + await page.evaluate(() => { + window.__opened = []; + window.open = (url) => { + window.__opened.push(url); + return { closed: false, close() {} }; + }; + }); + + await page.getByRole('button', { name: 'Prøv det' }).click(); + await expect + .poll(() => page.evaluate(() => window.__opened), { timeout: 5000 }) + .toEqual(['https://example.dk']); + + // Og appen blev, hvor den var. Faldbacket må ikke kapre den fane, man står i: + // et link, der smider en ud af programmet, er værre end et, der åbner et sted, + // man ikke havde valgt. + await expect(page).toHaveURL(/\/indstillinger$/); + + // Tilbage til systemets standard, så resten af prøverne klikker på links som + // alle andre. + await page.getByLabel('Åbn links i').selectOption(''); + await expect(page.getByLabel('Skema')).toBeHidden(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/linkopen.js b/web/src/lib/linkopen.js new file mode 100644 index 0000000..d5a3e28 --- /dev/null +++ b/web/src/lib/linkopen.js @@ -0,0 +1,172 @@ +/** + * Hvilken browser et link åbner i. + * + * # Hvad der ikke kan lade sig gøre + * + * En webside kan hverken se, hvilke browsere der er installeret, eller bede + * systemet om at åbne et link i en bestemt af dem. Listen over installerede + * programmer er lukket land med vilje — den er et fingeraftryk, der ville følge + * folk rundt på nettet — og et almindeligt link går altid til standardbrowseren. + * + * Det eneste håndtag, der findes, er de URL-skemaer, browserne selv registrerer: + * `firefox://open-url?url=…`, `googlechrome://…`, `microsoft-edge:…`. Der er ingen + * måde at spørge, om et skema findes; man kan kun prøve. + * + * # Hvad der så sker + * + * Vi prøver skemaet og holder øje med, om siden forsvinder. Åbnede en anden + * browser, mister denne fane fokus, og så er der ikke mere at gøre. Sker der + * ingenting inden for et sekund — hvilket er, hvad en Mac gør, hvor Chrome og + * Firefox ikke registrerer noget skema — åbnes linket helt normalt. + * + * Faldbacket er ikke en detalje: uden det ville en indstilling, der ser ud til at + * virke på telefonen, gøre links uklikbare på skrivebordet. + * + * # Hvorfor det står i localStorage + * + * Samme grund som sidebjælkens bredde: det er en egenskab ved maskinen, man + * sidder ved, ikke ved personen. `firefox://` virker på telefonen og gør + * ingenting på Mac'en, så et valg, der fulgte kontoen, ville være forkert det ene + * af de to steder hver gang. + */ + +const KEY = 'verdande:link-opener'; + +/** + * De skemaer, der er værd at tilbyde, og hvad de faktisk gør. + * + * `{url}` erstattes af adressen. Nogle skemaer vil have den kodet som en parameter + * (Firefox), andre sætter den bag skemaet, som den er (Chrome, Edge) — derfor er + * det en skabelon og ikke et navn, og derfor kan man skrive sin egen. + */ +export const OPENERS = [ + { id: '', label: 'link.openerDefault', template: '' }, + { id: 'safari', label: 'link.openerSafari', template: 'x-safari-{url}' }, + { id: 'chrome', label: 'link.openerChrome', template: 'googlechrome{stripped}' }, + { id: 'firefox', label: 'link.openerFirefox', template: 'firefox://open-url?url={encoded}' }, + { id: 'edge', label: 'link.openerEdge', template: 'microsoft-edge:{url}' } +]; + +/** Det gemte valg: en af id'erne ovenfor, eller en skabelon man selv har skrevet. */ +export function stored() { + if (typeof localStorage === 'undefined') return { id: '', template: '' }; + try { + const raw = localStorage.getItem(KEY); + if (!raw) return { id: '', template: '' }; + const parsed = JSON.parse(raw); + return { id: parsed.id ?? '', template: parsed.template ?? '' }; + } catch { + return { id: '', template: '' }; + } +} + +export function save(choice) { + try { + localStorage.setItem(KEY, JSON.stringify(choice)); + } catch { + // Privat vindue; valget holder til fanen lukkes. + } +} + +/** Skabelonen, der gælder nu — enten den valgte eller den, man selv har skrevet. */ +function template() { + const choice = stored(); + if (choice.id === 'custom') return choice.template; + return OPENERS.find((o) => o.id === choice.id)?.template ?? ''; +} + +/** + * Skabelonen fyldt ud med adressen. + * + * `{url}` er adressen, som den er — det er dét, `x-safari-https://…` og + * `microsoft-edge:https://…` vil have. `{encoded}` er den som parameterværdi, til + * Firefox. `{stripped}` er den uden `https://` foran, som Chrome vil have den: + * `googlechrome://example.dk`. + */ +export function expand(tpl, url) { + if (!tpl) return ''; + return tpl + .replace('{encoded}', encodeURIComponent(url)) + .replace('{stripped}', '://' + url.replace(/^https?:\/\//, '')) + .replace('{url}', url); +} + +/** + * Åbner en adresse i den valgte browser, og ellers helt normalt. + * + * Returnerer, om skemaet blev forsøgt. Kalderen skal stadig lade være med at + * følge linket selv — det er derfor, klikket bliver stoppet, inden vi kommer + * hertil. + */ +export function open(url) { + const tpl = template(); + if (!tpl) { + window.open(url, '_blank', 'noopener,noreferrer'); + return false; + } + + let handled = false; + // Forsvinder siden, åbnede noget andet den. Begge signaler, fordi de to + // platforme ikke er enige om hvilket der kommer: en telefon skjuler siden, en + // pc flytter fokus. + const gone = () => { + handled = true; + }; + window.addEventListener('blur', gone, { once: true }); + document.addEventListener('visibilitychange', gone, { once: true }); + + // Adresselinjen og ikke et skjult iframe. + // + // Iframet er den kendte opskrift, og den kan ikke bruges her: appens egen + // Content-Security-Policy siger `default-src 'self'`, så en ramme med et + // fremmed skema bliver blokeret, inden browseren når at se på den. Målt i + // e2e-prøven, som fangede CSP-fejlen i konsollen — uden den ville skemaet + // aldrig være blevet forsøgt i produktion, og indstillingen ville se ud til + // at virke, fordi faldbacket åbnede linket alligevel. + // + // At sætte location på et ukendt skema afbryder navigationen frem for at + // forlade siden: dokumentet bliver stående, og det er dét, der gør, at + // faldbacket nedenfor stadig har en side at køre i. Nogle browsere siger til + // om en adresse, de ikke forstår, og det er prisen for at kunne prøve. + location.href = expand(tpl, url); + + // Kort, og det er ikke en smagssag. + // + // Faldbacket åbner en fane, og en browser tillader kun det et øjeblik efter et + // klik — Chrome og Firefox regner med omtrent fem sekunder, Safari med + // betydeligt mindre. Ventes der for længe, bliver faldbacket blokeret som en + // pop-up, og så gør linket ingenting overhovedet. Seks hundrede millisekunder + // er rigeligt til, at en anden browser når at tage over, og godt inden for + // det, der stadig tæller som "fordi nogen klikkede". + setTimeout(() => { + window.removeEventListener('blur', gone); + document.removeEventListener('visibilitychange', gone); + if (handled || document.hidden) return; + + // Skemaet fandtes ikke her. Linket skal stadig åbne — alt andet ville gøre + // en indstilling, der virker på telefonen, til ødelagte links på + // skrivebordet. + // + // Uden `noopener`, og det er ikke en forglemmelse: med den flag returnerer + // window.open **altid** null, også når fanen åbnede fint — det står i + // specifikationen. Målt her, efter at faldbacket havde troet sig blokeret + // hver eneste gang og navigeret den aktuelle fane væk i stedet. Håndtaget + // er det eneste, der kan kende en blokeret fane fra en åbnet, så det skal + // bruges, og opener ryddes bagefter. + const tab = window.open(url, '_blank'); + if (tab) { + try { + tab.opener = null; + } catch { + // En fane på et andet domæne lader sig ikke altid røre. Fanen er + // åbnet, og det var det, klikket handlede om. + } + return; + } + // Blev den alligevel blokeret, går vi i den fane, vi står i. Et dårligere + // sted at ende end en ny fane, og et meget bedre sted end et link, der + // ikke gjorde noget. + location.href = url; + }, 600); + return true; +} diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 2ed8ec3..e24cb7a 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -105,6 +105,23 @@ export const da = { 'ai.skip': 'Nej tak', 'ai.applied': 'Gjort.', 'ai.allDone': 'Ikke mere at tage stilling til.', + // Hvilken browser links åbner i. Se linkopen.js for, hvorfor det er et forsøg + // og ikke et løfte: en webside kan ikke se, hvilke browsere der findes. + 'link.title': 'Links', + 'link.hint': + 'Hvilken browser et link åbner i. Der findes ingen måde at spørge systemet, hvilke browsere der er installeret, så valget er et forsøg: virker det ikke på denne maskine, åbner linket helt normalt. I praksis virker det på telefonen og sjældent på en Mac eller pc.', + 'link.opener': 'Åbn links i', + 'link.openerDefault': 'Systemets standard', + 'link.openerSafari': 'Safari', + 'link.openerChrome': 'Chrome', + 'link.openerFirefox': 'Firefox', + 'link.openerEdge': 'Edge', + 'link.openerCustom': 'Min egen adresse …', + 'link.template': 'Skema', + 'link.templateHint': + '{url} bliver til adressen som den er, {encoded} til den som parameterværdi, og {stripped} til den uden http foran.', + 'link.try': 'Prøv det', + 'link.tryHint': 'Åbner example.dk, så du kan se, om det virker her.', 'beacon.title': 'Tælling af installationer', 'beacon.hint': 'Én gang i døgnet melder denne installation, at den findes, så projektet kan se hvor mange der bruger det. Der sendes kun de to værdier herunder — et tilfældigt id og versionen — og intet andet: ingen IP bliver gemt, ingen navne, ingen adresser, intet om hvad du bruger det til. Slået til som udgangspunkt, og du kan slå den fra her.', 'beacon.send': 'Send en anonym daglig melding', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index a83686c..dcd3ef0 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -102,6 +102,23 @@ export const en = { 'ai.skip': 'No thanks', 'ai.applied': 'Done.', 'ai.allDone': 'Nothing left to decide on.', + // Which browser links open in. See linkopen.js for why this is an attempt and + // not a promise: a web page cannot see which browsers exist. + 'link.title': 'Links', + 'link.hint': + 'Which browser a link opens in. There is no way to ask the system which browsers are installed, so the choice is an attempt: if it does not work on this machine, the link opens the usual way. In practice it works on a phone and rarely on a Mac or PC.', + 'link.opener': 'Open links in', + 'link.openerDefault': 'The system default', + 'link.openerSafari': 'Safari', + 'link.openerChrome': 'Chrome', + 'link.openerFirefox': 'Firefox', + 'link.openerEdge': 'Edge', + 'link.openerCustom': 'My own address …', + 'link.template': 'Scheme', + 'link.templateHint': + '{url} becomes the address as it is, {encoded} the address as a parameter value, and {stripped} the address without the http in front.', + 'link.try': 'Try it', + 'link.tryHint': 'Opens example.dk, so you can see whether it works here.', 'beacon.title': 'Counting installations', 'beacon.hint': 'Once a day this installation reports that it exists, so the project can see how many people run it. It sends only the two values below — a random id and the version — and nothing else: no IP is stored, no names, no addresses, nothing about what you use it for. On by default, and you can turn it off here.', 'beacon.send': 'Send an anonymous daily report', diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index c41e67b..c8070b2 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -11,6 +11,7 @@ import TaskDetail from '$lib/components/TaskDetail.svelte'; import { t } from '$lib/i18n.svelte.js'; import { setBadge } from '$lib/badge.js'; + import { open as openInChosenBrowser, stored as storedOpener } from '$lib/linkopen.js'; import BeaconNotice from '$lib/components/BeaconNotice.svelte'; let { children } = $props(); @@ -55,6 +56,37 @@ app.load(); }); + /** + * Links ud af programmet, åbnet i den browser, maskinen er sat op til. + * + * Ét sted frem for ét pr. komponent. Links står i opgavetitler, i + * beskrivelser, i noter og i indstillingerne, og en regel, der skulle + * gentages hvert af de steder, ville mangle det ene sted, ingen tænkte på. + * + * Kun udadgående. Programmets egne ruter går gennem SvelteKit og skal blive + * ved med at gøre det: en fane mere til ens egen notesside er ikke noget, + * nogen har bedt om. + * + * Er der ikke valgt nogen browser — hvilket er udgangspunktet — bliver + * klikket ikke rørt, og systemet gør, som det plejer. + */ + function onLinkClick(event) { + if (event.defaultPrevented || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (!storedOpener().id) return; + + const link = event.target?.closest?.('a[href]'); + if (!link || link.target === '_self') return; + const href = link.getAttribute('href') ?? ''; + if (!/^https?:\/\//i.test(href)) return; + // Vores egen adresse er ikke et link ud af programmet, uanset hvor den + // står — en fane mere til den side, man allerede er på, er ingen hjælp. + if (new URL(href, location.href).origin === location.origin) return; + + event.preventDefault(); + openInChosenBrowser(href); + } + // Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet, // skubbet over websocket'en eller læst væk — mærket er det samme svar på alle // tre, og en optælling pr. vej ville være tre, der kan blive uenige. @@ -125,7 +157,8 @@ } - + {#if app.loading} +
+ + {t('link.tryHint')} +
+ {/if} + +

{t('account.password')}