Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b1fd35f
feat(ui): add interaction states, browser-surface theming, and shared…
erwin-wee Sep 22, 2026
69e90bc
feat(help): add in-app help panel and contextual guidance
erwin-wee Sep 22, 2026
b75f0a6
feat(toolbar): condense toolbar and banners, add help trigger
erwin-wee Sep 22, 2026
3dd4b44
feat(history,changes): two-line commit rows, lighter file rows, filte…
erwin-wee Sep 22, 2026
848473b
feat(a11y): searchable shortcuts, clearer discard copy, diff-control …
erwin-wee Sep 22, 2026
c015675
feat(ui): add a commit-success signature moment
erwin-wee Sep 22, 2026
349a8a1
fix(gh): keep pull request list under GitHub GraphQL cost limits
erwin-wee Sep 23, 2026
5c4c6c5
fix(push): show hook output while waiting for transfer progress
erwin-wee Sep 23, 2026
8a5812f
feat(server): headless web server over the tailnet, desktop client mode
erwin-wee Sep 23, 2026
2092bec
feat(mobile): phone and tablet layout for the web UI
erwin-wee Sep 23, 2026
5b193ec
test(smoke): wait for renderer bootstrap before running steps
erwin-wee Sep 23, 2026
4a409cd
docs(server): document server mode, archive add-server-mode spec
erwin-wee Sep 23, 2026
6cf476f
chore: ignore local .serena and .impeccable artifacts
erwin-wee Sep 23, 2026
93570ab
fix(server): address server-mode review findings
erwin-wee Sep 23, 2026
3b014ae
fix(mobile): keep back navigation inside the app after a forward swipe
erwin-wee Sep 23, 2026
d104eb2
fix(desktop): keep the desktop layout at any window width or zoom
erwin-wee Sep 24, 2026
446c706
fix(mobile): let a finger drag across diff lines to select a range
erwin-wee Sep 24, 2026
9fca9c6
fix(server): rate-limit by source address, confine client-mode native…
erwin-wee Sep 24, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ jobs:
- name: Typecheck
run: npm run typecheck

# Headless server bundle: fails if server-reachable code imports electron.
- name: Build server
run: npm run build:server

- name: Unit tests
run: npm run test:unit

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ coverage/
*.tsbuildinfo
test/smoke/out/
.lavish/
.serena/
.impeccable/
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,48 @@ GitGood looks for the tools on `PATH` and in the usual install locations; you ca

**Options → Accounts → Sign in to GitHub.com** runs `gh auth login --web`. GitGood shows the one-time code, opens `github.com/login/device` in your browser and waits for approval. When it completes, GitGood runs `gh auth setup-git`, so Git pushes and pulls to GitHub over HTTPS use the same token. Nothing else is stored by the app.

## Server mode (web access over your tailnet)

GitGood can also run **headless as a web server** so you can reach it from a browser on another device — e.g. your desktop's GitGood open on a laptop or phone — over your [Tailscale](https://tailscale.com) tailnet. The same UI and the same `git`/`gh` backend run; only the transport changes (HTTP + WebSocket instead of Electron IPC).

It executes `git`, `gh`, shell and AI-CLI commands **as the host user**, so it is locked down by default:

- **Loopback only.** The server binds `127.0.0.1` and never `0.0.0.0`; `tailscale serve` is the only way in. Public exposure (`tailscale funnel`) is never enabled. It also only answers to `127.0.0.1`/`localhost` and `*.ts.net` host names, so a web page can't reach it through DNS rebinding.
- **Bearer token.** A 256-bit token is generated on first start, stored `0600` in the user-data directory, and printed once. It is required on `/invoke` and the `/events` WebSocket.
- **Tailscale identity.** Every request that comes through `tailscale serve`, including the page itself, must carry the allowed `Tailscale-User-Login`. By default that is the owner of this tailnet node (from `tailscale status`); set `GITGOOD_ALLOWED_LOGIN` to override. Other users and tagged devices are refused, and if no login can be determined nothing from the tailnet gets in.
- **Path confinement.** Paths are restricted to the server user's home directory, the registered repositories, watched folders, the default clone directory and `GITGOOD_ALLOWED_ROOTS`; a path outside them is refused before any command runs. The web folder picker browses the same locations and accepts a typed path (absolute or `~/…`).

