835 lines
32 KiB
JavaScript
835 lines
32 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 —, 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']);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Un échec que l'interface nomme : le code et les détails d'une
|
||
|
|
* ErreurStockage (src/stockage/types.js en donne la table).
|
||
|
|
*/
|
||
|
|
export class RefusFichier extends Error {
|
||
|
|
/**
|
||
|
|
* @param {string} code
|
||
|
|
* @param {Object} details
|
||
|
|
*/
|
||
|
|
constructor(code, details) {
|
||
|
|
super(`${code} ${JSON.stringify(details)}`);
|
||
|
|
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 : 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,
|
||
|
|
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);
|
||
|
|
try {
|
||
|
|
await confiner(loc);
|
||
|
|
return (await fs.readFile(loc.absolu)).toString('utf8');
|
||
|
|
} catch (erreur) {
|
||
|
|
throw echecLecture(loc, erreur);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
// 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);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
async creerDossier(racine, chemin) {
|
||
|
|
const loc = localiser(racine, chemin);
|
||
|
|
try {
|
||
|
|
await confiner(loc);
|
||
|
|
await fs.mkdir(loc.absolu, { recursive: true });
|
||
|
|
} 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)));
|
||
|
|
}
|
||
|
|
}
|