diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index db9ceaf..82913ad 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,14 @@ -## Corrections - -**L'IA déposait ses fichiers sur votre Bureau.** En mode agent, quand elle créait un fichier (une recherche web mise de côté, un script, des notes), celui-ci atterrissait dans le dossier depuis lequel AJEAN avait été lancé. Le Bureau si vous aviez double-cliqué le fichier téléchargé, `C:\ProgramData\jean\bin` si vous passiez par l'installation. Personne ne s'attend à ce qu'une conversation laisse des fichiers derrière elle. - -L'IA travaille désormais dans un dossier à elle, `workspace`, rangé avec vos réglages. Les fichiers déjà créés ne bougent pas, et le bouton « Où sont mes fichiers ? » vous dit où regarder. Si vous lui demandez explicitement d'écrire à un endroit précis, elle le fait toujours : seuls les fichiers sans destination indiquée sont rangés. - -Merci à Sébastien pour le signalement, et pour avoir tout de suite pointé le lien avec le dossier de lancement. - -**Le bouton de mise à jour échouait sur un message incompréhensible.** Sous Linux, lancé sans `sudo`, il répondait `open /usr/local/bin/.jean-update.tmp: permission denied`. AJEAN prévoyait bien un message expliquant qu'il fallait des privilèges, mais il arrivait trop tard dans le déroulé : l'échec se produisait avant, au moment d'écrire le fichier temporaire. - -Les droits sont maintenant vérifiés avant de télécharger quoi que ce soit, et le message donne la commande exacte à lancer. Le téléchargement dispose par ailleurs de dix minutes au lieu de trente secondes : sur une connexion lente, une mise à jour qui réussissait pouvait être annoncée comme un échec. - -Merci à Emmanuel pour le signalement et pour avoir isolé le rôle de `sudo`. - ## Lancement sous Windows -**On sait enfin ce que fait le fichier téléchargé.** Le double-clic sur `jean-windows-amd64.exe` déclenchait une installation silencieuse : copie du programme, ajout au PATH, sans le moindre message. Impossible de savoir si l'on venait d'installer AJEAN ou seulement de l'ouvrir. Deux copies du programme coexistaient alors, celle que vous lanciez et celle installée, et la mise à jour ne touchait que la première. +**Le fichier téléchargé met AJEAN à jour et démarre.** Si AJEAN est déjà installé sur la machine, lancer `jean-windows-amd64.exe` remplace le programme installé par cette version, puis ouvre l'application. Sans question, sans message : c'est la seule chose qu'on puisse attendre d'un fichier qu'on vient de télécharger. -AJEAN vous pose maintenant la question, une seule fois. Il annonce où il s'installe, où vont vos réglages, ajoute un raccourci au menu Démarrer et au Bureau, puis démarre depuis la copie installée. Un seul programme, à un endroit connu, que la mise à jour retrouve. Répondre non le lance sans rien installer. La désinstallation retire les raccourcis qu'elle a posés. +La 0.6.10 se contentait de signaler qu'AJEAN était déjà installé ailleurs, puis démarrait quand même la copie téléchargée. Une information dont on ne pouvait rien faire, suivie d'un comportement qu'elle n'annonçait pas. + +À la toute première installation, en revanche, AJEAN demande toujours : c'est le seul moment où savoir ce qui est posé sur la machine, et à quel endroit, change quelque chose. Un refus est désormais mémorisé au lieu d'être reposé à chaque lancement. ## Interface -**« Où sont mes fichiers ? »** Un bouton dans les actions affiche l'emplacement de vos données, de la configuration, de la mémoire et du dossier de travail de l'IA. Il vous prévient aussi si le programme que vous exécutez n'est pas celui installé. En ligne de commande : `jean where`. - -**Nouvelle icône.** L'icône de l'application, celle de la zone de notification Windows et celle de la barre de menus macOS reprennent le logo noir du site. Elles sont dessinées à partir d'une source unique et ne peuvent plus se retrouver décalées les unes des autres. Sur macOS, l'icône s'adapte au thème clair ou sombre de la barre de menus. +**Les emplacements ont rejoint le journal du moteur.** Le dossier de vos données, la configuration, la mémoire et le dossier de travail de l'IA s'affichent sous le journal, en cliquant sur la pastille d'état. Ils avaient leur propre bouton dans les actions en 0.6.10, loin de l'endroit où l'on va quand on cherche à comprendre son installation. ## Mise à jour @@ -30,6 +16,6 @@ AJEAN vous pose maintenant la question, une seule fois. Il annonce où il s'inst jean update ``` -Si vous lancez AJEAN sans privilèges administrateur et que le programme appartient à root, cette version-ci doit encore être installée en ligne de commande avec `sudo jean update`. Les suivantes vous le diront clairement depuis l'interface. +Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait désormais le même travail. -L'icône de la barre de menus macOS n'a pas pu être vérifiée sur une vraie machine. +L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas pu être vérifiée sur une vraie machine. diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index f0a18da..783dc09 100644 Binary files a/cmd/jean/resource_windows_amd64.syso and b/cmd/jean/resource_windows_amd64.syso differ diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 14c399d..6887f30 100644 Binary files a/cmd/jean/resource_windows_arm64.syso and b/cmd/jean/resource_windows_arm64.syso differ diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 2b81497..069538f 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 10, + "Patch": 11, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 10, + "Patch": 11, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.10", + "ProductVersion": "0.6.11", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index 13ab934..2b3dc12 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -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() { diff --git a/internal/jean/sys_firstrun_windows.go b/internal/jean/sys_firstrun_windows.go index 266d20c..bbb479b 100644 --- a/internal/jean/sys_firstrun_windows.go +++ b/internal/jean/sys_firstrun_windows.go @@ -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. diff --git a/internal/jean/sys_firstrun_windows_test.go b/internal/jean/sys_firstrun_windows_test.go new file mode 100644 index 0000000..2275802 --- /dev/null +++ b/internal/jean/sys_firstrun_windows_test.go @@ -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) + } +} diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 0c0d407..4574494 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -872,6 +872,12 @@ button:hover{border-color:var(--dim);color:var(--text)}

+    
+    
+ Emplacements +
+
@@ -1059,8 +1065,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
-
-
@@ -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=>'
'+r[0]+'
'+escHtml(r[1]||'')+'
').join(''); - if(p.exe && p.installed && p.exe!==p.installed){ - el.innerHTML+='
Vous exécutez une copie du programme, pas celle installée ('+escHtml(p.installed)+'). La mise à jour ne modifiera que la copie lancée.
'; - } + 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=>'
'+r[0]+'
'+escHtml(r[1]||'')+'
').join(''); }catch(e){ el.textContent='Erreur'; } } async function applyUpdate(){ diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index fe30c22..e2a1589 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -44,6 +44,12 @@

+    
+    
+ Emplacements +
+
@@ -231,8 +237,6 @@
-
-
diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js index 81b8e9e..38a3541 100644 --- a/internal/jean/ui/src/js/05-status.js +++ b/internal/jean/ui/src/js/05-status.js @@ -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=>'
'+r[0]+'
'+escHtml(r[1]||'')+'
').join(''); - if(p.exe && p.installed && p.exe!==p.installed){ - el.innerHTML+='
Vous exécutez une copie du programme, pas celle installée ('+escHtml(p.installed)+'). La mise à jour ne modifiera que la copie lancée.
'; - } + 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=>'
'+r[0]+'
'+escHtml(r[1]||'')+'
').join(''); }catch(e){ el.textContent='Erreur'; } } async function applyUpdate(){