Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb689e4c2 | ||
|
|
e931ddc313 |
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user