diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1fb12f2..2b1a33a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,23 +1,14 @@ ## Correction -**Un message d'erreur s'affichait avant chaque commande sur les postes Windows sans droits administrateur.** Il ressemblait à ceci, et revenait même pour un simple `ajean help` : +**Sous Windows, « Une version plus récente d'AJEAN est déjà installée » pouvait s'afficher à chaque lancement.** Le message était exact, mais rien ne permettait d'en sortir : il revenait indéfiniment. -``` -[info] dossier de données pas encore migré vers C:\ProgramData\ajean - (rename ... : Access is denied.) — on continue sur C:\ProgramData\jean -``` +Deux causes, qui se renforçaient. -Rien n'était cassé. AJEAN fonctionnait normalement, sur son dossier habituel, sans aucune donnée en jeu. Mais renommer `C:\ProgramData\jean` demande le droit d'écrire dans `C:\ProgramData`, qu'un compte standard n'a pas : le renommage échouait à chaque lancement, et le message revenait indéfiniment. C'était un détail interne affiché comme une erreur. +La commande `jean`, conservée en alias depuis la 0.7.0, est une copie du programme posée à côté de `ajean.exe`. Windows refusant d'écraser un exécutable en cours d'exécution, cette copie n'était pas remplacée si elle tournait au moment de la mise à jour — elle restait figée sur une version périmée. Elle est désormais mise de côté puis réécrite, la même technique que celle utilisée pour le programme principal. -Il ne s'affiche plus. La raison est conservée et ressortie là où elle sert à quelque chose — `ajean where`, qui montre justement les emplacements — avec ce qu'il faut faire pour aligner les noms si vous le souhaitez : +Et les raccourcis du menu Démarrer et du Bureau, créés avant la 0.7.0, visaient toujours cet alias. Ils n'étaient jamais repointés. Chaque lancement exécutait donc l'ancienne version, qui constatait à juste titre qu'une plus récente était installée — et le disait. Un raccourci existant est maintenant repointé vers le programme courant. -``` -ajean where -``` - -`ajean install` lancé en administrateur termine désormais la migration : c'est le seul moment où les droits nécessaires sont réunis. - -Rien de tout cela n'est obligatoire. Un AJEAN qui continue d'utiliser `C:\ProgramData\jean` fonctionne exactement comme les autres, et vos données restent où elles sont. +Après cette mise à jour, le message disparaît de lui-même au premier lancement. ## Mise à jour @@ -25,4 +16,6 @@ Rien de tout cela n'est obligatoire. Un AJEAN qui continue d'utiliser `C:\Progra ajean update ``` +Sous Windows, télécharger `ajean-windows-amd64.exe` depuis cette page et le lancer fait le même travail. + Les binaires restent publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que le parc bascule. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index dba2a3f..234274d 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index e0a00b9..1f069e6 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 40e7a7b..826e2ae 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 1, + "Patch": 2, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 1, + "Patch": 2, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.1", + "ProductVersion": "0.7.2", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 70702da..4af5f02 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.1" +const Version = "0.7.2" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index 8e11192..532cff5 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -426,7 +426,21 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) { if (-not $d) { continue } if (-not (Test-Path $d)) { continue } $lnk=Join-Path $d 'AJEAN.lnk' - if (Test-Path $lnk) { $done+=$d; continue } + # Un raccourci existant est REPOINTÉ s'il vise autre chose que le binaire + # canonique — typiquement l'ancien « jean.exe », qui n'est plus qu'un alias. + # Le laisser en l'état condamnait l'utilisateur à relancer indéfiniment une + # version périmée par son propre raccourci. + if (Test-Path $lnk) { + try { + $s=$w.CreateShortcut($lnk) + if ($s.TargetPath -and $s.TargetPath -ne $t -and (Test-Path $s.TargetPath)) { + $s.TargetPath=$t + $s.WorkingDirectory=(Split-Path $t) + $s.Save() + } + } catch {} + $done+=$d; continue + } try { $s=$w.CreateShortcut($lnk) $s.TargetPath=$t diff --git a/internal/ajean/sys_firstrun_windows_test.go b/internal/ajean/sys_firstrun_windows_test.go index b5cde1e..1b385c4 100644 --- a/internal/ajean/sys_firstrun_windows_test.go +++ b/internal/ajean/sys_firstrun_windows_test.go @@ -80,3 +80,59 @@ func TestBinaryVersionWithoutResource(t *testing.T) { t.Fatalf("binaryVersion sur un fichier sans ressource = %q, attendu \"\"", v) } } + +// Reproduit le cas signale en usage : l'alias « jean.exe » est EN COURS +// d'execution au moment de la mise a jour. copyExe seul echouait, l'alias +// restait fige sur une version perimee, et tout raccourci le visant relancait +// indefiniment l'ancienne version — qui affichait « une version plus recente +// est deja installee » a chaque lancement. +func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "neuf.exe") + dst := filepath.Join(dir, "jean.exe") + if err := os.WriteFile(src, []byte("VERSION-NEUVE"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("version-perimee"), 0o755); err != nil { + t.Fatal(err) + } + + // Verrou exclusif : imite un .exe en cours d'execution sous Windows. + held, err := os.OpenFile(dst, os.O_RDONLY, 0) + if err != nil { + t.Fatal(err) + } + defer held.Close() + + if err := replaceExe(src, dst); err != nil { + t.Fatalf("remplacement impossible: %v", err) + } + b, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(b) != "VERSION-NEUVE" { + t.Fatalf("l'alias est reste perime: %q", b) + } +} + +// Le remplacement ne doit JAMAIS faire disparaitre la cible : si la copie +// echoue apres le renommage, l'ancien fichier revient a sa place. +func TestReplaceExeRestaureSiLaCopieEchoue(t *testing.T) { + dir := t.TempDir() + dst := filepath.Join(dir, "jean.exe") + if err := os.WriteFile(dst, []byte("a-preserver"), 0o755); err != nil { + t.Fatal(err) + } + // Source inexistante : la copie echouera forcement. + if err := replaceExe(filepath.Join(dir, "absent.exe"), dst); err == nil { + t.Fatal("attendu une erreur") + } + b, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("le fichier a disparu: %v", err) + } + if string(b) != "a-preserver" { + t.Fatalf("contenu altere: %q", b) + } +} diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index efc72aa..70aafa3 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -130,7 +130,9 @@ func installSelf(binDir string) (string, error) { if err := os.MkdirAll(binDir, 0o755); err != nil { return "", err } - if err := copyExe(src, dst); err != nil { + // replaceExe et non copyExe : ajean.exe peut etre en cours d'execution + // (service en tache de fond), auquel cas Windows refuse de l'ecraser. + if err := replaceExe(src, dst); err != nil { return "", err } installLegacyAlias(binDir, dst) @@ -150,9 +152,32 @@ func installLegacyAlias(binDir, src string) { if strings.EqualFold(src, alias) { return } - // Échoue si un « jean.exe » est en cours d'exécution — sans gravité, il s'agit - // alors déjà d'une version d'AJEAN, et la copie repassera au prochain lancement. - _ = copyExe(src, alias) + _ = replaceExe(src, alias) +} + +// replaceExe copie src vers dst, y compris quand dst est un exécutable EN COURS. +// +// Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le +// RENOMMER : on décale l'ancien puis on écrit le nouveau à sa place. Sans ça, +// l'alias « jean.exe » restait figé sur une version périmée dès qu'il tournait +// au moment de la mise à jour — et comme les raccourcis existants le visent +// encore, chaque lancement relançait l'ancienne version, qui constatait qu'une +// plus récente était installée et le disait. À chaque fois. Constaté en usage. +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 { + return err // ni écrasable ni renommable : on laisse la place en l'état + } + if err := copyExe(src, dst); err != nil { + _ = os.Rename(old, dst) // rien ne doit disparaître + return err + } + _ = os.Remove(old) // échoue tant que l'ancien tourne ; nettoyé plus tard + return nil } func copyExe(src, dst string) error { diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index ff6b9b2..dd6c89b 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -370,9 +370,14 @@ func cleanupOldBinary() { if runtime.GOOS != "windows" { return } - if exe, err := os.Executable(); err == nil { - _ = os.Remove(exe + ".old") + exe, err := os.Executable() + if err != nil { + return } + _ = os.Remove(exe + ".old") + // 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")) } // handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI.