diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 44a941c..d1c8a07 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 a507d78..cf6c133 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 dc14f4b..a2d3f51 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -2,14 +2,14 @@ "FixedFileInfo": { "FileVersion": { "Major": 0, - "Minor": 7, - "Patch": 13, + "Minor": 8, + "Patch": 0, "Build": 0 }, "ProductVersion": { "Major": 0, - "Minor": 7, - "Patch": 13, + "Minor": 8, + "Patch": 0, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.13", + "ProductVersion": "0.8.0", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index e47cfc8..dbf32f0 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -3,7 +3,10 @@ package ajean import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "net" "os" "os/exec" @@ -127,6 +130,51 @@ func appFirstRun() bool { return launch(target) } +// exeSelf renvoie le chemin réel du binaire en cours (liens résolus). +func exeSelf() string { + p, err := os.Executable() + if err != nil { + return "" + } + if r, err := filepath.EvalSymlinks(p); err == nil { + return r + } + return p +} + +// sameFile dit si deux fichiers ont exactement le même contenu. Taille d'abord : +// deux binaires qui diffèrent ont presque toujours des tailles différentes, et +// c'est immédiat, là où le hachage lit 15 Mo. +func sameFile(a, b string) bool { + if a == "" || b == "" { + return false + } + fa, err1 := os.Stat(a) + fb, err2 := os.Stat(b) + if err1 != nil || err2 != nil { + return false + } + if fa.Size() != fb.Size() { + return false + } + ha, err1 := hashFile(a) + hb, err2 := hashFile(b) + return err1 == nil && err2 == nil && ha == hb +} + +func hashFile(p string) (string, error) { + f, err := os.Open(p) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + // launch démarre la copie installée et demande à l'appelant de rendre la main. // Renvoie false si le lancement échoue, auquel cas l'exécutable courant prend le // relais : mieux vaut démarrer depuis le mauvais dossier que pas du tout. @@ -136,7 +184,11 @@ func launch(target string) bool { // double-cliqué ? » — en regardant qui partage notre console. Un processus // lancé sans console du tout (relance après mise à jour) ne peut pas y // répondre, et se serait retrouvé à afficher l'aide au lieu de démarrer. - cmd := exec.Command(target, "app") + // hideCmd : le binaire est en sous-système console, donc Windows allouerait + // une console à l'enfant et on verrait une fenêtre noire apparaître. Comme + // « app » est passé explicitement, l'absence de console ne gêne pas la + // détection du mode application. + cmd := hideCmd(exec.Command(target, "app")) cmd.Dir = filepath.Dir(target) return cmd.Start() == nil } @@ -156,6 +208,14 @@ func runAsInstaller(target string) bool { if installed != "" { cmp = semver.Compare(ensureV(Version), ensureV(installed)) } + // Même numéro de version ne veut pas dire même binaire : une préversion + // republiée en garde le sien. Sans cette comparaison des fichiers, relancer + // le nouveau téléchargement démarrait simplement l'ancienne copie installée, + // sans rien remplacer et sans rien dire. On traite alors ce cas comme une + // mise à jour ordinaire. + if cmp == 0 && !sameFile(exeSelf(), target) { + cmp = 1 + } running := runningPIDs(target) switch { @@ -396,11 +456,19 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) { if (Test-Path $lnk) { try { $s=$w.CreateShortcut($lnk) + $chg=$false if ($s.TargetPath -and $s.TargetPath -ne $t -and (Test-Path $s.TargetPath)) { $s.TargetPath=$t $s.WorkingDirectory=(Split-Path $t) - $s.Save() + $chg=$true } + # Les raccourcis posés avant le passage en sous-système console n'ont pas + # le démarrage minimisé, et laissent donc apparaître une fenêtre noire. + if ($s.WindowStyle -ne 7) { $s.WindowStyle=7; $chg=$true } + # Icône épinglée sur l'exe : sans ça, Windows garde parfois en cache + # l'icône d'un binaire remplacé et affiche un carré blanc. + if ($s.IconLocation -ne "$t,0") { $s.IconLocation="$t,0"; $chg=$true } + if ($chg) { $s.Save() } } catch {} $done+=$d; continue } @@ -413,6 +481,7 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) { # une console au lancement. AJEAN la referme aussitôt, mais demander un # démarrage minimisé garantit qu'elle n'est jamais peinte à l'écran. $s.WindowStyle=7 + $s.IconLocation="$t,0" $s.Save() $done+=$d } catch {} diff --git a/internal/ajean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go index 2064385..64a55b9 100644 --- a/internal/ajean/sys_platform_windows.go +++ b/internal/ajean/sys_platform_windows.go @@ -63,7 +63,7 @@ func defaultEditor() string { return "notepad" } // openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les // pièges de quoting de `cmd /c start`. func openBrowser(url string) error { - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + return hideCmd(exec.Command("rundll32", "url.dll,FileProtocolHandler", url)).Start() } // hideCmd empêche une commande externe d'ouvrir une fenêtre de console diff --git a/internal/ajean/version_test.go b/internal/ajean/version_test.go new file mode 100644 index 0000000..73d452f --- /dev/null +++ b/internal/ajean/version_test.go @@ -0,0 +1,41 @@ +package ajean + +import ( + "encoding/json" + "fmt" + "os" + "testing" +) + +// La ressource de version Windows (cmd/ajean/versioninfo.json) doit suivre la +// constante Version. Elles ont divergé en 0.8.0 : la constante était à jour, la +// ressource restait à 0.7.13. Conséquence peu lisible — le binaire installé se +// DÉCLARAIT 0.7.13 auprès de Windows, si bien que relancer le fichier téléchargé +// proposait indéfiniment « appliquer la mise à jour ? » sur une version déjà +// installée. Ce test rend l'oubli impossible. +func TestVersionInfoWindowsSuitLaVersion(t *testing.T) { + b, err := os.ReadFile("../../cmd/ajean/versioninfo.json") + if err != nil { + t.Fatal(err) + } + var vi struct { + FixedFileInfo struct { + FileVersion struct{ Major, Minor, Patch int } + ProductVersion struct{ Major, Minor, Patch int } + } + StringFileInfo struct{ ProductVersion string } + } + if err := json.Unmarshal(b, &vi); err != nil { + t.Fatal(err) + } + f := vi.FixedFileInfo.FileVersion + for name, got := range map[string]string{ + "FixedFileInfo.FileVersion": fmt.Sprintf("%d.%d.%d", f.Major, f.Minor, f.Patch), + "FixedFileInfo.ProductVersion": fmt.Sprintf("%d.%d.%d", vi.FixedFileInfo.ProductVersion.Major, vi.FixedFileInfo.ProductVersion.Minor, vi.FixedFileInfo.ProductVersion.Patch), + "StringFileInfo.ProductVersion": vi.StringFileInfo.ProductVersion, + } { + if got != Version { + t.Errorf("%s = %s, attendu %s — pense à `go generate ./cmd/ajean` après un bump", name, got, Version) + } + } +}