diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0f00ff5..4f63fe3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,10 +1,14 @@ -## Le renommage du dossier se termine tout seul sous Windows +## Le renommage du dossier se fait maintenant à la mise à jour -Jusqu'ici, sur un ordinateur où vous n'êtes pas administrateur, `C:\ProgramData\jean` ne pouvait pas devenir `C:\ProgramData\ajean` : renommer ce dossier demande un droit qu'un compte standard n'a pas. Le renommage restait donc à moitié fait, indéfiniment. +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. -L'installation demande désormais l'autorisation Windows nécessaire, le temps du renommage, puis rend la main. La fenêtre apparaît au moment où vous installez ou mettez à jour AJEAN — jamais au démarrage ordinaire, et jamais si elle ne sert à rien : ni quand le dossier porte déjà le bon nom, ni quand vous avez choisi vous-même son emplacement. +`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`. -Refuser cette autorisation est une réponse valable. AJEAN continue alors d'utiliser son dossier actuel, exactement comme avant. Rien n'est perdu, rien n'est bloqué, et la question reviendra à la prochaine installation. +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. + +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. ## Mise à jour diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 4b1f6b3..cb8da48 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 d3e5e08..be1de78 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 62a3a6e..9596947 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 3, + "Patch": 4, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 3, + "Patch": 4, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.3", + "ProductVersion": "0.7.4", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index bf7942c..a3a4833 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.3" +const Version = "0.7.4" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 4512712..2c1b491 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -9,7 +9,6 @@ import ( "os/user" "path/filepath" "strings" - "sync" ) // sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent @@ -94,7 +93,7 @@ func cmdInstall(args []string) error { if err := migrateLayout(defaultLayoutPlan()); err != nil { return err } - homeOnce, homePath = sync.Once{}, "" // le dossier a pu changer à l'instant + resetResolvedHome() // le dossier a pu changer à l'instant ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index 93b6910..7ab1309 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -9,7 +9,6 @@ import ( "os/user" "path/filepath" "strings" - "sync" ) const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart @@ -83,7 +82,7 @@ func cmdInstall(args []string) error { if err := migrateLayout(defaultLayoutPlan()); err != nil { return err } - homeOnce, homePath = sync.Once{}, "" // le dossier a pu changer à l'instant + resetResolvedHome() // le dossier a pu changer à l'instant ajeanHome := defaultAjeanHome() if v := os.Getenv("JEAN_HOME"); v != "" { diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go index 39960d3..5365a6b 100644 --- a/internal/ajean/sys_migrate.go +++ b/internal/ajean/sys_migrate.go @@ -33,8 +33,13 @@ import ( // 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ». var ( - homeOnce sync.Once - homePath string + // homeMu protège la résolution du dossier. Un simple sync.Once ne suffisait + // pas : retryHomeMigration le RÉINITIALISE, et il est appelé depuis le + // handler HTTP de mise à jour pendant que le serveur sert d'autres requêtes. + // Réécrire ces variables sans verrou était une course de données. + homeMu sync.RWMutex + homeResolved bool + homePath string // migrationDeferred retient pourquoi le dossier n'a pas pu être renommé, // pour que seules les commandes qui parlent d'emplacements en fassent état. @@ -76,7 +81,19 @@ func renameCause(err error) string { // N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un // chemin : un choix explicite de l'utilisateur ne se migre pas. func migratedDefaultHome() string { - homeOnce.Do(func() { homePath = resolveDefaultHome() }) + homeMu.RLock() + if homeResolved { + p := homePath + homeMu.RUnlock() + return p + } + homeMu.RUnlock() + + homeMu.Lock() + defer homeMu.Unlock() + if !homeResolved { // un autre appelant a pu résoudre entre les deux verrous + homePath, homeResolved = resolveDefaultHome(), true + } return homePath } @@ -93,14 +110,17 @@ func resolveDefaultHome() string { // resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est // arrêté : c'est là qu'on retente. // -// À N'APPELER QUE dans cette fenêtre, et avant que quoi que ce soit d'autre -// n'ait ouvert de fichier de données : réinitialiser le cache n'est ni atomique -// ni sûr vis-à-vis des goroutines, et surtout les chemins déjà calculés par -// l'appelant deviennent obsolètes (voir migrateThenResolveTarget). +// L'accès au cache est protégé par homeMu, mais cela ne rend pas l'appel anodin +// pour autant : les chemins DÉJÀ CALCULÉS par l'appelant deviennent obsolètes +// dès que la migration aboutit (voir migrateThenResolveTarget et +// relinkAfterMigration, qui reconstruisent ce qui en dépend). func retryHomeMigration() bool { - before := migratedDefaultHome() - homeOnce = sync.Once{} - homePath = "" + before := migratedDefaultHome() // force la résolution si elle n'a pas eu lieu + + homeMu.Lock() + homeResolved = false + homeMu.Unlock() + return migratedDefaultHome() != before } @@ -243,3 +263,12 @@ func adoptLegacyStateFiles(home string) { _ = os.Rename(from, to) } } + +// resetResolvedHome oublie le dossier résolu, pour que le prochain appel reparte +// du disque. Utilisé par `install` juste après avoir migré l'agencement : sans +// ça, la suite de l'installation continuerait de viser l'ancien emplacement. +func resetResolvedHome() { + homeMu.Lock() + homeResolved = false + homeMu.Unlock() +} diff --git a/internal/ajean/sys_postupdate_other.go b/internal/ajean/sys_postupdate_other.go new file mode 100644 index 0000000..81e896b --- /dev/null +++ b/internal/ajean/sys_postupdate_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package ajean + +// Pendant non-Windows de sys_postupdate_windows.go : la migration d'agencement +// se fait dans restartAfterUpdate (voir sys_layout.go), qui tourne deja en root. +func postUpdateMigrate() {} diff --git a/internal/ajean/sys_postupdate_windows.go b/internal/ajean/sys_postupdate_windows.go new file mode 100644 index 0000000..fff8836 --- /dev/null +++ b/internal/ajean/sys_postupdate_windows.go @@ -0,0 +1,51 @@ +//go:build windows + +package ajean + +import ( + "fmt" + "path/filepath" +) + +// Migration du dossier juste après une mise à jour, sous Windows. +// +// C'est le chaînon qui manquait. Les utilisateurs ne relancent pas `install` et +// ne retéléchargent pas l'exécutable : ils font `ajean update`, ou cliquent le +// bouton de l'interface. Or ce chemin-là ne tentait rien — l'élévation n'était +// câblée que dans `install` et dans l'installeur de mise à jour. Résultat : un +// poste sans droits administrateur restait sur %ProgramData%\jean pour toujours, +// sans qu'aucune fenêtre ne propose jamais de le régler. +// +// Contrairement à ce qu'on pourrait croire, le binaire en cours d'exécution +// n'empêche PAS de renommer le dossier qui le contient : Windows verrouille le +// fichier .exe lui-même, pas le nom de ses dossiers parents. Vérifié sur banc +// d'essai. La migration peut donc se faire depuis l'application en marche. +func postUpdateMigrate() { + if !retryHomeMigration() && !elevateForHomeMigration() { + return + } + fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome()) + relinkAfterMigration() +} + +// relinkAfterMigration répare ce que le déplacement laisse derrière lui. +// +// Le binaire installé vit DANS le dossier de données (%AJEAN_HOME%\bin). Le +// renommer déplace donc aussi le programme : le raccourci du menu Démarrer et +// l'entrée de PATH désignent un chemin qui n'existe plus. Sans cette étape, la +// migration « réussit » et l'utilisateur se retrouve avec un raccourci mort et +// une commande `ajean` introuvable — soit exactement ce qu'on cherchait à éviter. +func relinkAfterMigration() { + target := installedExePath() + binDir := filepath.Dir(target) + + if _, err := addToUserPath(binDir); err != nil { + fmt.Printf(" %s PATH non mis à jour (%v) — ajoute %s à la main\n", dim("•"), err, binDir) + } + // L'ancienne entrée ne désigne plus rien : la laisser encombrerait le PATH + // d'un chemin mort, et ferait échouer un `jean` tapé par habitude. + if legacyBin := filepath.Join(legacyDefaultHome(), "bin"); legacyBin != binDir { + _, _ = removeFromUserPath(legacyBin) + } + ensureShortcuts(target) +} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index dd6c89b..6a701bf 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -251,7 +251,11 @@ func cmdUpdate(args []string) error { if err != nil { return err } - fmt.Printf("✓ jean mis à jour en %s\n", newVer) + fmt.Printf("✓ ajean mis à jour en %s\n", newVer) + // Sous Windows, c'est ICI qu'on peut enfin migrer le dossier : `update` est + // le geste délibéré de l'utilisateur, et c'est le seul que la plupart + // feront. No-op ailleurs (Unix migre dans restartAfterUpdate, déjà en root). + postUpdateMigrate() printRestartHint() return nil } @@ -401,6 +405,7 @@ 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()