Files
Loki/internal/ajean/sys_service_windows.go
T
nathaninline 993be881d5 Renommage AJEAN (3/3) : textes, aide, README et variables d'environnement
Surface visible passee en AJEAN : aide de la CLI, messages, invite du chat
terminal, commentaires, README, metadonnees Windows du binaire
(InternalName / OriginalFilename).

Les reglages s'appellent desormais AJEAN_* (AJEAN_HOME, AJEAN_MODEL_DIRS,
AJEAN_DL_CONNS, AJEAN_LINK_URL, AJEAN_ACME_EMAIL, AJEAN_SERVICE), avec
l'ancien nom JEAN_* toujours lu en second via envAny(). Ces variables sont
posees a la main dans des shells, des units, des docker-compose et des
scripts de deploiement qu'on ne peut pas retrouver : cesser de les lire
ferait retomber silencieusement un reglage sur sa valeur par defaut.

Migration one-shot du dossier de donnees retentee dans la fenetre ou
l'installateur Windows a arrete toutes les instances, seule fenetre ou le
rename peut aboutir. Le chemin du binaire est recalcule juste apres, sinon
on reinstallerait dans le dossier qu'on vient de deplacer.

Deux choses volontairement NON renommees, parce que les renommer casserait
des utilisateurs sans rien apporter :

- "model": "jean", le nom expose sur l'endpoint compatible OpenAI. Des
  integrations externes le configurent en dur.
- les cles localStorage (jean-theme, jean.sys...). Les renommer
  reinitialiserait theme, preferences d'affichage et prompt systeme dans le
  navigateur de chaque utilisateur.

Le plist launchd recevait /usr/local/bin/jean en dur : le chemin est
desormais injecte, sinon le daemon macOS aurait refuse de demarrer apres le
renommage.

index.html regenere depuis ui/src via go generate.
2026-08-04 14:16:12 +02:00

229 lines
6.6 KiB
Go

//go:build windows
package ajean
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
)
// On Windows there is no systemd, so "the service" is a background copy of
// `ajean serve` that this process launches detached. We track it with a PID file
// and stream its output to a log file under JEAN_HOME. This needs no admin
// rights and no external tools beyond the always-present tasklist/taskkill.
const (
createNewProcessGroup = 0x00000200 // CREATE_NEW_PROCESS_GROUP
detachedProcess = 0x00000008 // DETACHED_PROCESS
createNoWindow = 0x08000000 // CREATE_NO_WINDOW (aucune console pour l'enfant)
)
func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
func serviceAction(action string) error {
switch action {
case "start":
return svcStart()
case "stop":
return svcStop(true)
case "restart":
_ = svcStop(false)
time.Sleep(500 * time.Millisecond)
return svcStart()
case "status":
return svcStatus()
case "enable", "disable":
fmt.Printf("%s '%s' n'est pas géré sur Windows (pas de service système).\n", yellow("[info]"), action)
fmt.Printf(" Pour un démarrage au boot, crée une tâche planifiée ou un service via %s.\n", bold("sc.exe"))
return nil
default:
return fmt.Errorf("action inconnue: %s", action)
}
}
func svcStart() error {
if pid := readServicePID(); pid > 0 && processAlive(pid) {
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
return nil
}
self, err := os.Executable()
if err != nil {
return err
}
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("ouverture du log %s: %w", logFilePath(), err)
}
defer logf.Close()
cmd := exec.Command(self, "serve")
cmd.Stdout = logf
cmd.Stderr = logf
cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME
// createNoWindow + HideWindow : le service enfant (`ajean serve`) ne doit JAMAIS
// faire clignoter de console noire quand Jean est lancé en mode app (double-clic).
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de 'ajean serve': %w", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
return fmt.Errorf("écriture du PID: %w", err)
}
// Don't wait — let it run detached.
_ = cmd.Process.Release()
return checkStarted(pid)
}
func checkStarted(pid int) error {
time.Sleep(2 * time.Second)
if processAlive(pid) {
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath()))
return nil
}
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
fmt.Println("------------------------------------------------")
fmt.Print(tailFile(logFilePath(), 20))
fmt.Println("------------------------------------------------")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
_ = os.Remove(pidFilePath())
return fmt.Errorf("service %s non démarré", serviceName())
}
func svcStop(verbose bool) error {
pid := readServicePID()
if pid <= 0 || !processAlive(pid) {
_ = os.Remove(pidFilePath())
if verbose {
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
}
return nil
}
// taskkill /T tue aussi le processus enfant llama-server.
cmd := hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F"))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("arrêt du PID %d: %w\n%s", pid, err, string(out))
}
_ = os.Remove(pidFilePath())
if verbose {
fmt.Println(green("[ok]") + " arrêté")
}
return nil
}
func svcStatus() error {
pid := readServicePID()
if pid > 0 && processAlive(pid) {
fmt.Printf("%s %s: actif (PID %d)\n", green("[ok]"), serviceName(), pid)
} else {
fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName())
}
fmt.Printf(" logs : %s\n", logFilePath())
fmt.Printf(" config : %s\n", confPath())
return nil
}
func serviceLogs() error {
path := logFilePath()
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("aucun log à %s (le service a-t-il déjà démarré ?): %w", path, err)
}
defer f.Close()
// Print the tail, then follow appended bytes (poor man's tail -f).
fmt.Print(tailFile(path, 80))
if _, err := f.Seek(0, io.SeekEnd); err != nil {
return err
}
buf := make([]byte, 4096)
for {
n, err := f.Read(buf)
if n > 0 {
os.Stdout.Write(buf[:n])
}
if err == io.EOF {
time.Sleep(500 * time.Millisecond)
continue
}
if err != nil {
return err
}
}
}
// serviceIsActive reports whether the background server is running.
func serviceIsActive() bool {
pid := readServicePID()
return pid > 0 && processAlive(pid)
}
func readServicePID() int {
b, err := os.ReadFile(pidFilePath())
if err != nil {
return 0
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
return pid
}
// processAlive reports whether a PID is currently running. On utilise l'API
// Win32 (OpenProcess + GetExitCodeProcess) plutôt que `tasklist` : l'ancien
// appel externe faisait CLIGNOTER une fenêtre de console à CHAQUE vérification,
// or l'UI web poll le statut en boucle → rafale de fenêtres noires. L'API ne
// lance aucun process.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
const (
queryLimitedInfo = 0x1000
stillActive = 259
)
k := syscall.NewLazyDLL("kernel32.dll")
h, _, _ := k.NewProc("OpenProcess").Call(queryLimitedInfo, 0, uintptr(pid))
if h == 0 {
return false
}
defer k.NewProc("CloseHandle").Call(h)
var code uint32
r, _, _ := k.NewProc("GetExitCodeProcess").Call(h, uintptr(unsafe.Pointer(&code)))
if r == 0 {
return false
}
return code == stillActive
}
// tailFile returns the last n lines of the file at path (best-effort).
func tailFile(path string, n int) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n") + "\n"
}
// serviceLogTail renvoie les n dernières lignes du journal du service (pour
// l'UI web).
func serviceLogTail(n int) string { return tailFile(logFilePath(), n) }