docs(wails): move the prose onto v3 and record Phase 7
CI / check (push) Successful in 4m49s
CI / e2e (push) Successful in 6m8s

CLAUDE.md gains a Packaging section for the four Taskfile facts the
recipes just needed — wails3 on PATH by bare name, no -ldflags on
`wails3 build`, bin/ not build/bin/, and bundling as its own step —
plus how build/'s platform metadata generates from build/config.yml and
what that refresh overwrites.

Its lifecycle, bindings, harness, events and CI sections were still
describing v2. The events one matters most: the rule to emit through
events.Emit survives, but its justification is now the weaker one, and
saying so is the point of the migration. v2's runtime.EventsEmit
log.Fatalf'd on any context not carrying the runtime; v3's emit takes
no context at all, so what is left to pin is that one emit path is what
lets emitStatus drop an unchanged payload for every caller at once.

README told a contributor to `go install wails/v2/cmd/wails` and
apt-get libgtk-3-dev/libwebkit2gtk-4.1-dev; the CLI is vendored and the
stack is GTK4 + WebKitGTK 6.0. Two comments claiming Xvfb and one
claiming frontend/wailsjs go with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
2026-08-14 23:09:36 -04:00
co-authored by Claude Opus 5
parent cad3d1339b
commit 1128881e8d
5 changed files with 258 additions and 43 deletions
+148 -25
View File
@@ -39,7 +39,7 @@ make e2e-setup # Install the e2e runner + its browser (once)
make ui-test # Vitest component/store suite in a real browser (no app)
make ui-visual # Same, including toMatchScreenshot comparisons
make ui-setup # Install the Vitest provider's own Chromium (once)
make bindings-check # Fail if frontend/wailsjs is stale vs the Go bindings
make bindings-check # Fail if frontend/bindings is stale vs the Go bindings
make skill-check # Fail if .pi/ documents a make target that doesn't exist
make commit-check # Fail if a commit subject is not a Conventional Commit
make lint # golangci-lint v2 (strict), all three build configurations
@@ -87,9 +87,12 @@ through `internal/testfixtures`, selecting files by *case*
path, and skip themselves when it has not been generated.
The app itself can be run without a blocking window — `make
dev-headless` — and driven with `playwright-cli` against the dev server
on `:34115`, which is the real app with real bindings on `window.go`,
bridged to the same Go backend a desktop window would use.
dev-headless` — and driven with `playwright-cli` against it on
`:34115`. That is wails v3's first-class `-tags server` mode: the real
app, the real bindings, the same Go backend a desktop window would use,
served over HTTP with **no display at all**. The Xvfb this used to
require is gone, from the script and from CI; `dbus-run-session` stays,
for MPRIS.
**The operational half of all this lives in the
`yellowjacket-dev` skill** (`.pi/skills/yellowjacket-dev/`): which tier
@@ -106,8 +109,16 @@ phase 3:
backend event on `window.__yjEvents`. Half this app is push-driven,
so assertions **await an event, not a timeout**:
`await window.__yjEvents.wait('LibraryScanComplete', {timeoutMs: 60000})`.
It also provides `ready()` and `call('queue.Queue.GetState', [])`,
which times out instead of hanging.
It also provides `ready()` and `call('queue.Queue.GetState', [])`.
Both hook v3's own seams rather than an internal: inbound is
`window._wails.dispatchWailsEvent`, the entry point the backend's push
uses, and outbound is **`fetch`** — v3 routes every runtime call
through one POST to `/wails/runtime`, so one hook sees calls from any
module and cannot miss one made before the harness looked. `call()`
posts by method name, so it depends on nothing in the app's bundle and
works on a page with no init script — which is why `seed-sandbox.sh`
is `curl` now and needs no browser. It no longer races a timeout
either: v3 rejects bad arguments and unknown methods cleanly.
- **The dev-only control surface**, `backend/testctl`, mounted at
`/__test/` on the same port: `health`, `db/snapshot`, `db/restore`,
`emit` (force any backend event, which renders push-driven views
@@ -133,18 +144,29 @@ meaningless against the seed's one empty playlist, so it builds ten
It wraps every bound Go method, so "did that refetch the library" is a
fact rather than an inference. It is not a spec and does not run in CI.
**The cheapest tier needs none of that.** `make ui-test` runs 672
Vitest tests in a real Chromium in ~2 s with no Wails, no backend, no
seeded library and no virtual display, because `frontend/wailsjs/` is a
pure passthrough to `window.go` / `window.runtime` and
`frontend/test/support/wails-fake.ts` replaces just those two globals —
so the tests exercise the real generated bindings and the real store
code.
**The cheapest tier needs none of that.** `make ui-test` runs 757
Vitest tests in a real Chromium with no Wails, no backend, no seeded
library and no virtual display, because **v3 routes every runtime call
— bindings, event emits, window, dialogs, clipboard — through one IPC
transport**, and `frontend/test/support/wails-fake.ts` replaces it via
`setTransport()`, which is public documented API. So the fake covers
strictly more than v2's two globals did while being shorter, and the
tests exercise the real generated bindings, the real runtime and the
real store code. A binding carries an **ID**, not a name
(`$Call.ByID(2822423495)` is FNV-1a over
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
that map from the generated tree rather than writing it down.
**`frontend/wailsjs/` is generated by `wails`, not `go generate`**, so
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
the pre-commit codegen check does not cover it. `make bindings-check`
(~1.5 s, also a pre-commit hook) regenerates it and fails on a dirty
tree; `make bindings` regenerates it for real.
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
regenerates it and fails on a dirty tree; `make bindings` regenerates it
for real. It is slower than v2's because v3's generator is a **static
analyser** over the whole package graph rather than runtime reflection
— which is also why the tag set it runs under matters, and why it is
the *default* one: that is the configuration users run, and neither
`indexbuild` nor `dev` adds a bound service. See
`scripts/bindings-check.sh`.
**Seeds are produced by running the app**, never by hand-writing a
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
@@ -154,7 +176,46 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
## Architecture
**Wails app lifecycle** (`main.go``backend/app.go`): `YellowJacketApp` is the root struct bound to Wails. Its methods are callable from the frontend. Lifecycle hooks: `OnStartup` (init audio), `OnDomReady` (start library scan), `OnBeforeClose` (save window state), `OnShutdown` (persist player/queue state).
**Wails app lifecycle** (`main.go``backend/app.go`): `main.go` is
`application.New(opts)``app.Window.NewWithOptions(…)``app.Run()`.
Each bound service takes its context from `ServiceStartup(ctx,
application.ServiceOptions{})` — v3 calls it on every service, in
registration order, on the main goroutine — and gives it back in
`ServiceShutdown()`. That context is **cancelled on app shutdown**,
which `SetContext` never was.
Three things about it are load-bearing.
**`ServiceShutdown()` takes no context.** A method with a
`context.Context` parameter does not satisfy the interface and is
**silently never called** — no error, no warning.
**The cross-service wiring is a service, not an event.** v3 has no
`OnStartup`/`OnDomReady` option, and the obvious replacement —
`app.Event.OnApplicationEvent(events.Common.ApplicationStarted, …)`
is the right *moment* and the wrong *mechanism*: **server mode emits no
application events at all** (`setupCommonEvents` is an explicit no-op
under `-tags server`), so the desktop build wired itself and the
headless harness did not. `backend/startup.go` is registered last
instead, which takes the ordering from the mechanism rather than from
an event and therefore holds in every mode. Anything else keyed on
`Common.*` is suspect for the same reason.
**The quit veto is asynchronous now.** v2's `MessageDialog` blocked and
returned the button; v3's `Show()` returns immediately and the answer
arrives on a `Button.OnClick` callback, so `ShouldQuit` cannot ask and
answer in one call — it vetoes, shows the dialog, and calls
`app.Quit()` from the callback. `quitConfirmed` is what stops that
second `Quit()` asking again; `quitAsking` stops a second close attempt
stacking dialogs. Window state moved off that path entirely, onto a
`Common.WindowClosing` hook, because the size has to be read while the
window still exists and `OnShutdown` has neither a context nor a
window.
`internalServiceMethods` auto-excludes `ServiceStartup`,
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
shape **removed** 12 spurious bindings and the bogus `context` model
rather than renaming them.
**Backend packages** (under `backend/`):
- `player` — Audio playback via beep. `BufferedStreamer` provides a ring buffer for smooth seeking.
@@ -444,7 +505,11 @@ that invents its own flat layout agrees with the bug.
keeping only `explore.ArtistImageKeepNames()` and refusing an empty
keep set for the reason the covers sweep refuses an empty live set.
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated in `frontend/wailsjs/` — don't edit by hand.
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated as TypeScript in `frontend/bindings/`, nested by Go import path — don't edit by hand. The `@go` alias absorbs the constant prefix, so a call site imports `@go/library/library.js`.
**One seam states what the generated types get wrong, rather than 78 patches.** v3's generator is honest where v2's lied: a Go `nil` slice marshals to JSON `null` and always has (v2 typed it `T[]`), and a Go named string type is a closed set (v2 typed it `string`). There is no flag to turn either off, correctly. So `utils/binding.ts` states the app's actual contract at the only place it is true — `list` yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil map and drop null-valued keys (which loses nothing: `noUncheckedIndexedAccess` already makes every read `V | undefined`), and `compact` is the same for a map arriving as a *field*. Where a nullable slice is a model field there is no boundary to put it at, and those are `?? []` at the point of use.
All three also return a **plain `Promise`**: v3 bindings return a `CancellablePromise` and nothing in this app cancels one, so letting it inward would put a Wails type in every store signature for a capability none of them use.
**A view is a chunk, and three components are not.** `index.ts` holds a
loader table (`VIEW_LOADERS`, `DETAIL_LOADERS`) and `await`s a view's
@@ -1427,11 +1492,26 @@ on every change, or `scrollToIndex` from calling `scrollIntoView()` on
something invisible.
Emit through **`events.Emit(ctx, name, data...)`**, never
`runtime.EventsEmit` — wails `log.Fatalf`s (unrecoverably) on any
context that does not carry its runtime, which includes every
`context.Background()`, so a direct call cannot run under test and can
kill the app from a background worker. `TestNoDirectRuntimeEmits` fails
the build on a direct call anywhere outside `backend/events`.
`app.Event.Emit`. `TestNoDirectRuntimeEmits` fails the build on a
direct call anywhere outside `backend/events`.
**Its justification changed with v3 and is now the weaker one.** Under
v2 this was a safety rule: `runtime.EventsEmit` `log.Fatalf`'d —
unrecoverably, taking the process down — on any context not carrying
the runtime, which includes every `context.Background()`, so a
background worker could kill the app by emitting. That is gone. v3's
emit takes **no context at all**, and `application.Get()` with no app
running returns `nil` rather than dying, so `Deliver` is
`if app == nil { return ErrNoRuntime }` where it used to probe the
**v2-private context key** `ctx.Value("events")`. What remains worth
pinning is narrower and still real: one emit path is what lets
`emitStatus` drop an unchanged payload for every caller at once.
**`events.Emit` keeps its `ctx` anyway, and it is now purely a test
seam.** Delivery does not go through it; `events.WithSink(ctx, rec)`
does, which is how a service is asserted on in-process
(`backend/queue/emit_test.go` is the model). Dropping the parameter
would have churned 45 call sites and every test for no gain.
That wrapper is what makes services testable in-process: install a
recorder with `events.WithSink(ctx, rec)` and assert on the payload the
@@ -1489,8 +1569,10 @@ Two jobs, both in an `ubuntu:24.04` container:
`make lint` and `make test` (three build configurations each),
`tsc --noEmit`, `make ui-test`, `make bindings-check`,
`make skill-check`.
- **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by
running the app, `make dev-headless`, then the Playwright suite
- **`e2e`** — under a private D-Bus and **no display at all**, since
v3's `-tags server` is a real headless mode: fixtures, a seed built by
running the app (`curl` against the runtime endpoint — no browser),
`make dev-headless`, then the Playwright suite
against **both** Chromium and WebKit. Playwright's Linux WebKit links
Ubuntu 24.04 libraries that Arch does not provide, so CI is the only
place it can run, and it is the closest available approximation of
@@ -1528,3 +1610,44 @@ WebKitGTK 6.0, which Arch and ubuntu:24.04 both ship, so the
`webkit2_41` tag that used to be mandatory everywhere is gone. A
machine without `webkitgtk-6.0` can still build with `-tags gtk3`, but
that is an escape hatch, not what CI or a release builds.
## Packaging
**The Makefile is the front door and `Taskfile.yml` is an
implementation detail behind it.** `make dev`, `make build-dev`,
`make build-prod`, `make bindings` and `make e2e` all keep their names;
what changed underneath is that a build is now a Taskfile tree
(`Taskfile.yml` → `build/<platform>/Taskfile.yml`) rather than one
`wails build` invocation, and `wails3` is still a **vendored Go tool**
(`go tool wails3`), never a global install.
Four things about that tree bite anything outside the Makefile, and all
four bit the packaging recipes:
- **The tasks invoke `wails3` by bare name**, in 54 places across the
scaffold files. `scripts/toolbin/wails3` puts that name on PATH
pointing back at the vendored tool; without it a build dies at its
first sub-task with `wails3: command not found`. The Makefile
prepends it, and so must `packaging/arch/PKGBUILD` and the Homebrew
formula.
- **`wails3 build` has no `-ldflags`, `-trimpath` or `-clean`** — those
were v2's. `-trimpath` and `-w -s` are already in the production
task's own flags; the version stamp goes through **`LDFLAGS_EXTRA`**,
this repo's one edit to the scaffold Taskfiles (linux and darwin
alike), passed as `wails3 task build LDFLAGS_EXTRA="-X 'main.version=…'"`.
- **The output is `bin/`, not v2's `build/bin/`.** `build/` is *tracked
build assets* now.
- **Bundling is a separate step from building.** `task build` produces
a bare binary on every platform; the macOS `.app` is `task package`.
**`build/`'s platform metadata is generated from `build/config.yml`.**
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
one file — so a hand edit to any of them is lost on the next refresh,
and the two fields it does *not* own (nfpm's `homepage` and `license`)
say so in place. That refresh also regenerates `build/ios/` and
`build/android/`, which this repo does not carry: they are gitignored
rather than deleted-and-rediscovered, and their `includes:` entries
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.