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
}
+19 -4
View File
@@ -3,15 +3,22 @@ package assets
import (
"embed"
"fmt"
"io/fs"
"log/slog"
"net/http"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v3/pkg/application"
)
// distRoot is where the frontend build lands inside the embedded FS.
// v2 knew this prefix itself; v3 takes an fs.FS rooted at the assets,
// so the sub-FS is taken here.
const distRoot = "frontend/dist"
// Handler serves frontend assets with custom route support.
type Handler struct {
Options *assetserver.Options
Options application.AssetOptions
logger *slog.Logger
frontendDistAssets embed.FS
serveMux *http.ServeMux
@@ -25,8 +32,16 @@ func NewAssetHandler(logger *slog.Logger, frontendDistAssets embed.FS) (*Handler
frontendDistAssets: frontendDistAssets,
serveMux: http.NewServeMux(),
}
handler.Options = &assetserver.Options{
Assets: handler.frontendDistAssets,
dist, err := fs.Sub(frontendDistAssets, distRoot)
if err != nil {
return nil, fmt.Errorf(
"could not open %s in the embedded assets: %w", distRoot, err,
)
}
handler.Options = application.AssetOptions{
Handler: application.AssetFileServerFS(dist),
Middleware: handler.Middleware,
}
+12 -3
View File
@@ -19,6 +19,8 @@ import (
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
@@ -199,13 +201,20 @@ func NewService(
}
}
// SetContext stores the Wails runtime context (called from
// OnStartup).
func (s *Service) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (s *Service) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
s.mu.Lock()
defer s.mu.Unlock()
s.ctx = ctx
return nil
}
// emitEvent emits a Wails runtime event under the service lock, which
+11 -2
View File
@@ -10,6 +10,7 @@ import (
"path"
"github.com/BurntSushi/toml"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/download"
"yellowjacket/backend/events"
@@ -280,9 +281,17 @@ func (c *Config) applyDefaults() {
c.Downloads.ApplyDefaults()
}
// SetContext sets the Wails runtime context for event emission.
func (c *Config) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (c *Config) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
c.ctx = ctx
return nil
}
// GetLibraryDirectory returns the currently configured library directory path.
+6 -1
View File
@@ -6,6 +6,8 @@ import (
"path/filepath"
"testing"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/events"
)
@@ -28,7 +30,10 @@ func setupRecordedConfig(t *testing.T) (*Config, *events.Recorder) {
}
rec := events.NewRecorder()
conf.SetContext(events.WithSink(context.Background(), rec))
_ = conf.ServiceStartup(
events.WithSink(context.Background(), rec),
application.ServiceOptions{},
)
return conf, rec
}
+12 -2
View File
@@ -7,6 +7,8 @@ import (
"log/slog"
"strconv"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/events"
)
@@ -47,9 +49,17 @@ func NewService(
}
}
// SetContext injects the Wails runtime context for event emission.
func (s *Service) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (s *Service) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
s.ctx = ctx
return nil
}
// emit publishes an event, tolerating a service that has no runtime
+29 -22
View File
@@ -5,11 +5,11 @@ import (
"errors"
"log/slog"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
// ErrNoRuntime is returned by Deliver when the context carries neither
// a test Sink nor a live Wails runtime, so the event went nowhere.
// ErrNoRuntime is returned by Deliver when there is neither a test Sink
// in the context nor a running application, so the event went nowhere.
var ErrNoRuntime = errors.New(
"no Wails runtime or event sink in context",
)
@@ -46,14 +46,16 @@ func sinkFrom(ctx context.Context) Sink {
// Emit publishes a Wails event, tolerating any context.
//
// This is the only supported way to emit an event: nothing outside this
// package may call runtime.EventsEmit, which TestNoDirectEventsEmit
// enforces.
// package may call app.Event.Emit, which TestNoDirectRuntimeEmits
// enforces. That rule outlived its original reason — v2's
// runtime.EventsEmit called log.Fatalf on a context that did not carry
// the runtime, taking the process down from any background worker —
// and is kept because one emit path is what keeps emitStatus-style
// deduplication honest.
//
// runtime.EventsEmit calls log.Fatalf when the context is nil or lacks
// the runtime's "events" value, terminating the process rather than
// returning an error. Background workers that outlive a context, and
// any test that constructs a service directly, both hit that path — so
// an event with nowhere to go is dropped and logged here instead.
// The context is no longer a delivery mechanism: v3's emit takes none.
// It stays because WithSink travels in it, which is what makes a
// service that emits events testable in-process.
func Emit(ctx context.Context, name string, data ...any) {
if err := Deliver(ctx, name, data...); err != nil {
slog.Default().Debug(
@@ -70,21 +72,26 @@ func Emit(ctx context.Context, name string, data ...any) {
//
// Ordinary emitters want Emit.
func Deliver(ctx context.Context, name string, data ...any) error {
if ctx == nil {
// A nil context cannot carry a sink, but it is no longer a reason
// not to deliver: v3 emits through the application, not the context.
if ctx != nil {
if sink := sinkFrom(ctx); sink != nil {
sink.Emit(name, data...)
return nil
}
}
// application.Get() returns nil when no app is running — under test,
// before Run, and after shutdown. It does not terminate the
// process, which is what the v2 probe of the private "events"
// context key existed to avoid.
app := application.Get()
if app == nil {
return ErrNoRuntime
}
if sink := sinkFrom(ctx); sink != nil {
sink.Emit(name, data...)
return nil
}
if ctx.Value("events") == nil {
return ErrNoRuntime
}
runtime.EventsEmit(ctx, name, data...)
app.Event.Emit(name, data...)
return nil
}
+12 -4
View File
@@ -16,6 +16,13 @@ var allowedEmitters = map[string]bool{
// TestNoDirectRuntimeEmits fails if anything outside backend/events
// calls the Wails runtime's event emitter directly.
//
// The original reason was survival: v2's runtime.EventsEmit called
// log.Fatalf on a context that did not carry the runtime, so a direct
// call from a background worker could take the process down. v3's
// emit takes no context and cannot do that, and the rule is kept for
// the weaker but still real reason — one emit path is what lets
// emitStatus drop an unchanged payload for every caller at once.
//
// This is a text walk rather than a golangci-lint rule because
// golangci-lint runs once per build configuration, so a call in an
// indexbuild- or dev-tagged file is only seen by the pass that compiles
@@ -23,10 +30,11 @@ var allowedEmitters = map[string]bool{
// entirely.
func TestNoDirectRuntimeEmits(t *testing.T) {
// A selector, not a bare name: built at runtime so this file does
// not match itself, and qualified so it catches every import alias
// the tree uses (plain runtime., and wailsruntime.) without also
// matching an identifier that merely ends in the same letters.
needle := ".Events" + "Emit("
// not match itself, and qualified so it catches the call however
// the application value is named (app.Event.Emit, a.Event.Emit,
// application.Get().Event.Emit) without matching an identifier
// that merely ends in the same letters.
needle := ".Event" + ".Emit("
root := filepath.Join("..", "..")
+11 -3
View File
@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
@@ -139,11 +140,18 @@ func (e *Service) CAALimiter() *RateLimiter {
return e.caaLimiter
}
// SetContext injects the Wails runtime context. Called from
// OnStartup after the Wails runtime is initialised.
func (e *Service) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (e *Service) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
e.ctx = ctx
e.index.SetContext(ctx)
return nil
}
// StartIndexBuild kicks off the background search index build.
+49 -32
View File
@@ -3,12 +3,18 @@ package frontendutil
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
// ErrNoRuntime is returned when a dialog is asked for with no running
// application to parent it to — under test, or after shutdown.
var ErrNoRuntime = errors.New("no Wails runtime to show a dialog")
// FrontendUtil provides frontend-bound Go functions.
type FrontendUtil struct {
ctx context.Context
@@ -19,18 +25,35 @@ func NewFrontendUtil() (*FrontendUtil, error) {
return &FrontendUtil{}, nil
}
// SetContext sets the Wails runtime context.
func (fe *FrontendUtil) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (fe *FrontendUtil) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
fe.ctx = ctx
return nil
}
// DirectoryPicker opens a directory selection dialog.
//
// v3 has no separate directory dialog: it is the file dialog told to
// choose directories and not files.
func (fe *FrontendUtil) DirectoryPicker() (string, error) {
runtime.LogInfo(fe.ctx, "selecting a directory")
app := application.Get()
if app == nil {
return "", ErrNoRuntime
}
dir, err := runtime.OpenDirectoryDialog(
fe.ctx,
runtime.OpenDialogOptions{})
slog.Default().Info("selecting a directory")
dir, err := app.Dialog.OpenFile().
CanChooseDirectories(true).
CanChooseFiles(false).
PromptForSingleSelection()
if err != nil {
return "", fmt.Errorf(
"could not open directory dialog\n%w", err,
@@ -46,20 +69,17 @@ func (fe *FrontendUtil) PlaylistFilePicker() (
[]string,
error,
) {
runtime.LogInfo(fe.ctx, "selecting playlist files")
app := application.Get()
if app == nil {
return nil, ErrNoRuntime
}
files, err := runtime.OpenMultipleFilesDialog(
fe.ctx,
runtime.OpenDialogOptions{
Title: "Import Playlist",
Filters: []runtime.FileFilter{
{
DisplayName: "Playlist Files (*.m3u, *.m3u8)",
Pattern: "*.m3u;*.m3u8",
},
},
},
)
slog.Default().Info("selecting playlist files")
files, err := app.Dialog.OpenFile().
SetTitle("Import Playlist").
AddFilter("Playlist Files (*.m3u, *.m3u8)", "*.m3u;*.m3u8").
PromptForMultipleSelection()
if err != nil {
return nil, fmt.Errorf(
"could not open file dialog: %w", err,
@@ -73,18 +93,15 @@ func (fe *FrontendUtil) PlaylistFilePicker() (
// files (JPEG, PNG). Returns the selected file path, or empty
// string if the user cancelled.
func (fe *FrontendUtil) ImageFilePicker() (string, error) {
file, err := runtime.OpenFileDialog(
fe.ctx,
runtime.OpenDialogOptions{
Title: "Select Cover Art",
Filters: []runtime.FileFilter{
{
DisplayName: "Image Files (*.jpg, *.jpeg, *.png)",
Pattern: "*.jpg;*.jpeg;*.png",
},
},
},
)
app := application.Get()
if app == nil {
return "", ErrNoRuntime
}
file, err := app.Dialog.OpenFile().
SetTitle("Select Cover Art").
AddFilter("Image Files (*.jpg, *.jpeg, *.png)", "*.jpg;*.jpeg;*.png").
PromptForSingleSelection()
if err != nil {
return "", fmt.Errorf("could not open file dialog: %w", err)
}
+11 -2
View File
@@ -16,6 +16,7 @@ import (
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/autotag"
@@ -183,13 +184,21 @@ func (l *Library) AcquirePipelineLock() { l.pipelineMu.Lock() }
// ReleasePipelineLock releases the pipeline mutex after a tag write.
func (l *Library) ReleasePipelineLock() { l.pipelineMu.Unlock() }
// SetContext sets the Wails runtime context and registers event handlers.
func (l *Library) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (l *Library) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
l.mu.Lock()
l.ctx = ctx
l.mu.Unlock()
l.registerEventHandlers()
return nil
}
// emit publishes a Wails event under the library lock, which the
-94
View File
@@ -1,94 +0,0 @@
// Package logging provides a slog-based logger adapter for Wails.
package logging
import (
"fmt"
"log/slog"
"strings"
)
// Logger wraps slog to implement the Wails logger interface.
type Logger struct {
slogger *slog.Logger
moduleFilters []string
}
// NewLogger creates a logger with optional message filters.
func NewLogger(slogger *slog.Logger, filters []string) *Logger {
return &Logger{
slogger: slogger,
moduleFilters: filters,
}
}
// Print outputs a message if not filtered.
func (l *Logger) Print(message string) {
if l.isFilteredOut(message) {
return
}
fmt.Printf("[Print] %s\n", message)
}
// Trace logs a trace-level message if not filtered.
func (l *Logger) Trace(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Debug("[Trace] " + message)
}
// Debug logs a debug-level message if not filtered.
func (l *Logger) Debug(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Debug(message)
}
// Info logs an info-level message if not filtered.
func (l *Logger) Info(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Info(message)
}
// Warning logs a warning-level message if not filtered.
func (l *Logger) Warning(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Warn(message)
}
func (l *Logger) Error(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Error(message)
}
// Fatal logs a fatal-level message if not filtered.
func (l *Logger) Fatal(message string) {
if l.isFilteredOut(message) {
return
}
l.slogger.Error("[Trace] " + message)
}
func (l *Logger) isFilteredOut(message string) bool {
for _, f := range l.moduleFilters {
if strings.HasPrefix(message, fmt.Sprintf("[%s]", f)) {
return true
}
}
return false
}
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"testing"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/events"
)
@@ -17,7 +19,7 @@ func recordedPlayer(t *testing.T) (*Player, *events.Recorder) {
p := NewPlayer(slog.Default(), nil)
rec := events.NewRecorder()
p.SetContext(events.WithSink(t.Context(), rec))
_ = p.ServiceStartup(events.WithSink(t.Context(), rec), application.ServiceOptions{})
return p, rec
}
+11 -3
View File
@@ -16,6 +16,7 @@ import (
"github.com/gopxl/beep/v2/effects"
"github.com/gopxl/beep/v2/generators"
"github.com/gopxl/beep/v2/speaker"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
@@ -191,15 +192,22 @@ func (p *Player) SetMediaControls(h mediacontrols.Handler) {
p.mediaControls = h
}
// SetContext sets the Wails runtime context and restores persisted
// state.
func (p *Player) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (p *Player) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
p.mu.Lock()
defer p.mu.Unlock()
p.ctx = ctx
p.restoreStateLocked()
p.startPositionTicker()
return nil
}
// positionTickInterval is how often the backend reports its own
+3 -1
View File
@@ -5,6 +5,8 @@ import (
"os"
"testing"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/internal/testfixtures"
)
@@ -40,7 +42,7 @@ func TestPlayer(t *testing.T) {
}
// SetContext restores persisted state; only works with a real Wails context.
p.SetContext(t.Context())
_ = p.ServiceStartup(t.Context(), application.ServiceOptions{})
t.Logf("initializing player")
for _, track := range testQueue {
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"log/slog"
"testing"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
@@ -30,7 +32,7 @@ func setupRecordedService(
svc.dataDirOverride = t.TempDir()
rec := events.NewRecorder()
svc.SetContext(events.WithSink(context.Background(), rec))
_ = svc.ServiceStartup(events.WithSink(context.Background(), rec), application.ServiceOptions{})
return svc, db, rec
}
+12 -4
View File
@@ -15,6 +15,8 @@ import (
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
@@ -161,15 +163,21 @@ func (s *Service) SetFavoritesConfig(
s.favoritesConf = provider
}
// SetContext sets the Wails runtime context and runs the
// one-time startup migration to bootstrap M3U8 files for
// existing playlists.
func (s *Service) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (s *Service) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
s.mu.Lock()
s.ctx = ctx
s.mu.Unlock()
s.migrateExistingPlaylists()
return nil
}
// GetAllPlaylists returns all playlists ordered by most recently
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"testing"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
@@ -29,7 +31,7 @@ func setupRecordedQueue(t *testing.T) (*Queue, *database.DB, *events.Recorder) {
q.SetPlayer(&mockTrackLoader{})
rec := events.NewRecorder()
q.SetContext(events.WithSink(context.Background(), rec))
_ = q.ServiceStartup(events.WithSink(context.Background(), rec), application.ServiceOptions{})
return q, db, rec
}
+3 -1
View File
@@ -6,6 +6,8 @@ import (
"log/slog"
"testing"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
@@ -44,7 +46,7 @@ func setupFailingQueue(
q.SetPlayer(loader)
rec := events.NewRecorder()
q.SetContext(events.WithSink(context.Background(), rec))
_ = q.ServiceStartup(events.WithSink(context.Background(), rec), application.ServiceOptions{})
return q, db, rec, loader
}
+12 -2
View File
@@ -8,6 +8,8 @@ import (
"sync"
"sync/atomic"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/profiling"
@@ -198,12 +200,20 @@ func NewQueue(logger *slog.Logger, db *database.DB) *Queue {
}
}
// SetContext sets the Wails runtime context for event emission.
func (q *Queue) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (q *Queue) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
q.mu.Lock()
defer q.mu.Unlock()
q.ctx = ctx
return nil
}
// SetPlayer provides the queue with a reference to the player for auto-advance.
+12 -3
View File
@@ -7,6 +7,8 @@ import (
"log/slog"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/database"
"yellowjacket/backend/events"
)
@@ -76,10 +78,17 @@ func NewTagWriter(
}
}
// SetContext stores the Wails runtime context for event emission.
// Called during the two-phase init pattern in OnStartup.
func (tw *TagWriter) SetContext(ctx context.Context) {
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (tw *TagWriter) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
tw.ctx = ctx
return nil
}
// WriteTrackTags is the single entry point for writing metadata to