feat(wails): move the Go side to v3

Phases 2 and 3 of plan 009, plus the parts of phase 1 that could not
land before them. Nothing in the tree imports wails/v2 any more; all
three lint and test configurations are green and `go build .` produces
a running binary.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
2026-08-14 14:01:02 -04:00
co-authored by Claude Opus 5
parent f47b2db308
commit 4471db3aef
32 changed files with 601 additions and 564 deletions
+117 -77
View File
@@ -10,9 +10,10 @@ import (
"log/slog"
"net/http"
"path/filepath"
"sync/atomic"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/assets"
"yellowjacket/backend/autotagservice"
@@ -39,7 +40,9 @@ import (
// YellowJacketApp is the main application struct for Wails.
type YellowJacketApp struct {
FEBindings []any
// Services is what v3 binds to the frontend. Each entry's
// ServiceStartup runs before the app-level wiring in OnStartup.
Services []application.Service
FrontendUtil *frontendutil.FrontendUtil
logger *slog.Logger
@@ -61,6 +64,12 @@ type YellowJacketApp struct {
appContext context.Context
appConfig *config.Config
startupErr error
// quitAsking guards the one quit-confirmation dialog; quitConfirmed
// records that the user already answered "quit anyway", so the
// Quit() issued from that callback is not questioned again.
quitAsking atomic.Bool
quitConfirmed atomic.Bool
}
// NewYellowJacketApp creates and initializes the application.
@@ -211,26 +220,31 @@ func NewYellowJacketApp(
)
}
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
yjApp.appConfig,
yjApp.library,
yjApp.playlist,
yjApp.queue,
yjApp.player,
yjApp.tagWriter,
yjApp.explore,
yjApp.autotag,
jobs.NewService(yjApp.jobs),
home.NewService(
// application.NewService is generic over a concrete pointer type —
// the static analyser that generates bindings reads these calls, so
// a []any of the same values would generate nothing.
yjApp.Services = []application.Service{
application.NewService(yjApp.FrontendUtil),
application.NewService(yjApp.appConfig),
application.NewService(yjApp.library),
application.NewService(yjApp.playlist),
application.NewService(yjApp.queue),
application.NewService(yjApp.player),
application.NewService(yjApp.tagWriter),
application.NewService(yjApp.explore),
application.NewService(yjApp.autotag),
application.NewService(jobs.NewService(yjApp.jobs)),
application.NewService(home.NewService(
yjApp.logger.WithGroup("home"),
yjApp.database,
yjApp.library,
),
)),
}
if yjApp.downloadSvc != nil {
yjApp.FEBindings = append(yjApp.FEBindings, yjApp.downloadSvc)
yjApp.Services = append(
yjApp.Services, application.NewService(yjApp.downloadSvc),
)
}
return yjApp, nil
@@ -329,19 +343,18 @@ func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
return yj.appConfig.Window
}
// OnStartup initializes components that require the Wails runtime context.
// OnStartup wires the services to each other once the runtime exists.
//
// It is no longer where each service *gets* the context: every bound
// service implements v3's ServiceStartup, which the runtime calls
// before this runs. What is left here is the cross-service wiring —
// hooks, adapters and the callbacks that make one package drive
// another — which has no home inside any single service.
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
// initialize anything that needs to use the wails runtime AFTER its been initialized
// you CANNOT use the wails runtime during this function
yj.appContext = ctx
// Set context for components that need Wails runtime for events
yj.appConfig.SetContext(ctx)
yj.FrontendUtil.SetContext(ctx)
yj.library.SetContext(ctx)
yj.playlist.SetContext(ctx)
yj.playlist.EnsureDefaultPlaylist()
// Recover playlists that lost tracks from a pre-fix FullRescan.
go yj.playlist.RepopulateFromM3U()
@@ -358,14 +371,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
)
}
yj.player.SetContext(ctx)
yj.tagWriter.SetContext(ctx)
yj.explore.SetContext(ctx)
yj.autotag.SetContext(ctx)
// The job registry is not a bound service — it is wrapped by
// jobs.NewService for that — so it still takes the context by hand.
yj.jobs.SetContext(ctx)
if yj.downloadSvc != nil {
yj.downloadSvc.SetContext(ctx)
yj.initDownloadRuntime(ctx)
}
@@ -375,8 +385,6 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.library.RestorePausedScans()
yj.explore.AdoptPausedIndexBuild()
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
yj.queue.SetPlayer(yj.player)
yj.queue.SetFallbackSource(&queueFallbackAdapter{
config: yj.appConfig,
@@ -515,37 +523,32 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.player.SetMediaControls(yj.mediaControls)
}
// OnBeforeClose captures window state while the window is still alive,
// and asks first when quitting would abandon a job that is writing to
// the user's files.
//
// Returning true keeps the window open. Quitting mid-apply cancels the
// service context and leaves a folder half-retagged with nothing
// recording where it stopped (errors.p4), which is the one case worth
// interrupting a quit for.
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
if yj.confirmQuitDuringWrites(ctx) {
return true
// SaveWindowState captures the window's size while the window is still
// alive. It is registered on the WindowClosing event, because at
// shutdown there is no window left to measure.
func (yj *YellowJacketApp) SaveWindowState(window application.Window) {
if window == nil {
return
}
w, h := wailsruntime.WindowGetSize(ctx)
w, h := window.Size()
// Guard against a bogus size clobbering a good saved one. During
// teardown / hot-reload the runtime can report a zero or below-
// minimum size; persisting that would shrink the window to the
// minimum on next launch. Keep the previously-saved size instead.
if w < config.MinWidth || h < config.MinHeight {
yj.logger.Warn("OnBeforeClose: ignoring bogus window size",
yj.logger.Warn("window close: ignoring bogus window size",
"width", w,
"height", h,
"kept_width", yj.appConfig.Window.Width,
"kept_height", yj.appConfig.Window.Height,
)
return false
return
}
yj.logger.Info("OnBeforeClose: saving window state",
yj.logger.Info("window close: saving window state",
"width", w,
"height", h,
"accentColor", yj.appConfig.Theme.AccentColor,
@@ -561,39 +564,69 @@ func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
"err", err,
)
}
}
// ShouldQuit answers v3's quit veto: false keeps the app running.
//
// Quitting mid-apply cancels the service context and leaves a folder
// half-retagged with nothing recording where it stopped (errors.p4),
// which is the one case worth interrupting a quit for.
//
// The shape differs from v2's OnBeforeClose because v3's dialog is
// asynchronous — Show() returns immediately and the answer arrives on
// a button callback — so this cannot ask and answer in one call. It
// vetoes the quit, asks, and quits again from the callback if the user
// says so. quitConfirmed is what stops that second Quit() coming
// straight back here and asking a second time.
func (yj *YellowJacketApp) ShouldQuit() bool {
if yj.quitConfirmed.Load() {
return true
}
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
return true
}
// A dialog already up must not spawn another on every close attempt.
if !yj.quitAsking.CompareAndSwap(false, true) {
return false
}
app := application.Get()
if app == nil {
// No runtime to ask through: never trap the user in the app.
return true
}
dialog := app.Dialog.Question()
dialog.SetTitle("Tags are still being written")
dialog.SetMessage(
"YellowJacket is rewriting tags on your files. " +
"Quitting now leaves that folder holding a mix of old and " +
"new tags.\n\nQuit anyway?",
)
quit := dialog.AddButton("Quit anyway")
quit.OnClick(func() {
yj.quitConfirmed.Store(true)
yj.quitAsking.Store(false)
app.Quit()
})
stay := dialog.AddButton("Keep writing")
stay.OnClick(func() { yj.quitAsking.Store(false) })
stay.SetAsDefault()
stay.SetAsCancel()
dialog.Show()
return false
}
// confirmQuitDuringWrites returns true when the user chose to stay.
// A dialog that cannot be shown is not allowed to trap anyone in the
// app, so any error here quits.
func (yj *YellowJacketApp) confirmQuitDuringWrites(ctx context.Context) bool {
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
return false
}
answer, err := wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
Type: wailsruntime.QuestionDialog,
Title: "Tags are still being written",
Message: "YellowJacket is rewriting tags on your files. " +
"Quitting now leaves that folder holding a mix of old and " +
"new tags.\n\nQuit anyway?",
Buttons: []string{"Quit anyway", "Keep writing"},
DefaultButton: "Keep writing",
CancelButton: "Keep writing",
})
if err != nil {
yj.logger.Warn("could not ask about quitting mid-write", "err", err)
return false
}
return answer == "Keep writing" || answer == "No"
}
// OnShutdown saves player state and cleans up resources before the application exits.
func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
// OnShutdown saves player state and cleans up resources before the
// application exits. v3 passes no context — the app is going away, so
// there is nothing left to scope work to.
func (yj *YellowJacketApp) OnShutdown() {
if yj.player != nil {
yj.player.SaveState()
}
@@ -612,10 +645,17 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
// driven by the frontend: once its stores have registered their event
// listeners, index.ts calls Player.EmitCurrentState() and
// Queue.EmitCurrentState() via Wails bindings.
func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
func (yj *YellowJacketApp) OnDomReady(_ context.Context) {
if yj.startupErr != nil {
yj.logger.Error("startup error", "err", yj.startupErr.Error())
wailsruntime.Quit(ctx)
// A startup failure is not a mid-write quit, so go straight out
// rather than through the ShouldQuit question.
yj.quitConfirmed.Store(true)
if app := application.Get(); app != nil {
app.Quit()
}
return
}