Compare commits

..
Author SHA1 Message Date
kristianwindandClaude Opus 5 5eb689e4c2 feat(links): vælg hvilken browser et link åbner i
Ø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 <[email protected]>
2026-09-09 08:41:21 +02:00
Kristian Wind e931ddc313 Merge pull request #70 from kristianwind/ny-version-siger-til
CI / OpenAPI is valid (push) Successful in 11s
CI / End-to-end (push) Successful in 9m50s
CI / Docker image builds (push) Successful in 1m54s
CI / Go (push) Failing after 14m45s
Release / Build and publish image (push) Failing after 5s
feat(opdatering): en ny version siger til, i stedet for at vente på at blive fundet
2026-09-08 17:05:52 +02:00
6 changed files with 356 additions and 1 deletions
+53
View File
@@ -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([]);
});
+172
View File
@@ -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;
}
+17
View File
@@ -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',
+17
View File
@@ -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',
+34 -1
View File
@@ -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 @@
}
</script>
<svelte:window {onkeydown} />
<svelte:window
onclick={onLinkClick} {onkeydown} />
{#if app.loading}
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
+63
View File
@@ -1,6 +1,12 @@
<script>
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import {
OPENERS,
stored as storedOpener,
save as saveOpenerChoice,
open as openLink
} from '$lib/linkopen.js';
import { app, theme, THEMES, look, LOOKS, menuSize, textSize, SIZES } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
import { supported, register } from '$lib/passkey.js';
@@ -8,6 +14,20 @@
// --- profile ---------------------------------------------------------------
// Hvilken browser links åbner i. På apparatet, ikke på kontoen: `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.
let opener = $state(storedOpener());
function saveOpener(choice) {
opener = choice;
saveOpenerChoice(choice);
}
function chooseOpener(id) {
saveOpener({ id, template: id === 'custom' ? opener.template : '' });
}
let name = $state('');
let timezone = $state('');
let locale = $state('da');
@@ -401,6 +421,49 @@
</div>
</section>
<section class="panel">
<header>
<h2>{t('link.title')}</h2>
<p class="hint">{t('link.hint')}</p>
</header>
<div class="field">
<label for="opener">{t('link.opener')}</label>
<select id="opener" value={opener.id} onchange={(e) => chooseOpener(e.currentTarget.value)}>
{#each OPENERS as option (option.id)}
<option value={option.id}>{t(option.label)}</option>
{/each}
<option value="custom">{t('link.openerCustom')}</option>
</select>
</div>
{#if opener.id === 'custom'}
<div class="field">
<label for="openertpl">{t('link.template')}</label>
<input
id="openertpl"
class="mono"
value={opener.template}
placeholder="firefox://open-url?url={'{encoded}'}"
onchange={(e) => saveOpener({ id: 'custom', template: e.currentTarget.value })}
/>
<p class="hint">{t('link.templateHint')}</p>
</div>
{/if}
{#if opener.id}
<!-- Et forsøg frem for et løfte. Der er ingen måde at spørge, om et skema
findes — man kan kun prøve, og så er det bedre at prøve på en adresse,
man selv har valgt, end at opdage det på et rigtigt link. -->
<div class="row">
<button class="secondary" onclick={() => openLink('https://example.dk')}>
{t('link.try')}
</button>
<span class="hint">{t('link.tryHint')}</span>
</div>
{/if}
</section>
<section class="panel">
<header>
<h2>{t('account.password')}</h2>