diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcf2924c..fb8745592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Settings gained a "Project" group with a per-project Claude Code binary + override. The backend already resolved project → global → `$PATH`; the + override just had no UI. + +### Fixed + +- Terminal sessions no longer inherit the AppImage's runtime environment. + Beyond the vars stripped in #3, `childEnv` now drops the AppImageLauncher + vars and the `WEBKIT_DISABLE_*` pair, and scrubs mount paths out of + `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GDK_PIXBUF_MODULE_FILE` (and any + future path list) — the bundled Ubuntu libs broke linkers and GTK apps + launched from a lich terminal. User-set entries survive; outside an AppImage + the environment passes through untouched. +- The `GDK_BACKEND=x11` forced at startup no longer leaks into spawned + sessions: the session environment is snapshotted before the GTK tweak. +- `task dev` now opens alongside an installed lich: dev instances register a + distinct GTK application ID (`lichdev`), so GTK single-instance no longer + swallows the dev window when the AppImage is running. + ## [0.1.1] - 2026-07-12 ### Fixed diff --git a/frontend/src/components/settings/ProjectClaudeCodeSettings.tsx b/frontend/src/components/settings/ProjectClaudeCodeSettings.tsx new file mode 100644 index 000000000..f409ed006 --- /dev/null +++ b/frontend/src/components/settings/ProjectClaudeCodeSettings.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react" +import { Service as Store } from "../../../bindings/github.com/omartelo/lich/internal/store" +import { useProjects } from "@/lib/projects" +import { SettingBlock } from "./SettingBlock" + +// Same key as the global section; the scope (project id) is what differs. An +// empty override inherits the global value — the backend resolves project → +// global → $PATH in store.ClaudeBin. +const GLOBAL_SCOPE = "" +const CLAUDE_BIN_KEY = "claude.bin" + +export function ProjectClaudeCodeSettings() { + const { projects } = useProjects() + const [globalBin, setGlobalBin] = useState("") + const [bins, setBins] = useState>({}) + + useEffect(() => { + void Store.GetSetting(CLAUDE_BIN_KEY, GLOBAL_SCOPE).then(setGlobalBin) + for (const project of projects) { + void Store.GetSetting(CLAUDE_BIN_KEY, project.id).then((value) => + setBins((prev) => ({ ...prev, [project.id]: value })), + ) + } + }, [projects]) + + const persist = (projectId: string, value: string) => { + setBins((prev) => ({ ...prev, [projectId]: value })) + void Store.SetSetting(CLAUDE_BIN_KEY, projectId, value.trim()) + } + + if (projects.length === 0) { + return ( +

+ No open projects. Open a project to configure its overrides. +

