diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c38fc2d0..7621422c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,8 +140,66 @@ jobs: name: lich-windows path: bin/lich-* + mac: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: frontend/package.json + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install task + run: | + go install github.com/go-task/task/v3/cmd/task@latest + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + # dist must exist before the backend suite (go:embed). macOS is Unix, so + # this runs the real PTY tests on Apple Silicon; frontend tests already + # gate the linux job. + - name: Frontend install + build + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: Backend tests (macos) + run: bash .github/scripts/go-test-summary.sh macos + + - name: Build macOS binaries + run: task build:mac + + - name: Assemble macOS assets + run: | + cd bin + tag="${GITHUB_REF_NAME//\//-}" + mv lich-darwin-arm64 "lich-${tag}-darwin-arm64" + mv lich-darwin-amd64 "lich-${tag}-darwin-amd64" + ls -la lich-${tag}-* + + - name: Upload macOS assets + uses: actions/upload-artifact@v4 + with: + name: lich-macos + path: bin/lich-* + release: - needs: [linux, windows] + needs: [linux, windows, mac] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c8efe63..c58d8aa1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 npm's `claude.cmd` shim is spawned through `cmd.exe /c`. Releases now ship a `windows-amd64.exe` asset built — and backend-tested — on a Windows runner in parallel with the Linux packages. +- Experimental macOS build (`lich-*-darwin-arm64`, `lich-*-darwin-amd64`, + `task build:mac`). macOS is Unix, so the terminal (creack/pty), the shell and + the native folder picker already work through the shared seams; only the + Chromium launcher gained a macOS list, finding Chrome/Chromium/Edge/Brave in + their `.app` bundles under `/Applications` and `~/Applications` (they never + land on PATH). Releases now ship both-arch darwin binaries, built and + backend-tested — the PTY included, on a real macOS runner — alongside the + Linux and Windows jobs. Unsigned: Gatekeeper quarantines them until + notarization ships (see the README/Known Ceilings). ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index d94c862e5..4e571d3da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,11 @@ product, it is a bespoke tool. Linux first; an experimental Windows build ships small seams, never by runtime checks — the PTY is the model (`internal/terminal`'s `startPTY`: creack/pty on Unix, ConPTY on Windows, where npm's `claude.cmd` shim runs through `cmd.exe /c`). - **Shell**: system Chromium-family browser launched in `--app` mode (`internal/chromium`), persistent profile under - the user config dir (`~/.config/lich/chromium-profile`; `%AppData%\lich` on Windows). Window closed = app exit. - Runtime needs: a Chromium-family browser — chromium/chrome/brave on PATH on Linux, plus zenity for the folder - picker; chrome/edge/brave via their conventional install paths on Windows (picker is native win32, and Edge being - everywhere guarantees a window). + the user config dir (`~/.config/lich/chromium-profile`; `%AppData%\lich` on Windows; `~/Library/Application + Support/lich` on macOS — all via `os.UserConfigDir`). Window closed = app exit. Runtime needs: a Chromium-family + browser — chromium/chrome/brave on PATH on Linux, plus zenity for the folder picker; chrome/edge/brave via their + conventional install paths on Windows (picker is native win32, and Edge being everywhere guarantees a window); + Chrome/Chromium/Edge/Brave in their `.app` bundles on macOS (picker is osascript, built in). - **Frontend**: React 18 + TypeScript + Vite. Terminal is xterm.js 6 + `@xterm/addon-webgl`. Service shapes are hand-owned in `frontend/src/lib/api-types.ts` (mirrors of the Go structs' JSON tags — keep in sync). - **Build/tasks**: [Task](https://taskfile.dev) — see Commands. @@ -151,3 +152,13 @@ Deliberate limits and shortcuts, with the upgrade path when it matters: before the tag can narrow), and the shell session is `COMSPEC`/cmd.exe — no PowerShell preference yet. The build is GUI subsystem (`-H=windowsgui`): no console rides along, stdout/stderr go nowhere, and `%AppData%\lich\lich.log` is the diagnostic surface — "double-clicked and nothing happened" means read the log. +- **macOS is experimental.** What holds it up: the codebase is pure Go and cross-compiles for darwin, and because + macOS is Unix the terminal (creack/pty), shell and folder picker (osascript, via ncruces/zenity) already run + through the shared `!windows` seams — the only macOS-specific code is `candidates_darwin.go`, which finds + Chrome/Chromium/Edge/Brave in their `.app` bundles under `/Applications` and `~/Applications` (macOS installs + never land on PATH). The backend suite — the real PTY tests included — runs on a `macos-latest` (Apple Silicon) + runner every release, and both-arch raw binaries (`darwin-arm64`, `darwin-amd64`) ship as assets. What's missing: + no code signing or notarization (Gatekeeper quarantines an unsigned binary — right-click-Open or + `xattr -d com.apple.quarantine com.apple.provenance` until an Apple Developer cert + notarization ship), no + `.dmg`/Homebrew packaging (raw binaries only, like the portable Windows exe), and the window path has not been + hand-smoke-tested on real hardware yet. diff --git a/Taskfile.yml b/Taskfile.yml index 093040485..397002a2f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -33,6 +33,16 @@ tasks: # nowhere — the persistent lich.log is the diagnostic surface. - cmd: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H=windowsgui" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe . + build:mac: + summary: Cross-compiles the macOS binaries — arm64 + amd64 (experimental) + deps: [build:frontend] + cmds: + # Both arches: Apple Silicon (arm64) and Intel (amd64). Pure Go, so both + # cross-compile from any host. Unsigned — Gatekeeper quarantines until + # notarization ships (see CLAUDE.md Known Ceilings). + - cmd: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build {{.GO_BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-darwin-arm64 . + - cmd: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build {{.GO_BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-darwin-amd64 . + run: summary: Builds and runs the application deps: [build] diff --git a/internal/chromium/candidates_darwin.go b/internal/chromium/candidates_darwin.go new file mode 100644 index 000000000..7d44ab785 --- /dev/null +++ b/internal/chromium/candidates_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +package chromium + +import "os" + +// browserCandidates lists candidate browsers in preference order. macOS +// installs land in .app bundles under /Applications, not on PATH, so the list +// is absolute paths built from the install roots (darwinBrowserCandidates in +// chromium.go, where the logic stays testable from any OS). +func browserCandidates() []string { + return darwinBrowserCandidates(os.Getenv) +} diff --git a/internal/chromium/candidates_unix.go b/internal/chromium/candidates_unix.go index cf2fc121c..f9eaa7496 100644 --- a/internal/chromium/candidates_unix.go +++ b/internal/chromium/candidates_unix.go @@ -1,10 +1,12 @@ -//go:build !windows +//go:build !windows && !darwin package chromium // browserCandidates lists candidate binaries in preference order. Any // Chromium gives the same compositor; the preference only picks the most -// conventional install. All bare names — Unix installs live on PATH. +// conventional install. All bare names — Linux/BSD installs live on PATH. +// macOS keeps browsers in .app bundles off PATH, so it has its own list +// (candidates_darwin.go). func browserCandidates() []string { return []string{ "chromium", diff --git a/internal/chromium/chromium.go b/internal/chromium/chromium.go index 680e129c2..da03b5402 100644 --- a/internal/chromium/chromium.go +++ b/internal/chromium/chromium.go @@ -49,6 +49,32 @@ func windowsBrowserCandidates(getenv func(string) string) []string { return append(out, "chrome", "msedge") } +// darwinBrowserCandidates builds the macOS candidate list: chrome, then +// chromium, then edge, then brave, each as its .app executable under the +// system (/Applications) and per-user (~/Applications) install roots, with +// bare PATH names last for a Homebrew-formula install. Paths are joined with a +// literal slash so the pure logic tests the same on any OS. Kept out of the +// build-tagged file for exactly that reason. +func darwinBrowserCandidates(getenv func(string) string) []string { + apps := []string{ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "Brave Browser.app/Contents/MacOS/Brave Browser", + } + roots := []string{"/Applications"} + if home := getenv("HOME"); home != "" { + roots = append(roots, home+"/Applications") + } + var out []string + for _, app := range apps { + for _, root := range roots { + out = append(out, root+"/"+app) + } + } + return append(out, "chromium", "google-chrome") +} + // Args builds the --app invocation. The dedicated user-data-dir is // load-bearing twice over: without it Chromium adopts the window into an // already-running instance (the spawned process exits immediately, breaking diff --git a/internal/chromium/chromium_test.go b/internal/chromium/chromium_test.go index 173f9a036..c9d1b4c3c 100644 --- a/internal/chromium/chromium_test.go +++ b/internal/chromium/chromium_test.go @@ -70,6 +70,52 @@ func TestWindowsBrowserCandidatesEmptyEnv(t *testing.T) { } } +// TestDarwinBrowserCandidates proves the macOS list expands each browser under +// both the system and per-user Applications roots, prefers chrome > chromium > +// edge > brave, and ends with the bare PATH names. +func TestDarwinBrowserCandidates(t *testing.T) { + got := darwinBrowserCandidates(func(k string) string { + if k == "HOME" { + return "/Users/u" + } + return "" + }) + + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Users/u/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Users/u/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Users/u/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Users/u/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "chromium", + "google-chrome", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + +// TestDarwinBrowserCandidatesNoHome proves a missing HOME drops the per-user +// root but still leaves the system paths and PATH names, so FindBrowser never +// iterates an empty list. +func TestDarwinBrowserCandidatesNoHome(t *testing.T) { + got := darwinBrowserCandidates(func(string) string { return "" }) + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "chromium", + "google-chrome", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + func TestArgs(t *testing.T) { args := Args("http://127.0.0.1:47821/?token=x", "/home/u/.config/lich/chromium-profile", "lichdev", []string{"--ozone-platform=wayland"}) for _, want := range []string{