Improving UI and sync process
Some checks failed
Tests et déploiement / validate (push) Failing after 4m57s
Tests et déploiement / deploy (push) Has been skipped

This commit is contained in:
2026-10-10 16:51:43 +02:00
parent 5686ee5072
commit d651166c6a
9 changed files with 363 additions and 101 deletions

38
SYNC.md
View File

@@ -8,24 +8,36 @@ Une écriture de journée reste locale et transactionnelle, même sans réseau.
Dans **Données et sauvegardes → Synchronisation et appareils** :
- Préparer le coffre, télécharger `pardis-secours.json`, le conserver à part,
puis confirmer l’activation. Le document donne accès au coffre : il doit être
protégé comme un mot de passe de gestionnaire de secrets.
- Choisir **Créer mon coffre**. L’application prépare alors le coffre et son
document de secours, puis amène directement à l’étape obligatoire de sauvegarde.
Télécharger `pardis-secours.json`, le conserver à part et confirmer l’activation.
Le document donne accès au coffre : il doit être protégé comme un mot de passe
de gestionnaire de secrets.
- Ajouter un appareil depuis ce même écran. L’invitation expire après cinq minutes,
n’accepte qu’un candidat et doit être renouvelée par l’écran visible toutes les
quelques secondes. Quitter ou masquer l’écran l’annule ; en cas de fermeture
brutale, le bail serveur expire sous 25 secondes.
- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec l’appareil
photo du téléphone. Le QR ouvre l’application web ; le navigateur/profil qui
quelques secondes. En quittant l’écran ou en passant à l’appareil photo, les
battements s’arrêtent et le bail serveur expire sous 25 secondes si l’invitation
n’est pas reprise.
- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec la caméra
d’un appareil compatible. Le QR ouvre l’application web ; le navigateur/profil qui
reçoit ce lien doit être celui dans lequel on veut utiliser Pardis. Les copies
installées et les navigateurs peuvent avoir des stockages distincts, notamment
sur iOS ; le code manuel permet d’associer directement la bonne copie.
- Comparer **toute** l’empreinte de huit groupes hexadécimaux sur les deux écrans,
cocher leur égalité et confirmer sur l’appareil existant. Les demandes ne sont
- Le nouvel appareil peut scanner le QR de l’appareil existant. Si l’appareil
existant est un smartphone, il peut aussi scanner le QR de la demande affiché par
le nouvel appareil. Dans les deux cas, la confirmation finale se fait sur
l’appareil existant. Les QR contiennent un lien vers l’écran d’association, que
la caméra native du téléphone peut ouvrir ; les navigateurs avec lecteur caméra
intégré peuvent aussi les lire directement. La saisie manuelle reste disponible.
- Vérifier l’empreinte de cinq groupes hexadécimaux, ou faire vérifier les deux
empreintes par QR, puis confirmer sur l’appareil existant. Les demandes ne sont
ni surveillées ni affichées en dehors de l’écran dédié.
- Les données déjà présentes sur le nouvel appareil sont conservées. Les journées
distinctes sont réunies et les dates communes divergentes deviennent des conflits.
Les réglages déjà enregistrés peuvent eux aussi nécessiter un choix.
- Après confirmation, choisir de conserver tout l’état du coffre, tout l’état de
l’appareil courant, ou d’ouvrir la fusion détaillée. Le premier choix remplace
les journées locales par celles du coffre. Le second fait de l’état courant la
nouvelle version partagée. La fusion détaillée réunit les dates distinctes et
permet de départager ensuite les dates ou réglages divergents. Les anciennes
révisions chiffrées restent dans l’historique du coffre.
Les automatismes ont un responsable. Un autre appareil propose discrètement de
prendre le rôle ; ce transfert exige le réseau et un numéro de rôle encore actuel.
@@ -94,7 +106,7 @@ la validation et l’installation transactionnelle de la page reçue.
la confidentialité ; l’authentification de l’association vient de la comparaison
de l’empreinte liant invitation et clés des deux participants. Le QR épingle en
plus l’identité de l’appareil parrain. Le code court ne dérive aucune clé.
- L’empreinte humaine a 128 bits. Elle n’est pas remplacée par un petit code décimal
- L’empreinte humaine a 80 bits. Elle n’est pas remplacée par un petit code décimal
qui, sans protocole d’engagement adapté, serait vulnérable à une recherche hors ligne.
- Chaque appareil a sa clé de signature et sa clé de réception. Le document de
secours possède une identité distincte et une clé privée de déchiffrement HPKE.

46
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.8.0",
"dependencies": {
"@hpke/core": "^1.9.0",
"@zxing/browser": "^0.2.1",
"dexie": "^4.4.4",
"qrcode": "^1.5.4"
},
@@ -3116,6 +3117,41 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@zxing/browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.23.0"
}
},
"node_modules/@zxing/library": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -6874,6 +6910,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -30,6 +30,7 @@
},
"dependencies": {
"@hpke/core": "^1.9.0",
"@zxing/browser": "^0.2.1",
"dexie": "^4.4.4",
"qrcode": "^1.5.4"
}

