mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
f541d997d2
commit
0d9284e95c
10 files changed
+209
-70
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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(){
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(){
|
||||
|
||||
Reference in new issue
Block a user