21 conflicts, all from the same cause: three features were developed on both lines and this branch's copies are the ones adapted to v3's bindings and to the file-shaped schema. Resolutions: - `frontend/wailsjs/` stays deleted — v2's generated bindings, replaced by `frontend/bindings/`. - remove-from-library, `library-status.ts`, the requested-badge spec and its component test: took this branch's copies, which differ from main's only in calling `pruneEmptyEntities`/`CountAudioFiles`, importing `@go/download/models.js`, and staging a real UUID for the catalog's `CHECK(length(mbid) = 16)`. - `GetFilePathsByRecordingMBIDsByLibrary` dropped: it joined `recordings`, which no longer exists, and `library_id = 0` answers both scoped and unscoped now. `GetAudioFilesByPaths` was already here. - The album page, the artist page and the library badge kept this branch's versions, which supersede main's: ownership asked once from the files, the partial-completeness ring, and the request action. - Docs: no migration chain (013) over main's two-file column rule and its pre-1.0 squashing note, both of which 013 retired. Kept main's `CreateSmartPlaylist` read-pool example, which is a real second instance of that bug. Verified on the merge result, not on either parent: lint clean in all three build configurations, `make test` green in all three, 776 Vitest tests, `tsc --noEmit`, bindings-check and skill-check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
58 KiB
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 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.
Corrected after the fact.
make build-prodstrips and trims (-trimpath -ldflags="-w -s", 28.8 MB against the dev build's 38 MB) but does not UPX-compress: that was v2'swails build -upxflag, and v3's Taskfile has no equivalent. Neithermake build-devnormake build-prodwas 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.8pinned,wails3added to thetoolblock,build/asset tree copied from a scaffold withconfig.ymlfilled fromwails.json,Taskfile.ymlon pnpm.- With Phase 2/3 — the Makefile's six
go tool wailsinvocations, everywebkit2_41site (50 of them, not the ~30 the inventory estimated),lefthook.yml, both packaging recipes, andci.yml's apt lists.backend/loggingis deleted, not ported, answering one of the open questions: v3 takes a*slog.Loggerdirectly, so v2'slogger.Loggeradapter 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/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 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
- 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 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:
createFromis gone. v3 emits interfaces (-i), so the threeexplore.ShelfPage.createFrom({…})sites are object literals andnew tracklist.Column()is one too.- An enum needs a value import.
import type * as downloadcannot reachFormat, sodownload-clients.tsimports it separately — and its local format list is nowFormat[]rather thanstring[], 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.tsstill hand-mirrorsJobState/JobKindas 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) 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 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 #<id>, 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 asyncruntimeCallWithID, the transport, aCancellablePromise, against v2's one resolved promise — and the tests were already written as thoughfixture()meant "mounted and loaded". Fixing it there rather than in each test is what kept this to one test-file edit. Microtasks and notsetTimeout: the first attempt used a timer, which hungtransport.test.tsfor 45 s because it installs fake ones.tracklist-storekeeps its defaults on an empty answer.GetTrackListColumnssubstitutestracklist.DefaultColumnsonly 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 typedColumn[], an absent answer arrived asundefined, and.mapthrew into thecatchthat restores the defaults. Phase 4'slist()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 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 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 (setupCommonEventsis 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 noTrackLoader, 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
SetQueuewith three of its four arguments. v2 accepted the call and filled the gap with a zero value; v3 answersexpects 4 arguments, got 3.NO_QUEUE_SOURCEsays explicitly what was being supplied silently. requested-badge's cleanup was a no-op. It readwindow.goinside a type assertion andreturned onif (!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.Searchtrusted 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.gohad already fixed exactly this and lefthasCatalogRowsbehind; 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/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 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 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 |
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 |
Retired. Codemod done in Phase 4; the alias absorbed the prefix, so it was a specifier rewrite | |
| Binding generation analyses the wrong build config | 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 | 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 | 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:
- 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.