# 009 — Wails v3 migration **Status:** Phases 0–7 complete. CI needed nothing (it had ridden along with Phases 1–6); **packaging needed everything** — both recipes still called v2's CLI, installed from v2's output path, and built from a scaffold's `yjref` metadata. Locally green: `make lint` and `make test` (all three build configurations), `tsc --noEmit`, `make build-prod`, 757 Vitest tests across all 58 files, `make e2e` 92/92 on chromium, `make bindings-check`, `make skill-check`, `make commit-check`, `make css-check`, and `make perf` runs and reports real binding counts again. **WebKit is unverified** — it is CI-only by design — so the merge waits on a green CI run. **Branch:** `wails-v3`, off `main` at `edb13a6`. **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 1–4 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///.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(, …)` 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. > **Corrected after the fact.** `make build-prod` strips and trims > (`-trimpath -ldflags="-w -s"`, 28.8 MB against the dev build's > 38 MB) but **does not UPX-compress**: that was v2's `wails build > -upx` flag, and v3's Taskfile has no equivalent. Neither `make > build-dev` nor `make build-prod` was actually run when Phase 1 was > recorded — both were broken until the shim below. Whether to > reintroduce UPX is a packaging decision for Phase 7, not a port. **Est.** Half a session. Low risk, high churn. ### Phase 1 — what actually landed Split across two commits, because steps 5 and 6 could not land before the app was on the v3 API: swapping the build tag before `main.go` was ported would have broken the only build that then worked. - **`e7873bd`** — `wails/v3 v3.0.0-beta.8` pinned, `wails3` added to the `tool` block, `build/` asset tree copied from a scaffold with `config.yml` filled from `wails.json`, `Taskfile.yml` on pnpm. - **With Phase 2/3** — the Makefile's six `go tool wails` invocations, every `webkit2_41` site (50 of them, not the ~30 the inventory estimated), `lefthook.yml`, both packaging recipes, and `ci.yml`'s apt lists. `backend/logging` is **deleted**, not ported, answering one of the open questions: v3 takes a `*slog.Logger` directly, so v2's `logger.Logger` adapter had no remaining caller. **Two things the plan did not anticipate.** **`build/` was already taken.** This repo used it as *ignored* build output (`build/bin/` held three v2 binaries), and v3 wants it for *tracked* build assets. `.gitignore` now names `build/bin/` and `bin/` rather than `build`, and the assets are committed. The mobile platform trees (`build/android`, `build/ios`, ~40 files) are not carried — this is a desktop player and cannot target them — and their `includes:` entries are dropped from `Taskfile.yml`. **The v3 CLI needs the GTK4 toolchain to compile at all — not just the app.** Before `webkitgtk-6.0` was installed, `go tool wails3` failed outright, because the CLI links `internal/operatingsystem`, which `pkg-config`s `gtk4 webkitgtk-6.0` under default tags. That is sharper than the plan's "fallback if GTK4 misbehaves": the gtk3 fallback covers the *app*, and reaching it meant building the CLI by hand with `go run -tags gtk3 …/cmd/wails3`. Resolved — `webkitgtk-6.0` 2.52.5 and `gtk4` 4.22.4 are installed, and `go tool wails3 doctor` now reports both toolchains with gtk3 and webkit2gtk marked legacy, which is the state Phase 0 described. The default (GTK4) path is what the migration targets; the gtk3 escape hatch is recorded here only so the next person recognises the failure if they meet it on a fresh machine. --- ## 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 "1–4 hours" the official guide prices. ### Phase 2 — what actually landed The shape held, but four things differed from the plan. **`WebviewGpuPolicy` did not survive where the plan said it would.** It is not on v3's `LinuxOptions` at all — it moved to `LinuxWindow`, inside `WebviewWindowOptions`, so it is a per-window setting now. The NVIDIA/Wayland `WEBKIT_DISABLE_DMABUF_RENDERER` workaround in `main.go` is **kept**: v3's `operatingsystem` package detects the driver, but nothing in it sets that variable, so removing ours would have removed the fix. **There is no `OnStartup` or `OnDomReady` option.** Both are gone from `application.Options`. The app-level wiring is driven from `app.Event.OnApplicationEvent(events.Common.ApplicationStarted, …)`, which fires after every service's `ServiceStartup`. That ordering is what makes the split work: the services take their context from the runtime, and `OnStartup` is left holding only the cross-service wiring that belongs to no single service. **Ten services convert, not twelve.** `jobs.Registry` and `explore.SearchIndex` have a `SetContext` too, but neither is bound — the registry is wrapped by `jobs.NewService` and the index is internal to `explore.Service` — so both keep it. Converting them would have been churn for no binding removed. The ten that are bound now implement `ServiceStartup`, and each therefore imports `wails/v3/pkg/application`, which is a real cost: five files imported wails before, fifteen do now. **`application.NewService` is generic over a concrete pointer type**, so `FEBindings []any` could not survive as a slice of values — the binding generator is a static analyser that reads those call sites, so a `[]any` would have generated nothing at all. It is `[]application.Service` built from explicit `NewService` calls. **The quit veto changed shape, and this is the part to test deliberately.** v2's `MessageDialog` blocked and returned the button; v3's `Show()` returns immediately and the answer arrives on a `Button.OnClick` callback — on GTK4 `gtkDispatch` runs the dialog in a goroutine. So `ShouldQuit` cannot ask and answer in one call. It vetoes the quit, shows the dialog, and calls `app.Quit()` from the callback if the user says quit; `quitConfirmed` is what stops that second `Quit()` coming straight back and asking again, and `quitAsking` stops a second close attempt stacking dialogs. **Window state moved off the quit path entirely.** It was `OnBeforeClose`'s other job; it is now a `Common.WindowClosing` hook, because the size has to be read while the window still exists and v3's `OnShutdown` takes no context and no window. The sub-minimum guard is kept for the reason it was written. --- ## 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 3 — what actually landed Exactly as designed, and it is the migration's whole point: the `ctx.Value("events")` probe of a **v2-private context key** is gone, replaced by `application.Get() == nil`. D1 held — `events.Emit` keeps its `ctx`, all 45 call sites and all 7 test files are untouched, and one production file changed. Two adjustments. `Deliver` no longer rejects a nil context outright: a nil context cannot carry a sink, but it is no longer a reason not to deliver, because delivery does not go through the context any more. And `TestNoDirectRuntimeEmits`'s needle moved from `.EventsEmit(` to `.Event.Emit(` — the test is kept, but its justification is now the weaker one written into its doc comment: not "a direct call can kill the process" (v3's emit cannot), but "one emit path is what lets `emitStatus` drop an unchanged payload for every caller at once". The six test files that called `SetContext` on a converted service now call `ServiceStartup(ctx, application.ServiceOptions{})`. That is the one place the plan's "zero test edits" ambition does not apply — it is Phase 2's rename reaching the tests, not Phase 5's fake. --- ## 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 4 — what actually landed `frontend/wailsjs/` is deleted; `frontend/bindings/` is committed in its place. **272 methods across 12 services**, and the acceptance criteria hold: no `SetContext` binding survives and there is no `context` model, which is Phase 2c's `ServiceStartup` port showing up where it was predicted to. **The alias absorbs the prefix, so the codemod was one line per site.** `@go/*` points at `bindings/yellowjacket/backend/*` rather than at `bindings/`, so `@go/library/Library` became `@go/library/library.js` — the same shape, lowercased, plus the extension the generated tree uses internally. The models cluster was the only structural change: v2's single `models.ts` of namespaces became one module per package, so `import type { library } from '@go/models'` is `import type * as library from '@go/library/models.js'` and every `library.Track` usage is untouched. **Two sites the plan's inventory missed**, both because they are outside `src/`: `frontend/index.ts`'s three imports, found by the vite build rather than by `tsc` (the alias resolves for the compiler and not for the bundler when the path's case is wrong on a case-sensitive filesystem). **The 102 type errors were not migration damage. They were v2's generator being caught lying**, and that is worth stating because the temptation is to suppress them. A Go `nil` slice marshals to JSON `null` and always has; v2 typed it `T[]`. A Go named string type is a closed set; v2 typed it `string`. v3 types them `T[] | null` and as a real TS `enum`, and there is no generator flag to turn either off — correctly, since both are true. So the fix is one seam rather than 78 patches: **`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`, so an absent key and a nil value are indistinguishable downstream), and `compact` is the same thing for a map arriving as a *field*. 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. Where a nullable slice is a model *field* rather than a return value there is no boundary to put it at, and those are `?? []` at the point of use — `score.candidates`, `shelf.albums`, `page.shelves`, `view.items`. Four smaller consequences, all of them v3 being stricter: - **`createFrom` is gone.** v3 emits interfaces (`-i`), so the three `explore.ShelfPage.createFrom({…})` sites are object literals and `new tracklist.Column()` is one too. - **An enum needs a value import.** `import type * as download` cannot reach `Format`, so `download-clients.ts` imports it separately — and its local format list is now `Format[]` rather than `string[]`, which is a small win v2 could not have given. - **Four test fixtures widen an enum field back to its value union** (`` state?: `${Job['state']}` ``) rather than casting, so the fixtures stay literal-checked. - `job-store.ts` still hand-mirrors `JobState`/`JobKind` as string unions beside the generated enums. Left alone deliberately; folding them together is a redesign, not a port. **`bindings-check` got simpler and slower.** The `chmod` dance and `core.fileMode=false` diff are gone with v2's generator (which wrote three runtime files 755); a check for added or removed *files* is new, because a renamed service is now a renamed module rather than a changed line. It runs in ~3.5 s warm against v2's ~1.5 s, ~20 s on a cold build cache — v3's generator is a static analyser over the whole package graph. **On step 6 (the tag set): the answer is "no flag", and that is the deliberate answer.** The generator sees only the configuration it is told about, and the one that matters is the one users run — which, since Phase 1, is the *default* tag set. Neither of this repo's other two configurations (`indexbuild`, `dev`) adds a bound service, so generating under them would only widen the API past what ships. Written into `scripts/bindings-check.sh` so it is not re-derived. Note this is *not* what Wails' own Taskfile does (`-tags server,production`), for the good reason that its shipped artifact is the Docker image. **One error is left, and it is Phase 5's.** `test/harness.test.ts:69` imports `EventsEmit` from the shim, which does not export one — because nothing in `src/` emits from the frontend, and adding an export to production code to satisfy a test would be the wrong way round. The test underneath it is a bigger problem than its import: it asserts that a frontend emit notifies in-page listeners before reaching Go, which v2 did and **v3 does not** — `Events.Emit` in `@wailsio/runtime` calls the backend and touches no local listener. That is exactly the "re-derive the fake, don't port it" risk the plan flagged, arriving early. `make ui-test` is broken regardless: the fake still fakes `window.go`. --- ## 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 5 — what actually landed **757 tests pass across all 63 files, and one test file changed.** The plan's design survived and got smaller, because v3 has a seam v2 did not. **`setTransport()` is the whole fake.** v3 routes *every* runtime call — bindings, event emits, window, dialogs, clipboard, screens — through one IPC transport, and replacing it is public, documented API. So the fake covers strictly more than v2's two globals did while being shorter, and the tests still exercise the real generated bindings, the real runtime and the real store code. **The dispatcher is deleted rather than re-derived.** The plan said to re-derive `Listener`/`notify()` against v3 instead of porting them; the better answer is that neither is needed. `emit()` goes through `window._wails.dispatchWailsEvent`, the exact entry point the backend's push uses, so delivery, `maxCallbacks` expiry and the post-dispatch filter are the runtime's own code; registration and unregistration are `Events.OnMultiple` / `Off` / `OffAll`. What *is* mirrored is one line of Go — how `EventManager.Emit` packs variadic data into an event's single `data` field (none is null, one is the value, more is the slice), which is invisible when wrong and shows up as a store reading `undefined` off its payload. One thing stayed non-public: the listener registry, for `listenerNames()`. `listener.js` has no entry in the package's exports map, so `vitest.config.mts` aliases it. It buys the one question the public surface cannot answer — did importing a store subscribe it — and if Wails moves the file the import throws at setup, which is loud. **A binding carries an ID, not a name, and the map has to be complete.** `$Call.ByID(2822423495)` is FNV-1a over `yellowjacket/backend/home.Service.GetShelves`, so the fake computes the same hash — deriving the FQN from the generated tree rather than writing it down. The Go type's casing survives in exactly one place, each package's `index.ts` (`export { Library }`); the filename cannot tell you `frontendutil.ts` is `FrontendUtil`. Building the map lazily as paths are mentioned does not work: 21 assertions read `calls()` with no argument and compare the whole list of paths, including methods no test stubs. An unmapped ID records as `#`, which fails the assertion naming it. **Two things had to change that are not the fake**, and both are findings rather than accommodations: - **`fixture()` drains microtasks between two renders.** A v3 binding settles several hops later than v2's — `Call()`, an async `runtimeCallWithID`, the transport, a `CancellablePromise`, against v2's one resolved promise — and the tests were already written as though `fixture()` meant "mounted *and loaded*". Fixing it there rather than in each test is what kept this to one test-file edit. Microtasks and **not** `setTimeout`: the first attempt used a timer, which hung `transport.test.ts` for 45 s because it installs fake ones. - **`tracklist-store` keeps its defaults on an empty answer.** `GetTrackListColumns` substitutes `tracklist.DefaultColumns` only when the whole config section is missing — a section that exists with no columns returns nothing, and a track list with no columns is not what that means. Until v3 this was accidental: the binding was typed `Column[]`, an absent answer arrived as `undefined`, and `.map` threw into the `catch` that restores the defaults. Phase 4's `list()` turned that into an honest empty list and the accident stopped working. **The one test file edited was `harness.test.ts`, and it was asserting something no longer true.** v2's `EventsEmit` notified in-page listeners *before* Go, so a frontend emit was observable synchronously. v3's `Events.Emit` does not touch the local registry at all: it calls the backend, and `EventProcessor.Emit` sends the event back out to every window, including the emitting one. The page still sees its own emit, one round trip later. The test says that now, and the fake reproduces it with a microtask. That is the "re-derive, don't port" risk paying off — ported blindly, this would have looked like a store bug. **`make ui-test` still cannot complete in one run on this machine, and that is not this migration.** A single browser session dies partway through the 58 files it queues, with "Cannot connect to the iframe" after ~5 s. It reproduces **unchanged at `c9905fb`**, the commit before Phase 4 — checked in a worktree, not assumed — so it is a resource limit here (6 GB available, 9 GB already in swap), not a regression. Run in batches of six it is 757 passed, 0 failed. If CI is green on the single run, nothing needs doing; if it is not, that is a pre-existing problem to file separately rather than something Phase 5 introduced. **Not verified: `make ui-visual`.** Screenshot baselines only mean anything on the machine that recorded them, and nothing here changes what a component renders. --- ## 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 6 — what actually landed **`make e2e` is green on chromium: 92 passed, 0 failed.** Three of the four things this phase replaced came out better than what they replaced, and the fourth — `window.go` enumeration — turned out not to be needed at all. **6d decided itself.** The plan asked whether `-tags server` was worth adopting; it was not optional. `dev-headless.sh` ran a `-tags dev` binary whose `app_dev.go` parsed `-devserver` / `-assetdir` straight out of `os.Args`, and that file went with v2 — so the harness had **no server at all**, not a worse one. `-tags dev,server` is a first-class mode, it needs no display, and testctl mounts on it unchanged. **Xvfb is gone** from the script and from CI, which retires the "Xvfb is not optional" note the script had carried since plan 005. `dbus-run-session` stays, for MPRIS, exactly as before. **6a hooks two places, and the outbound one is the good surprise.** Inbound is `window._wails.dispatchWailsEvent` — the entry point the backend's own push uses. It is wrapped by *pre-creating* the object the runtime keeps (`window._wails = window._wails || {}`) and putting an accessor on the one property, which is simpler than v2's accessor-on-`window`, where the whole object was replaced. Outbound is **`fetch`**. v3 routes every runtime call — bindings, event emits, window, dialogs, clipboard — through one POST to `/wails/runtime`. There is no global to wrap the way v2's `window.runtime` could be, and it does not matter: one hook sees calls from any module, needs no walk of an object graph, and cannot miss a call made before the harness looked, which is what v2's "runs twice" dance in `measure.mjs` existed to work around. **`__yjEvents.call` is now HTTP, and that is what unblocked everything else.** It posts by *method name*, so it depends on nothing in the app's bundle and works on a page with no init script. That is what lets `seed-sandbox.sh` **drop `playwright-cli` entirely** — it drove `AddLibrary` through a real browser only because `window.go` was v2's one way in — taking with it a global npm install, a second Chromium, and the `PLAYWRIGHT_BROWSERS_PATH` revision dance in CI. A seed is `curl` now and still produced by running the app. **6b: option (1), and it costs less than feared.** `e2e/support/method-ids.mjs` derives `methodID → pkg.Type.Method` from `frontend/bindings/` by reading the id literal beside the function that sends it — no hashing, nothing to drift. Plain `.mjs` rather than `.ts` because `measure.mjs` runs under bare `node`, and one derivation is better than two that can disagree. `harness.spec`'s enumeration was not worth replacing in kind: "is this the real app" is now asked of the runtime (`_wails.clientId`, `dispatchWailsEvent`) and of the backend (a real method answers, an invented one is refused), which is a better question than `Object.keys`. **6c: nothing to do.** testctl's mount on the asset handler works in both modes and is shared with `/artist-images/`; `ServiceOptions{Route}` would be churn. `Deps.Context` stays a function, because `testctl.Register` still runs in `NewYellowJacketApp`, before any context exists. **Four bugs, and the migration is how each surfaced.** - **The cross-service wiring never ran headless.** Phase 2 hung it off `Common.ApplicationStarted`, which 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 harness build did not — "No player set, cannot load track", the queue with no `TrackLoader`, a track that changed the queue and then silently did nothing. It is a service registered last now (`backend/startup.go`), which gets the ordering from the mechanism rather than from an event: services start in registration order, on the main goroutine, in every mode. - **Six specs called `SetQueue` with three of its four arguments.** v2 accepted the call and filled the gap with a zero value; v3 answers `expects 4 arguments, got 3`. `NO_QUEUE_SOURCE` says explicitly what was being supplied silently. - **`requested-badge`'s cleanup was a no-op.** It read `window.go` inside a type assertion and `return`ed on `if (!svc)` — the silent cleanup its own comment was written to prevent, one migration later, which is why the spec failed against its own leftovers. It posts to the runtime endpoint now, which needs no bridge and no global. - **`SearchIndex.Search` trusted a startup latch.** `IsReady()` is set once, so rows staged by a spec afterwards were unsearchable, and three specs passed only when an earlier one happened to flip it — order-dependent, and reproducibly red in isolation. `shelves.go` had already fixed exactly this and left `hasCatalogRows` behind; the search path uses it as the fallback, with the latch still the fast path. **Two spec edits, both deletions of assertions about v2.** `harness.spec` checked `Object.keys(window.go)` and that a bad call *hung* — the second being a test that the harness's own invented deadline fired, since v2 gave it nothing else. And `album-actions` asserted a `.tracklist-legend` that **`dcc40b1` deleted on `main`**: that spec has been failing since, verified in a worktree, and what replaced it (the dimming and its `aria-disabled`) is covered in `frontend/test/components/album-actions.test.ts`. **One thing to know before trusting a local run.** `dev-headless.sh` does not set `YJ_CORE_INDEX_URL`, so a local `make e2e` fetches the real 1.1 M-row explore artifact and then behaves differently from CI (which points it at a dead address at the job level) — slower, and with testctl's snapshot/restore copying an enormous table set. Run it the way CI does: ``` YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" make dev-headless SEED=default ``` **Not verified: WebKit.** Playwright's Linux WebKit links Ubuntu libraries Arch does not provide, so it remains CI-only — which is precisely why `ci.yml`'s `if: ${{ !cancelled() }}` on that step still matters. --- ## 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 7 — what actually landed Most of the bullets above had already ridden along with Phases 1–6: `ci.yml`'s apt lists were `libwebkitgtk-6.0-dev libgtk-4-dev` on both jobs, Xvfb and `@playwright/cli` were gone from it, the PKGBUILD's `depends=()` was `webkitgtk-6.0 gtk4`, and `nfpm.yaml` shipped the GTK4 dependency set. **What had not been checked is whether any of the packaging recipes still work**, and neither did. **Both of them were still calling v2's CLI.** `go tool wails3 build -clean -trimpath -ldflags …` fails outright — `flag provided but not defined: -clean`. v3's `build` takes `-tags`, `-obfuscated` and `-garbleargs` and nothing else, because the build is a Taskfile tree now and `-trimpath`/`-w -s` live in the production task's own flags. Both recipes also installed from `build/bin/`, which is v2's output path; v3 writes to `bin/`, and `build/` is *tracked build assets*. Neither had been run since Phase 1 — the same gap the corrected note under Phase 1 records about `make build-dev`. **The version stamp needed a seam, and it is this repo's one edit to the scaffold Taskfiles.** `wails3 build` has no `-ldflags`, and `build:native` computes `BUILD_FLAGS` in its own `vars:`, so a CLI variable cannot override it. `LDFLAGS_EXTRA` is appended *inside* the production `-ldflags` string in `build/linux/Taskfile.yml` and `build/darwin/Taskfile.yml` (identical on both, so the Homebrew formula has one invocation rather than two), empty by default so `make build-dev` and `make build-prod` are unchanged. Verified end to end, not by reading the template: `wails3 task build LDFLAGS_EXTRA="-X 'main.version=v9.9.9' …"` produces a binary that logs `version: v9.9.9` on startup. **The tasks invoke `wails3` by bare name**, which is why the Makefile has `scripts/toolbin` — and neither packaging recipe had it, so both would have died at the first sub-task even with correct flags. Both prepend it now. **Bundling is a separate step from building in v3**, which the formula did not know: `task build` produces a bare binary on *both* platforms, and the macOS `.app` is `task package`. The formula's `Dir["build/bin/ *.app"]` would have found nothing and `odie`'d. **The build assets were a scaffold's, not this app's** — and this is the find that mattered most. `build/darwin/Info.plist` named `CFBundleExecutable` **`yjref`** (the scratchpad app Phase 1 scaffolded from) and `com.example.yjref`; `nfpm.yaml` packaged `./bin/yjref` to `/usr/local/bin/yjref`; the `.desktop` template said "A yjref application"; the Windows manifest said `com.example.yjref`. A macOS `.app` built from that plist would not have launched. They are generated from `build/config.yml`, so the fix is `wails3 task common:update:build-assets` and filling that file's `info` block — which had also never been filled from `wails.json`, contrary to Phase 1 step 3. Two consequences recorded in place: nfpm's `homepage` and `license` are **not** derived from `config.yml` and survive the refresh (they were `wails.io` / `MIT`), and the refresh regenerates `build/ios/` and `build/android/`, which are gitignored rather than deleted-and-rediscovered on every run. **Documentation.** `make skill-check` passes; `.pi/` needed no changes (Phase 6 had already retired `window.go` and Xvfb from it). `README.md` still told a contributor to `go install wails/v2/cmd/wails` and `apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev`. CLAUDE.md gained a **Packaging** section for the four Taskfile facts above, and its lifecycle, bindings, harness, events and CI sections were rewritten onto v3 — including the point of the whole migration, that `TestNoDirectRuntimeEmits`'s justification is now the *weaker* one. **Locally green after the change:** `make lint` and `make test` (all three build configurations), `tsc --noEmit`, `make css-check`, `make bindings-check`, `make skill-check`, `make commit-check`, `make build-prod`, 757 Vitest tests across all 58 files (batched — the single-run failure still reproduces and is still this machine's resource limit, unchanged from Phase 5), and `make e2e` **92/92 on chromium**. **Still unverified: WebKit**, and it is CI-only by design. That is the one thing standing between this branch and a merge. --- ## 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~~ | **Retired.** 6b option (1): `e2e/support/method-ids.mjs` derives id → name from `frontend/bindings/`. The fetch hook made enumeration unnecessary for *wrapping*; only labelling needed the map | | 93 `@go` sites need editing after all | ~~high~~ | **Retired.** Codemod done in Phase 4; the alias absorbed the prefix, so it was a specifier rewrite | | Binding generation analyses the wrong build config | ~~high~~ | **Retired.** Default tags *are* the shipped configuration since Phase 1; recorded in `scripts/bindings-check.sh` | | Beta churn mid-migration | high | Pin `beta.8`. `beta.3`→`beta.8` in one app's lifetime | | E2E rewrite silently weakens coverage | ~~high~~ | **Retired.** 92/92 on chromium. Two spec edits, both deleting assertions about v2 behaviour that is gone; one deletion of a spec already failing on `main` | | `make ui-test` cannot complete in one browser session here | low | *New.* Pre-existing — reproduces at `c9905fb`. A resource limit on this machine; batched runs are green. Watch CI | | v3 event ordering differs from v2's | ~~high~~ | **Retired.** Confirmed and handled in Phase 5: v3's frontend `Events.Emit` round-trips through Go instead of notifying locally first. One test asserted the old behaviour and now asserts the new | | `ServiceShutdown()` signature trap | medium | Silent no-call; grep after Phase 2c | | An app-level event that a headless mode never emits | — | *Found in Phase 6, not predicted.* Server mode emits **no** application events; the cross-service wiring is a service now. Anything else keyed on `Common.*` is suspect | | 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. Note the fixture seed leaves the bulk-library rows blank; a real comparison wants `make sandbox-seed-bulk` | | GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run | --- ## Sequencing and staging **Phases 1–4 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 1–4 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:** 4–6 focused sessions. The official guide's "1–4 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 "1–4 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 3–6 not at all. - Local Phase 0 artifacts (scratchpad, ephemeral): scaffolded `spike/` app, `q2.mjs`/`q2b.mjs` browser probes, `q3_test.go` emit-safety tests.