From ae38aca9ba6e7d133070eb9331318737cd10f6cb Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 5 Aug 2026 17:03:43 +0200 Subject: [PATCH] MAJ Windows : ne plus echouer sur un ecartement encore verrouille Le remplacement du binaire renommait l'ancien en un nom FIXE (.old). Si un processus tournait encore depuis ce .old (mise a jour precedente), le fichier n'etait ni supprimable ni ecrasable : le renommage sortait en "Acces refuse" (errno 5), que os.IsPermission rapporte comme un defaut de DROITS. L'UI affichait donc "droits insuffisants, relance en administrateur", conseil faux : aucun privilege ne remplace l'image d'un executable en cours d'execution. - renameAside() ecarte sous un nom UNIQUE (.old-) : le renommage passe meme si un ancien ecartement est verrouille (verifie sur banc d'essai avec deux processus vivants) - les trois endroits qui faisaient la manoeuvre l'utilisent (update, install, premier lancement), et le menage ramasse le nom fixe herite + les uniques - message d'erreur : Windows renvoie le meme code pour "droits insuffisants" et "fichier utilise", les deux causes sont donc nommees Aussi : la section Cartes graphiques restait affichee avec une seule carte. L'attribut hidden etait sans effet, .pe-group{display:flex} l'emportant sur la regle par defaut du navigateur. --- internal/ajean/sys_firstrun_windows.go | 6 +-- internal/ajean/sys_install_windows.go | 8 +-- internal/ajean/sys_update.go | 43 ++++++++++++--- internal/ajean/sys_update_aside_test.go | 71 +++++++++++++++++++++++++ internal/ajean/ui/index.html | 5 ++ internal/ajean/ui/src/styles.css | 5 ++ 6 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 internal/ajean/sys_update_aside_test.go diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index c3a7b50..5662802 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -250,9 +250,9 @@ func migrateThenResolveTarget(target string) string { } func replaceInstalled(target string) error { - old := target + ".old" - _ = os.Remove(old) - if err := os.Rename(target, old); err != nil { + removeOldBinaries(target) + old, err := renameAside(target) // nom unique, cf. renameAside + if err != nil { return err } if _, err := installSelf(filepath.Dir(target)); err != nil { diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index c575c52..f9fa438 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -170,9 +170,11 @@ func replaceExe(src, dst string) error { if err := copyExe(src, dst); err == nil { return nil } - old := dst + ".old" - _ = os.Remove(old) // reliquat d'un remplacement précédent - if err := os.Rename(dst, old); err != nil { + removeOldBinaries(dst) // reliquats des remplacements précédents + // Nom unique : un écartement encore verrouillé par un ancien processus ne + // doit pas bloquer celui-ci (voir renameAside). + old, err := renameAside(dst) + if err != nil { return err // ni écrasable ni renommable : on laisse la place en l'état } if err := copyExe(src, dst); err != nil { diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index ab249a8..67a143e 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -201,7 +201,10 @@ func applyUpdate() (string, error) { // droits d'écrire son propre binaire : la commande exacte à lancer. func updatePermissionError(exe string) error { if runtime.GOOS == "windows" { - return fmt.Errorf("droits insuffisants pour remplacer %s — relance AJEAN en administrateur puis réessaie", exe) + // Windows renvoie le MÊME code (5, accès refusé) pour « droits + // insuffisants » et pour « fichier utilisé par un processus » : on nomme + // les deux causes au lieu d'affirmer la mauvaise. + return fmt.Errorf("impossible de remplacer %s : soit un autre AJEAN utilise ce fichier (ferme l'application et arrête le service, puis réessaie), soit les droits manquent (relance AJEAN en administrateur)", exe) } return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo ajean update", exe) } @@ -353,9 +356,8 @@ func fileSize(p string) int64 { // cours : on renomme l'ancien en .old (supprimé au prochain lancement). func replaceBinary(exe, tmp string) error { if runtime.GOOS == "windows" { - old := exe + ".old" - _ = os.Remove(old) // nettoyage d'une éventuelle MAJ précédente - if err := os.Rename(exe, old); err != nil { + old, err := renameAside(exe) + if err != nil { return err } if err := os.Rename(tmp, exe); err != nil { @@ -368,6 +370,35 @@ func replaceBinary(exe, tmp string) error { return os.Rename(tmp, exe) } +// renameAside écarte un fichier en le renommant sous un nom UNIQUE. +// +// Un nom FIXE (« .old ») est un piège sous Windows : si un processus tourne +// ENCORE depuis le .old d'une mise à jour précédente, ce fichier ne peut être ni +// supprimé ni écrasé. Le renommage échoue alors avec « Accès refusé » (errno 5), +// que os.IsPermission rapporte comme un problème de droits — d'où le message +// « relance AJEAN en administrateur », un conseil FAUX : aucun privilège ne +// permet de remplacer l'image d'un exécutable en cours d'exécution. Reproduit +// puis vérifié corrigé sur banc d'essai, deux processus vivants sur les deux +// noms : avec un suffixe unique le renommage passe. +func renameAside(path string) (string, error) { + aside := fmt.Sprintf("%s.old-%d", path, time.Now().UnixNano()) + if err := os.Rename(path, aside); err != nil { + return "", err + } + return aside, nil +} + +// removeOldBinaries supprime les écartements laissés par les remplacements +// précédents (nom fixe hérité ET noms uniques). Best-effort : ceux dont le +// processus tourne encore résistent, on réessaiera au prochain lancement. +func removeOldBinaries(path string) { + _ = os.Remove(path + ".old") + matches, _ := filepath.Glob(path + ".old-*") + for _, m := range matches { + _ = os.Remove(m) + } +} + // cleanupOldBinary supprime silencieusement le .old laissé par une MAJ Windows // précédente (le fichier n'était pas supprimable tant que l'exe tournait). func cleanupOldBinary() { @@ -378,10 +409,10 @@ func cleanupOldBinary() { if err != nil { return } - _ = os.Remove(exe + ".old") + removeOldBinaries(exe) // L'alias herite laisse le meme reliquat quand il etait en cours d'execution // au moment ou on l'a remplace (voir replaceExe). - _ = os.Remove(filepath.Join(filepath.Dir(exe), "jean.exe.old")) + removeOldBinaries(filepath.Join(filepath.Dir(exe), "jean.exe")) } // handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI. diff --git a/internal/ajean/sys_update_aside_test.go b/internal/ajean/sys_update_aside_test.go new file mode 100644 index 0000000..24bf384 --- /dev/null +++ b/internal/ajean/sys_update_aside_test.go @@ -0,0 +1,71 @@ +package ajean + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Le remplacement du binaire doit fonctionner MÊME si un écartement précédent +// est toujours là et impossible à supprimer (processus encore vivant dessus). +// Avec un nom fixe « .old », le renommage échouait alors en « Accès refusé », +// que jean traduisait par « droits insuffisants, relance en administrateur » : +// un conseil faux, aucun privilège ne remplace une image en cours d'exécution. +func TestRenameAsideNeBloquePasSurUnAncienEcartement(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "ajean.exe") + if err := os.WriteFile(exe, []byte("v1"), 0o755); err != nil { + t.Fatal(err) + } + // Un écartement d'une mise à jour précédente occupe déjà le nom fixe. + if err := os.WriteFile(exe+".old", []byte("v0"), 0o755); err != nil { + t.Fatal(err) + } + aside, err := renameAside(exe) + if err != nil { + t.Fatalf("renameAside a échoué alors qu'un .old existe déjà : %v", err) + } + if aside == exe+".old" { + t.Error("nom d'écartement fixe : c'est précisément ce qui bloquait") + } + if !strings.HasPrefix(filepath.Base(aside), "ajean.exe.old-") { + t.Errorf("nom inattendu : %s", aside) + } + if _, err := os.Stat(exe); !os.IsNotExist(err) { + t.Error("le binaire n'a pas été écarté") + } + // Deux écartements successifs ne doivent jamais se marcher dessus. + if err := os.WriteFile(exe, []byte("v2"), 0o755); err != nil { + t.Fatal(err) + } + aside2, err := renameAside(exe) + if err != nil { + t.Fatal(err) + } + if aside2 == aside { + t.Error("deux écartements ont produit le même nom") + } +} + +// Le ménage doit ramasser l'ancien nom fixe ET les noms uniques. +func TestRemoveOldBinaries(t *testing.T) { + dir := t.TempDir() + exe := filepath.Join(dir, "ajean.exe") + for _, n := range []string{exe + ".old", exe + ".old-1", exe + ".old-2"} { + if err := os.WriteFile(n, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(exe, []byte("v1"), 0o755); err != nil { + t.Fatal(err) + } + removeOldBinaries(exe) + restes, _ := filepath.Glob(exe + ".old*") + if len(restes) != 0 { + t.Errorf("écartements non nettoyés : %v", restes) + } + if _, err := os.Stat(exe); err != nil { + t.Errorf("le binaire courant a été supprimé : %v", err) + } +} diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 1940a36..a5d0050 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -567,6 +567,11 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .pe-val::-webkit-outer-spin-button,.pe-val::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} /* Un groupe = un petit titre + une liste encadrée aux coins arrondis. */ .pe-group{display:flex;flex-direction:column;gap:8px} +/* `display:flex` sur la classe l'emporte sur la règle par défaut du navigateur + pour [hidden] : sans cette ligne, masquer un groupe via l'attribut hidden + n'avait AUCUN effet visible (section « Cartes graphiques » qui restait + affichée avec une seule carte). */ +.pe-group[hidden]{display:none} .pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px} .pe-list{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden} /* Ligne : libellé à gauche, contrôle à droite, séparateur fin entre lignes. */ diff --git a/internal/ajean/ui/src/styles.css b/internal/ajean/ui/src/styles.css index 843bc98..4720597 100644 --- a/internal/ajean/ui/src/styles.css +++ b/internal/ajean/ui/src/styles.css @@ -541,6 +541,11 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .pe-val::-webkit-outer-spin-button,.pe-val::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} /* Un groupe = un petit titre + une liste encadrée aux coins arrondis. */ .pe-group{display:flex;flex-direction:column;gap:8px} +/* `display:flex` sur la classe l'emporte sur la règle par défaut du navigateur + pour [hidden] : sans cette ligne, masquer un groupe via l'attribut hidden + n'avait AUCUN effet visible (section « Cartes graphiques » qui restait + affichée avec une seule carte). */ +.pe-group[hidden]{display:none} .pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px} .pe-list{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden} /* Ligne : libellé à gauche, contrôle à droite, séparateur fin entre lignes. */