Files
yellowjacket/.planning/plans/active/009-wails-v3-migration.md
T
yonluandClaude Opus 5 4471db3aef feat(wails): move the Go side to v3
Phases 2 and 3 of plan 009, plus the parts of phase 1 that could not
land before them. Nothing in the tree imports wails/v2 any more; all
three lint and test configurations are green and `go build .` produces
a running binary.

The point of the migration is one file. backend/events/emit.go probed
ctx.Value("events") — a v2-*private* context key — to decide whether
emitting was safe, because runtime.EventsEmit called log.Fatalf on a
context without the runtime and took the process down with it. v3's
emit takes no context, so that is now application.Get() == nil. D1
held: events.Emit keeps its ctx as the WithSink test seam, and all 45
call sites and 7 test files are untouched.

The bootstrap splits into application.New + Window.NewWithOptions +
Run. Ten bound services implement ServiceStartup instead of being
handed a context by hand from OnStartup, which also stops ten
SetContext methods being exported as bindings. jobs.Registry and
explore.SearchIndex keep theirs — neither is bound, so converting them
would be churn for no binding removed.

Four things differed from the plan and are written up in it: GPU policy
moved to the per-window LinuxWindow options rather than surviving on
LinuxOptions; there is no OnStartup/OnDomReady option, so app-level
wiring hangs off ApplicationStarted; application.NewService is generic,
so FEBindings []any could not survive (the binding generator is a
static analyser and would have seen nothing); and the quit veto had to
be restructured, because v3's dialog answers on a callback rather than
returning the button, so ShouldQuit vetoes, asks, and quits again from
the callback.

Window state saving moves to a WindowClosing hook — the size has to be
read while the window still exists, and v3's OnShutdown has neither
context nor window. backend/logging is deleted rather than ported:
v3 takes a *slog.Logger directly, so the v2 logger.Logger adapter had
no caller left.

Phase 1's tail rides along, now that it can: the Makefile's wails
invocations, all 50 webkit2_41 sites, lefthook, both packaging recipes
and ci.yml's apt lists. v3 builds against GTK4 + WebKitGTK 6.0, which
Arch and ubuntu:24.04 both ship, so the tag is a deletion rather than
a translation.

Phase 4 is next and the branch is not usable until it lands: the app
builds, but frontend/wailsjs/ is v2's tree and nothing regenerates it,
so the frontend cannot reach the backend yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 14:01:02 -04:00

36 KiB
Raw Blame History

009 — Wails v3 migration

Status: Phases 03 complete. The Go side is entirely on v3: nothing in the tree imports wails/v2, all three lint and test configurations are green, and go build . produces a running binary. Phase 4 (bindings) is next, and until it lands the app builds but the frontend cannot talk to itfrontend/wailsjs/ is v2's tree and nothing regenerates it. 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.3beta.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 allapp.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-devlibwebkitgtk-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 workCall.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/wailswails/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 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.

  • e7873bdwails/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-configs 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/BackgroundColourWebviewWindowOptions.
  • Linux.WebviewGpuPolicy survives (v3 keeps Always/OnDemand/Never).
  • Loggerslog; backend/logging/'s adapter likely deletes outright, since the repo already uses slog everywhere else.
  • AssetServerapplication.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 — BindServices. 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:

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 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:

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 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-devlibwebkitgtk-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.3beta.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