diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2b1a33a..0f00ff5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,14 +1,10 @@ -## Correction +## Le renommage du dossier se termine tout seul sous Windows -**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. +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. -Deux causes, qui se renforçaient. +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. -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. - -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. - -Après cette mise à jour, le message disparaît de lui-même au premier lancement. +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. ## Mise à jour diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 234274d..4b1f6b3 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 1f069e6..d3e5e08 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 826e2ae..62a3a6e 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 2, + "Patch": 3, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 2, + "Patch": 3, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.2", + "ProductVersion": "0.7.3", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 4af5f02..bf7942c 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.2" +const Version = "0.7.3" // 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 migrateHomeArg: + // Sous-commande interne, volontairement absente de l'aide : c'est le + // travail delegue au process elevé par elevateForHomeMigration(). + mustExit(cmdMigrateHomeElevated()) case "install": mustExit(cmdInstall(args)) case "uninstall": diff --git a/internal/ajean/sys_elevate_other.go b/internal/ajean/sys_elevate_other.go new file mode 100644 index 0000000..b5a1a8a --- /dev/null +++ b/internal/ajean/sys_elevate_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package ajean + +// Pendant non-Windows de sys_elevate_windows.go. +// +// L'élévation ponctuelle n'a de sens que sous Windows : ailleurs, l'installation +// se fait déjà sous `sudo ajean install`, qui a les droits nécessaires et migre +// l'agencement complet (voir sys_layout.go). Il n'y a donc rien à relancer. + +const migrateHomeArg = "migrate-home-elevated" + +func cmdMigrateHomeElevated() error { return nil } + +func elevateForHomeMigration() bool { return false } diff --git a/internal/ajean/sys_elevate_windows.go b/internal/ajean/sys_elevate_windows.go new file mode 100644 index 0000000..0253a1d --- /dev/null +++ b/internal/ajean/sys_elevate_windows.go @@ -0,0 +1,79 @@ +//go:build windows + +package ajean + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// Élévation ponctuelle pour terminer la migration du dossier de données. +// +// Renommer %ProgramData%\jean exige d'écrire dans %ProgramData%, ce qu'un compte +// standard ne peut pas faire. Sans élévation, ces postes gardent l'ancien nom +// pour toujours — ce qui est sans conséquence, mais laisse le renommage à +// moitié fait. +// +// La fenêtre UAC n'apparaît QUE si elle sert à quelque chose : +// +// - migration déjà faite, inutile, ou chemin imposé par l'utilisateur → rien ; +// - migration en attente, dans un parcours d'installation → on demande. +// +// On ne cherche PAS à deviner la cause depuis le code d'erreur : Windows renvoie +// ERROR_ACCESS_DENIED aussi bien pour un manque de droits que pour un dossier +// verrouillé par un programme en cours. Impossible de les séparer — vérifié. +// +// C'est donc le LIEU D'APPEL qui fait la sélection, et lui est fiable : on +// n'élève que depuis `install` et depuis l'installeur de mise à jour, tous deux +// après l'arrêt des instances en cours. Le cas « verrouillé » y est déjà écarté ; +// ce qui reste est un problème de droits, que l'élévation résout. + +// migrateHomeArg est la sous-commande interne exécutée par le process élevé. +// Absente de l'aide : ce n'est pas une commande destinée aux utilisateurs, mais +// le travail précis qu'on délègue à un process ayant les droits. +const migrateHomeArg = "migrate-home-elevated" + +// cmdMigrateHomeElevated est le point d'entrée du process élevé : il migre, dit +// ce qu'il a fait, et rend la main. Rien d'autre — moins il en fait sous des +// droits administrateur, mieux c'est. +func cmdMigrateHomeElevated() error { + if retryHomeMigration() { + fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome()) + return nil + } + if migrationDeferred != "" { + return fmt.Errorf("migration toujours impossible : %s", migrationDeferred) + } + return nil +} + +// elevateForHomeMigration relance AJEAN en administrateur le temps de la seule +// migration, puis revérifie. Renvoie true si le dossier a effectivement changé. +// +// Silencieux et sans effet quand l'élévation ne servirait à rien, y compris si +// l'utilisateur refuse la fenêtre UAC : son refus est une réponse valable, la +// machine continue de fonctionner sur l'ancien chemin. +func elevateForHomeMigration() bool { + if migrationDeferred == "" { + return false + } + exe, err := os.Executable() + if err != nil { + return false + } + // Start-Process -Verb RunAs déclenche l'invite UAC ; -Wait pour que la suite + // de l'installation reparte sur le bon dossier, pas sur l'ancien. + ps := fmt.Sprintf( + `try { Start-Process -FilePath %s -ArgumentList '%s' -Verb RunAs -Wait -WindowStyle Hidden; 'ok' } catch { 'refus' }`, + psQuote(exe), migrateHomeArg) + out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-Command", ps)).CombinedOutput() + if err != nil || !strings.Contains(string(out), "ok") { + return false // refus de l'utilisateur, ou élévation indisponible + } + // Le process élevé a fait le travail dans SON espace mémoire : on relit le + // disque pour savoir ce qu'il en est réellement ici. + migrationDeferred = "" + return retryHomeMigration() +} diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index 532cff5..e443131 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -195,6 +195,11 @@ func runAsInstaller(target string) bool { return launch(target) // l'instance en cours reprend la main (port occupé) } stopProcesses(running) + // Les instances sont arrêtées : c'est le moment où le renommage peut + // aboutir. S'il bute encore, et seulement sur un refus de droits, on + // demande l'élévation — l'utilisateur est déjà dans un parcours + // d'installation, une fenêtre UAC n'y est pas une surprise. + elevateForHomeMigration() target = migrateThenResolveTarget(target) if err := replaceInstalled(target); err != nil { messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.", @@ -206,6 +211,7 @@ func runAsInstaller(target string) bool { case cmp > 0: // Mise à jour, application arrêtée : rien à décider, on remplace et on // démarre. C'est le cas courant et il doit rester muet. + elevateForHomeMigration() target = migrateThenResolveTarget(target) _ = replaceInstalled(target) ensureShortcuts(target) diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index 70aafa3..c575c52 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -42,7 +42,10 @@ func cmdInstall(args []string) error { // enfin aboutir sur un poste où l'utilisateur courant n'a pas les droits sur // %ProgramData%. On la retente donc AVANT de résoudre le dossier : l'inverse // laisserait la variable pointer sur l'ancien chemin et installerait à côté. - if retryHomeMigration() { + // elevateForHomeMigration ne demande l'élévation que si elle peut débloquer + // la situation : jamais quand il n'y a rien à migrer, ni quand l'obstacle + // est un fichier verrouillé que des droits administrateur ne libéreraient pas. + if retryHomeMigration() || elevateForHomeMigration() { fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome()) } else if notice := homeMigrationNotice(); notice != "" { fmt.Printf("%s %s\n", yellow("[info]"), notice) diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go index 73fa14b..89d7f94 100644 --- a/internal/ajean/sys_migrate_test.go +++ b/internal/ajean/sys_migrate_test.go @@ -271,3 +271,16 @@ func TestNoNoticeWhenMigrationSucceeds(t *testing.T) { t.Fatalf("message inattendu apres une migration reussie:\n%s", n) } } + +// Migration reussie : aucun residu, donc aucune demande d'elevation nulle part. +func TestAucunResiduApresMigrationReussie(t *testing.T) { + migrationDeferred = "" + t.Cleanup(func() { migrationDeferred = "" }) + root := t.TempDir() + legacy := filepath.Join(root, "jean") + mkHome(t, legacy) + migrateHome(filepath.Join(root, "ajean"), legacy) + if migrationDeferred != "" { + t.Errorf("etat inattendu apres une migration reussie: %q", migrationDeferred) + } +}