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.
This commit is contained in:
nathaninline committed 2026-08-04 11:39:25 +02:00
1 parent f541d997d2
commit 0d9284e95c
10 files changed
+209 -70

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.6.10"
const Version = "0.6.11"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
+135 -25
View File
@@ -10,6 +10,8 @@ import (
"strings"
"syscall"
"unsafe"
"golang.org/x/mod/semver"
)
// Premier lancement sous Windows.
@@ -22,12 +24,21 @@ import (
// 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 : on DEMANDE, une bonne fois. Si l'utilisateur accepte, on installe
// pour de bon (copie + PATH + raccourcis menu Démarrer/Bureau) puis on relance
// depuis la copie installée et on quitte — il n'existe alors qu'UN seul binaire qui
// compte, à un emplacement connu, et le bouton de mise à jour agit dessus. S'il
// refuse, on lance l'application telle quelle, sans rien écrire ailleurs que dans
// le dossier de données.
// 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
@@ -37,7 +48,14 @@ const (
idYes = 6
)
var pMessageBoxW = u32s.NewProc("MessageBoxW")
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)
@@ -66,21 +84,21 @@ func appFirstRun() bool {
return false // on EST déjà l'application installée
}
if _, err := os.Stat(target); err == nil {
// Une copie est déjà installée — typiquement par l'installation silencieuse
// des versions ≤ 0.6.9. On le signale UNE SEULE FOIS : répéter la boîte à
// chaque double-clic du fichier téléchargé serait un harcèlement, pas une
// information.
notice := filepath.Join(JeanHome(), ".install_notice")
if _, seen := os.Stat(notice); seen != nil {
messageBox(
"AJEAN est déjà installé sur cet ordinateur :\n\n"+target+
"\n\nCe fichier-ci n'est qu'une copie téléchargée : vous pouvez la supprimer et lancer AJEAN depuis le menu Démarrer.\n\nCette copie va démarrer normalement.",
"AJEAN", mbIconInfo)
_ = os.WriteFile(notice, []byte(target+"\n"), 0o644)
}
return false
// 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"+
@@ -89,6 +107,9 @@ func appFirstRun() bool {
"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
}
@@ -103,14 +124,103 @@ func appFirstRun() bool {
"\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.",
"AJEAN", mbIconInfo)
// Relance depuis la copie installée : à partir de maintenant, une seule et
// même image du programme se met à jour, se relance et apparaît dans le tray.
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)
if err := cmd.Start(); err != nil {
return false // échec du relancement : on continue avec la copie courante
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
}
return true
// 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.
@@ -0,0 +1,39 @@
//go:build windows
package jean
import (
"os"
"path/filepath"
"regexp"
"testing"
)
// binaryVersion pilote la mise à jour automatique du binaire installé quand on
// lance le fichier téléchargé : si elle renvoie du vide, plus aucune mise à jour
// n'a lieu, et en silence. C'est exactement ce qui arrivait avec la première
// implémentation (exécuter `jean version`, dont la sortie est vide pour un
// binaire en sous-système GUI). D'où ces tests sur la plomberie syscall.
func TestBinaryVersionReadsResource(t *testing.T) {
// kernel32.dll porte toujours une ressource de version.
dll := filepath.Join(os.Getenv("SystemRoot"), "System32", "kernel32.dll")
if _, err := os.Stat(dll); err != nil {
t.Skip("kernel32.dll introuvable")
}
v := binaryVersion(dll)
if !regexp.MustCompile(`^\d+\.\d+\.\d+$`).MatchString(v) {
t.Fatalf("binaryVersion(kernel32.dll) = %q, attendu une version x.y.z", v)
}
}
// Un fichier sans ressource de version doit renvoyer "" : upgradeInstalled s'en
// sert pour s'abstenir plutôt que de remplacer un binaire au hasard.
func TestBinaryVersionWithoutResource(t *testing.T) {
p := filepath.Join(t.TempDir(), "vide.exe")
if err := os.WriteFile(p, []byte("pas un exe"), 0o644); err != nil {
t.Fatal(err)
}
if v := binaryVersion(p); v != "" {
t.Fatalf("binaryVersion sur un fichier sans ressource = %q, attendu \"\"", v)
}
}
+12 -10
View File
@@ -872,6 +872,12 @@ button:hover{border-color:var(--dim);color:var(--text)}
<button onclick="loadSvcLog()" title="rafraîchir">↻</button>
</div>
<pre id="svc-log" class="muted" style="font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:6px 0 0"></pre>
<!-- Emplacements : au même endroit que le journal, c'est le panneau où l'on
va quand on cherche à comprendre l'état de son installation. -->
<div class="row" style="justify-content:space-between;align-items:center;margin-top:12px">
<span class="io-cap">Emplacements</span>
</div>
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
</div>
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
une seule section, deux blocs de statistique dans la même carte. -->
@@ -1059,8 +1065,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div class="slist pad">
<div class="row"><button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button></div>
<div id="upd-msg" class="muted"></div>
<div class="row"><button onclick="showPaths()">📁 Où sont mes fichiers ?</button></div>
<div id="paths-msg" class="muted"></div>
<div class="row">
<button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button>
@@ -1635,7 +1639,7 @@ function toggleSvcLog(){
if(!box) return;
const show = box.style.display==='none';
box.style.display = show ? '' : 'none';
if(show) loadSvcLog();
if(show){ loadSvcLog(); showPaths(); }
}
async function loadSvcLog(){
const el=document.getElementById('svc-log');
@@ -1661,18 +1665,16 @@ async function checkUpdate(){
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
// Emplacements : la question « où sont mes fichiers / est-ce que ça a été
// installé ? » revient assez souvent pour mériter une réponse dans l'UI.
// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on
// ouvre quand on cherche à comprendre l'état de son installation.
async function showPaths(){
const el=document.getElementById('paths-msg');
if(!el) return;
el.textContent='…';
try{
const p=await jget('/api/paths');
const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]];
el.innerHTML=rows.map(r=>'<div><b>'+r[0]+'</b><br><code>'+escHtml(r[1]||'')+'</code></div>').join('');
if(p.exe && p.installed && p.exe!==p.installed){
el.innerHTML+='<div style="color:var(--warn);margin-top:8px">Vous exécutez une copie du programme, pas celle installée (<code>'+escHtml(p.installed)+'</code>). La mise à jour ne modifiera que la copie lancée.</div>';
}
const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['Programme',p.exe]];
el.innerHTML=rows.map(r=>'<div style="margin-bottom:4px">'+r[0]+'<br><code style="word-break:break-all">'+escHtml(r[1]||'')+'</code></div>').join('');
}catch(e){ el.textContent='Erreur'; }
}
async function applyUpdate(){
+6 -2
View File
@@ -44,6 +44,12 @@
<button onclick="loadSvcLog()" title="rafraîchir">↻</button>
</div>
<pre id="svc-log" class="muted" style="font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:6px 0 0"></pre>
<!-- Emplacements : au même endroit que le journal, c'est le panneau où l'on
va quand on cherche à comprendre l'état de son installation. -->
<div class="row" style="justify-content:space-between;align-items:center;margin-top:12px">
<span class="io-cap">Emplacements</span>
</div>
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
</div>
<!-- GPU et RAM décrivent la même chose (les ressources de la machine) :
une seule section, deux blocs de statistique dans la même carte. -->
@@ -231,8 +237,6 @@
<div class="slist pad">
<div class="row"><button id="upd-check" onclick="checkUpdate()">↥ Vérifier les mises à jour</button></div>
<div id="upd-msg" class="muted"></div>
<div class="row"><button onclick="showPaths()">📁 Où sont mes fichiers ?</button></div>
<div id="paths-msg" class="muted"></div>
<div class="row">
<button onclick="loadAll()">↻ refresh</button>
<button onclick="resetChat()">⌫ clear chat</button>
+6 -8
View File
@@ -35,7 +35,7 @@ function toggleSvcLog(){
if(!box) return;
const show = box.style.display==='none';
box.style.display = show ? '' : 'none';
if(show) loadSvcLog();
if(show){ loadSvcLog(); showPaths(); }
}
async function loadSvcLog(){
const el=document.getElementById('svc-log');
@@ -61,18 +61,16 @@ async function checkUpdate(){
}catch(e){ msg.textContent='Erreur réseau'; }
b.disabled=false;
}
// Emplacements : la question « où sont mes fichiers / est-ce que ça a été
// installé ? » revient assez souvent pour mériter une réponse dans l'UI.
// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on
// ouvre quand on cherche à comprendre l'état de son installation.
async function showPaths(){
const el=document.getElementById('paths-msg');
if(!el) return;
el.textContent='…';
try{
const p=await jget('/api/paths');
const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'agent',p.workspace],['Mémoire',p.memory],['Programme en cours',p.exe]];
el.innerHTML=rows.map(r=>'<div><b>'+r[0]+'</b><br><code>'+escHtml(r[1]||'')+'</code></div>').join('');
if(p.exe && p.installed && p.exe!==p.installed){
el.innerHTML+='<div style="color:var(--warn);margin-top:8px">Vous exécutez une copie du programme, pas celle installée (<code>'+escHtml(p.installed)+'</code>). La mise à jour ne modifiera que la copie lancée.</div>';
}
const rows=[['Données',p.home],['Configuration',p.config],['Fichiers créés par l\'IA',p.workspace],['Mémoire',p.memory],['Programme',p.exe]];
el.innerHTML=rows.map(r=>'<div style="margin-bottom:4px">'+r[0]+'<br><code style="word-break:break-all">'+escHtml(r[1]||'')+'</code></div>').join('');
}catch(e){ el.textContent='Erreur'; }
}
async function applyUpdate(){