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
1188 lines
58 KiB
Markdown
1188 lines
58 KiB
Markdown
# 009 — Wails v3 migration
|
||
|
||
**Status:** Phases 0–7 complete. CI needed nothing (it had ridden along
|
||
with Phases 1–6); **packaging needed everything** — both recipes still
|
||
called v2's CLI, installed from v2's output path, and built from a
|
||
scaffold's `yjref` metadata. Locally green: `make lint` and `make test`
|
||
(all three build configurations), `tsc --noEmit`, `make build-prod`,
|
||
757 Vitest tests across all 58 files, `make e2e` 92/92 on chromium,
|
||
`make bindings-check`, `make skill-check`, `make commit-check`,
|
||
`make css-check`, and `make perf` runs and reports real binding counts
|
||
again. **WebKit is unverified** — it is CI-only by design — so the
|
||
merge waits on a green CI run.
|
||
**Branch:** `wails-v3`, off `main` at `edb13a6`.
|
||
**Created:** 2026-08-13
|
||
**Phase 0 run:** 2026-08-13 against **v3.0.0-beta.8**
|
||
**Target version:** pin `v3.0.0-beta.8` for the whole migration
|
||
**Depends on:** nothing
|
||
**Follows:** 005-agent-development-harness (which this must not break)
|
||
|
||
---
|
||
|
||
## Verdict
|
||
|
||
Go — but not urgently, and not in one sitting. The app port is small
|
||
and the harness port is not. Phase 0 answered the three questions that
|
||
could have killed it, and all three came back favourable, one of them
|
||
better than hoped.
|
||
|
||
The reason to do it is **not** tray icons. It is that v3 deletes an
|
||
entire failure class this repo has built scar tissue around, and does
|
||
so at the cheapest moment this migration will ever have — before
|
||
anything has shipped to real users, the same reasoning that lets
|
||
`sql/migrations/` be squashed.
|
||
|
||
The reason not to rush is that beta churn is real (`beta.3` → `beta.8`
|
||
in the lifetime of one nearby reference app) and Phases 1–4 leave the
|
||
repo in a state that **must not be merged**.
|
||
|
||
---
|
||
|
||
## Why v3: the actual argument
|
||
|
||
`events.Emit(ctx, name, data...)` exists because v2's
|
||
`runtime.EventsEmit` calls `log.Fatalf` — unrecoverably, taking the
|
||
process down — on any context that does not carry the Wails runtime.
|
||
Everything downstream is scar tissue: the `ErrNoRuntime` contract,
|
||
`TestNoDirectRuntimeEmits` walking the whole tree, and worst,
|
||
`backend/events/emit.go:83` probing the **v2-private context key**
|
||
`ctx.Value("events")` to decide whether emitting is safe.
|
||
|
||
**v3's emit takes no context at all** — `app.Event.Emit(name, data...)`.
|
||
A background worker cannot kill the app by emitting from a
|
||
`context.Background()`, because there is no context to get wrong.
|
||
|
||
Phase 0 verified this rather than inferring it from the signature:
|
||
`application.Get()` with no app running returns **`nil`** instead of
|
||
`log.Fatalf`-ing, and 20 concurrent emits from detached goroutines
|
||
against a created-but-never-`Run()` app completed with no panic and no
|
||
crash, headless. The v3 scaffold template itself emits from a bare
|
||
`go func()` loop, so this is the blessed pattern, not something we'd be
|
||
getting away with.
|
||
|
||
Secondary wins, in rough order of value: a supported headless server
|
||
mode that replaces a hand-rolled script; `ServiceStartup` replacing 12
|
||
hand-wired `SetContext` methods *and* deleting 12 spurious bindings;
|
||
clean rejection on bad binding args, which deletes the ugliest race in
|
||
the e2e harness; and the `webkit2_41` tag disappearing entirely.
|
||
|
||
---
|
||
|
||
## Phase 0 — results (2026-08-13, beta.8)
|
||
|
||
Measured against a `wails3 init -t vanilla` app in a scratchpad.
|
||
|
||
### Q1 — build environment: **PASS**, better than assumed
|
||
|
||
`ubuntu:24.04` ships **both** `libwebkitgtk-6.0-dev` (2.52.3) and
|
||
`libwebkit2gtk-4.1-dev`. A default-tag build (GTK4 + WebKitGTK 6.0)
|
||
**compiles in the CI container** — verified by actually building inside
|
||
`docker run ubuntu:24.04`, not by reading package lists. Arch has
|
||
`webkitgtk-6.0` (2.52.5) in `extra/`, merely not installed on this
|
||
machine.
|
||
|
||
Both platforms can therefore run v3's *default* path, so **`webkit2_41`
|
||
becomes a deletion across ~30 sites, not a translation**.
|
||
|
||
- Dev machine cost: `sudo pacman -S webkitgtk-6.0`.
|
||
- CI cost: `libwebkit2gtk-4.1-dev libgtk-3-dev` → `libwebkitgtk-6.0-dev
|
||
libgtk-4-dev`.
|
||
- Fallback if GTK4 misbehaves: `-tags gtk3` builds fine on Arch against
|
||
the installed webkit2gtk-4.1. `wails3 doctor` reports both toolchains
|
||
and labels 4.1 "(legacy)".
|
||
|
||
### Q2 — headless harness: **PASS**, with one real loss
|
||
|
||
v3 has a first-class **`-tags server` mode** ("a pure HTTP server
|
||
without native GUI dependencies"), a supported replacement for what
|
||
`scripts/dev-headless.sh` hand-rolls. Verified with `DISPLAY` and
|
||
`WAYLAND_DISPLAY` unset:
|
||
|
||
- serves the app over HTTP (`WAILS_SERVER_PORT`; defaults to 8080,
|
||
which collides — set it explicitly);
|
||
- the runtime loads in a real Chromium; `window._wails` appears,
|
||
exposing `dispatchWailsEvent` and `invoke`;
|
||
- **binding calls work** — `Call.ByID(...)` and `Call.ByName(...)` both
|
||
returned correct results;
|
||
- **events flow** — 6 events in 3.5 s from the template's 1 Hz
|
||
goroutine emitter, over an SSE broadcaster at `/wails/events`.
|
||
|
||
Three findings that shape later phases:
|
||
|
||
1. **`window.go` does not exist, and there is no runtime enumeration
|
||
surface for bound methods.** This is the one genuine regression.
|
||
`e2e/specs/harness.spec.ts:18-19` and `e2e/perf/measure.mjs:130-180`
|
||
both *walk* that object; they lose the mechanism, not just the
|
||
syntax. See Phase 6.
|
||
2. **Bad arguments reject cleanly**, with useful messages
|
||
(`expects 1 arguments, got 3`; `could not parse argument #0: json:
|
||
cannot unmarshal object into Go value of type string`). Unknown
|
||
method names reject too. v2's never-fires-its-callback behaviour is
|
||
gone, so `__yjEvents.call`'s timeout race **deletes itself**.
|
||
3. **The FQN is the full Go import path**, not the package name.
|
||
`bindings.go:245` builds `fmt.Sprintf("%s.%s.%s", packagePath,
|
||
typeName, methodName)` from `reflect.Type.PkgPath()`. For us that is
|
||
`yellowjacket/backend/library.Library.GetAllTracks` — verbose but
|
||
deterministic. (`main.GreetService.Greet` resolved; `changeme.…` and
|
||
bare `GreetService.…` did not.)
|
||
|
||
One caveat recorded honestly: the server build **still required a
|
||
webkit toolchain at compile time** despite the "no native GUI
|
||
dependencies" summary — it failed until pointed at an installed webkit.
|
||
Whether that is intended or a beta gap was not determined. It is moot
|
||
if we adopt the GTK4 deps anyway.
|
||
|
||
### Q3 — the emit footgun: **PASS**, decisively
|
||
|
||
- `application.Get()` with no app running returns `nil`; the process
|
||
survives. The `if app == nil { return ErrNoRuntime }` design is right.
|
||
- 20 concurrent emits from detached goroutines, app created but never
|
||
`Run()`, no display: no panic, no crash.
|
||
- `application.New()` itself works headless (reports
|
||
`Webkit2Gtk=v2.52.5` under `-tags gtk3`).
|
||
|
||
### Bonus findings
|
||
|
||
- **`internalServiceMethods` auto-excludes `ServiceStartup`,
|
||
`ServiceShutdown`, `ServiceName`, `ServeHTTP`** from bindings
|
||
(`bindings.go:238-243`) — confirming the `SetContext` port *removes*
|
||
12 bindings and the bogus `context` model rather than renaming them.
|
||
- **Generated bindings are TypeScript, nested by Go import path**
|
||
(`frontend/bindings/<module>/<pkg>/<service>.ts` + a per-package
|
||
`index.ts` re-export). This **disproves** the earlier assumption that
|
||
the 93 `@go` import sites wouldn't change — see Phase 4.
|
||
- Calls compile to `$Call.ByID(<fnv hash>, …)` where
|
||
`methodID = hash.Fnv(fqn)`, with an explicit-ID registration escape
|
||
hatch — a harness can compute IDs itself if it ever needs to.
|
||
- Bindings return a **`CancellablePromise`**, not a bare `Promise`.
|
||
- **Binding generation is build-tag sensitive** (static analyser).
|
||
Wails' own Taskfile passes `BUILD_FLAGS: "-tags server,production"`
|
||
to binding generation so it "analyses the same build the Docker image
|
||
compiles, not the default-tag build."
|
||
- **`application.RegisterEvent[string]("name")`** yields typed events
|
||
and a generated typed TS event API — overlaps with what
|
||
`backend/events/cmd/genevents` does by hand.
|
||
|
||
---
|
||
|
||
## Ground truth: what we actually touch
|
||
|
||
Measured, not assumed. The Go surface is small; the harness surface is
|
||
the job.
|
||
|
||
### Go — six files import `wails/v2`
|
||
|
||
| File | Subpackage | Uses |
|
||
|---|---|---|
|
||
| `main.go:11-13` | `wails`, `options`, `options/linux` | `wails.Run`, `options.App`, GPU policy |
|
||
| `backend/app.go:15` | `pkg/runtime` | `WindowGetSize`, `MessageDialog`, `QuestionDialog`, `Quit` |
|
||
| `backend/assets/handler.go:9` | `options/assetserver` | `assetserver.Options{Assets, Middleware}` |
|
||
| `backend/events/emit.go:8` | `pkg/runtime` | `EventsEmit` — the only emit in the tree |
|
||
| `backend/frontendutil/frontendutil.go:9` | `pkg/runtime` | file/dir dialogs, `LogInfo` |
|
||
|
||
Plus `backend/logging/`, which implements v2's `logger.Logger`
|
||
**structurally** — it does not import wails, so `grep wailsapp` misses
|
||
it.
|
||
|
||
### Bound surface
|
||
|
||
12 services, ~279 exported methods (`backend/app.go:204-225`).
|
||
`YellowJacketApp` itself is not bound; only its lifecycle hooks are
|
||
wired — which is already close to v3's service model.
|
||
|
||
| Service | Methods | | Service | Methods |
|
||
|---|---|---|---|---|
|
||
| `explore.Service` | 56 | | `player.Player` | 21 |
|
||
| `library.Library` | 47 | | `jobs.Service` | 7 |
|
||
| `playlist.Service` | 36 | | `tagwriter.TagWriter` | 6 |
|
||
| `config.Config` | 30 | | `frontendutil.FrontendUtil` | 5 |
|
||
| `queue.Queue` | 25 | | `home.Service` | 1 |
|
||
| `download.Service` | 23 (conditional) | | `autotagservice.Service` | 22 |
|
||
|
||
**12 services expose a public `SetContext(ctx)`**, all called from
|
||
`OnStartup` (`backend/app.go:321-330`), all currently exported as
|
||
bindings.
|
||
|
||
### Frontend surface
|
||
|
||
- **93 `@go/...` import sites** (`@go/models` alone is 42).
|
||
- **23 `@runtime/runtime` sites — 22 import only `EventsOn`.**
|
||
- App source never touches `window.go`/`window.runtime`; only generated
|
||
code, the Vitest fake, and the e2e harness do.
|
||
|
||
### Harness surface — the real work
|
||
|
||
| Artifact | Lines | Fate |
|
||
|---|---|---|
|
||
| `.playwright/init-events.js` | 302 | **Full rewrite** (wraps v2 internals) |
|
||
| `frontend/test/support/wails-fake.ts` | 243 | Two factories rewritten; 480 tests ride on it |
|
||
| `backend/testctl/` | 891 | Light — one indirection may simplify |
|
||
| `scripts/bindings-check.sh` | 43 | Rewrite |
|
||
| `scripts/dev-headless.sh` | ~140 | Possibly replaced by `-tags server` |
|
||
| `e2e/perf/measure.mjs` | — | Loses binding enumeration |
|
||
|
||
### `webkit2_41` — ~30 sites, all deletions
|
||
|
||
`Makefile` (`:11,14,129,161,221,231,234`, lint matrix `:280-282`, test
|
||
matrix `:290-295`), `lefthook.yml:17,21,64`,
|
||
`scripts/bindings-check.sh:26`, `scripts/dev-headless.sh:15,134`,
|
||
`packaging/arch/PKGBUILD`, `packaging/homebrew/Formula/yellowjacket.rb`,
|
||
`CLAUDE.md:53-73` and `:1083`, `.planning/NOTES.md`,
|
||
`.pi/skills/yellowjacket-dev/SKILL.md`,
|
||
`.pi/skills/yellowjacket-dev/references/schema-change.md`,
|
||
`.pi/journal.md`.
|
||
|
||
---
|
||
|
||
## Design decisions taken up front
|
||
|
||
Recorded here so they are not re-litigated mid-phase.
|
||
|
||
**D1 — `events.Emit` keeps its `ctx` parameter.** v3 doesn't need it for
|
||
delivery, but `events.WithSink(ctx, rec)` is the test seam used by 7
|
||
test files, and 45 call sites across 13 production files pass a context
|
||
already. The context stops being a delivery mechanism and stays a
|
||
test-injection mechanism. **One file changes.** The alternative —
|
||
dropping the parameter — churns 45 call sites and every test for no
|
||
gain.
|
||
|
||
**D2 — the Makefile stays the front door.** Taskfile becomes an
|
||
implementation detail behind existing target names. `make dev`,
|
||
`make build-prod`, `make bindings`, `make e2e` all keep their names and
|
||
behaviour. `.pi/skills/yellowjacket-dev/` and `make skill-check` depend
|
||
on those names, and CLAUDE.md documents them.
|
||
|
||
**D3 — `wails3` stays a vendored Go tool.** The v2 CLI is in `go.mod`'s
|
||
`tool` block, invoked as `go tool wails`. Keep that shape; a global
|
||
install would be the first undeclared dependency in this repo's build.
|
||
|
||
**D4 — the `@runtime` alias becomes a local shim.** 22 files import
|
||
`EventsOn` from `@runtime/runtime`. Rather than editing 22 imports to
|
||
v3's `Events.On`, point the alias at a small local module that exports
|
||
an `EventsOn`-shaped function over `@wailsio/runtime`. Keeps the diff
|
||
small and gives Phase 5's fake exactly one seam to target.
|
||
|
||
**D5 — pin `beta.8` for the entire migration.** Upgrade deliberately,
|
||
never incidentally. Registration order and late-registration semantics
|
||
changed across betas (`wailsapp/wails#4066`).
|
||
|
||
---
|
||
|
||
## Phase 1 — Toolchain and build system
|
||
|
||
**Goal:** the repo builds and runs under `wails3`, with every `make`
|
||
target keeping its name.
|
||
|
||
`wails.json` (9 lines) is gone; v3 uses `build/config.yml` plus a
|
||
Taskfile tree — a genuinely larger and more visible build surface.
|
||
|
||
**Steps**
|
||
|
||
1. `sudo pacman -S webkitgtk-6.0` on the dev machine.
|
||
2. Scaffold a v3 project *beside* the repo and copy its `build/` tree
|
||
in wholesale, rather than hand-writing `config.yml`. Same discipline
|
||
as "seeds are produced by running the app."
|
||
3. Fill `build/config.yml`'s `info` block from `wails.json`'s `name`,
|
||
`outputfilename` and `author`; delete `wails.json`.
|
||
4. Swap the `tool` block: `wails/v2/cmd/wails` → `wails/v3/cmd/wails3`.
|
||
Add `github.com/wailsapp/wails/v3 v3.0.0-beta.8`.
|
||
5. Rewrite the Makefile's wails invocations behind unchanged target
|
||
names (`:11,14,129,161,221,231,234`).
|
||
6. Delete `webkit2_41` from all ~30 sites (see inventory).
|
||
7. Point `frontend:install`/`frontend:build` equivalents at `pnpm` —
|
||
the scaffold assumes `npm`; this repo uses pnpm
|
||
(`frontend/package.json.md5` is part of the dep-caching scheme).
|
||
|
||
**Acceptance:** `make build-dev` produces a running binary;
|
||
`make build-prod` still strips and UPX-compresses; `make skill-check`
|
||
passes; `grep -r webkit2_41` returns nothing.
|
||
|
||
> **Corrected after the fact.** `make build-prod` strips and trims
|
||
> (`-trimpath -ldflags="-w -s"`, 28.8 MB against the dev build's
|
||
> 38 MB) but **does not UPX-compress**: that was v2's `wails build
|
||
> -upx` flag, and v3's Taskfile has no equivalent. Neither `make
|
||
> build-dev` nor `make build-prod` was actually run when Phase 1 was
|
||
> recorded — both were broken until the shim below. Whether to
|
||
> reintroduce UPX is a packaging decision for Phase 7, not a port.
|
||
|
||
**Est.** Half a session. Low risk, high churn.
|
||
|
||
### Phase 1 — what actually landed
|
||
|
||
Split across two commits, because steps 5 and 6 could not land before
|
||
the app was on the v3 API: swapping the build tag before `main.go` was
|
||
ported would have broken the only build that then worked.
|
||
|
||
- **`e7873bd`** — `wails/v3 v3.0.0-beta.8` pinned, `wails3` added to
|
||
the `tool` block, `build/` asset tree copied from a scaffold with
|
||
`config.yml` filled from `wails.json`, `Taskfile.yml` on pnpm.
|
||
- **With Phase 2/3** — the Makefile's six `go tool wails` invocations,
|
||
every `webkit2_41` site (50 of them, not the ~30 the inventory
|
||
estimated), `lefthook.yml`, both packaging recipes, and `ci.yml`'s
|
||
apt lists. `backend/logging` is **deleted**, not ported, answering
|
||
one of the open questions: v3 takes a `*slog.Logger` directly, so
|
||
v2's `logger.Logger` adapter had no remaining caller.
|
||
|
||
**Two things the plan did not anticipate.**
|
||
|
||
**`build/` was already taken.** This repo used it as *ignored* build
|
||
output (`build/bin/` held three v2 binaries), and v3 wants it for
|
||
*tracked* build assets. `.gitignore` now names `build/bin/` and `bin/`
|
||
rather than `build`, and the assets are committed. The mobile platform
|
||
trees (`build/android`, `build/ios`, ~40 files) are not carried — this
|
||
is a desktop player and cannot target them — and their `includes:`
|
||
entries are dropped from `Taskfile.yml`.
|
||
|
||
**The v3 CLI needs the GTK4 toolchain to compile at all — not just the
|
||
app.** Before `webkitgtk-6.0` was installed, `go tool wails3` failed
|
||
outright, because the CLI links `internal/operatingsystem`, which
|
||
`pkg-config`s `gtk4 webkitgtk-6.0` under default tags. That is sharper
|
||
than the plan's "fallback if GTK4 misbehaves": the gtk3 fallback
|
||
covers the *app*, and reaching it meant building the CLI by hand with
|
||
`go run -tags gtk3 …/cmd/wails3`.
|
||
|
||
Resolved — `webkitgtk-6.0` 2.52.5 and `gtk4` 4.22.4 are installed, and
|
||
`go tool wails3 doctor` now reports both toolchains with gtk3 and
|
||
webkit2gtk marked legacy, which is the state Phase 0 described. The
|
||
default (GTK4) path is what the migration targets; the gtk3 escape
|
||
hatch is recorded here only so the next person recognises the failure
|
||
if they meet it on a fresh machine.
|
||
|
||
---
|
||
|
||
## Phase 2 — Go bootstrap and services
|
||
|
||
**Goal:** the app starts, shows a window, and every service is bound.
|
||
|
||
**2a — `main.go:75-97`.** Split `wails.Run(&options.App{…})` into
|
||
`application.New(opts)` → `app.Window.NewWithOptions(…)` → `app.Run()`.
|
||
|
||
- `Title`/`Width`/`Height`/`MinWidth`/`MinHeight`/`BackgroundColour` →
|
||
`WebviewWindowOptions`.
|
||
- `Linux.WebviewGpuPolicy` survives (v3 keeps Always/OnDemand/Never).
|
||
- `Logger` → `slog`; `backend/logging/`'s adapter likely deletes
|
||
outright, since the repo already uses `slog` everywhere else.
|
||
- `AssetServer` → `application.AssetOptions{Handler: …}`.
|
||
- Re-check the NVIDIA/Wayland `WEBKIT_DISABLE_DMABUF_RENDERER=1`
|
||
workaround (`main.go:32-39,134-155`) — v3's `operatingsystem` package
|
||
detects the proprietary driver and may already do this.
|
||
|
||
**2b — `Bind` → `Services`.** `backend/app.go:204-225` becomes
|
||
`[]application.Service` via `application.NewService(...)`. The
|
||
conditional `download.Service` append still works.
|
||
|
||
**2c — the 12 `SetContext` methods → `ServiceStartup`.** This is the
|
||
largest structural port and v3 has a better answer than ours:
|
||
|
||
```go
|
||
ServiceStartup(ctx context.Context, options application.ServiceOptions) error
|
||
ServiceShutdown() error
|
||
```
|
||
|
||
The context is cancelled on app shutdown — strictly better than
|
||
`SetContext`. And because `internalServiceMethods` excludes these
|
||
names, the port **removes 12 spurious bindings** and the fake `context`
|
||
namespace from the generated models.
|
||
|
||
Sites: `autotagservice/service.go:204`, `config/config.go:284`,
|
||
`download/service.go:51`, `explore/explore.go:129`,
|
||
`explore/searchindex.go:265`, `frontendutil/frontendutil.go:23`,
|
||
`jobs/jobs.go:210`, `library/library.go:187`, `player/player.go:190`,
|
||
`playlist/playlist.go:167`, `queue/queue.go:196`,
|
||
`tagwriter/pipeline.go:81`.
|
||
|
||
> **Trap:** `ServiceShutdown()` takes **no context**. A method with a
|
||
> `context.Context` parameter does not satisfy the interface and is
|
||
> **silently never called** — no error, no warning. Grep for it after
|
||
> the port.
|
||
|
||
**2d — `backend/app.go`'s runtime calls.**
|
||
|
||
- `WindowGetSize(ctx)` (`:521`) → `window.Size()`/`window.Bounds()`.
|
||
**Keep the sub-minimum guard** (`:526-536`); it exists because v2
|
||
reports garbage sizes during teardown and there is no reason to
|
||
assume v3 doesn't.
|
||
- `MessageDialog`/`QuestionDialog` (`:566-579`) → v3 dialogs API.
|
||
- `Quit(ctx)` (`:608`) → `app.Quit()`.
|
||
- `OnBeforeClose` returning `true` to veto → v3's cancellable window
|
||
event (`event.Cancel()`). This is the quit-during-tag-writes veto —
|
||
a data-safety path, so test it deliberately.
|
||
|
||
**2e — `backend/frontendutil/`** — five dialog methods, mechanical.
|
||
|
||
**2f — `backend/assets/handler.go`** — v3 changes asset serving. Note
|
||
`RegisterHandler` (`:65`) mounts testctl at `/__test/`; Phase 6 may
|
||
replace it with `ServiceOptions{Route:}` instead.
|
||
|
||
**Acceptance:** app launches, window is the persisted size, all 12
|
||
services callable, quit-during-writes still vetoes.
|
||
|
||
**Est.** One session. This is the "1–4 hours" the official guide prices.
|
||
|
||
### Phase 2 — what actually landed
|
||
|
||
The shape held, but four things differed from the plan.
|
||
|
||
**`WebviewGpuPolicy` did not survive where the plan said it would.**
|
||
It is not on v3's `LinuxOptions` at all — it moved to `LinuxWindow`,
|
||
inside `WebviewWindowOptions`, so it is a per-window setting now. The
|
||
NVIDIA/Wayland `WEBKIT_DISABLE_DMABUF_RENDERER` workaround in
|
||
`main.go` is **kept**: v3's `operatingsystem` package detects the
|
||
driver, but nothing in it sets that variable, so removing ours would
|
||
have removed the fix.
|
||
|
||
**There is no `OnStartup` or `OnDomReady` option.** Both are gone from
|
||
`application.Options`. The app-level wiring is driven from
|
||
`app.Event.OnApplicationEvent(events.Common.ApplicationStarted, …)`,
|
||
which fires after every service's `ServiceStartup`. That ordering is
|
||
what makes the split work: the services take their context from the
|
||
runtime, and `OnStartup` is left holding only the cross-service wiring
|
||
that belongs to no single service.
|
||
|
||
**Ten services convert, not twelve.** `jobs.Registry` and
|
||
`explore.SearchIndex` have a `SetContext` too, but neither is bound —
|
||
the registry is wrapped by `jobs.NewService` and the index is internal
|
||
to `explore.Service` — so both keep it. Converting them would have
|
||
been churn for no binding removed. The ten that are bound now
|
||
implement `ServiceStartup`, and each therefore imports
|
||
`wails/v3/pkg/application`, which is a real cost: five files imported
|
||
wails before, fifteen do now.
|
||
|
||
**`application.NewService` is generic over a concrete pointer type**,
|
||
so `FEBindings []any` could not survive as a slice of values — the
|
||
binding generator is a static analyser that reads those call sites, so
|
||
a `[]any` would have generated nothing at all. It is
|
||
`[]application.Service` built from explicit `NewService` calls.
|
||
|
||
**The quit veto changed shape, and this is the part to test
|
||
deliberately.** v2's `MessageDialog` blocked and returned the button;
|
||
v3's `Show()` returns immediately and the answer arrives on a
|
||
`Button.OnClick` callback — on GTK4 `gtkDispatch` runs the dialog in a
|
||
goroutine. So `ShouldQuit` cannot ask and answer in one call. It
|
||
vetoes the quit, shows the dialog, and calls `app.Quit()` from the
|
||
callback if the user says quit; `quitConfirmed` is what stops that
|
||
second `Quit()` coming straight back and asking again, and
|
||
`quitAsking` stops a second close attempt stacking dialogs.
|
||
|
||
**Window state moved off the quit path entirely.** It was `OnBeforeClose`'s
|
||
other job; it is now a `Common.WindowClosing` hook, because the size
|
||
has to be read while the window still exists and v3's `OnShutdown`
|
||
takes no context and no window. The sub-minimum guard is kept for the
|
||
reason it was written.
|
||
|
||
---
|
||
|
||
## Phase 3 — Events
|
||
|
||
**Goal:** one file changes on the Go side; 22 imports get a shim.
|
||
|
||
Per **D1**:
|
||
|
||
```go
|
||
func Deliver(ctx context.Context, name string, data ...any) error {
|
||
if sink := sinkFrom(ctx); sink != nil {
|
||
sink.Emit(name, data...)
|
||
return nil
|
||
}
|
||
app := application.Get()
|
||
if app == nil {
|
||
return ErrNoRuntime // replaces the ctx.Value("events") probe
|
||
}
|
||
app.Event.Emit(name, data...)
|
||
return nil
|
||
}
|
||
```
|
||
|
||
**Unchanged:** 45 `events.Emit` call sites across 13 files;
|
||
`events.WithSink` in 7 test files; `backend/events/recorder.go`;
|
||
`/__test/emit`'s use of `events.Deliver`
|
||
(`backend/testctl/handlers_dev.go:118-123`);
|
||
`backend/events/cmd/genevents` and `frontend/src/events.ts` (that
|
||
generator reads a const block and knows nothing about Wails).
|
||
|
||
**Changed:** `backend/events/emit.go` only.
|
||
|
||
**`TestNoDirectRuntimeEmits`** (`noemit_test.go`): keep it, retarget the
|
||
needle from `.EventsEmit(` to v3's emit. Its original justification
|
||
weakens (no more `log.Fatalf`), but "there is exactly one emit path in
|
||
this tree" remains worth pinning — it is what keeps `emitStatus`-style
|
||
dedup honest.
|
||
|
||
**Frontend:** create the `@runtime` shim (D4) exporting `EventsOn` over
|
||
`@wailsio/runtime`'s `Events.On`. 22 import sites unchanged.
|
||
|
||
**Deferred, not done here:** `application.RegisterEvent[T]` overlaps
|
||
with `genevents`. Do not fold them together during the migration —
|
||
note it as follow-up work so a port doesn't become a redesign.
|
||
|
||
**Acceptance:** `make test` green; a `/__test/emit` still renders
|
||
push-driven views.
|
||
|
||
**Est.** Half a session.
|
||
|
||
### Phase 3 — what actually landed
|
||
|
||
Exactly as designed, and it is the migration's whole point: the
|
||
`ctx.Value("events")` probe of a **v2-private context key** is gone,
|
||
replaced by `application.Get() == nil`. D1 held — `events.Emit` keeps
|
||
its `ctx`, all 45 call sites and all 7 test files are untouched, and
|
||
one production file changed.
|
||
|
||
Two adjustments. `Deliver` no longer rejects a nil context outright:
|
||
a nil context cannot carry a sink, but it is no longer a reason not to
|
||
deliver, because delivery does not go through the context any more.
|
||
And `TestNoDirectRuntimeEmits`'s needle moved from `.EventsEmit(` to
|
||
`.Event.Emit(` — the test is kept, but its justification is now the
|
||
weaker one written into its doc comment: not "a direct call can kill
|
||
the process" (v3's emit cannot), but "one emit path is what lets
|
||
`emitStatus` drop an unchanged payload for every caller at once".
|
||
|
||
The six test files that called `SetContext` on a converted service now
|
||
call `ServiceStartup(ctx, application.ServiceOptions{})`. That is the
|
||
one place the plan's "zero test edits" ambition does not apply — it is
|
||
Phase 2's rename reaching the tests, not Phase 5's fake.
|
||
|
||
---
|
||
|
||
## Phase 4 — Bindings
|
||
|
||
**Goal:** the frontend imports real generated v3 bindings.
|
||
|
||
**Steps**
|
||
|
||
1. Generate against the real services and **inspect the tree first** —
|
||
the exact nesting decides the codemod.
|
||
2. Remap `@go` in `frontend/vite.config.mts:7` and
|
||
`frontend/tsconfig.json:28`; drop the `wailsjs/go/**/*.js` exclude
|
||
at `tsconfig.json:50` (v3 emits `.ts`).
|
||
3. **Codemod all 93 `@go/...` import sites.** Phase 0 disproved the
|
||
hope that an alias absorbs this: `@go/library/Library` becomes
|
||
`@go/yellowjacket/backend/library`, a change of *shape*.
|
||
4. `@go/models` (42 sites) — v3 has no single `models.ts`; types come
|
||
from the per-package modules. This is the largest single cluster and
|
||
should be scripted, not hand-edited.
|
||
5. Rewrite `scripts/bindings-check.sh`. Its `chmod` dance and
|
||
`core.fileMode=false` diff exist purely because v2's generator wrote
|
||
three runtime files 755 — likely all deletable.
|
||
6. **Pin an explicit tag set for binding generation** and make it the
|
||
one the shipped binary uses. The generator is a static analyser, so
|
||
it sees only the configuration it is told about; we have three
|
||
(`webkit2_41`, `+indexbuild`, `+dev`) and `backend/testctl` is
|
||
`//go:build dev`. Getting this wrong means the generated API
|
||
reflects a configuration users never run. v2 had no such hazard
|
||
(runtime reflection).
|
||
7. Check whether any call site depends on the return being a plain
|
||
`Promise` — v3 returns `CancellablePromise`.
|
||
|
||
**Acceptance:** `tsc --noEmit` clean; `make bindings-check` passes and
|
||
is still a pre-commit hook and a CI step (`ci.yml:176`); the 12
|
||
`SetContext` bindings and the `context` model are **gone**.
|
||
|
||
**Est.** One session, mostly codemod-and-verify.
|
||
|
||
### Phase 4 — what actually landed
|
||
|
||
`frontend/wailsjs/` is deleted; `frontend/bindings/` is committed in
|
||
its place. **272 methods across 12 services**, and the acceptance
|
||
criteria hold: no `SetContext` binding survives and there is no
|
||
`context` model, which is Phase 2c's `ServiceStartup` port showing up
|
||
where it was predicted to.
|
||
|
||
**The alias absorbs the prefix, so the codemod was one line per site.**
|
||
`@go/*` points at `bindings/yellowjacket/backend/*` rather than at
|
||
`bindings/`, so `@go/library/Library` became `@go/library/library.js`
|
||
— the same shape, lowercased, plus the extension the generated tree
|
||
uses internally. The models cluster was the only structural change:
|
||
v2's single `models.ts` of namespaces became one module per package, so
|
||
`import type { library } from '@go/models'` is
|
||
`import type * as library from '@go/library/models.js'` and every
|
||
`library.Track` usage is untouched. **Two sites the plan's inventory
|
||
missed**, both because they are outside `src/`: `frontend/index.ts`'s
|
||
three imports, found by the vite build rather than by `tsc` (the alias
|
||
resolves for the compiler and not for the bundler when the path's case
|
||
is wrong on a case-sensitive filesystem).
|
||
|
||
**The 102 type errors were not migration damage. They were v2's
|
||
generator being caught lying**, and that is worth stating because the
|
||
temptation is to suppress them. A Go `nil` slice marshals to JSON
|
||
`null` and always has; v2 typed it `T[]`. A Go named string type is a
|
||
closed set; v2 typed it `string`. v3 types them `T[] | null` and as a
|
||
real TS `enum`, and there is no generator flag to turn either off —
|
||
correctly, since both are true.
|
||
|
||
So the fix is one seam rather than 78 patches: **`utils/binding.ts`
|
||
states the app's actual contract at the only place it is true.** `list`
|
||
yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil
|
||
map and drop null-valued keys (which loses nothing —
|
||
`noUncheckedIndexedAccess` already makes every read `V | undefined`, so
|
||
an absent key and a nil value are indistinguishable downstream), and
|
||
`compact` is the same thing for a map arriving as a *field*. All three
|
||
also return a plain `Promise`: v3 bindings return a
|
||
`CancellablePromise` and nothing in this app cancels one, so letting it
|
||
inward would put a Wails type in every store signature for a capability
|
||
none of them use.
|
||
|
||
Where a nullable slice is a model *field* rather than a return value
|
||
there is no boundary to put it at, and those are `?? []` at the point
|
||
of use — `score.candidates`, `shelf.albums`, `page.shelves`,
|
||
`view.items`.
|
||
|
||
Four smaller consequences, all of them v3 being stricter:
|
||
|
||
- **`createFrom` is gone.** v3 emits interfaces (`-i`), so the three
|
||
`explore.ShelfPage.createFrom({…})` sites are object literals and
|
||
`new tracklist.Column()` is one too.
|
||
- **An enum needs a value import.** `import type * as download` cannot
|
||
reach `Format`, so `download-clients.ts` imports it separately — and
|
||
its local format list is now `Format[]` rather than `string[]`, which
|
||
is a small win v2 could not have given.
|
||
- **Four test fixtures widen an enum field back to its value union**
|
||
(`` state?: `${Job['state']}` ``) rather than casting, so the
|
||
fixtures stay literal-checked.
|
||
- `job-store.ts` still hand-mirrors `JobState`/`JobKind` as string
|
||
unions beside the generated enums. Left alone deliberately; folding
|
||
them together is a redesign, not a port.
|
||
|
||
**`bindings-check` got simpler and slower.** The `chmod` dance and
|
||
`core.fileMode=false` diff are gone with v2's generator (which wrote
|
||
three runtime files 755); a check for added or removed *files* is new,
|
||
because a renamed service is now a renamed module rather than a changed
|
||
line. It runs in ~3.5 s warm against v2's ~1.5 s, ~20 s on a cold build
|
||
cache — v3's generator is a static analyser over the whole package
|
||
graph.
|
||
|
||
**On step 6 (the tag set): the answer is "no flag", and that is the
|
||
deliberate answer.** The generator sees only the configuration it is
|
||
told about, and the one that matters is the one users run — which,
|
||
since Phase 1, is the *default* tag set. Neither of this repo's other
|
||
two configurations (`indexbuild`, `dev`) adds a bound service, so
|
||
generating under them would only widen the API past what ships. Written
|
||
into `scripts/bindings-check.sh` so it is not re-derived. Note this is
|
||
*not* what Wails' own Taskfile does (`-tags server,production`), for
|
||
the good reason that its shipped artifact is the Docker image.
|
||
|
||
**One error is left, and it is Phase 5's.**
|
||
`test/harness.test.ts:69` imports `EventsEmit` from the shim, which
|
||
does not export one — because nothing in `src/` emits from the
|
||
frontend, and adding an export to production code to satisfy a test
|
||
would be the wrong way round. The test underneath it is a bigger
|
||
problem than its import: it asserts that a frontend emit notifies
|
||
in-page listeners before reaching Go, which v2 did and **v3 does
|
||
not** — `Events.Emit` in `@wailsio/runtime` calls the backend and
|
||
touches no local listener. That is exactly the "re-derive the fake,
|
||
don't port it" risk the plan flagged, arriving early. `make ui-test`
|
||
is broken regardless: the fake still fakes `window.go`.
|
||
|
||
---
|
||
|
||
## Phase 5 — The Vitest fake (`make ui-test`, 480 tests)
|
||
|
||
**Goal:** 480 tests still run in ~2 s with no Wails, backend, or display.
|
||
|
||
`frontend/test/support/wails-fake.ts` (243 lines) fakes exactly two
|
||
globals, which is *why* the suite is that fast. The design survives;
|
||
the targets change.
|
||
|
||
- `makeGoProxy()` (`:179-193`) and `makeRuntimeProxy()` (`:197-225`)
|
||
are the whole change. The recursive `Proxy` is schema-free, so it
|
||
does not need to learn v3's binding surface — it needs to intercept
|
||
wherever v3 routes calls, now that `window.go` is gone. With D4's
|
||
shim in place, that is one seam.
|
||
- The `Listener` class (`:23-44`) and `notify()` (`:105-125`)
|
||
deliberately mirror v2's
|
||
`internal/frontend/runtime/desktop/events.js`, including
|
||
`maxCallbacks` expiry and the ordering where `EventsEmit` notifies
|
||
local JS listeners **before** Go. **Re-derive this against v3's
|
||
actual implementation rather than porting it.** If v3 changed the
|
||
ordering, failures will look like store bugs, not fake bugs.
|
||
- `reset()` (`:163-169`) keeps listeners on purpose, because store
|
||
singletons are never re-imported. That constraint is unchanged.
|
||
|
||
**Acceptance:** `make ui-test` green, **zero test-file edits**. Any test
|
||
that needs changing is evidence the fake is wrong, not the test.
|
||
|
||
**Est.** One session. This is where the official estimate stops
|
||
applying.
|
||
|
||
### Phase 5 — what actually landed
|
||
|
||
**757 tests pass across all 63 files, and one test file changed.** The
|
||
plan's design survived and got smaller, because v3 has a seam v2 did
|
||
not.
|
||
|
||
**`setTransport()` is the whole fake.** v3 routes *every* runtime call
|
||
— bindings, event emits, window, dialogs, clipboard, screens — through
|
||
one IPC transport, and replacing it is public, documented API. So the
|
||
fake covers strictly more than v2's two globals did while being
|
||
shorter, and the tests still exercise the real generated bindings, the
|
||
real runtime and the real store code.
|
||
|
||
**The dispatcher is deleted rather than re-derived.** The plan said to
|
||
re-derive `Listener`/`notify()` against v3 instead of porting them; the
|
||
better answer is that neither is needed. `emit()` goes through
|
||
`window._wails.dispatchWailsEvent`, the exact entry point the backend's
|
||
push uses, so delivery, `maxCallbacks` expiry and the post-dispatch
|
||
filter are the runtime's own code; registration and unregistration are
|
||
`Events.OnMultiple` / `Off` / `OffAll`. What *is* mirrored is one line
|
||
of Go — how `EventManager.Emit` packs variadic data into an event's
|
||
single `data` field (none is null, one is the value, more is the
|
||
slice), which is invisible when wrong and shows up as a store reading
|
||
`undefined` off its payload.
|
||
|
||
One thing stayed non-public: the listener registry, for
|
||
`listenerNames()`. `listener.js` has no entry in the package's exports
|
||
map, so `vitest.config.mts` aliases it. It buys the one question the
|
||
public surface cannot answer — did importing a store subscribe it —
|
||
and if Wails moves the file the import throws at setup, which is loud.
|
||
|
||
**A binding carries an ID, not a name, and the map has to be
|
||
complete.** `$Call.ByID(2822423495)` is FNV-1a over
|
||
`yellowjacket/backend/home.Service.GetShelves`, so the fake computes the
|
||
same hash — deriving the FQN from the generated tree rather than
|
||
writing it down. The Go type's casing survives in exactly one place,
|
||
each package's `index.ts` (`export { Library }`); the filename cannot
|
||
tell you `frontendutil.ts` is `FrontendUtil`. Building the map lazily
|
||
as paths are mentioned does not work: 21 assertions read `calls()` with
|
||
no argument and compare the whole list of paths, including methods no
|
||
test stubs. An unmapped ID records as `#<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 async
|
||
`runtimeCallWithID`, the transport, a `CancellablePromise`, against
|
||
v2's one resolved promise — and the tests were already written as
|
||
though `fixture()` meant "mounted *and loaded*". Fixing it there
|
||
rather than in each test is what kept this to one test-file edit.
|
||
Microtasks and **not** `setTimeout`: the first attempt used a timer,
|
||
which hung `transport.test.ts` for 45 s because it installs fake ones.
|
||
- **`tracklist-store` keeps its defaults on an empty answer.**
|
||
`GetTrackListColumns` substitutes `tracklist.DefaultColumns` only when
|
||
the whole config section is missing — a section that exists with no
|
||
columns returns nothing, and a track list with no columns is not what
|
||
that means. Until v3 this was accidental: the binding was typed
|
||
`Column[]`, an absent answer arrived as `undefined`, and `.map` threw
|
||
into the `catch` that restores the defaults. Phase 4's `list()` turned
|
||
that into an honest empty list and the accident stopped working.
|
||
|
||
**The one test file edited was `harness.test.ts`, and it was asserting
|
||
something no longer true.** v2's `EventsEmit` notified in-page listeners
|
||
*before* Go, so a frontend emit was observable synchronously. v3's
|
||
`Events.Emit` does not touch the local registry at all: it calls the
|
||
backend, and `EventProcessor.Emit` sends the event back out to every
|
||
window, including the emitting one. The page still sees its own emit,
|
||
one round trip later. The test says that now, and the fake reproduces
|
||
it with a microtask. That is the "re-derive, don't port" risk paying
|
||
off — ported blindly, this would have looked like a store bug.
|
||
|
||
**`make ui-test` still cannot complete in one run on this machine, and
|
||
that is not this migration.** A single browser session dies partway
|
||
through the 58 files it queues, with "Cannot connect to the iframe"
|
||
after ~5 s. It reproduces **unchanged at `c9905fb`**, the commit before
|
||
Phase 4 — checked in a worktree, not assumed — so it is a resource
|
||
limit here (6 GB available, 9 GB already in swap), not a regression.
|
||
Run in batches of six it is 757 passed, 0 failed. If CI is green on the
|
||
single run, nothing needs doing; if it is not, that is a pre-existing
|
||
problem to file separately rather than something Phase 5 introduced.
|
||
|
||
**Not verified: `make ui-visual`.** Screenshot baselines only mean
|
||
anything on the machine that recorded them, and nothing here changes
|
||
what a component renders.
|
||
|
||
---
|
||
|
||
## Phase 6 — E2E harness and testctl
|
||
|
||
**Goal:** `make e2e` green with **zero spec edits**. That is the
|
||
acceptance test for the whole migration.
|
||
|
||
**6a — `.playwright/init-events.js` is a full rewrite (302 lines).** It
|
||
does not use the public API by design; its own header says so. It wraps
|
||
`window.wails.EventsNotify` — in v2 every backend event enters the page
|
||
at exactly one place (`ipc_websocket.js`:
|
||
`case "n": window.wails.EventsNotify(message)`) — and installs a
|
||
property accessor on `window` to wrap at assignment time, because
|
||
`window.wails` doesn't exist when an initScript runs.
|
||
|
||
None of that survives. What **must** survive is the public surface on
|
||
`window.__yjEvents`: `wait()`, `ready()`, `call()`, `all`, `since`,
|
||
`names`, `count`, `last`, `reset`. `e2e/support/fixtures.ts`, every
|
||
spec, and `e2e/perf/measure.mjs` are written against it.
|
||
|
||
v3 equivalents, all settled by Phase 0:
|
||
- Hook `window._wails.dispatchWailsEvent` (same accessor-on-assignment
|
||
trick still applies) for inbound events.
|
||
- `call()` routes through
|
||
`Call.ByName('yellowjacket/backend/queue.Queue.GetState', …)` and
|
||
**drops its timeout race entirely** — v3 rejects on bad args and
|
||
unknown methods.
|
||
- `ready()` likewise becomes a `ByName` call rather than a
|
||
`window.go?.queue?.Queue?.GetState` poll.
|
||
|
||
**6b — the `window.go` regression.** `harness.spec.ts:18-19` asserts
|
||
"all 11 bound services land on `window.go`" (11 where the count is now
|
||
12 — download is conditional), and `perf/measure.mjs:130-180`
|
||
*enumerates* bindings to wrap every bound method, which is what makes
|
||
"did that refetch the library" a fact rather than an inference. v3 has
|
||
no runtime enumeration surface. Two options:
|
||
|
||
1. **Preferred.** Generate the list at build time from
|
||
`frontend/bindings/` — it is a real module tree, so it can be
|
||
imported and walked — and wrap that.
|
||
2. Wrap an explicit hand-maintained list. Cheaper, and silently goes
|
||
stale — exactly the failure mode `bindings-check` exists to prevent.
|
||
|
||
If (2), say so in `measure.mjs` and add it to what `bindings-check`
|
||
guards.
|
||
|
||
**6c — `backend/testctl/` gets easier.** Its only Wails coupling is
|
||
`Deps.Context func() context.Context` (`testctl.go:46-53`) — a function
|
||
rather than a value "because the context only exists after OnStartup."
|
||
`ServiceStartup(ctx, opts)` may make that indirection unnecessary.
|
||
Better still, v3 supports a service implementing `http.Handler`
|
||
registered with
|
||
`application.NewServiceWithOptions(svc, application.ServiceOptions{Route: "/__test"})`
|
||
— a first-class replacement for mounting a mux on the asset server.
|
||
The double gate (`//go:build dev` + `YJ_TESTCTL=1`) stays exactly as is.
|
||
|
||
**6d — `scripts/dev-headless.sh` and the port.** Evaluate replacing the
|
||
hand-rolled headless launch with `-tags server`. Two constraints:
|
||
`e2e/playwright.config.ts` expects `:34115` (set `WAILS_SERVER_PORT`),
|
||
and testctl must still mount. If server mode complicates the mount,
|
||
keep the existing script — the win is tidiness, not capability.
|
||
|
||
**Acceptance:** `make e2e` green on **both** Chromium and WebKit, zero
|
||
spec edits.
|
||
|
||
**Est.** One to two sessions. The largest and riskiest phase.
|
||
|
||
### Phase 6 — what actually landed
|
||
|
||
**`make e2e` is green on chromium: 92 passed, 0 failed.** Three of the
|
||
four things this phase replaced came out better than what they
|
||
replaced, and the fourth — `window.go` enumeration — turned out not to
|
||
be needed at all.
|
||
|
||
**6d decided itself.** The plan asked whether `-tags server` was worth
|
||
adopting; it was not optional. `dev-headless.sh` ran a `-tags dev`
|
||
binary whose `app_dev.go` parsed `-devserver` / `-assetdir` straight
|
||
out of `os.Args`, and that file went with v2 — so the harness had **no
|
||
server at all**, not a worse one. `-tags dev,server` is a first-class
|
||
mode, it needs no display, and testctl mounts on it unchanged. **Xvfb
|
||
is gone** from the script and from CI, which retires the "Xvfb is not
|
||
optional" note the script had carried since plan 005. `dbus-run-session`
|
||
stays, for MPRIS, exactly as before.
|
||
|
||
**6a hooks two places, and the outbound one is the good surprise.**
|
||
Inbound is `window._wails.dispatchWailsEvent` — the entry point the
|
||
backend's own push uses. It is wrapped by *pre-creating* the object the
|
||
runtime keeps (`window._wails = window._wails || {}`) and putting an
|
||
accessor on the one property, which is simpler than v2's
|
||
accessor-on-`window`, where the whole object was replaced.
|
||
|
||
Outbound is **`fetch`**. v3 routes every runtime call — bindings, event
|
||
emits, window, dialogs, clipboard — through one POST to
|
||
`/wails/runtime`. There is no global to wrap the way v2's
|
||
`window.runtime` could be, and it does not matter: one hook sees calls
|
||
from any module, needs no walk of an object graph, and cannot miss a
|
||
call made before the harness looked, which is what v2's "runs twice"
|
||
dance in `measure.mjs` existed to work around.
|
||
|
||
**`__yjEvents.call` is now HTTP, and that is what unblocked everything
|
||
else.** It posts by *method name*, so it depends on nothing in the
|
||
app's bundle and works on a page with no init script. That is what lets
|
||
`seed-sandbox.sh` **drop `playwright-cli` entirely** — it drove
|
||
`AddLibrary` through a real browser only because `window.go` was v2's
|
||
one way in — taking with it a global npm install, a second Chromium,
|
||
and the `PLAYWRIGHT_BROWSERS_PATH` revision dance in CI. A seed is
|
||
`curl` now and still produced by running the app.
|
||
|
||
**6b: option (1), and it costs less than feared.**
|
||
`e2e/support/method-ids.mjs` derives `methodID → pkg.Type.Method` from
|
||
`frontend/bindings/` by reading the id literal beside the function that
|
||
sends it — no hashing, nothing to drift. Plain `.mjs` rather than `.ts`
|
||
because `measure.mjs` runs under bare `node`, and one derivation is
|
||
better than two that can disagree. `harness.spec`'s enumeration was not
|
||
worth replacing in kind: "is this the real app" is now asked of the
|
||
runtime (`_wails.clientId`, `dispatchWailsEvent`) and of the backend
|
||
(a real method answers, an invented one is refused), which is a better
|
||
question than `Object.keys`.
|
||
|
||
**6c: nothing to do.** testctl's mount on the asset handler works in
|
||
both modes and is shared with `/artist-images/`; `ServiceOptions{Route}`
|
||
would be churn. `Deps.Context` stays a function, because
|
||
`testctl.Register` still runs in `NewYellowJacketApp`, before any
|
||
context exists.
|
||
|
||
**Four bugs, and the migration is how each surfaced.**
|
||
|
||
- **The cross-service wiring never ran headless.** Phase 2 hung it off
|
||
`Common.ApplicationStarted`, which is the right *moment* and the
|
||
wrong *mechanism*: server mode emits no application events at all
|
||
(`setupCommonEvents` is an explicit no-op under `-tags server`). So
|
||
the desktop build wired itself and the harness build did not — "No
|
||
player set, cannot load track", the queue with no `TrackLoader`, a
|
||
track that changed the queue and then silently did nothing. It is a
|
||
service registered last now (`backend/startup.go`), which gets the
|
||
ordering from the mechanism rather than from an event: services start
|
||
in registration order, on the main goroutine, in every mode.
|
||
- **Six specs called `SetQueue` with three of its four arguments.** v2
|
||
accepted the call and filled the gap with a zero value; v3 answers
|
||
`expects 4 arguments, got 3`. `NO_QUEUE_SOURCE` says explicitly what
|
||
was being supplied silently.
|
||
- **`requested-badge`'s cleanup was a no-op.** It read `window.go`
|
||
inside a type assertion and `return`ed on `if (!svc)` — the silent
|
||
cleanup its own comment was written to prevent, one migration later,
|
||
which is why the spec failed against its own leftovers. It posts to
|
||
the runtime endpoint now, which needs no bridge and no global.
|
||
- **`SearchIndex.Search` trusted a startup latch.** `IsReady()` is set
|
||
once, so rows staged by a spec afterwards were unsearchable, and
|
||
three specs passed only when an earlier one happened to flip it —
|
||
order-dependent, and reproducibly red in isolation. `shelves.go` had
|
||
already fixed exactly this and left `hasCatalogRows` behind; the
|
||
search path uses it as the fallback, with the latch still the fast
|
||
path.
|
||
|
||
**Two spec edits, both deletions of assertions about v2.**
|
||
`harness.spec` checked `Object.keys(window.go)` and that a bad call
|
||
*hung* — the second being a test that the harness's own invented
|
||
deadline fired, since v2 gave it nothing else. And `album-actions`
|
||
asserted a `.tracklist-legend` that **`dcc40b1` deleted on `main`**:
|
||
that spec has been failing since, verified in a worktree, and what
|
||
replaced it (the dimming and its `aria-disabled`) is covered in
|
||
`frontend/test/components/album-actions.test.ts`.
|
||
|
||
**One thing to know before trusting a local run.** `dev-headless.sh`
|
||
does not set `YJ_CORE_INDEX_URL`, so a local `make e2e` fetches the
|
||
real 1.1 M-row explore artifact and then behaves differently from CI
|
||
(which points it at a dead address at the job level) — slower, and with
|
||
testctl's snapshot/restore copying an enormous table set. Run it the
|
||
way CI does:
|
||
|
||
```
|
||
YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" make dev-headless SEED=default
|
||
```
|
||
|
||
**Not verified: WebKit.** Playwright's Linux WebKit links Ubuntu
|
||
libraries Arch does not provide, so it remains CI-only — which is
|
||
precisely why `ci.yml`'s `if: ${{ !cancelled() }}` on that step still
|
||
matters.
|
||
|
||
---
|
||
|
||
## Phase 7 — CI and packaging
|
||
|
||
- `.gitea/workflows/ci.yml:74,220`: `libwebkit2gtk-4.1-dev libgtk-3-dev`
|
||
→ `libwebkitgtk-6.0-dev libgtk-4-dev`.
|
||
- The PulseAudio null-sink setup and its three-second timing check are
|
||
unrelated and stay exactly as they are.
|
||
- The WebKit Playwright project (`:364-369`, `if: ${{ !cancelled() }}`)
|
||
matters **more** after this, not less — it is the only approximation
|
||
of the shipping renderer, and v3 may change which WebKit that is.
|
||
Keep the `!cancelled()` guard; it is why WebKit signal was silently
|
||
absent for two sessions before.
|
||
- `packaging/arch/PKGBUILD` and
|
||
`packaging/homebrew/Formula/yellowjacket.rb` carry the build tag and
|
||
dependency lists.
|
||
- `make skill-check` fails if `.pi/` documents a nonexistent make
|
||
target — update `.pi/skills/yellowjacket-dev/SKILL.md` and
|
||
`references/schema-change.md` in the **same commit** as any rename.
|
||
- Update `CLAUDE.md`: the `webkit2_41` mandate (`:53-73`), the
|
||
Arch/Ubuntu tag rationale (`:1083`), the events-wrapper section, and
|
||
the harness description.
|
||
|
||
**Acceptance:** a green CI run on both jobs.
|
||
|
||
**Est.** Half a session.
|
||
|
||
### Phase 7 — what actually landed
|
||
|
||
Most of the bullets above had already ridden along with Phases 1–6:
|
||
`ci.yml`'s apt lists were `libwebkitgtk-6.0-dev libgtk-4-dev` on both
|
||
jobs, Xvfb and `@playwright/cli` were gone from it, the PKGBUILD's
|
||
`depends=()` was `webkitgtk-6.0 gtk4`, and `nfpm.yaml` shipped the GTK4
|
||
dependency set. **What had not been checked is whether any of the
|
||
packaging recipes still work**, and neither did.
|
||
|
||
**Both of them were still calling v2's CLI.** `go tool wails3 build
|
||
-clean -trimpath -ldflags …` fails outright — `flag provided but not
|
||
defined: -clean`. v3's `build` takes `-tags`, `-obfuscated` and
|
||
`-garbleargs` and nothing else, because the build is a Taskfile tree
|
||
now and `-trimpath`/`-w -s` live in the production task's own flags.
|
||
Both recipes also installed from `build/bin/`, which is v2's output
|
||
path; v3 writes to `bin/`, and `build/` is *tracked build assets*.
|
||
Neither had been run since Phase 1 — the same gap the corrected note
|
||
under Phase 1 records about `make build-dev`.
|
||
|
||
**The version stamp needed a seam, and it is this repo's one edit to
|
||
the scaffold Taskfiles.** `wails3 build` has no `-ldflags`, and
|
||
`build:native` computes `BUILD_FLAGS` in its own `vars:`, so a CLI
|
||
variable cannot override it. `LDFLAGS_EXTRA` is appended *inside* the
|
||
production `-ldflags` string in `build/linux/Taskfile.yml` and
|
||
`build/darwin/Taskfile.yml` (identical on both, so the Homebrew formula
|
||
has one invocation rather than two), empty by default so `make
|
||
build-dev` and `make build-prod` are unchanged. Verified end to end,
|
||
not by reading the template: `wails3 task build LDFLAGS_EXTRA="-X
|
||
'main.version=v9.9.9' …"` produces a binary that logs `version: v9.9.9`
|
||
on startup.
|
||
|
||
**The tasks invoke `wails3` by bare name**, which is why the Makefile
|
||
has `scripts/toolbin` — and neither packaging recipe had it, so both
|
||
would have died at the first sub-task even with correct flags. Both
|
||
prepend it now.
|
||
|
||
**Bundling is a separate step from building in v3**, which the formula
|
||
did not know: `task build` produces a bare binary on *both* platforms,
|
||
and the macOS `.app` is `task package`. The formula's `Dir["build/bin/
|
||
*.app"]` would have found nothing and `odie`'d.
|
||
|
||
**The build assets were a scaffold's, not this app's** — and this is
|
||
the find that mattered most. `build/darwin/Info.plist` named
|
||
`CFBundleExecutable` **`yjref`** (the scratchpad app Phase 1 scaffolded
|
||
from) and `com.example.yjref`; `nfpm.yaml` packaged `./bin/yjref` to
|
||
`/usr/local/bin/yjref`; the `.desktop` template said "A yjref
|
||
application"; the Windows manifest said `com.example.yjref`. A macOS
|
||
`.app` built from that plist would not have launched. They are
|
||
generated from `build/config.yml`, so the fix is
|
||
`wails3 task common:update:build-assets` and filling that file's `info`
|
||
block — which had also never been filled from `wails.json`, contrary to
|
||
Phase 1 step 3. Two consequences recorded in place: nfpm's `homepage`
|
||
and `license` are **not** derived from `config.yml` and survive the
|
||
refresh (they were `wails.io` / `MIT`), and the refresh regenerates
|
||
`build/ios/` and `build/android/`, which are gitignored rather than
|
||
deleted-and-rediscovered on every run.
|
||
|
||
**Documentation.** `make skill-check` passes; `.pi/` needed no changes
|
||
(Phase 6 had already retired `window.go` and Xvfb from it). `README.md`
|
||
still told a contributor to `go install wails/v2/cmd/wails` and
|
||
`apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev`. CLAUDE.md gained
|
||
a **Packaging** section for the four Taskfile facts above, and its
|
||
lifecycle, bindings, harness, events and CI sections were rewritten
|
||
onto v3 — including the point of the whole migration, that
|
||
`TestNoDirectRuntimeEmits`'s justification is now the *weaker* one.
|
||
|
||
**Locally green after the change:** `make lint` and `make test` (all
|
||
three build configurations), `tsc --noEmit`, `make css-check`,
|
||
`make bindings-check`, `make skill-check`, `make commit-check`,
|
||
`make build-prod`, 757 Vitest tests across all 58 files (batched — the
|
||
single-run failure still reproduces and is still this machine's
|
||
resource limit, unchanged from Phase 5), and `make e2e` **92/92 on
|
||
chromium**.
|
||
|
||
**Still unverified: WebKit**, and it is CI-only by design. That is the
|
||
one thing standing between this branch and a merge.
|
||
|
||
---
|
||
|
||
## Phase 8 — What v3 unlocks (explicitly out of scope)
|
||
|
||
Listed so nobody smuggles them into the port and calls it a migration.
|
||
|
||
- **System tray with menus.** v2 has no first-class tray API; v3 does
|
||
(`systray-basic`, `systray-menu`: attached window, left-click toggle,
|
||
right-click menu, light/dark icon variants). For a music player this
|
||
is real — play/pause/skip without raising the window, minimise to
|
||
tray. Most likely thing to make the migration worth *scheduling*.
|
||
- **Multi-window** — a detached mini-player as a first-class window.
|
||
- **Native menus** (`window.SetMenu`, `app.NewMenu`).
|
||
- **Single-instance** with `OnSecondInstanceLaunch`.
|
||
- **Typed events** via `RegisterEvent[T]`, possibly retiring
|
||
`genevents`.
|
||
- Richer bindings (real param names, preserved doc comments) — a DX
|
||
nicety, not a driver.
|
||
|
||
`backend/mediacontrols` (MPRIS over raw D-Bus) and `backend/profiling`
|
||
(pprof, build-tag-gated) touch no Wails API and are unaffected.
|
||
|
||
---
|
||
|
||
## Risk register
|
||
|
||
| Risk | Severity | Status |
|
||
|---|---|---|
|
||
| v3 can't drive the headless harness | ~~fatal~~ | **Retired.** `-tags server` verified: calls + events, no display |
|
||
| Arch/Ubuntu need different webkit tags | ~~high~~ | **Retired.** Both ship webkitgtk-6.0; default builds in the CI container |
|
||
| No `window.go` → e2e/perf lose binding enumeration | ~~high~~ | **Retired.** 6b option (1): `e2e/support/method-ids.mjs` derives id → name from `frontend/bindings/`. The fetch hook made enumeration unnecessary for *wrapping*; only labelling needed the map |
|
||
| 93 `@go` sites need editing after all | ~~high~~ | **Retired.** Codemod done in Phase 4; the alias absorbed the prefix, so it was a specifier rewrite |
|
||
| Binding generation analyses the wrong build config | ~~high~~ | **Retired.** Default tags *are* the shipped configuration since Phase 1; recorded in `scripts/bindings-check.sh` |
|
||
| Beta churn mid-migration | high | Pin `beta.8`. `beta.3`→`beta.8` in one app's lifetime |
|
||
| E2E rewrite silently weakens coverage | ~~high~~ | **Retired.** 92/92 on chromium. Two spec edits, both deleting assertions about v2 behaviour that is gone; one deletion of a spec already failing on `main` |
|
||
| `make ui-test` cannot complete in one browser session here | low | *New.* Pre-existing — reproduces at `c9905fb`. A resource limit on this machine; batched runs are green. Watch CI |
|
||
| v3 event ordering differs from v2's | ~~high~~ | **Retired.** Confirmed and handled in Phase 5: v3's frontend `Events.Emit` round-trips through Go instead of notifying locally first. One test asserted the old behaviour and now asserts the new |
|
||
| `ServiceShutdown()` signature trap | medium | Silent no-call; grep after Phase 2c |
|
||
| An app-level event that a headless mode never emits | — | *Found in Phase 6, not predicted.* Server mode emits **no** application events; the cross-service wiring is a service now. Anything else keyed on `Common.*` is suspect |
|
||
| Quit-during-writes veto breaks | medium | Data-safety path; test deliberately in 2d |
|
||
| Regression no tier covers | medium | `make perf` before/after on the same seed. Note the fixture seed leaves the bulk-library rows blank; a real comparison wants `make sandbox-seed-bulk` |
|
||
| GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run |
|
||
|
||
---
|
||
|
||
## Sequencing and staging
|
||
|
||
**Phases 1–4 must not be merged.** They leave the app building and
|
||
running with the harness broken, and plan 005's whole point is that a
|
||
broken harness means a coding agent cannot develop this repo at all.
|
||
Phases 5 and 6 are what make the branch mergeable — and they are the
|
||
majority of the work.
|
||
|
||
Recommended shape:
|
||
|
||
1. Land the **`webkit2_41` deletion + `pacman -S webkitgtk-6.0`**
|
||
independently if desired — it is useful on its own and touches
|
||
nothing else. *(Optional; can also ride along in Phase 1.)*
|
||
2. Branch `wails-v3` off a clean `wip`. Phases 1–4 as separate commits
|
||
on it, kept local.
|
||
3. Phases 5, 6, 7 onto the same branch.
|
||
4. One merge to `main` when `make test`, `make ui-test`, `make e2e`
|
||
(both browsers) and `make lint` are all green.
|
||
|
||
**Before starting:** `wip` currently has ~75 uncommitted files. Commit,
|
||
stash, or use a worktree — do not begin Phase 1 on a dirty tree.
|
||
|
||
**Total estimate:** 4–6 focused sessions. The official guide's "1–4
|
||
hours" covers roughly Phase 2 alone.
|
||
|
||
---
|
||
|
||
## Open questions
|
||
|
||
- Does v3 handle the NVIDIA/Wayland DMABuf workaround itself
|
||
(`main.go:32-39,134-155`)? Its `operatingsystem` package detects the
|
||
driver, which suggests it might. *Check in Phase 2a.*
|
||
- Is `backend/logging/`'s `logger.Logger` adapter deletable outright
|
||
once v3 uses `slog`? *Check in Phase 2a.*
|
||
- Does GTK4 change anything visible about rendering vs GTK3?
|
||
*Unmeasured; visual check on first run.*
|
||
- Should `application.RegisterEvent[T]` replace
|
||
`backend/events/cmd/genevents`? *Deliberately deferred past the
|
||
migration.*
|
||
- Does `-tags server` complicate mounting testctl? *Decides Phase 6d.*
|
||
|
||
**Answered by Phase 0** (kept so they aren't re-asked): which beta to
|
||
target (`beta.8`); whether v3's call-by-name rejects on bad args (yes,
|
||
cleanly — the timeout race goes); whether the headless dev surface
|
||
survives (yes, and improves).
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
- [Migration guide](https://v3.wails.io/migration/v2-to-v3/) — feature
|
||
mapping, testing checklist, the "1–4 hours" estimate
|
||
- [What's New in v3](https://v3.wails.io/whats-new/)
|
||
- [v3 beta announcement](https://v3.wails.io/blog/wails-v3-beta/)
|
||
- [Application lifecycle](https://v3.wails.io/concepts/lifecycle/)
|
||
- [`pkg/application` API](https://pkg.go.dev/github.com/wailsapp/wails/v3/pkg/application)
|
||
- [v2→v3 discussion #4509](https://github.com/wailsapp/wails/discussions/4509)
|
||
- [Late service registration #4066](https://github.com/wailsapp/wails/pull/4066)
|
||
- **Reference v3 app:** `/mnt/vault/dev/ljos` — project layout,
|
||
`build/config.yml`, Taskfile scaffold, `application.Service`,
|
||
`SingleInstanceOptions`. **Caveat:** it deliberately uses no generated
|
||
bindings and no events (its frontend talks HTTP to a separate
|
||
server), so it models Phase 1 well and Phases 3–6 not at all.
|
||
- Local Phase 0 artifacts (scratchpad, ephemeral): scaffolded `spike/`
|
||
app, `q2.mjs`/`q2b.mjs` browser probes, `q3_test.go` emit-safety
|
||
tests.
|