diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 886b861..e4c2b9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 26db6bb..bc0a39b 100644 Binary files a/cmd/jean/resource_windows_amd64.syso and b/cmd/jean/resource_windows_amd64.syso differ diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 0fd37e6..2552e14 100644 Binary files a/cmd/jean/resource_windows_arm64.syso and b/cmd/jean/resource_windows_arm64.syso differ diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 53e5ea5..02208c7 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -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": { diff --git a/internal/jean/backend_build.go b/internal/jean/backend_build.go index 18ac68a..42c7639 100644 --- a/internal/jean/backend_build.go +++ b/internal/jean/backend_build.go @@ -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() diff --git a/internal/jean/mcp_client.go b/internal/jean/mcp_client.go index c71163f..dd46c75 100644 --- a/internal/jean/mcp_client.go +++ b/internal/jean/mcp_client.go @@ -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 { diff --git a/internal/jean/relay_link.go b/internal/jean/relay_link.go index c5bf90a..cf37528 100644 --- a/internal/jean/relay_link.go +++ b/internal/jean/relay_link.go @@ -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 != "" { diff --git a/internal/jean/run.go b/internal/jean/run.go index 4ba00a0..8fc0d61 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -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//SKILL.md) vers la // nouvelle mémoire (MEMORY/.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 { diff --git a/internal/jean/sys_console_other.go b/internal/jean/sys_console_other.go new file mode 100644 index 0000000..9d2242a --- /dev/null +++ b/internal/jean/sys_console_other.go @@ -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 } diff --git a/internal/jean/sys_console_windows.go b/internal/jean/sys_console_windows.go new file mode 100644 index 0000000..c730f07 --- /dev/null +++ b/internal/jean/sys_console_windows.go @@ -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)) + } +} diff --git a/internal/jean/sys_install_windows.go b/internal/jean/sys_install_windows.go index 3f65fcb..d6fc32a 100644 --- a/internal/jean/sys_install_windows.go +++ b/internal/jean/sys_install_windows.go @@ -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 { diff --git a/internal/jean/sys_platform_unix.go b/internal/jean/sys_platform_unix.go index f75b4c9..dd03c41 100644 --- a/internal/jean/sys_platform_unix.go +++ b/internal/jean/sys_platform_unix.go @@ -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 diff --git a/internal/jean/sys_platform_windows.go b/internal/jean/sys_platform_windows.go index 6601c64..224fc09 100644 --- a/internal/jean/sys_platform_windows.go +++ b/internal/jean/sys_platform_windows.go @@ -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 } diff --git a/internal/jean/sys_proc_other.go b/internal/jean/sys_proc_other.go deleted file mode 100644 index 55fe021..0000000 --- a/internal/jean/sys_proc_other.go +++ /dev/null @@ -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) } diff --git a/internal/jean/sys_proc_windows.go b/internal/jean/sys_proc_windows.go deleted file mode 100644 index 6d99d5d..0000000 --- a/internal/jean/sys_proc_windows.go +++ /dev/null @@ -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 -} diff --git a/internal/jean/sys_service_windows.go b/internal/jean/sys_service_windows.go index 7215866..e212365 100644 --- a/internal/jean/sys_service_windows.go +++ b/internal/jean/sys_service_windows.go @@ -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) } diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index e4bbe28..d4f3233 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -484,6 +484,30 @@ button:disabled{opacity:.5;cursor:not-allowed} +
Accès distant +
ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
+ +
+

Ouvrez l'accès distant à ce serveur en quelques clics — compte, abonnement et connexion, sans terminal.

+ +
+ + +
MOTEUR
…
@@ -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 (.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 : '+(r.fingerprint||'—')+'
Code d\'appairage (valable 10 min, usage unique) : '+r.code+''; + } else { + box.textContent = (r && r.error) || 'code indisponible'; + } + }catch(e){ box.textContent='erreur : '+e; } +} diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index da6ed74..df23aa6 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -124,6 +124,30 @@
+
Accès distant +
ajean.link?Accédez à ce serveur Jean depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
+ +
+

Ouvrez l'accès distant à ce serveur en quelques clics — compte, abonnement et connexion, sans terminal.

+ +
+ + +
MOTEUR
…
diff --git a/internal/jean/ui/src/js/06-settings.js b/internal/jean/ui/src/js/06-settings.js index 72f7cdf..9b031ff 100644 --- a/internal/jean/ui/src/js/06-settings.js +++ b/internal/jean/ui/src/js/06-settings.js @@ -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 (.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); } diff --git a/internal/jean/ui/src/js/13-remote.js b/internal/jean/ui/src/js/13-remote.js new file mode 100644 index 0000000..57e8910 --- /dev/null +++ b/internal/jean/ui/src/js/13-remote.js @@ -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 : '+(r.fingerprint||'—')+'
Code d\'appairage (valable 10 min, usage unique) : '+r.code+''; + } else { + box.textContent = (r && r.error) || 'code indisponible'; + } + }catch(e){ box.textContent='erreur : '+e; } +} diff --git a/internal/jean/web_link_api.go b/internal/jean/web_link_api.go new file mode 100644 index 0000000..4086940 --- /dev/null +++ b/internal/jean/web_link_api.go @@ -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()}) +} diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index e1a479e..a11abf2 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -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], "\"")