Files
Loki/internal/jean/sys_firstrun_windows.go
T
nathaninline 0d9284e95c v0.6.11 : le .exe telecharge met a jour et demarre, emplacements dans le journal
- Windows : quand AJEAN est deja installe, lancer le fichier telecharge remplace
  le binaire installe (s'il est plus recent) puis demarre l'application, sans
  message. La 0.6.10 annoncait « vous lancez une copie » puis demarrait quand
  meme : une information inactionnable suivie d'un comportement non annonce.
  Le refus d'installation est desormais memorise (.install_declined).
- la version du binaire installe est lue dans les ressources du fichier
  (VS_VERSIONINFO). L'implementation evidente (executer `jean version` et lire
  la sortie) renvoie du VIDE : le binaire est en sous-systeme GUI et n'ecrit pas
  dans un tuyau, donc la mise a jour ne se serait jamais faite, en silence.
  Piege documente dans le code + deux tests.
- UI : les emplacements passent sous le journal du moteur (clic sur la pastille
  d'etat) au lieu d'un bouton isole dans les actions.
2026-08-04 11:39:25 +02:00

283 lines
11 KiB
Go

//go:build windows
package jean
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/mod/semver"
)
// Premier lancement sous Windows.
//
// Avant : double-cliquer jean-windows-amd64.exe déclenchait une installation
// SILENCIEUSE (copie du binaire dans %ProgramData%\jean\bin, ajout au PATH) dont
// l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en
// afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce
// que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau,
// qui tourne, et celle installée) : « mettre à jour » ne touchait que celle lancée,
// tandis que le raccourci et le PATH pointaient sur l'autre, restée en arrière.
//
// Maintenant, deux cas seulement :
//
// 1. AJEAN n'est pas installé. On DEMANDE, une bonne fois : c'est le seul moment
// où l'utilisateur gagne à savoir ce qu'on pose sur sa machine et où. Puis
// copie + PATH + raccourcis, et on démarre depuis la copie installée. Un refus
// est mémorisé, on ne repose pas la question.
//
// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte alors comme un
// installeur de mise à jour : s'il est plus récent, il remplace le binaire
// installé, puis l'application démarre. AUCUN message. Annoncer « vous lancez
// une copie, ça ne sert à rien » puis démarrer quand même n'apprenait rien et
// ne laissait rien à décider.
//
// Dans les deux cas il n'existe au final qu'UN binaire qui compte, à un
// emplacement connu, et c'est celui que le bouton de mise à jour modifie.
const (
mbYesNo = 0x00000004
mbIconQuestion = 0x00000020
mbIconInfo = 0x00000040
mbTopmost = 0x00040000
idYes = 6
)
var (
pMessageBoxW = u32s.NewProc("MessageBoxW")
verDLL = syscall.NewLazyDLL("version.dll")
pGetFileVersionInfoSizeW = verDLL.NewProc("GetFileVersionInfoSizeW")
pGetFileVersionInfoW = verDLL.NewProc("GetFileVersionInfoW")
pVerQueryValueW = verDLL.NewProc("VerQueryValueW")
)
func messageBox(text, title string, flags uintptr) int {
t, _ := syscall.UTF16PtrFromString(text)
c, _ := syscall.UTF16PtrFromString(title)
r, _, _ := pMessageBoxW.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(c)), flags|mbTopmost)
return int(r)
}
// appFirstRun prépare le dossier de données et, si le binaire lancé n'est pas
// celui installé, propose l'installation. Renvoie true si l'app a été relancée
// depuis la copie installée (l'appelant doit alors rendre la main immédiatement).
func appFirstRun() bool {
// Le dossier de données et config.env sont indispensables pour que l'UI
// s'ouvre : on les crée sans rien demander, ils ne surprennent personne.
_ = provisionDataDir()
exe, err := os.Executable()
if err != nil {
return false
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
target := installedExePath()
if strings.EqualFold(exe, target) {
return false // on EST déjà l'application installée
}
if _, err := os.Stat(target); err == nil {
// AJEAN est déjà installé. Lancer le fichier téléchargé doit alors se
// comporter comme un installeur de mise à jour : si cette copie est plus
// récente, elle remplace celle installée, et l'application démarre. Sans
// message : annoncer « vous lancez une copie » puis démarrer quand même
// n'apprend rien à l'utilisateur et ne lui laisse rien à décider.
upgradeInstalled(target)
return launch(target)
}
// Première installation : là, une question. C'est le seul moment où
// l'utilisateur a intérêt à savoir ce qui va être posé sur sa machine, et
// c'est l'absence de cette question qui rendait le double-clic illisible.
if declinedInstall() {
return false
}
if messageBox(
"Installer AJEAN sur cet ordinateur ?\n\n"+
"• le programme sera copié dans :\n "+target+"\n"+
"• un raccourci sera ajouté au menu Démarrer et au Bureau\n"+
"• vos réglages et modèles seront rangés dans :\n "+JeanHome()+"\n\n"+
"Vous pourrez ensuite supprimer le fichier que vous venez de télécharger.\n\n"+
"Répondre Non lance AJEAN sans rien installer.",
"Installation d'AJEAN", mbYesNo|mbIconQuestion) != idYes {
// Refus mémorisé : reposer la question à chaque double-clic serait
// ignorer la réponse déjà donnée.
_ = os.WriteFile(declineFlag(), []byte("non\n"), 0o644)
return false
}
if _, err := installSelf(filepath.Dir(target)); err != nil {
messageBox("Installation impossible :\n\n"+err.Error()+"\n\nAJEAN va démarrer depuis l'emplacement actuel.", "AJEAN", mbIconInfo)
return false
}
_, _ = addToUserPath(filepath.Dir(target))
shortcuts := createShortcuts(target)
messageBox("AJEAN est installé.\n\n"+target+"\n\n"+shortcuts+
"\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.",
"AJEAN", mbIconInfo)
return launch(target)
}
func declineFlag() string { return filepath.Join(JeanHome(), ".install_declined") }
func declinedInstall() bool {
_, err := os.Stat(declineFlag())
return err == 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.
func launch(target string) bool {
cmd := exec.Command(target)
cmd.Dir = filepath.Dir(target)
return cmd.Start() == nil
}
// upgradeInstalled remplace le binaire installé par celui qu'on exécute, quand
// ce dernier est plus récent : lancer le fichier téléchargé met ainsi AJEAN à
// jour, ce qui est la seule chose qu'on puisse raisonnablement en attendre.
// Silencieux et best-effort : si la copie échoue (application en cours
// d'exécution, donc fichier verrouillé), on démarre simplement la version en
// place, qui remontera l'onglet existant.
func upgradeInstalled(target string) {
installed := binaryVersion(target)
if installed == "" || semver.Compare(ensureV(Version), ensureV(installed)) <= 0 {
return // même version, plus ancienne, ou version illisible : on ne touche à rien
}
// L'exe installé peut être en cours d'exécution : on l'écarte d'abord, comme
// pour une mise à jour classique (voir replaceBinary).
old := target + ".old"
_ = os.Remove(old)
if err := os.Rename(target, old); err != nil {
return
}
if _, err := installSelf(filepath.Dir(target)); err != nil {
_ = os.Rename(old, target) // rollback
return
}
_ = os.Remove(old) // échoue si l'ancien tourne encore ; nettoyé au prochain lancement
}
// binaryVersion lit la version dans les ressources du fichier (VS_VERSIONINFO,
// posées par goversioninfo, cf cmd/jean/versioninfo.json), comme le fait
// l'onglet « Détails » des propriétés Windows.
//
// ⚠️ NE PAS remplacer par un `jean version` exécuté : le binaire est compilé en
// sous-système GUI, il s'attache à la console du parent et n'écrit RIEN dans un
// tuyau. La sortie capturée est vide, donc la comparaison de versions échouerait
// toujours en silence et la mise à jour ne se ferait jamais (vérifié).
//
// Renvoie "" si la ressource est absente ou illisible : on s'abstient alors de
// remplacer quoi que ce soit.
func binaryVersion(path string) string {
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
return ""
}
size, _, _ := pGetFileVersionInfoSizeW.Call(uintptr(unsafe.Pointer(p)), 0)
if size == 0 {
return ""
}
buf := make([]byte, size)
if r, _, _ := pGetFileVersionInfoW.Call(
uintptr(unsafe.Pointer(p)), 0, size, uintptr(unsafe.Pointer(&buf[0]))); r == 0 {
return ""
}
sub, _ := syscall.UTF16PtrFromString(`\`)
var info *vsFixedFileInfo
var length uint32
if r, _, _ := pVerQueryValueW.Call(
uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(sub)),
uintptr(unsafe.Pointer(&info)), uintptr(unsafe.Pointer(&length))); r == 0 || info == nil {
return ""
}
return fmt.Sprintf("%d.%d.%d",
info.FileVersionMS>>16, info.FileVersionMS&0xffff, info.FileVersionLS>>16)
}
// vsFixedFileInfo : bloc racine de VS_VERSIONINFO. Seuls les champs de version
// nous intéressent, mais la structure doit être complète pour l'alignement.
type vsFixedFileInfo struct {
Signature uint32
StrucVersion uint32
FileVersionMS uint32
FileVersionLS uint32
ProductVersionMS uint32
ProductVersionLS uint32
FileFlagsMask uint32
FileFlags uint32
FileOS uint32
FileType uint32
FileSubtype uint32
FileDateMS uint32
FileDateLS uint32
}
// provisionDataDir crée le dossier de données et un config.env de départ.
// Extrait de cmdInstall pour être réutilisable au premier lancement, sans
// embarquer l'installation du binaire ni les messages de console.
func provisionDataDir() error {
jeanHome := JeanHome()
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
conf := filepath.Join(jeanHome, "config.env")
if _, err := os.Stat(conf); os.IsNotExist(err) {
return os.WriteFile(conf, []byte(configTemplate), 0o644)
}
return nil
}
// createShortcuts pose un raccourci « AJEAN » dans le menu Démarrer et sur le
// Bureau. Renvoie une phrase décrivant ce qui a été créé (affichée à
// l'utilisateur). Best-effort : un échec n'empêche pas l'installation.
func createShortcuts(target string) string {
ps := fmt.Sprintf(`$t=%s
$w=New-Object -ComObject WScript.Shell
$done=@()
foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) {
if (-not $d) { continue }
try {
$s=$w.CreateShortcut((Join-Path $d 'AJEAN.lnk'))
$s.TargetPath=$t
$s.WorkingDirectory=(Split-Path $t)
$s.Description='AJEAN — IA locale'
$s.Save()
$done+=$d
} catch {}
}
Write-Output ($done -join ';')`, psQuote(target))
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
out, err := cmd.CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) == "" {
return "(raccourcis non créés — lancez AJEAN depuis " + target + ")"
}
return "Raccourci « AJEAN » ajouté au menu Démarrer et au Bureau."
}
// removeShortcuts efface les raccourcis posés par createShortcuts. Renvoie true
// si au moins un a été supprimé.
func removeShortcuts() bool {
ps := `$n=0
foreach ($d in @([Environment]::GetFolderPath('StartMenu'), [Environment]::GetFolderPath('Desktop'))) {
if (-not $d) { continue }
$p=Join-Path $d 'AJEAN.lnk'
if (Test-Path $p) { try { Remove-Item $p -Force; $n++ } catch {} }
}
Write-Output $n`
out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) != "0"
}