docs(plan): record what Phase 1 landed and the two things it hit

The plan assumed build/ was free and that GTK4 was a preference. It
was not free — this repo used it as ignored build output — and GTK4 is
not available on the dev machine, which breaks `go tool wails3`
outright rather than merely changing which webkit is linked.

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 13:39:57 -04:00
co-authored by Claude Opus 5
parent e7873bded3
commit f47b2db308
@@ -1,685 +0,0 @@
# 009 — Wails v3 migration
**Status:** Phase 0 complete — **go**. Phases 17 not started.
**Branch:** none yet (the spike ran in a scratchpad; the repo was not touched)
**Created:** 2026-08-13
**Phase 0 run:** 2026-08-13 against **v3.0.0-beta.8**
**Target version:** pin `v3.0.0-beta.8` for the whole migration
**Depends on:** nothing
**Follows:** 005-agent-development-harness (which this must not break)
---
## Verdict
Go — but not urgently, and not in one sitting. The app port is small
and the harness port is not. Phase 0 answered the three questions that
could have killed it, and all three came back favourable, one of them
better than hoped.
The reason to do it is **not** tray icons. It is that v3 deletes an
entire failure class this repo has built scar tissue around, and does
so at the cheapest moment this migration will ever have — before
anything has shipped to real users, the same reasoning that lets
`sql/migrations/` be squashed.
The reason not to rush is that beta churn is real (`beta.3``beta.8`
in the lifetime of one nearby reference app) and Phases 14 leave the
repo in a state that **must not be merged**.
---
## Why v3: the actual argument
`events.Emit(ctx, name, data...)` exists because v2's
`runtime.EventsEmit` calls `log.Fatalf` — unrecoverably, taking the
process down — on any context that does not carry the Wails runtime.
Everything downstream is scar tissue: the `ErrNoRuntime` contract,
`TestNoDirectRuntimeEmits` walking the whole tree, and worst,
`backend/events/emit.go:83` probing the **v2-private context key**
`ctx.Value("events")` to decide whether emitting is safe.
**v3's emit takes no context at all**`app.Event.Emit(name, data...)`.
A background worker cannot kill the app by emitting from a
`context.Background()`, because there is no context to get wrong.
Phase 0 verified this rather than inferring it from the signature:
`application.Get()` with no app running returns **`nil`** instead of
`log.Fatalf`-ing, and 20 concurrent emits from detached goroutines
against a created-but-never-`Run()` app completed with no panic and no
crash, headless. The v3 scaffold template itself emits from a bare
`go func()` loop, so this is the blessed pattern, not something we'd be
getting away with.
Secondary wins, in rough order of value: a supported headless server
mode that replaces a hand-rolled script; `ServiceStartup` replacing 12
hand-wired `SetContext` methods *and* deleting 12 spurious bindings;
clean rejection on bad binding args, which deletes the ugliest race in
the e2e harness; and the `webkit2_41` tag disappearing entirely.
---
## Phase 0 — results (2026-08-13, beta.8)
Measured against a `wails3 init -t vanilla` app in a scratchpad.
### Q1 — build environment: **PASS**, better than assumed
`ubuntu:24.04` ships **both** `libwebkitgtk-6.0-dev` (2.52.3) and
`libwebkit2gtk-4.1-dev`. A default-tag build (GTK4 + WebKitGTK 6.0)
**compiles in the CI container** — verified by actually building inside
`docker run ubuntu:24.04`, not by reading package lists. Arch has
`webkitgtk-6.0` (2.52.5) in `extra/`, merely not installed on this
machine.
Both platforms can therefore run v3's *default* path, so **`webkit2_41`
becomes a deletion across ~30 sites, not a translation**.
- Dev machine cost: `sudo pacman -S webkitgtk-6.0`.
- CI cost: `libwebkit2gtk-4.1-dev libgtk-3-dev` → `libwebkitgtk-6.0-dev
libgtk-4-dev`.
- Fallback if GTK4 misbehaves: `-tags gtk3` builds fine on Arch against
the installed webkit2gtk-4.1. `wails3 doctor` reports both toolchains
and labels 4.1 "(legacy)".
### Q2 — headless harness: **PASS**, with one real loss
v3 has a first-class **`-tags server` mode** ("a pure HTTP server
without native GUI dependencies"), a supported replacement for what
`scripts/dev-headless.sh` hand-rolls. Verified with `DISPLAY` and
`WAYLAND_DISPLAY` unset:
- serves the app over HTTP (`WAILS_SERVER_PORT`; defaults to 8080,
which collides — set it explicitly);
- the runtime loads in a real Chromium; `window._wails` appears,
exposing `dispatchWailsEvent` and `invoke`;
- **binding calls work** — `Call.ByID(...)` and `Call.ByName(...)` both
returned correct results;
- **events flow** — 6 events in 3.5 s from the template's 1 Hz
goroutine emitter, over an SSE broadcaster at `/wails/events`.
Three findings that shape later phases:
1. **`window.go` does not exist, and there is no runtime enumeration
surface for bound methods.** This is the one genuine regression.
`e2e/specs/harness.spec.ts:18-19` and `e2e/perf/measure.mjs:130-180`
both *walk* that object; they lose the mechanism, not just the
syntax. See Phase 6.
2. **Bad arguments reject cleanly**, with useful messages
(`expects 1 arguments, got 3`; `could not parse argument #0: json:
cannot unmarshal object into Go value of type string`). Unknown
method names reject too. v2's never-fires-its-callback behaviour is
gone, so `__yjEvents.call`'s timeout race **deletes itself**.
3. **The FQN is the full Go import path**, not the package name.
`bindings.go:245` builds `fmt.Sprintf("%s.%s.%s", packagePath,
typeName, methodName)` from `reflect.Type.PkgPath()`. For us that is
`yellowjacket/backend/library.Library.GetAllTracks` — verbose but
deterministic. (`main.GreetService.Greet` resolved; `changeme.…` and
bare `GreetService.…` did not.)
One caveat recorded honestly: the server build **still required a
webkit toolchain at compile time** despite the "no native GUI
dependencies" summary — it failed until pointed at an installed webkit.
Whether that is intended or a beta gap was not determined. It is moot
if we adopt the GTK4 deps anyway.
### Q3 — the emit footgun: **PASS**, decisively
- `application.Get()` with no app running returns `nil`; the process
survives. The `if app == nil { return ErrNoRuntime }` design is right.
- 20 concurrent emits from detached goroutines, app created but never
`Run()`, no display: no panic, no crash.
- `application.New()` itself works headless (reports
`Webkit2Gtk=v2.52.5` under `-tags gtk3`).
### Bonus findings
- **`internalServiceMethods` auto-excludes `ServiceStartup`,
`ServiceShutdown`, `ServiceName`, `ServeHTTP`** from bindings
(`bindings.go:238-243`) — confirming the `SetContext` port *removes*
12 bindings and the bogus `context` model rather than renaming them.
- **Generated bindings are TypeScript, nested by Go import path**
(`frontend/bindings/<module>/<pkg>/<service>.ts` + a per-package
`index.ts` re-export). This **disproves** the earlier assumption that
the 93 `@go` import sites wouldn't change — see Phase 4.
- Calls compile to `$Call.ByID(<fnv hash>, …)` where
`methodID = hash.Fnv(fqn)`, with an explicit-ID registration escape
hatch — a harness can compute IDs itself if it ever needs to.
- Bindings return a **`CancellablePromise`**, not a bare `Promise`.
- **Binding generation is build-tag sensitive** (static analyser).
Wails' own Taskfile passes `BUILD_FLAGS: "-tags server,production"`
to binding generation so it "analyses the same build the Docker image
compiles, not the default-tag build."
- **`application.RegisterEvent[string]("name")`** yields typed events
and a generated typed TS event API — overlaps with what
`backend/events/cmd/genevents` does by hand.
---
## Ground truth: what we actually touch
Measured, not assumed. The Go surface is small; the harness surface is
the job.
### Go — six files import `wails/v2`
| File | Subpackage | Uses |
|---|---|---|
| `main.go:11-13` | `wails`, `options`, `options/linux` | `wails.Run`, `options.App`, GPU policy |
| `backend/app.go:15` | `pkg/runtime` | `WindowGetSize`, `MessageDialog`, `QuestionDialog`, `Quit` |
| `backend/assets/handler.go:9` | `options/assetserver` | `assetserver.Options{Assets, Middleware}` |
| `backend/events/emit.go:8` | `pkg/runtime` | `EventsEmit` — the only emit in the tree |
| `backend/frontendutil/frontendutil.go:9` | `pkg/runtime` | file/dir dialogs, `LogInfo` |
Plus `backend/logging/`, which implements v2's `logger.Logger`
**structurally** — it does not import wails, so `grep wailsapp` misses
it.
### Bound surface
12 services, ~279 exported methods (`backend/app.go:204-225`).
`YellowJacketApp` itself is not bound; only its lifecycle hooks are
wired — which is already close to v3's service model.
| Service | Methods | | Service | Methods |
|---|---|---|---|---|
| `explore.Service` | 56 | | `player.Player` | 21 |
| `library.Library` | 47 | | `jobs.Service` | 7 |
| `playlist.Service` | 36 | | `tagwriter.TagWriter` | 6 |
| `config.Config` | 30 | | `frontendutil.FrontendUtil` | 5 |
| `queue.Queue` | 25 | | `home.Service` | 1 |
| `download.Service` | 23 (conditional) | | `autotagservice.Service` | 22 |
**12 services expose a public `SetContext(ctx)`**, all called from
`OnStartup` (`backend/app.go:321-330`), all currently exported as
bindings.
### Frontend surface
- **93 `@go/...` import sites** (`@go/models` alone is 42).
- **23 `@runtime/runtime` sites — 22 import only `EventsOn`.**
- App source never touches `window.go`/`window.runtime`; only generated
code, the Vitest fake, and the e2e harness do.
### Harness surface — the real work
| Artifact | Lines | Fate |
|---|---|---|
| `.playwright/init-events.js` | 302 | **Full rewrite** (wraps v2 internals) |
| `frontend/test/support/wails-fake.ts` | 243 | Two factories rewritten; 480 tests ride on it |
| `backend/testctl/` | 891 | Light — one indirection may simplify |
| `scripts/bindings-check.sh` | 43 | Rewrite |
| `scripts/dev-headless.sh` | ~140 | Possibly replaced by `-tags server` |
| `e2e/perf/measure.mjs` | — | Loses binding enumeration |
### `webkit2_41` — ~30 sites, all deletions
`Makefile` (`:11,14,129,161,221,231,234`, lint matrix `:280-282`, test
matrix `:290-295`), `lefthook.yml:17,21,64`,
`scripts/bindings-check.sh:26`, `scripts/dev-headless.sh:15,134`,
`packaging/arch/PKGBUILD`, `packaging/homebrew/Formula/yellowjacket.rb`,
`CLAUDE.md:53-73` and `:1083`, `.planning/NOTES.md`,
`.pi/skills/yellowjacket-dev/SKILL.md`,
`.pi/skills/yellowjacket-dev/references/schema-change.md`,
`.pi/journal.md`.
---
## Design decisions taken up front
Recorded here so they are not re-litigated mid-phase.
**D1 — `events.Emit` keeps its `ctx` parameter.** v3 doesn't need it for
delivery, but `events.WithSink(ctx, rec)` is the test seam used by 7
test files, and 45 call sites across 13 production files pass a context
already. The context stops being a delivery mechanism and stays a
test-injection mechanism. **One file changes.** The alternative —
dropping the parameter — churns 45 call sites and every test for no
gain.
**D2 — the Makefile stays the front door.** Taskfile becomes an
implementation detail behind existing target names. `make dev`,
`make build-prod`, `make bindings`, `make e2e` all keep their names and
behaviour. `.pi/skills/yellowjacket-dev/` and `make skill-check` depend
on those names, and CLAUDE.md documents them.
**D3 — `wails3` stays a vendored Go tool.** The v2 CLI is in `go.mod`'s
`tool` block, invoked as `go tool wails`. Keep that shape; a global
install would be the first undeclared dependency in this repo's build.
**D4 — the `@runtime` alias becomes a local shim.** 22 files import
`EventsOn` from `@runtime/runtime`. Rather than editing 22 imports to
v3's `Events.On`, point the alias at a small local module that exports
an `EventsOn`-shaped function over `@wailsio/runtime`. Keeps the diff
small and gives Phase 5's fake exactly one seam to target.
**D5 — pin `beta.8` for the entire migration.** Upgrade deliberately,
never incidentally. Registration order and late-registration semantics
changed across betas (`wailsapp/wails#4066`).
---
## Phase 1 — Toolchain and build system
**Goal:** the repo builds and runs under `wails3`, with every `make`
target keeping its name.
`wails.json` (9 lines) is gone; v3 uses `build/config.yml` plus a
Taskfile tree — a genuinely larger and more visible build surface.
**Steps**
1. `sudo pacman -S webkitgtk-6.0` on the dev machine.
2. Scaffold a v3 project *beside* the repo and copy its `build/` tree
in wholesale, rather than hand-writing `config.yml`. Same discipline
as "seeds are produced by running the app."
3. Fill `build/config.yml`'s `info` block from `wails.json`'s `name`,
`outputfilename` and `author`; delete `wails.json`.
4. Swap the `tool` block: `wails/v2/cmd/wails` → `wails/v3/cmd/wails3`.
Add `github.com/wailsapp/wails/v3 v3.0.0-beta.8`.
5. Rewrite the Makefile's wails invocations behind unchanged target
names (`:11,14,129,161,221,231,234`).
6. Delete `webkit2_41` from all ~30 sites (see inventory).
7. Point `frontend:install`/`frontend:build` equivalents at `pnpm` —
the scaffold assumes `npm`; this repo uses pnpm
(`frontend/package.json.md5` is part of the dep-caching scheme).
**Acceptance:** `make build-dev` produces a running binary;
`make build-prod` still strips and UPX-compresses; `make skill-check`
passes; `grep -r webkit2_41` returns nothing.
**Est.** Half a session. Low risk, high churn.
---
## Phase 2 — Go bootstrap and services
**Goal:** the app starts, shows a window, and every service is bound.
**2a — `main.go:75-97`.** Split `wails.Run(&options.App{…})` into
`application.New(opts)` → `app.Window.NewWithOptions(…)` → `app.Run()`.
- `Title`/`Width`/`Height`/`MinWidth`/`MinHeight`/`BackgroundColour` →
`WebviewWindowOptions`.
- `Linux.WebviewGpuPolicy` survives (v3 keeps Always/OnDemand/Never).
- `Logger` → `slog`; `backend/logging/`'s adapter likely deletes
outright, since the repo already uses `slog` everywhere else.
- `AssetServer` → `application.AssetOptions{Handler: …}`.
- Re-check the NVIDIA/Wayland `WEBKIT_DISABLE_DMABUF_RENDERER=1`
workaround (`main.go:32-39,134-155`) — v3's `operatingsystem` package
detects the proprietary driver and may already do this.
**2b — `Bind` → `Services`.** `backend/app.go:204-225` becomes
`[]application.Service` via `application.NewService(...)`. The
conditional `download.Service` append still works.
**2c — the 12 `SetContext` methods → `ServiceStartup`.** This is the
largest structural port and v3 has a better answer than ours:
```go
ServiceStartup(ctx context.Context, options application.ServiceOptions) error
ServiceShutdown() error
```
The context is cancelled on app shutdown — strictly better than
`SetContext`. And because `internalServiceMethods` excludes these
names, the port **removes 12 spurious bindings** and the fake `context`
namespace from the generated models.
Sites: `autotagservice/service.go:204`, `config/config.go:284`,
`download/service.go:51`, `explore/explore.go:129`,
`explore/searchindex.go:265`, `frontendutil/frontendutil.go:23`,
`jobs/jobs.go:210`, `library/library.go:187`, `player/player.go:190`,
`playlist/playlist.go:167`, `queue/queue.go:196`,
`tagwriter/pipeline.go:81`.
> **Trap:** `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. Grep for it after
> the port.
**2d — `backend/app.go`'s runtime calls.**
- `WindowGetSize(ctx)` (`:521`) → `window.Size()`/`window.Bounds()`.
**Keep the sub-minimum guard** (`:526-536`); it exists because v2
reports garbage sizes during teardown and there is no reason to
assume v3 doesn't.
- `MessageDialog`/`QuestionDialog` (`:566-579`) → v3 dialogs API.
- `Quit(ctx)` (`:608`) → `app.Quit()`.
- `OnBeforeClose` returning `true` to veto → v3's cancellable window
event (`event.Cancel()`). This is the quit-during-tag-writes veto —
a data-safety path, so test it deliberately.
**2e — `backend/frontendutil/`** — five dialog methods, mechanical.
**2f — `backend/assets/handler.go`** — v3 changes asset serving. Note
`RegisterHandler` (`:65`) mounts testctl at `/__test/`; Phase 6 may
replace it with `ServiceOptions{Route:}` instead.
**Acceptance:** app launches, window is the persisted size, all 12
services callable, quit-during-writes still vetoes.
**Est.** One session. This is the "14 hours" the official guide prices.
---
## Phase 3 — Events
**Goal:** one file changes on the Go side; 22 imports get a shim.
Per **D1**:
```go
func Deliver(ctx context.Context, name string, data ...any) error {
if sink := sinkFrom(ctx); sink != nil {
sink.Emit(name, data...)
return nil
}
app := application.Get()
if app == nil {
return ErrNoRuntime // replaces the ctx.Value("events") probe
}
app.Event.Emit(name, data...)
return nil
}
```
**Unchanged:** 45 `events.Emit` call sites across 13 files;
`events.WithSink` in 7 test files; `backend/events/recorder.go`;
`/__test/emit`'s use of `events.Deliver`
(`backend/testctl/handlers_dev.go:118-123`);
`backend/events/cmd/genevents` and `frontend/src/events.ts` (that
generator reads a const block and knows nothing about Wails).
**Changed:** `backend/events/emit.go` only.
**`TestNoDirectRuntimeEmits`** (`noemit_test.go`): keep it, retarget the
needle from `.EventsEmit(` to v3's emit. Its original justification
weakens (no more `log.Fatalf`), but "there is exactly one emit path in
this tree" remains worth pinning — it is what keeps `emitStatus`-style
dedup honest.
**Frontend:** create the `@runtime` shim (D4) exporting `EventsOn` over
`@wailsio/runtime`'s `Events.On`. 22 import sites unchanged.
**Deferred, not done here:** `application.RegisterEvent[T]` overlaps
with `genevents`. Do not fold them together during the migration —
note it as follow-up work so a port doesn't become a redesign.
**Acceptance:** `make test` green; a `/__test/emit` still renders
push-driven views.
**Est.** Half a session.
---
## Phase 4 — Bindings
**Goal:** the frontend imports real generated v3 bindings.
**Steps**
1. Generate against the real services and **inspect the tree first** —
the exact nesting decides the codemod.
2. Remap `@go` in `frontend/vite.config.mts:7` and
`frontend/tsconfig.json:28`; drop the `wailsjs/go/**/*.js` exclude
at `tsconfig.json:50` (v3 emits `.ts`).
3. **Codemod all 93 `@go/...` import sites.** Phase 0 disproved the
hope that an alias absorbs this: `@go/library/Library` becomes
`@go/yellowjacket/backend/library`, a change of *shape*.
4. `@go/models` (42 sites) — v3 has no single `models.ts`; types come
from the per-package modules. This is the largest single cluster and
should be scripted, not hand-edited.
5. Rewrite `scripts/bindings-check.sh`. Its `chmod` dance and
`core.fileMode=false` diff exist purely because v2's generator wrote
three runtime files 755 — likely all deletable.
6. **Pin an explicit tag set for binding generation** and make it the
one the shipped binary uses. The generator is a static analyser, so
it sees only the configuration it is told about; we have three
(`webkit2_41`, `+indexbuild`, `+dev`) and `backend/testctl` is
`//go:build dev`. Getting this wrong means the generated API
reflects a configuration users never run. v2 had no such hazard
(runtime reflection).
7. Check whether any call site depends on the return being a plain
`Promise` — v3 returns `CancellablePromise`.
**Acceptance:** `tsc --noEmit` clean; `make bindings-check` passes and
is still a pre-commit hook and a CI step (`ci.yml:176`); the 12
`SetContext` bindings and the `context` model are **gone**.
**Est.** One session, mostly codemod-and-verify.
---
## Phase 5 — The Vitest fake (`make ui-test`, 480 tests)
**Goal:** 480 tests still run in ~2 s with no Wails, backend, or display.
`frontend/test/support/wails-fake.ts` (243 lines) fakes exactly two
globals, which is *why* the suite is that fast. The design survives;
the targets change.
- `makeGoProxy()` (`:179-193`) and `makeRuntimeProxy()` (`:197-225`)
are the whole change. The recursive `Proxy` is schema-free, so it
does not need to learn v3's binding surface — it needs to intercept
wherever v3 routes calls, now that `window.go` is gone. With D4's
shim in place, that is one seam.
- The `Listener` class (`:23-44`) and `notify()` (`:105-125`)
deliberately mirror v2's
`internal/frontend/runtime/desktop/events.js`, including
`maxCallbacks` expiry and the ordering where `EventsEmit` notifies
local JS listeners **before** Go. **Re-derive this against v3's
actual implementation rather than porting it.** If v3 changed the
ordering, failures will look like store bugs, not fake bugs.
- `reset()` (`:163-169`) keeps listeners on purpose, because store
singletons are never re-imported. That constraint is unchanged.
**Acceptance:** `make ui-test` green, **zero test-file edits**. Any test
that needs changing is evidence the fake is wrong, not the test.
**Est.** One session. This is where the official estimate stops
applying.
---
## Phase 6 — E2E harness and testctl
**Goal:** `make e2e` green with **zero spec edits**. That is the
acceptance test for the whole migration.
**6a — `.playwright/init-events.js` is a full rewrite (302 lines).** It
does not use the public API by design; its own header says so. It wraps
`window.wails.EventsNotify` — in v2 every backend event enters the page
at exactly one place (`ipc_websocket.js`:
`case "n": window.wails.EventsNotify(message)`) — and installs a
property accessor on `window` to wrap at assignment time, because
`window.wails` doesn't exist when an initScript runs.
None of that survives. What **must** survive is the public surface on
`window.__yjEvents`: `wait()`, `ready()`, `call()`, `all`, `since`,
`names`, `count`, `last`, `reset`. `e2e/support/fixtures.ts`, every
spec, and `e2e/perf/measure.mjs` are written against it.
v3 equivalents, all settled by Phase 0:
- Hook `window._wails.dispatchWailsEvent` (same accessor-on-assignment
trick still applies) for inbound events.
- `call()` routes through
`Call.ByName('yellowjacket/backend/queue.Queue.GetState', …)` and
**drops its timeout race entirely** — v3 rejects on bad args and
unknown methods.
- `ready()` likewise becomes a `ByName` call rather than a
`window.go?.queue?.Queue?.GetState` poll.
**6b — the `window.go` regression.** `harness.spec.ts:18-19` asserts
"all 11 bound services land on `window.go`" (11 where the count is now
12 — download is conditional), and `perf/measure.mjs:130-180`
*enumerates* bindings to wrap every bound method, which is what makes
"did that refetch the library" a fact rather than an inference. v3 has
no runtime enumeration surface. Two options:
1. **Preferred.** Generate the list at build time from
`frontend/bindings/` — it is a real module tree, so it can be
imported and walked — and wrap that.
2. Wrap an explicit hand-maintained list. Cheaper, and silently goes
stale — exactly the failure mode `bindings-check` exists to prevent.
If (2), say so in `measure.mjs` and add it to what `bindings-check`
guards.
**6c — `backend/testctl/` gets easier.** Its only Wails coupling is
`Deps.Context func() context.Context` (`testctl.go:46-53`) — a function
rather than a value "because the context only exists after OnStartup."
`ServiceStartup(ctx, opts)` may make that indirection unnecessary.
Better still, v3 supports a service implementing `http.Handler`
registered with
`application.NewServiceWithOptions(svc, application.ServiceOptions{Route: "/__test"})`
— a first-class replacement for mounting a mux on the asset server.
The double gate (`//go:build dev` + `YJ_TESTCTL=1`) stays exactly as is.
**6d — `scripts/dev-headless.sh` and the port.** Evaluate replacing the
hand-rolled headless launch with `-tags server`. Two constraints:
`e2e/playwright.config.ts` expects `:34115` (set `WAILS_SERVER_PORT`),
and testctl must still mount. If server mode complicates the mount,
keep the existing script — the win is tidiness, not capability.
**Acceptance:** `make e2e` green on **both** Chromium and WebKit, zero
spec edits.
**Est.** One to two sessions. The largest and riskiest phase.
---
## Phase 7 — CI and packaging
- `.gitea/workflows/ci.yml:74,220`: `libwebkit2gtk-4.1-dev libgtk-3-dev`
→ `libwebkitgtk-6.0-dev libgtk-4-dev`.
- The PulseAudio null-sink setup and its three-second timing check are
unrelated and stay exactly as they are.
- The WebKit Playwright project (`:364-369`, `if: ${{ !cancelled() }}`)
matters **more** after this, not less — it is the only approximation
of the shipping renderer, and v3 may change which WebKit that is.
Keep the `!cancelled()` guard; it is why WebKit signal was silently
absent for two sessions before.
- `packaging/arch/PKGBUILD` and
`packaging/homebrew/Formula/yellowjacket.rb` carry the build tag and
dependency lists.
- `make skill-check` fails if `.pi/` documents a nonexistent make
target — update `.pi/skills/yellowjacket-dev/SKILL.md` and
`references/schema-change.md` in the **same commit** as any rename.
- Update `CLAUDE.md`: the `webkit2_41` mandate (`:53-73`), the
Arch/Ubuntu tag rationale (`:1083`), the events-wrapper section, and
the harness description.
**Acceptance:** a green CI run on both jobs.
**Est.** Half a session.
---
## Phase 8 — What v3 unlocks (explicitly out of scope)
Listed so nobody smuggles them into the port and calls it a migration.
- **System tray with menus.** v2 has no first-class tray API; v3 does
(`systray-basic`, `systray-menu`: attached window, left-click toggle,
right-click menu, light/dark icon variants). For a music player this
is real — play/pause/skip without raising the window, minimise to
tray. Most likely thing to make the migration worth *scheduling*.
- **Multi-window** — a detached mini-player as a first-class window.
- **Native menus** (`window.SetMenu`, `app.NewMenu`).
- **Single-instance** with `OnSecondInstanceLaunch`.
- **Typed events** via `RegisterEvent[T]`, possibly retiring
`genevents`.
- Richer bindings (real param names, preserved doc comments) — a DX
nicety, not a driver.
`backend/mediacontrols` (MPRIS over raw D-Bus) and `backend/profiling`
(pprof, build-tag-gated) touch no Wails API and are unaffected.
---
## Risk register
| Risk | Severity | Status |
|---|---|---|
| v3 can't drive the headless harness | ~~fatal~~ | **Retired.** `-tags server` verified: calls + events, no display |
| Arch/Ubuntu need different webkit tags | ~~high~~ | **Retired.** Both ship webkitgtk-6.0; default builds in the CI container |
| No `window.go` → e2e/perf lose binding enumeration | **high** | *New, confirmed.* Decide 6b option (1)/(2) |
| 93 `@go` sites need editing after all | **high** | *Confirmed.* Bindings nest by import path; codemod required |
| Binding generation analyses the wrong build config | **high** | *New.* Pin tags in Phase 4 step 6 |
| Beta churn mid-migration | high | Pin `beta.8`. `beta.3`→`beta.8` in one app's lifetime |
| E2E rewrite silently weakens coverage | high | Acceptance = `make e2e` green, **zero spec edits** |
| v3 event ordering differs from v2's | medium | Re-derive the fake; don't port it |
| `ServiceShutdown()` signature trap | medium | Silent no-call; grep after Phase 2c |
| Quit-during-writes veto breaks | medium | Data-safety path; test deliberately in 2d |
| Regression no tier covers | medium | `make perf` before/after on the same seed |
| GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run |
---
## Sequencing and staging
**Phases 14 must not be merged.** They leave the app building and
running with the harness broken, and plan 005's whole point is that a
broken harness means a coding agent cannot develop this repo at all.
Phases 5 and 6 are what make the branch mergeable — and they are the
majority of the work.
Recommended shape:
1. Land the **`webkit2_41` deletion + `pacman -S webkitgtk-6.0`**
independently if desired — it is useful on its own and touches
nothing else. *(Optional; can also ride along in Phase 1.)*
2. Branch `wails-v3` off a clean `wip`. Phases 14 as separate commits
on it, kept local.
3. Phases 5, 6, 7 onto the same branch.
4. One merge to `main` when `make test`, `make ui-test`, `make e2e`
(both browsers) and `make lint` are all green.
**Before starting:** `wip` currently has ~75 uncommitted files. Commit,
stash, or use a worktree — do not begin Phase 1 on a dirty tree.
**Total estimate:** 46 focused sessions. The official guide's "14
hours" covers roughly Phase 2 alone.
---
## Open questions
- Does v3 handle the NVIDIA/Wayland DMABuf workaround itself
(`main.go:32-39,134-155`)? Its `operatingsystem` package detects the
driver, which suggests it might. *Check in Phase 2a.*
- Is `backend/logging/`'s `logger.Logger` adapter deletable outright
once v3 uses `slog`? *Check in Phase 2a.*
- Does GTK4 change anything visible about rendering vs GTK3?
*Unmeasured; visual check on first run.*
- Should `application.RegisterEvent[T]` replace
`backend/events/cmd/genevents`? *Deliberately deferred past the
migration.*
- Does `-tags server` complicate mounting testctl? *Decides Phase 6d.*
**Answered by Phase 0** (kept so they aren't re-asked): which beta to
target (`beta.8`); whether v3's call-by-name rejects on bad args (yes,
cleanly — the timeout race goes); whether the headless dev surface
survives (yes, and improves).
---
## References
- [Migration guide](https://v3.wails.io/migration/v2-to-v3/) — feature
mapping, testing checklist, the "14 hours" estimate
- [What's New in v3](https://v3.wails.io/whats-new/)
- [v3 beta announcement](https://v3.wails.io/blog/wails-v3-beta/)
- [Application lifecycle](https://v3.wails.io/concepts/lifecycle/)
- [`pkg/application` API](https://pkg.go.dev/github.com/wailsapp/wails/v3/pkg/application)
- [v2→v3 discussion #4509](https://github.com/wailsapp/wails/discussions/4509)
- [Late service registration #4066](https://github.com/wailsapp/wails/pull/4066)
- **Reference v3 app:** `/mnt/vault/dev/ljos` — project layout,
`build/config.yml`, Taskfile scaffold, `application.Service`,
`SingleInstanceOptions`. **Caveat:** it deliberately uses no generated
bindings and no events (its frontend talks HTTP to a separate
server), so it models Phase 1 well and Phases 36 not at all.
- Local Phase 0 artifacts (scratchpad, ephemeral): scaffolded `spike/`
app, `q2.mjs`/`q2b.mjs` browser probes, `q3_test.go` emit-safety
tests.