diff --git a/internal/jean/cli_app.go b/internal/jean/cli_app.go index 35948e7..32bf63b 100644 --- a/internal/jean/cli_app.go +++ b/internal/jean/cli_app.go @@ -39,27 +39,24 @@ func cmdApp(args []string) error { return openBrowser(url) } - // Sert l'UI en tâche de fond ; l'icône tray tient le premier plan. - go func() { _ = http.Serve(ln, newWebMux()) }() + // UN SEUL process propriétaire de la conversation, comme le service jean-link + // sous Linux : l'app sert l'UI locale ET le tunnel avec le MÊME mux. Deux + // process qui servent la conversation (objet en mémoire persisté dans + // conversation.json) donnent deux fils divergents entre le local et + // app.ajean.link — le bug qu'on a vécu avec le worker détaché. + appOwnsLink = true + appWebMux = newWebMux() + go func() { _ = http.Serve(ln, appWebMux) }() - // Accès distant déjà configuré ? On relance le tunnel. Sous Linux systemd s'en - // charge au boot, mais sur un poste de bureau (macOS/Windows) personne ne le - // fait : sans ça le token reste enregistré et le panneau affiche « service - // arrêté » à chaque démarrage de l'app. go func() { if readLinkToken() == "" { return } - // Worker rescapé d'une AUTRE copie de l'app (ancienne version, copie - // translocatée…) : il tourne toujours — le tunnel est détaché exprès — et - // continue de servir l'UI distante avec du code périmé. On le remplace. - if linkWorkerIsStale() { - _ = linkServiceCtl("restart") - return - } - if !linkServiceActive() { - _ = linkServiceCtl("start") - } + // Worker détaché rescapé (version antérieure, autre copie de l'app) : il + // possède SA propre conversation et écrase la nôtre. On le supprime avant + // de prendre la main. + killForeignLinkWorker() + startAppLink(appWebMux) }() sp := showSplash("Lancement de Jean en cours…") diff --git a/internal/jean/relay_link.go b/internal/jean/relay_link.go index b141983..183750e 100644 --- a/internal/jean/relay_link.go +++ b/internal/jean/relay_link.go @@ -31,6 +31,7 @@ import ( "runtime" "strconv" "strings" + "sync" "time" "github.com/coder/websocket" @@ -260,6 +261,103 @@ func runLinkForeground() error { } } +// --------------------------------------------------------------------------- +// Tunnel DANS le process de l'app (macOS/Windows). +// +// Le modèle Linux est : UN SEUL process possède la conversation et sert les deux +// surfaces (UI locale :8090 + tunnel). C'est ce qui rend le fil identique en +// local et sur app.ajean.link — la conversation est un objet EN MÉMOIRE (voir +// chat_conversation.go), simplement persisté sur disque ; deux process qui la +// servent = deux fils divergents qui s'écrasent dans conversation.json. +// +// Sur un poste de bureau, l'app EST ce process propriétaire : elle sert déjà +// :8090, donc elle fait aussi tourner le tunnel elle-même au lieu de déléguer à +// un worker détaché. L'accès distant vit donc aussi longtemps que Jean.app est +// ouverte — sur un portable qui s'endort, c'est de toute façon la réalité. + +var appLink struct { + mu sync.Mutex + running bool + stop chan struct{} +} + +// appOwnsLink : vrai dans le process de l'app de bureau, qui pilote le tunnel +// en interne. linkServiceCtl s'y adapte pour ne PAS lancer de worker concurrent. +// appWebMux est le mux servi par l'app — réutilisé pour le tunnel afin que les +// deux surfaces partagent la même conversation. +var ( + appOwnsLink bool + appWebMux *http.ServeMux +) + +// startAppLink démarre la boucle de lien dans ce process, en servant le mux de +// l'app (donc la MÊME conversation que l'UI locale). Idempotent. +func startAppLink(mux *http.ServeMux) { + appLink.mu.Lock() + defer appLink.mu.Unlock() + if appLink.running { + return + } + token := readLinkToken() + if token == "" { + return + } + stop := make(chan struct{}) + appLink.running, appLink.stop = true, stop + handler := newLinkHandler(mux) + oaiTLS := oaiTLSConfig() + go func() { + backoff := time.Second + for { + select { + case <-stop: + return + default: + } + _ = runLinkSession(token, handler, oaiTLS) + select { + case <-stop: + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + if backoff > 30*time.Second { + backoff = 30 * time.Second + } + } + } + }() +} + +// stopAppLink arrête la boucle interne (la session en cours se termine d'elle-même). +func stopAppLink() { + appLink.mu.Lock() + defer appLink.mu.Unlock() + if !appLink.running { + return + } + close(appLink.stop) + appLink.running = false +} + +func appLinkRunning() bool { + appLink.mu.Lock() + defer appLink.mu.Unlock() + return appLink.running +} + +// killForeignLinkWorker tue un worker de lien détaché encore en vie (laissé par +// une version antérieure ou une autre copie de l'app). Indispensable AVANT que +// l'app ne prenne la main : sinon deux process servent la même conversation et +// les fils divergent entre l'UI locale et app.ajean.link. +func killForeignLinkWorker() { + if pid := linkUserPID(); pid > 0 { + killTree(pid) + _ = os.Remove(linkPIDPath()) + } +} + // serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME // que celui exposé dans le tunnel. Le process jean-link devient ainsi l'unique // propriétaire de la conversation, partagée entre l'accès local et l'accès @@ -289,6 +387,24 @@ func linkLogPath() string { return filepath.Join(JeanHome(), linkServiceName+".l // composait jamais le tunnel. func linkServiceCtl(action string) error { if runtime.GOOS != "linux" { + // Dans l'app de bureau, le tunnel tourne DANS ce process (conversation + // unique) : on ne lance surtout pas un worker séparé. + if appOwnsLink { + switch action { + case "stop": + stopAppLink() + case "start", "restart": + stopAppLink() + killForeignLinkWorker() + startAppLink(appWebMux) + if !appLinkRunning() { + return fmt.Errorf("tunnel non démarré (clé de liaison absente ?)") + } + default: + return fmt.Errorf("action inconnue: %s", action) + } + return nil + } return linkUserSvcCtl(action) } bin, pre := "systemctl", []string{} @@ -313,47 +429,15 @@ func linkServiceCtl(action string) error { // Linux, processus suivi par fichier PID ailleurs). func linkServiceActive() bool { if runtime.GOOS != "linux" { + if appOwnsLink { + return appLinkRunning() + } return linkUserPID() > 0 } out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output() return strings.TrimSpace(string(out)) == "active" } -// linkUserExe renvoie le binaire qui a lancé le worker de lien en cours (2e ligne -// du fichier PID), "" si inconnu. Sert à repérer un worker rescapé d'une ANCIENNE -// copie de l'app : le tunnel étant détaché exprès pour survivre à la fermeture de -// Jean.app, un worker lancé depuis une copie translocatée (ou simplement une -// version précédente) continue de servir l'UI distante avec du code périmé — -// symptôme vécu : l'alerte App Translocation visible sur app.ajean.link alors que -// l'app locale, elle, tourne bien depuis /Applications. -func linkUserExe() string { - b, err := os.ReadFile(linkPIDPath()) - if err != nil { - return "" - } - lines := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2) - if len(lines) < 2 { - return "" - } - return strings.TrimSpace(lines[1]) -} - -// linkWorkerIsStale : un worker tourne, mais depuis un autre binaire que nous. -func linkWorkerIsStale() bool { - if runtime.GOOS == "linux" || linkUserPID() == 0 { - return false - } - exe, err := os.Executable() - if err != nil { - return false - } - if p, err := filepath.EvalSymlinks(exe); err == nil { - exe = p - } - running := linkUserExe() - return running != "" && running != exe -} - // linkUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon // (fichier absent, illisible, ou process mort → on nettoie le fichier obsolète). func linkUserPID() int { @@ -400,7 +484,7 @@ func linkUserSvcCtl(action string) error { return err } if p, err := filepath.EvalSymlinks(self); err == nil { - self = p // même forme que la comparaison de linkWorkerIsStale + self = p } if err := os.MkdirAll(JeanHome(), 0o755); err != nil { return err @@ -417,8 +501,8 @@ func linkUserSvcCtl(action string) error { return fmt.Errorf("démarrage de « jean link serve »: %w", err) } pid := cmd.Process.Pid - // PID + binaire d'origine : la 2e ligne permet de détecter plus tard un worker - // rescapé d'une ancienne copie de l'app (voir linkWorkerIsStale). + // PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce + // worker — précieux pour diagnostiquer un process rescapé d'une autre version. if err := os.WriteFile(linkPIDPath(), []byte(strconv.Itoa(pid)+"\n"+self+"\n"), 0o644); err != nil { return fmt.Errorf("écriture du PID: %w", err) }