diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 4f63fe3..6e6ab07 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,14 +1,12 @@ -## Le renommage du dossier se fait maintenant à la mise à jour +## Le bouton « mettre à jour » redémarre AJEAN tout seul -Les versions 0.7.1 à 0.7.3 n'y parvenaient pas sur les postes Windows sans droits administrateur : le code prévu pour demander l'autorisation n'était jamais atteint. Il ne se déclenchait qu'en relançant l'installation ou en retéléchargeant le programme — deux gestes que personne ne fait pour une mise à jour. +Sous Windows, il fallait jusqu'ici quitter puis relancer l'application à la main pour que la mise à jour prenne effet. C'est fait automatiquement : la page se reconnecte d'elle-même au bout de quelques secondes. -`ajean update`, et le bouton de mise à jour de l'interface, s'en chargent désormais. L'autorisation Windows est demandée à ce moment-là, une fois, et le dossier `C:\ProgramData\jean` devient `C:\ProgramData\ajean`. +Ce redémarrage règle aussi, au passage, le renommage du dossier de données. Tant qu'AJEAN tourne, il tient son propre dossier, et `C:\ProgramData\jean` ne peut pas devenir `C:\ProgramData\ajean` — c'est ce qui laissait certains postes indéfiniment sur l'ancien nom malgré les versions précédentes. Le redémarrage ouvre le court instant où plus rien ne le tient, et la migration s'y termine. -Le raccourci du menu Démarrer et la commande `ajean` sont repointés dans la foulée. Le programme installé vit à l'intérieur du dossier de données : sans cette étape, le déplacement aurait laissé un raccourci mort et une commande introuvable. +Le raccourci du menu Démarrer et la commande `ajean` suivent le déplacement, puisque le programme installé vit à l'intérieur de ce dossier. -Refuser l'autorisation reste sans conséquence : AJEAN continue sur son dossier actuel, et la question reviendra à la prochaine mise à jour. - -Si vous êtes déjà sur la dernière version et toujours sur l'ancien dossier, la migration se fera à la mise à jour suivante. +Si quelque chose empêche le renommage, il ne se passe rien : AJEAN redémarre sur son dossier actuel et réessaiera plus tard. Rien n'est jamais copié ni supprimé. ## Mise à jour @@ -16,6 +14,6 @@ Si vous êtes déjà sur la dernière version et toujours sur l'ancien dossier, ajean update ``` -Sous Windows, télécharger `ajean-windows-amd64.exe` depuis cette page et le lancer fait le même travail. +Ou le bouton de l'interface, qui se charge désormais de tout. 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 cb8da48..4bc2170 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 be1de78..3b98aaf 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 9596947..3d556f8 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 4, + "Patch": 5, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 4, + "Patch": 5, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.4", + "ProductVersion": "0.7.5", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index a3a4833..e5a8e9b 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.4" +const Version = "0.7.5" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { @@ -91,6 +91,10 @@ func Main() { mustExit(cmdUpdate(args)) case "where", "paths": mustExit(cmdWhere(args)) + case restartArg: + // Sous-commande interne, absente de l'aide : l'accompagnateur detache qui + // attend la fermeture de l'app, migre, puis la relance. + mustExit(cmdRestartAfterUpdate(args)) case migrateHomeArg: // Sous-commande interne, volontairement absente de l'aide : c'est le // travail delegue au process elevé par elevateForHomeMigration(). diff --git a/internal/ajean/sys_restart_other.go b/internal/ajean/sys_restart_other.go new file mode 100644 index 0000000..7a134b6 --- /dev/null +++ b/internal/ajean/sys_restart_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package ajean + +// Pendant non-Windows de sys_restart_windows.go : sous Linux le redemarrage +// passe par systemd (restartAfterUpdate), qui sait deja relancer proprement un +// service. Il n'y a pas d'accompagnateur a lancer. + +const restartArg = "restart-after-update" + +func scheduleAppRestart() (bool, string) { return false, "" } + +func cmdRestartAfterUpdate([]string) error { return nil } diff --git a/internal/ajean/sys_restart_windows.go b/internal/ajean/sys_restart_windows.go new file mode 100644 index 0000000..cff0597 --- /dev/null +++ b/internal/ajean/sys_restart_windows.go @@ -0,0 +1,93 @@ +//go:build windows + +package ajean + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Redémarrage propre de l'application après une mise à jour, sous Windows. +// +// Un process ne peut pas se relancer lui-même : tant qu'il tourne, le nouveau +// binaire ne peut pas prendre sa place sur le port, et le dossier de données +// reste tenu. On délègue donc à un ACCOMPAGNATEUR détaché — une copie de nous +// lancée avec une sous-commande interne — qui attend notre disparition, profite +// de cette fenêtre pour terminer la migration, puis relance l'application. +// +// C'est cette fenêtre qui manquait. Une mise à jour en place laisse AJEAN +// tourner, et donc le dossier tenu : le renommage échouait avec « Accès refusé », +// qui sous Windows ne distingue pas un verrou d'un manque de droits. D'où des +// postes qui restaient indéfiniment sur l'ancien nom. + +const restartArg = "restart-after-update" + +// scheduleAppRestart lance l'accompagnateur et annonce que le redémarrage est +// pris en charge. L'appelant doit ensuite rendre la main puis quitter, pour que +// la réponse HTTP parte AVANT que le process ne disparaisse. +func scheduleAppRestart() (bool, string) { + exe, err := os.Executable() + if err != nil { + return false, "" + } + cmd := spawnDetached(exe, restartArg, strconv.Itoa(os.Getpid())) + if err := cmd.Start(); err != nil { + return false, "" + } + // L'accompagnateur ne doit pas mourir avec nous : spawnDetached l'a déjà + // sorti de notre groupe de processus, on se contente de l'oublier. + _ = cmd.Process.Release() + + go func() { + time.Sleep(1500 * time.Millisecond) // laisser la réponse atteindre le navigateur + os.Exit(0) + }() + return true, "AJEAN redémarre — la page se reconnectera toute seule dans quelques secondes." +} + +// cmdRestartAfterUpdate est exécuté par l'accompagnateur détaché. +// +// Il n'écrit rien à l'écran : personne ne le regarde, il n'a pas de console, et +// son seul travail visible est de faire réapparaître l'application. +func cmdRestartAfterUpdate(args []string) error { + if len(args) > 0 { + if pid, err := strconv.Atoi(args[0]); err == nil { + waitForExit(pid, 30*time.Second) + } + } + // L'application vient de se fermer : plus rien ne tient le dossier de + // données, le renommage peut enfin aboutir. postUpdateMigrate ne fait rien + // s'il n'y a pas lieu, et repointe raccourci et PATH s'il a migré. + postUpdateMigrate() + + // installedExePath() est relu APRÈS la migration : le binaire a pu changer + // de dossier avec elle, et relancer l'ancien chemin échouerait. + target := installedExePath() + if _, err := os.Stat(target); err != nil { + // La cible a disparu : mieux vaut relancer ce qu'on a que rien du tout. + if self, e := os.Executable(); e == nil { + target = self + } + } + if !launch(target) { + return fmt.Errorf("relance de %s impossible", target) + } + return nil +} + +// waitForExit attend la fin du process pid, sans dépasser le délai imparti. +// Un dépassement n'est pas bloquant : on poursuit quand même, quitte à ce que +// la migration échoue et soit retentée plus tard. Rester coincé ici serait pire +// — l'utilisateur se retrouverait sans application du tout. +func waitForExit(pid int, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !pidAlive(pid) { + time.Sleep(500 * time.Millisecond) // laisser les handles se fermer + return + } + time.Sleep(200 * time.Millisecond) + } +} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index 6a701bf..ab249a8 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -405,7 +405,6 @@ func handleUpdateApply(w http.ResponseWriter, r *http.Request) { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } - postUpdateMigrate() restarting, msg := restartAfterUpdate() if !restarting { msg = restartHintText() @@ -423,6 +422,13 @@ func handleUpdateApply(w http.ResponseWriter, r *http.Request) { // et l'UI se reconnecte toute seule (boucle de reconnexion du flux). Renvoie // (déclenché, message). Non-serveur (poste client, Windows) → (false, ""). func restartAfterUpdate() (bool, string) { + // Windows : pas de superviseur pour nous relancer, on delegue a un + // accompagnateur detache (voir sys_restart_windows.go). C'est aussi ce qui + // cree la fenetre ou plus rien ne tient le dossier de donnees, donc ou la + // migration peut aboutir. + if runtime.GOOS == "windows" { + return scheduleAppRestart() + } if runtime.GOOS != "linux" || !linkServiceActive() { return false, "" }