[ADD] electron: own shell, portable Windows exe built under podman

The deliverable is a single-file portable executable whose Electron the
project updates itself. The shell keeps context isolation on, Node
integration off and the page sandboxed; one IPC channel returns the
folder the portable launcher publishes. The build runs in a no-Wine image
pinned by digest, relays no host variable, refuses to overwrite a built
number, removes what a failed build leaves, and accepts only the name
version.json gives. The spec stops claiming the portable target needs
Wine.

Checked: exe built in 86 s, resource FileVersion 2026.1005.1.0 and
© 2026 TechnoLibre; 109 node tests pass; two end-of-build mutations fail.

--- FR ---

[ADD] electron : coquille propre, exécutable portable construit sous podman

Le livrable est un exécutable portable à fichier unique, dont le projet
met lui-même Electron à jour. La coquille garde l'isolation de contexte,
coupe l'intégration de Node et isole la page ; un seul canal IPC rend le
dossier que publie le lanceur portable. La construction s'exécute dans
une image sans Wine épinglée par empreinte, ne relaie aucune variable de
l'hôte, refuse d'écraser un numéro construit, retire ce qu'un échec
laisse, et n'accepte que le nom que donne version.json. Le spec cesse
d'affirmer que la cible portable exige Wine.

Vérifié : exécutable construit en 86 s, ressource FileVersion
2026.1005.1.0 et © 2026 TechnoLibre ; 109 épreuves node passent ; deux
mutations du contrôle de fin échouent.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-05 13:13:32 -04:00
parent 9fc104d0e9
commit a3354e2b0b
13 changed files with 4510 additions and 3 deletions

View file

@ -0,0 +1,26 @@
# Gestion table tournante Libre
Logiciel hors ligne qui place les personnes d'un événement autour de tables et
les fait tourner sur plusieurs tours, pour que chacune rencontre le plus de
monde possible.
## Commandes
| commande | effet |
|---|---|
| `npm install` | installe les outils de construction et d'épreuve |
| `npx playwright install chromium` | télécharge le Chromium des épreuves navigateur, une fois par machine |
| `npm run dev` | sert l'application en développement, sur la plateforme web |
| `npm test` | série `node` : les fonctions pures, à chaque modification |
| `npm run test:long` | épreuves lourdes, avant un commit |
| `npm run test:navigateur` | épreuves de rendu dans Chromium, avant une livraison |
| `npm run build` | construit la page dans `www/` |
| `scripts/construire_windows.sh` | construit l'exécutable Windows portable, sous `podman` |
L'exécutable porte la version datée du logiciel dans son nom, selon le gabarit
`gestion_table_tournante_libre_v<AAAA>_<MM>_<JJ>_<NN>.exe`.
## Licence
© 2026 TechnoLibre. GNU Affero General Public License, version 3 ou ultérieure ;
texte complet dans `LICENSE`.

View file

@ -0,0 +1,35 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Configuration d'electron-builder : un exécutable Windows portable à fichier
// unique (§ 16), nommé d'après la version datée (§ 18.2).
//
// electron-builder ne lit ce fichier que par « --config
// electron-builder.config.cjs » : son nom n'est pas de ceux qu'il cherche
// seul, et sans lui il retombe sur ses défauts, dont un installateur. Le
// script scripts/construire_windows.sh le lui passe.
//
// Le nom du livrable sort de deriver, appliqué à version.json : la
// configuration ne le compose pas (§ 13.2). scripts/version.js est un module
// ESM, chargé ici par require : Node l'accepte depuis 20.19 et 22.12, pour un
// module sans await de premier niveau.
const { readFileSync } = require('node:fs');
const { join } = require('node:path');
const { deriver } = require('./scripts/version.js');
const lireJson = (nom) => JSON.parse(readFileSync(join(__dirname, nom), 'utf8'));
module.exports = {
// Un seul identifiant pour toutes les plateformes, celui de Capacitor.
appId: lireJson('capacitor.config.json').appId,
productName: lireJson('package.json').productName,
// Champ LegalCopyright de la ressource de version de l'exécutable.
copyright: '© 2026 TechnoLibre',
directories: { output: 'dist-electron' },
// La page construite, la coquille, et package.json, dont electron-builder
// tire le point d'entrée. package.json ne déclarant aucune dépendance
// d'exécution, aucun node_modules n'entre dans le paquet.
files: ['www/**', 'electron/**', 'package.json'],
artifactName: deriver(lireJson('version.json').version).nomFichier,
win: { target: [{ target: 'portable', arch: ['x64'] }] },
};

48
electron/main.js Normal file
View file