View File

@@ -196,7 +196,7 @@ export function createSyncServer({ filename = ':memory:', origin = '', maxBytes
if (req.method === 'DELETE') { run('DELETE FROM invitations WHERE code=?', invite.code); return { ok: true }; }
if (match[2] === 'heartbeat' && req.method === 'POST') {
if (!invite.approved) run('UPDATE invitations SET lease=? WHERE code=?', now() + 25000, invite.code);
return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved };
return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved, expires: invite.expires };
}
if (match[2] === 'approve' && req.method === 'POST') {
if (invite.approved) return { ok: true };

View File

@@ -90,9 +90,6 @@
{#if !ready}
<div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div>
{:else}
{#if syncState.linked && !['sync', 'data'].includes(view)}
<div class="update"><button onclick={() => go('sync')}>{syncState.conflicts.length ? `${syncState.conflicts.length} conflit(s) à résoudre` : syncState.revoked ? 'Appareil révoqué' : !syncState.enabled ? 'Synchronisation en pause' : syncState.pending ? 'Enregistré ici · en attente de synchronisation' : 'Synchronisation'}</button></div>
{/if}
{#if syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'}
<div class="update"><span>Les automatismes sont exécutés sur un autre appareil.</span><button disabled={syncState.busy} onclick={takeRole}>Utiliser cet appareil pour les automatismes</button><button onclick={() => { roleDismissed = true; }}>Plus tard</button></div>
{#if roleError}<p role="status">{roleError}</p>{/if}

View File

@@ -1,7 +1,7 @@
import { db } from '../db.js';
import { canonical, identity, publicIdentity, randomSecret, requestProof, b64, bytes, encrypt, decrypt, seal, open, sign, verify, objectId, fingerprint, pairingTranscript } from './crypto.js';
import { revisionHeader, validateValue } from './model.js';
import { captureChanges, detach, getConfig, receiveVersions, saveConfig, tables } from './store.js';
import { captureChanges, detach, finishInitialChoice, getConfig, receiveVersions, saveConfig, snapshotLocalValues, tables } from './store.js';
import { syncState } from './state.svelte.js';
const API = '/api/sync';
@@ -80,13 +80,16 @@ export async function activateVault() {
await db.transaction('rw', db.syncState, async () => { await saveConfig(linked); await db.syncState.delete('setup'); });
await synchronize();
}
async function attach(config) {
async function attach(config, initialChoice = '') {
await db.transaction('rw', tables(db), async () => {
if (await getConfig()) throw new Error('Cet appareil est déjà lié.');
const local = initialChoice === 'local' ? await snapshotLocalValues() : null;
for (const t of [db.syncObjects, db.syncVersions, db.syncOutbox, db.syncConflicts]) await t.clear();
await db.syncState.delete('cursor'); await db.syncState.delete('server'); await db.syncState.delete('lastSync');
await saveConfig(config);
await captureChanges(); // Existing local dates become independent versions, never silently replaced.
if (initialChoice === 'remote') { await db.days.clear(); await db.entries.clear(); }
await saveConfig(initialChoice ? { ...config, initialChoice } : config);
if (initialChoice === 'local') await db.syncState.put({ key: 'initial-local', value: local });
if (!initialChoice) await captureChanges(); // Manual merge keeps local dates as independent versions.
await db.syncState.delete('joining'); await db.syncState.delete('setup'); await db.syncState.delete('recovery-reset');
});
}
@@ -130,6 +133,18 @@ export async function cancelInvitation() {
const current = invitation; invitation = null;
if (current) await api(`/invitations/${current.code}`, { method: 'DELETE', body: {}, device: current.device }).catch(() => {});
}
export async function resumeInvitation(code) {
const config = await getConfig();
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
if (!config?.enabled || config.revoked || !/^[A-Z2-9]{8}$/.test(normalized)) throw new Error('Invitation invalide sur cet appareil.');
const previous = invitation;
invitation = { code: normalized, expires: 0, sponsor: publicIdentity(config.device), device: config.device };
try {
const result = await pollInvitation(normalized);
invitation.expires = result.expires;
return { invite: { ...invitation, url: '' }, ...result };
} catch (error) { invitation = previous; throw error; }
}
export async function pollInvitation(code) {
if (invitation?.code !== code || document.visibilityState === 'hidden') throw new Error('Écran d’association fermé.');
const result = await api(`/invitations/${code}/heartbeat`, { method: 'POST', body: {}, device: invitation.device });
@@ -164,14 +179,22 @@ export async function approveInvitation(code, applicant, name) {
export async function finishJoining() {
const pending = (await db.syncState.get('joining'))?.value;
if (!pending?.vault) throw new Error('Aucune association en cours.');
if (pending.approved) return true;
const result = await api(`/invitations/${pending.code}/result`, { device: pending.device, who: `pair:${pending.code}` });
if (result.waiting) return false;
if (result.vault !== pending.vault) throw new Error('Coffre inattendu.');
const ring = validateRing(await open(pending.device.wrapPrivate, result.envelope, wrapContext(result.vault, pending.device.id)));
await attach({ vault: result.vault, device: pending.device, enabled: true, owner: '', ring });
await synchronize();
await db.syncState.put({ key: 'joining', value: { ...pending, ring, approved: true } });
return true;
}
export async function chooseJoiningStrategy(strategy) {
if (!['remote', 'local', 'manual'].includes(strategy)) throw new Error('Choix de fusion invalide.');
const pending = (await db.syncState.get('joining'))?.value;
if (!pending?.approved || !pending.ring) throw new Error('Attendez la confirmation de l’appareil d’origine.');
const config = { vault: pending.vault, device: pending.device, enabled: true, owner: '', ring: pending.ring };
await attach(config, strategy === 'manual' ? '' : strategy);
await synchronize();
}
async function fetchState(config) {
const state = await api('/state', { device: config.device });
if (state.vault !== config.vault) throw new Error('Coffre inattendu.');
@@ -229,6 +252,7 @@ async function syncOnce() {
more = page.more;
if (more && !records.length) throw new Error('Réponse de synchronisation incomplète.');
}
if (await finishInitialChoice()) await syncOnce();
await db.syncState.put({ key: 'lastSync', value: Date.now() });
} catch (error) {
syncState.error = error.message;

View File

@@ -65,9 +65,9 @@ export async function objectId(secret, key) {
}
export async function fingerprint(value) {
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(canonical(value))));
// 128 bits, compared in full: short decimal SAS without a commitment phase
// 80 bits, compared in full: short decimal SAS without a commitment phase
// would let an active relay search for a colliding key offline.
return [...hash.slice(0, 16)].map(n => n.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join(' ');
return [...hash.slice(0, 10)].map(n => n.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join(' ');
}
export const pairingTranscript = (invite, applicant) => ({ protocol: 'pardis-pair-v1', code: invite.code, sponsor: invite.sponsor, applicant: publicIdentity(applicant) });
export async function requestProof(privateKey, method, path, body, who) {

View File

@@ -5,14 +5,14 @@ import { dayValue, headsOf, same, settingKeys, validateValue } from './model.js'
export const tables = database => [database.days, database.entries, database.settings, database.syncState, database.syncObjects, database.syncVersions, database.syncOutbox, database.syncConflicts];
export async function getConfig(database = db) { return (await database.syncState.get('config'))?.value ?? null; }
export async function saveConfig(value, database = db) { await database.syncState.put({ key: 'config', value }); }
async function values(database) {
async function values(database, { completeSettings = false } = {}) {
const [days, entries, settings] = await Promise.all([database.days.toArray(), database.entries.toArray(), database.settings.get('app')]);
const result = new Map(days.map(day => [`day:${day.date}`, dayValue(day, entries)]));
// A joining device with no saved preferences must not overwrite existing rules.
if (settings) for (const key of settingKeys) result.set(`setting:${key}`, settings.value[key] ?? DEFAULT_SETTINGS[key]);
if (settings || completeSettings) for (const key of settingKeys) result.set(`setting:${key}`, settings?.value[key] ?? DEFAULT_SETTINGS[key]);
return result;
}
async function localRevision(database, config, key, value, parents) {
export async function localRevision(database, config, key, value, parents) {
const version = { id: crypto.randomUUID(), key, value, parents, device: config.device.id, modifiedAt: Date.now() };
await database.syncVersions.add(version);
await database.syncOutbox.add(version);
@@ -24,7 +24,9 @@ async function localRevision(database, config, key, value, parents) {
export async function captureChanges(database = db) {
return database.transaction('rw', tables(database), async () => {
const config = await getConfig(database);
if (!config?.enabled) return;
// Initial-choice linking must download the remote graph before making the
// selected local or remote state authoritative.
if (!config?.enabled || config.initialChoice) return;
const current = await values(database);
const previous = await database.syncObjects.toArray();
const byKey = new Map(previous.map(o => [o.key, o]));
@@ -38,7 +40,7 @@ export async function captureChanges(database = db) {
await database.syncState.put({ key: 'dirty', value: false });
});
}
async function install(database, key, value) {
export async function install(database, key, value) {
validateValue(key, value);
if (key.startsWith('setting:')) {
const rec = await database.settings.get('app');
@@ -106,7 +108,7 @@ export async function receiveVersions(records, cursor, vault, database = db) {
}
await database.syncVersions.add(record);
}
await reconcileObjects(database);
if (!config.initialChoice) await reconcileObjects(database);
await database.syncState.put({ key: 'cursor', value: Math.max(cursor, (await database.syncState.get('cursor'))?.value ?? 0) });
});
}
@@ -125,6 +127,54 @@ export async function resolveConflict(key, chosenId, expectedHeads, database = d
await reconcileObjects(database);
});
}
export async function finishInitialChoice(database = db) {
return database.transaction('rw', tables(database), async () => {
const config = await getConfig(database);
if (!['local', 'remote'].includes(config?.initialChoice)) return false;
if (config.initialChoice === 'remote') {
await database.days.clear();
await database.entries.clear();
const settings = await database.settings.get('app');
if (settings) await database.settings.put({ key: 'app', value: { ...DEFAULT_SETTINGS, theme: settings.value.theme ?? DEFAULT_SETTINGS.theme } });
await reconcileObjects(database);
delete config.initialChoice;
await saveConfig(config, database);
return false;
}
const original = new Map((await database.syncState.get('initial-local'))?.value ?? []);
const grouped = new Map();
for (const version of await database.syncVersions.toArray()) {
if (!grouped.has(version.key)) grouped.set(version.key, []);
grouped.get(version.key).push(version);
}
const keys = new Set([...grouped.keys(), ...original.keys()]);
// Clear remote active days before restoring the one (if any) on this device.
const ordered = [...keys].sort((a, b) => Number(original.get(a)?.day?.status === 'active') - Number(original.get(b)?.day?.status === 'active'));
for (const key of ordered) {
const versions = grouped.get(key) ?? [];
const heads = headsOf(versions);
const value = original.has(key) ? original.get(key) : null;
if (!versions.length && value === null) continue;
if (!await install(database, key, value)) {
await database.syncConflicts.put({ key, versions: heads, localValue: value, reason: 'Une autre journée est déjà en cours sur cet appareil.' });
continue;
}
// Revisions are needed when this choice overrides a remote head, or to
// join identical but independently-created heads into one graph branch.
if (heads.length === 0 || heads.length > 1 || heads.some(v => !same(v.value, value))) {
await localRevision(database, config, key, value, heads.map(v => v.id));
} else await database.syncObjects.put({ key, value, localHeads: heads.map(v => v.id) });
await database.syncConflicts.delete(key);
}
delete config.initialChoice;
await saveConfig(config, database);
await database.syncState.delete('initial-local');
return true;
});
}
export async function snapshotLocalValues(database = db) {
return [...await values(database, { completeSettings: true })];
}
export async function canAutomate(database = db) {
const config = await getConfig(database);
if (!config) return true;

View File

@@ -1,28 +1,33 @@
<script>
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import QRCode from 'qrcode';
import { go } from '../lib/router.svelte.js';
import { dayState, refresh } from '../lib/day.svelte.js';
import { loadSettings, WEEKDAY_LABELS } from '../lib/settings.svelte.js';
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
import { syncState } from '../lib/sync/state.svelte.js';
import { prepareVault, activateVault, prepareRecoveryReset, commitRecoveryReset, recoverVault, startInvitation, cancelInvitation, pollInvitation, joinInvitation, approveInvitation, finishJoining, synchronize, setSyncEnabled, revokeDevice, disconnect, deleteVault, loadSyncStatus, takeAutomationRole } from '../lib/sync/client.js';
import { prepareVault, activateVault, prepareRecoveryReset, commitRecoveryReset, recoverVault, startInvitation, resumeInvitation, cancelInvitation, pollInvitation, joinInvitation, approveInvitation, finishJoining, chooseJoiningStrategy, synchronize, setSyncEnabled, revokeDevice, disconnect, deleteVault, loadSyncStatus, takeAutomationRole } from '../lib/sync/client.js';
import { resolveConflict } from '../lib/sync/store.js';
const query = new URLSearchParams(location.hash.split('?')[1] ?? '');
let name = $state('Mon appareil');
let code = $state(query.get('invite') ?? '');
let pin = $state(query.get('pin') ?? '');
let error = $state('');
let busy = $state(false);
let recovery = $state('');
let recoveryReset = $state(false);
let savedRecovery = $state(false);
let recoveryInput = $state('');
let recoveryPanel = $state(null);
let creationFeedback = $state('');
let invite = $state(null);
let qr = $state('');
let joiningQr = $state('');
let applicant = $state(null);
let comparison = $state('');
let compared = $state(false);
let scannedFingerprint = $state('');
let applicantName = $state('Nouvel appareil');
let joining = $state(null);
let message = $state('');
@@ -30,6 +35,11 @@
let target = $state('');
let deleteText = $state('');
let erase = $state(false);
let showConflicts = $state(false);
let scanning = $state(false);
let scanPurpose = $state('');
let scanVideo = $state(null);
let scanControls = null;
let alive = true;
let polling = false;
@@ -37,7 +47,7 @@
if (busy || dayState.importing) return;
busy = true; error = ''; message = '';
try { await work(); await loadSyncStatus(); await loadSettings(); await refresh(); }
catch (cause) { error = cause.message; }
catch (cause) { error = cause.message; if (creationFeedback.startsWith('Création du coffre')) creationFeedback = ''; }
finally { busy = false; }
}
function download(text, filename) {
@@ -47,11 +57,84 @@
setTimeout(() => URL.revokeObjectURL(url), 60000);
}
async function newInvitation() {
invite = await startInvitation(); applicant = null; comparison = ''; compared = false;
invite = await startInvitation(); applicant = null; comparison = ''; compared = false; scannedFingerprint = '';
qr = await QRCode.toDataURL(invite.url, { width: 260, margin: 2, errorCorrectionLevel: 'M' });
}
async function makeJoiningQr(request) {
const url = `${location.origin}${location.pathname}#/sync?pairback=${request.code}&verify=${encodeURIComponent(request.fingerprint)}`;
return QRCode.toDataURL(url, { width: 240, margin: 2, errorCorrectionLevel: 'M' });
}
async function createVault() {
await run(async () => {
creationFeedback = 'Création du coffre et préparation du document de secours…';
recovery = await prepareVault(name); recoveryReset = false; savedRecovery = false;
await tick();
recoveryPanel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
recoveryPanel?.focus({ preventScroll: true });
creationFeedback = 'Coffre prêt. Enregistrez le document de secours ci-dessous pour continuer.';
});
}
async function stopScanner() {
scanning = false;
scanControls?.stop();
scanControls = null;
if (scanVideo) scanVideo.srcObject = null;
}
async function handleQr(raw, purpose) {
try {
error = '';
if (purpose === 'join') {
const url = new URL(raw);
const parts = url.hash.replace(/^#\/?/, '').split('?');
const params = new URLSearchParams(parts[1] ?? '');
const scannedCode = params.get('invite') ?? '';
const pairingPin = params.get('pin') ?? '';
if (url.origin !== location.origin || parts[0] !== 'sync' || !/^[A-Z2-9]{8}$/.test(scannedCode) || !pairingPin) throw new Error('Ce QR code n’est pas une invitation Pardis valide.');
code = scannedCode;
await run(async () => {
joining = await joinInvitation(scannedCode, name, pairingPin);
joiningQr = await makeJoiningQr(joining);
await poll();
});
} else {
let payload;
try {
const url = new URL(raw);
const parts = url.hash.replace(/^#\/?/, '').split('?');
const params = new URLSearchParams(parts[1] ?? '');
if (url.origin === location.origin && parts[0] === 'sync') payload = { code: params.get('pairback'), fingerprint: params.get('verify') };
} catch { /* Accept an earlier compact QR payload during a rollout. */ }
if (!payload) try { payload = JSON.parse(raw); } catch { throw new Error('Ce QR code de demande est illisible.'); }
if (payload?.code !== invite?.code || payload?.fingerprint !== comparison || !applicant) throw new Error('Le QR code ne correspond pas à la demande affichée.');
scannedFingerprint = payload.fingerprint;
compared = true;
message = 'Empreintes vérifiées par QR code. Confirmez l’autorisation sur cet écran.';
}
} catch (cause) {
error = cause.message ?? 'Impossible de lire ce QR code.';
}
}
async function scanQr(purpose) {
try {
error = ''; scanPurpose = purpose; scanning = true;
await tick();
const { BrowserQRCodeReader } = await import('@zxing/browser');
const reader = new BrowserQRCodeReader();
scanControls = await reader.decodeFromVideoDevice(undefined, scanVideo, result => {
if (result && scanning) {
const raw = result.getText();
void stopScanner().then(() => handleQr(raw, purpose));
}
});
if (!scanning) await stopScanner();
} catch (cause) {
await stopScanner();
error = cause.message ?? 'Impossible d’ouvrir la caméra. Utilisez le code manuel.';
}
}
async function stopInvitation() {
invite = null; applicant = null; comparison = ''; compared = false; qr = '';
scannedFingerprint = '';
await cancelInvitation();
}
async function poll() {
@@ -64,24 +147,41 @@
if (alive && invite?.code === current) {
if (comparison !== result.fingerprint) compared = false;
applicant = result.applicant; comparison = result.fingerprint;
if (scannedFingerprint) compared = !!result.fingerprint && result.fingerprint === scannedFingerprint;
}
}
if (joining && await finishJoining()) {
joining = null; message = 'Appareil lié. Les journées distinctes sont réunies ; les versions concurrentes sont conservées ci-dessous.';
await loadSettings(); await refresh();
if (joining && !joining.approved && await finishJoining()) {
joining = { ...joining, approved: true };
message = 'Appareil confirmé. Choisissez maintenant quelles données conserver.';
}
} catch (cause) {
if (alive) { error = cause.message; await stopInvitation(); joining = null; }
} finally { polling = false; }
}
async function resumePairback() {
const params = new URLSearchParams(location.hash.split('?')[1] ?? '');
const pairback = params.get('pairback');
if (params.get('invite')) { code = params.get('invite'); pin = params.get('pin') ?? ''; }
if (!pairback) return;
if (pairback === invite?.code) {
scannedFingerprint = params.get('verify') ?? '';
compared = !!comparison && comparison === scannedFingerprint;
return;
}
await run(async () => {
const resumed = await resumeInvitation(pairback);
invite = resumed.invite; applicant = resumed.applicant; comparison = resumed.fingerprint;
scannedFingerprint = params.get('verify') ?? '';
compared = !!comparison && comparison === scannedFingerprint;
});
}
onMount(() => {
void loadSyncStatus();
void resumePairback();
const hash = () => { void resumePairback(); };
window.addEventListener('hashchange', hash);
const timer = setInterval(poll, 3000);
const hide = () => { if (document.visibilityState === 'hidden' && invite) void stopInvitation(); };
document.addEventListener('visibilitychange', hide);
const leave = () => { void stopInvitation(); };
window.addEventListener('pagehide', leave);
return () => { alive = false; clearInterval(timer); document.removeEventListener('visibilitychange', hide); window.removeEventListener('pagehide', leave); void cancelInvitation(); };
return () => { alive = false; clearInterval(timer); window.removeEventListener('hashchange', hash); void stopScanner(); };
});
const settingLabels = {
workdays: 'Jours travaillés', nonWorkingDates: 'Jours non travaillés',
@@ -104,43 +204,90 @@
</script>
<fieldset class="screen" disabled={busy || dayState.importing}>
<h1>Synchronisation et appareils</h1>
<button onclick={() => go('data')}>Retour aux données</button>
<h1>{showConflicts ? 'Fusion manuelle des données' : 'Synchronisation et appareils'}</h1>
<button onclick={() => showConflicts ? (showConflicts = false) : go('data')}>{showConflicts ? 'Retour aux appareils' : 'Retour aux données'}</button>
<p>Vos saisies sont toujours enregistrées sur cet appareil. La synchronisation facultative partage vos journées et règles avec vos appareils autorisés, sous forme chiffrée.</p>
{#if error}<p class="notice" role="alert">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
{#if busy}<p role="status">Opération en cours…</p>{/if}
{#if !syncState.linked}
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
<section class="card stack">
<h2>Créer mon coffre</h2>
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
<button onclick={() => run(async () => { recovery = await prepareVault(name); recoveryReset = false; savedRecovery = false; })}>Préparer la synchronisation</button>
</section>
<section class="card stack">
<h2>Lier à un appareil existant</h2>
<p>Sur l’appareil déjà lié, ouvrez cet écran puis « Ajouter un appareil ». Saisissez son code ici ou scannez son QR code avec l’appareil photo de votre téléphone.</p>
<p>Les journées déjà présentes ici seront conservées et fusionnées. Les dates communes et les règles différentes pourront nécessiter un choix.</p>
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
<button disabled={!!joining} onclick={() => run(async () => { joining = await joinInvitation(code, name, query.get('pin') ?? ''); await poll(); })}>Demander la liaison</button>
{#if joining}
<p>Comparez toute cette empreinte avec celle affichée sur l’appareil d’origine, puis confirmez sur celui-ci :</p>
<code class="fingerprint">{joining.fingerprint}</code>
<p role="status">En attente de confirmation sur l’autre appareil…</p>
<button onclick={() => { joining = null; }}>Arrêter l’attente</button>
{/if}
</section>
<section class="card stack">
<h2>Récupérer mon coffre</h2>
<p>Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.</p>
<label>Document de secours<input type="file" accept="application/json,.json" onchange={event => run(async () => {
const file = event.currentTarget.files?.[0];
if (file) { if (file.size > 50000) throw new Error('Document trop volumineux.'); recoveryInput = await file.text(); }
})} /></label>
<label>Ou coller le document<textarea bind:value={recoveryInput} rows="3" spellcheck="false" autocomplete="off"></textarea></label>
<p>Les données présentes ici seront conservées. La récupération retire les anciens appareils et renouvelle les clés ; vous pourrez les associer à nouveau.</p>
<button disabled={!recoveryInput} onclick={() => run(async () => { await recoverVault(recoveryInput, name); recoveryInput = ''; message = 'Accès récupéré. Les anciens appareils ont été retirés.'; })}>Récupérer l’accès</button>
{#if joining}
<section class="card stack">
<h2>{joining.approved ? 'Choisir les données à conserver' : 'Demande envoyée'}</h2>
{#if joining.approved}
<p>Choisissez une option. La fusion détaillée permet ensuite de départager chaque date ou réglage.</p>
<p>Remplacer les données de cet appareil par celles du coffre existant. L’historique chiffré reste conservé.</p>
<button onclick={() => run(async () => { await chooseJoiningStrategy('remote'); joining = null; message = 'Les données du coffre sont conservées sur cet appareil.'; })}>Tout conserver du coffre existant</button>
<p>Remplacer l’état partagé actuel par les données de cet appareil. Les autres appareils recevront ce nouvel état ; les anciennes révisions chiffrées restent conservées.</p>
<button onclick={() => run(async () => { await chooseJoiningStrategy('local'); joining = null; message = 'Les données de cet appareil remplacent l’état partagé actuel.'; })}>Tout conserver sur cet appareil</button>
<p>Réunir les données des deux appareils et résoudre séparément les différences.</p>
<button onclick={() => run(async () => { await chooseJoiningStrategy('manual'); joining = null; await loadSyncStatus(); showConflicts = syncState.conflicts.length > 0; message = showConflicts ? 'Choisissez les données à garder pour chaque conflit.' : 'Les données des deux appareils ont été réunies.'; })}>Fusion manuelle détaillée…</button>
{:else}
<p>Vérifiez cette empreinte sur l’appareil d’origine. Celui-ci doit confirmer la demande avant de continuer.</p>
<code class="fingerprint">{joining.fingerprint}</code>
{#if joiningQr}<img src={joiningQr} width="240" height="240" alt="QR code de cette demande, à scanner depuis l’appareil d’origine" />{/if}
<p role="status">En attente de confirmation sur l’autre appareil…</p>
<button onclick={() => scanQr('join')}>Scanner le QR de l’appareil d’origine</button>
{#if scanning && scanPurpose === 'join'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
<button onclick={async () => { await stopScanner(); joining = null; joiningQr = ''; }}>Arrêter l’attente</button>
{/if}
</section>
{:else}
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
<section class="card stack">
<h2>Créer mon coffre</h2>
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
<button onclick={createVault}>Créer mon coffre</button>
{#if creationFeedback}<p role="status">{creationFeedback}</p>{/if}
</section>
<section class="card stack">
<h2>Lier à un appareil existant</h2>
<p>Ouvrez cet écran sur l’appareil déjà lié. Vous pouvez saisir son code ici, ou scanner un QR code avec un smartphone, quel que soit l’appareil qui le présente.</p>
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
<button disabled={!!joining} onclick={() => run(async () => {
joining = await joinInvitation(code, name, pin);
joiningQr = await makeJoiningQr(joining);
await poll();
})}>Demander la liaison</button>
<button onclick={() => scanQr('join')}>Scanner le QR de l’appareil d’origine</button>
{#if scanning && scanPurpose === 'join'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
</section>
<section class="card stack">
<h2>Récupérer mon coffre</h2>
<p>Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.</p>
<label>Document de secours<input type="file" accept="application/json,.json" onchange={event => run(async () => {
const file = event.currentTarget.files?.[0];
if (file) { if (file.size > 50000) throw new Error('Document trop volumineux.'); recoveryInput = await file.text(); }
})} /></label>
<label>Ou coller le document<textarea bind:value={recoveryInput} rows="3" spellcheck="false" autocomplete="off"></textarea></label>
<p>Les données présentes ici seront conservées. La récupération retire les anciens appareils et renouvelle les clés ; vous pourrez les associer à nouveau.</p>
<button disabled={!recoveryInput} onclick={() => run(async () => { await recoverVault(recoveryInput, name); recoveryInput = ''; message = 'Accès récupéré. Les anciens appareils ont été retirés.'; })}>Récupérer l’accès</button>
</section>
{/if}
{:else if showConflicts}
<section class="manual-screen stack">
<h2>Versions à départager ({syncState.conflicts.length})</h2>
{#if !syncState.conflicts.length}<p>Aucun conflit à résoudre.</p>{/if}
{#each syncState.conflicts as conflict}
<article class="conflict stack">
<h3>{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}</h3>
<p>{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.</p>
{#each conflict.versions as version}
<div class="version stack">
<strong>{deviceName(version.device)} · {when(version.modifiedAt)}</strong>
{#if version.value === null}<p>Suppression</p>
{:else if conflict.key.startsWith('day:')}
<p>{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}</p>
<ul>{#each version.value.entries as entry}<li>{new Date(entry.startTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})}–{entry.endTs === null ? 'en cours' : new Date(entry.endTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} · {entry.task} {entry.client ?? ''}</li>{/each}</ul>
{:else}<p>{settingValue(conflict.key, version.value)}</p>{/if}
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, version.id, conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver cette version</button>
</div>
{/each}
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, 'local', conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver l’état actuel de cet appareil</button>
<p class="dim">Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.</p>
</article>
{/each}
</section>
{:else}
<section class="card stack">
@@ -148,6 +295,7 @@
<p>{syncState.revoked ? 'Cet appareil a été révoqué.' : syncState.enabled ? syncState.busy ? 'Synchronisation en cours…' : syncState.pending ? 'Enregistré sur cet appareil · en attente d’envoi' : 'Modifications envoyées' : 'Synchronisation en pause'}</p>
<p>Dernière synchronisation : {when(syncState.lastSync)}.</p>
{#if syncState.error}<p role="status">{syncState.error} Vos données restent enregistrées ici.</p>{/if}
<button disabled={!syncState.conflicts.length} onclick={() => { showConflicts = true; }}>Fusion manuelle détaillée ({syncState.conflicts.length})</button>
{#if !syncState.revoked}
<button disabled={!syncState.enabled || syncState.busy} onclick={() => run(synchronize)}>Synchroniser maintenant</button>
<button onclick={() => run(() => setSyncEnabled(!syncState.enabled))}>{syncState.enabled ? 'Mettre en pause' : 'Reprendre la synchronisation'}</button>
@@ -171,8 +319,10 @@
<strong class="code">{invite.code.slice(0, 4)} {invite.code.slice(4)}</strong>
{#if qr}<img src={qr} width="260" height="260" alt="QR code d’association à scanner sur le nouvel appareil" />{/if}
{#if applicant}
<p>Une demande est arrivée. Comparez l’empreinte complète sur les deux écrans :</p>
<p>Une demande est arrivée. Vérifiez l’empreinte sur les deux écrans ou scannez le QR affiché par le nouvel appareil :</p>
<code class="fingerprint">{comparison}</code>
<button onclick={() => scanQr('approve')}>Scanner le QR du nouvel appareil</button>
{#if scanning && scanPurpose === 'approve'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
<label>Nom du nouvel appareil<input bind:value={applicantName} maxlength="80" /></label>
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
@@ -181,29 +331,6 @@
{/if}
</section>
<section class="card stack">
<h2>Versions à départager ({syncState.conflicts.length})</h2>
{#if !syncState.conflicts.length}<p>Aucun conflit.</p>{/if}
{#each syncState.conflicts as conflict}
<article class="stack conflict">
<h3>{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}</h3>
<p>{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.</p>
{#each conflict.versions as version}
<div class="version stack">
<strong>{deviceName(version.device)} · {when(version.modifiedAt)}</strong>
{#if version.value === null}<p>Suppression</p>
{:else if conflict.key.startsWith('day:')}
<p>{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}</p>
<ul>{#each version.value.entries as entry}<li>{new Date(entry.startTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})}–{entry.endTs === null ? 'en cours' : new Date(entry.endTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} · {entry.task} {entry.client ?? ''}</li>{/each}</ul>
{:else}<p>{settingValue(conflict.key, version.value)}</p>{/if}
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, version.id, conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver cette version</button>
</div>
{/each}
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, 'local', conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver l’état actuel de cet appareil</button>
<p class="dim">Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.</p>
</article>
{/each}
</section>
<section class="card stack">
<h2>Secours et déconnexion</h2>
<p>Le document de secours n’est pas conservé sur les appareils liés. Vous pouvez en préparer un nouveau ; après validation, le précédent ne permettra plus de récupérer le coffre.</p>
@@ -214,13 +341,13 @@
{/if}
{#if recovery}
<section class="card stack">
<section class="card stack" bind:this={recoveryPanel} tabindex="-1">
<h2>Conserver le document de secours</h2>
<p>Enregistrez-le ailleurs que sur cet appareil, par exemple dans un gestionnaire de mots de passe. Toute personne qui le possède peut accéder au coffre.</p>
<button onclick={() => download(recovery, 'pardis-secours.json')}>Télécharger le document de secours</button>
<details><summary>Afficher le document pour le copier</summary><textarea readonly value={recovery} rows="6" aria-label="Contenu du document de secours" spellcheck="false"></textarea></details>
<label class="check"><input type="checkbox" bind:checked={savedRecovery} /> J’ai conservé une copie du document à part</label>
<button disabled={!savedRecovery} onclick={() => run(async () => { if (recoveryReset) await commitRecoveryReset(); else await activateVault(); recovery = ''; message = 'Synchronisation configurée.'; })}>{recoveryReset ? 'Valider le nouveau secours' : 'Activer la synchronisation'}</button>
<button disabled={!savedRecovery} onclick={() => run(async () => { if (recoveryReset) await commitRecoveryReset(); else await activateVault(); recovery = ''; creationFeedback = ''; message = recoveryReset ? 'Document de secours renouvelé.' : 'Coffre créé et synchronisation activée.'; })}>{recoveryReset ? 'Valider le nouveau secours' : 'Créer le coffre et activer la synchronisation'}</button>
<button onclick={() => { recovery = ''; }}>Fermer</button>
</section>
{/if}
@@ -254,7 +381,12 @@
textarea { width: 100%; box-sizing: border-box; font: inherit; }
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
img { max-width: 100%; height: auto; align-self: center; }
.device, .version, .conflict { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
.device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
.manual-screen { padding: 0 .2rem; }
.conflict { border-top: var(--border) solid var(--line); padding: .8rem 0; }
.version { border-left: .2rem solid var(--line); padding: .2rem .8rem; }
video { width: min(100%, 28rem); max-height: 55vh; align-self: center; background: #111; }
[tabindex="-1"]:focus { outline: .15rem solid var(--line); outline-offset: .2rem; }
.notice { color: var(--danger-fg); background: var(--danger); padding: .6rem; }
ul { padding-left: 1.2rem; overflow-wrap: anywhere; }
</style>