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

+45 -74
View File
@@ -4,96 +4,67 @@ package ajean
// sys_console_windows.go — gestion de la console sous Windows.
//
// Le binaire est compilé en sous-système GUI (`-H=windowsgui`) : au double-clic,
// Windows n'alloue AUCUNE console → plus jamais de « fenêtre noire de terminal »
// qui s'ouvre et se ferme. En contrepartie, un binaire GUI lancé depuis un
// 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,
// pour que l'usage CLI (`ajean web`, `ajean status`, …) reste lisible.
// Le binaire est compilé en sous-système CONSOLE. C'est ce qui fait que `cmd`
// ATTEND la fin du programme : avec un binaire graphique, il rendait la main
// aussitôt et réaffichait son invite AVANT la sortie d'AJEAN, ce qui donnait
//
// C:\Users\Nathan>ajean
//
// 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 (
"os"
"syscall"
"unsafe"
)
// setupConsole rattache le process à la console de son parent si elle existe.
// Retourne true si on dispose d'une console (→ usage CLI), false sinon (double-
// clic sans console → expérience « application »).
// setupConsole décide dans quel monde on tourne et renvoie true en usage CLI.
//
// 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 {
// 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")
r, _, _ := k.NewProc("AttachConsole").Call(attachParentProcess)
if r == 0 {
return false // aucune console parente = lancé par double-clic (mode app)
}
// 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).
// Combien de processus partagent notre console ? Nous seul (1) = personne ne
// 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.
//
// MAIS seulement ceux que le shell n'a pas déjà branchés ailleurs. Réouvrir
// systématiquement sur CONOUT$ écrasait la redirection : « ajean where >
// fichier.txt » n'écrivait rien dans le fichier, et « ajean where | findstr »
// ne transmettait rien au tube — tout partait sur la console. On ne touche
// donc qu'aux flux réellement inutilisables.
if !outRedirected {
if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil {
os.Stdout = con
}
}
if !errRedirected {
if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil {
os.Stderr = con
}
}
if !inRedirected {
if cin, err := os.OpenFile("CONIN$", os.O_RDONLY, 0); err == nil {
os.Stdin = cin
// Le compte vaut 0 quand il n'y a aucune console (parent détaché). On répond
// alors « CLI », le cas contraire étant couvert autrement : les relances
// internes passent « app » explicitement (voir launch, sys_firstrun_windows.go).
// Mieux vaut afficher une aide inutile que démarrer un serveur en silence.
var pids [8]uint32
n, _, _ := k.NewProc("GetConsoleProcessList").Call(
uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
if n == 1 {
if hwnd, _, _ := k.NewProc("GetConsoleWindow").Call(); hwnd != 0 {
// Masquer AVANT de libérer : sinon la fenêtre reste peinte le temps
// que Windows la détruise.
const swHide = 0
_, _, _ = k.NewProc("ShowWindow").Call(hwnd, swHide)
}
_, _, _ = 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()
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
// (couleurs/curseur), comme le faisait l'init() au chargement — mais APRÈS le
// rattachement, sinon ça ciblait une console encore inexistante.
// (couleurs/curseur).
func configureConsole() {
const (
cpUTF8 = 65001
+15 -6
View File
@@ -29,11 +29,11 @@ import (
//
// Maintenant, deux cas seulement :
//
// 1. AJEAN n'est pas installé. On DEMANDE : 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 vaut « pas maintenant » et la question revient au prochain lancement
// du fichier : mémorisé, il ne laissait AUCUN moyen d'installer AJEAN.
// 1. AJEAN n'est pas installé. On installe, sans demander : rester à
// l'emplacement du fichier téléchargé ne donne pas une installation
// utilisable, la question n'avait donc qu'une réponse. Copie + PATH +
// raccourcis, un message qui dit ce qui a été fait, et on démarre depuis la
// copie installée.
//
// 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) :
@@ -131,7 +131,12 @@ func appFirstRun() bool {
// 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)
// « 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)
return cmd.Start() == nil
}
@@ -404,6 +409,10 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) {
$s.TargetPath=$t
$s.WorkingDirectory=(Split-Path $t)
$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()
$done+=$d
} catch {}