@ -0,0 +1,48 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// 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.
//
// 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 } from 'electron';
import { fileURLToPath } from 'node:url';
// 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);
function creerFenetre() {
const fenetre = new BrowserWindow({
width: 1366,
height: 768,
webPreferences: {
preload: PRECHARGEMENT,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
// La page ne quitte jamais www/ : toute nouvelle fenêtre est refusée, toute
// navigation annulée, quelle que soit sa cible.
fenetre.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
fenetre.webContents.on('will-navigate', (evenement) => evenement.preventDefault());
fenetre.loadFile(PAGE);
}
app.whenReady().then(creerFenetre);

19
electron/preload.cjs Normal file
View file

@ -0,0 +1,19 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// 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.
//
// 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.
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'),
});

3176
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
"author": "TechnoLibre (http://www.technolibre.ca)",
"private": true,
"type": "module",
"main": "electron/main.js",
"scripts": {
"dev": "vite --mode developpement",
"build": "vite build --mode developpement",
@ -23,6 +24,8 @@
"@capacitor/core": "^8.5.2",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@vitest/browser-playwright": "^5.0.3",
"electron": "^44.5.1",
"electron-builder": "^26.15.3",
"fast-check": "^4.10.2",
"playwright": "^1.63.0",
"svelte": "^5.57.1",

171
scripts/construire_windows.sh Executable file
View file

@ -0,0 +1,171 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
# Construit l'exécutable Windows portable (§ 16) dans dist-electron/, par
# electron-builder exécuté sous podman, puis imprime son chemin, sa taille et
# ce qu'en dit « file ».
#
# scripts/construire_windows.sh refuse si l'exécutable existe
# scripts/construire_windows.sh --reconstruire l'efface, puis le reconstruit
#
# Sortie : 0, l'exécutable est construit ; 1, refus ou échec ; 2, option
# inconnue ; 130 ou 143, interrompu par INT ou TERM. La racine du projet est
# le parent du répertoire du script.
#
# Chaque étape arrête la suite quand elle échoue :
# 1. « node scripts/version.js controler » applique les huit points du
# § 18.5 : une version sans section au changelog ne se construit pas.
# 2. Le nom du livrable est celui que deriver tire de version.json : ce script
# ne le compose pas. electron-builder.config.cjs doit passer le même nom à
# electron-builder ; s'il en passe un autre, tout s'arrête là.
# 3. Un exécutable de ce nom dans dist-electron/ arrête tout (§ 18.2), sauf
# --reconstruire, qui l'efface d'abord. Passé cette étape, un fichier de ce
# nom ne peut venir que de cette exécution.
# 4. La page est construite sur l'hôte, en mode livraison.
# 5. electron-builder emballe www/ et electron/ dans le conteneur.
# 6. Contrôle de fin de construction (§ 18.5) : le seul exécutable apparu au
# premier niveau de dist-electron/ porte le nom de l'étape 2.
# De l'étape 5 à l'acceptation par l'étape 6, toute sortie retire le fichier
# de ce nom : une construction qui échoue, ou que le contrôle refuse, ne
# laisse aucun exécutable sous ce nom.
set -euo pipefail
# Image sans Wine, épinglée par empreinte. La cible portable n'appelle pas
# Wine ; une cible qui l'exige, un installateur, échoue dans cette image au
# lieu de produire le livrable que le § 16 exclut. L'empreinte fige l'image,
# qu'une étiquette ferait avancer à chaque publication.
readonly IMAGE='docker.io/electronuserland/builder@sha256:e41cd059a4fdc0831ccd2a55cb59cbc6b73dc5f44c625778f7fda38874a70df3'
readonly SORTIE='dist-electron'
readonly CONFIGURATION='electron-builder.config.cjs'
refuser() {
printf 'Construction refusée : %s\n' "$1" >&2
exit 1
}
# Noms des fichiers .exe au premier niveau de dist-electron/, un par ligne.
executables() {
find "$SORTIE" -maxdepth 1 -type f -name '*.exe' -printf '%f\n'
}
# Retire le fichier du nom du livrable qu'aucun contrôle de fin n'a accepté.
# electron-builder l'écrit avant d'avoir fini : resté là après un échec ou un
# refus, il serait refusé à l'exécution suivante comme un exécutable déjà
# construit.
retirer_non_controle() {
[[ -e "$cible" ]] || return 0
rm -f -- "$cible"
printf '%s retiré : il n’a pas passé le contrôle de fin de construction.\n' "$cible" >&2
}
reconstruire=0
for argument in "$@"; do
case "$argument" in
--reconstruire) reconstruire=1 ;;
*)
printf 'usage : scripts/construire_windows.sh [--reconstruire]\n' >&2
exit 2
;;
esac
done
racine="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd -- "$racine"
command -v podman >/dev/null || refuser 'podman introuvable : electron-builder s’exécute dans un conteneur.'
# 1. Contrôle de version.
node scripts/version.js controler \
|| refuser 'le contrôle de version du § 18.5 échoue (voir ci-dessus).'
# 2. Nom du livrable. Un seul lancement de node imprime deux lignes : le nom
# que deriver tire de version.json, puis celui que la configuration passe à
# electron-builder. L'étape 1 a déjà appliqué deriver à version.json : un
# échec vient de la configuration. Les deux noms doivent coïncider avant
# l'étape 3, la première qui efface : une configuration refusée n'efface
# rien, même sous --reconstruire, et « rm » ne reçoit que le nom calculé.
noms="$(node -e "
const { deriver } = require('./scripts/version.js');
console.log(deriver(require('./version.json').version).nomFichier);
console.log(require('./$CONFIGURATION').artifactName);
")" || refuser "$CONFIGURATION ne se charge pas."
nom="${noms%%$'\n'*}"
nom_configuration="${noms#*$'\n'}"
[[ "$nom_configuration" == "$nom" ]] \
|| refuser "$CONFIGURATION nomme le livrable $nom_configuration ; version.json donne $nom (§ 18.2)."
cible="$SORTIE/$nom"
# 3. Refus d'écraser.
if [[ -e "$cible" ]]; then
if (( ! reconstruire )); then
refuser "$cible existe déjà. Un numéro ne désigne qu'un binaire (§ 18.2) : changer la version, ou relancer avec --reconstruire si cet exécutable n'a pas quitté cette machine."
fi
rm -f -- "$cible"
fi
# 4. La page.
npm run --silent build:livraison || refuser 'la construction de la page échoue.'
[[ -f www/index.html ]] || refuser 'www/index.html manque après la construction de la page.'
# 5. L'exécutable. Le projet est monté en lecture seule : seuls dist-electron/
# et les volumes nommés sont inscriptibles. Le volume monté sur
# /project/node_modules masque celui de l'hôte, que le « npm ci » du
# conteneur, sous un autre Node, écraserait. « --ignore-scripts » n'exécute
# aucun script d'installation : electron-builder télécharge lui-même
# l'Electron Windows qu'il emballe. /root/.cache garde ce téléchargement et
# les outils d'electron-builder, /root/.npm les archives npm, d'une
# construction à l'autre.
# La commande ne passe aucune variable, et --env-host=false refuse
# l'environnement de l'hôte quel que soit containers.conf, dont un
# env_host = true le transmettrait entier : BUILD_NUMBER et les numéros de
# construction des services d'intégration continue y remplaceraient le
# quatrième champ de la version Windows. podman relaie encore les variables
# de mandataire de l'hôte (http_proxy, https_proxy, ftp_proxy, no_proxy, en
# minuscules comme en majuscules) : derrière un mandataire, npm et
# electron-builder en ont besoin pour télécharger, et elles ne touchent pas
# à la version.
mkdir -p -- "$SORTIE"
declare -A avant=()
while IFS= read -r fichier; do
avant["$fichier"]=1
done < <(executables)
# Le retrait s'arme avant podman. INT et TERM mènent à exit, donc au retrait,
# une fois podman terminé : le shell n'exécute un piège qu'après la commande
# en cours, et podman ne peut plus rien écrire après le retrait. Sans ces deux
# pièges, TERM arrête le shell sur-le-champ, podman continue d'écrire après le
# retrait, et INT reçu par le shell seul n'interrompt rien.
trap retirer_non_controle EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
printf 'Construction de %s sous podman.\n' "$nom"
podman run --rm --env-host=false \
-v "$racine:/project:ro" \
-v "$racine/$SORTIE:/project/$SORTIE" \
-v gestion_table_tournante_libre-node_modules:/project/node_modules \
-v gestion_table_tournante_libre-cache:/root/.cache \
-v gestion_table_tournante_libre-npm:/root/.npm \
-w /project \
"$IMAGE" \
bash -c "npm ci --ignore-scripts --no-audit --no-fund && npx --no-install electron-builder --win --publish never --config $CONFIGURATION" \
|| refuser "electron-builder échoue sous podman (code $?)."
# 6. Contrôle de fin de construction.
apparus=()
while IFS= read -r fichier; do
[[ -n "${avant["$fichier"]-}" ]] || apparus+=("$fichier")
done < <(executables)
if (( ${#apparus[@]} == 0 )); then
refuser "aucun exécutable n'est apparu dans $SORTIE/ ; attendu : $nom."
fi
if (( ${#apparus[@]} > 1 )) || [[ "${apparus[0]}" != "$nom" ]]; then
refuser "exécutable apparu : ${apparus[*]} ; attendu : $nom, le seul nom que version.json donne (§ 18.5)."
fi
# Accepté : l'exécutable reste, même si l'impression qui suit est interrompue.
trap - EXIT INT TERM
printf 'Exécutable : %s\n' "$racine/$cible"
printf 'Taille : %s octets\n' "$(wc -c < "$cible")"
printf 'Type : %s\n' "$(file -b -- "$cible" 2>/dev/null || echo 'inconnu, « file » indisponible')"

View file

@ -0,0 +1,476 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le script de construction de l'exécutable Windows (§ 16, § 18.2, § 18.5) :
// ce qu'il refuse avant de construire, la commande qu'il passe au conteneur,
// le contrôle de fin de construction, et ce qu'il laisse quand la
// construction échoue ou qu'un signal l'interrompt.
//
// Chaque épreuve copie le script et les fichiers qu'il lit dans un arbre
// temporaire, et le lance avec, en tête du PATH, un podman et un npm
// factices. Ils consignent leurs arguments. podman écrit chaque nom de
// FAUX_PRODUIT, un par ligne, dans le dossier de l'hôte qu'il reçoit monté
// sur /project/dist-electron, comme electron-builder y écrirait
// l'exécutable ; il y écrit aussi, à chaque lancement, les deux exécutables
// qu'electron-builder pose toujours sous win-unpacked/, aux profondeurs 2
// et 3 ; puis il sort en FAUX_CODE ; sous FAUX_ATTENTE, il s'annonce sur
// sa sortie et attend une ligne sur son entrée avant d'écrire. npm écrit
// www/index.html, sauf sous FAUX_SANS_PAGE, puis sort en FAUX_CODE_NPM.
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { once } from 'node:events';
import {
accessSync,
appendFileSync,
chmodSync,
constants,
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import { versionVoisine } from '../test/version_voisine.js';
import { deriver } from './version.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const VERSION = JSON.parse(readFileSync(join(RACINE, 'version.json'), 'utf8')).version;
const NOM = deriver(VERSION).nomFichier;
// Le livrable d'une autre version du même jour : il reste dans dist-electron/
// quoi que fasse la construction de la version courante.
const NOM_AUTRE_VERSION = deriver(versionVoisine(VERSION)).nomFichier;
// Délai d'un lancement du script. Il reste sous celui d'une épreuve de
// Vitest, 5 s, qui n'interrompt pas un appel synchrone : un script qui ne
// s'arrête pas fait échouer son épreuve en le disant, au lieu de figer la
// série. Le délai écoulé, le script reçoit KILL : il piège TERM et attend
// la fin de la commande en cours, si bien que TERM n'arrêterait pas un
// script dont un enfant ne finit pas.
const DELAI_MS = 4_000;
// Ce que le script lit, et ce que lit le contrôle de version qu'il lance.
const COPIES = [
'version.json',
'package.json',
'package-lock.json',
'CHANGELOG.md',
'README.md',
'capacitor.config.json',
'electron-builder.config.cjs',
'scripts/version.js',
'scripts/construire_windows.sh',
'src/version.genere.js',
'src/main.js',
];
const LANCE = 'podman factice lancé';
const PODMAN_FACTICE = [
'#!/usr/bin/env bash',
'printf "%s\\0" "$@" > "$JOURNAL/podman"',
'echo podman >> "$JOURNAL/ordre"',
'for argument in "$@"; do',
' case "$argument" in',
' *:/project/dist-electron) sortie="${argument%:/project/dist-electron}" ;;',
' esac',
'done',
'if [[ -n "${FAUX_ATTENTE-}" ]]; then',
` echo '${LANCE}'`,
' read -r _',
'fi',
'mkdir -p "$sortie/win-unpacked/resources"',
'printf MZ > "$sortie/win-unpacked/Gestion table tournante Libre.exe"',
'printf MZ > "$sortie/win-unpacked/resources/elevate.exe"',
'while IFS= read -r produit; do',
' [[ -z "$produit" ]] || printf MZ > "$sortie/$produit"',
'done <<< "${FAUX_PRODUIT-}"',
'exit "${FAUX_CODE:-0}"',
'',
].join('\n');
const NPM_FACTICE = [
'#!/usr/bin/env bash',
'printf "%s\\0" "$@" > "$JOURNAL/npm"',
'echo npm >> "$JOURNAL/ordre"',
'[[ -n "${FAUX_SANS_PAGE-}" ]] || { mkdir -p www && echo "<!doctype html>" > www/index.html; }',
'exit "${FAUX_CODE_NPM:-0}"',
'',
].join('\n');
// node qui se consigne dans l'ordre des lancements, puis passe la main au
// vrai : sert là où le PATH est réduit à bin/.
const NODE_CONSIGNE = [
'#!/usr/bin/env bash',
'echo node >> "$JOURNAL/ordre"',
`exec '${process.execPath}' "$@"`,
'',
].join('\n');
// Outils que le script appelle, hors podman, npm et node. Un PATH réduit à
// bin/ en reçoit des liens : seul podman y manque.
const OUTILS = ['bash', 'dirname', 'find', 'mkdir', 'rm', 'wc', 'file'];
// Chemin d'un exécutable dans le PATH de l'épreuve, ou null.
function chercher(nom) {
for (const dossier of (process.env.PATH ?? '').split(delimiter)) {
try {
accessSync(join(dossier, nom), constants.X_OK);
return join(dossier, nom);
} catch {
// Absent de ce dossier : le suivant.
}
}
return null;
}
const lireSiPresent = (chemin) => (existsSync(chemin) ? readFileSync(chemin, 'utf8') : '');
// Arguments consignés par un factice, ou null s'il n'a pas été lancé.
function argumentsDe(journal, factice) {
const texte = lireSiPresent(join(journal, factice));
return texte === '' ? null : texte.split('\0').slice(0, -1);
}
// Contenu de chaque fichier .exe au premier niveau de dist-electron/.
function executables(projet) {
const sortie = join(projet, 'dist-electron');
if (!existsSync(sortie)) return {};
return Object.fromEntries(
readdirSync(sortie)
.filter((nom) => nom.endsWith('.exe'))
.sort()
.map((nom) => [nom, readFileSync(join(sortie, nom), 'utf8')]),
);
}
// Un arbre neuf, prêt au lancement : le projet recopié, bin/ et ses
// factices, le journal, l'environnement du script. produit est un nom ou une
// liste de noms que podman écrit ; page: false et codeNpm règlent npm ;
// attente règle podman. sansPodman réduit le PATH à bin/, qui reçoit les
// OUTILS et NODE_CONSIGNE, sans podman. preparer reçoit la racine du projet.
function preparerArbre({ produit, code, codeNpm, page = true, attente = false, sansPodman = false, preparer } = {}) {
const base = mkdtempSync(join(tmpdir(), 'construire-windows-'));
const projet = join(base, 'projet');
const bin = join(base, 'bin');
const journal = join(base, 'journal');
for (const chemin of COPIES) {
mkdirSync(dirname(join(projet, chemin)), { recursive: true });
copyFileSync(join(RACINE, chemin), join(projet, chemin));
}
mkdirSync(bin);
mkdirSync(journal);
const factices = sansPodman
? [['npm', NPM_FACTICE], ['node', NODE_CONSIGNE]]
: [['podman', PODMAN_FACTICE], ['npm', NPM_FACTICE]];
for (const [nom, contenu] of factices) {
writeFileSync(join(bin, nom), contenu);
chmodSync(join(bin, nom), 0o755);
}
if (sansPodman) {
for (const nom of OUTILS) {
const chemin = chercher(nom);
if (chemin !== null) symlinkSync(chemin, join(bin, nom));
}
}
preparer?.(projet);
const environnement = {
...process.env,
PATH: sansPodman ? bin : [bin, dirname(process.execPath), process.env.PATH].join(delimiter),
JOURNAL: journal,
};
for (const cle of ['FAUX_PRODUIT', 'FAUX_CODE', 'FAUX_ATTENTE', 'FAUX_SANS_PAGE', 'FAUX_CODE_NPM']) {
delete environnement[cle];
}
if (produit !== undefined) environnement.FAUX_PRODUIT = [produit].flat().join('\n');
if (code !== undefined) environnement.FAUX_CODE = String(code);
if (codeNpm !== undefined) environnement.FAUX_CODE_NPM = String(codeNpm);
if (!page) environnement.FAUX_SANS_PAGE = '1';
if (attente) environnement.FAUX_ATTENTE = '1';
return { base, projet, journal, environnement, script: join(projet, 'scripts', 'construire_windows.sh') };
}
// Lance le script dans un arbre neuf, puis rend ce qu'il a imprimé, ce que
// les factices ont consigné et les exécutables de dist-electron/. Les
// paramètres sont ceux de preparerArbre, plus options, les arguments du
// script, et relever, appelé sur la racine du projet avant son retrait :
// son résultat est rendu sous « releve ».
function construire({ options = [], relever, ...parametres } = {}) {
const arbre = preparerArbre(parametres);
try {
const resultat = spawnSync(arbre.script, options, {
cwd: arbre.base,
encoding: 'utf8',
env: arbre.environnement,
timeout: DELAI_MS,
killSignal: 'SIGKILL',
});
assert.notEqual(
resultat.error?.code,
'ETIMEDOUT',
`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms :\n${resultat.stderr}`,
);
return {
statut: resultat.status,
sortie: resultat.stdout,
erreurs: `${resultat.error?.message ?? ''}${resultat.stderr}`,
projet: arbre.projet,
ordre: lireSiPresent(join(arbre.journal, 'ordre')).split('\n').filter(Boolean),
npm: argumentsDe(arbre.journal, 'npm'),
podman: argumentsDe(arbre.journal, 'podman'),
executables: executables(arbre.projet),
releve: relever?.(arbre.projet),
};
} finally {
rmSync(arbre.base, { recursive: true, force: true });
}
}
const ecrireExecutable = (contenu, nom = NOM) => (projet) => {
mkdirSync(join(projet, 'dist-electron'), { recursive: true });
writeFileSync(join(projet, 'dist-electron', nom), contenu);
};
// La configuration recopiée tape le nom du livrable : la ligne ajoutée
// remplace celui qu'elle tient de deriver.
const taperLeNom = (nom) => (projet) =>
appendFileSync(join(projet, 'electron-builder.config.cjs'), `module.exports.artifactName = ${JSON.stringify(nom)};\n`);
// Une construction réussie, partagée par les épreuves qui la lisent.
let reussie;
const constructionReussie = () => (reussie ??= construire({ produit: NOM }));
describe('construction Windows : refus avant de construire', () => {
test('une version sans section au changelog ne se construit pas (§ 16)', () => {
const r = construire({
preparer: (projet) =>
writeFileSync(join(projet, 'CHANGELOG.md'), '# Changelog — Gestion table tournante Libre\n'),
});
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /^3\. CHANGELOG\.md/m);
assert.match(r.erreurs, /Construction refusée/);
assert.deepEqual(r.ordre, []);
});
test("un exécutable du même nom n'est pas écrasé (§ 18.2)", () => {
const r = construire({ produit: NOM, preparer: ecrireExecutable('ancien') });
assert.equal(r.statut, 1, r.erreurs);
assert.ok(r.erreurs.includes(`dist-electron/${NOM}`), r.erreurs);
assert.match(r.erreurs, /--reconstruire/);
assert.deepEqual(r.ordre, []);
assert.deepEqual(r.executables, { [NOM]: 'ancien' });
});
test('une option inconnue sort en 2 et rappelle l’usage', () => {
const r = construire({ options: ['--forcer'] });
assert.equal(r.statut, 2, r.erreurs);
assert.match(r.erreurs, /usage .*--reconstruire/);
assert.deepEqual(r.ordre, []);
});
test('sans podman dans le PATH, refuse avant le contrôle de version', () => {
const r = construire({ sansPodman: true });
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /podman introuvable/);
assert.deepEqual(r.ordre, []);
});
test("une configuration d'electron-builder qui ne se charge pas arrête tout, avant la page", () => {
const r = construire({
preparer: (projet) =>
writeFileSync(join(projet, 'electron-builder.config.cjs'), "throw new Error('configuration illisible');\n"),
});
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /electron-builder\.config\.cjs ne se charge pas/);
assert.deepEqual(r.ordre, []);
});
test('une configuration qui nomme le livrable autrement que deriver depuis version.json arrête tout, avant la page (§ 18.2)', () => {
// podman produirait le nom tapé : seul le nom calculé depuis version.json
// distingue cette construction d'une construction conforme.
const r = construire({ produit: NOM_AUTRE_VERSION, preparer: taperLeNom(NOM_AUTRE_VERSION) });
assert.equal(r.statut, 1, r.erreurs);
assert.ok(r.erreurs.includes(NOM_AUTRE_VERSION) && r.erreurs.includes(NOM), r.erreurs);
assert.deepEqual(r.ordre, []);
assert.deepEqual(r.executables, {});
});
test("--reconstruire n'efface rien quand la configuration nomme le livrable autrement : ni l'exécutable, ni le fichier qu'elle désigne", () => {
const tape = '../version.json';
const r = construire({
options: ['--reconstruire'],
preparer: (projet) => {
ecrireExecutable('ancien')(projet);
taperLeNom(tape)(projet);
},
relever: (projet) => existsSync(join(projet, 'version.json')),
});
assert.equal(r.statut, 1, r.erreurs);
assert.ok(r.erreurs.includes(tape) && r.erreurs.includes(NOM), r.erreurs);
assert.deepEqual(r.ordre, []);
assert.deepEqual(r.executables, { [NOM]: 'ancien' });
assert.equal(r.releve, true, 'version.json effacé');
});
});
describe('construction Windows : la page', () => {
test('une page qui ne se construit pas arrête tout, avant podman', () => {
const r = construire({ produit: NOM, codeNpm: 1 });
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /la construction de la page échoue/);
assert.deepEqual(r.ordre, ['npm']);
assert.deepEqual(r.executables, {});
});
test('une page absente après sa construction arrête tout, avant podman', () => {
const r = construire({ produit: NOM, page: false });
assert.equal(r.statut, 1, r.erreurs);
assert.match(r.erreurs, /www\/index\.html manque/);
assert.deepEqual(r.ordre, ['npm']);
assert.deepEqual(r.executables, {});
});
});
describe('construction Windows : la construction', () => {
test("construit la page puis l'exécutable, et imprime son chemin, sa taille et son type", () => {
const r = constructionReussie();
assert.equal(r.statut, 0, r.erreurs);
assert.deepEqual(r.ordre, ['npm', 'podman']);
assert.deepEqual(r.npm, ['run', '--silent', 'build:livraison']);
assert.deepEqual(r.executables, { [NOM]: 'MZ' });
assert.ok(r.sortie.includes(join(r.projet, 'dist-electron', NOM)), r.sortie);
assert.match(r.sortie, /^Taille +: 2 octets$/m);
assert.match(r.sortie, /^Type +: \S/m);
});
test("le conteneur : image épinglée, projet en lecture seule, node_modules de l'hôte masqué, aucune variable de l'hôte, quel que soit containers.conf", () => {
const { statut, erreurs, projet, podman } = constructionReussie();
assert.equal(statut, 0, erreurs);
assert.equal(podman[0], 'run');
// Tout ce qui suit l'image est la commande du conteneur : les options de
// podman la précèdent toutes.
const image = podman.findIndex((a) => /^docker\.io\/electronuserland\/builder@sha256:[0-9a-f]{64}$/.test(a));
assert.notEqual(image, -1, 'image non épinglée par empreinte');
assert.deepEqual(podman.slice(image + 1, -1), ['bash', '-c']);
const options = podman.slice(1, image);
assert.ok(options.includes('--rm'));
assert.ok(options.includes(`${projet}:/project:ro`), 'projet non monté en lecture seule');
assert.ok(options.includes(`${projet}/dist-electron:/project/dist-electron`));
assert.ok(
options.some((a) => /^[\w.-]+:\/project\/node_modules$/.test(a)),
'node_modules non masqué par un volume nommé',
);
// Aucune variable passée, et l'environnement de l'hôte refusé sur la ligne
// de commande : un env_host = true de containers.conf le transmettrait
// entier, BUILD_NUMBER compris.
assert.deepEqual(options.filter((a) => a === '-e' || a.startsWith('--env')), ['--env-host=false']);
const commande = podman.at(-1);
assert.match(commande, /npm ci --ignore-scripts/);
assert.match(
commande,
/electron-builder --win --publish never --config electron-builder\.config\.cjs/,
);
});
test("accepte le livrable quand dist-electron/ garde celui d'une autre version et les exécutables de win-unpacked/", () => {
const r = construire({ produit: NOM, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) });
assert.equal(r.statut, 0, r.erreurs);
assert.deepEqual(r.executables, { [NOM]: 'MZ', [NOM_AUTRE_VERSION]: 'autre' });
});
test("--reconstruire remplace un exécutable du même nom", () => {
const r = construire({ options: ['--reconstruire'], produit: NOM, preparer: ecrireExecutable('ancien') });
assert.equal(r.statut, 0, r.erreurs);
assert.deepEqual(r.executables, { [NOM]: 'MZ' });
});
});
describe('construction Windows : contrôle de fin de construction (§ 18.5)', () => {
test("refuse un exécutable dont le nom ne dérive pas de version.json", () => {
const autre = 'Gestion table tournante Libre.exe';
const r = construire({ produit: autre });
assert.equal(r.statut, 1, r.sortie);
assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs);
assert.doesNotMatch(r.sortie, /^Taille/m);
});
test("refuse quand aucun exécutable n'apparaît", () => {
const r = construire();
assert.equal(r.statut, 1, r.sortie);
assert.match(r.erreurs, /aucun exécutable/);
});
test("un échec sous podman ne laisse aucun exécutable sous ce nom, même avec --reconstruire", () => {
const r = construire({ options: ['--reconstruire'], code: 7, preparer: ecrireExecutable('ancien') });
assert.equal(r.statut, 1, r.sortie);
assert.match(r.erreurs, /podman.*7/);
assert.deepEqual(r.executables, {});
});
test("un échec sous podman retire ce qu'electron-builder a déjà écrit sous ce nom, et rien d'autre", () => {
const r = construire({ produit: NOM, code: 7, preparer: ecrireExecutable('autre', NOM_AUTRE_VERSION) });
assert.equal(r.statut, 1, r.sortie);
assert.match(r.erreurs, /podman.*7/);
assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs);
assert.deepEqual(r.executables, { [NOM_AUTRE_VERSION]: 'autre' });
});
test("deux exécutables apparus : refusé, et celui qui porte le nom attendu est retiré", () => {
const autre = 'Gestion table tournante Libre.exe';
const r = construire({ produit: [NOM, autre] });
assert.equal(r.statut, 1, r.sortie);
assert.ok(r.erreurs.includes(autre) && r.erreurs.includes(NOM), r.erreurs);
assert.ok(r.erreurs.includes(`dist-electron/${NOM} retiré`), r.erreurs);
assert.equal(r.executables[NOM], undefined, `dist-electron/${NOM} reste après un refus`);
assert.doesNotMatch(r.sortie, /^Taille/m);
});
});
describe('construction Windows : interruption', () => {
// Le signal ne vise que le shell, comme un kill depuis un autre terminal.
// podman écrit après l'avoir reçu, comme electron-builder qui achève son
// travail : l'épreuve ne réagit qu'à des événements, l'annonce du podman
// factice puis la fin du script, et le délai ne fait que les borner.
for (const [signal, code] of [['SIGTERM', 143], ['SIGINT', 130]]) {
test(`${signal} reçu pendant podman : le script attend sa fin, retire ce qu'il a écrit et sort en ${code}`, async () => {
const arbre = preparerArbre({ produit: NOM, attente: true });
const script = spawn(arbre.script, [], { cwd: arbre.base, env: arbre.environnement });
script.stdin.on('error', () => {});
let sortie = '';
let erreurs = '';
script.stderr.setEncoding('utf8').on('data', (morceau) => (erreurs += morceau));
const lance = new Promise((resoudre) => {
script.stdout.setEncoding('utf8').on('data', (morceau) => {
sortie += morceau;
if (sortie.includes(LANCE)) resoudre();
});
});
const fin = once(script, 'close', { signal: AbortSignal.timeout(DELAI_MS) }).catch((erreur) => {
throw new Error(`scripts/construire_windows.sh ne s'arrête pas en ${DELAI_MS} ms (${erreur.name})\n${erreurs}`);
});
fin.catch(() => {});
try {
await Promise.race([lance, fin]);
assert.ok(sortie.includes(LANCE), `podman factice jamais lancé\n${erreurs}`);
script.kill(signal);
script.stdin.end('\n');
const [statut, signalDeFin] = await fin;
assert.deepEqual({ statut, signalDeFin }, { statut: code, signalDeFin: null }, erreurs);
assert.ok(erreurs.includes(`dist-electron/${NOM} retiré`), erreurs);
assert.deepEqual(executables(arbre.projet), {});
} finally {
script.stdin.destroy();
script.kill('SIGKILL');
rmSync(arbre.base, { recursive: true, force: true });
}
});
}
});

