Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
10 changes: 10 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions internal/chromium/candidates_darwin.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 4 additions & 2 deletions internal/chromium/candidates_unix.go
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 26 additions & 0 deletions internal/chromium/chromium.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions internal/chromium/chromium_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading