v0.5.4 : app macOS Jean.app (clic = UI web + icone barre de menus)

- Jean.app publie par la CI (zip par archi) : ouverture depuis le Finder ->
  mode application, comme le double-clic sur jean.exe sous Windows
- icone dans la barre de menus macOS (systray/Cocoa) avec Ouvrir/Quitter,
  bundle marque LSUIElement (pas d'icone Dock)
- builds darwin deplacees sur un runner macOS (CGO requis pour Cocoa)
- signature ad-hoc du bundle + icone .icns
This commit is contained in:
nathaninline committed 2026-07-25 10:23:33 +02:00
1 parent 2f1f40c604
commit ac171e5461
12 files changed
+261 -75

No files matched your search

+1 -1
View File
@@ -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() {
+36
View File
@@ -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
}
+5 -3
View File
@@ -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 }
+45
View File
@@ -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)
})
}
+69
View File
@@ -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()
}
+5 -5
View File
@@ -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
+7 -59
View File
@@ -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