11
spec.md
View file

@ -3787,9 +3787,14 @@ ne désigne nulle part une organisation existante.
L'exécutable Windows se construit depuis Linux par `electron-builder`, qui
emballe la coquille Electron du projet et le même `www/` que servent les
plateformes de Capacitor (§ 13.1). Une cible Windows construite depuis Linux passe
par Wine ; la construction s'exécute donc dans un conteneur qui le porte, et la
machine de développement n'a rien à installer en dehors du projet.
plateformes de Capacitor (§ 13.1). La construction s'exécute dans un conteneur,
sous une image épinglée par empreinte, de sorte que la machine de développement
n'a rien à installer en dehors du projet. La cible portable n'exige pas Wine :
`electron-builder` édite lui-même les ressources de l'exécutable. L'image n'en
porte donc pas, et une cible qui l'exigerait — un installateur — y échoue au lieu
de produire le livrable que ce paragraphe exclut. Le conteneur ne reçoit aucune
variable de l'hôte : un numéro de construction d'intégration continue y
remplacerait le quatrième champ de la version Windows (§ 18.1).
**Cette capacité est à vérifier dès la première semaine**, sur un squelette vide,
au même titre que la publication du dossier d'origine par le lanceur portable

140
test/coquille.test.js Normal file
View file

@ -0,0 +1,140 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// 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).
//
// 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.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
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';
// 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é
// actif l'en empêche, et l'exécution ne finit jamais. Le délai reste sous
// celui d'une épreuve de Vitest, 5 s, qui n'interrompt pas un appel
// synchrone : l'épreuve échoue en le disant, au lieu de figer la série. Le
// délai écoulé, le processus reçoit KILL, qu'aucun gestionnaire de signal
// 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() {
const environnement = { ...process.env };
delete environnement.PORTABLE_EXECUTABLE_DIR;
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
try {
const resultat = spawnSync(process.execPath, ['--import', FACTICE, PRINCIPAL], {
cwd: ailleurs,
encoding: 'utf8',
env: environnement,
timeout: DELAI_MS,
killSignal: 'SIGKILL',
});
assert.notEqual(
resultat.error?.code,
'ETIMEDOUT',
`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);
} finally {
rmSync(ailleurs, { 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) {
try {
execution = { journal: executerLaCoquille() };
} catch (erreur) {
execution = { erreur };
}
}
if (execution.erreur !== undefined) throw execution.erreur;
return execution.journal;
}
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", () => {
const { fenetres } = journalDeLaCoquille();
assert.equal(fenetres.length, 1);
const [{ width, height, webPreferences }] = fenetres;
assert.deepEqual({ width, height }, { width: 1366, height: 768 });
assert.deepEqual(webPreferences, {
preload: PRECHARGEMENT,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
});
assert.ok(existsSync(webPreferences.preload), `${webPreferences.preload} absent`);
});
test('la fenêtre charge la page que Vite construit dans www/', () => {
assert.deepEqual(journalDeLaCoquille().chargements, [
{ loadFile: join(RACINE, 'www', 'index.html') },
]);
});
test(`un seul canal IPC, ${CANAL}, posé avant le chargement de la page`, () => {
const { canaux, sequence } = journalDeLaCoquille();
assert.deepEqual(canaux, [CANAL]);
const pose = sequence.indexOf(`ipcMain.handle:${CANAL}`);
const charge = sequence.indexOf('loadFile');
assert.ok(pose !== -1 && charge !== -1 && pose < charge, `ordre des appels : ${sequence.join(', ')}`);
});
test("la page n'ouvre aucune fenêtre et ne navigue nulle part", () => {
const { ouvertures, navigations } = journalDeLaCoquille();
assert.deepEqual(ouvertures, [{ action: 'deny' }]);
assert.ok(navigations.length > 0, 'aucun écouteur de will-navigate');
assert.deepEqual(navigations, navigations.map(() => true));
});
});
describe('coquille : préchargement', () => {
test("n'expose que window.gtt, 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');
});
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 },
});
// 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]);
});
});

229
test/electron_factice.js Normal file
View file

@ -0,0 +1,229 @@
// © 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, appelle le pont exposé et les gestionnaires posés par le processus
// principal, 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
// 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.
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
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));
// Valeur de PORTABLE_EXECUTABLE_DIR quand le lanceur portable la publie.
const DOSSIER_PUBLIE = 'E:\\soirees';
const journal = {
// Appels qui comptent pour l'ordre, dans l'ordre où ils arrivent.
sequence: [],
// Options de chaque new BrowserWindow.
fenetres: [],
// { 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: [],
// 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.
prechargement: null,
};
const gestionnaires = 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) {
journal.sequence.push(`app.on:${evenement}`);
},
quit() {
journal.sequence.push('app.quit');
},
};
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 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);
},
};
// 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_);
},
},
};
}
// 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 };
}
}
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 gtt = exposes.find(({ cle }) => cle === 'gtt')?.api;
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,
};
}
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 };
}
process.stdout.write(`${JSON.stringify(journal)}\n`);
}
process.once('beforeExit', () => {
jouerLaPage().catch((erreur) => {
process.stderr.write(`${erreur.stack}\n`);
process.exitCode = 1;
});
});

169
test/livrable.test.js Normal file
View file

@ -0,0 +1,169 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le livrable tel qu'electron-builder.config.cjs le décrit : son nom (§ 18.2),
// sa forme (§ 16), ce qu'il emporte (§ 14.15) et l'identité inscrite dans sa
// ressource de version. La configuration se charge ici comme electron-builder
// la charge, par require.
import assert from 'node:assert/strict';
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createRequire, isBuiltin } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriver } from '../scripts/version.js';
import { describe, test } from './lanceur.js';
import { versionVoisine } from './version_voisine.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
const CONFIGURATION = 'electron-builder.config.cjs';
const ELECTRON = join(RACINE, 'electron');
const lireJson = (racine, chemin) => JSON.parse(readFileSync(join(racine, chemin), 'utf8'));
const charger = (racine) => {
const chemin = join(racine, CONFIGURATION);
return createRequire(chemin)(chemin);
};
// Fichiers d'electron/, à toute profondeur, en chemins absolus.
const fichiersDElectron = () =>
readdirSync(ELECTRON, { recursive: true, withFileTypes: true })
.filter((entree) => entree.isFile())
.map((entree) => join(entree.parentPath, entree.name));
// Chargements que demande le texte d'un module : import et export statiques,
// import() et require(). Chacun est { specificateur } quand l'argument est
// une chaîne littérale, { argument } sinon. Le relevé lit le texte entier,
// commentaires compris.
const DEPUIS = /\b(?:import|export)\b[^'"`;]*?\bfrom\s*(['"])(?<specificateur>[^'"]+)\1/g;
const IMPORT_NU = /\bimport\s*(['"])(?<specificateur>[^'"]+)\1/g;
const APPEL = /\b(?:import|require)\s*\((?<argument>[^)]*)\)/g;
const LITTERAL = /^\s*(['"])(?<specificateur>[^'"]*)\1\s*$/;
function chargements(texte) {
return [
...[...texte.matchAll(DEPUIS), ...texte.matchAll(IMPORT_NU)].map(({ groups }) => ({
specificateur: groups.specificateur,
})),
...[...texte.matchAll(APPEL)].map(({ groups }) => {
const litteral = LITTERAL.exec(groups.argument);
return litteral === null ? { argument: groups.argument.trim() } : { specificateur: litteral.groups.specificateur };
}),
];
}
// Raison de refuser le chargement que demande un fichier d'electron/, ou
// null quand il reste dans le paquet : electron, un module de Node hors du
// lanceur d'épreuves, ou un fichier d'electron/. Le paquet n'emporte ni
// node_modules ni rien de test/ : un chargement qui en sort échoue au
// démarrage de l'exécutable. Un chargement calculé ne se vérifie pas d'ici.
function raisonDeRefus({ fichier, specificateur, argument }) {
if (specificateur === undefined) return `argument calculé, ${argument} : il ne se vérifie pas`;
if (/^(?:vitest|node:test)(?:\/|$)|^@vitest\//.test(specificateur)) return "lanceur d'épreuves";
if (specificateur === 'electron' || isBuiltin(specificateur)) return null;
if (/^\.{1,2}(?:\/|$)/.test(specificateur)) {
return resolve(dirname(fichier), specificateur).startsWith(`${ELECTRON}${sep}`) ? null : "hors d'electron/";
}
return "hors du paquet, qui n'emporte aucun node_modules";
}
describe('livrable : nom', () => {
test('le nom est celui que deriver calcule depuis version.json (§ 18.2)', () => {
const { version } = lireJson(RACINE, 'version.json');
assert.equal(charger(RACINE).artifactName, deriver(version).nomFichier);
});
test("le nom suit version.json : la configuration ne l'écrit pas elle-même", () => {
// Un arbre où version.json porte la version voisine. La configuration et
// scripts/version.js y sont recopiés tels quels.
const { version } = lireJson(RACINE, 'version.json');
const autre = versionVoisine(version);
const arbre = mkdtempSync(join(tmpdir(), 'livrable-'));
try {
for (const chemin of [CONFIGURATION, 'scripts/version.js', 'package.json', 'capacitor.config.json']) {
mkdirSync(dirname(join(arbre, chemin)), { recursive: true });
copyFileSync(join(RACINE, chemin), join(arbre, chemin));
}
writeFileSync(join(arbre, 'version.json'), `${JSON.stringify({ version: autre })}\n`);
const nom = charger(arbre).artifactName;
assert.equal(nom, deriver(autre).nomFichier);
assert.notEqual(nom, deriver(version).nomFichier);
} finally {
rmSync(arbre, { recursive: true, force: true });
}
});
});
describe('livrable : forme et contenu', () => {
test('un exécutable portable à fichier unique, pour Windows x64 : jamais un installateur (§ 16)', () => {
assert.deepEqual(charger(RACINE).win, { target: [{ target: 'portable', arch: ['x64'] }] });
});
test("sortie dans dist-electron/ ; le paquet n'emporte que la page, la coquille et package.json", () => {
const configuration = charger(RACINE);
assert.deepEqual(configuration.directories, { output: 'dist-electron' });
assert.deepEqual(configuration.files, ['www/**', 'electron/**', 'package.json']);
});
test("la configuration n'a pas d'autre clé que celles que ces épreuves examinent (§ 14.15, § 18.1)", () => {
// Une clé de plus change ce que le paquet emporte (extraResources,
// extraFiles, asar) ou la version qu'il porte (buildVersion, buildNumber),
// et aucune épreuve ne la lirait : elle échoue ici jusqu'à sa revue. Les
// épreuves de chaque clé comparent sa valeur entière, si bien qu'une clé
// ajoutée plus bas, sous win ou directories, échoue aussi.
assert.deepEqual(Object.keys(charger(RACINE)).sort(), [
'appId',
'artifactName',
'copyright',
'directories',
'files',
'productName',
'win',
]);
});
test("electron/, que le paquet emporte en entier, ne contient aucune épreuve (§ 14.15)", () => {
const fichiers = fichiersDElectron();
assert.ok(fichiers.length > 0, 'electron/ ne contient aucun fichier');
assert.deepEqual(fichiers.filter((chemin) => /\.test\.[cm]?js$/.test(chemin)), []);
});
test("electron/ ne charge qu'electron, Node et ses propres fichiers : ni épreuve, ni lanceur, ni dépendance (§ 14.9)", () => {
const demandes = fichiersDElectron().flatMap((fichier) =>
chargements(readFileSync(fichier, 'utf8')).map((chargement) => ({ fichier, ...chargement })),
);
assert.ok(demandes.length > 0, 'aucun chargement relevé dans electron/');
const refusees = demandes
.map((demande) => ({ ...demande, fichier: relative(RACINE, demande.fichier), raison: raisonDeRefus(demande) }))
.filter(({ raison }) => raison !== null);
assert.deepEqual(refusees, []);
});
test("package.json ne déclare aucune dépendance d'exécution : le paquet n'emporte aucun node_modules", () => {
const paquet = lireJson(RACINE, 'package.json');
assert.equal(paquet.dependencies, undefined);
assert.equal(paquet.optionalDependencies, undefined);
assert.match(paquet.devDependencies?.electron ?? '', /^\^44(\.|$)/);
assert.match(paquet.devDependencies?.['electron-builder'] ?? '', /^\^26(\.|$)/);
assert.equal(paquet.main, 'electron/main.js');
});
});
describe('livrable : identité', () => {
test("l'identifiant est celui de Capacitor, le nom de produit celui de package.json", () => {
const configuration = charger(RACINE);
assert.equal(configuration.appId, lireJson(RACINE, 'capacitor.config.json').appId);
assert.equal(configuration.productName, lireJson(RACINE, 'package.json').productName);
// Une source muette rend undefined des deux côtés de l'égalité, et
// electron-builder retombe alors sur ses valeurs par défaut : chaque
// valeur a aussi sa forme.
assert.match(configuration.appId ?? '', /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/);
assert.ok(
typeof configuration.productName === 'string' && configuration.productName.trim() !== '',
`productName vide : ${configuration.productName}`,
);
});
test('le titulaire entre dans la ressource de version, champ LegalCopyright', () => {
assert.equal(charger(RACINE).copyright, '© 2026 TechnoLibre');
});
});

10
test/version_voisine.js Normal file
View file

@ -0,0 +1,10 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Version voisine d'une version affichée AAAA.MM.JJ.NN valide, pour les
// épreuves qui ont besoin d'une seconde version : même date, rang 01 changé
// en 02 et tout autre rang en 01. La date reste valide quelle qu'elle soit,
// un 29 février compris, et la voisine diffère toujours de la version reçue.
export function versionVoisine(version) {
return version.replace(/\d{2}$/, (rang) => (rang === '01' ? '02' : '01'));
}