### Run it

```bash
npm run build # builds the app, including the renderer (out/renderer) and the headless server (out/server)
npm run start:server # data lives in ~/.config/gitgood-server

# Then expose the loopback port on your tailnet (default port 4600):
tailscale serve --bg 4600
# GitGood is now at https://<your-host>.<tailnet>.ts.net
```

To keep it running on Linux, `npm run install:service` installs and starts a systemd user service (`gitgood-server`) for this checkout; set environment variables with `systemctl --user edit gitgood-server`, and re-run it after moving the checkout. To start from your desktop's repositories and settings, copy `settings.json`, `repositories.json` and `state.json` into `~/.config/gitgood-server` once, with the server stopped.

Installed copies of GitGood ship the server too; run it with the app's own runtime, e.g. `ELECTRON_RUN_AS_NODE=1 /path/to/gitgood /path/to/resources/app.asar/out/server/index.mjs` (for the Linux AppImage, `--appimage-extract` it first and use `squashfs-root/`).

Environment variables: `GITGOOD_SERVER_PORT` (default `4600`), `GITGOOD_USER_DATA` (default `~/.config/gitgood-server`), `GITGOOD_ALLOWED_LOGIN` (Tailscale login to allow; defaults to this node's owner), `GITGOOD_ALLOWED_ROOTS` (extra filesystem roots the client may reach, `PATH`-separated), `GITGOOD_RENDERER_DIR` (defaults to the bundled `out/renderer`).

### Web-mode differences

Because the browser is not the host machine, desktop-only actions degrade gracefully: **Copy** uses the browser clipboard and **external links** open in a new tab, while **open-in-editor/terminal** are disabled in the browser (they would run on the server's machine) and the folder picker browses the server's allowed locations. Anything moved to the trash (discarded changes, removed repositories) goes to `trash/` in the server's data directory (`~/.config/gitgood-server/trash`). Several clients can be connected at once, each with its own open repository and live updates; changes to the same repository are serialized.

### Phones and tablets

On a phone the web UI switches to a one-pane-at-a-time layout: a bottom tab bar (Changes, History, Stashes, More), tap a file or commit to drill in, and the in-app back button or the system back gesture to return. The repository and branch pickers, dialogs and right-click menus open as bottom sheets; long-press anything that has a context menu. Portrait tablets keep the two-pane layout with the same tab bar and touch-sized controls. Use your browser's **Add to Home Screen** / **Install app** to run it full-screen like a native app.

### Desktop app as a client

To have one set of settings and repositories everywhere, point the desktop app at the server instead of its own data: put the server URL in `~/.config/gitgood/server-url` (or set `GITGOOD_SERVER_URL`), e.g. `http://127.0.0.1:4600`, and restart GitGood. The window then runs the server's UI, so changes made on the desktop, in a browser, or on another machine show up everywhere. The desktop keeps native clipboard, links, zoom, the unread badge and OS notifications; with a server on the same machine (`127.0.0.1`/`localhost`) it also keeps native file dialogs, reveal/open/trash and open-in-editor/terminal. For a remote server those fall back to the web behavior. Remove the file to run standalone again; the desktop's own settings are left untouched while in client mode.

If the desktop app and the server run different versions, the desktop warns once after connecting; update or rebuild whichever is behind.

## Documentation

- [docs/AI-FEATURES.md](docs/AI-FEATURES.md) — how each AI feature works, in depth.
Expand Down
2 changes: 2 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export default defineConfig({
build: {
rollupOptions: { input: { index: resolve('src/renderer/index.html') } },
sourcemap: true,
// The same bundle is served to phone browsers (server mode): flatten CSS nesting for Safari < 17.2.
cssTarget: ['chrome110', 'safari15'],
},
},
});
161 changes: 161 additions & 0 deletions openspec/changes/archive/2026-09-24-add-server-mode/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Design: GitGood Server Mode

## Context

The renderer never touches Electron directly. The only coupling is `window.gitgoodBridge`
(`src/preload/index.ts`), consumed exclusively by `src/renderer/src/api.ts`:

- `invokeRaw(method, ...args): Promise<unknown>` → `ipcRenderer.invoke(IPC_INVOKE_CHANNEL, method, ...args)`
- `on(event, cb): () => void` → `ipcRenderer.on(IPC_EVENT_CHANNEL, …)`
- `platform: string`

Main-side dispatch is one handler over a plain map:
`ipcMain.handle(IPC_INVOKE_CHANNEL, (_e, method, ...args) => handlers[method](...args))`
returning `IpcResult<T>` (`{ok:true, value}` | `{ok:false, error: GitErrorInfo}`,
`src/main/ipc.ts:1038`). Events go out via `sendEvent(win, event, payload)` →
`win.webContents.send(IPC_EVENT_CHANNEL, event, payload)` (`ipc.ts:76`).

Therefore server mode swaps the transport under `api.ts` and reuses everything above it.
The engineering is in decoupling, security, and desktop-affordance gaps — not the wire.

## Goals / Non-goals

- Goals: run the real GitGood UI at `https://<host>.ts.net` over the tailnet; reuse the
existing handlers and git/gh/ai wrappers verbatim; keep the desktop app fully working
and green at every step.
- Non-goals (this change): public-internet exposure / `tailscale funnel`; multi-user
tenancy; replacing Electron.

## Decisions

### 1. Electron-free core (`HostCapabilities`)

Extract two things from `src/main/ipc.ts` into an Electron-free module (e.g.
`src/main/core/handlers.ts`):

- `createHandlers(deps): Record<ApiMethodName, (...args) => Promise<unknown>>` — the exact
existing map, unchanged in behavior.
- An event bus `emit(event, payload)` replacing direct `win.webContents.send`. `sendEvent`
becomes a thin adapter that publishes to the bus; the Electron app subscribes and
forwards to its window, the server subscribes and forwards to connected sockets.

Native/host operations move behind a `HostCapabilities` interface:

```
interface HostCapabilities {
chooseDirectory(opts): Promise<string | null>;
openExternal(url): Promise<void>;
showItemInFolder(path): Promise<void>;
openInEditor(path) / openInTerminal(path): Promise<void>;
clipboardWrite(text): Promise<void>;
notify(title, body): Promise<void>;
setTheme(theme) / systemTheme(): ...;
// menu, tray, updater, window controls, protocol handler, single-instance
}
```

- `ElectronHost` implements it with `dialog`/`shell`/`Menu`/`Tray`/`nativeTheme`/
`autoUpdater`.
- `WebHost` implements the network-safe subset and returns a typed `unsupported` result
for the rest (renderer already tolerates rejected invokes via `ApiError`). Semantics
that differ (a "directory" is on the *server*) are documented per capability.

Rationale: no handler should transitively import `electron`, so the server bundle stays
Electron-free. This is the load-bearing refactor; it changes no behavior.

### 2. Transport

- **Invoke:** `POST /invoke` with `{method, args}` JSON → `handlers[method](...args)` →
the same `IpcResult` JSON the desktop returns. One route; the typed `ApiMethods` map is
the contract.
- **Events:** a single WebSocket `/events`; the server subscribes to the core event bus
and pushes `{event, payload}` frames. (SSE is an alternative; WebSocket chosen for
symmetry and future client→server needs.)
- **Web bridge shim** (`src/server/web-bridge.ts`, served before the app bundle): defines
`window.gitgoodBridge = { platform, invokeRaw: fetch→/invoke, on: WS subscription }`,
mirroring `preload/index.ts`. `api.ts` is unchanged.
- `platform` in web mode reports the **server** platform (git path semantics are the
server's); the renderer already keys Windows/CRLF handling off it, which is correct
because the repos live on the server.

### 3. Security (mandatory)

Server mode is remote code execution as the host user; the following are requirements,
not options:

- **Bind `127.0.0.1` only.** `tailscale serve` is the sole ingress (tailnet-only,
matching the existing openspectacles setup); the raw port is never on `0.0.0.0`.
- **Authenticate both channels.** A bearer token (generated on first server start, stored
in the app's user-data dir, shown once) gates `/invoke` and the `/events` upgrade.
- **Authorize by Tailscale identity.** `tailscale serve` forwards `X-Forwarded-For` and,
for user-owned devices, `Tailscale-User-Login`. Any forwarded request (static pages and
`/gitgood-bridge.js` included, since the bridge carries the token) must carry exactly the
allowed login: `GITGOOD_ALLOWED_LOGIN`, else the node owner from `tailscale status
--json`. Tagged devices (no login header), other users, and every forwarded request
when no login is known are refused. Direct loopback calls (no forwarding headers) are
the desktop client and local tooling and rely on the token.
- **Host allowlist.** Only `127.0.0.1`, `localhost`, `[::1]` and `*.ts.net` `Host`
headers are answered, so a DNS-rebinding page cannot read the served token.
- **Confine paths.** Handlers take repo paths as arguments; the server rejects any
absolute path argument outside the allowlisted roots (registered repositories, watched
folders, default clone directory, the server user's home, `GITGOOD_ALLOWED_ROOTS`).
Home is included so the web folder picker can add repositories that are not registered
yet. Gated centrally at the dispatch boundary.
- **Same-origin / CSRF** on the WebSocket upgrade and a strict `Content-Type` on
`/invoke`.

### 4. Multi-client

`sendEvent` today assumes one window. The core bus fans out to N subscribers; the server
broadcasts each event to all connected sockets. Phase 1 ships a **single-active-client
advisory lock** (a second client is read-only or warned) to avoid concurrent-mutation
surprises; full multi-client correctness (per-client view state, optimistic conflict
handling) is a later phase.

### 5. Packaging

`vite.server.config.ts` bundles `src/server` + the Electron-free core into
`out/server/index.mjs`, fails the build on any `electron` import, and bakes in the
package version (the service runs the file directly, without npm). `npm run build`
builds it alongside the desktop app, so installers carry it and it can run under the
app's own runtime with `ELECTRON_RUN_AS_NODE=1`. `npm run install:service` writes a
systemd user unit for a checkout. CI builds the bundle on every push.

### 6. Desktop app as a client

With `GITGOOD_SERVER_URL` or `<userData>/server-url` set, the desktop window loads the
server's renderer and web bridge instead of running its own backend, so one set of
settings and repositories serves every device. `src/main/client.ts` answers the native
capabilities (clipboard, links, zoom, badge, notifications; plus dialogs, reveal and
editor/terminal when the server is on the same machine) before the bridge falls back to
its web path. The window runs the server's renderer against this build's native IPC, so
the desktop fetches `GET /version` after each load and warns once on a mismatch.

### 7. Phone layout

Below 768px the renderer switches to one pane at a time (`Mobile.tsx`): a bottom tab
bar, tap-to-drill-in lists mirrored into browser history so the system back gesture
pops a level, bottom-sheet pickers/dialogs/menus, and long-press opening context menus
(iOS Safari never fires `contextmenu`). A web manifest and icons make it installable to
the home screen. Desktop widths are unchanged.

## Risks / tradeoffs

- **Decoupling churn:** touching `ipc.ts` and the host surface risks regressions in the
desktop app — mitigated by keeping `ElectronHost` behavior identical and running
typecheck/tests/smoke after each group.
- **Affordance semantics:** "open in editor/terminal" runs on the server; users must
understand server-vs-client filesystem. Phase 1 marks these `unsupported`; Phase 2 gives
each a real web story rather than a surprising one.
- **Security is the failure mode:** a missing check is an RCE. The path allowlist and
identity check are gated at one dispatch boundary so they cannot be bypassed per-handler.

## Migration / phasing

- Phase 0: Electron-free core + `HostCapabilities` (no behavior change).
- Phase 1: thin secure server (transport + shim + auth + identity + allowlist + static),
desktop-only handlers return `unsupported`.
- Phase 2: web stories for the affordance gaps (server-side directory picker, clipboard,
external links, editor/terminal decisions).
- Phase 3: multi-client broadcast + hardening.
- Phase 4: desktop-as-client, phone layout, packaging and identity/host hardening.
68 changes: 68 additions & 0 deletions openspec/changes/archive/2026-09-24-add-server-mode/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Add GitGood Server Mode (web access over the tailnet)

## Why

GitGood today is Electron-only: the renderer runs inside a `BrowserWindow` and every
operation reaches the Node backend through Electron IPC (`ipcRenderer.invoke` /
`webContents.send`) exposed by the preload `contextBridge` as `window.gitgoodBridge`.
There is no HTTP listener, so GitGood cannot be reverse-proxied to a tailnet host the way
a normal web app is with `tailscale serve`. Users who want to reach their GitGood on a
headless or remote machine currently have no option short of full remote-desktop.

The renderer↔backend contract is already a single clean seam — `invoke(method, ...args)`
request/response plus `on(event, cb)` server-push, both defined once in
`src/shared/ipc.ts` (`ApiMethods`, `EventPayloads`, `IPC_INVOKE_CHANNEL`,
`IPC_EVENT_CHANNEL`) and wrapped by `src/renderer/src/api.ts`. That makes a network
transport a swap at one layer rather than a rewrite of the app. The cost is not the
transport; it is doing the security, the Electron decoupling, and the desktop-affordance
gaps correctly.

## What Changes

- Add a **server mode**: a Node HTTP/WebSocket server (no Electron) that hosts the
existing backend handlers and serves the built renderer as a web app, so GitGood is
reachable at `https://<host>.<tailnet>.ts.net` behind `tailscale serve`.
- Extract the backend from Electron: the `handlers` registry (`src/main/ipc.ts`) and the
event emitter (`sendEvent`) become an Electron-free core both the desktop app and the
server mount. Native calls (`shell`, `dialog`, `Menu`, `Tray`, `nativeTheme`,
`autoUpdater`, `BrowserWindow`) move behind a `HostCapabilities` interface with an
Electron implementation and a web implementation.
- Add a **web bridge shim** that defines `window.gitgoodBridge` over `fetch` (invoke) and
a WebSocket (events), mirroring `src/preload/index.ts`, so `api.ts` and every existing
caller are unchanged.
- **[User-confirmed / security-sensitive]** Exposing the backend over a network is remote
execution of `git`/`gh`/shell/AI-CLI as the host user. Server mode SHALL bind only to
`127.0.0.1` (never `0.0.0.0`), require authentication on both the invoke and event
channels, authorize against the forwarded Tailscale identity header, and confine repo
operations to the registered-repository allowlist.
- Desktop-only handlers degrade gracefully in web mode (return an `unsupported` result)
until a per-feature web story exists (later phase).
- The desktop app can run as a client of a server, sharing one set of settings and
repositories across devices, and warns on a version mismatch.
- A phone layout for the web UI (single pane, bottom tab bar, back gesture, long-press
menus) and an installable web manifest.

## Impact

- Affected specs: new capability `server-mode`.
- Affected code:
- `src/shared/ipc.ts` — the IPC contract is the transport-neutral interface both
transports implement; `IpcResult` stays the wire shape.
- `src/main/ipc.ts` — extract the `handlers` map and `sendEvent` into an Electron-free
core module; the Electron `ipcMain.handle(IPC_INVOKE_CHANNEL, …)` and
`win.webContents.send(IPC_EVENT_CHANNEL, …)` become one binding of that core.
- `src/main/window.ts`, `src/main/index.ts`, `src/main/menu.ts`, tray, updater — the
Electron-specific host surface, moved behind `HostCapabilities`.
- `src/preload/index.ts` — the reference bridge the web shim mirrors.
- `src/renderer/src/api.ts` — unchanged; it consumes whichever `window.gitgoodBridge`
is present.
- New `src/server/` — HTTP `POST /invoke`, WebSocket `/events`, static hosting of
`out/renderer`, auth + Tailscale-identity authorization, repo-path allowlist.
- `electron.vite.config.*` / build scripts — a server build target that must not import
`electron`.
- External commands: no new `git`/`gh` commands; server mode reuses the existing
`src/main/git` and `src/main/gh` wrappers unchanged (all runs keep
`GIT_TERMINAL_PROMPT=0`).
- No new runtime dependency is assumed; the HTTP/WS server SHOULD use the Node standard
library (`node:http`, a minimal WS implementation) — any candidate dependency requires a
dependency-review task first (four-dependency budget).
Loading
Loading