diff --git a/internal/jean/cli_app.go b/internal/jean/cli_app.go index e679799..cedd36b 100644 --- a/internal/jean/cli_app.go +++ b/internal/jean/cli_app.go @@ -42,6 +42,16 @@ func cmdApp(args []string) error { // Sert l'UI en tâche de fond ; l'icône tray tient le premier plan. go func() { _ = http.Serve(ln, newWebMux()) }() + // 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() != "" && !linkServiceActive() { + _ = linkServiceCtl("start") + } + }() + sp := showSplash("Lancement de Jean en cours…") waitServerReady(url) _ = openBrowser(url) diff --git a/internal/jean/relay_link.go b/internal/jean/relay_link.go index cf37528..f3dbfbc 100644 --- a/internal/jean/relay_link.go +++ b/internal/jean/relay_link.go @@ -29,6 +29,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "time" @@ -274,11 +275,21 @@ func serveLocalWebMux(mux *http.ServeMux) { _ = http.Serve(ln, mux) } -// linkServiceCtl pilote l'unité systemd jean-link (start/stop/restart), avec sudo -// non interactif si on n'est pas root. Linux uniquement (le relais cible est Linux). +// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, +// Windows), où Jean est une app de bureau lancée sans droits root. +func linkPIDPath() string { return filepath.Join(JeanHome(), linkServiceName+".pid") } +func linkLogPath() string { return filepath.Join(JeanHome(), linkServiceName+".log") } + +// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est +// l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ; +// ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance +// « jean link serve » en processus détaché suivi par un fichier PID, comme le +// fait déjà le service principal. Sans ça, l'accès distant restait définitivement +// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne +// composait jamais le tunnel. func linkServiceCtl(action string) error { if runtime.GOOS != "linux" { - return fmt.Errorf("gestion du service %s : Linux/systemd uniquement (lance « jean link --foreground » directement)", linkServiceName) + return linkUserSvcCtl(action) } bin, pre := "systemctl", []string{} if os.Geteuid() != 0 { @@ -298,15 +309,90 @@ func linkServiceCtl(action string) error { return nil } -// linkServiceActive indique si l'unité systemd jean-link tourne. +// linkServiceActive indique si le worker de lien tourne (unité systemd sous +// Linux, processus suivi par fichier PID ailleurs). func linkServiceActive() bool { if runtime.GOOS != "linux" { - return false + return linkUserPID() > 0 } out, _ := exec.Command("systemctl", "is-active", linkServiceName).Output() return strings.TrimSpace(string(out)) == "active" } +// 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 { + b, err := os.ReadFile(linkPIDPath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + if pid <= 0 || !pidAlive(pid) { + return 0 + } + return pid +} + +// linkUserSvcCtl : équivalent de systemctl pour les OS sans systemd. +func linkUserSvcCtl(action string) error { + switch action { + case "stop", "restart": + if pid := linkUserPID(); pid > 0 { + killTree(pid) + for i := 0; i < 30 && pidAlive(pid); i++ { + time.Sleep(100 * time.Millisecond) + } + } + _ = os.Remove(linkPIDPath()) + if action == "stop" { + fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName) + return nil + } + case "start": + if linkUserPID() > 0 { + fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName) + return nil + } + default: + return fmt.Errorf("action inconnue: %s", action) + } + + self, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + return err + } + logf, err := os.OpenFile(linkLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("ouverture du log %s: %w", linkLogPath(), err) + } + defer logf.Close() + + cmd := spawnDetached(self, "link", "serve") + cmd.Stdout, cmd.Stderr = logf, logf + if err := cmd.Start(); err != nil { + return fmt.Errorf("démarrage de « jean link serve »: %w", err) + } + pid := cmd.Process.Pid + if err := os.WriteFile(linkPIDPath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { + return fmt.Errorf("écriture du PID: %w", err) + } + _ = cmd.Process.Release() + + // Le worker peut mourir aussitôt (token refusé, relais injoignable) : on laisse + // passer un instant avant de déclarer la victoire, sinon l'UI afficherait + // « en ligne » sur un process déjà mort. + time.Sleep(1500 * time.Millisecond) + if !pidAlive(pid) { + _ = os.Remove(linkPIDPath()) + return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) + } + fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName, pid) + return nil +} + // newLinkHandler construit le handler servi à travers le tunnel : // - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint // compatible OpenAI, avec injection de la clé API locale) → permet de diff --git a/internal/jean/sys_platform_unix.go b/internal/jean/sys_platform_unix.go index 922717c..7645c62 100644 --- a/internal/jean/sys_platform_unix.go +++ b/internal/jean/sys_platform_unix.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "syscall" + "time" ) // defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean @@ -295,3 +296,39 @@ func ramUsageMB() (used, total int) { } return (totalKB - availKB) / 1024, totalKB / 1024 } + +// --- Supervision de processus détachés (worker de lien, service en mode +// utilisateur). Pendant Unix de sys_platform_windows.go. + +// spawnDetached prépare une commande qui survivra à la mort de Jean : Setsid la +// place dans une nouvelle session, donc elle n'est pas tuée avec notre groupe. +func spawnDetached(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return cmd +} + +// pidAlive : le signal 0 ne tue rien, il teste juste l'existence du process. +func pidAlive(pid int) bool { + if pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +// killTree arrête le process ET ses enfants. Setsid ayant fait de lui un chef de +// groupe, un PID négatif vise le groupe entier ; SIGKILL en dernier recours. +func killTree(pid int) { + if pid <= 0 { + return + } + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + for i := 0; i < 30 && pidAlive(pid); i++ { + time.Sleep(100 * time.Millisecond) + } + if pidAlive(pid) { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } +} diff --git a/internal/jean/sys_platform_windows.go b/internal/jean/sys_platform_windows.go index 56d0912..2e6bbb3 100644 --- a/internal/jean/sys_platform_windows.go +++ b/internal/jean/sys_platform_windows.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" "unsafe" @@ -390,3 +391,28 @@ func ramUsageMB() (used, total int) { const mb = 1024 * 1024 return int((m.ullTotalPhys - m.ullAvailPhys) / mb), int(m.ullTotalPhys / mb) } + +// --- Supervision de processus détachés (worker de lien, service). Pendant +// Windows de sys_platform_unix.go. + +// spawnDetached prépare une commande détachée et SANS console : en mode app +// (double-clic) le moindre enfant console ferait clignoter une fenêtre noire. +func spawnDetached(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow, + } + return cmd +} + +// pidAlive : alias de processAlive (API commune avec Unix). +func pidAlive(pid int) bool { return processAlive(pid) } + +// killTree arrête le process et toute sa descendance (taskkill /T). +func killTree(pid int) { + if pid <= 0 { + return + } + _ = hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")).Run() +} diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 845fa9d..c0863a5 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -516,6 +516,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
+