diff --git a/internal/jean/sys_console_darwin.go b/internal/jean/sys_console_darwin.go index 139579f..f3d0096 100644 --- a/internal/jean/sys_console_darwin.go +++ b/internal/jean/sys_console_darwin.go @@ -30,7 +30,36 @@ func setupConsole() bool { } // .../Jean.app/Contents/MacOS/jean if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") { + fixFinderPath() return false } return true } + +// fixFinderPath complète le PATH famélique hérité du Finder. Une app lancée par +// LaunchServices reçoit /usr/bin:/bin:/usr/sbin:/sbin — pas les dossiers de +// Homebrew : sans ça, Jean ne voit ni `brew`, ni `cmake`, ni `git`, et la +// compilation d'un backend échoue alors que les outils sont bien installés. +func fixFinderPath() { + extra := []string{ + "/opt/homebrew/bin", "/opt/homebrew/sbin", // Homebrew Apple Silicon + "/usr/local/bin", "/usr/local/sbin", // Homebrew Intel + "/opt/local/bin", // MacPorts + } + path := os.Getenv("PATH") + have := map[string]bool{} + for _, p := range strings.Split(path, ":") { + have[p] = true + } + var add []string + for _, p := range extra { + if !have[p] { + if fi, err := os.Stat(p); err == nil && fi.IsDir() { + add = append(add, p) + } + } + } + if len(add) > 0 { + _ = os.Setenv("PATH", strings.Join(add, ":")+":"+path) + } +} diff --git a/internal/jean/sys_platform_unix.go b/internal/jean/sys_platform_unix.go index dd03c41..922717c 100644 --- a/internal/jean/sys_platform_unix.go +++ b/internal/jean/sys_platform_unix.go @@ -17,7 +17,41 @@ import ( // defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean // override it. -func defaultJeanHome() string { return "/etc/jean" } +// +// Linux : /etc/jean (machine de prod, service systemd lancé par root). +// macOS : /etc/jean n'est PAS écrivable par une app lancée depuis le Finder +// (« mkdir /etc/jean: permission denied »), et un Mac est une machine de bureau, +// pas un serveur — on retombe donc sur le dossier utilisateur standard, sauf si +// un `sudo jean install` a déjà créé /etc/jean à notre nom. +func defaultJeanHome() string { + if runtime.GOOS != "darwin" || os.Geteuid() == 0 { + return "/etc/jean" + } + if isWritableDir("/etc/jean") { + return "/etc/jean" // installé par `sudo jean install` puis chown à l'utilisateur + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, "Library", "Application Support", "jean") + } + return "/etc/jean" +} + +// isWritableDir teste qu'un dossier existe ET qu'on peut y écrire (le seul test +// fiable : les permissions POSIX seules ignorent ACL, SIP, volumes read-only). +func isWritableDir(dir string) bool { + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + return false + } + probe := filepath.Join(dir, ".jean-write-test") + f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return false + } + f.Close() + _ = os.Remove(probe) + return true +} // defaultEditor is used by `jean edit` when $EDITOR is unset. func defaultEditor() string { return "nano" } @@ -194,3 +228,70 @@ func execServer(bin string, args []string) error { func newShellCmd(ctx context.Context, command string) *exec.Cmd { return exec.CommandContext(ctx, "/bin/bash", "-c", command) } + +// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram. Linux : /proc/meminfo. +// macOS : sysctl pour le total, vm_stat pour ce qui est réellement libre (pages +// free + inactive + speculative ; le reste — wired, active, compressé — est +// considéré occupé, comme le fait le Moniteur d'activité). +func ramUsageMB() (used, total int) { + if runtime.GOOS == "darwin" { + total = int(totalRAMGB() * 1024) + if total == 0 { + return 0, 0 + } + out, err := exec.Command("vm_stat").Output() + if err != nil { + return 0, total + } + pageSize := 4096 + freePages := 0 + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, "Mach Virtual Memory Statistics") { + // « … (page size of 16384 bytes) » — l'Apple Silicon n'est pas en 4 Ko. + if i := strings.Index(line, "page size of "); i >= 0 { + if v, err := strconv.Atoi(strings.Fields(line[i+len("page size of "):])[0]); err == nil { + pageSize = v + } + } + continue + } + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + switch strings.TrimSpace(k) { + case "Pages free", "Pages inactive", "Pages speculative": + if n, err := strconv.Atoi(strings.Trim(strings.TrimSpace(v), ".")); err == nil { + freePages += n + } + } + } + freeMB := freePages * pageSize / (1024 * 1024) + if freeMB > total { + freeMB = total + } + return total - freeMB, total + } + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0 + } + var totalKB, availKB int + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 2 { + continue + } + v, _ := strconv.Atoi(f[1]) // kB + switch f[0] { + case "MemTotal:": + totalKB = v + case "MemAvailable:": + availKB = v + } + } + if totalKB == 0 { + return 0, 0 + } + return (totalKB - availKB) / 1024, totalKB / 1024 +} diff --git a/internal/jean/sys_platform_windows.go b/internal/jean/sys_platform_windows.go index 224fc09..56d0912 100644 --- a/internal/jean/sys_platform_windows.go +++ b/internal/jean/sys_platform_windows.go @@ -368,3 +368,25 @@ func cudaPathEnv(toolkitDir string) []string { func newShellCmd(ctx context.Context, command string) *exec.Cmd { return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command)) } + +// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram (GlobalMemoryStatusEx). +func ramUsageMB() (used, total int) { + var m struct { + dwLength uint32 + dwMemoryLoad uint32 + ullTotalPhys uint64 + ullAvailPhys uint64 + ullTotalPageFile uint64 + ullAvailPageFile uint64 + ullTotalVirtual uint64 + ullAvailVirtual uint64 + ullAvailExtendedVirtual uint64 + } + m.dwLength = uint32(unsafe.Sizeof(m)) + kernel32 := syscall.NewLazyDLL("kernel32.dll") + if r, _, _ := kernel32.NewProc("GlobalMemoryStatusEx").Call(uintptr(unsafe.Pointer(&m))); r == 0 { + return 0, 0 + } + const mb = 1024 * 1024 + return int((m.ullTotalPhys - m.ullAvailPhys) / mb), int(m.ullTotalPhys / mb) +} diff --git a/internal/jean/web_api.go b/internal/jean/web_api.go index 17c12a2..cd761f3 100644 --- a/internal/jean/web_api.go +++ b/internal/jean/web_api.go @@ -71,32 +71,12 @@ func handleVram(w http.ResponseWriter, r *http.Request) { } // handleRam renvoie la RAM système {used, total} en Mo (mêmes unités que -// /api/vram → l'UI divise par 1024 pour des Gio). Lit /proc/meminfo (Linux, la -// box de prod). Sur un OS sans /proc, renvoie total=0 → l'UI masque le bloc. +// /api/vram → l'UI divise par 1024 pour des Gio). La mesure est spécifique à +// l'OS (ramUsageMB : /proc/meminfo sous Linux, sysctl+vm_stat sur macOS, +// GlobalMemoryStatusEx sous Windows). total=0 → l'UI masque le bloc. func handleRam(w http.ResponseWriter, r *http.Request) { - out := map[string]any{"used": 0, "total": 0} - b, err := os.ReadFile("/proc/meminfo") - if err == nil { - var totalKB, availKB int - for _, line := range strings.Split(string(b), "\n") { - f := strings.Fields(line) - if len(f) < 2 { - continue - } - v, _ := strconv.Atoi(f[1]) // valeur en kB - switch f[0] { - case "MemTotal:": - totalKB = v - case "MemAvailable:": - availKB = v - } - } - if totalKB > 0 { - out["total"] = totalKB / 1024 - out["used"] = (totalKB - availKB) / 1024 - } - } - sendJSON(w, 200, out) + used, total := ramUsageMB() + sendJSON(w, 200, map[string]any{"used": used, "total": total}) } func handleConfigEnv(w http.ResponseWriter, r *http.Request) {