diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c9c99c3..1fc17ae6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- On macOS, lich has a menu-bar item: a lich whose window is closed shows it is still running its sessions there, with Show lich, the count of running sessions and Quit, as the tray does on Linux and Windows. + ### Changed - The quick filters over the pull request list in Pulls (All, Ready, Drafts, Failing) are lighter: small text with the selected one shaded, as in the dock's tabs, sharing the list's width. A filter with nothing in it is faded and Failing's count turns red when a check fails. They no longer run past the list's edge with a monospace interface font. @@ -15,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A session card no longer grows and shrinks as its agent starts and stops a tool, waits on you or compacts, pushing every card below it up and down. That line now takes the place of the card's path while it shows, and the path comes back when it goes. - The native window holds less memory for its fonts. A font file asked for more than once, such as the interface and the terminal set in the same family or the bold face that also stands in for semibold, was parsed again each time, about 30MB for a Nerd Font face; it is now parsed once and shared while in use. An interface in a language other than Chinese, Japanese or Korean no longer loads a CJK font at start, tens of megabytes: it does on the first CJK text on screen, drawn meanwhile with a system font. With one Nerd Font for the interface, the terminal and the editor and no CJK on screen, the fonts take 119MB where they took 246MB. +- On macOS, opening lich from the Dock, Launchpad, Spotlight or Finder while it runs with its window closed opens the window again. lich started at login with `--no-window` included, where the click did nothing. Logging out now quits lich the way `lich quit` does, so the next launch does not read it as a crash. ## [0.78.1] - 2026-10-23 diff --git a/build/darwin/Info.plist.tpl b/build/darwin/Info.plist.tpl index d6ed174c9..e8030ca8b 100644 --- a/build/darwin/Info.plist.tpl +++ b/build/darwin/Info.plist.tpl @@ -3,7 +3,8 @@ Rendered by build/darwin/bundle.sh (@VERSION@ -> the release version). Two executables share this plist: lich, the pure-Go binary LaunchServices - starts, which never touches AppKit, and lich-native, the window, which does. + starts, which reaches AppKit through purego for the menu-bar item and the + reopen (internal/appkit), and lich-native, the window. macOS reads a process's bundle off its executable's path, so the window in Contents/MacOS is Lich.app to the Dock, to Cmd-Tab and to the menu bar: its icon is CFBundleIconFile and its name CFBundleName. diff --git a/docs/ceilings.md b/docs/ceilings.md index e49c0243e..10992ee3d 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -840,8 +840,11 @@ work when nobody knows it and that the call site never shows. The mechanism and icon (below) is all that says it runs. Launching lich again opens a window on it; `lich quit` (or SIGINT / SIGTERM) ends it. `task dev` behaves the same: kept running, the dev backend stays on 47822 until Ctrl+C or `LICH_DEV=1 go run . quit`. -- **The tray icon is Linux and Windows only, and late** (`internal/tray`): macOS's menu-bar item needs cgo - and lich is built without it, so a windowless lich on a Mac shows nothing. On Linux it is a +- **The tray icon is late, and on macOS it rides AppKit through purego** (`internal/tray`, `internal/appkit`): + lich is built without cgo, so on a Mac the backend runs AppKit's loop on its main thread through purego, as + an accessory app (no Dock tile, no Cmd-Tab), and the menu-bar item and LaunchServices' reopen and terminate + live on that loop. A Mac where AppKit does not load runs lich without either, and opening lich again from + Finder then depends on LaunchServices launching a second copy. On Linux it is a StatusNotifierItem: GNOME draws none without the AppIndicator extension, and a desktop with no tray host shows nothing either. Its words are the window's interface language, so it comes up once a window has handed them over (`native/cmd/lich-native/tray.go`); a lich whose window never opened has no tray. `task dev` puts up a second diff --git a/go.mod b/go.mod index 4349b0fd6..e2f462549 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/UserExistsError/conpty v0.1.4 github.com/coder/websocket v1.8.14 github.com/creack/pty v1.1.24 + github.com/ebitengine/purego v0.11.2 github.com/lichdotdev/godemirror v0.8.1 github.com/ncruces/zenity v0.10.14 golang.org/x/image v0.47.0 diff --git a/go.sum b/go.sum index f736f5898..4220687d0 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pO github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= +github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= diff --git a/internal/appkit/appkit_darwin.go b/internal/appkit/appkit_darwin.go new file mode 100644 index 000000000..6ee6ee307 --- /dev/null +++ b/internal/appkit/appkit_darwin.go @@ -0,0 +1,132 @@ +package appkit + +import ( + "log/slog" + "os" + "runtime" + "sync" + + "github.com/ebitengine/purego" + "github.com/ebitengine/purego/objc" +) + +// AppKit runs the main thread's loop only if the main goroutine still holds +// that thread when Run is called, and an init is the one place to pin it. +func init() { + runtime.LockOSThread() +} + +const appKitPath = "/System/Library/Frameworks/AppKit.framework/AppKit" + +// NSApplicationActivationPolicyAccessory keeps the backend off the Dock and +// out of Cmd-Tab: the window, lich-native, is the app a person sees there. +const activationPolicyAccessory = 1 + +// NSTerminateLater holds the logout until lich has quit on its own, through +// the same quit `lich quit` runs, instead of AppKit's exit skipping it. +const terminateLater = 2 + +var ( + mu sync.Mutex + reopen func() + terminate func() + queue []func() + // runner is the object Do hands its queue to on the main thread; zero + // until Run brought AppKit up. + runner objc.ID +) + +// Handle sets what a reopen (lich opened again while it runs) and a terminate +// (logout, shutdown) do. Either one arriving before Handle is dropped. +func Handle(onReopen, onTerminate func()) { + mu.Lock() + defer mu.Unlock() + reopen, terminate = onReopen, onTerminate +} + +// Run runs app beside AppKit's loop and exits when app returns. A Mac where +// AppKit cannot load runs app alone, with no reopen and no menu-bar item. +func Run(app func()) { + nsApp, err := start() + if err != nil { + slog.Warn("appkit unavailable: no reopen and no menu-bar item", "err", err) + app() + return + } + go func() { + app() + os.Exit(0) + }() + nsApp.Send(objc.RegisterName("run")) +} + +// Do runs fn on the main thread, where every AppKit call has to be made. It +// does not wait for fn. +func Do(fn func()) { + mu.Lock() + r := runner + if r != 0 { + queue = append(queue, fn) + } + mu.Unlock() + if r == 0 { + slog.Warn("appkit: not running, dropped a main-thread call") + return + } + r.Send(objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:"), + objc.RegisterName("drain:"), objc.ID(0), false) +} + +func start() (objc.ID, error) { + if _, err := purego.Dlopen(appKitPath, purego.RTLD_GLOBAL|purego.RTLD_LAZY); err != nil { + return 0, err + } + class, err := objc.RegisterClass("LichAppDelegate", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{ + {Cmd: objc.RegisterName("applicationShouldHandleReopen:hasVisibleWindows:"), Fn: handleReopen}, + {Cmd: objc.RegisterName("applicationShouldTerminate:"), Fn: shouldTerminate}, + {Cmd: objc.RegisterName("drain:"), Fn: drain}, + }) + if err != nil { + return 0, err + } + delegate := objc.ID(class).Send(objc.RegisterName("new")) + nsApp := objc.ID(objc.GetClass("NSApplication")).Send(objc.RegisterName("sharedApplication")) + nsApp.Send(objc.RegisterName("setActivationPolicy:"), activationPolicyAccessory) + nsApp.Send(objc.RegisterName("setDelegate:"), delegate) + mu.Lock() + runner = delegate + mu.Unlock() + return nsApp, nil +} + +// handleReopen answers NO: lich opens its window itself, off the main thread, +// since opening one may start a process. +func handleReopen(_ objc.ID, _ objc.SEL, _ objc.ID, _ bool) bool { + mu.Lock() + fn := reopen + mu.Unlock() + if fn != nil { + go fn() + } + return false +} + +func shouldTerminate(_ objc.ID, _ objc.SEL, _ objc.ID) uint { + mu.Lock() + fn := terminate + mu.Unlock() + if fn != nil { + go fn() + } + return terminateLater +} + +func drain(_ objc.ID, _ objc.SEL, _ objc.ID) { + mu.Lock() + fns := queue + queue = nil + mu.Unlock() + for _, fn := range fns { + fn() + } +} diff --git a/internal/appkit/appkit_other.go b/internal/appkit/appkit_other.go new file mode 100644 index 000000000..f1743b843 --- /dev/null +++ b/internal/appkit/appkit_other.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package appkit + +// Run runs app: only macOS has an application loop lich has to run beside. +func Run(app func()) { app() } + +// Handle does nothing: reopen and terminate are LaunchServices' events. +func Handle(onReopen, onTerminate func()) {} diff --git a/internal/appkit/doc.go b/internal/appkit/doc.go new file mode 100644 index 000000000..422131f65 --- /dev/null +++ b/internal/appkit/doc.go @@ -0,0 +1,7 @@ +// Package appkit makes lich's backend a macOS application, through purego +// rather than cgo: it runs AppKit's event loop on the main thread, which +// AppKit insists on, and the backend beside it. That loop is what hears +// LaunchServices: opening lich again while it runs reaches this process as a +// reopen, and logging out as a terminate. It is also the thread the menu-bar +// item lives on (internal/tray). +package appkit diff --git a/internal/tray/native_darwin.go b/internal/tray/native_darwin.go index ea677f51b..89b5207d9 100644 --- a/internal/tray/native_darwin.go +++ b/internal/tray/native_darwin.go @@ -1,5 +1,138 @@ package tray -// startNative puts nothing up on macOS: its menu-bar item needs cgo, and lich is -// built without it (docs/ceilings.md). -func startNative(*Tray) {} +import ( + "log/slog" + "runtime" + "structs" + "time" + "unsafe" + + "github.com/ebitengine/purego/objc" + "github.com/lichdotdev/lich/internal/appkit" +) + +// NSVariableStatusItemLength: the item is as wide as its icon. +const variableStatusItemLength = -1.0 + +// iconPoints is the menu bar's icon size; the bar is 22 points tall. +const iconPoints = 18 + +type nsSize struct { + _ structs.HostLayout + Width, Height float64 +} + +// startNative puts the menu-bar item up on the main thread, where AppKit +// runs (internal/appkit). +func startNative(t *Tray) { + appkit.Do(t.ready) +} + +func (t *Tray) icon() []byte { return t.icons.PNG } + +// menu is the item's menu entries. +type menu struct { + show, running, quit objc.ID +} + +func (t *Tray) ready() { + target, err := t.target() + if err != nil { + slog.Warn("tray: menu target", "err", err) + return + } + labels := t.currentLabels() + item := objc.ID(objc.GetClass("NSStatusBar")).Send(sel("systemStatusBar")). + Send(sel("statusItemWithLength:"), variableStatusItemLength) + // The status bar drops an item nothing holds. + item.Send(sel("retain")) + button := item.Send(sel("button")) + button.Send(sel("setImage:"), statusImage(t.icon())) + button.Send(sel("setToolTip:"), nsString("lich")) + + m := menu{ + show: menuItem(labels.Show, sel("show:"), target), + running: menuItem("", 0, 0), + quit: menuItem(labels.Quit, sel("quit:"), target), + } + nsMenu := objc.ID(objc.GetClass("NSMenu")).Send(sel("new")) + nsMenu.Send(sel("addItem:"), m.show) + nsMenu.Send(sel("addItem:"), m.running) + nsMenu.Send(sel("addItem:"), objc.ID(objc.GetClass("NSMenuItem")).Send(sel("separatorItem"))) + nsMenu.Send(sel("addItem:"), m.quit) + item.Send(sel("setMenu:"), nsMenu) + + t.setRelabel(func(l Labels) { + appkit.Do(func() { + m.show.Send(sel("setTitle:"), nsString(l.Show)) + m.quit.Send(sel("setTitle:"), nsString(l.Quit)) + }) + t.refresh(m, l) + }) + t.refresh(m, labels) + go t.serve(m) +} + +// target registers the object the menu's entries call, which runs the tray's +// show and quit off the main thread. +func (t *Tray) target() (objc.ID, error) { + class, err := objc.RegisterClass("LichTrayTarget", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{ + {Cmd: sel("show:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { go t.show() }}, + {Cmd: sel("quit:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { + go func() { + if err := t.quit(); err != nil { + slog.Warn("tray: quit", "err", err) + } + }() + }}, + }) + if err != nil { + return 0, err + } + return objc.ID(class).Send(sel("new")), nil +} + +// refresh rewrites the session count. A count it cannot read leaves the title +// as it was rather than show a wrong one. +func (t *Tray) refresh(m menu, l Labels) { + live, err := t.live() + if err != nil { + slog.Warn("tray: count the running sessions", "err", err) + return + } + title := l.running(live) + appkit.Do(func() { m.running.Send(sel("setTitle:"), nsString(title)) }) +} + +func (t *Tray) serve(m menu) { + ticker := time.NewTicker(refreshEvery) + defer ticker.Stop() + for range ticker.C { + t.refresh(m, t.currentLabels()) + } +} + +// menuItem is an entry calling action on target; one with no action is drawn +// disabled, which is what the session count is. +func menuItem(title string, action objc.SEL, target objc.ID) objc.ID { + item := objc.ID(objc.GetClass("NSMenuItem")).Send(sel("alloc")). + Send(sel("initWithTitle:action:keyEquivalent:"), nsString(title), action, nsString("")) + if target != 0 { + item.Send(sel("setTarget:"), target) + } + return item +} + +func statusImage(png []byte) objc.ID { + data := objc.ID(objc.GetClass("NSData")).Send(sel("dataWithBytes:length:"), unsafe.Pointer(&png[0]), uint(len(png))) + runtime.KeepAlive(png) + image := objc.ID(objc.GetClass("NSImage")).Send(sel("alloc")).Send(sel("initWithData:"), data) + image.Send(sel("setSize:"), nsSize{Width: iconPoints, Height: iconPoints}) + return image +} + +func nsString(s string) objc.ID { + return objc.ID(objc.GetClass("NSString")).Send(sel("stringWithUTF8String:"), s) +} + +func sel(name string) objc.SEL { return objc.RegisterName(name) } diff --git a/internal/tray/tray.go b/internal/tray/tray.go index 49fe9bc7d..8e0491e68 100644 --- a/internal/tray/tray.go +++ b/internal/tray/tray.go @@ -51,7 +51,8 @@ type Tray struct { } // Icons are the two encodings the platforms take: PNG for the Linux -// StatusNotifierItem, ICO for the Windows notification area. +// StatusNotifierItem and the macOS menu bar, ICO for the Windows notification +// area. type Icons struct { PNG []byte ICO []byte diff --git a/main.go b/main.go index 996ad228c..116deb298 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/agentplugin" + "github.com/lichdotdev/lich/internal/appkit" "github.com/lichdotdev/lich/internal/appupdate" "github.com/lichdotdev/lich/internal/claudeprofile" "github.com/lichdotdev/lich/internal/cli" @@ -99,7 +100,13 @@ func main() { if code := cli.Run(os.Args[1:], version, os.Getenv, os.Stdout, os.Stderr); code != cli.NotACommand { os.Exit(code) } + // On macOS AppKit's loop takes the main thread, and the app runs beside it + // (internal/appkit). + appkit.Run(app) +} +// app is lich itself: the backend, its listener and its window. +func app() { noWindow := slices.Contains(os.Args[1:], restart.NoWindowArg) configDir, err := os.UserConfigDir() @@ -337,6 +344,13 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub Failed: windowFailed(configDir, liveCount, coord.Quit), }) routes.System(dispatcher, sys) + // macOS opens lich again by telling the running one, and ends it at logout + // the same way. + appkit.Handle(func() { showWindow(sys, "on reopen") }, func() { + if err := coord.Quit(); err != nil { + slog.Warn("quit at logout", "err", err) + } + }) // Deleting a session for good takes any prompt parked on it, and the row is // the only copy of what the user wrote. Both channels are used, and neither // is the other's fallback: the toast is what the user reading the card sees,