diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4c2b9c..96965d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,10 @@ # ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les # assets du même nom (softprops remplace), et des sommes qui ne correspondent # plus aux binaires font échouer `jean update` (vécu sur v0.4.7). +# +# Deux jobs de build : macOS a besoin d'un vrai runner Apple (l'icône de barre de +# menus passe par Cocoa → CGO, pas cross-compilable depuis Linux) ; Linux et +# Windows restent CGO_ENABLED=0 sur ubuntu. name: release on: push: @@ -13,17 +17,84 @@ permissions: contents: write jobs: + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: build macOS (CGO, arm64 + amd64) + run: | + mkdir -p dist + # arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64). + CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ + go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-arm64 ./cmd/jean + CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \ + CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \ + go build -trimpath -ldflags="-s -w" -o dist/jean-darwin-amd64 ./cmd/jean + file dist/jean-darwin-* + - name: bundles Jean.app + run: | + # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc + # aussi un vrai bundle Jean.app (zippé pour garder le bit exécutable) : + # au clic, le Finder lance Contents/MacOS/jean, qui détecte le bundle + # et bascule en mode « application » (UI web + navigateur + icône dans + # la barre de menus), comme le double-clic sur jean.exe sous Windows. + # LSUIElement : app de barre de menus, aucune icône dans le Dock. + mkdir -p /tmp/jean.iconset + sips -s format png 'cmd/jean/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true + for s in 16 32 128 256 512; do + sips -z $s $s /tmp/icon.png --out "/tmp/jean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true + done + iconutil -c icns /tmp/jean.iconset -o /tmp/jean.icns >/dev/null 2>&1 || true + v="${GITHUB_REF_NAME#v}" + for arch in amd64 arm64; do + app="dist/mac-$arch/Jean.app" + mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + cp "dist/jean-darwin-$arch" "$app/Contents/MacOS/jean" + chmod +x "$app/Contents/MacOS/jean" + cp /tmp/jean.icns "$app/Contents/Resources/jean.icns" 2>/dev/null || true + printf '%s\n' \ + '' \ + '' \ + '' \ + 'CFBundleNameJean' \ + 'CFBundleDisplayNameJean' \ + 'CFBundleExecutablejean' \ + 'CFBundleIdentifierlink.ajean.jean' \ + 'CFBundleIconFilejean' \ + 'CFBundlePackageTypeAPPL' \ + "CFBundleShortVersionString$v" \ + "CFBundleVersion$v" \ + 'LSMinimumSystemVersion11.0' \ + 'LSUIElement' \ + 'NSHighResolutionCapable' \ + '' > "$app/Contents/Info.plist" + # Signature ad-hoc : sans elle, macOS refuse purement et simplement + # de lancer un binaire arm64 non signé (pas juste un avertissement). + codesign --force --deep -s - "$app" || true + (cd "dist/mac-$arch" && zip -qry "../Jean-macos-$arch.zip" Jean.app) + rm -rf "dist/mac-$arch" + done + ls -l dist + - uses: actions/upload-artifact@v4 + with: + name: macos + path: dist/* + release: + needs: macos runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: build 6 cibles + - name: build Linux + Windows run: | mkdir -p dist - for t in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do + for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do 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). @@ -31,7 +102,14 @@ jobs: GOOS=$os GOARCH=$arch CGO_ENABLED=0 \ go build -trimpath -ldflags="$ldflags" -o "dist/jean-$os-$arch$ext" ./cmd/jean done - (cd dist && sha256sum jean-* > SHA256SUMS) + - uses: actions/download-artifact@v4 + with: + name: macos + path: dist + - name: SHA256SUMS + run: | + chmod +x dist/jean-darwin-* + (cd dist && sha256sum jean-* Jean-macos-*.zip > SHA256SUMS) ls -l dist && cat dist/SHA256SUMS - name: publier la release uses: softprops/action-gh-release@v2 diff --git a/README.md b/README.md index 2f58971..cecbede 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Faire tourner llama.cpp comme un vrai service, ça veut dire d'habitude : trouve - **Protection par clé API** (`jean set-api-key`) — auth Bearer pour exposer le serveur publiquement ; la clé est stockée à part pour survivre aux changements de preset - **Benchmark** (`jean bench`) — tok/s prefill/decode honnêtes avec un corpus varié - **Auto-mise à jour** (`jean update`) — récupère la dernière release GitHub et remplace le binaire en place (`--check` pour seulement vérifier) -- **Binaire statique unique** — compilé avec `CGO_ENABLED=0`, se cross-compile trivialement +- **Binaire unique** — statique (`CGO_ENABLED=0`) sur Linux et Windows, se cross-compile trivialement ; la build macOS ajoute juste Cocoa pour l’icône de barre de menus ## Démarrage rapide @@ -96,6 +96,14 @@ jean logs # suit %ProgramData%\jean\jean.log `jean llamacpp install` peut compiler llama.cpp si `git` et `cmake` sont présents ; sinon récupère un binaire `llama-server.exe` pré-compilé et pointe `BIN` dessus avec `jean edit`. +## macOS + +Comme sous Windows, tu peux lancer Jean **d'un simple clic** : la page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézippe, glisse-le dans *Applications*, ouvre-le : Jean démarre son UI web sur `http://localhost:8090`, l'ouvre dans le navigateur et pose son **icône dans la barre de menus** (en haut à droite) — pendant exact du system tray Windows, avec « Ouvrir Jean » / « Quitter ». Pas de fenêtre de Terminal, pas d'icône dans le Dock. + +L'app n'est **pas signée** par un certificat Apple (seulement ad-hoc) : au premier lancement, fais **clic droit → Ouvrir** (ou *Réglages Système → Confidentialité et sécurité → Ouvrir quand même*). + +Pour l'usage CLI, prends le binaire nu `jean-darwin-arm64` (ou `-amd64`) et mets-le dans `/usr/local/bin/jean` : hors bundle, il garde son comportement en ligne de commande. Les services utilisent **launchd** (pas systemd). + ## Commandes ``` diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index a770c1b..e7abb3d 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 cbcda36..60f7e74 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 c258b9e..6d3b7f6 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 5, - "Patch": 3, + "Patch": 4, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 5, - "Patch": 3, + "Patch": 4, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "Jean", - "ProductVersion": "0.5.3", + "ProductVersion": "0.5.4", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/run.go b/internal/jean/run.go index bd81213..00bb6bb 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.5.3" +const Version = "0.5.4" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/sys_console_darwin.go b/internal/jean/sys_console_darwin.go new file mode 100644 index 0000000..139579f --- /dev/null +++ b/internal/jean/sys_console_darwin.go @@ -0,0 +1,36 @@ +//go:build darwin + +package jean + +// sys_console_darwin.go — détection du lancement « par clic » sous macOS. +// +// Sur macOS il n'y a pas d'équivalent d'AttachConsole : un binaire lancé depuis +// le Finder hérite quand même de descripteurs stdout/stderr (vers les logs +// système). Le signal fiable, c'est l'emplacement de l'exécutable : la release +// publie un bundle Jean.app dont le binaire vit dans +// Jean.app/Contents/MacOS/jean. Si on tourne depuis là, c'est un double-clic +// dans le Finder → expérience « application » (UI web + navigateur), exactement +// comme le double-clic sur jean.exe sous Windows. +// +// Le même binaire copié dans /usr/local/bin garde évidemment le comportement CLI. + +import ( + "os" + "path/filepath" + "strings" +) + +func setupConsole() bool { + exe, err := os.Executable() + if err != nil { + return true + } + if p, err := filepath.EvalSymlinks(exe); err == nil { + exe = p + } + // .../Jean.app/Contents/MacOS/jean + if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") { + return false + } + return true +} diff --git a/internal/jean/sys_console_other.go b/internal/jean/sys_console_other.go index 9d2242a..d8539b2 100644 --- a/internal/jean/sys_console_other.go +++ b/internal/jean/sys_console_other.go @@ -1,8 +1,10 @@ -//go:build !windows +//go:build !windows && !darwin package jean -// setupConsole : hors Windows, le process a toujours une vraie console/tty +// setupConsole : sous Linux, 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 ». +// console ». Les cas « lancé par un clic » sont traités par +// sys_console_windows.go (double-clic sur jean.exe) et sys_console_darwin.go +// (ouverture de Jean.app depuis le Finder). func setupConsole() bool { return true } diff --git a/internal/jean/sys_tray_darwin.go b/internal/jean/sys_tray_darwin.go new file mode 100644 index 0000000..a77e3df --- /dev/null +++ b/internal/jean/sys_tray_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package jean + +// sys_tray_darwin.go — icône de Jean dans la barre de menus macOS (en haut à +// droite, à côté de l'horloge), pendant exact du system tray Windows : quand on +// ouvre Jean.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec +// le menu « Ouvrir Jean » / « Quitter ». +// +// Contrairement à Windows, systray passe ici par Cocoa → CGO obligatoire : les +// binaires macOS sont donc compilés sur un runner macOS (voir release.yml), et +// le bundle est marqué LSUIElement (pas d'icône dans le Dock, uniquement la +// barre de menus). + +import ( + "os" + + "github.com/getlantern/systray" +) + +// runTray fait tourner l'icône de la barre de menus. Bloque jusqu'à « Quitter ». +// À appeler depuis la goroutine principale : Cocoa exige le thread main. +func runTray(url string) { + systray.Run(func() { + systray.SetIcon(trayIconPNG()) + systray.SetTooltip("Jean — votre IA locale") + mOpen := systray.AddMenuItem("Ouvrir Jean", "Ouvrir l'interface") + systray.AddSeparator() + mQuit := systray.AddMenuItem("Quitter", "Arrêter Jean") + + go func() { + for { + select { + case <-mOpen.ClickedCh: + _ = openBrowser(url) + case <-mQuit.ClickedCh: + systray.Quit() + return + } + } + }() + }, func() { + os.Exit(0) // « Quitter » → arrête tout le process (serveur inclus) + }) +} diff --git a/internal/jean/sys_tray_icon.go b/internal/jean/sys_tray_icon.go new file mode 100644 index 0000000..790f224 --- /dev/null +++ b/internal/jean/sys_tray_icon.go @@ -0,0 +1,69 @@ +//go:build windows || darwin + +package jean + +// sys_tray_icon.go — icône de Jean pour la zone de notification (Windows) et la +// barre de menus (macOS). RÉPLIQUE EXACTE du favicon de l'UI web : carré bleu +// arrondi #1f6feb + « j » blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). +// Générée à la volée — pas d'asset binaire à embarquer. + +import ( + "bytes" + "image" + "image/color" + "image/png" +) + +const trayIconSize = 32 + +// trayIconPNG rend l'icône en PNG 32x32 avec alpha (coins arrondis). +func trayIconPNG() []byte { + const n = 32 + blue := color.RGBA{0x1f, 0x6f, 0xeb, 0xff} + white := color.RGBA{0xff, 0xff, 0xff, 0xff} + clear := color.RGBA{0, 0, 0, 0} + const r = 2.0 // rayon des coins, en unités de la grille 12 + // rects blancs du favicon (x, y, w, h en unités 12) + rects := [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} + // hors du rectangle à coins arrondis (grille 12) → pixel transparent + outside := func(gx, gy float64) bool { + corner := func(cx, cy float64) bool { + dx, dy := gx-cx, gy-cy + return dx*dx+dy*dy > r*r + } + switch { + case gx < r && gy < r: + return corner(r, r) + case gx > 12-r && gy < r: + return corner(12-r, r) + case gx < r && gy > 12-r: + return corner(r, 12-r) + case gx > 12-r && gy > 12-r: + return corner(12-r, 12-r) + } + return false + } + img := image.NewRGBA(image.Rect(0, 0, n, n)) + scale := float64(n) / 12 + for y := 0; y < n; y++ { + for x := 0; x < n; x++ { + gx := (float64(x) + 0.5) / scale + gy := (float64(y) + 0.5) / scale + if outside(gx, gy) { + img.Set(x, y, clear) + continue + } + c := blue + for _, rc := range rects { + if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] { + c = white + } + } + img.Set(x, y, c) + } + } + + var pngBuf bytes.Buffer + _ = png.Encode(&pngBuf, img) + return pngBuf.Bytes() +} diff --git a/internal/jean/sys_tray_other.go b/internal/jean/sys_tray_other.go index 3aa7fea..9824225 100644 --- a/internal/jean/sys_tray_other.go +++ b/internal/jean/sys_tray_other.go @@ -1,11 +1,11 @@ -//go:build !windows +//go:build !windows && !darwin package jean -// sys_tray_other.go — pas d'icône de zone de notification hors Windows (la demande -// vise le tray Windows). On se contente d'ouvrir l'UI dans le navigateur et de -// garder le serveur en vie. Aucun import systray ici → les builds Linux/macOS -// restent purs (CGO_ENABLED=0). +// sys_tray_other.go — pas d'icône de zone de notification sous Linux (serveur +// sans session graphique la plupart du temps). On se contente d'ouvrir l'UI dans +// le navigateur et de garder le serveur en vie. Aucun import systray ici → les +// builds Linux restent purs (CGO_ENABLED=0). func runTray(url string) { select {} // garde le serveur en vie (Ctrl-C pour quitter) ; le navigateur est déjà ouvert par cmdApp diff --git a/internal/jean/sys_tray_windows.go b/internal/jean/sys_tray_windows.go index 15e47b6..89b09ca 100644 --- a/internal/jean/sys_tray_windows.go +++ b/internal/jean/sys_tray_windows.go @@ -8,15 +8,12 @@ package jean // Jean tourne et offre un menu « Ouvrir Jean » / « Quitter ». // // getlantern/systray est pur Go sur Windows (Win32 via x/sys, aucun CGO) — le -// binaire reste statique. L'import est confiné à ce fichier windows pour que les -// builds Linux/macOS (CGO_ENABLED=0) ne le touchent jamais. +// binaire reste statique. L'équivalent macOS vit dans sys_tray_darwin.go (Cocoa, +// donc CGO) ; les builds Linux ne touchent jamais systray. import ( "bytes" "encoding/binary" - "image" - "image/color" - "image/png" "os" "github.com/getlantern/systray" @@ -49,61 +46,12 @@ func runTray(url string) { }) } -// trayIcon génère l'icône (.ico) de Jean, RÉPLIQUE EXACTE du favicon de l'UI -// web : carré bleu arrondi #1f6feb + « j » blanc (rects (6,3) (6,5) (4,7) sur -// une grille 12x12). Générée à la volée — pas d'asset binaire à embarquer. -// Windows accepte un PNG (avec alpha pour les coins arrondis) dans le .ico. +// trayIcon emballe le PNG de trayIconPNG() dans un conteneur .ico (ICONDIR + +// une ICONDIRENTRY) : Windows accepte un PNG (avec alpha pour les coins +// arrondis) comme image d'un .ico. func trayIcon() []byte { - const n = 32 - blue := color.RGBA{0x1f, 0x6f, 0xeb, 0xff} - white := color.RGBA{0xff, 0xff, 0xff, 0xff} - clear := color.RGBA{0, 0, 0, 0} - const r = 2.0 // rayon des coins, en unités de la grille 12 - // rects blancs du favicon (x, y, w, h en unités 12) - rects := [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}} - // hors du rectangle à coins arrondis (grille 12) → pixel transparent - outside := func(gx, gy float64) bool { - corner := func(cx, cy float64) bool { - dx, dy := gx-cx, gy-cy - return dx*dx+dy*dy > r*r - } - switch { - case gx < r && gy < r: - return corner(r, r) - case gx > 12-r && gy < r: - return corner(12-r, r) - case gx < r && gy > 12-r: - return corner(r, 12-r) - case gx > 12-r && gy > 12-r: - return corner(12-r, 12-r) - } - return false - } - img := image.NewRGBA(image.Rect(0, 0, n, n)) - scale := float64(n) / 12 - for y := 0; y < n; y++ { - for x := 0; x < n; x++ { - gx := (float64(x) + 0.5) / scale - gy := (float64(y) + 0.5) / scale - if outside(gx, gy) { - img.Set(x, y, clear) - continue - } - c := blue - for _, rc := range rects { - if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] { - c = white - } - } - img.Set(x, y, c) - } - } - - var pngBuf bytes.Buffer - _ = png.Encode(&pngBuf, img) - p := pngBuf.Bytes() - - // Conteneur ICO : ICONDIR (6) + 1 ICONDIRENTRY (16) + PNG. + p := trayIconPNG() + const n = trayIconSize var ico bytes.Buffer binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône