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:
+117
-77
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("..", "..")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user