The plan assumed build/ was free and that GTK4 was a preference. It was not free — this repo used it as ignored build output — and GTK4 is not available on the dev machine, which breaks `go tool wails3` outright rather than merely changing which webkit is linked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
32 KiB
009 — Wails v3 migration
Status: Phase 0 complete — go. Phase 1 partly done (toolchain
and build assets landed; Makefile rewrite and the tag swap not started).
Phases 2–7 not started.
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 gtk3builds fine on Arch against the installed webkit2gtk-4.1.wails3 doctorreports 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._wailsappears, exposingdispatchWailsEventandinvoke; - binding calls work —
Call.ByID(...)andCall.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:
window.godoes not exist, and there is no runtime enumeration surface for bound methods. This is the one genuine regression.e2e/specs/harness.spec.ts:18-19ande2e/perf/measure.mjs:130-180both walk that object; they lose the mechanism, not just the syntax. See Phase 6.- 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. - The FQN is the full Go import path, not the package name.
bindings.go:245buildsfmt.Sprintf("%s.%s.%s", packagePath, typeName, methodName)fromreflect.Type.PkgPath(). For us that isyellowjacket/backend/library.Library.GetAllTracks— verbose but deterministic. (main.GreetService.Greetresolved;changeme.…and bareGreetService.…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 returnsnil; the process survives. Theif 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 (reportsWebkit2Gtk=v2.52.5under-tags gtk3).
Bonus findings
internalServiceMethodsauto-excludesServiceStartup,ServiceShutdown,ServiceName,ServeHTTPfrom bindings (bindings.go:238-243) — confirming theSetContextport removes 12 bindings and the boguscontextmodel rather than renaming them.- Generated bindings are TypeScript, nested by Go import path
(
frontend/bindings/<module>/<pkg>/<service>.ts+ a per-packageindex.tsre-export). This disproves the earlier assumption that the 93@goimport sites wouldn't change — see Phase 4. - Calls compile to
$Call.ByID(<fnv hash>, …)wheremethodID = 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 barePromise. - 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 whatbackend/events/cmd/geneventsdoes 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/modelsalone is 42). - 23
@runtime/runtimesites — 22 import onlyEventsOn. - 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
sudo pacman -S webkitgtk-6.0on the dev machine.- Scaffold a v3 project beside the repo and copy its
build/tree in wholesale, rather than hand-writingconfig.yml. Same discipline as "seeds are produced by running the app." - Fill
build/config.yml'sinfoblock fromwails.json'sname,outputfilenameandauthor; deletewails.json. - Swap the
toolblock:wails/v2/cmd/wails→wails/v3/cmd/wails3. Addgithub.com/wailsapp/wails/v3 v3.0.0-beta.8. - Rewrite the Makefile's wails invocations behind unchanged target
names (
:11,14,129,161,221,231,234). - Delete
webkit2_41from all ~30 sites (see inventory). - Point
frontend:install/frontend:buildequivalents atpnpm— the scaffold assumesnpm; this repo uses pnpm (frontend/package.json.md5is 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 (e7873bd)
Steps 2, 3 (partly), 4 and 7 are done; 1, 5 and 6 are not.
- Done.
wails/v3 v3.0.0-beta.8pinned andwails3added to thetoolblock beside the v2 CLI.build/holds the scaffold's asset tree withconfig.yml'sinfoblock filled fromwails.json.Taskfile.ymldefaultsPACKAGE_MANAGERto pnpm. - Not done. The Makefile still calls
go tool wails(v2) throughout,webkit2_41is still on all ~30 sites, andwails.jsonis still present — deliberately, because the v2 CLI reads it and the app has not moved to the v3 API yet. Steps 5 and 6 are entangled with Phase 2 and should land with it: swapping the tag beforemain.gois ported breaks the only build that currently works.
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.
GTK4 is unavailable on the dev machine, so the gtk3 fallback is in
use. webkitgtk-6.0 is not installed and installing it needs sudo.
The consequence is sharper than the plan's "fallback if GTK4
misbehaves": go tool wails3 does not work at all, because the
CLI itself links internal/operatingsystem, which pkg-configs
gtk4 webkitgtk-6.0 under default tags. go run -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3 builds and runs fine
(doctor reports -tags gtk3 and Webkit2Gtk v2.52.5), so that — not
go tool wails3 — is the invocation the Makefile must use until
sudo pacman -S webkitgtk-6.0 happens. Phase 0's claim that
"wails3 doctor reports both toolchains" was not true on this
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.WebviewGpuPolicysurvives (v3 keeps Always/OnDemand/Never).Logger→slog;backend/logging/'s adapter likely deletes outright, since the repo already usesslogeverywhere else.AssetServer→application.AssetOptions{Handler: …}.- Re-check the NVIDIA/Wayland
WEBKIT_DISABLE_DMABUF_RENDERER=1workaround (main.go:32-39,134-155) — v3'soperatingsystempackage 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:
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 acontext.Contextparameter 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().OnBeforeClosereturningtrueto 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 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 4 — Bindings
Goal: the frontend imports real generated v3 bindings.
Steps
- Generate against the real services and inspect the tree first — the exact nesting decides the codemod.
- Remap
@goinfrontend/vite.config.mts:7andfrontend/tsconfig.json:28; drop thewailsjs/go/**/*.jsexclude attsconfig.json:50(v3 emits.ts). - Codemod all 93
@go/...import sites. Phase 0 disproved the hope that an alias absorbs this:@go/library/Librarybecomes@go/yellowjacket/backend/library, a change of shape. @go/models(42 sites) — v3 has no singlemodels.ts; types come from the per-package modules. This is the largest single cluster and should be scripted, not hand-edited.- Rewrite
scripts/bindings-check.sh. Itschmoddance andcore.fileMode=falsediff exist purely because v2's generator wrote three runtime files 755 — likely all deletable. - 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) andbackend/testctlis//go:build dev. Getting this wrong means the generated API reflects a configuration users never run. v2 had no such hazard (runtime reflection). - Check whether any call site depends on the return being a plain
Promise— v3 returnsCancellablePromise.
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) andmakeRuntimeProxy()(:197-225) are the whole change. The recursiveProxyis schema-free, so it does not need to learn v3's binding surface — it needs to intercept wherever v3 routes calls, now thatwindow.gois gone. With D4's shim in place, that is one seam.- The
Listenerclass (:23-44) andnotify()(:105-125) deliberately mirror v2'sinternal/frontend/runtime/desktop/events.js, includingmaxCallbacksexpiry and the ordering whereEventsEmitnotifies 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 throughCall.ByName('yellowjacket/backend/queue.Queue.GetState', …)and drops its timeout race entirely — v3 rejects on bad args and unknown methods.ready()likewise becomes aByNamecall rather than awindow.go?.queue?.Queue?.GetStatepoll.
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:
- 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. - Wrap an explicit hand-maintained list. Cheaper, and silently goes
stale — exactly the failure mode
bindings-checkexists to prevent.
If (2), say so in measure.mjs and add it to what bindings-check
guards.
6c — backend/testctl/ gets easier. Its only Wails coupling is
Deps.Context func() context.Context (testctl.go:46-53) — a function
rather than a value "because the context only exists after OnStartup."
ServiceStartup(ctx, opts) may make that indirection unnecessary.
Better still, v3 supports a service implementing http.Handler
registered with
application.NewServiceWithOptions(svc, application.ServiceOptions{Route: "/__test"})
— a first-class replacement for mounting a mux on the asset server.
The double gate (//go:build dev + YJ_TESTCTL=1) stays exactly as is.
6d — scripts/dev-headless.sh and the port. Evaluate replacing the
hand-rolled headless launch with -tags server. Two constraints:
e2e/playwright.config.ts expects :34115 (set WAILS_SERVER_PORT),
and testctl must still mount. If server mode complicates the mount,
keep the existing script — the win is tidiness, not capability.
Acceptance: make e2e green on both Chromium and WebKit, zero
spec edits.
Est. One to two sessions. The largest and riskiest phase.
Phase 7 — CI and packaging
.gitea/workflows/ci.yml:74,220:libwebkit2gtk-4.1-dev libgtk-3-dev→libwebkitgtk-6.0-dev libgtk-4-dev.- The PulseAudio null-sink setup and its three-second timing check are unrelated and stay exactly as they are.
- The WebKit Playwright project (
:364-369,if: ${{ !cancelled() }}) matters more after this, not less — it is the only approximation of the shipping renderer, and v3 may change which WebKit that is. Keep the!cancelled()guard; it is why WebKit signal was silently absent for two sessions before. packaging/arch/PKGBUILDandpackaging/homebrew/Formula/yellowjacket.rbcarry the build tag and dependency lists.make skill-checkfails if.pi/documents a nonexistent make target — update.pi/skills/yellowjacket-dev/SKILL.mdandreferences/schema-change.mdin the same commit as any rename.- Update
CLAUDE.md: thewebkit2_41mandate (: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 retiringgenevents. - 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 | Retired. -tags server verified: calls + events, no display |
|
| Arch/Ubuntu need different webkit tags | Retired. Both ship webkitgtk-6.0; default builds in the CI container | |
No window.go → e2e/perf lose binding enumeration |
high | New, confirmed. Decide 6b option (1)/(2) |
93 @go sites need editing after all |
high | Confirmed. Bindings nest by import path; codemod required |
| Binding generation analyses the wrong build config | high | New. Pin tags in Phase 4 step 6 |
| Beta churn mid-migration | high | Pin beta.8. beta.3→beta.8 in one app's lifetime |
| E2E rewrite silently weakens coverage | high | Acceptance = make e2e green, zero spec edits |
| v3 event ordering differs from v2's | medium | Re-derive the fake; don't port it |
ServiceShutdown() signature trap |
medium | Silent no-call; grep after Phase 2c |
| Quit-during-writes veto breaks | medium | Data-safety path; test deliberately in 2d |
| Regression no tier covers | medium | make perf before/after on the same seed |
| GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run |
Sequencing and staging
Phases 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:
- Land the
webkit2_41deletion +pacman -S webkitgtk-6.0independently if desired — it is useful on its own and touches nothing else. (Optional; can also ride along in Phase 1.) - Branch
wails-v3off a cleanwip. Phases 1–4 as separate commits on it, kept local. - Phases 5, 6, 7 onto the same branch.
- One merge to
mainwhenmake test,make ui-test,make e2e(both browsers) andmake lintare 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)? Itsoperatingsystempackage detects the driver, which suggests it might. Check in Phase 2a. - Is
backend/logging/'slogger.Loggeradapter deletable outright once v3 usesslog? Check in Phase 2a. - Does GTK4 change anything visible about rendering vs GTK3? Unmeasured; visual check on first run.
- Should
application.RegisterEvent[T]replacebackend/events/cmd/genevents? Deliberately deferred past the migration. - Does
-tags servercomplicate 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 — feature mapping, testing checklist, the "1–4 hours" estimate
- What's New in v3
- v3 beta announcement
- Application lifecycle
pkg/applicationAPI- v2→v3 discussion #4509
- Late service registration #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.mjsbrowser probes,q3_test.goemit-safety tests.