Windows : sous-systeme console, pour qu'ajean se comporte en vraie commande

Dans un terminal, cmd rendait la main avant qu'ajean n'ait rien ecrit : l'invite
se reaffichait puis la sortie arrivait par-dessus. Windows n'attend jamais la fin
d'un programme en sous-systeme graphique. La meme cause empechait les
redirections et les tubes, et rendait 'ajean chat' inutilisable (le shell et
ajean se disputaient l'entree).

Le binaire passe donc en sous-systeme console. La fenetre noire au double-clic,
que le sous-systeme graphique evitait, est traitee autrement : quand aucun autre
processus ne partage notre console, c'est que Windows l'a creee pour nous, donc
que personne ne nous a lances depuis un terminal. On la masque et on la libere.
Les raccourcis demandent en plus un demarrage minimise.

La relance interne (apres mise a jour) passe desormais 'app' explicitement : la
detection ne repond qu'a la question du double-clic, et un processus sans
console du tout ne doit pas avoir a y repondre.

Verifie : ordre d'affichage correct en session cmd, redirection et tube
fonctionnels, 'ajean' sans argument affiche l'aide, et aucune fenetre console
visible au lancement facon double-clic.
This commit is contained in:
nathaninline committed 2026-08-07 21:59:42 +02:00
1 parent 922b7a6862
commit aa965227fc
4 files changed
+68 -85

No files matched your search

+4 -3
View File
@@ -107,9 +107,10 @@ jobs:
windows/amd64:ajean-windows.exe windows/arm64:ajean-windows-arm.exe; do windows/amd64:ajean-windows.exe windows/arm64:ajean-windows-arm.exe; do
target=${t%%:*}; name=${t#*:} target=${t%%:*}; name=${t#*:}
os=${target%/*}; arch=${target#*/}; ldflags="-s -w" os=${target%/*}; arch=${target#*/}; ldflags="-s -w"
# Windows : sous-système GUI → aucune console noire au double-clic # Windows : sous-système CONSOLE, pour que `cmd` attende la fin du
# (l'app se rattache à la console du terminal parent si lancée en CLI). # programme et que redirections, tubes et commandes interactives
[ "$os" = windows ] && ldflags="$ldflags -H=windowsgui" # fonctionnent. La console créée au double-clic est refermée par le
# programme lui-même (voir setupConsole, sys_console_windows.go).
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ GOOS=$os GOARCH=$arch CGO_ENABLED=0 \
go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/ajean go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/ajean
done done
+4 -2
View File
@@ -42,9 +42,11 @@ Séparer les deux services garde son intérêt : redémarrer l'interface est ins
**« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système. **« Quitter » arrête vraiment tout.** Le moteur tourne dans un processus détaché, qui survit volontairement à la fermeture de l'interface pour garder le modèle chargé entre deux ouvertures. Mais après un « Quitter » depuis la zone de notification, plus rien ne le pilotait et il conservait des dizaines de gigaoctets de mémoire, sans la moindre fenêtre pour l'expliquer. Quitter décharge désormais le modèle. Ailleurs, rien ne change : sous Linux et macOS le moteur appartient à systemd ou launchd, et fermer une interface n'a pas à arrêter un service système.
**La redirection et les tubes fonctionnent.** `ajean where > fichier.txt` n'écrivait rien dans le fichier, et `ajean help | findstr quelquechose` ne transmettait rien : tout partait sur la console. Le programme réouvrait sa sortie sur la console pour avoir où écrire, et écrasait ce faisant ce que le shell lui avait branché. **AJEAN se comporte enfin comme une vraie commande.** Dans un terminal, `ajean` rendait la main avant d'avoir écrit quoi que ce soit : l'invite se réaffichait, et la sortie du programme arrivait par-dessus, comme si la commande avait été tapée deux fois.
À savoir : lancé depuis `cmd`, AJEAN rend la main immédiatement et son affichage arrive après la nouvelle invite. C'est le prix du sous-système graphique, celui-là même qui évite la fenêtre noire au double-clic. La même cause empêchait `ajean where > fichier.txt` d'écrire dans le fichier, `ajean help | findstr ...` de transmettre quoi que ce soit, et rendait `ajean chat` inutilisable depuis un terminal, le shell et AJEAN se disputant le clavier. Le binaire était compilé en sous-système graphique, et Windows n'attend jamais la fin d'un programme de ce type.
Il est désormais en sous-système console. La contrepartie, une fenêtre noire au double-clic, est traitée par le programme lui-même : constatant qu'aucun terminal ne l'a lancé, il referme aussitôt la console que Windows lui a allouée. Les raccourcis posés à l'installation demandent en plus un démarrage minimisé, si bien qu'elle n'est jamais peinte à l'écran.
**Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission. **Plus de question au premier lancement.** Le double-clic demandait s'il fallait installer AJEAN ou seulement le lancer. La question n'avait qu'une réponse utile : rester à l'emplacement du fichier téléchargé ne donne pas une installation exploitable, sans raccourci, sans rien dans le PATH, et avec une application qui disparaît le jour où l'on vide ses téléchargements. L'installation se fait donc directement, et le message qui suit dit ce qui a été fait au lieu de demander une permission.
+45 -74
View File
@@ -4,96 +4,67 @@ package ajean
// sys_console_windows.go — gestion de la console sous Windows. // sys_console_windows.go — gestion de la console sous Windows.
// //
// Le binaire est compilé en sous-système GUI (`-H=windowsgui`) : au double-clic, // Le binaire est compilé en sous-système CONSOLE. C'est ce qui fait que `cmd`
// Windows n'alloue AUCUNE console → plus jamais de « fenêtre noire de terminal » // ATTEND la fin du programme : avec un binaire graphique, il rendait la main
// qui s'ouvre et se ferme. En contrepartie, un binaire GUI lancé depuis un // aussitôt et réaffichait son invite AVANT la sortie d'AJEAN, ce qui donnait
// terminal (cmd/PowerShell) n'écrit nulle part par défaut : on se rattache alors //
// explicitement à la console du parent et on réouvre stdout/stderr/stdin dessus, // C:\Users\Nathan>ajean
// pour que l'usage CLI (`ajean web`, `ajean status`, …) reste lisible. //
// C:\Users\Nathan>ajean 0.8.0 — manager llama.cpp + UI web
//
// La même cause cassait la redirection et les tubes, et rendait `ajean chat`
// inutilisable depuis un terminal (le shell et AJEAN se disputaient l'entrée).
//
// EN CONTREPARTIE, Windows alloue une console à tout lancement sans terminal —
// un double-clic. On la referme alors immédiatement : voir setupConsole. Les
// raccourcis créés par l'installation demandent en plus un démarrage minimisé,
// si bien que cette console n'est jamais peinte à l'écran.
import ( import (
"os"
"syscall" "syscall"
"unsafe" "unsafe"
) )
// setupConsole rattache le process à la console de son parent si elle existe. // setupConsole décide dans quel monde on tourne et renvoie true en usage CLI.
// Retourne true si on dispose d'une console (→ usage CLI), false sinon (double- //
// clic sans console → expérience « application »). // Le signal est le nombre de processus rattachés à notre console : si nous
// sommes le SEUL, c'est que Windows l'a créée pour nous, donc qu'aucun terminal
// ne nous a lancés. On la fait disparaître et on bascule en mode application.
// Un shell rattaché (cmd, PowerShell, Windows Terminal) en fait au moins deux.
func setupConsole() bool { func setupConsole() bool {
// Relevé AVANT le rattachement, et c'est tout l'enjeu : AttachConsole
// REMPLACE lui-même les handles standard du processus par ceux de la
// console. Interrogé après, on ne voit donc plus jamais qu'une console, et
// la trace de la redirection posée par le shell est déjà perdue.
outRedirected := stdRedirected(stdOutputHandle)
errRedirected := stdRedirected(stdErrorHandle)
inRedirected := stdRedirected(stdInputHandle)
const attachParentProcess = ^uintptr(0) // (DWORD)-1 = ATTACH_PARENT_PROCESS
k := syscall.NewLazyDLL("kernel32.dll") k := syscall.NewLazyDLL("kernel32.dll")
r, _, _ := k.NewProc("AttachConsole").Call(attachParentProcess)
if r == 0 { // Combien de processus partagent notre console ? Nous seul (1) = personne ne
return false // aucune console parente = lancé par double-clic (mode app) // nous a lancés depuis un terminal, Windows a créé cette console pour nous :
} // c'est un double-clic. Un shell rattaché en fait au moins deux.
// Réouvre les flux standard sur la console fraîchement rattachée : sans ça,
// os.Stdout/Stderr pointent sur des handles invalides (sous-système GUI).
// //
// MAIS seulement ceux que le shell n'a pas déjà branchés ailleurs. Réouvrir // Le compte vaut 0 quand il n'y a aucune console (parent détaché). On répond
// systématiquement sur CONOUT$ écrasait la redirection : « ajean where > // alors « CLI », le cas contraire étant couvert autrement : les relances
// fichier.txt » n'écrivait rien dans le fichier, et « ajean where | findstr » // internes passent « app » explicitement (voir launch, sys_firstrun_windows.go).
// ne transmettait rien au tube — tout partait sur la console. On ne touche // Mieux vaut afficher une aide inutile que démarrer un serveur en silence.
// donc qu'aux flux réellement inutilisables. var pids [8]uint32
if !outRedirected { n, _, _ := k.NewProc("GetConsoleProcessList").Call(
if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
os.Stdout = con if n == 1 {
} if hwnd, _, _ := k.NewProc("GetConsoleWindow").Call(); hwnd != 0 {
} // Masquer AVANT de libérer : sinon la fenêtre reste peinte le temps
if !errRedirected { // que Windows la détruise.
if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil { const swHide = 0
os.Stderr = con _, _, _ = k.NewProc("ShowWindow").Call(hwnd, swHide)
}
}
if !inRedirected {
if cin, err := os.OpenFile("CONIN$", os.O_RDONLY, 0); err == nil {
os.Stdin = cin
} }
_, _, _ = k.NewProc("FreeConsole").Call()
return false
} }
// Lancé depuis un terminal : les flux standard sont déjà ceux du shell,
// redirections et tubes compris. Il n'y a rien à rebrancher, seulement à
// régler l'encodage et les couleurs.
configureConsole() configureConsole()
return true return true
} }
// Handles standard, tels que les nomme GetStdHandle.
const (
stdInputHandle = ^uintptr(9) // -10
stdOutputHandle = ^uintptr(10) // -11
stdErrorHandle = ^uintptr(11) // -12
)
// stdRedirected dit si un flux standard est DÉJÀ branché sur un fichier ou un
// tube par le processus parent. Dans ce cas il ne faut surtout pas le remplacer
// par la console : c'est ce qui cassait « > fichier » et « | commande ».
//
// Un flux de type « caractère » (la console elle-même) ou inutilisable n'est pas
// considéré comme redirigé : on le réouvre alors sur CONOUT$/CONIN$, ce dont un
// binaire GUI a besoin pour écrire quelque part.
func stdRedirected(which uintptr) bool {
const (
invalidHandle = ^uintptr(0) // INVALID_HANDLE_VALUE
fileTypeDisk = 1
fileTypePipe = 3
)
k := syscall.NewLazyDLL("kernel32.dll")
h, _, _ := k.NewProc("GetStdHandle").Call(which)
if h == 0 || h == invalidHandle {
return false
}
t, _, _ := k.NewProc("GetFileType").Call(h)
return t == fileTypeDisk || t == fileTypePipe
}
// configureConsole passe la console en UTF-8 + traitement des séquences ANSI // configureConsole passe la console en UTF-8 + traitement des séquences ANSI
// (couleurs/curseur), comme le faisait l'init() au chargement — mais APRÈS le // (couleurs/curseur).
// rattachement, sinon ça ciblait une console encore inexistante.
func configureConsole() { func configureConsole() {
const ( const (
cpUTF8 = 65001 cpUTF8 = 65001
+15 -6
View File
@@ -29,11 +29,11 @@ import (
// //
// Maintenant, deux cas seulement : // Maintenant, deux cas seulement :
// //
// 1. AJEAN n'est pas installé. On DEMANDE : c'est le seul moment où // 1. AJEAN n'est pas installé. On installe, sans demander : rester à
// l'utilisateur gagne à savoir ce qu'on pose sur sa machine et où. Puis // l'emplacement du fichier téléchargé ne donne pas une installation
// copie + PATH + raccourcis, et on démarre depuis la copie installée. Un // utilisable, la question n'avait donc qu'une réponse. Copie + PATH +
// refus vaut « pas maintenant » et la question revient au prochain lancement // raccourcis, un message qui dit ce qui a été fait, et on démarre depuis la
// du fichier : mémorisé, il ne laissait AUCUN moyen d'installer AJEAN. // copie installée.
// //
// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de // 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de
// mise à jour, avec trois situations bien distinctes (voir runAsInstaller) : // mise à jour, avec trois situations bien distinctes (voir runAsInstaller) :
@@ -131,7 +131,12 @@ func appFirstRun() bool {
// Renvoie false si le lancement échoue, auquel cas l'exécutable courant prend le // 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. // relais : mieux vaut démarrer depuis le mauvais dossier que pas du tout.
func launch(target string) bool { func launch(target string) bool {
cmd := exec.Command(target) // « app » est passé EXPLICITEMENT plutôt que de compter sur la détection du
// mode application. Celle-ci répond à une seule question — « ai-je été
// double-cliqué ? » — en regardant qui partage notre console. Un processus
// lancé sans console du tout (relance après mise à jour) ne peut pas y
// répondre, et se serait retrouvé à afficher l'aide au lieu de démarrer.
cmd := exec.Command(target, "app")
cmd.Dir = filepath.Dir(target) cmd.Dir = filepath.Dir(target)
return cmd.Start() == nil return cmd.Start() == nil
} }
@@ -404,6 +409,10 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) {
$s.TargetPath=$t $s.TargetPath=$t
$s.WorkingDirectory=(Split-Path $t) $s.WorkingDirectory=(Split-Path $t)
$s.Description='AJEAN, votre IA locale' $s.Description='AJEAN, votre IA locale'
# Minimisé (7) : le binaire étant en sous-système console, Windows lui alloue
# une console au lancement. AJEAN la referme aussitôt, mais demander un
# démarrage minimisé garantit qu'elle n'est jamais peinte à l'écran.
$s.WindowStyle=7
$s.Save() $s.Save()
$done+=$d $done+=$d
} catch {} } catch {}