+ ) + } + + return ( + +
+ {projects.map((project) => ( +
+
{project.name}
+

{project.path}

+ persist(project.id, event.target.value)} + placeholder={globalBin || "claude"} + spellCheck={false} + aria-label={`Claude Code path for ${project.name}`} + className="h-9 w-96 max-w-full rounded-md border border-border bg-background px-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/settings/Settings.tsx b/frontend/src/components/settings/Settings.tsx index 4bcc79b3a..c4e9d0c71 100644 --- a/frontend/src/components/settings/Settings.tsx +++ b/frontend/src/components/settings/Settings.tsx @@ -4,20 +4,29 @@ import { Search } from "lucide-react" import { TerminalSettings } from "./TerminalSettings" import { AppearanceSettings } from "./AppearanceSettings" import { ClaudeCodeSettings } from "./ClaudeCodeSettings" +import { ProjectClaudeCodeSettings } from "./ProjectClaudeCodeSettings" import { HotkeysSettings } from "./HotkeysSettings" import { cn } from "@/lib/utils" // SECTIONS is the settings registry: adding a category is one new file under // components/settings/ plus one entry here — the nav and the content pane both -// derive from this list. +// derive from this list. "app" sections apply globally; "project" sections hold +// per-project overrides and render under a "Project" group header in the nav. const SECTIONS = [ - { id: "appearance", label: "Appearance", Component: AppearanceSettings }, - { id: "terminal", label: "Terminal", Component: TerminalSettings }, - { id: "hotkeys", label: "Hotkeys", Component: HotkeysSettings }, - { id: "claude-code", label: "Claude Code", Component: ClaudeCodeSettings }, + { id: "appearance", label: "Appearance", group: "app", Component: AppearanceSettings }, + { id: "terminal", label: "Terminal", group: "app", Component: TerminalSettings }, + { id: "hotkeys", label: "Hotkeys", group: "app", Component: HotkeysSettings }, + { id: "claude-code", label: "Claude Code", group: "app", Component: ClaudeCodeSettings }, + { + id: "project-claude-code", + label: "Claude Code", + group: "project", + Component: ProjectClaudeCodeSettings, + }, ] as const satisfies ReadonlyArray<{ id: string label: string + group: "app" | "project" Component: ComponentType }> @@ -33,9 +42,25 @@ export function Settings() { const filtered = SECTIONS.filter((section) => section.label.toLowerCase().includes(query.toLowerCase()), ) + const appSections = filtered.filter((section) => section.group === "app") + const projectSections = filtered.filter((section) => section.group === "project") const activeSection = SECTIONS.find((section) => section.id === active) const ActiveComponent = activeSection?.Component + const navButton = (section: (typeof SECTIONS)[number]) => ( + + ) + return (
diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 96b7aeede..67be93419 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -47,15 +47,24 @@ type Service struct { mu sync.Mutex sessions map[string]*session bins BinResolver + // env is the environment every spawned session inherits: the launch + // environment cleaned of AppImage runtime leakage (see childEnv), plus TERM. + env []string // ws is the local WebSocket transport for terminal I/O (see transport.go); // nil when it failed to start, leaving the Wails event bridge as the path. ws *transport } // New returns a ready-to-use terminal service that resolves the binary to spawn -// through bins. -func New(bins BinResolver) *Service { - s := &Service{sessions: make(map[string]*session), bins: bins} +// through bins. env is the process environment to derive session environments +// from — callers pass a snapshot taken before any os.Setenv tweaks (main.go +// forces GDK_BACKEND on Linux) so those never leak into spawned shells. +func New(bins BinResolver, env []string) *Service { + s := &Service{ + sessions: make(map[string]*session), + bins: bins, + env: append(childEnv(env), "TERM=xterm-256color"), + } ws, err := newTransport(func(id string, data []byte) { _ = s.writeBytes(id, data) }) if err == nil { s.ws = ws @@ -96,25 +105,92 @@ func resolveCommand(kind, bin, shellEnv string) string { return resolveBin(bin) } -// appImageVars are set by the AppImage runtime and must not leak into the shell: -// ARGV0 (the .AppImage's invocation name) makes mise/asdf-style shims misread the -// shell as an invalid shim, and the rest are runtime internals a child never needs. -var appImageVars = map[string]bool{"ARGV0": true, "APPIMAGE": true, "APPDIR": true, "OWD": true} +// appImageVars are injected by the AppImage runtime, AppImageLauncher or our +// AppRun (build/linux/appimage/fix-appimage.sh) and must not leak into the +// shell: ARGV0 (the .AppImage's invocation name) makes mise/asdf-style shims +// misread the shell as an invalid shim, the WEBKIT_ pair would run any +// WebKitGTK app launched from the terminal without its sandbox, and the rest +// are runtime internals a child never needs. +var appImageVars = map[string]bool{ + "ARGV0": true, + "APPIMAGE": true, + "APPDIR": true, + "OWD": true, + "TARGET_APPIMAGE": true, + "REDIRECT_APPIMAGE": true, + "DESKTOPINTEGRATION": true, + "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS": true, + "WEBKIT_DISABLE_DMABUF_RENDERER": true, +} -// childEnv strips AppImage runtime variables from env so spawned shells inherit a -// clean environment. Outside an AppImage none are present and env is returned as-is. +// childEnv returns env cleaned of everything the AppImage runtime injected, so +// spawned shells inherit the environment lich itself was launched with. Outside +// an AppImage (no APPDIR — the deb/rpm/dev case) env is returned unchanged. +// Inside one, appImageVars are dropped and every value is scrubbed of +// colon-separated path entries under the AppImage mount — AppRun prepends the +// mount to LD_LIBRARY_PATH, PATH and XDG_DATA_DIRS, and its bundled Ubuntu libs +// break linkers and GTK apps run from the terminal. Entries the user set +// survive verbatim, so a pre-existing LD_LIBRARY_PATH keeps working. func childEnv(env []string) []string { + appdir := "" + for _, kv := range env { + if v, ok := strings.CutPrefix(kv, "APPDIR="); ok { + appdir = v + break + } + } + if appdir == "" { + return env + } out := make([]string, 0, len(env)) for _, kv := range env { - key, _, _ := strings.Cut(kv, "=") + key, value, _ := strings.Cut(kv, "=") if appImageVars[key] { continue } - out = append(out, kv) + scrubbed, changed := scrubPathList(value, appdir) + switch { + case changed && scrubbed == "": + // The variable only pointed into the mount (AppRun's "${VAR:-}" + // expansion can also leave a dangling empty entry): drop it. + case changed: + out = append(out, key+"="+scrubbed) + default: + out = append(out, kv) + } } return out } +// scrubPathList drops colon-separated entries of value that live under dir. +// changed reports whether anything was dropped; values without such entries are +// returned untouched, so non-path variables are never rewritten. When only +// empty entries remain the returned value is "". +func scrubPathList(value, dir string) (string, bool) { + if !strings.Contains(value, dir) { + return value, false + } + var kept []string + changed, nonEmpty := false, false + for entry := range strings.SplitSeq(value, ":") { + if entry == dir || strings.HasPrefix(entry, dir+"/") { + changed = true + continue + } + if entry != "" { + nonEmpty = true + } + kept = append(kept, entry) + } + if !changed { + return value, false + } + if !nonEmpty { + return "", true + } + return strings.Join(kept, ":"), true +} + // Start spawns the binary for session id under project projectID — the user's // shell when kind is "shell", otherwise the Claude Code binary resolved from the // project's settings (falling back to "claude" via $PATH) — attached to a new @@ -139,7 +215,7 @@ func (s *Service) Start(id, projectID, cwd, kind string, cols, rows int) error { cmd := exec.Command(resolveCommand(kind, s.bins.ClaudeBin(projectID), os.Getenv("SHELL"))) cmd.Dir = cwd - cmd.Env = append(childEnv(os.Environ()), "TERM=xterm-256color") + cmd.Env = s.env ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) if err != nil { diff --git a/internal/terminal/terminal_test.go b/internal/terminal/terminal_test.go index 5d07719cb..dd65be5e6 100644 --- a/internal/terminal/terminal_test.go +++ b/internal/terminal/terminal_test.go @@ -39,10 +39,98 @@ func TestChildEnvStripsAppImageVars(t *testing.T) { } } +// TestChildEnvOutsideAppImageIsUntouched proves the deb/rpm/dev case: without +// APPDIR nothing is dropped or rewritten, even values that look like mount +// paths or AppImage-ish keys the user happens to have set. +func TestChildEnvOutsideAppImageIsUntouched(t *testing.T) { + in := []string{ + "HOME=/home/user", + "LD_LIBRARY_PATH=/opt/lib:/tmp/.mount_other/usr/lib", + "WEBKIT_DISABLE_DMABUF_RENDERER=1", + } + got := childEnv(in) + if strings.Join(got, "\n") != strings.Join(in, "\n") { + t.Errorf("childEnv without APPDIR rewrote env:\n%v\nwant\n%v", got, in) + } +} + +// TestChildEnvScrubsMountPaths proves path lists lose only the entries under +// the AppImage mount: user-set entries survive, values reduced to nothing are +// dropped, and unrelated values are never rewritten. +func TestChildEnvScrubsMountPaths(t *testing.T) { + const mount = "/tmp/.mount_lich" + in := []string{ + "APPDIR=" + mount, + "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1", + "WEBKIT_DISABLE_DMABUF_RENDERER=1", + "TARGET_APPIMAGE=/home/user/Applications/lich.AppImage", + "REDIRECT_APPIMAGE=/home/user/Applications/lich.AppImage", + "DESKTOPINTEGRATION=AppImageLauncher", + // AppRun's "${LD_LIBRARY_PATH:-}" on an unset var leaves a trailing + // empty entry; everything points into the mount, so the var must go. + "LD_LIBRARY_PATH=" + mount + "/usr/lib/x86_64-linux-gnu:" + mount + "/usr/lib:", + "PATH=" + mount + "/usr/bin:/usr/local/bin:/usr/bin", + "XDG_DATA_DIRS=" + mount + "/usr/share:/usr/local/share:/usr/share", + "GDK_PIXBUF_MODULE_FILE=" + mount + "/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache", + "GREP_COLORS=ms=01;31:mc=01;31", + "HOME=/home/user", + } + got := strings.Join(childEnv(in), "\n") + + for _, want := range []string{ + "PATH=/usr/local/bin:/usr/bin", + "XDG_DATA_DIRS=/usr/local/share:/usr/share", + "GREP_COLORS=ms=01;31:mc=01;31", + "HOME=/home/user", + } { + if !strings.Contains(got, want) { + t.Errorf("childEnv output missing %q:\n%s", want, got) + } + } + for _, gone := range []string{ + "LD_LIBRARY_PATH", "GDK_PIXBUF_MODULE_FILE", "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", + "WEBKIT_DISABLE_DMABUF_RENDERER", "TARGET_APPIMAGE", "REDIRECT_APPIMAGE", + "DESKTOPINTEGRATION", "APPDIR", + } { + if strings.Contains(got, gone+"=") { + t.Errorf("childEnv leaked %q:\n%s", gone, got) + } + } + if strings.Contains(got, mount) { + t.Errorf("childEnv leaked a mount path:\n%s", got) + } +} + +// TestChildEnvKeepsUserLibraryPath proves a user's own LD_LIBRARY_PATH suffix +// survives the scrub — AppRun prepends the mount to whatever was already set. +func TestChildEnvKeepsUserLibraryPath(t *testing.T) { + in := []string{ + "APPDIR=/tmp/.mount_lich", + "LD_LIBRARY_PATH=/tmp/.mount_lich/usr/lib:/opt/cuda/lib64", + } + got := strings.Join(childEnv(in), "\n") + if !strings.Contains(got, "LD_LIBRARY_PATH=/opt/cuda/lib64") { + t.Errorf("childEnv lost the user's LD_LIBRARY_PATH:\n%s", got) + } +} + +// TestNewSessionEnv proves the service derives its session environment at +// construction: cleaned of AppImage leakage and terminated by TERM. +func TestNewSessionEnv(t *testing.T) { + svc := New(stubBins{}, []string{"APPDIR=/tmp/.mount_lich", "ARGV0=lich.AppImage", "HOME=/home/user"}) + got := strings.Join(svc.env, "\n") + if strings.Contains(got, "ARGV0=") || strings.Contains(got, "APPDIR=") { + t.Errorf("session env leaked AppImage vars:\n%s", got) + } + if !strings.Contains(got, "HOME=/home/user") || !strings.Contains(got, "TERM=xterm-256color") { + t.Errorf("session env missing HOME or TERM:\n%s", got) + } +} + // TestOperationsOnUnknownSessionAreNoops proves Write/Resize/Close on a session // that was never started return nil instead of panicking on a missing PTY. func TestOperationsOnUnknownSessionAreNoops(t *testing.T) { - svc := New(stubBins{}) + svc := New(stubBins{}, nil) if err := svc.Write("ghost", "hi"); err != nil { t.Errorf("Write unknown = %v, want nil", err) } @@ -66,7 +154,7 @@ func TestSetVisibleReachesCoalescer(t *testing.T) { out.SetVisible(false) out.Write([]byte("pending")) - svc := New(stubBins{}) + svc := New(stubBins{}, nil) sess := spawnSession(t) sess.out = out svc.sessions["s1"] = sess @@ -105,7 +193,7 @@ func spawnSession(t *testing.T) *session { // TestWriteResizeCloseOnLiveSession drives a real session end to end: input is // written, the window is resized and Close reaps the shell and drops it. func TestWriteResizeCloseOnLiveSession(t *testing.T) { - svc := New(stubBins{}) + svc := New(stubBins{}, nil) svc.sessions["s1"] = spawnSession(t) if err := svc.Write("s1", "hello"); err != nil { @@ -125,7 +213,7 @@ func TestWriteResizeCloseOnLiveSession(t *testing.T) { // TestStartIsNoopWhenAlreadyRunning proves Start returns without spawning a // second shell for a session ID that is already tracked. func TestStartIsNoopWhenAlreadyRunning(t *testing.T) { - svc := New(stubBins{}) + svc := New(stubBins{}, nil) sess := spawnSession(t) svc.sessions["s1"] = sess diff --git a/main.go b/main.go index 442a07ecc..d4863aec2 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,11 @@ var assets embed.FS // main is the application's entry point. It creates the application, opens the // main window and blocks until the app exits. func main() { + // Snapshot the environment before the GDK_BACKEND tweak below: spawned + // terminal sessions must inherit what the user launched lich with, not our + // GTK workarounds (see terminal.childEnv). + env := os.Environ() + // WebKitGTK under Wayland fractional scaling renders every damage frame // at 2x and downsamples it on the CPU — typing in a full-size window cost // ~40ms/frame of engine time regardless of raster backend (measured @@ -44,11 +49,21 @@ func main() { } defer db.Close() + // The Name becomes the GTK application ID (org.wails. on D-Bus), + // which is single-instance: a second process with the same ID is treated + // as a remote instance and never gets a window. A distinct dev name lets + // `task dev` (LICH_DEV=1, see Taskfile.yml) open alongside an installed + // lich. + name := "lich" + if os.Getenv("LICH_DEV") != "" { + name = "lichdev" + } + app := application.New(application.Options{ - Name: "lich", + Name: name, Description: "Personal harness", Services: []application.Service{ - application.NewService(terminal.New(db)), + application.NewService(terminal.New(db, env)), application.NewService(fonts.New()), application.NewService(project.New()), application.NewService(db),