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
333 lines
12 KiB
JavaScript
333 lines
12 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
// Electron factice : la coquille s'éprouve sous Node, sans Electron.
|
|
//
|
|
// node --import <ce module> electron/main.js
|
|
//
|
|
// Chargé avant electron/main.js, ce module fait résoudre le spécificateur
|
|
// « electron » vers lui-même : le processus principal s'exécute contre les
|
|
// 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, 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. 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
|
|
// d'employer : un import absent d'ici fait échouer le chargement de
|
|
// 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, 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';
|
|
|
|
registerHooks({
|
|
resolve(specifier, context, nextResolve) {
|
|
if (specifier === 'electron') return { url: import.meta.url, shortCircuit: true };
|
|
return nextResolve(specifier, context);
|
|
},
|
|
});
|
|
|
|
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;
|
|
|
|
// 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.
|
|
menus: [],
|
|
// { loadFile: chemin } ou { loadURL: adresse }, par appel.
|
|
chargements: [],
|
|
// Canal de chaque ipcMain.handle ou ipcMain.on.
|
|
canaux: [],
|
|
// 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, 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,
|
|
// ni dans les microtâches qu'elle lance : whenReady() la rend prête à la
|
|
// phase setImmediate qui suit, puis résout, la même promesse à chaque appel.
|
|
// Sans appel à whenReady(), elle ne l'est jamais.
|
|
let pret = false;
|
|
let quandPret;
|
|
|
|
export const app = {
|
|
whenReady() {
|
|
journal.sequence.push('app.whenReady');
|
|
quandPret ??= new Promise((resoudre) => {
|
|
setImmediate(() => {
|
|
pret = true;
|
|
resoudre();
|
|
});
|
|
});
|
|
return quandPret;
|
|
},
|
|
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 {
|
|
constructor(options) {
|
|
if (!pret) throw new Error('Cannot create BrowserWindow before app is ready');
|
|
journal.sequence.push('new BrowserWindow');
|
|
journal.fenetres.push(options);
|
|
const page = { ouverture: null, ecouteurs: [] };
|
|
pageDeFenetre.push(page);
|
|
this.webContents = {
|
|
setWindowOpenHandler(gestionnaire) {
|
|
page.ouverture = gestionnaire;
|
|
},
|
|
on(evenement, ecouteur) {
|
|
page.ecouteurs.push({ evenement, ecouteur });
|
|
},
|
|
};
|
|
}
|
|
|
|
loadFile(chemin) {
|
|
journal.sequence.push('loadFile');
|
|
journal.chargements.push({ loadFile: chemin });
|
|
return Promise.resolve();
|
|
}
|
|
|
|
loadURL(adresse) {
|
|
journal.sequence.push('loadURL');
|
|
journal.chargements.push({ loadURL: adresse });
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
export const Menu = {
|
|
setApplicationMenu(menu) {
|
|
journal.sequence.push('Menu.setApplicationMenu');
|
|
journal.menus.push(menu);
|
|
},
|
|
};
|
|
|
|
export const ipcMain = {
|
|
handle(canal, gestionnaire) {
|
|
journal.sequence.push(`ipcMain.handle:${canal}`);
|
|
journal.canaux.push(canal);
|
|
if (gestionnaires.has(canal)) {
|
|
throw new Error(`Attempted to register a second handler for '${canal}'`);
|
|
}
|
|
gestionnaires.set(canal, gestionnaire);
|
|
},
|
|
on(canal) {
|
|
journal.sequence.push(`ipcMain.on:${canal}`);
|
|
journal.canaux.push(canal);
|
|
},
|
|
};
|
|
|
|
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.
|
|
function electronDuRendu(exposes) {
|
|
return {
|
|
contextBridge: {
|
|
exposeInMainWorld(cle, api) {
|
|
exposes.push({ cle, api });
|
|
},
|
|
},
|
|
ipcRenderer: {
|
|
async invoke(canal, ...arguments_) {
|
|
journal.invocations.push(canal);
|
|
const gestionnaire = gestionnaires.get(canal);
|
|
if (gestionnaire === undefined) throw new Error(`No handler registered for '${canal}'`);
|
|
return gestionnaire({ sender: null }, ...arguments_);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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() {
|
|
const requis = [];
|
|
const exposes = [];
|
|
const electron = electronDuRendu(exposes);
|
|
const requerir = (nom) => {
|
|
requis.push(nom);
|
|
if (nom === 'electron') return electron;
|
|
throw new Error(`module « ${nom} » indisponible dans un préchargement en bac à sable`);
|
|
};
|
|
const module = { exports: {} };
|
|
compileFunction(readFileSync(PRECHARGEMENT, 'utf8'), ['require', 'module', 'exports'], {
|
|
filename: PRECHARGEMENT,
|
|
})(requerir, module, module.exports);
|
|
|
|
const fichiers = exposes.find(({ cle }) => cle === 'gtt')?.api?.fichiers;
|
|
return {
|
|
requis,
|
|
exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })),
|
|
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) {
|
|
journal.ouvertures.push(page.ouverture === null ? null : page.ouverture({ url: externe }));
|
|
for (const { evenement, ecouteur } of page.ecouteurs) {
|
|
if (evenement !== 'will-navigate') continue;
|
|
let annulee = false;
|
|
ecouteur({ url: externe, preventDefault: () => (annulee = true) }, externe);
|
|
journal.navigations.push(annulee);
|
|
}
|
|
}
|
|
try {
|
|
journal.prechargement = await jouerLePrechargement();
|
|
} 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`);
|
|
}
|
|
|
|
process.once('beforeExit', () => {
|
|
jouerLaPage().catch((erreur) => {
|
|
process.stderr.write(`${erreur.stack}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
});
|