[ADD] electron: confined file system, atomic writes, lock, bridge
The shell carries the file-system boundary (§ 13.1): the page names a root the main process defines — data/ beside the published executable, the product folder in Documents, or one the operator picks in a dialog — and a relative path, refused if absolute, with .., a NUL, or a real path outside the root. Writes go to a temporary file, flushed, renamed over the target with retries; a failed rename never truncates it. The lock records session, process and host. Chromium's profile lives in a temporary folder removed at exit, not in AppData nor in the events. Checked: node and real-folder long tests; real Electron start under Ozone. --- FR --- [ADD] electron : système de fichiers confiné, écriture atomique, verrou La coquille porte la frontière du système de fichiers (§ 13.1) : la page nomme une racine que le processus principal définit — data/ à côté de l'exécutable publié, le dossier du produit dans Documents, ou un dossier choisi par dialogue — et un chemin relatif, refusé s'il est absolu, porte .., un NUL, ou un chemin réel hors de la racine. Une écriture passe par un temporaire vidé sur le disque puis renommé par-dessus, réessayé ; un renommage manqué ne tronque jamais la cible. Le profil de Chromium vit dans un dossier temporaire effacé à la sortie, hors d'AppData. Vérifié : épreuves node et longues sur dossier réel ; Electron réel lancé. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
parent
99524aa9bf
commit
f2b66f2f92
8 changed files with 3002 additions and 96 deletions
834
electron/fichiers.js
Normal file
834
electron/fichiers.js
Normal file
|
|
@ -0,0 +1,834 @@
|
|||
// © 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,33 +2,81 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui
|
||||
// charge la page construite par Vite dans www/, et un seul canal IPC, que
|
||||
// electron/preload.cjs expose à la page sous window.gtt.
|
||||
// charge la page construite par Vite dans www/, et le système de fichiers de
|
||||
// la plateforme (electron/fichiers.js), offert à la page par un canal IPC
|
||||
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers.
|
||||
//
|
||||
// La page n'a ni Node ni accès au disque : isolation de contexte active,
|
||||
// intégration de Node coupée, rendu en bac à sable. Elle n'atteint le
|
||||
// processus principal que par les canaux que ce module traite. Un nom importé
|
||||
// interprété comme du balisage reste ainsi une faute d'affichage, et non un
|
||||
// accès au disque.
|
||||
import { app, BrowserWindow, ipcMain, Menu } from 'electron';
|
||||
// processus principal que par ces canaux, et n'y désigne un fichier que par
|
||||
// une racine que ce processus connaît et un chemin relatif à elle. Un nom
|
||||
// importé interprété comme du balisage reste ainsi une faute d'affichage, et
|
||||
// non un accès au disque.
|
||||
import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { readdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { creerFichiers, decrireSysteme, processusVivant, servir } from './fichiers.js';
|
||||
|
||||
// Chemins relatifs à ce module : ils valent dans le dépôt comme dans
|
||||
// l'archive app.asar de l'exécutable, qui reproduit electron/ et www/.
|
||||
const PRECHARGEMENT = fileURLToPath(new URL('./preload.cjs', import.meta.url));
|
||||
const PAGE = fileURLToPath(new URL('../www/index.html', import.meta.url));
|
||||
|
||||
// Dossier de l'exécutable livré (§ 8.6, point 1). L'exécutable portable est
|
||||
// une archive auto-extractible : l'application tourne depuis un dossier
|
||||
// temporaire effacé à la sortie, et le lanceur publie son propre dossier dans
|
||||
// PORTABLE_EXECUTABLE_DIR avant de la lancer. Hors de ce lanceur, la variable
|
||||
// est absente ; vide, elle ne désigne aucun dossier. Dans les deux cas, le
|
||||
// canal rend null. Le gestionnaire est posé avant le chargement de la page,
|
||||
// qui peut l'appeler dès son premier script.
|
||||
ipcMain.handle('gtt:dossier-executable', () => process.env.PORTABLE_EXECUTABLE_DIR || null);
|
||||
// Profil de Chromium (§ 8.6) : un dossier neuf sous le dossier temporaire du
|
||||
// système, nommé gtt-profil-<pid>-…, posé avant que l'application soit
|
||||
// prête — Chromium l'ouvre alors —, et retiré à la sortie. L'application ne
|
||||
// garde rien dans ce profil, et un exécutable portable ne laisse ainsi rien
|
||||
// dans AppData, ni dans le dossier de travail.
|
||||
//
|
||||
// Le retrait de la sortie n'est qu'un premier temps : Chromium écrit encore
|
||||
// dans le profil après l'évènement quit — son état local, ses préférences,
|
||||
// le stockage de session —, et le dossier renaît, réduit à quelques fichiers.
|
||||
// Une séance qui s'arrête sans sortir n'en retire rien. Chaque démarrage
|
||||
// retire donc, après l'ouverture de la fenêtre, les profils des séances
|
||||
// terminées : le pid de leur nom ne désigne plus aucun processus de ce
|
||||
// poste. Le profil d'une séance qui tourne encore reste.
|
||||
const TEMPORAIRE = app.getPath('temp');
|
||||
const PROFIL_DE_SEANCE = /^gtt-profil-(\d+)-/;
|
||||
const PROFIL = mkdtempSync(join(TEMPORAIRE, `gtt-profil-${process.pid}-`));
|
||||
app.setPath('userData', PROFIL);
|
||||
app.on('quit', () => {
|
||||
try {
|
||||
rmSync(PROFIL, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Au mieux : le démarrage suivant le retire.
|
||||
}
|
||||
});
|
||||
|
||||
async function retirerProfilsTermines() {
|
||||
for (const nom of await readdir(TEMPORAIRE)) {
|
||||
const pid = Number(PROFIL_DE_SEANCE.exec(nom)?.[1]);
|
||||
if (!(pid > 0) || pid === process.pid || processusVivant(pid)) continue;
|
||||
await rm(join(TEMPORAIRE, nom), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Le système de fichiers : emplacements et racines lus une fois, au
|
||||
// démarrage (§ 8.6) ; les dialogues s'ouvrent sur la fenêtre de la page. Les
|
||||
// gestionnaires sont posés avant le chargement de la page, qui peut les
|
||||
// appeler dès son premier script.
|
||||
let fenetre = null;
|
||||
const fichiers = creerFichiers({
|
||||
...decrireSysteme({
|
||||
env: process.env,
|
||||
plateforme: process.platform,
|
||||
cheminSysteme: (nom) => app.getPath(nom),
|
||||
nomProduit: app.getName(),
|
||||
}),
|
||||
dialog,
|
||||
shell,
|
||||
fenetre: () => fenetre,
|
||||
});
|
||||
servir(fichiers, (canal, gestionnaire) => ipcMain.handle(canal, gestionnaire));
|
||||
|
||||
function creerFenetre() {
|
||||
const fenetre = new BrowserWindow({
|
||||
fenetre = new BrowserWindow({
|
||||
width: 1366,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
|
|
@ -53,4 +101,5 @@ function creerFenetre() {
|
|||
app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null);
|
||||
creerFenetre();
|
||||
retirerProfilsTermines().catch(() => {});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,18 +2,44 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le
|
||||
// processus principal. Il expose window.gtt et rien d'autre ; la page n'en
|
||||
// reçoit que des fonctions qui appellent un canal nommé, jamais ipcRenderer
|
||||
// lui-même.
|
||||
// processus principal. Il expose window.gtt.fichiers et rien d'autre : une
|
||||
// fonction par primitive du système de fichiers, qui invoque le canal de
|
||||
// cette primitive, gtt:fichiers:<primitive>, et rend la réponse du processus
|
||||
// principal (electron/fichiers.js). La page ne reçoit jamais ipcRenderer
|
||||
// lui-même, ni une fonction qui invoquerait un canal de son choix.
|
||||
//
|
||||
// Un préchargement exécuté en bac à sable est un script CommonJS, d'où
|
||||
// l'extension .cjs dans un paquet de type module. Son require ne connaît
|
||||
// qu'electron et quelques modules de Node.
|
||||
// qu'electron et quelques modules de Node, et non les fichiers du projet :
|
||||
// les primitives s'écrivent donc ici, dans l'ordre de l'interface, et
|
||||
// test/coquille.test.js éprouve qu'elles sont celles des canaux que pose le
|
||||
// processus principal.
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('gtt', {
|
||||
plateforme: 'electron',
|
||||
// Dossier publié par le lanceur portable (§ 8.6, point 1), ou null hors de
|
||||
// lui ; voir electron/main.js.
|
||||
dossierExecutable: () => ipcRenderer.invoke('gtt:dossier-executable'),
|
||||
});
|
||||
const PRIMITIVES = [
|
||||
'emplacements',
|
||||
'racines',
|
||||
'choisirDossier',
|
||||
'sonder',
|
||||
'typeSupport',
|
||||
'lireTexte',
|
||||
'ecrireAtomique',
|
||||
'ajouterLigne',
|
||||
'lister',
|
||||
'creerDossier',
|
||||
'deplacer',
|
||||
'supprimer',
|
||||
'verrouiller',
|
||||
'deverrouiller',
|
||||
'ouvrirDansExplorateur',
|
||||
'choisirFichierAImporter',
|
||||
'enregistrerSous',
|
||||
];
|
||||
|
||||
const fichiers = {};
|
||||
for (const primitive of PRIMITIVES) {
|
||||
const canal = `gtt:fichiers:${primitive}`;
|
||||
fichiers[primitive] = (...parametres) => ipcRenderer.invoke(canal, ...parametres);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('gtt', { fichiers });
|
||||
|
|
|
|||
59
src/stockage/fichiers_electron.js
Normal file
59
src/stockage/fichiers_electron.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// © 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 plateforme electron, côté page (§ 13.1,
|
||||
// § 13.4) : l'une des deux exceptions nommées de la frontière des couches,
|
||||
// qui lit le pont window.gtt. Chaque primitive appelle la fonction du même
|
||||
// nom que electron/preload.cjs expose sous window.gtt.fichiers ; le
|
||||
// processus principal l'exécute (electron/fichiers.js) et rend
|
||||
// { ok: true, valeur }, ou { ok: false, code, details } pour un échec, dont
|
||||
// une ErreurStockage de ce code et de ces détails prend ici la place : une
|
||||
// erreur levée ne traverse pas l'IPC. Une faute de la coquille, qu'Electron
|
||||
// rapporte par son seul message, se lève telle quelle.
|
||||
//
|
||||
// L'écriture atomique et le verrou du § 8.8 sont ceux de la coquille.
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
||||
/**
|
||||
* Le système de fichiers de la plateforme electron.
|
||||
*
|
||||
* @param {Object<string, Function>} [pont] une fonction par primitive, qui
|
||||
* rend la réponse du processus principal ; window.gtt.fichiers par
|
||||
* défaut
|
||||
* @returns {import('./systeme_fichiers.js').SystemeFichiers}
|
||||
*/
|
||||
export function creerFichiersElectron(pont = window.gtt.fichiers) {
|
||||
// La valeur de la réponse ; l'ErreurStockage d'un échec ; une TypeError
|
||||
// pour une réponse d'une autre forme.
|
||||
async function appeler(primitive, ...parametres) {
|
||||
const reponse = await pont[primitive](...parametres);
|
||||
if (reponse?.ok === true) return reponse.valeur;
|
||||
if (reponse?.ok === false && typeof reponse.code === 'string') {
|
||||
throw new ErreurStockage(reponse.code, reponse.details);
|
||||
}
|
||||
throw new TypeError(`réponse du pont illisible pour ${primitive}`);
|
||||
}
|
||||
|
||||
return {
|
||||
nature: 'electron',
|
||||
renommageAtomique: true,
|
||||
verrouDisponible: true,
|
||||
emplacements: () => appeler('emplacements'),
|
||||
racines: () => appeler('racines'),
|
||||
choisirDossier: () => appeler('choisirDossier'),
|
||||
sonder: (racine) => appeler('sonder', racine),
|
||||
typeSupport: (racine) => appeler('typeSupport', racine),
|
||||
lireTexte: (racine, chemin) => appeler('lireTexte', racine, chemin),
|
||||
ecrireAtomique: (racine, chemin, texte) => appeler('ecrireAtomique', racine, chemin, texte),
|
||||
ajouterLigne: (racine, chemin, ligne) => appeler('ajouterLigne', racine, chemin, ligne),
|
||||
lister: (racine, dossier) => appeler('lister', racine, dossier),
|
||||
creerDossier: (racine, chemin) => appeler('creerDossier', racine, chemin),
|
||||
deplacer: (racine, de, vers) => appeler('deplacer', racine, de, vers),
|
||||
supprimer: (racine, chemin) => appeler('supprimer', racine, chemin),
|
||||
verrouiller: (racine, chemin, seance) => appeler('verrouiller', racine, chemin, seance),
|
||||
deverrouiller: (racine, chemin, seance) => appeler('deverrouiller', racine, chemin, seance),
|
||||
ouvrirDansExplorateur: (racine) => appeler('ouvrirDansExplorateur', racine),
|
||||
choisirFichierAImporter: () => appeler('choisirFichierAImporter'),
|
||||
enregistrerSous: (nomPropose, octets) => appeler('enregistrerSous', nomPropose, octets),
|
||||
};
|
||||
}
|
||||
|
|
@ -2,28 +2,45 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal,
|
||||
// la page qu'elle charge, le pont étroit que le préchargement expose, et le
|
||||
// trajet du dossier publié par le lanceur portable jusqu'à la page (§ 8.6,
|
||||
// point 1).
|
||||
// la page qu'elle charge, le profil de Chromium hors d'AppData (§ 8.6), le
|
||||
// pont étroit et nommé que le préchargement expose — une fonction par
|
||||
// primitive du système de fichiers, un canal par fonction —, et ce que le
|
||||
// processus principal y répond : emplacements, racines, dialogues,
|
||||
// explorateur.
|
||||
//
|
||||
// electron/main.js s'exécute sous Node, dans un processus à part, contre
|
||||
// test/electron_factice.js, qui consigne ce que la coquille demande à
|
||||
// Electron puis rend son journal en JSON. Ce qui s'éprouve ici est ce que la
|
||||
// coquille demande ; qu'Electron l'applique relève d'une épreuve dans un vrai
|
||||
// Electron.
|
||||
// Electron (make essai_demarrage). Les primitives elles-mêmes s'éprouvent
|
||||
// dans test/fichiers_electron.test.js.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { basename, dirname, join, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { PRIMITIVES } from '../electron/fichiers.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const FACTICE = pathToFileURL(join(RACINE, 'test', 'electron_factice.js')).href;
|
||||
const PRINCIPAL = join(RACINE, 'electron', 'main.js');
|
||||
const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs');
|
||||
const CANAL = 'gtt:dossier-executable';
|
||||
const NOM_PRODUIT = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8')).productName;
|
||||
const CANAUX = PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`);
|
||||
|
||||
// Dossier que publie le lanceur portable, et LOCALAPPDATA, dans le scénario
|
||||
// qui les donne. Aucun des deux n'est lu ni écrit.
|
||||
const PUBLIE = join(sep, 'media', 'cle', 'soirees');
|
||||
const LOCAL = join(sep, 'donnees', 'locales');
|
||||
|
||||
// Ce que la coquille voit de l'environnement, par scénario.
|
||||
const SCENARIOS = {
|
||||
publie: { PORTABLE_EXECUTABLE_DIR: PUBLIE, LOCALAPPDATA: LOCAL },
|
||||
absent: {},
|
||||
vide: { PORTABLE_EXECUTABLE_DIR: '' },
|
||||
};
|
||||
|
||||
// Délai de l'exécution de la coquille. Le factice ne joue la page qu'une
|
||||
// fois le processus sans rien à faire ; un minuteur ou un écouteur laissé
|
||||
|
|
@ -34,19 +51,41 @@ const CANAL = 'gtt:dossier-executable';
|
|||
// ne retient.
|
||||
const DELAI_MS = 4_000;
|
||||
|
||||
// Exécute la coquille contre le factice et rend son journal. Le répertoire
|
||||
// courant est un dossier vide hors du projet : dans l'exécutable, il n'est
|
||||
// jamais l'intérieur d'app.asar, si bien qu'un chemin résolu contre lui ne
|
||||
// désigne ni la page ni le préchargement.
|
||||
function executerLaCoquille() {
|
||||
// Le pid d'un processus terminé : aucun processus de ce poste n'y répond
|
||||
// plus.
|
||||
const PID_TERMINE = spawnSync(process.execPath, ['-e', '']).pid;
|
||||
|
||||
// Ce que le dossier temporaire porte avant la coquille : les Documents du
|
||||
// produit, où l'explorateur s'ouvre ; le profil qu'a laissé une séance
|
||||
// terminée, celui d'une séance qui tourne encore — ce processus d'épreuve —,
|
||||
// et un dossier dont le nom ne porte aucun pid.
|
||||
const PROFIL_TERMINE = `gtt-profil-${PID_TERMINE}-Ab12Cd`;
|
||||
const PROFIL_VIVANT = `gtt-profil-${process.pid}-Ef34Gh`;
|
||||
const AUTRE = 'gtt-profil-sans-pid';
|
||||
const DOSSIERS_POSES = ['Documents', PROFIL_TERMINE, PROFIL_VIVANT, AUTRE];
|
||||
|
||||
// Exécute la coquille contre le factice dans un scénario, et rend son
|
||||
// journal, son pid, le dossier temporaire qu'elle a reçu, et ce qu'elle y a
|
||||
// laissé. Le répertoire courant est un dossier vide hors du projet : dans
|
||||
// l'exécutable, il n'est jamais l'intérieur d'app.asar, si bien qu'un chemin
|
||||
// résolu contre lui ne désigne ni la page ni le préchargement. Le dossier
|
||||
// temporaire est neuf, et porte les DOSSIERS_POSES.
|
||||
function executerLaCoquille(scenario) {
|
||||
const environnement = { ...process.env };
|
||||
delete environnement.PORTABLE_EXECUTABLE_DIR;
|
||||
delete environnement.LOCALAPPDATA;
|
||||
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
|
||||
const temporaire = mkdtempSync(join(tmpdir(), 'coquille-temp-'));
|
||||
mkdirSync(join(temporaire, 'Documents', NOM_PRODUIT), { recursive: true });
|
||||
for (const nom of [PROFIL_TERMINE, PROFIL_VIVANT, AUTRE]) {
|
||||
mkdirSync(join(temporaire, nom, 'Session Storage'), { recursive: true });
|
||||
writeFileSync(join(temporaire, nom, 'Local State'), '{}');
|
||||
}
|
||||
try {
|
||||
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
|
||||
cwd: ailleurs,
|
||||
encoding: 'utf8',
|
||||
env: environnement,
|
||||
env: { ...environnement, TMPDIR: temporaire, TEMP: temporaire, TMP: temporaire, ...SCENARIOS[scenario] },
|
||||
timeout: DELAI_MS,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
|
|
@ -56,26 +95,37 @@ function executerLaCoquille() {
|
|||
`la coquille ne s'arrête pas en ${DELAI_MS} ms : un minuteur ou un écouteur la tient en vie`,
|
||||
);
|
||||
assert.equal(resultat.status, 0, `la coquille ne s'exécute pas :\n${resultat.stderr}`);
|
||||
return JSON.parse(resultat.stdout);
|
||||
return {
|
||||
journal: JSON.parse(resultat.stdout),
|
||||
pid: resultat.pid,
|
||||
temporaire,
|
||||
restes: readdirSync(temporaire).sort(),
|
||||
};
|
||||
} finally {
|
||||
rmSync(ailleurs, { recursive: true, force: true });
|
||||
rmSync(temporaire, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// L'exécution est partagée par les épreuves : le processus ne se lance
|
||||
// qu'une fois, et son échec se rend à chacune sans relancer.
|
||||
let execution;
|
||||
function journalDeLaCoquille() {
|
||||
if (execution === undefined) {
|
||||
// Chaque scénario ne s'exécute qu'une fois, et son échec se rend à chaque
|
||||
// épreuve qui le demande, sans relancer.
|
||||
const executions = new Map();
|
||||
function coquille(scenario = 'publie') {
|
||||
if (!executions.has(scenario)) {
|
||||
try {
|
||||
execution = { journal: executerLaCoquille() };
|
||||
executions.set(scenario, { resultat: executerLaCoquille(scenario) });
|
||||
} catch (erreur) {
|
||||
execution = { erreur };
|
||||
executions.set(scenario, { erreur });
|
||||
}
|
||||
}
|
||||
const execution = executions.get(scenario);
|
||||
if (execution.erreur !== undefined) throw execution.erreur;
|
||||
return execution.journal;
|
||||
return execution.resultat;
|
||||
}
|
||||
const journalDeLaCoquille = (scenario) => coquille(scenario).journal;
|
||||
|
||||
// L'appel de la page à une fonction du pont, consigné par le factice.
|
||||
const appelDe = (journal, membre) => journal.prechargement.appels.find((appel) => appel.membre === membre);
|
||||
|
||||
describe('coquille : processus principal', () => {
|
||||
test("une seule fenêtre, 1366 × 768 : page isolée, sans Node, en bac à sable, préchargée par le pont", () => {
|
||||
|
|
@ -106,12 +156,16 @@ describe('coquille : processus principal', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test(`un seul canal IPC, ${CANAL}, posé avant le chargement de la page`, () => {
|
||||
test('un canal gtt:fichiers:<primitive> par primitive, et aucun autre, chacun posé avant le chargement de la page', () => {
|
||||
const { canaux, sequence } = journalDeLaCoquille();
|
||||
assert.deepEqual(canaux, [CANAL]);
|
||||
const pose = sequence.indexOf(`ipcMain.handle:${CANAL}`);
|
||||
assert.deepEqual(canaux, CANAUX);
|
||||
assert.equal(CANAUX.length, 17);
|
||||
const charge = sequence.indexOf('loadFile');
|
||||
assert.ok(pose !== -1 && charge !== -1 && pose < charge, `ordre des appels : ${sequence.join(', ')}`);
|
||||
assert.notEqual(charge, -1);
|
||||
for (const canal of CANAUX) {
|
||||
const pose = sequence.indexOf(`ipcMain.handle:${canal}`);
|
||||
assert.ok(pose !== -1 && pose < charge, `${canal} : ${sequence.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => {
|
||||
|
|
@ -120,29 +174,128 @@ describe('coquille : processus principal', () => {
|
|||
assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate');
|
||||
assert.deepEqual(navigations, navigations.map(() => true));
|
||||
});
|
||||
|
||||
test('le profil de Chromium est un dossier neuf sous le dossier temporaire, nommé par le pid de la séance, posé avant whenReady, retiré à la sortie (§ 8.6)', () => {
|
||||
const { journal, pid, temporaire } = coquille();
|
||||
assert.equal(journal.setPath.length, 1, JSON.stringify(journal.setPath));
|
||||
const [{ nom, chemin }] = journal.setPath;
|
||||
assert.equal(nom, 'userData');
|
||||
assert.equal(dirname(chemin), temporaire);
|
||||
assert.ok(basename(chemin).startsWith(`gtt-profil-${pid}-`), basename(chemin));
|
||||
const pose = journal.sequence.indexOf('app.setPath:userData');
|
||||
const pret = journal.sequence.indexOf('app.whenReady');
|
||||
assert.ok(pose !== -1 && pret !== -1 && pose < pret, `ordre des appels : ${journal.sequence.join(', ')}`);
|
||||
assert.deepEqual(journal.profil, { avant: true, apres: false });
|
||||
});
|
||||
|
||||
test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => {
|
||||
const { restes } = coquille();
|
||||
assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('coquille : préchargement', () => {
|
||||
test("n'expose que window.gtt, et ne requiert qu'electron", () => {
|
||||
test("n'expose que window.gtt.fichiers, une fonction par primitive, et ne requiert qu'electron", () => {
|
||||
const { prechargement } = journalDeLaCoquille();
|
||||
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
|
||||
assert.deepEqual(prechargement.requis, ['electron']);
|
||||
assert.deepEqual(prechargement.exposes, [
|
||||
{ cle: 'gtt', membres: ['dossierExecutable', 'plateforme'] },
|
||||
]);
|
||||
assert.equal(prechargement.plateforme, 'electron');
|
||||
assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['fichiers'] }]);
|
||||
assert.deepEqual(prechargement.fichiers, [...PRIMITIVES]);
|
||||
assert.deepEqual(
|
||||
prechargement.appels.map(({ membre, type }) => ({ membre, type })),
|
||||
PRIMITIVES.map((membre) => ({ membre, type: 'function' })),
|
||||
);
|
||||
});
|
||||
|
||||
test(`gtt.dossierExecutable() demande à chaque appel, par ${CANAL}, le dossier publié par le lanceur portable, null sans lui`, () => {
|
||||
const { prechargement, invocations } = journalDeLaCoquille();
|
||||
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
|
||||
assert.deepEqual(prechargement.reponses, {
|
||||
publie: { valeur: 'E:\\soirees' },
|
||||
absent: { valeur: null },
|
||||
vide: { valeur: null },
|
||||
test('chaque fonction invoque son propre canal, une fois par appel, et rend la réponse du processus principal', () => {
|
||||
const journal = journalDeLaCoquille();
|
||||
assert.deepEqual(
|
||||
journal.prechargement.appels.map(({ membre, canaux }) => ({ membre, canaux })),
|
||||
PRIMITIVES.map((membre) => ({ membre, canaux: [`gtt:fichiers:${membre}`] })),
|
||||
);
|
||||
assert.deepEqual(journal.invocations, CANAUX);
|
||||
// Une racine que le processus principal ne connaît pas est refusée par
|
||||
// lui, en réponse et non en erreur.
|
||||
for (const [membre, chemin] of [
|
||||
['sonder', ''],
|
||||
['typeSupport', ''],
|
||||
['lireTexte', 'soiree.gtt.json'],
|
||||
['ecrireAtomique', 'soiree.gtt.json'],
|
||||
['ajouterLigne', 'soiree.gtt-journal.jsonl'],
|
||||
['lister', ''],
|
||||
['creerDossier', 'corbeille'],
|
||||
['deplacer', 'soiree.gtt.json'],
|
||||
['supprimer', 'soiree.gtt.json'],
|
||||
['verrouiller', 'soiree.gtt.verrou'],
|
||||
['deverrouiller', 'soiree.gtt.verrou'],
|
||||
]) {
|
||||
assert.deepEqual(
|
||||
appelDe(journal, membre).reponse,
|
||||
{ ok: false, code: 'CHEMIN_REFUSE', details: { chemin } },
|
||||
membre,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("emplacements et racines viennent du processus principal : le dossier publié et son data/, AppData, LOCALAPPDATA, le temporaire, les Documents du produit", () => {
|
||||
const journal = journalDeLaCoquille('publie');
|
||||
const { chemins } = journal;
|
||||
assert.deepEqual(appelDe(journal, 'emplacements').reponse, {
|
||||
ok: true,
|
||||
valeur: {
|
||||
executable: PUBLIE,
|
||||
donneesApplicatives: [chemins.appData, LOCAL, chemins.temp],
|
||||
insensibleCasse: process.platform === 'win32' || process.platform === 'darwin',
|
||||
separateur: sep,
|
||||
},
|
||||
});
|
||||
// Le préchargement voit lui aussi process.env : seules les invocations
|
||||
// montrent que la réponse vient du processus principal, une par appel.
|
||||
assert.deepEqual(invocations, [CANAL, CANAL, CANAL]);
|
||||
assert.deepEqual(appelDe(journal, 'racines').reponse, {
|
||||
ok: true,
|
||||
valeur: {
|
||||
portable: { id: 'portable', chemin: join(PUBLIE, 'data') },
|
||||
documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('sans dossier publié, ou publié vide, ni exécutable ni racine portable', () => {
|
||||
for (const scenario of ['absent', 'vide']) {
|
||||
const journal = journalDeLaCoquille(scenario);
|
||||
const { chemins } = journal;
|
||||
assert.deepEqual(
|
||||
appelDe(journal, 'emplacements').reponse.valeur,
|
||||
{
|
||||
executable: null,
|
||||
donneesApplicatives: [chemins.appData, chemins.temp],
|
||||
insensibleCasse: process.platform === 'win32' || process.platform === 'darwin',
|
||||
separateur: sep,
|
||||
},
|
||||
scenario,
|
||||
);
|
||||
assert.deepEqual(
|
||||
appelDe(journal, 'racines').reponse.valeur,
|
||||
{ portable: null, documents: { id: 'documents', chemin: join(chemins.documents, NOM_PRODUIT) } },
|
||||
scenario,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("les dialogues s'ouvrent sur la fenêtre de la page, et une annulation rend null", () => {
|
||||
const journal = journalDeLaCoquille();
|
||||
assert.deepEqual(journal.dialogues, [
|
||||
{ methode: 'showOpenDialog', parent: true, options: { properties: ['openDirectory', 'createDirectory'] } },
|
||||
{ methode: 'showOpenDialog', parent: true, options: { properties: ['openFile'] } },
|
||||
{ methode: 'showSaveDialog', parent: true, options: { defaultPath: join(journal.chemins.documents, 'liste.csv') } },
|
||||
]);
|
||||
for (const membre of ['choisirDossier', 'choisirFichierAImporter', 'enregistrerSous']) {
|
||||
assert.deepEqual(appelDe(journal, membre).reponse, { ok: true, valeur: null }, membre);
|
||||
}
|
||||
});
|
||||
|
||||
test("l'explorateur s'ouvre sur la racine que la page désigne", () => {
|
||||
const journal = journalDeLaCoquille();
|
||||
assert.deepEqual(journal.explorateur, [join(journal.chemins.documents, NOM_PRODUIT)]);
|
||||
// La réponse porte valeur: undefined, que le journal en JSON ne garde pas.
|
||||
assert.deepEqual(appelDe(journal, 'ouvrirDansExplorateur').reponse, { ok: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,14 +10,15 @@
|
|||
// fausses API exportées ici, qui consignent chaque appel. Quand le processus
|
||||
// n'a plus rien à faire, le module joue le rôle de la page : il exécute
|
||||
// electron/preload.cjs dans une fonction qui reçoit require, module et
|
||||
// exports, appelle le pont exposé et les gestionnaires posés par le processus
|
||||
// principal, puis imprime le journal en JSON sur la sortie standard.
|
||||
// exports, et appelle chaque fonction du pont exposé, qui atteint les
|
||||
// gestionnaires posés par le processus principal. Il joue ensuite la sortie
|
||||
// de l'application — before-quit, will-quit, quit — puis imprime le journal
|
||||
// en JSON sur la sortie standard.
|
||||
//
|
||||
// Seul require est restreint, à electron, comme dans un rendu en bac à
|
||||
// sable. Les globales de Node du processus d'épreuve, dont process et Buffer,
|
||||
// restent visibles du préchargement, et un vrai préchargement en bac à sable
|
||||
// lit lui aussi process.env. Un pont qui lirait lui-même la variable
|
||||
// répondrait donc juste : le journal des invocations, et non l'absence de
|
||||
// lit lui aussi process.env. Le journal des invocations, et non l'absence de
|
||||
// process, montre que chaque réponse vient du processus principal.
|
||||
//
|
||||
// Les exports sont la surface d'Electron que la coquille a le droit
|
||||
|
|
@ -25,9 +26,17 @@
|
|||
// electron/main.js. Ils refusent ce qu'Electron refuse et qu'une erreur de
|
||||
// la coquille provoquerait : une fenêtre créée avant que l'application soit
|
||||
// prête, un second gestionnaire pour un même canal, un appel à un canal sans
|
||||
// gestionnaire.
|
||||
import { readFileSync } from 'node:fs';
|
||||
// gestionnaire, un nom de chemin qu'app.getPath ne connaît pas.
|
||||
//
|
||||
// app.getPath rend le dossier temporaire du processus — que l'épreuve
|
||||
// désigne par TMPDIR, et par TEMP et TMP sous Windows —, et des Documents et
|
||||
// un AppData qui y vivent ; app.getName, le nom de produit de package.json,
|
||||
// comme Electron. Les dialogues rendent une annulation ; l'explorateur
|
||||
// consigne le dossier qu'on lui demande d'ouvrir, et réussit.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compileFunction } from 'node:vm';
|
||||
|
||||
|
|
@ -39,13 +48,45 @@ registerHooks({
|
|||
});
|
||||
|
||||
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
|
||||
const NOM_PRODUIT = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).productName;
|
||||
|
||||
// Valeur de PORTABLE_EXECUTABLE_DIR quand le lanceur portable la publie.
|
||||
const DOSSIER_PUBLIE = 'E:\\soirees';
|
||||
// Ce que rend app.getPath, par nom.
|
||||
const TEMPORAIRE = tmpdir();
|
||||
const CHEMINS = Object.freeze({
|
||||
temp: TEMPORAIRE,
|
||||
documents: join(TEMPORAIRE, 'Documents'),
|
||||
appData: join(TEMPORAIRE, 'AppData'),
|
||||
});
|
||||
|
||||
// Arguments que la page passe à chaque fonction du pont, selon la réponse de
|
||||
// racines() quand elle a précédé : une racine que le processus principal ne
|
||||
// connaît pas, pour que la réponse vienne du confinement sans toucher au
|
||||
// disque ; la racine documents pour l'explorateur ; un nom et des octets
|
||||
// pour l'enregistrement. Une fonction absente d'ici s'appelle sans argument.
|
||||
const INCONNUE = { id: 'racine-inconnue', chemin: TEMPORAIRE };
|
||||
const ARGUMENTS_DE_LA_PAGE = {
|
||||
sonder: () => [INCONNUE],
|
||||
typeSupport: () => [INCONNUE],
|
||||
lireTexte: () => [INCONNUE, 'soiree.gtt.json'],
|
||||
ecrireAtomique: () => [INCONNUE, 'soiree.gtt.json', '{}\n'],
|
||||
ajouterLigne: () => [INCONNUE, 'soiree.gtt-journal.jsonl', '{}'],
|
||||
lister: () => [INCONNUE, ''],
|
||||
creerDossier: () => [INCONNUE, 'corbeille'],
|
||||
deplacer: () => [INCONNUE, 'soiree.gtt.json', 'corbeille/soiree.gtt.json'],
|
||||
supprimer: () => [INCONNUE, 'soiree.gtt.json'],
|
||||
verrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'],
|
||||
deverrouiller: () => [INCONNUE, 'soiree.gtt.verrou', 'seance-factice'],
|
||||
ouvrirDansExplorateur: (racines) => [racines?.documents],
|
||||
enregistrerSous: () => ['liste.csv', new Uint8Array([0x6e, 0x6f, 0x6d])],
|
||||
};
|
||||
|
||||
const journal = {
|
||||
// Appels qui comptent pour l'ordre, dans l'ordre où ils arrivent.
|
||||
sequence: [],
|
||||
// Ce que rend app.getPath, par nom.
|
||||
chemins: CHEMINS,
|
||||
// Chaque app.setPath : { nom, chemin }.
|
||||
setPath: [],
|
||||
// Options de chaque new BrowserWindow.
|
||||
fenetres: [],
|
||||
// Argument de chaque Menu.setApplicationMenu.
|
||||
|
|
@ -57,17 +98,26 @@ const journal = {
|
|||
// Canal de chaque ipcRenderer.invoke du préchargement, dans l'ordre des
|
||||
// appels.
|
||||
invocations: [],
|
||||
// Chaque dialogue : sa méthode, sa fenêtre parente est-elle une
|
||||
// BrowserWindow, ses options.
|
||||
dialogues: [],
|
||||
// Dossier de chaque shell.openPath.
|
||||
explorateur: [],
|
||||
// Réponse du gestionnaire d'ouverture de chaque fenêtre à une adresse
|
||||
// externe.
|
||||
ouvertures: [],
|
||||
// Pour chaque écouteur de will-navigate : la navigation est-elle annulée ?
|
||||
navigations: [],
|
||||
// Ce que fait le préchargement : modules requis, objets exposés, et la
|
||||
// réponse du pont selon PORTABLE_EXECUTABLE_DIR.
|
||||
// Ce que fait le préchargement : modules requis, objets exposés, membres
|
||||
// de gtt.fichiers, et pour chacun les canaux qu'il invoque et sa réponse.
|
||||
prechargement: null,
|
||||
// Le dossier userData existe-t-il avant la sortie de l'application, puis
|
||||
// après ; null sans app.setPath('userData').
|
||||
profil: null,
|
||||
};
|
||||
|
||||
const gestionnaires = new Map();
|
||||
const ecouteurs = new Map();
|
||||
const pageDeFenetre = [];
|
||||
|
||||
// L'application n'est jamais prête pendant l'évaluation du module principal,
|
||||
|
|
@ -88,12 +138,25 @@ export const app = {
|
|||
});
|
||||
return quandPret;
|
||||
},
|
||||
on(evenement) {
|
||||
on(evenement, ecouteur) {
|
||||
journal.sequence.push(`app.on:${evenement}`);
|
||||
if (!ecouteurs.has(evenement)) ecouteurs.set(evenement, []);
|
||||
ecouteurs.get(evenement).push(ecouteur);
|
||||
},
|
||||
quit() {
|
||||
journal.sequence.push('app.quit');
|
||||
},
|
||||
getName() {
|
||||
return NOM_PRODUIT;
|
||||
},
|
||||
getPath(nom) {
|
||||
if (!Object.hasOwn(CHEMINS, nom)) throw new Error(`Failed to get '${nom}' path`);
|
||||
return CHEMINS[nom];
|
||||
},
|
||||
setPath(nom, chemin) {
|
||||
journal.sequence.push(`app.setPath:${nom}`);
|
||||
journal.setPath.push({ nom, chemin });
|
||||
},
|
||||
};
|
||||
|
||||
export class BrowserWindow {
|
||||
|
|
@ -148,6 +211,24 @@ export const ipcMain = {
|
|||
},
|
||||
};
|
||||
|
||||
export const dialog = {
|
||||
async showOpenDialog(parent, options) {
|
||||
journal.dialogues.push({ methode: 'showOpenDialog', parent: parent instanceof BrowserWindow, options });
|
||||
return { canceled: true, filePaths: [] };
|
||||
},
|
||||
async showSaveDialog(parent, options) {
|
||||
journal.dialogues.push({ methode: 'showSaveDialog', parent: parent instanceof BrowserWindow, options });
|
||||
return { canceled: true, filePath: '' };
|
||||
},
|
||||
};
|
||||
|
||||
export const shell = {
|
||||
async openPath(chemin) {
|
||||
journal.explorateur.push(chemin);
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
// Côté page : ce que require('electron') rend au préchargement. invoke se
|
||||
// consigne, atteint le gestionnaire posé par le processus principal pour ce
|
||||
// canal, et rejette comme Electron quand il n'y en a pas.
|
||||
|
|
@ -169,16 +250,25 @@ function electronDuRendu(exposes) {
|
|||
};
|
||||
}
|
||||
|
||||
// Réponse du pont pour une valeur de PORTABLE_EXECUTABLE_DIR, undefined
|
||||
// valant variable absente ; une erreur se consigne par son message.
|
||||
async function reponseDuPont(api, valeur) {
|
||||
if (valeur === undefined) delete process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
else process.env.PORTABLE_EXECUTABLE_DIR = valeur;
|
||||
try {
|
||||
return { valeur: await api.dossierExecutable() };
|
||||
} catch (erreur) {
|
||||
return { erreur: erreur.message };
|
||||
// Appelle chaque fonction de gtt.fichiers, dans l'ordre de ses membres, et
|
||||
// consigne pour chacune son type, les canaux qu'elle invoque et sa réponse,
|
||||
// une erreur par son message.
|
||||
async function appelerLePont(fichiers) {
|
||||
const appels = [];
|
||||
let racines;
|
||||
for (const membre of Object.keys(fichiers)) {
|
||||
const avant = journal.invocations.length;
|
||||
const appel = { membre, type: typeof fichiers[membre] };
|
||||
try {
|
||||
appel.reponse = await fichiers[membre](...(ARGUMENTS_DE_LA_PAGE[membre]?.(racines) ?? []));
|
||||
if (membre === 'racines') racines = appel.reponse?.valeur;
|
||||
} catch (erreur) {
|
||||
appel.erreur = erreur.message;
|
||||
}
|
||||
appel.canaux = journal.invocations.slice(avant);
|
||||
appels.push(appel);
|
||||
}
|
||||
return appels;
|
||||
}
|
||||
|
||||
async function jouerLePrechargement() {
|
||||
|
|
@ -195,22 +285,23 @@ async function jouerLePrechargement() {
|
|||
filename: PRECHARGEMENT,
|
||||
})(requerir, module, module.exports);
|
||||
|
||||
const gtt = exposes.find(({ cle }) => cle === 'gtt')?.api;
|
||||
const fichiers = exposes.find(({ cle }) => cle === 'gtt')?.api?.fichiers;
|
||||
return {
|
||||
requis,
|
||||
exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })),
|
||||
plateforme: gtt?.plateforme ?? null,
|
||||
reponses:
|
||||
typeof gtt?.dossierExecutable === 'function'
|
||||
? {
|
||||
publie: await reponseDuPont(gtt, DOSSIER_PUBLIE),
|
||||
absent: await reponseDuPont(gtt, undefined),
|
||||
vide: await reponseDuPont(gtt, ''),
|
||||
}
|
||||
: null,
|
||||
fichiers: fichiers === undefined ? null : Object.keys(fichiers),
|
||||
appels: fichiers === undefined ? [] : await appelerLePont(fichiers),
|
||||
};
|
||||
}
|
||||
|
||||
// Joue la sortie de l'application : chaque écouteur de ces évènements, dans
|
||||
// l'ordre où Electron les émet.
|
||||
function sortir() {
|
||||
for (const evenement of ['before-quit', 'will-quit', 'quit']) {
|
||||
for (const ecouteur of ecouteurs.get(evenement) ?? []) ecouteur({ preventDefault() {} }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function jouerLaPage() {
|
||||
const externe = 'https://exemple.invalid/';
|
||||
for (const page of pageDeFenetre) {
|
||||
|
|
@ -227,6 +318,10 @@ async function jouerLaPage() {
|
|||
} catch (erreur) {
|
||||
journal.prechargement = { erreur: erreur.message };
|
||||
}
|
||||
const profil = journal.setPath.find(({ nom }) => nom === 'userData')?.chemin;
|
||||
const avant = profil !== undefined && existsSync(profil);
|
||||
sortir();
|
||||
journal.profil = profil === undefined ? null : { avant, apres: existsSync(profil) };
|
||||
process.stdout.write(`${JSON.stringify(journal)}\n`);
|
||||
}
|
||||
|
||||
|
|
|
|||
290
test/fichiers_electron.long.test.js
Normal file
290
test/fichiers_electron.long.test.js
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// © 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 sur un vrai disque (§ 8.6, § 8.8,
|
||||
// § 14.4). La suite de contrat de test/contrat_fichiers.js s'y joue sur la
|
||||
// page, src/stockage/fichiers_electron.js, derrière le vrai préchargement,
|
||||
// devant les primitives d'electron/fichiers.js ; seul l'IPC d'Electron est
|
||||
// remplacé, et ses arguments et réponses y sont copiés comme il les copie.
|
||||
// Suivent la sonde d'écriture, qui ne s'éprouve utilement que contre un vrai
|
||||
// système de fichiers, un dossier en lecture seule, un lien qui sort de la
|
||||
// racine, un dossier voisin dont le nom prolonge celui de la racine, le
|
||||
// renommage qui remplace la cible, et le verrou d'un processus vivant ou
|
||||
// terminé. Chaque épreuve écrit sous ses propres dossiers
|
||||
// temporaires, retirés à sa fin, qu'elle passe ou non.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { hostname, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compileFunction } from 'node:vm';
|
||||
import { TEMOIN, creerFichiers, decrireSysteme, servir } from '../electron/fichiers.js';
|
||||
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
||||
import { creerFichiersElectron } from '../src/stockage/fichiers_electron.js';
|
||||
import { eprouverContrat } from './contrat_fichiers.js';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
||||
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
|
||||
const NOM_PRODUIT = 'Gestion table tournante Libre';
|
||||
|
||||
// Sous Windows, un dossier ne passe pas en lecture seule par ses droits, et
|
||||
// un lien symbolique demande un privilège : un lien de dossier s'y pose en
|
||||
// jonction. Sous root, les droits d'un dossier n'arrêtent aucune écriture.
|
||||
const SOUS_WINDOWS = process.platform === 'win32';
|
||||
const SOUS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
|
||||
// Dossiers temporaires de l'épreuve en cours.
|
||||
const ouverts = [];
|
||||
|
||||
function dossierTemporaire() {
|
||||
const dossier = mkdtempSync(join(tmpdir(), 'gtt-fichiers-'));
|
||||
ouverts.push(dossier);
|
||||
return dossier;
|
||||
}
|
||||
|
||||
// test du lanceur, dont chaque épreuve retire à sa fin les dossiers
|
||||
// temporaires qu'elle a ouverts.
|
||||
function testNettoye(nom, ...suite) {
|
||||
const corps = suite.pop();
|
||||
test(nom, ...suite, async () => {
|
||||
try {
|
||||
await corps();
|
||||
} finally {
|
||||
for (const dossier of ouverts.splice(0)) rmSync(dossier, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Charge electron/preload.cjs comme un rendu en bac à sable, contre un
|
||||
// electron dont invoke porte chaque appel au gestionnaire que servir a posé
|
||||
// sur ce canal ; arguments et réponse sont copiés comme l'IPC les copie, et
|
||||
// d'une erreur levée ne passe que le message. Rend gtt.fichiers.
|
||||
function parLePrechargement(fichiers) {
|
||||
const gestionnaires = new Map();
|
||||
servir(fichiers, (canal, gestionnaire) => gestionnaires.set(canal, gestionnaire));
|
||||
let gtt;
|
||||
const electron = {
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (cle, api) => {
|
||||
assert.equal(cle, 'gtt');
|
||||
gtt = api;
|
||||
},
|
||||
},
|
||||
ipcRenderer: {
|
||||
async invoke(canal, ...parametres) {
|
||||
const gestionnaire = gestionnaires.get(canal);
|
||||
if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`);
|
||||
let reponse;
|
||||
try {
|
||||
reponse = await gestionnaire({ sender: null }, ...structuredClone(parametres));
|
||||
} catch (erreur) {
|
||||
throw new Error(`Error invoking remote method '${canal}': ${erreur}`);
|
||||
}
|
||||
return structuredClone(reponse);
|
||||
},
|
||||
},
|
||||
};
|
||||
const module = { exports: {} };
|
||||
const requerir = (nom) => {
|
||||
if (nom === 'electron') return electron;
|
||||
throw new Error(`module « ${nom} » indisponible dans un préchargement en bac à sable`);
|
||||
};
|
||||
compileFunction(readFileSync(PRECHARGEMENT, 'utf8'), ['require', 'module', 'exports'], {
|
||||
filename: PRECHARGEMENT,
|
||||
})(requerir, module, module.exports);
|
||||
return gtt.fichiers;
|
||||
}
|
||||
|
||||
// Les dialogues et l'explorateur ne servent à aucune de ces épreuves.
|
||||
const SANS_APPEL = new Proxy({}, {
|
||||
get: (_, nom) => () => assert.fail(`appel inattendu : ${String(nom)}`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Le système de la page sur un dossier temporaire neuf : les Documents du
|
||||
* poste y sont Documents/, et le dossier publié par le lanceur portable,
|
||||
* quand il est donné, y est relatif. Rend le système et le chemin de ses
|
||||
* racines sur le disque.
|
||||
*/
|
||||
function systemeSur(dossier, { publie = null } = {}) {
|
||||
const chemins = { appData: join(dossier, 'AppData'), temp: tmpdir(), documents: join(dossier, 'Documents') };
|
||||
const systeme = decrireSysteme({
|
||||
env: publie === null ? {} : { PORTABLE_EXECUTABLE_DIR: join(dossier, publie) },
|
||||
plateforme: process.platform,
|
||||
cheminSysteme: (nom) => chemins[nom],
|
||||
nomProduit: NOM_PRODUIT,
|
||||
});
|
||||
const fs = creerFichiersElectron(parLePrechargement(creerFichiers({ ...systeme, dialog: SANS_APPEL, shell: SANS_APPEL })));
|
||||
return { fs, ...systeme.racines };
|
||||
}
|
||||
|
||||
// Rend la raison du rejet de la promesse ; échoue quand elle se résout.
|
||||
async function rejette(promesse, message) {
|
||||
let raison;
|
||||
await assert.rejects(
|
||||
promesse,
|
||||
(erreur) => {
|
||||
raison = erreur;
|
||||
return true;
|
||||
},
|
||||
message,
|
||||
);
|
||||
return raison;
|
||||
}
|
||||
|
||||
// Attend le rejet d'une ErreurStockage de ce code et de ces détails.
|
||||
async function echoue(promesse, code, details, message = code) {
|
||||
const erreur = await rejette(promesse, message);
|
||||
assert.ok(erreur instanceof ErreurStockage, `${message} : ${erreur}`);
|
||||
assert.equal(erreur.code, code, message);
|
||||
assert.deepEqual(erreur.details, details, message);
|
||||
}
|
||||
|
||||
eprouverContrat(
|
||||
'electron, sur un vrai dossier',
|
||||
async () => {
|
||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||
mkdirSync(documents, { recursive: true });
|
||||
return { fs, racine: (await fs.racines()).documents };
|
||||
},
|
||||
{
|
||||
describe,
|
||||
test: testNettoye,
|
||||
egal: (reel, attendu, message) => assert.deepEqual(reel, attendu, message),
|
||||
vrai: (valeur, message) => assert.ok(valeur, message),
|
||||
rejette,
|
||||
},
|
||||
);
|
||||
|
||||
describe('coquille sur un vrai disque', () => {
|
||||
testNettoye("la sonde crée la racine et ses parents, efface un témoin resté, et ne laisse que les autres fichiers", async () => {
|
||||
const { fs, portable } = systemeSur(dossierTemporaire(), { publie: join('cle', 'soirees') });
|
||||
const racine = (await fs.racines()).portable;
|
||||
assert.equal(racine.chemin, portable);
|
||||
assert.deepEqual(await fs.sonder(racine), { inscriptible: true, cause: null });
|
||||
assert.deepEqual(readdirSync(portable), []);
|
||||
writeFileSync(join(portable, TEMOIN), 'resté d\u{2019}une séance précédente');
|
||||
writeFileSync(join(portable, 'garde.txt'), 'garde');
|
||||
assert.deepEqual(await fs.sonder(racine), { inscriptible: true, cause: null });
|
||||
assert.deepEqual(readdirSync(portable), ['garde.txt']);
|
||||
});
|
||||
|
||||
testNettoye(
|
||||
"un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine",
|
||||
{ skip: SOUS_ROOT || SOUS_WINDOWS },
|
||||
async () => {
|
||||
const { fs, documents, portable } = systemeSur(dossierTemporaire(), {
|
||||
publie: join('Documents', NOM_PRODUIT, 'cle'),
|
||||
});
|
||||
mkdirSync(documents, { recursive: true });
|
||||
chmodSync(documents, 0o555);
|
||||
try {
|
||||
const racines = await fs.racines();
|
||||
assert.deepEqual(await fs.sonder(racines.documents), { inscriptible: false, cause: 'EACCES' });
|
||||
assert.deepEqual(await fs.sonder(racines.portable), { inscriptible: false, cause: 'EACCES' });
|
||||
assert.ok(portable.startsWith(documents), portable);
|
||||
assert.deepEqual(readdirSync(documents), []);
|
||||
} finally {
|
||||
chmodSync(documents, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testNettoye("un lien qui sort de la racine lève CHEMIN_REFUSE, et rien ne s'écrit, ne se lit ni ne s'efface au-dehors", async () => {
|
||||
const dossier = dossierTemporaire();
|
||||
const dehors = join(dossierTemporaire(), 'dehors');
|
||||
// Au-dehors, un fichier et un verrou de la séance qui appelle, que
|
||||
// deverrouiller effacerait.
|
||||
const verrou = `${JSON.stringify({ seance: 'seance-a', pid: 777, hote: 'poste-essai', depuis: '2026-10-06T19:02:11-04:00' })}\n`;
|
||||
mkdirSync(dehors);
|
||||
writeFileSync(join(dehors, 'secret.txt'), 'hors de la racine');
|
||||
writeFileSync(join(dehors, 'soiree.gtt.verrou'), verrou);
|
||||
const { fs, documents } = systemeSur(dossier);
|
||||
mkdirSync(documents, { recursive: true });
|
||||
writeFileSync(join(documents, 'ici.txt'), 'ici');
|
||||
symlinkSync(dehors, join(documents, 'lien'), SOUS_WINDOWS ? 'junction' : 'dir');
|
||||
const racine = (await fs.racines()).documents;
|
||||
await echoue(fs.lireTexte(racine, 'lien/secret.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
|
||||
await echoue(fs.ecrireAtomique(racine, 'lien/neuf.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.txt' });
|
||||
await echoue(fs.ajouterLigne(racine, 'lien/secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'lien/secret.txt' });
|
||||
await echoue(fs.lister(racine, 'lien'), 'CHEMIN_REFUSE', { chemin: 'lien' });
|
||||
await echoue(fs.deplacer(racine, 'ici.txt', 'lien/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'lien/vole.txt' });
|
||||
await echoue(fs.verrouiller(racine, 'lien/neuf.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/neuf.gtt.verrou' });
|
||||
await echoue(fs.deverrouiller(racine, 'lien/soiree.gtt.verrou', 'seance-a'), 'CHEMIN_REFUSE', { chemin: 'lien/soiree.gtt.verrou' });
|
||||
if (!SOUS_WINDOWS) {
|
||||
symlinkSync(join(dehors, 'secret.txt'), join(documents, 'secret.txt'));
|
||||
await echoue(fs.lireTexte(racine, 'secret.txt'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
|
||||
await echoue(fs.ajouterLigne(racine, 'secret.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'secret.txt' });
|
||||
}
|
||||
assert.deepEqual(readdirSync(dehors).sort(), ['secret.txt', 'soiree.gtt.verrou']);
|
||||
assert.equal(readFileSync(join(dehors, 'secret.txt'), 'utf8'), 'hors de la racine');
|
||||
assert.equal(readFileSync(join(dehors, 'soiree.gtt.verrou'), 'utf8'), verrou);
|
||||
assert.equal(readFileSync(join(documents, 'ici.txt'), 'utf8'), 'ici');
|
||||
});
|
||||
|
||||
testNettoye("un dossier voisin dont le nom prolonge celui de la racine est hors d'elle, et reste intact", async () => {
|
||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||
const voisin = `${documents} (copie)`;
|
||||
mkdirSync(documents, { recursive: true });
|
||||
mkdirSync(voisin);
|
||||
writeFileSync(join(voisin, 'secret.txt'), 'dans le dossier voisin');
|
||||
writeFileSync(join(documents, 'ici.txt'), 'ici');
|
||||
symlinkSync(voisin, join(documents, 'voisin'), SOUS_WINDOWS ? 'junction' : 'dir');
|
||||
const racine = (await fs.racines()).documents;
|
||||
await echoue(fs.lireTexte(racine, 'voisin/secret.txt'), 'CHEMIN_REFUSE', { chemin: 'voisin/secret.txt' });
|
||||
await echoue(fs.ecrireAtomique(racine, 'voisin/neuf.txt', 'x'), 'CHEMIN_REFUSE', { chemin: 'voisin/neuf.txt' });
|
||||
await echoue(fs.deplacer(racine, 'ici.txt', 'voisin/vole.txt'), 'CHEMIN_REFUSE', { chemin: 'voisin/vole.txt' });
|
||||
assert.deepEqual(readdirSync(voisin), ['secret.txt']);
|
||||
assert.equal(readFileSync(join(voisin, 'secret.txt'), 'utf8'), 'dans le dossier voisin');
|
||||
assert.equal(readFileSync(join(documents, 'ici.txt'), 'utf8'), 'ici');
|
||||
});
|
||||
|
||||
testNettoye("l'écriture atomique remplace la cible par un renommage : le nom passe à un autre fichier, sans résidu", async () => {
|
||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||
mkdirSync(documents, { recursive: true });
|
||||
const racine = (await fs.racines()).documents;
|
||||
const cible = join(documents, 'soiree.gtt.json');
|
||||
writeFileSync(cible, 'ancien');
|
||||
const avant = statSync(cible).ino;
|
||||
await fs.ecrireAtomique(racine, 'soiree.gtt.json', 'nouveau');
|
||||
assert.equal(readFileSync(cible, 'utf8'), 'nouveau');
|
||||
assert.notEqual(statSync(cible).ino, avant);
|
||||
assert.deepEqual(readdirSync(documents), ['soiree.gtt.json']);
|
||||
});
|
||||
|
||||
testNettoye("le verrou d'un processus vivant de ce poste se dit vivant, celui d'un processus terminé non", async () => {
|
||||
const { fs, documents } = systemeSur(dossierTemporaire());
|
||||
mkdirSync(documents, { recursive: true });
|
||||
const racine = (await fs.racines()).documents;
|
||||
assert.deepEqual(await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-a'), { pris: true });
|
||||
const pose = JSON.parse(readFileSync(join(documents, 'soiree.gtt.verrou'), 'utf8'));
|
||||
assert.deepEqual([pose.pid, pose.hote], [process.pid, hostname()]);
|
||||
const tenu = await fs.verrouiller(racine, 'soiree.gtt.verrou', 'seance-b');
|
||||
assert.deepEqual(tenu, { pris: false, seance: 'seance-a', depuis: pose.depuis, vivant: true });
|
||||
|
||||
const termine = spawnSync(process.execPath, ['-e', '']);
|
||||
assert.equal(termine.status, 0);
|
||||
const depuis = '2026-10-06T19:02:11-04:00';
|
||||
writeFileSync(
|
||||
join(documents, 'ancienne.gtt.verrou'),
|
||||
`${JSON.stringify({ seance: 'seance-terminee', pid: termine.pid, hote: hostname(), depuis })}\n`,
|
||||
);
|
||||
assert.deepEqual(await fs.verrouiller(racine, 'ancienne.gtt.verrou', 'seance-b'), {
|
||||
pris: false,
|
||||
seance: 'seance-terminee',
|
||||
depuis,
|
||||
vivant: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
1400
test/fichiers_electron.test.js
Normal file
1400
test/fichiers_electron.test.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue