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 @@
}
-