gestion_table_tournante_libre/electron/fichiers.js
Mathieu Benoit 0bc48fa1ae [FIX] storage, session: strict UTF-8, lost journal, lock, exclusions
The whole-iteration review found ways to lose or corrupt data. A state
saved in another encoding opened with its accents turned to U+FFFD and was
then overwritten; decoding is now strict, the state opens read-only, and a
gesture from its secours first moves it to the trash. A journal decodes line
by line, so one bad byte drops only the lines after it. A vanished journal
is rewritten whole, never appended to. Deleting refuses an event another
session holds; switching to writing rereads the disk; an import that
changes exclu says which reservations it suspends.
Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %.

--- FR ---

[FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions

La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données.
Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en
U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture
seule, et un geste depuis son secours le range d'abord à la corbeille. Le
journal se décode ligne par ligne : un octet fautif n'écarte que la suite.
Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse
un événement qu'une autre séance tient ; passer en écriture relit le disque ;
un import qui change exclu dit quelles réservations il suspend.
Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %.

Assisted-by: Claude Opus 5.5
2026-10-07 01:20:42 -04:00

870 lines
34 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le système de fichiers de la coquille (§ 8.6, § 8.8, § 13.1) : les dix-sept
// primitives de l'interface de src/stockage/systeme_fichiers.js, exécutées
// sous Node par le processus principal, et le service qui les offre à la
// page, un canal IPC par primitive, gtt:fichiers:<primitive>.
//
// La page ne désigne jamais un fichier par un chemin absolu : elle donne une
// racine, que ce module connaît par son seul identifiant — portable,
// documents, ou choisi-N qu'a rendu le dialogue de choix d'un dossier —, et
// un chemin relatif à elle. Un chemin que refuse la règle des segments, une
// racine inconnue, ou une cible dont le chemin réel sort de celui de sa
// racine — un lien symbolique en chemin — lèvent CHEMIN_REFUSE avant toute
// écriture.
//
// Le paquet n'emporte de src/ que la page construite (§ 14.15) : ce module ne
// charge que des modules de Node. Ce qu'il partage avec src/stockage — la
// règle des segments, le suffixe .ecriture, les codes d'échec — s'y
// recopie, et test/fichiers_electron.test.js éprouve que les deux
// définitions s'accordent.
//
// Un échec que l'interface nomme lève RefusFichier, porteur du code et des
// détails d'une ErreurStockage. Une erreur ne traverse pas l'IPC : repondre
// fait de chaque appel une réponse { ok: true, valeur } ou { ok: false, code,
// details }, que src/stockage/fichiers_electron.js retransforme en
// ErreurStockage. Une faute du code, ou un argument de forme fausse, lève une
// autre erreur, dont Electron ne transmet que le message.
//
// Deux codes s'ajoutent à ceux de l'interface : LECTURE {chemin, dossier,
// cause}, une lecture que le système refuse autrement que sur un absent — un
// dossier illisible n'est pas un dossier vide —, ou un fichier dont les
// octets ne sont pas de l'UTF-8, cause UTF8_INVALIDE, ses détails portant
// alors aussi octets, un Uint8Array que l'IPC transporte ; et NON_DISPONIBLE
// {cause}, l'explorateur du système qui ne s'ouvre pas.
import { execFile } from 'node:child_process';
import { realpath as realpathRappel } from 'node:fs';
import { lstat, mkdir, open, readdir, readFile, rename, stat, unlink } from 'node:fs/promises';
import { hostname } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
/** Les primitives de l'interface, dans l'ordre où elle les déclare. */
export const PRIMITIVES = Object.freeze([
'emplacements',
'racines',
'choisirDossier',
'sonder',
'typeSupport',
'lireTexte',
'ecrireAtomique',
'ajouterLigne',
'lister',
'creerDossier',
'deplacer',
'supprimer',
'verrouiller',
'deverrouiller',
'ouvrirDansExplorateur',
'choisirFichierAImporter',
'enregistrerSous',
]);
/** Le canal d'une primitive est ce préfixe suivi de son nom. */
export const PREFIXE_CANAL = 'gtt:fichiers:';
/** Ajouté au nom d'un fichier pendant son écriture atomique (§ 8.8). */
export const SUFFIXE_ECRITURE = '.ecriture';
/** Le témoin de la sonde d'écriture, à la racine sondée (§ 8.6). */
export const TEMOIN = '.gtt-temoin';
// Ce que la sonde écrit, puis attend à la relecture : un texte en UTF-8 hors
// de l'ASCII, qu'un support qui altère les octets ne rend pas tel quel.
const TEXTE_TEMOIN = 'T\u{E9}moin d\u{2019}\u{E9}criture de Gestion table tournante Libre.\n';
/**
* Le renommage de l'écriture atomique se réessaie, après son premier essai,
* REESSAIS_RENOMMAGE fois sur une cause passagère : un antivirus ou un agent
* de synchronisation qui tient la cible ouverte sous Windows (EPERM, EBUSY,
* EACCES). La pause entre deux essais part de PAUSE_INITIALE_MS et double
* jusqu'à PAUSE_MAX_MS : 3,75 s d'attente au plus avant de renoncer.
*/
export const REESSAIS_RENOMMAGE = 10;
export const PAUSE_INITIALE_MS = 50;
export const PAUSE_MAX_MS = 500;
const CAUSES_PASSAGERES = new Set(['EPERM', 'EBUSY', 'EACCES']);
/** La commande qui lit le type d'un lecteur sous Windows est bornée à 5 s. */
export const DELAI_SUPPORT_MS = 5000;
// Lit le type du lecteur de la racine que reçoit la variable GTT_RACINE : le
// chemin passe par l'environnement, jamais par le texte de la commande, où
// une apostrophe le ferait lire autrement.
const COMMANDE_LECTEUR = '[System.IO.DriveInfo]::new($env:GTT_RACINE).DriveType';
// La règle des segments d'exigerCheminRelatif (src/stockage/systeme_fichiers.js),
// recopiée : un segment ne finit ni par un point ni par une espace — « . »,
// « .. » et ce que Windows y ramène —, et ne porte ni barre oblique inverse,
// ni deux-points, ni caractère nul. Un segment vide vient d'un chemin absolu,
// d'un séparateur doublé ou final.
const SEGMENT_ADMIS = /^[^\\:\u{0}]*[^\\:\u{0}. ]$/u;
// Codes d'une cible absente : elle-même, un dossier parent qui est un
// fichier ; pour une lecture, un dossier là où l'on attend un fichier.
const ABSENCE = new Set(['ENOENT', 'ENOTDIR']);
const ABSENCE_EN_LECTURE = new Set(['ENOENT', 'ENOTDIR', 'EISDIR']);
// Décodage strict d'un fichier lu : un octet hors UTF-8 lève au lieu de se
// lire U+FFFD, et la marque d'ordre d'octets reste dans le texte.
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
// Le remplaçant JSON d'un détail : un Uint8Array devient « N octets », comme
// dans le message d'une ErreurStockage.
const sansOctets = (cle, valeur) => (valeur instanceof Uint8Array ? `${valeur.length} octets` : valeur);
/**
* Un échec que l'interface nomme : le code et les détails d'une
* ErreurStockage (src/stockage/types.js en donne la table). Le message écrit
* les détails en JSON, un détail en octets par son seul nombre d'octets.
*/
export class RefusFichier extends Error {
/**
* @param {string} code
* @param {Object} details
*/
constructor(code, details) {
super(`${code} ${JSON.stringify(details, sansOctets)}`);
this.code = code;
this.details = details;
}
}
RefusFichier.prototype.name = 'RefusFichier';
/**
* Les segments d'un chemin relatif à une racine : [] pour '', la racine
* elle-même. Le contrôle ne lit que le texte.
*
* @param {unknown} chemin
* @returns {string[]}
* @throws {RefusFichier} CHEMIN_REFUSE {chemin} : autre chose qu'une chaîne,
* ou un segment que la règle refuse
*/
export function segmentsAdmis(chemin) {
if (typeof chemin !== 'string') throw new RefusFichier('CHEMIN_REFUSE', { chemin });
if (chemin === '') return [];
const segments = chemin.split('/');
if (!segments.every((segment) => SEGMENT_ADMIS.test(segment))) {
throw new RefusFichier('CHEMIN_REFUSE', { chemin });
}
return segments;
}
/**
* Les emplacements et les racines du poste, lus une fois, au démarrage
* (§ 8.6). Le dossier de l'exécutable est celui que publie le lanceur
* portable dans PORTABLE_EXECUTABLE_DIR — absente ou vide, aucun —, jamais
* celui du processus, qui tourne dans le dossier temporaire où l'archive
* s'extrait. Les données applicatives sont AppData, LOCALAPPDATA quand le
* poste le définit, et le dossier temporaire. Les chemins se composent à la
* manière de la plateforme donnée.
*
* @param {Object} poste
* @param {Object<string, string|undefined>} poste.env l'environnement du processus
* @param {string} poste.plateforme process.platform
* @param {(nom: 'appData'|'temp'|'documents') => string} poste.cheminSysteme
* app.getPath d'Electron
* @param {string} poste.nomProduit le dossier du produit dans les Documents
* @returns {{emplacements: Object, racines: {portable: string|null, documents: string}}}
* emplacements a la forme Emplacements de src/stockage/systeme_fichiers.js
*/
export function decrireSysteme({ env, plateforme, cheminSysteme, nomProduit }) {
const chemins = plateforme === 'win32' ? path.win32 : path.posix;
const executable = env.PORTABLE_EXECUTABLE_DIR || null;
const donneesApplicatives = [cheminSysteme('appData'), env.LOCALAPPDATA, cheminSysteme('temp')].filter(
(dossier) => typeof dossier === 'string' && dossier !== '',
);
return {
emplacements: {
executable,
donneesApplicatives,
insensibleCasse: plateforme === 'win32' || plateforme === 'darwin',
separateur: chemins.sep,
},
racines: {
portable: executable === null ? null : chemins.join(executable, 'data'),
documents: chemins.join(cheminSysteme('documents'), nomProduit),
},
};
}
// Les fonctions de node:fs/promises dont les primitives se servent. realpath
// est celle de node:fs, qui résout les liens segment par segment ; celle de
// node:fs/promises demande au système le chemin final d'un fichier ouvert,
// ce que des lecteurs de Windows refusent.
const FS_NODE = Object.freeze({
lstat,
stat,
realpath: promisify(realpathRappel),
readFile,
open,
rename,
unlink,
mkdir,
readdir,
});
// Lance une commande sans shell, ces variables ajoutées à l'environnement du
// processus, tuée au-delà du délai ; rend sa sortie standard, ou rejette.
function lancerCommande(commande, arguments_, { variables, delai }) {
return new Promise((resoudre, rejeter) => {
execFile(
commande,
arguments_,
{ env: { ...process.env, ...variables }, timeout: delai, windowsHide: true, encoding: 'utf8' },
(erreur, sortie) => (erreur ? rejeter(erreur) : resoudre(sortie)),
);
});
}
const pause = (ms) => new Promise((resoudre) => setTimeout(resoudre, ms));
// Vrai pour une erreur du système, telle que node:fs la lève : un code et
// l'appel système qui a échoué. Les autres sont des fautes du code.
const estErreurSysteme = (erreur) =>
erreur instanceof Error && typeof erreur.code === 'string' && typeof erreur.syscall === 'string';
const estObjet = (valeur) => valeur !== null && typeof valeur === 'object';
function exigerTexte(valeur, quoi) {
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
}
function exigerLigne(ligne) {
exigerTexte(ligne, 'la ligne');
if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne');
}
function exigerSeance(seance) {
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
}
// Le contenu d'un verrou, {seance, pid, hote, depuis}, ou null quand le texte
// n'en est pas un : JSON invalide, champ manquant ou de type faux, pid qui
// ne désigne pas un processus — 0 et les négatifs désignent des groupes.
function analyserVerrou(texte) {
let contenu;
try {
contenu = JSON.parse(texte);
} catch {
return null;
}
const valide =
estObjet(contenu) &&
typeof contenu.seance === 'string' &&
Number.isSafeInteger(contenu.pid) &&
contenu.pid > 0 &&
typeof contenu.hote === 'string' &&
typeof contenu.depuis === 'string';
return valide ? contenu : null;
}
// Un instant sous la forme de l'horloge de l'application,
// AAAA-MM-JJTHH:MM:SS±HH:MM, à l'heure locale du poste et avec son décalage.
function horodatage(instant) {
const deux = (n) => String(n).padStart(2, '0');
const decalage = -instant.getTimezoneOffset();
const ecart = Math.abs(decalage);
return (
`${instant.getFullYear()}-${deux(instant.getMonth() + 1)}-${deux(instant.getDate())}` +
`T${deux(instant.getHours())}:${deux(instant.getMinutes())}:${deux(instant.getSeconds())}` +
`${decalage < 0 ? '-' : '+'}${deux(Math.floor(ecart / 60))}:${deux(ecart % 60)}`
);
}
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
/**
* Vrai quand le processus de ce pid tourne sur ce poste : il répond au
* signal 0, ou refuse de le recevoir (EPERM) parce qu'il tourne sous un
* autre utilisateur.
*
* @param {number} pid
* @param {(pid: number) => void} [signaler] process.kill(pid, 0)
* @returns {boolean}
*/
export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0)) {
try {
signaler(pid);
return true;
} catch (erreur) {
return erreur?.code === 'EPERM';
}
}
/**
* Les primitives du système de fichiers, sous Node. Chacune prend les
* arguments de l'interface, tels que l'IPC les a copiés, et rend sa valeur ou
* lève : RefusFichier pour un échec que l'interface nomme, TypeError pour un
* argument de forme fausse.
*
* @param {Object} reglages
* @param {Object} reglages.emplacements ceux de decrireSysteme
* @param {{portable: string|null, documents: string}} reglages.racines
* chemins absolus des racines du poste
* @param {Object} reglages.dialog le module dialog d'Electron
* @param {Object} reglages.shell le module shell d'Electron
* @param {() => Object|null} [reglages.fenetre] la fenêtre parente des dialogues
* Le reste remplace le système, pour l'épreuve :
* @param {Object} [reglages.fs] lstat, stat, realpath, readFile, open,
* rename, unlink, mkdir, readdir, comme ceux de node:fs/promises
* @param {Object} [reglages.chemins] node:path, ou l'une de ses variantes
* @param {string} [reglages.plateforme] process.platform
* @param {Function} [reglages.lancer] (commande, arguments, {variables,
* delai}) → la sortie standard de la commande
* @param {(ms: number) => Promise<void>} [reglages.attendre]
* @param {(pid: number) => void} [reglages.signaler] celui de processusVivant
* @param {number} [reglages.pid]
* @param {string} [reglages.hote]
* @param {() => Date} [reglages.maintenant]
* @returns {Object<string, Function>} les primitives, par nom
*/
export function creerFichiers({
emplacements,
racines: { portable, documents },
dialog,
shell,
fenetre = () => null,
fs = FS_NODE,
chemins = path,
plateforme = process.platform,
lancer = lancerCommande,
attendre = pause,
signaler,
pid = process.pid,
hote = hostname(),
maintenant = () => new Date(),
}) {
// Chemin absolu de chaque racine connue, par identifiant. Une Map : un
// identifiant reçu de la page ne rencontre aucune propriété héritée.
const bases = new Map([['documents', documents]]);
if (portable !== null) bases.set('portable', portable);
let choisis = 0;
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
// la cible, la racine elle-même pour ''. Seul le texte se lit :
// CHEMIN_REFUSE pour un chemin que la règle des segments refuse ou une
// racine inconnue.
function localiser(racine, chemin) {
const segments = segmentsAdmis(chemin);
const base = estObjet(racine) && typeof racine.id === 'string' ? bases.get(racine.id) : undefined;
if (base === undefined) throw new RefusFichier('CHEMIN_REFUSE', { chemin });
return {
chemin,
segments,
racine: chemins.join(base),
absolu: chemins.join(base, ...segments),
dossier: chemins.join(base, ...segments.slice(0, -1)),
};
}
// Un fichier choisi hors des racines, nommé dans un échec par son nom et
// son dossier.
const horsRacine = (absolu) => ({ chemin: chemins.basename(absolu), dossier: chemins.dirname(absolu) });
const refus = (loc, cause) => new RefusFichier('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause });
// Ce que devient une erreur levée pendant une lecture, ou une écriture :
// un refus déjà nommé passe tel quel, une faute du code aussi ; une erreur
// du système devient ABSENT, LECTURE ou ECRITURE.
function echecLecture(loc, erreur) {
if (!estErreurSysteme(erreur)) return erreur;
if (ABSENCE_EN_LECTURE.has(erreur.code)) return new RefusFichier('ABSENT', { chemin: loc.chemin });
return new RefusFichier('LECTURE', { chemin: loc.chemin, dossier: loc.dossier, cause: erreur.code });
}
const echecEcriture = (loc, erreur) => (estErreurSysteme(erreur) ? refus(loc, erreur.code) : erreur);
// L'état d'un chemin sans suivre son dernier lien, ou null quand il n'existe
// pas ; l'état de ce qu'il désigne, liens suivis, ou null quand rien n'y
// mène — absent, lien sans cible, boucle de liens.
async function etatDe(absolu) {
try {
return await fs.lstat(absolu);
} catch (erreur) {
if (ABSENCE.has(erreur?.code)) return null;
throw erreur;
}
}
async function etatSuivi(absolu) {
try {
return await fs.stat(absolu);
} catch (erreur) {
if (ABSENCE.has(erreur?.code) || erreur?.code === 'ELOOP') return null;
throw erreur;
}
}
// Efface un fichier, ou le lien lui-même ; un absent n'est pas une faute.
async function effacer(absolu) {
try {
await fs.unlink(absolu);
} catch (erreur) {
if (!ABSENCE.has(erreur?.code)) throw erreur;
}
}
// Écrit dans une poignée ouverte, la vide sur le disque et la ferme ; un
// échec la ferme aussi, puis se lève.
async function ecrireDans(poignee, donnees) {
try {
await poignee.writeFile(donnees);
await poignee.sync();
} catch (erreur) {
await poignee.close().catch(() => {});
throw erreur;
}
await poignee.close();
}
// Chemin réel d'un chemin absolu : celui de son plus long préfixe qui
// existe, liens résolus, suivi des segments qui n'existent pas encore et
// qu'aucun lien ne peut donc détourner. Rien n'existe, pas même le
// volume — une clé retirée — : le chemin tel quel. Un lien sans cible, ou
// qui boucle, n'a pas de chemin réel : null.
async function cheminReel(absolu) {
const absents = [];
let existant = absolu;
while ((await etatDe(existant)) === null) {
const parent = chemins.dirname(existant);
if (parent === existant) return absolu;
absents.unshift(chemins.basename(existant));
existant = parent;
}
try {
return chemins.join(await fs.realpath(existant), ...absents);
} catch (erreur) {
if (ABSENCE.has(erreur?.code) || erreur?.code === 'ELOOP') return null;
throw erreur;
}
}
// Vrai quand un chemin est le dossier donné ou se trouve sous lui, sur une
// frontière de segment ; la racine d'un volume, qui finit par le
// séparateur, porte tout le volume.
function estSous(chemin, dossier) {
return chemin === dossier || chemin.startsWith(dossier.endsWith(chemins.sep) ? dossier : `${dossier}${chemins.sep}`);
}
// Lève CHEMIN_REFUSE quand le chemin réel de la cible n'est ni celui de sa
// racine ni sous lui : un lien, en chemin ou visé, ne fait pas sortir. Les
// deux chemins réels se résolvent de la même façon, si bien qu'un lien
// dans le chemin de la racine elle-même ne compte pas.
async function confiner(loc) {
const racineReelle = await cheminReel(loc.racine);
const cibleReelle = await cheminReel(loc.absolu);
if (racineReelle === null || cibleReelle === null || !estSous(cibleReelle, racineReelle)) {
throw new RefusFichier('CHEMIN_REFUSE', { chemin: loc.chemin });
}
}
// Renomme, et réessaie sur une cause passagère, après une pause qui
// double ; la dernière erreur se lève.
async function renommer(de, vers) {
let attente = PAUSE_INITIALE_MS;
for (let essai = 0; ; essai += 1) {
try {
await fs.rename(de, vers);
return;
} catch (erreur) {
if (essai === REESSAIS_RENOMMAGE || !CAUSES_PASSAGERES.has(erreur?.code)) throw erreur;
}
await attendre(attente);
attente = Math.min(2 * attente, PAUSE_MAX_MS);
}
}
// L'écriture atomique d'un chemin absolu (§ 8.8) : <cible>.ecriture,
// effacé s'il reste d'une écriture interrompue — lien compris, que
// l'effacement ne suit pas —, créé en exclusif, écrit, vidé sur le disque,
// fermé, puis renommé par-dessus la cible. La cible n'est jamais ouverte :
// un échec la laisse intacte, et retire le temporaire.
async function remplacer(absolu, octets) {
const temporaire = `${absolu}${SUFFIXE_ECRITURE}`;
await effacer(temporaire);
const poignee = await fs.open(temporaire, 'wx');
try {
await ecrireDans(poignee, octets);
await renommer(temporaire, absolu);
} catch (erreur) {
await effacer(temporaire).catch(() => {});
throw erreur;
}
}
// Ce que rend verrouiller d'un verrou présent, ou null quand il a disparu
// depuis. Un verrou qui ne se lit pas rend seance, depuis et vivant à null.
async function verrouPresent(absolu) {
let texte;
try {
texte = (await fs.readFile(absolu)).toString('utf8');
} catch (erreur) {
if (erreur?.code === 'ENOENT') return null;
throw erreur;
}
const contenu = analyserVerrou(texte);
if (contenu === null) return { pris: false, seance: null, depuis: null, vivant: null };
return {
pris: false,
seance: contenu.seance,
depuis: contenu.depuis,
vivant: contenu.hote === hote ? processusVivant(contenu.pid, signaler) : null,
};
}
// Le type de support du volume qui porte un chemin, sous Windows : le type
// de lecteur que rend DriveInfo, par PowerShell.
async function supportWindows(absolu) {
const sortie = await lancer('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', COMMANDE_LECTEUR], {
variables: { GTT_RACINE: absolu },
delai: DELAI_SUPPORT_MS,
});
const type = String(sortie).trim();
return type === 'Removable' ? 'amovible' : type === 'Fixed' ? 'fixe' : 'inconnu';
}
// Sous Linux : l'attribut removable du disque qui porte le plus proche
// dossier existant du chemin. Le numéro du périphérique se décompose comme
// le fait glibc ; /sys/dev/block/<majeur>:<mineur> mène au périphérique,
// dont une partition porte le fichier partition et laisse removable à son
// disque, le dossier parent. Un périphérique que /sys/dev/block ne décrit
// pas — tmpfs, btrfs, un partage réseau — rejette.
async function supportLinux(absolu) {
let existant = absolu;
while ((await etatSuivi(existant)) === null && chemins.dirname(existant) !== existant) {
existant = chemins.dirname(existant);
}
const { dev } = await fs.stat(existant, { bigint: true });
const majeur = ((dev >> 8n) & 0xfffn) | ((dev >> 32n) & ~0xfffn);
const mineur = (dev & 0xffn) | ((dev >> 12n) & 0xffffff00n);
const peripherique = await fs.realpath(`/sys/dev/block/${majeur}:${mineur}`);
const disque = (await etatDe(chemins.join(peripherique, 'partition'))) === null
? peripherique
: chemins.dirname(peripherique);
const attribut = (await fs.readFile(chemins.join(disque, 'removable'))).toString('utf8').trim();
return attribut === '1' ? 'amovible' : attribut === '0' ? 'fixe' : 'inconnu';
}
return {
async emplacements() {
return { ...emplacements, donneesApplicatives: [...emplacements.donneesApplicatives] };
},
async racines() {
return {
portable: portable === null ? null : { id: 'portable', chemin: portable },
documents: { id: 'documents', chemin: documents },
};
},
// Le dossier choisi devient une racine pour le reste de la séance.
async choisirDossier() {
const { canceled, filePaths } = await dialog.showOpenDialog(fenetre(), {
properties: ['openDirectory', 'createDirectory'],
});
if (canceled || filePaths.length === 0) return null;
choisis += 1;
const id = `choisi-${choisis}`;
bases.set(id, filePaths[0]);
return { id, chemin: filePaths[0] };
},
// Crée la racine et ses parents, efface un témoin resté — ou un lien posé
// sous son nom, que l'effacement ne suit pas —, écrit le témoin en
// exclusif, le vide sur le disque, le relit et l'efface. Un échec rend sa
// cause, et le témoin s'efface.
async sonder(racine) {
const loc = localiser(racine, '');
const temoin = chemins.join(loc.absolu, TEMOIN);
try {
await fs.mkdir(loc.absolu, { recursive: true });
await effacer(temoin);
await ecrireDans(await fs.open(temoin, 'wx'), TEXTE_TEMOIN);
const relu = (await fs.readFile(temoin)).toString('utf8');
await fs.unlink(temoin);
return relu === TEXTE_TEMOIN ? { inscriptible: true, cause: null } : { inscriptible: false, cause: 'RELECTURE' };
} catch (erreur) {
if (!estErreurSysteme(erreur)) throw erreur;
await effacer(temoin).catch(() => {});
return { inscriptible: false, cause: erreur.code };
}
},
// Windows et Linux savent le dire ; ailleurs, et quand la commande ou
// /sys ne répondent pas, inconnu.
async typeSupport(racine) {
const loc = localiser(racine, '');
try {
if (plateforme === 'win32') return await supportWindows(loc.absolu);
if (plateforme === 'linux') return await supportLinux(loc.absolu);
} catch {
// Une commande qui échoue, qui dépasse son délai, ou un périphérique
// que /sys ne décrit pas : le type reste inconnu.
}
return 'inconnu';
},
async lireTexte(racine, chemin) {
const loc = localiser(racine, chemin);
let octets;
try {
await confiner(loc);
octets = await fs.readFile(loc.absolu);
} catch (erreur) {
throw echecLecture(loc, erreur);
}
try {
return UTF8_STRICT.decode(octets);
} catch {
const details = { chemin: loc.chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE' };
throw new RefusFichier('LECTURE', { ...details, octets: new Uint8Array(octets) });
}
},
// Une cible qui est un dossier est refusée avant d'écrire : le
// renommage par-dessus un dossier échouerait, sous Windows après ses
// réessais.
async ecrireAtomique(racine, chemin, texte) {
exigerTexte(texte, 'le texte');
const loc = localiser(racine, chemin);
try {
await confiner(loc);
if ((await etatDe(loc.absolu))?.isDirectory()) throw refus(loc, 'EISDIR');
await remplacer(loc.absolu, Buffer.from(texte, 'utf8'));
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Ouvre en ajout — le fichier absent se crée, son dossier jamais —,
// écrit la ligne et sa fin de ligne, vide sur le disque et ferme.
async ajouterLigne(racine, chemin, ligne) {
exigerLigne(ligne);
const loc = localiser(racine, chemin);
try {
await confiner(loc);
await ecrireDans(await fs.open(loc.absolu, 'a'), `${ligne}\n`);
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Les fichiers et les dossiers, liens suivis ; une entrée qui disparaît
// entre la liste et son état, ou un lien sans cible, ne compte pas.
async lister(racine, dossier) {
const loc = localiser(racine, dossier);
try {
await confiner(loc);
const entrees = [];
for (const nom of await fs.readdir(loc.absolu)) {
const etat = await etatSuivi(chemins.join(loc.absolu, nom));
if (etat?.isFile()) entrees.push({ nom, type: 'fichier', taille: etat.size, modifie: etat.mtimeMs });
else if (etat?.isDirectory()) entrees.push({ nom, type: 'dossier', taille: 0, modifie: etat.mtimeMs });
}
return entrees.sort(parNom);
} catch (erreur) {
throw echecLecture(loc, erreur);
}
},
// '' crée la racine, parents compris. Un autre chemin se crée dossier par
// dossier sous la racine, sans recursive : mkdir lève ENOENT quand son
// parent manque, si bien qu'une racine qui disparaît, même entre deux
// appels, ne renaît pas (§ 8.6). Un dossier présent passe ; un fichier en
// travers lève EEXIST sur le chemin même, ENOTDIR au-dessus.
async creerDossier(racine, chemin) {
const loc = localiser(racine, chemin);
const { segments } = loc;
try {
await confiner(loc);
if (segments.length === 0) await fs.mkdir(loc.absolu, { recursive: true });
for (let rang = 1; rang <= segments.length; rang += 1) {
const dossier = chemins.join(loc.racine, ...segments.slice(0, rang));
try {
await fs.mkdir(dossier);
} catch (erreur) {
if (erreur?.code !== 'EEXIST') throw erreur;
const cause = rang === segments.length ? 'EEXIST' : 'ENOTDIR';
if (!(await etatSuivi(dossier))?.isDirectory()) throw refus(loc, cause);
}
}
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Un fichier, en un seul essai. rename écrase une cible existante sous
// POSIX : sa présence se contrôle d'abord, comme le système compare les
// noms, si bien que sans égard à la casse une autre casse du même nom
// est une cible présente. Un dossier absent sous la cible nomme la
// cible ; un renommage refusé nomme la source.
async deplacer(racine, de, vers) {
const source = localiser(racine, de);
const cible = localiser(racine, vers);
let etat;
try {
await confiner(source);
etat = await etatDe(source.absolu);
} catch (erreur) {
throw echecEcriture(source, erreur);
}
if (etat === null) throw new RefusFichier('ABSENT', { chemin: de });
if (etat.isDirectory()) throw refus(source, 'EISDIR');
try {
await confiner(cible);
if ((await etatDe(cible.absolu)) !== null) throw new RefusFichier('EXISTE', { chemin: vers });
if (!(await etatSuivi(chemins.dirname(cible.absolu)))?.isDirectory()) throw refus(cible, 'ENOENT');
} catch (erreur) {
throw echecEcriture(cible, erreur);
}
try {
await fs.rename(source.absolu, cible.absolu);
} catch (erreur) {
throw echecEcriture(source, erreur);
}
},
async supprimer(racine, chemin) {
const loc = localiser(racine, chemin);
try {
await confiner(loc);
const etat = await etatDe(loc.absolu);
if (etat?.isDirectory()) throw refus(loc, 'EISDIR');
if (etat !== null) await effacer(loc.absolu);
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Création exclusive (wx), qui ne suit aucun lien. Un verrou présent se
// lit ; s'il disparaît entre la création refusée et sa lecture, la prise
// se tente une seconde fois. Un verrou écrit à moitié s'efface.
async verrouiller(racine, chemin, seance) {
exigerSeance(seance);
const loc = localiser(racine, chemin);
try {
await confiner(loc);
const contenu = `${JSON.stringify({ seance, pid, hote, depuis: horodatage(maintenant()) })}\n`;
for (let essai = 0; ; essai += 1) {
let poignee;
try {
poignee = await fs.open(loc.absolu, 'wx');
} catch (erreur) {
if (erreur?.code !== 'EEXIST') throw erreur;
const present = await verrouPresent(loc.absolu);
if (present !== null) return present;
if (essai === 1) throw erreur;
continue;
}
try {
await ecrireDans(poignee, contenu);
} catch (erreur) {
await effacer(loc.absolu).catch(() => {});
throw erreur;
}
return { pris: true };
}
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// N'efface que le verrou que cette séance a pris ; un verrou absent, un
// dossier à sa place ou un verrou illisible restent tels.
async deverrouiller(racine, chemin, seance) {
exigerSeance(seance);
const loc = localiser(racine, chemin);
try {
await confiner(loc);
let texte;
try {
texte = (await fs.readFile(loc.absolu)).toString('utf8');
} catch (erreur) {
if (ABSENCE_EN_LECTURE.has(erreur?.code)) return;
throw erreur;
}
if (analyserVerrou(texte)?.seance === seance) await effacer(loc.absolu);
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison
// de l'échec.
async ouvrirDansExplorateur(racine) {
const loc = localiser(racine, '');
let etat;
try {
etat = await etatSuivi(loc.absolu);
} catch (erreur) {
throw echecLecture(loc, erreur);
}
if (!etat?.isDirectory()) throw new RefusFichier('ABSENT', { chemin: '' });
const raison = await shell.openPath(loc.absolu);
if (raison !== '') throw new RefusFichier('NON_DISPONIBLE', { cause: raison });
},
async choisirFichierAImporter() {
const { canceled, filePaths } = await dialog.showOpenDialog(fenetre(), { properties: ['openFile'] });
if (canceled || filePaths.length === 0) return null;
const [absolu] = filePaths;
try {
return { nom: chemins.basename(absolu), octets: new Uint8Array(await fs.readFile(absolu)) };
} catch (erreur) {
throw echecLecture(horsRacine(absolu), erreur);
}
},
// Le dialogue propose le nom, sans ses dossiers, dans les Documents du
// poste ; le fichier choisi s'écrit par écriture atomique, le dialogue
// ayant déjà fait confirmer le remplacement d'un existant.
async enregistrerSous(nomPropose, octets) {
exigerTexte(nomPropose, 'le nom proposé');
if (!(octets instanceof Uint8Array)) throw new TypeError('les octets ne sont pas un Uint8Array');
const { canceled, filePath } = await dialog.showSaveDialog(fenetre(), {
defaultPath: chemins.join(chemins.dirname(documents), chemins.basename(nomPropose)),
});
if (canceled || !filePath) return null;
try {
await remplacer(filePath, octets);
} catch (erreur) {
throw echecEcriture(horsRacine(filePath), erreur);
}
return filePath;
},
};
}
/**
* La réponse d'un appel de primitive, telle qu'elle traverse l'IPC :
* { ok: true, valeur }, ou { ok: false, code, details } pour un RefusFichier.
* Toute autre erreur se lève.
*
* @param {() => Promise<unknown>} travail
*/
export async function repondre(travail) {
try {
return { ok: true, valeur: await travail() };
} catch (erreur) {
if (erreur instanceof RefusFichier) return { ok: false, code: erreur.code, details: erreur.details };
throw erreur;
}
}
/**
* Offre les primitives à la page : pour chacune, dans l'ordre de PRIMITIVES,
* poser(canal, gestionnaire) — ipcMain.handle dans le processus principal.
* Le gestionnaire reçoit l'évènement d'Electron, puis les arguments de la
* primitive, et rend sa réponse.
*
* @param {Object<string, Function>} fichiers ce que rend creerFichiers
* @param {(canal: string, gestionnaire: Function) => void} poser
*/
export function servir(fichiers, poser) {
for (const primitive of PRIMITIVES) {
poser(`${PREFIXE_CANAL}${primitive}`, (_evenement, ...parametres) => repondre(() => fichiers[primitive](...parametres)));
}
}