409 lines
20 KiB
JavaScript
409 lines
20 KiB
JavaScript
/* WHY : Firefox Release ne fournit pas de barrière sessionstore ni de preuve
|
||
* de nouveauté pour Ctrl+N. L'automatisme ci-dessous est un compromis observé,
|
||
* pas un CAS : une restauration sans signal avec extData très tardives reste
|
||
* indiscernable. Les fenêtres ambiguës observées restent protégées.
|
||
* CONTRACT : baseline uniquement sur install et absence de checkpoint attestée
|
||
* par le host avant chaque écriture. Capture native
|
||
* sans mutation d’identité ni de tab data.
|
||
* INVARIANT : aucune réattribution, aucun contenu/titre/URL lu, une opération
|
||
* active, une seule tentative d'écriture par fenêtre et instance.
|
||
*/
|
||
"use strict";
|
||
const KEY = "labfy.session.window_uuid";
|
||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||
const POLL_MS = 250;
|
||
const OBSERVE_MS = 2000;
|
||
const STARTUP_MS = 30000;
|
||
const API_MS = 2000;
|
||
const started = performance.now();
|
||
const RESTORE_GUARD_MS = 5000;
|
||
let phase = "BOOT";
|
||
let baseline = null;
|
||
let installed = false;
|
||
let bootstrapClosed = false;
|
||
const protectedBaseline = new Set();
|
||
let changedAt = started;
|
||
let restoreUntil = started;
|
||
let revision = 0;
|
||
let lastError = null;
|
||
const candidates = new Set();
|
||
const quarantine = new Set();
|
||
const attempted = new Set();
|
||
const known = new Map();
|
||
// Preuves locales, jamais persistées ni déduites d'un titre/contenu. Le host
|
||
// distingue les app_ids exacts du catalogue avant toute lecture/mint UUID.
|
||
const classifications = new Map();
|
||
const classificationErrors = new Map();
|
||
const classificationConflicts = new Set();
|
||
let operationWindows = null;
|
||
let halted = false;
|
||
let port = null;
|
||
let pending = null;
|
||
let active = false;
|
||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||
// Les rejets WebExtension ne sont pas nécessairement des objets Error.
|
||
const failureReason = error => error && typeof error.message === "string" ? error.message : "API_REJECTED";
|
||
|
||
// Un appel API bloqué n'est pas annulable. Après timeout, aucune nouvelle
|
||
// opération ordinaire n’est lancée avant redémarrage. Seul un effacement de
|
||
// préfixe après règlement du marquage expiré reste autorisé (voir mark).
|
||
async function api(call) {
|
||
if (halted) throw new Error("API_RESTART_REQUIRED");
|
||
let timer;
|
||
try {
|
||
return await Promise.race([Promise.resolve().then(call), new Promise((_, reject) => {
|
||
timer = setTimeout(() => { halted = true; reject(new Error("API_TIMEOUT")); }, API_MS);
|
||
})]);
|
||
} finally { clearTimeout(timer); }
|
||
}
|
||
function connect() {
|
||
if (port) return port;
|
||
const current = browser.runtime.connectNative("org.labfy.session_v2_identity");
|
||
port = current;
|
||
current.onMessage.addListener(answer => {
|
||
if (port !== current || !answer || typeof answer !== "object") return;
|
||
if (answer.event === "capture") return cycle();
|
||
if (pending && pending.port === current) pending.resolve(answer);
|
||
});
|
||
current.onDisconnect.addListener(() => {
|
||
if (port !== current) return;
|
||
port = null;
|
||
if (pending && pending.port === current) pending.reject(new Error("PROVIDER_UNAVAILABLE"));
|
||
});
|
||
return current;
|
||
}
|
||
async function request(message) {
|
||
if (pending) throw new Error("REQUEST_BUSY");
|
||
const current = connect();
|
||
return await new Promise((resolve, reject) => {
|
||
let timer;
|
||
// Chaque callback ne possède que sa demande et son port : une expiration
|
||
// ancienne ne peut ni rejeter ni déconnecter une nouvelle connexion.
|
||
const finish = (callback, value) => {
|
||
if (pending !== operation) return;
|
||
pending = null;
|
||
clearTimeout(timer);
|
||
callback(value);
|
||
};
|
||
const operation = {port: current, resolve: v => finish(resolve, v), reject: e => finish(reject, e)};
|
||
pending = operation;
|
||
timer = setTimeout(() => {
|
||
if (pending !== operation) return;
|
||
operation.reject(new Error("PROVIDER_TIMEOUT"));
|
||
if (port === current) port = null;
|
||
current.disconnect();
|
||
}, 5000);
|
||
try { current.postMessage(message); }
|
||
catch (e) {
|
||
operation.reject(e);
|
||
if (port === current) port = null;
|
||
current.disconnect();
|
||
}
|
||
});
|
||
}
|
||
async function rawIds() {
|
||
const windows = await api(() => browser.windows.getAll({populate: false, windowTypes: ["normal"]}));
|
||
if (windows.length > 256) throw new Error("WINDOW_LIMIT");
|
||
const result = windows.map(w => w.id).sort((a, b) => a - b);
|
||
if (result.some(id => !Number.isSafeInteger(id) || id < 0) || new Set(result).size !== result.length) throw new Error("WINDOW_IDS");
|
||
return result;
|
||
}
|
||
const sameIds = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
|
||
async function classifyWindow(id) {
|
||
if (classificationConflicts.has(id)) throw new Error("WINDOW_CLASS_CHANGED");
|
||
const runtime_token = crypto.randomUUID().replaceAll("-", "");
|
||
let answer;
|
||
try {
|
||
await mark(id, runtime_token);
|
||
answer = await request({op: "classify-window", runtime_token});
|
||
} finally {
|
||
await api(() => browser.windows.update(id, {titlePreface: ""}));
|
||
}
|
||
if (!answer || Object.keys(answer).sort().join(",") !== "con_id,kind,result,runtime_token"
|
||
|| answer.result !== "OK" || answer.runtime_token !== runtime_token
|
||
|| !["browser", "managed-webapp"].includes(answer.kind)
|
||
|| !Number.isSafeInteger(answer.con_id) || answer.con_id <= 0) {
|
||
throw new Error("WINDOW_CLASS_UNAVAILABLE");
|
||
}
|
||
const proof = {kind: answer.kind, con_id: answer.con_id};
|
||
const prior = classifications.get(id);
|
||
if (prior && (prior.kind !== proof.kind || prior.con_id !== proof.con_id)) {
|
||
classificationConflicts.add(id);
|
||
throw new Error("WINDOW_CLASS_CHANGED");
|
||
}
|
||
return proof;
|
||
}
|
||
async function ids(fresh = false) {
|
||
const current = await rawIds();
|
||
if (operationWindows === null) operationWindows = current;
|
||
if (!sameIds(current, operationWindows)) throw new Error("WINDOW_SET_CHANGED");
|
||
if (new Set([...classifications.keys(), ...classificationErrors.keys(), ...current]).size > 256) throw new Error("WINDOW_LIMIT");
|
||
const eligible = [];
|
||
for (const id of current) {
|
||
if (classificationConflicts.has(id)) throw new Error("WINDOW_CLASS_CHANGED");
|
||
if (!fresh && classificationErrors.has(id)) throw new Error(classificationErrors.get(id));
|
||
if (fresh || !classifications.has(id)) {
|
||
try {
|
||
const proof = await classifyWindow(id);
|
||
if (!sameIds(await rawIds(), current)) throw new Error("WINDOW_SET_CHANGED");
|
||
classifications.set(id, proof);
|
||
classificationErrors.delete(id);
|
||
} catch (error) {
|
||
// Une panne du host ne provoque pas un clignotement en boucle idle.
|
||
// Une capture explicite peut réessayer ; jamais une classe conflictuelle.
|
||
if (failureReason(error) !== "WINDOW_SET_CHANGED") classificationErrors.set(id, failureReason(error));
|
||
throw error;
|
||
}
|
||
}
|
||
if (classifications.get(id).kind === "browser") eligible.push(id);
|
||
else {
|
||
// Une webapp gérée n'entre dans aucun lot UUID, même sans extData.
|
||
candidates.delete(id); quarantine.delete(id); protectedBaseline.delete(id);
|
||
}
|
||
}
|
||
if (!sameIds(await rawIds(), current)) throw new Error("WINDOW_SET_CHANGED");
|
||
return eligible;
|
||
}
|
||
function validate(rows, complete = true) {
|
||
const seen = new Set();
|
||
for (const {uuid} of rows) {
|
||
if (uuid === undefined && !complete) continue;
|
||
if (typeof uuid !== "string" || !UUID.test(uuid)) throw new Error("INVALID_UUID");
|
||
if (seen.has(uuid)) throw new Error("UUID_COLLISION");
|
||
seen.add(uuid);
|
||
}
|
||
}
|
||
async function sample(expected) {
|
||
const current = await ids();
|
||
if (current.length !== expected.length || current.some((id, i) => id !== expected[i])) throw new Error("WINDOW_SET_CHANGED");
|
||
// Attendre aussi les lectures sœurs en échec : Promise.all rejetterait trop
|
||
// tôt et autoriserait une nouvelle capture avec des appels encore en vol.
|
||
const reads = await Promise.allSettled(current.map(async id => ({id, uuid: await api(() => browser.sessions.getWindowValue(id, KEY))})));
|
||
const failure = reads.find(read => read.status === "rejected");
|
||
const rows = reads.filter(read => read.status === "fulfilled").map(read => read.value);
|
||
let identityChanged = false;
|
||
for (const row of rows) {
|
||
// Même un lot rejeté contient des preuves : ne pas oublier une valeur
|
||
// reçue parce qu'une lecture sœur rejette ou qu'un autre UUID est invalide.
|
||
// Une valeur invalide observée n'autorise pas non plus un mint ultérieur.
|
||
if (known.has(row.id) && known.get(row.id) !== row.uuid) identityChanged = true;
|
||
if (row.uuid !== undefined && !known.has(row.id)) known.set(row.id, row.uuid);
|
||
}
|
||
if (failure) throw failure.reason;
|
||
validate(rows, false);
|
||
if (identityChanged) throw new Error("IDENTITY_CHANGED");
|
||
return rows;
|
||
}
|
||
async function observe() {
|
||
const expected = await ids();
|
||
const end = performance.now() + OBSERVE_MS;
|
||
while (true) {
|
||
const rows = await sample(expected);
|
||
// Une identité connue est utilisable immédiatement. L'absence attend toute
|
||
// la période bornée, sans jamais devenir une preuve de nouveauté.
|
||
if (rows.every(row => row.uuid !== undefined) || performance.now() >= end) return rows;
|
||
await sleep(POLL_MS);
|
||
}
|
||
}
|
||
// WHY : un update expiré peut appliquer son préfixe après le finally.
|
||
// CONTRACT : après règlement de cet appel seulement, un unique effacement de
|
||
// secours ; jamais de relance de l'API bloquée ni de nouvelles captures.
|
||
// LIMIT : si Firefox ne règle jamais l'appel (ou l'effacement), aucune garantie
|
||
// de nettoyage temporelle n'est possible. Redémarrage requis, nettoyage start.
|
||
let prefixCleanupPending = false;
|
||
async function mark(id, token) {
|
||
return api(() => {
|
||
const operation = Promise.resolve().then(() => browser.windows.update(id, {titlePreface: `[LABFY:${token}] `}));
|
||
const settled = () => {
|
||
if (!halted) return;
|
||
prefixCleanupPending = true;
|
||
void Promise.resolve().then(() => browser.windows.update(id, {titlePreface: ""}))
|
||
.then(() => { prefixCleanupPending = false; }, () => {});
|
||
};
|
||
void operation.then(settled, settled);
|
||
return operation;
|
||
});
|
||
}
|
||
async function publish(rows) {
|
||
validate(rows);
|
||
// Valider le lot complet avant le premier message, puis chaque UUID juste
|
||
// avant usage. Ce protocole n'est pas un CAS avec le sessionstore Firefox.
|
||
// Revalider aussi les exclusions après l'attente d'extData ; une conversion
|
||
// de classe ou un con_id remplacé ne devient pas une nouvelle autorisation.
|
||
await ids(true);
|
||
const fresh = await sample(rows.map(row => row.id));
|
||
if (fresh.some((row, i) => row.uuid !== rows[i].uuid)) throw new Error("IDENTITY_CHANGED");
|
||
for (const row of rows) {
|
||
if (await api(() => browser.sessions.getWindowValue(row.id, KEY)) !== row.uuid) throw new Error("IDENTITY_CHANGED");
|
||
const runtime_token = crypto.randomUUID().replaceAll("-", "");
|
||
try {
|
||
await mark(row.id, runtime_token);
|
||
const answer = await request({op: "map-window", uuid: row.uuid, runtime_token});
|
||
if (answer.result !== "OK" || answer.uuid !== row.uuid || !Number.isSafeInteger(answer.con_id)
|
||
|| answer.con_id !== classifications.get(row.id).con_id) throw new Error("PROVIDER_ERROR");
|
||
} finally {
|
||
await api(() => browser.windows.update(row.id, {titlePreface: ""}));
|
||
}
|
||
}
|
||
}
|
||
async function cycle() {
|
||
// Coalescence sans queue ; la capture ne peut voir un mint partiellement
|
||
// vérifié. Les erreurs restent explicites pour le diagnostic, sans nouveau
|
||
// message natif non supporté par le provider strict existant.
|
||
if (active) return {result: "BUSY"};
|
||
active = true;
|
||
operationWindows = null;
|
||
try {
|
||
await ids(true);
|
||
await publish(await observe());
|
||
lastError = null;
|
||
return {result: "OK"};
|
||
} catch (error) {
|
||
lastError = failureReason(error);
|
||
return {result: "REFUSED", reason: lastError};
|
||
} finally { operationWindows = null; active = false; }
|
||
}
|
||
function changed() { changedAt = performance.now(); ++revision; }
|
||
// CONTRACT : avant la résolution du premier getAll, la provenance est ambiguë.
|
||
// Après cette photographie, onCreated ouvre une candidature, y compris en BOOT.
|
||
// Ce choix produit n'est PAS une preuve contre une restauration sans signal.
|
||
browser.windows.onCreated.addListener(window => {
|
||
if (window.type !== undefined && window.type !== "normal") return;
|
||
changed();
|
||
if (!Number.isSafeInteger(window.id) || window.id < 0 || candidates.size + quarantine.size >= 256) {
|
||
phase = "PROTECTED";
|
||
return;
|
||
}
|
||
if (window.sessionId) protectedBaseline.add(window.id);
|
||
if (baseline !== null && !window.sessionId && !quarantine.has(window.id)) candidates.add(window.id);
|
||
else { quarantine.add(window.id); protectedBaseline.add(window.id); }
|
||
});
|
||
browser.windows.onRemoved.addListener(id => {
|
||
changed();
|
||
candidates.delete(id); quarantine.delete(id); attempted.delete(id); known.delete(id); protectedBaseline.delete(id);
|
||
classifications.delete(id); classificationErrors.delete(id); classificationConflicts.delete(id);
|
||
if (baseline) baseline.delete(id);
|
||
});
|
||
browser.sessions.onChanged.addListener(() => {
|
||
// Liste fermée modifiée (y compris fermeture d'onglet), sans provenance.
|
||
// Invalider l'observation, attendre le calme, conserver les candidatures.
|
||
changed();
|
||
restoreUntil = performance.now() + RESTORE_GUARD_MS;
|
||
});
|
||
browser.runtime.onInstalled.addListener(details => {
|
||
// WHY : install couvre aussi une réinstallation avec checkpoint externe.
|
||
// CONTRACT : événement conservé avant/après start, aucun autre reason admis.
|
||
if (details.reason === "install" && !installed) { installed = true; changed(); }
|
||
});
|
||
browser.runtime.onMessage.addListener((message, sender) => {
|
||
if (sender.id !== browser.runtime.id || !message || message.op !== "identity-status") return undefined;
|
||
return Promise.resolve({phase, result: halted || phase === "PROTECTED" || quarantine.size || lastError ? "REFUSED" : active || candidates.size ? "WAIT" : phase === "READY" ? "READY" : "WAIT",
|
||
reason: lastError, prefixCleanupPending, quarantined: quarantine.size, pending: candidates.size});
|
||
});
|
||
async function autoStep() {
|
||
if (active || halted || phase === "PROTECTED") return;
|
||
active = true;
|
||
operationWindows = null;
|
||
let targets = [];
|
||
try {
|
||
const expected = await ids();
|
||
if (baseline === null) baseline = new Set(expected);
|
||
const epoch = revision;
|
||
const rows = await sample(expected);
|
||
for (const row of rows) {
|
||
if (row.uuid !== undefined) { candidates.delete(row.id); quarantine.delete(row.id); }
|
||
else if (!candidates.has(row.id)) quarantine.add(row.id);
|
||
}
|
||
const now = performance.now();
|
||
if (phase === "BOOT") {
|
||
if (now - started < STARTUP_MS || now - changedAt < OBSERVE_MS || now < restoreUntil) return;
|
||
// Une ancienne absence empêche la capture exacte, pas les futurs Ctrl+N.
|
||
phase = "READY";
|
||
}
|
||
if (now < restoreUntil) return;
|
||
// Une seule tentative de lot baseline par instance. Refus, timeout ou
|
||
// activité invalident ce droit ; la reconnexion ne le ressuscite jamais.
|
||
const bootstrap = new Set(installed && !bootstrapClosed ? rows.filter(row =>
|
||
baseline.has(row.id) && row.uuid === undefined && !protectedBaseline.has(row.id)
|
||
&& !known.has(row.id) && !attempted.has(row.id)).map(row => row.id) : []);
|
||
if (installed) bootstrapClosed = true;
|
||
targets = rows.filter(row => row.uuid === undefined && (candidates.has(row.id) || bootstrap.has(row.id)) && !attempted.has(row.id)).map(row => row.id);
|
||
if (!targets.length) { lastError = quarantine.size ? "AMBIGUOUS_WINDOW" : null; return; }
|
||
// Observations répétées du lot complet ; un changement d'ensemble, une
|
||
// erreur ou un signal session durant l'attente révoque cette tentative.
|
||
const observed = targets.length ? await observe() : rows;
|
||
if (epoch !== revision) throw new Error("WINDOW_ACTIVITY");
|
||
const fresh = await sample(expected);
|
||
if (epoch !== revision) throw new Error("WINDOW_ACTIVITY");
|
||
let bootstrapAllowed = true;
|
||
for (const id of targets) {
|
||
if (bootstrap.has(id)) {
|
||
// WHY : réduire la course avec un checkpoint créé durant l'observation
|
||
// ou l'écriture sœur. Jamais d'autorité mémorisée entre deux fenêtres.
|
||
// CONTRACT : protocole exact, booléen strict, fail closed sans retry.
|
||
if (bootstrapAllowed) {
|
||
try {
|
||
const answer = await request({op: "bootstrap-eligibility"});
|
||
bootstrapAllowed = Object.keys(answer).length === 2 && answer.result === "OK" && answer.baseline_enrollment_allowed === true;
|
||
} catch { bootstrapAllowed = false; }
|
||
}
|
||
if (!bootstrapAllowed) continue;
|
||
}
|
||
if (fresh.find(row => row.id === id).uuid !== undefined) { candidates.delete(id); continue; }
|
||
if ((quarantine.has(id) && !bootstrap.has(id)) || attempted.has(id)) throw new Error("QUARANTINED");
|
||
if (observed.some(row => row.id === id && row.uuid !== undefined)) throw new Error("IDENTITY_CHANGED");
|
||
// Dernière lecture intégrale avant CHAQUE écriture. Les cibles sœurs
|
||
// absentes restent isolées ; une ancienne ambiguïté ne devient pas cible.
|
||
await ids(true);
|
||
const before = await sample(expected);
|
||
if (epoch !== revision) throw new Error("WINDOW_ACTIVITY");
|
||
if (before.find(row => row.id === id).uuid !== undefined) continue;
|
||
const value = crypto.randomUUID();
|
||
validate([...before.filter(row => row.id !== id), {id, uuid: value}], false);
|
||
await api(() => {
|
||
if (epoch !== revision) throw new Error("WINDOW_ACTIVITY");
|
||
// INVARIANT : marquer au point d'appel, jamais pour une lecture avortée.
|
||
// Rejet/timeout peut cacher un effet ; aucune seconde écriture ensuite.
|
||
attempted.add(id);
|
||
known.set(id, value);
|
||
return browser.sessions.setWindowValue(id, KEY, value);
|
||
});
|
||
const verified = await sample(expected);
|
||
if (epoch !== revision) throw new Error("WINDOW_ACTIVITY");
|
||
if (verified.find(row => row.id === id).uuid !== value) throw new Error("WRITE_UNCERTAIN");
|
||
candidates.delete(id); quarantine.delete(id);
|
||
}
|
||
lastError = quarantine.size ? "AMBIGUOUS_WINDOW" : null;
|
||
} catch (error) {
|
||
lastError = failureReason(error);
|
||
// CONTRACT : une activité avant écriture annule ce passage. Seuls les
|
||
// candidats onCreated seront relus après calme ; aucune écriture incertaine
|
||
// n'est retentée. Les erreurs de lecture/identité restent conservatrices.
|
||
// Une erreur de lecture baseline interdit également une autorisation future.
|
||
if (installed) bootstrapClosed = true;
|
||
const transient = ["WINDOW_ACTIVITY", "WINDOW_SET_CHANGED"].includes(lastError);
|
||
for (const id of transient ? targets : [...candidates]) {
|
||
if (!transient || attempted.has(id)) { candidates.delete(id); quarantine.add(id); }
|
||
}
|
||
} finally { operationWindows = null; active = false; }
|
||
}
|
||
async function start() {
|
||
active = true;
|
||
// La baseline est capturée avant les nettoyages ; les événements déjà reçus
|
||
// restent protégés indépendamment de leur présence dans cette photographie.
|
||
try { baseline = new Set(await rawIds()); }
|
||
catch (error) { phase = "PROTECTED"; lastError = failureReason(error); }
|
||
try {
|
||
for (const id of await rawIds()) {
|
||
try { await api(() => browser.windows.update(id, {titlePreface: ""})); } catch {}
|
||
}
|
||
} catch { /* Une erreur d'énumération ne doit pas supprimer la reconnexion. */ }
|
||
try { connect(); } catch {}
|
||
// Reconnexion seulement ; aucune requête de corrélation en idle.
|
||
setInterval(() => { if (!port) { try { connect(); } catch {} } }, 1000);
|
||
active = false;
|
||
}
|
||
// Un seul passage périodique, sans queue ni promesse empilée pendant un appel
|
||
// lent. Toutes les lectures/écritures et captures partagent le verrou active.
|
||
setInterval(() => { void autoStep(); }, 1000);
|
||
void start().catch(() => {});
|