v0.5.1 : accès distant ajean.link depuis l'UI + app Windows sans console

- Panneau « Accès distant » dans l'UI web : abonnement + liaison ajean.link
  en un clic (popup connect.html → clé remise à l'agent local), sans terminal.
  Endpoints locaux /api/link/status|connect|disconnect|paircode (web_link_api.go).
- Accès OpenAI public : refuse d'être activé sans accès distant, avec message.
- Windows : binaire compilé en sous-système GUI (-H=windowsgui) → plus aucune
  fenêtre de console au double-clic ; rattachement à la console parente en CLI
  (sys_console_windows.go). Suppression du hack relaunchDetachedApp.
- Windows : masquage (CREATE_NO_WINDOW) de TOUS les sous-process — git/vswhere
  du panneau MOTEUR (flash à chaque refresh), llama-server, MCP stdio, winget…
- Vouvoiement du nouveau parcours.
This commit is contained in:
nathaninline committed 2026-07-24 16:39:09 +02:00
1 parent 8e57685b19
commit d8b08dbb89
22 files changed
+444 -103

No files matched your search

+5 -2
View File
@@ -24,9 +24,12 @@ jobs:
run: |
mkdir -p dist
for t in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
os=${t%/*}; arch=${t#*/}; ext=""; [ "$os" = windows ] && ext=".exe"
os=${t%/*}; arch=${t#*/}; ext=""; ldflags="-s -w"
# Windows : sous-système GUI → aucune console noire au double-clic
# (l'app se rattache à la console du terminal parent si lancée en CLI).
[ "$os" = windows ] && { ext=".exe"; ldflags="$ldflags -H=windowsgui"; }
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w" -o "dist/jean-$os-$arch$ext" ./cmd/jean
go build -trimpath -ldflags="$ldflags" -o "dist/jean-$os-$arch$ext" ./cmd/jean
done
(cd dist && sha256sum jean-* > SHA256SUMS)
ls -l dist && cat dist/SHA256SUMS
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 5,
"Patch": 0,
"Patch": 1,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 5,
"Patch": 0,
"Patch": 1,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.5.0",
"ProductVersion": "0.5.1",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+3 -3
View File
@@ -441,7 +441,7 @@ func missingTools(tools []string) []string {
// gitOutput runs a git command in `dir` and returns trimmed stdout (or "").
func gitOutput(dir string, args ...string) string {
cmd := exec.Command("git", args...)
cmd := hideCmd(exec.Command("git", args...)) // pas de flash console (appelé à chaque refresh de l'UI)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
@@ -459,7 +459,7 @@ func runStep(name, dir, bin string, args ...string) error {
// pairs in `extraEnv`, which override existing ones).
func runStepEnv(name, dir, extraEnv, bin string, args ...string) error {
fmt.Printf("\n%s %s %s\n", cyan("▶"), name, dim(strings.Join(args, " ")))
cmd := exec.Command(bin, args...)
cmd := hideCmd(exec.Command(bin, args...))
cmd.Dir = dir
// Tee vers le sink de build (jobs web) en plus du terminal.
var out io.Writer = os.Stdout
@@ -495,7 +495,7 @@ func runStepEnv(name, dir, extraEnv, bin string, args ...string) error {
// failure the tail of the log is printed so the actual error is never lost.
func runBuildStep(name, dir, extraEnv, bin, logPath string, args ...string) error {
fmt.Printf("\n%s %s\n", cyan("▶"), name)
cmd := exec.Command(bin, args...)
cmd := hideCmd(exec.Command(bin, args...))
cmd.Dir = dir
if extraEnv != "" {
env := os.Environ()
+1 -1
View File
@@ -119,7 +119,7 @@ func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk.
var transport mcpsdk.Transport
switch cfg.Transport() {
case "stdio":
cmd := exec.Command(cfg.Command, cfg.Args...)
cmd := hideCmd(exec.Command(cfg.Command, cfg.Args...)) // pas de flash console (mode app Windows)
// Hérite de l'environnement du service + surcharges déclarées.
cmd.Env = os.Environ()
for k, v := range cfg.Env {
+8
View File
@@ -75,6 +75,14 @@ func saveLinkToken(tok string) error {
return os.WriteFile(linkTokenPath(), []byte(tok+"\n"), 0o600)
}
// removeLinkToken oublie la clé de liaison enregistrée (idempotent).
func removeLinkToken() error {
if err := os.Remove(linkTokenPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// relayURL resolves the relay WebSocket endpoint (env override → default).
func relayURL() string {
if u := strings.TrimSpace(os.Getenv("JEAN_LINK_URL")); u != "" {
+13 -6
View File
@@ -10,10 +10,16 @@ import (
"strings"
)
const Version = "0.5.0"
const Version = "0.5.1"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
// Rattache la console du terminal parent si on est lancé depuis un shell
// (Windows : binaire GUI). Retourne false au double-clic (aucune console) →
// on bascule alors sur l'expérience « application ». Hors Windows : toujours
// true. À faire AVANT toute écriture.
haveConsole := setupConsole()
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
// nouvelle mémoire (MEMORY/<nom>.md). Idempotente, silencieuse si rien à faire.
migrateSkillsToMemory()
@@ -27,12 +33,13 @@ func Main() {
cmd = args[0]
args = args[1:]
}
// Double-clic sur le binaire (aucun argument, console fraîche) → on lance
// l'expérience « application » (UI web + navigateur) plutôt que d'afficher
// l'aide dans une console qui se referme aussitôt. Lancé depuis un shell,
// Double-clic sur le binaire (aucun argument, aucune console rattachée) → on
// lance l'expérience « application » (UI web + navigateur + icône tray) plutôt
// que d'afficher l'aide. Le binaire étant compilé en sous-système GUI, il n'y a
// AUCUNE console à ce stade (donc plus de fenêtre noire). Lancé depuis un shell,
// `jean` sans argument garde son comportement d'aide.
if noArgs && launchedByDoubleClick() {
relaunchDetachedApp() // relance sans console (ne revient pas), puis quitte l'original
if noArgs && !haveConsole {
mustExit(cmdApp(args))
return
}
switch cmd {
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package jean
// setupConsole : hors Windows, le process a toujours une vraie console/tty
// standard (stdout hérité). Rien à faire → on signale simplement « on a une
// console » pour que le double-clic Windows soit le seul cas « mode application ».
func setupConsole() bool { return true }
+60
View File
@@ -0,0 +1,60 @@
//go:build windows
package jean
// 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 (`jean web`, `jean status`, …) reste lisible.
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 »).
func setupConsole() bool {
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).
if con, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0); err == nil {
os.Stdout = con
os.Stderr = con
}
if cin, err := os.OpenFile("CONIN$", os.O_RDONLY, 0); err == nil {
os.Stdin = cin
}
configureConsole()
return true
}
// 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.
func configureConsole() {
const (
cpUTF8 = 65001
enableVirtualTerminalProcessing = 0x0004
stdOutputHandle = ^uintptr(10) // -11
)
k := syscall.NewLazyDLL("kernel32.dll")
_, _, _ = k.NewProc("SetConsoleOutputCP").Call(uintptr(cpUTF8))
_, _, _ = k.NewProc("SetConsoleCP").Call(uintptr(cpUTF8))
h, _, _ := k.NewProc("GetStdHandle").Call(stdOutputHandle)
var mode uint32
if r, _, _ := k.NewProc("GetConsoleMode").Call(h, uintptr(unsafe.Pointer(&mode))); r != 0 {
_, _, _ = k.NewProc("SetConsoleMode").Call(h, uintptr(mode|enableVirtualTerminalProcessing))
}
}
+2 -2
View File
@@ -150,7 +150,7 @@ if ($parts -contains $d) { Write-Output 'present'; exit 0 }
$new=(@($parts) + $d) -join ';'
[Environment]::SetEnvironmentVariable('Path',$new,'User')
Write-Output 'added'`, psQuote(dir))
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
outBytes, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(outBytes))
if err != nil {
@@ -169,7 +169,7 @@ $parts=$p.Split(';') | Where-Object { $_ -ne '' -and $_ -ne $d }
if (($p.Split(';') | Where-Object { $_ -eq $d }).Count -eq 0) { Write-Output 'absent'; exit 0 }
[Environment]::SetEnvironmentVariable('Path',($parts -join ';'),'User')
Write-Output 'removed'`, psQuote(dir))
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
outBytes, err := cmd.CombinedOutput()
out := strings.TrimSpace(string(outBytes))
if err != nil {
-5
View File
@@ -65,11 +65,6 @@ func totalRAMGB() float64 {
return 0
}
// launchedByDoubleClick : sur Unix, jean tourne en service/CLI, on ne déclenche
// jamais le mode app sur un simple no-arg (éviterait de surprendre un serveur).
// L'expérience app double-clic est propre à Windows/macOS (coque native, phase 3).
func launchedByDoubleClick() bool { return false }
// setLibraryPath ensures llama-server can load shared libs bundled next to the
// binary by prepending dir to LD_LIBRARY_PATH. It also appends the CUDA runtime
// lib directories: a CUDA-enabled build links against libcudart/libcublas, which
+12 -25
View File
@@ -97,19 +97,6 @@ func totalRAMGB() float64 {
return float64(m.ullTotalPhys) / (1024 * 1024 * 1024)
}
// launchedByDoubleClick indique qu'on a été lancé par un double-clic (Explorer)
// et non depuis un shell existant. GetConsoleProcessList renvoie le nombre de
// process attachés à la console : 1 = on possède une console fraîche (double-
// clic), >1 = on a hérité de la console d'un shell (cmd/PowerShell), auquel cas
// l'utilisateur veut la CLI, pas l'app.
func launchedByDoubleClick() bool {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
proc := kernel32.NewProc("GetConsoleProcessList")
var pids [4]uint32
r, _, _ := proc.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
return r == 1
}
// setLibraryPath ensures llama-server can load its dependent DLLs. Windows
// resolves them via PATH (and the binary's own directory), so we prepend dir.
// For a CUDA build we must also add the CUDA Toolkit's bin: ggml-cuda.dll links
@@ -139,7 +126,7 @@ func setLibraryPath(dir string) {
// parent (this is the detached process the service supervisor tracks).
// args[0] is the binary path; the rest are its arguments.
func execServer(bin string, args []string) error {
cmd := exec.Command(bin, args[1:]...)
cmd := hideCmd(exec.Command(bin, args[1:]...)) // pas de console pour llama-server (mode app)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -165,9 +152,9 @@ func autoInstallTool(name string) error {
if !ok {
id = name
}
cmd := exec.Command("winget", "install", "--id", id, "-e",
cmd := hideCmd(exec.Command("winget", "install", "--id", id, "-e",
"--accept-source-agreements", "--accept-package-agreements",
"--disable-interactivity", "--silent")
"--disable-interactivity", "--silent"))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
@@ -180,7 +167,7 @@ func refreshToolPath() {
ps := `$m=[Environment]::GetEnvironmentVariable('Path','Machine')
$u=[Environment]::GetEnvironmentVariable('Path','User')
Write-Output ((@($m,$u) | Where-Object { $_ }) -join ';')`
out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps).Output()
out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).Output()
if err != nil {
return
}
@@ -206,9 +193,9 @@ func msvcInstallVersion() string {
if _, err := os.Stat(vs); err != nil {
return ""
}
out, err := exec.Command(vs, "-latest", "-products", "*",
out, err := hideCmd(exec.Command(vs, "-latest", "-products", "*",
"-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-property", "installationVersion").Output()
"-property", "installationVersion")).Output()
if err != nil {
return ""
}
@@ -247,10 +234,10 @@ func ensureCompiler() error {
return fmt.Errorf("compilateur C++ absent et winget introuvable — installe « Visual Studio Build Tools » (charge de travail C++) manuellement")
}
fmt.Printf("%s compilateur C++ absent — installation des Build Tools MSVC (gros téléchargement, une seule fois)…\n", yellow("[info]"))
cmd := exec.Command("winget", "install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e",
cmd := hideCmd(exec.Command("winget", "install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e",
"--accept-source-agreements", "--accept-package-agreements",
"--disable-interactivity",
"--override", "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended")
"--override", "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
@@ -280,9 +267,9 @@ func ensureAccelerator() {
return
}
fmt.Printf("%s GPU NVIDIA détecté — installation du CUDA Toolkit pour l'accélération GPU (gros téléchargement, une seule fois)…\n", yellow("[info]"))
cmd := exec.Command("winget", "install", "--id", "Nvidia.CUDA", "-e",
cmd := hideCmd(exec.Command("winget", "install", "--id", "Nvidia.CUDA", "-e",
"--accept-source-agreements", "--accept-package-agreements",
"--disable-interactivity")
"--disable-interactivity"))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
@@ -312,9 +299,9 @@ func ensureCudaVSIntegration(toolkitDir string) error {
if _, err := os.Stat(vs); err != nil {
return nil
}
out, err := exec.Command(vs, "-latest", "-products", "*",
out, err := hideCmd(exec.Command(vs, "-latest", "-products", "*",
"-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-property", "installationPath").Output()
"-property", "installationPath")).Output()
if err != nil {
return nil
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !windows
package jean
// sys_proc_other.go — hors Windows, pas de détachement (le mode app double-clic est
// propre à Windows). Défini pour la compilation ; non appelé en pratique car
// launchedByDoubleClick renvoie false sur Unix.
func relaunchDetachedApp() { _ = cmdApp(nil) }
-39
View File
@@ -1,39 +0,0 @@
//go:build windows
package jean
// sys_proc_windows.go — au double-clic, on relance Jean en processus DÉTACHÉ, sans
// console. L'original (qui, lui, possède la console fraîche créée par Explorer)
// se termine aussitôt, fermant sa console. Le nouveau process n'a aucune fenêtre
// de console : il n'y a donc plus de « terminal noir » et plus rien à fermer qui
// couperait le serveur. Seuls restent le splash puis l'icône de la zone de
// notification.
import (
"os"
"os/exec"
"syscall"
)
func relaunchDetachedApp() {
const (
detachedProcess = 0x00000008
createNoWindow = 0x08000000
createNewProcGrp = 0x00000200
)
exe, err := os.Executable()
if err != nil {
_ = cmdApp(nil) // repli : on lance quand même en place
return
}
cmd := exec.Command(exe, "app")
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: detachedProcess | createNoWindow | createNewProcGrp,
}
if err := cmd.Start(); err != nil {
_ = cmdApp(nil)
return
}
os.Exit(0) // ferme l'original → sa console disparaît ; le détaché continue
}
+7 -1
View File
@@ -23,6 +23,7 @@ import (
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(JeanHome(), serviceName()+".pid") }
@@ -70,7 +71,12 @@ func svcStart() error {
cmd := exec.Command(self, "serve")
cmd.Stdout = logf
cmd.Stderr = logf
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | detachedProcess}
// createNoWindow + HideWindow : le service enfant (`jean 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 'jean serve': %w", err)
}
+115 -2
View File
@@ -484,6 +484,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</details>
<details id="remote-details"><summary>Accès distant <span id="remote-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
<div id="remote-off">
<p class="muted" style="font-size:12px;line-height:1.5;margin:6px 0 10px">Ouvrez l'accès distant à ce serveur en quelques clics — compte, abonnement et connexion, sans terminal.</p>
<button onclick="remoteConnect()" style="border-color:var(--accent);color:var(--accent)">🔗 Connecter à ajean.link</button>
</div>
<!-- Connecté : URL publique + appairage + déconnexion. -->
<div id="remote-on" style="display:none">
<div class="muted" style="font-size:11px;margin-bottom:2px">🌍 votre adresse d'accès distant</div>
<div class="row">
<input id="remote-url" readonly style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit;font-size:12px">
<button onclick="copyText(document.getElementById('remote-url').value,'adresse copiée')" title="copier">⧉</button>
<button onclick="window.open(document.getElementById('remote-url').value,'_blank')" title="ouvrir">↗</button>
</div>
<div id="remote-status" class="muted" style="font-size:11px;margin-top:6px"></div>
<div class="subhead" style="margin-top:12px">Première connexion<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">À votre première ouverture du portail distant, confirmez l'<b>empreinte</b> et saisissez le <b>code d'appairage</b> : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.</span></span></div>
<button onclick="remotePairCode()" style="margin-top:4px">🔑 Afficher l'empreinte + le code d'appairage</button>
<div id="remote-pair" class="muted" style="font-size:12px;margin-top:8px;display:none;line-height:1.7"></div>
<div class="row" style="margin-top:12px">
<button onclick="remoteDisconnect()" style="border-color:var(--err);color:var(--err)">déconnecter l'accès distant</button>
</div>
</div>
</details>
<details id="lc-details"><summary>MOTEUR</summary>
<div id="lc-status" class="lc-head muted">…</div>
<div class="lc-modes">
@@ -1155,7 +1179,19 @@ async function apiKeyAction(action){
renderApiKey(await jpost('/api/apikey', {action}));
}
async function toggleOAIPublic(){
const on = document.getElementById('oai-public-toggle').checked;
const cb = document.getElementById('oai-public-toggle');
const on = cb.checked;
// L'accès OpenAI public passe par ajean.link (<machine>.oai.ajean.link) : il exige
// que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique.
if(on){
let linked = false;
try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){}
if(!linked){
cb.checked = false;
await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'});
return;
}
}
await jpost('/api/oai/public', {enabled:on});
toast(on ? 'accès public activé' : 'accès public coupé');
loadApiKey();
@@ -1176,7 +1212,7 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); }
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
@@ -2167,4 +2203,81 @@ async function deleteMcp(){
loadMCP();
toast('serveur supprimé');
}
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service).
const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
function renderRemote(d){
const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
const badge = document.getElementById('remote-badge');
if(!d || !d.linked){
off.style.display=''; on.style.display='none';
if(badge){ badge.textContent=''; }
return;
}
off.style.display='none'; on.style.display='';
document.getElementById('remote-url').value = d.machineURL || '';
const st = document.getElementById('remote-status');
if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; }
else { st.textContent='○ service arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; }
if(badge){ badge.textContent = d.active ? '● connecté' : '○ arrêté'; badge.style.color = d.active?'var(--accent)':'var(--warn)'; }
}
async function loadRemote(){
// Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque
// (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
const det = document.getElementById('remote-details');
if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
try{ renderRemote(await jget('/api/link/status')); }catch(e){}
}
// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
function remoteConnect(){
const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' });
const url = AJEAN_APP_ORIGIN + '/connect.html?' + params.toString();
const pop = window.open(url, 'ajean-connect', 'width=440,height=640');
if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; }
async function onMsg(ev){
// Anti-usurpation : n'accepte QUE des messages du portail ajean.link.
if(ev.origin !== AJEAN_APP_ORIGIN) return;
const d = ev.data || {};
if(d.type !== 'ajean-link' || !d.token) return;
window.removeEventListener('message', onMsg);
try{
const r = await jpost('/api/link/connect', { token: d.token });
if(r && r.linked){
toast('✓ accès distant connecté');
if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); }
loadRemote();
} else {
toast((r && r.error) || 'échec de la connexion');
}
}catch(e){ toast('erreur : '+e); }
}
window.addEventListener('message', onMsg);
}
async function remoteDisconnect(){
const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'});
if(!ok) return;
try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); }
catch(e){ toast('erreur : '+e); }
}
async function remotePairCode(){
const box = document.getElementById('remote-pair');
box.style.display=''; box.textContent='génération du code…';
try{
const r = await jpost('/api/link/paircode', {});
if(r && r.code){
box.innerHTML = 'Empreinte : <b>'+(r.fingerprint||'—')+'</b><br>Code d\'appairage (valable 10 min, usage unique) : <b>'+r.code+'</b>';
} else {
box.textContent = (r && r.error) || 'code indisponible';
}
}catch(e){ box.textContent='erreur : '+e; }
}
</script></body></html>
+24
View File
@@ -124,6 +124,30 @@
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</details>
<details id="remote-details"><summary>Accès distant <span id="remote-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
<div class="subhead">ajean.link<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Accédez à ce serveur Jean depuis <b>n'importe où</b>, sans ouvrir de port, à travers le relais <b>ajean.link</b>. Le trafic est chiffré de <b>bout en bout</b> : le relais ne voit rien. Abonnement 4,80 €/mois.</span></span></div>
<!-- Non connecté : un seul bouton, tout se passe dans la popup (compte + paiement). -->
<div id="remote-off">
<p class="muted" style="font-size:12px;line-height:1.5;margin:6px 0 10px">Ouvrez l'accès distant à ce serveur en quelques clics — compte, abonnement et connexion, sans terminal.</p>
<button onclick="remoteConnect()" style="border-color:var(--accent);color:var(--accent)">🔗 Connecter à ajean.link</button>
</div>
<!-- Connecté : URL publique + appairage + déconnexion. -->
<div id="remote-on" style="display:none">
<div class="muted" style="font-size:11px;margin-bottom:2px">🌍 votre adresse d'accès distant</div>
<div class="row">
<input id="remote-url" readonly style="flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit;font-size:12px">
<button onclick="copyText(document.getElementById('remote-url').value,'adresse copiée')" title="copier">⧉</button>
<button onclick="window.open(document.getElementById('remote-url').value,'_blank')" title="ouvrir">↗</button>
</div>
<div id="remote-status" class="muted" style="font-size:11px;margin-top:6px"></div>
<div class="subhead" style="margin-top:12px">Première connexion<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">À votre première ouverture du portail distant, confirmez l'<b>empreinte</b> et saisissez le <b>code d'appairage</b> : cela verrouille la boîte noire sur CE serveur (anti-interception). Une seule fois par appareil.</span></span></div>
<button onclick="remotePairCode()" style="margin-top:4px">🔑 Afficher l'empreinte + le code d'appairage</button>
<div id="remote-pair" class="muted" style="font-size:12px;margin-top:8px;display:none;line-height:1.7"></div>
<div class="row" style="margin-top:12px">
<button onclick="remoteDisconnect()" style="border-color:var(--err);color:var(--err)">déconnecter l'accès distant</button>
</div>
</div>
</details>
<details id="lc-details"><summary>MOTEUR</summary>
<div id="lc-status" class="lc-head muted">…</div>
<div class="lc-modes">
+14 -2
View File
@@ -195,7 +195,19 @@ async function apiKeyAction(action){
renderApiKey(await jpost('/api/apikey', {action}));
}
async function toggleOAIPublic(){
const on = document.getElementById('oai-public-toggle').checked;
const cb = document.getElementById('oai-public-toggle');
const on = cb.checked;
// L'accès OpenAI public passe par ajean.link (<machine>.oai.ajean.link) : il exige
// que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique.
if(on){
let linked = false;
try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){}
if(!linked){
cb.checked = false;
await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'});
return;
}
}
await jpost('/api/oai/public', {enabled:on});
toast(on ? 'accès public activé' : 'accès public coupé');
loadApiKey();
@@ -216,5 +228,5 @@ async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]); }
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadRemote()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+77
View File
@@ -0,0 +1,77 @@
// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
// l'agent local (/api/link/connect) qui fait le `jean link` (token + service).
const AJEAN_APP_ORIGIN = 'https://app.ajean.link';
function renderRemote(d){
const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
const badge = document.getElementById('remote-badge');
if(!d || !d.linked){
off.style.display=''; on.style.display='none';
if(badge){ badge.textContent=''; }
return;
}
off.style.display='none'; on.style.display='';
document.getElementById('remote-url').value = d.machineURL || '';
const st = document.getElementById('remote-status');
if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; }
else { st.textContent='○ service arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; }
if(badge){ badge.textContent = d.active ? '● connecté' : '○ arrêté'; badge.style.color = d.active?'var(--accent)':'var(--warn)'; }
}
async function loadRemote(){
// Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque
// (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
const det = document.getElementById('remote-details');
if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
try{ renderRemote(await jget('/api/link/status')); }catch(e){}
}
// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
function remoteConnect(){
const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' });
const url = AJEAN_APP_ORIGIN + '/connect.html?' + params.toString();
const pop = window.open(url, 'ajean-connect', 'width=440,height=640');
if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; }
async function onMsg(ev){
// Anti-usurpation : n'accepte QUE des messages du portail ajean.link.
if(ev.origin !== AJEAN_APP_ORIGIN) return;
const d = ev.data || {};
if(d.type !== 'ajean-link' || !d.token) return;
window.removeEventListener('message', onMsg);
try{
const r = await jpost('/api/link/connect', { token: d.token });
if(r && r.linked){
toast('✓ accès distant connecté');
if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); }
loadRemote();
} else {
toast((r && r.error) || 'échec de la connexion');
}
}catch(e){ toast('erreur : '+e); }
}
window.addEventListener('message', onMsg);
}
async function remoteDisconnect(){
const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'});
if(!ok) return;
try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); }
catch(e){ toast('erreur : '+e); }
}
async function remotePairCode(){
const box = document.getElementById('remote-pair');
box.style.display=''; box.textContent='génération du code…';
try{
const r = await jpost('/api/link/paircode', {});
if(r && r.code){
box.innerHTML = 'Empreinte : <b>'+(r.fingerprint||'—')+'</b><br>Code d\'appairage (valable 10 min, usage unique) : <b>'+r.code+'</b>';
} else {
box.textContent = (r && r.error) || 'code indisponible';
}
}catch(e){ box.textContent='erreur : '+e; }
}
+85
View File
@@ -0,0 +1,85 @@
package jean
// web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI
// web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une
// popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une
// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `jean link` tout
// seul (écrit le token + (re)démarre le service).
//
// Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais,
// newLinkHandler refuse tout /api/* non chiffré (boîte noire). La configuration
// de l'accès distant se fait donc depuis la machine elle-même, ce qui est correct.
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// remoteServerURL est l'URL du portail distant pointant droit sur cette machine.
func remoteServerURL() string {
return "https://app.ajean.link/server.html?m=" + machineID()
}
// handleLinkStatus (GET /api/link/status) : état de l'accès distant pour l'UI.
func handleLinkStatus(w http.ResponseWriter, r *http.Request) {
tok := readLinkToken()
sendJSON(w, http.StatusOK, map[string]any{
"linked": tok != "",
"active": linkServiceActive(),
"machineURL": remoteServerURL(),
"fingerprint": e2eFingerprint(),
})
}
// handleLinkConnect (POST /api/link/connect {token}) : enregistre la clé de
// liaison remise par la popup connect.html et (re)démarre le service de lien.
func handleLinkConnect(w http.ResponseWriter, r *http.Request) {
var req struct{ Token string }
_ = json.NewDecoder(r.Body).Decode(&req)
tok := strings.TrimSpace(req.Token)
if !strings.HasPrefix(tok, "jl_") {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "clé de liaison invalide"})
return
}
if err := saveLinkToken(tok); err != nil {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
// (re)démarre le worker pour qu'il prenne la nouvelle clé. Sur une machine sans
// systemd (dev/Windows), le token est quand même enregistré : on renvoie l'info
// mais on signale que le service n'a pas pu démarrer ici.
svcErr := ""
if err := startLink(true); err != nil {
svcErr = err.Error()
}
sendJSON(w, http.StatusOK, map[string]any{
"ok": true,
"linked": true,
"active": linkServiceActive(),
"machineURL": remoteServerURL(),
"serviceErr": svcErr,
})
}
// handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie
// la clé (équivalent `jean link stop` + `jean link logout`).
func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) {
_ = linkServiceCtl("stop")
_ = removeLinkToken()
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "linked": false, "active": false})
}
// handleLinkPairCode (POST /api/link/paircode) : génère un code d'appairage frais
// (usage unique, TTL 10 min) + l'empreinte E2E, à saisir UNE fois dans le portail
// distant lors de la première connexion (confirmation de la boîte noire). Évite le
// détour par `jean link code` en terminal.
func handleLinkPairCode(w http.ResponseWriter, r *http.Request) {
code, err := newPairCode()
if err != nil {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": fmt.Sprintf("génération du code (droits ?): %v", err)})
return
}
sendJSON(w, http.StatusOK, map[string]any{"code": code, "fingerprint": e2eFingerprint()})
}
+7 -3
View File
@@ -107,6 +107,10 @@ func newWebMux() *http.ServeMux {
api("/api/agent/compact", handleCompactToggle)
api("/api/apikey", handleAPIKey)
api("/api/oai/public", handleOAIPublic)
api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link)
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → jean link
api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé
api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion
api("/api/internet", handleInternet)
api("/api/mcp", handleMCP)
api("/api/mcp/save", handleMCPSave)
@@ -187,7 +191,7 @@ func killPid(pid int, force bool) {
if force {
args = append(args, "/F")
}
_ = exec.Command("taskkill", args...).Run()
_ = hideCmd(exec.Command("taskkill", args...)).Run()
return
}
sig := "-TERM"
@@ -204,7 +208,7 @@ func killPid(pid int, force bool) {
func pidOnPort(port int) (int, string) {
if runtime.GOOS == "windows" {
// netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234"
out, err := exec.Command("netstat", "-ano", "-p", "tcp").Output()
out, err := hideCmd(exec.Command("netstat", "-ano", "-p", "tcp")).Output()
if err != nil {
return 0, ""
}
@@ -240,7 +244,7 @@ func pidOnPort(port int) (int, string) {
func processName(pid int) string {
if runtime.GOOS == "windows" {
// tasklist CSV : "jean.exe","1234","Console","1","12 345 K"
out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH").Output()
out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output()
if err == nil {
if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 {
return strings.TrimPrefix(f[0], "\"")