diff --git a/cmd/mister/main.go b/cmd/mister/main.go index ca2de356a..46dcb1ab5 100755 --- a/cmd/mister/main.go +++ b/cmd/mister/main.go @@ -164,7 +164,7 @@ func run() error { switch { case *showLoader != "": - err := widgets.NoticeUI(pl, *showLoader, true) + err := widgets.NoticeUI(cfg, pl, *showLoader, true) if err != nil { return fmt.Errorf("error showing loader: %w", err) } @@ -176,7 +176,7 @@ func run() error { } return nil case *showNotice != "": - err := widgets.NoticeUI(pl, *showNotice, false) + err := widgets.NoticeUI(cfg, pl, *showNotice, false) if err != nil { return fmt.Errorf("error showing notice: %w", err) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 467238c62..62b3afbe8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,6 +10,7 @@ Reference material for Zaparoo Core's architecture, APIs, and subsystems. For de - **Launchers**: Per-system programs that launch games/media. Each platform provides built-in launchers. Custom launchers via TOML files in `launchers/`. See `pkg/platforms/`. - **Systems**: 200+ supported game/computer/media systems (e.g., `SNES`, `Genesis`, `PSX`). IDs are case-insensitive with aliases and fallbacks. - **Readers**: Hardware or virtual devices that detect tokens. Two scan modes: **tap** (default, free removal) and **hold** (token must stay on reader, removal stops media). +- **Global UI events**: Server-owned transient notice, loader, picker, or confirm requests. Host and connected clients render in parallel; first ID-bound response wins or Core times request out. See `pkg/ui/events/`. ## Zaparoo Ecosystem @@ -26,9 +27,19 @@ Reference material for Zaparoo Core's architecture, APIs, and subsystems. For de - **Launch endpoint**: `/l/{zapscript}` - GET-based execution for QR codes - **Auth**: API keys via `auth.toml`, anonymous access from localhost - **Discovery**: mDNS (`_zaparoo._tcp`) -- **Notifications**: Real-time WebSocket events (readers, tokens, media, indexing, playtime). See `docs/api/notifications.md`. +- **Notifications**: Real-time WebSocket events (readers, tokens, media, indexing, playtime, global UI). See `docs/api/notifications.md`. - **Full docs**: `docs/api/` +## Global UI Events + +`pkg/ui/events/` owns presentation lifecycle, authoritative expiry, monotonic revision, and first-response-wins arbitration. It initially exposes at most one active event but serializes plural `events`/`resolved` arrays so future overlays or priorities need no wire-format break. + +Platform UI rendering is optional. MiSTer implements notice, loader, picker, and confirm widgets; Batocera mirrors passive notice/loader text; unsupported platforms rely on App or another API client. Renderer errors never cancel event. + +Domain owners keep behavior: reader manager owns staged launch-guard token, playlist code owns selected ZapScript action, and installer owns download lifecycle. Public picker choices contain only label and opaque ID. Global events are broadcast to every permitted client and must never carry PINs, passwords, credentials, or other secrets. + +Clients consume `ui.changed`, replace local state using newest `revision`, query `ui` after reconnect, and answer through `ui.respond`. Legacy launch-guard `confirm` and token notifications remain supported. Launch-guard confirmation events deliberately skip host rendering so opening a MiSTer widget cannot interrupt active media; existing sound feedback and card re-tap confirmation remain unchanged. + ## Database - **Dual-database design**: UserDB (mappings, history, playlists) + MediaDB (indexed media content) diff --git a/docs/api/index.md b/docs/api/index.md index 949720dc0..6e13f782c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -232,7 +232,7 @@ Requests from the local device are allowed without restriction. Remote requests ## Methods -Methods are used to execute actions and request data back from the API. These API docs cover **62 methods** across core functionality areas, including deprecated aliases. See the [API Methods](./methods) page for detailed definitions and examples of each method. +Methods are used to execute actions and request data back from the API. These API docs cover **64 methods** across core functionality areas, including deprecated aliases. See the [API Methods](./methods) page for detailed definitions and examples of each method. | ID | Description | | :------------------------------ | :------------------------------------------------------------------------------------ | @@ -240,6 +240,8 @@ Methods are used to execute actions and request data back from the API. These AP | run | Run supplied ZapScript. | | stop | Kill any active launcher, if possible. | | confirm | Confirm and launch the currently staged token. | +| ui | Return authoritative global UI event state. | +| ui.respond | Respond to active global UI event. | | tokens | List active tokens. | | tokens.history | Return a list of the latest token launches. | | media | Return status and statistics about media database. | @@ -311,6 +313,7 @@ Notifications let a server or client know an event has occurred. See the [API No | tokens.removed | A token was removed. | | tokens.staged | A token was staged by launch guard and is awaiting confirmation. | | tokens.staged.ready | A staged token's delay period has expired and is ready for confirmation. | +| ui.changed | Authoritative global UI event state changed. | | media.started | New media was started on server. | | media.stopped | Media has stopped on server. | | media.indexing | The state of the indexing or optimization process has changed. | diff --git a/docs/api/methods.md b/docs/api/methods.md index d5263a42d..d61e48c78 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -130,6 +130,129 @@ Returns `null` on success. Returns an error if no token is currently staged. } ``` +## UI + +Core exposes transient UI requests so connected clients—and host platform when appropriate—can render same notice, loader, picker, or confirmation in parallel. Core initially keeps at most one active request, but API uses arrays for future expansion. First valid response for event ID wins; stale responses fail. + +UI events are intended for small, non-sensitive interactions. They are broadcast to every permitted connected client. Never use them for PINs, passwords, recovery codes, or other secrets. + +### ui + +Returns authoritative UI event state. Clients should call this after connecting or reconnecting. + +#### Parameters + +None. + +#### Result + +| Key | Type | Required | Description | +| :------- | :------------------- | :------- | :---------- | +| revision | number | Yes | Monotonic revision of global UI state shared across clients. Ignore older snapshots. | +| events | [UI event](#ui-event-object)[] | Yes | Active events. Initial implementation contains zero or one event. | +| resolved | [UI resolution](#ui-resolution-object)[] | Yes | Always empty in query response; terminal resolutions are delivered by `ui.changed`. | + +##### UI event object + +| Key | Type | Required | Description | +| :-------------- | :-------- | :------- | :---------- | +| id | string | Yes | Opaque event ID required by `ui.respond`. | +| kind | string | Yes | `notice`, `loader`, `picker`, or `confirm`. | +| title | string | No | Optional heading. | +| message | string | No | Optional body text. | +| choices | object[] | No | Picker choices containing opaque `id` and display `label`. | +| selectedChoiceId | string | No | Initially selected picker choice. | +| dismissible | boolean | Yes | Whether `dismiss` is accepted. | +| createdAt | string | Yes | RFC3339 creation timestamp. | +| expiresAt | string | No | Authoritative RFC3339 expiry. Omitted for producer-controlled events such as loaders. | + +Choice IDs are presentation-safe. Executable ZapScript and private choice values remain inside Core. + +#### Example + +```json +{ + "jsonrpc": "2.0", + "id": "ui-state-1", + "method": "ui" +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": "ui-state-1", + "result": { + "revision": 8, + "events": [ + { + "id": "56969e9c-f863-4cc8-9c2c-d7512bf10d4d", + "kind": "confirm", + "title": "Change game?", + "message": "**launch.system:snes", + "dismissible": true, + "createdAt": "2026-07-16T12:00:00Z", + "expiresAt": "2026-07-16T12:00:15Z" + } + ], + "resolved": [] + } +} +``` + +### ui.respond + +Responds to active UI event. First valid response wins globally and closes host/client renderers. + +#### Parameters + +| Key | Type | Required | Description | +| :------- | :----- | :------- | :---------- | +| id | string | Yes | Active event ID. | +| action | string | Yes | `dismiss`, `select`, or `confirm`. | +| choiceId | string | No | Required for picker `select`; must identify one published choice. | + +Allowed actions: + +- `notice`: `dismiss` when dismissible +- `loader`: `dismiss` only when explicitly dismissible +- `picker`: `select` with `choiceId`, or `dismiss` +- `confirm`: `confirm`, or `dismiss` when dismissible + +Returns `null` when accepted. Returns client error for stale event ID, invalid action, missing/unknown choice, expired event, or non-dismissible event. + +#### Example + +```json +{ + "jsonrpc": "2.0", + "id": "ui-response-1", + "method": "ui.respond", + "params": { + "id": "56969e9c-f863-4cc8-9c2c-d7512bf10d4d", + "action": "confirm" + } +} +``` + +```json +{ + "jsonrpc": "2.0", + "id": "ui-response-1", + "result": null +} +``` + +Top-level `confirm` remains launch-guard-specific for compatibility. It cannot confirm unrelated generic UI event. + +##### UI resolution object + +| Key | Type | Required | Description | +| :------ | :----- | :------- | :---------- | +| id | string | Yes | Resolved event ID. | +| outcome | string | Yes | `confirmed`, `selected`, `dismissed`, `timed_out`, `completed`, `superseded`, or `cancelled`. | +| choiceId | string | No | Selected opaque choice ID for `selected`. | + ## Tokens ### tokens diff --git a/docs/api/notifications.md b/docs/api/notifications.md index ff44e058c..a8f3f9881 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -81,6 +81,76 @@ A token was removed from a connected reader. Returns `null`. +## UI + +### ui.changed + +Authoritative global UI state changed. Sent when event opens, updates, is replaced, or resolves. Clients should replace local event snapshot with `events` from newest revision rather than applying incremental patches. + +Host platform and all connected clients may render same event in parallel. First valid response wins. A terminal update removes event from `events` and describes it in `resolved`, instructing every renderer to close. + +`ui.changed` is latest-wins coalescible. Clients that reconnect or suspect missed notifications should query [`ui`](./methods#ui). + +#### Parameters + +| Key | Type | Required | Description | +| :------- | :----- | :------- | :---------- | +| revision | number | Yes | Monotonic revision of process-wide UI state shared across clients. Ignore older values. | +| events | object[] | Yes | Complete active event snapshot. Initial implementation contains zero or one event. | +| resolved | object[] | Yes | Terminal resolutions associated with this transition. | + +Event and resolution fields are defined under [`ui`](./methods#ui-event-object) and [`ui.respond`](./methods#ui-resolution-object). + +#### Picker opened + +```json +{ + "jsonrpc": "2.0", + "method": "ui.changed", + "params": { + "revision": 12, + "events": [ + { + "id": "68bb6f25-dbd4-46a0-a3ac-e4fb54928ac2", + "kind": "picker", + "title": "Favorites", + "choices": [ + {"id": "e88560a7-76bb-4ab4-a914-da4be745778d", "label": "Game One"}, + {"id": "ce99379a-b803-4489-9da4-e752e96471e9", "label": "Game Two"} + ], + "selectedChoiceId": "e88560a7-76bb-4ab4-a914-da4be745778d", + "dismissible": true, + "createdAt": "2026-07-16T12:00:00Z", + "expiresAt": "2026-07-16T12:00:30Z" + } + ], + "resolved": [] + } +} +``` + +#### Picker resolved + +```json +{ + "jsonrpc": "2.0", + "method": "ui.changed", + "params": { + "revision": 13, + "events": [], + "resolved": [ + { + "id": "68bb6f25-dbd4-46a0-a3ac-e4fb54928ac2", + "outcome": "selected", + "choiceId": "ce99379a-b803-4489-9da4-e752e96471e9" + } + ] + } +} +``` + +Launch guard continues emitting `tokens.staged` and `tokens.staged.ready` for compatibility while also opening a global `confirm` UI event for API clients. Core does not present this specific confirmation through host platform renderer, preserving existing sound and card re-tap behavior during active media. + ## Media ### media.started diff --git a/pkg/api/methods/ui.go b/pkg/api/methods/ui.go new file mode 100644 index 000000000..787390bbc --- /dev/null +++ b/pkg/api/methods/ui.go @@ -0,0 +1,54 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package methods + +import ( + "errors" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation" +) + +var errUIServiceUnavailable = errors.New("UI event service is unavailable") + +//nolint:gocritic // single-use parameter in API handler +func HandleUI(env requests.RequestEnv) (any, error) { + if env.UI == nil { + return nil, errUIServiceUnavailable + } + return env.UI.State(), nil +} + +//nolint:gocritic // single-use parameter in API handler +func HandleUIRespond(env requests.RequestEnv) (any, error) { + if env.UI == nil { + return nil, errUIServiceUnavailable + } + + var params models.UIRespondParams + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + return nil, models.ClientErrf("invalid params: %w", err) + } + if err := env.UI.Respond(params.ID, params.Action, params.ChoiceID); err != nil { + return nil, models.ClientErrf("UI response failed: %w", err) + } + return NoContent{}, nil +} diff --git a/pkg/api/methods/ui_test.go b/pkg/api/methods/ui_test.go new file mode 100644 index 000000000..376a08a69 --- /dev/null +++ b/pkg/api/methods/ui_test.go @@ -0,0 +1,114 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package methods + +import ( + "encoding/json" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleUIReturnsState(t *testing.T) { + t.Parallel() + + service := events.New(clockwork.NewFakeClock(), nil, nil) + handle, err := service.Open(t.Context(), &events.Request{Kind: models.UIEventKindConfirm}) + require.NoError(t, err) + + response, err := HandleUI(requests.RequestEnv{UI: service}) + require.NoError(t, err) + state, ok := response.(models.UIStateResponse) + require.True(t, ok) + assert.Equal(t, uint64(1), state.Revision) + require.Len(t, state.Events, 1) + assert.Equal(t, handle.ID, state.Events[0].ID) + assert.Empty(t, state.Resolved) +} + +func TestHandleUIRespondResolvesEvent(t *testing.T) { + t.Parallel() + + service := events.New(clockwork.NewFakeClock(), nil, nil) + handle, err := service.Open(t.Context(), &events.Request{Kind: models.UIEventKindConfirm}) + require.NoError(t, err) + + params, err := json.Marshal(models.UIRespondParams{ + ID: handle.ID, + Action: models.UIResponseActionConfirm, + }) + require.NoError(t, err) + + response, err := HandleUIRespond(requests.RequestEnv{ + UI: service, + ClientRole: "member", + Params: params, + }) + require.NoError(t, err) + assert.Equal(t, NoContent{}, response) + + select { + case result := <-handle.Results: + assert.Equal(t, models.UIOutcomeConfirmed, result.Resolution.Outcome) + case <-time.After(time.Second): + t.Fatal("timed out waiting for UI event resolution") + } +} + +func TestHandleUIRespondRejectsInvalidParams(t *testing.T) { + t.Parallel() + + service := events.New(clockwork.NewFakeClock(), nil, nil) + _, err := HandleUIRespond(requests.RequestEnv{ + UI: service, + Params: json.RawMessage(`{"action":"confirm"}`), + }) + require.Error(t, err) + + var clientErr *models.ClientError + require.ErrorAs(t, err, &clientErr) +} + +func TestHandleUIRespondRejectsStaleEvent(t *testing.T) { + t.Parallel() + + service := events.New(clockwork.NewFakeClock(), nil, nil) + params := json.RawMessage(`{"id":"stale","action":"confirm"}`) + + _, err := HandleUIRespond(requests.RequestEnv{UI: service, Params: params}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no active UI event") +} + +func TestHandleUIRequiresService(t *testing.T) { + t.Parallel() + + _, err := HandleUI(requests.RequestEnv{}) + require.ErrorIs(t, err, errUIServiceUnavailable) + + _, err = HandleUIRespond(requests.RequestEnv{}) + require.ErrorIs(t, err, errUIServiceUnavailable) +} diff --git a/pkg/api/models/models.go b/pkg/api/models/models.go index 426208c62..54f6fdbf6 100644 --- a/pkg/api/models/models.go +++ b/pkg/api/models/models.go @@ -41,6 +41,7 @@ const ( NotificationClientsPaired = "clients.paired" NotificationProfilesActive = "profiles.active" NotificationProfilesData = "profiles.data" + NotificationUIChanged = "ui.changed" ) // Profile data swap statuses reported by the profiles.data notification. @@ -56,10 +57,41 @@ const ( PlaytimeLimitReasonDaily = "daily" ) +type UIEventKind string + +const ( + UIEventKindNotice UIEventKind = "notice" + UIEventKindLoader UIEventKind = "loader" + UIEventKindPicker UIEventKind = "picker" + UIEventKindConfirm UIEventKind = "confirm" +) + +type UIResponseAction string + +const ( + UIResponseActionDismiss UIResponseAction = "dismiss" + UIResponseActionSelect UIResponseAction = "select" + UIResponseActionConfirm UIResponseAction = "confirm" +) + +type UIOutcome string + +const ( + UIOutcomeConfirmed UIOutcome = "confirmed" + UIOutcomeSelected UIOutcome = "selected" + UIOutcomeDismissed UIOutcome = "dismissed" + UIOutcomeTimedOut UIOutcome = "timed_out" + UIOutcomeCompleted UIOutcome = "completed" + UIOutcomeSuperseded UIOutcome = "superseded" + UIOutcomeCancelled UIOutcome = "cancelled" +) + const ( MethodLaunch = "launch" // DEPRECATED MethodRun = "run" MethodConfirm = "confirm" + MethodUI = "ui" + MethodUIRespond = "ui.respond" MethodRunScript = "run.script" MethodStop = "stop" MethodTokens = "tokens" diff --git a/pkg/api/models/params.go b/pkg/api/models/params.go index cefccfc6f..a4604184c 100644 --- a/pkg/api/models/params.go +++ b/pkg/api/models/params.go @@ -69,6 +69,12 @@ type RunParams struct { Unsafe bool `json:"unsafe"` } +type UIRespondParams struct { + ID string `json:"id" validate:"required"` + Action UIResponseAction `json:"action" validate:"required,oneof=dismiss select confirm"` + ChoiceID string `json:"choiceId,omitempty"` +} + type RunScriptParams struct { Name *string `json:"name"` Cmds []zapscript.ZapScriptCmd `json:"cmds"` diff --git a/pkg/api/models/requests/requests.go b/pkg/api/models/requests/requests.go index 70504dd40..88548109f 100644 --- a/pkg/api/models/requests/requests.go +++ b/pkg/api/models/requests/requests.go @@ -33,6 +33,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/profiles" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" ) type RequestEnv struct { @@ -50,6 +51,7 @@ type RequestEnv struct { LauncherCache *helpers.LauncherCache Player audio.Player PlaybackManager audio.PlaybackManager + UI *uievents.Service TokenQueue chan<- tokens.Token ConfirmQueue chan<- chan error IndexPauser *syncutil.Pauser diff --git a/pkg/api/models/responses.go b/pkg/api/models/responses.go index eb874b412..5d1efb6f6 100644 --- a/pkg/api/models/responses.go +++ b/pkg/api/models/responses.go @@ -28,6 +28,35 @@ import ( "github.com/google/uuid" ) +type UIChoice struct { + ID string `json:"id"` + Label string `json:"label"` +} + +type UIEvent struct { + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + ID string `json:"id"` + Kind UIEventKind `json:"kind"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + SelectedChoiceID string `json:"selectedChoiceId,omitempty"` + Choices []UIChoice `json:"choices,omitempty"` + Dismissible bool `json:"dismissible"` +} + +type UIResolution struct { + ID string `json:"id"` + Outcome UIOutcome `json:"outcome"` + ChoiceID string `json:"choiceId,omitempty"` +} + +type UIStateResponse struct { + Events []UIEvent `json:"events"` + Resolved []UIResolution `json:"resolved"` + Revision uint64 `json:"revision"` +} + type SearchResultMedia struct { RelPath *string `json:"relativePath,omitempty"` System System `json:"system"` diff --git a/pkg/api/notifications/notifications.go b/pkg/api/notifications/notifications.go index 1bbb41fb8..ff06c1e93 100644 --- a/pkg/api/notifications/notifications.go +++ b/pkg/api/notifications/notifications.go @@ -39,23 +39,26 @@ var criticalNotifications = map[string]bool{ models.NotificationStopped: true, } -func sendNotification(ns chan<- models.Notification, method string, payload any) { - var notification models.Notification +func newNotification(method string, payload any) (models.Notification, bool) { + if payload == nil { + return models.Notification{Method: method}, true + } - if payload != nil { - params, err := json.Marshal(payload) - if err != nil { - log.Error().Err(err).Msgf("error marshalling notification params: %s", method) - return - } - notification = models.Notification{ - Method: method, - Params: params, - } - } else { - notification = models.Notification{ - Method: method, - } + params, err := json.Marshal(payload) + if err != nil { + log.Error().Err(err).Msgf("error marshalling notification params: %s", method) + return models.Notification{}, false + } + return models.Notification{ + Method: method, + Params: params, + }, true +} + +func sendNotification(ns chan<- models.Notification, method string, payload any) { + notification, ok := newNotification(method, payload) + if !ok { + return } // Use non-blocking send to prevent back-pressure from freezing callers. @@ -129,6 +132,15 @@ func InboxAdded(ns chan<- models.Notification, payload *models.InboxMessage) { sendNotification(ns, models.NotificationInboxAdded, payload) } +//nolint:gocritic // notification payload is copied before synchronous broker fan-out +func UIChanged(publish func(models.Notification), payload models.UIStateResponse) { + notification, ok := newNotification(models.NotificationUIChanged, payload) + if !ok { + return + } + publish(notification) +} + func ClientsPaired(ns chan<- models.Notification, payload models.ClientsPairedNotification) { sendNotification(ns, models.NotificationClientsPaired, payload) } diff --git a/pkg/api/notifications/notifications_test.go b/pkg/api/notifications/notifications_test.go index 0510f54e1..063fbe677 100644 --- a/pkg/api/notifications/notifications_test.go +++ b/pkg/api/notifications/notifications_test.go @@ -333,3 +333,28 @@ func TestPlaytimeLimitWarning_Payload(t *testing.T) { assert.Equal(t, models.NotificationPlaytimeLimitWarning, notification.Method) assert.Contains(t, string(notification.Params), "5 minutes") } + +func TestUIChanged_Payload(t *testing.T) { + t.Parallel() + + var notification models.Notification + UIChanged(func(published models.Notification) { + notification = published + }, models.UIStateResponse{ + Revision: 4, + Events: []models.UIEvent{{ + ID: "event-1", + Kind: models.UIEventKindConfirm, + }}, + Resolved: []models.UIResolution{}, + }) + + assert.Equal(t, models.NotificationUIChanged, notification.Method) + require.NotNil(t, notification.Params) + + var received models.UIStateResponse + require.NoError(t, json.Unmarshal(notification.Params, &received)) + assert.Equal(t, uint64(4), received.Revision) + require.Len(t, received.Events, 1) + assert.Equal(t, "event-1", received.Events[0].ID) +} diff --git a/pkg/api/server.go b/pkg/api/server.go index e9a5e91b2..ca3d43339 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -236,10 +236,12 @@ func NewMethodMap() *MethodMap { defaultMethods := map[string]func(requests.RequestEnv) (any, error){ // run - models.MethodLaunch: methods.HandleRun, // DEPRECATED - models.MethodRun: methods.HandleRun, - models.MethodStop: methods.HandleStop, - models.MethodConfirm: methods.HandleConfirm, + models.MethodLaunch: methods.HandleRun, // DEPRECATED + models.MethodRun: methods.HandleRun, + models.MethodStop: methods.HandleStop, + models.MethodConfirm: methods.HandleConfirm, + models.MethodUI: methods.HandleUI, + models.MethodUIRespond: methods.HandleUIRespond, // tokens models.MethodTokens: methods.HandleTokens, models.MethodHistory: methods.HandleHistory, @@ -1094,6 +1096,7 @@ func handleWSMessage( LauncherCache: helpers.GlobalLauncherCache, Player: player, PlaybackManager: playbackManager, + UI: st.UIEvents(), TokenQueue: inTokenQueue, ConfirmQueue: confirmQueue, IndexPauser: indexPauser, @@ -1354,6 +1357,7 @@ func handlePostRequest( LauncherCache: helpers.GlobalLauncherCache, Player: player, PlaybackManager: playbackManager, + UI: st.UIEvents(), TokenQueue: inTokenQueue, ConfirmQueue: confirmQueue, IndexPauser: indexPauser, diff --git a/pkg/platforms/batocera/platform.go b/pkg/platforms/batocera/platform.go index f08c36576..0aaae2068 100644 --- a/pkg/platforms/batocera/platform.go +++ b/pkg/platforms/batocera/platform.go @@ -38,7 +38,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/jonboulle/clockwork" "github.com/rs/zerolog/log" ) @@ -809,33 +808,23 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(customLaunchers, launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - args widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - if err := esapi.APINotify(args.Text); err != nil { - return nil, 0, fmt.Errorf("failed to show notice: %w", err) - } - return nil, 0, nil -} - -func (*Platform) ShowLoader( - _ *config.Instance, - args widgetmodels.NoticeArgs, +func (*Platform) PresentUI( + _ context.Context, + event *models.UIEvent, ) (func() error, error) { - if err := esapi.APINotify(args.Text); err != nil { - return nil, fmt.Errorf("failed to show loader: %w", err) + if event.Kind != models.UIEventKindNotice && event.Kind != models.UIEventKindLoader { + return nil, platforms.ErrNotSupported + } + text := event.Message + if text == "" { + text = event.Title + } + if err := esapi.APINotify(text); err != nil { + return nil, fmt.Errorf("failed to present UI event: %w", err) } return func() error { return nil }, nil } -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/platforms/batocera/platform_test.go b/pkg/platforms/batocera/platform_test.go index e3f899062..98e4dc751 100644 --- a/pkg/platforms/batocera/platform_test.go +++ b/pkg/platforms/batocera/platform_test.go @@ -24,6 +24,58 @@ import ( "github.com/stretchr/testify/require" ) +func TestPresentUIForwardsPassiveEvents(t *testing.T) { + // MockESAPIServer binds to hardcoded port 1234. + mockESAPI := helpers.NewMockESAPIServer(t) + p := &Platform{} + + tests := []struct { + name string + event models.UIEvent + }{ + { + name: "notice message", + event: models.UIEvent{ + Kind: models.UIEventKindNotice, + Title: "Ignored title", + Message: "Notice message", + }, + }, + { + name: "loader title fallback", + event: models.UIEvent{ + Kind: models.UIEventKindLoader, + Title: "Loading", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFn, err := p.PresentUI(t.Context(), &tt.event) + require.NoError(t, err) + require.NotNil(t, closeFn) + require.NoError(t, closeFn()) + }) + } + + assert.Equal(t, []string{"Notice message", "Loading"}, mockESAPI.Notifications()) +} + +func TestPresentUIRejectsInteractiveEvents(t *testing.T) { + t.Parallel() + + p := &Platform{} + for _, kind := range []models.UIEventKind{ + models.UIEventKindPicker, + models.UIEventKindConfirm, + } { + closeFn, err := p.PresentUI(t.Context(), &models.UIEvent{Kind: kind}) + require.ErrorIs(t, err, platforms.ErrNotSupported) + assert.Nil(t, closeFn) + } +} + // TestKodiLocalLauncherExists tests that KodiLocal launcher exists func TestKodiLocalLauncherExists(t *testing.T) { t.Parallel() diff --git a/pkg/platforms/libreelec/platform.go b/pkg/platforms/libreelec/platform.go index 146499daf..b932fffb2 100644 --- a/pkg/platforms/libreelec/platform.go +++ b/pkg/platforms/libreelec/platform.go @@ -29,7 +29,6 @@ import ( "os" "os/exec" "path/filepath" - "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -53,7 +52,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/adrg/xdg" "github.com/rs/zerolog/log" ) @@ -255,27 +253,6 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(helpers.ParseCustomLaunchers(p, cfg.CustomLaunchers()), launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/platforms/mac/platform.go b/pkg/platforms/mac/platform.go index c1fa7db8d..9cbf9161f 100644 --- a/pkg/platforms/mac/platform.go +++ b/pkg/platforms/mac/platform.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "path/filepath" - "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -32,7 +31,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/adrg/xdg" "github.com/rs/zerolog/log" ) @@ -232,27 +230,6 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(helpers.ParseCustomLaunchers(p, cfg.CustomLaunchers()), launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/platforms/mister/platform.go b/pkg/platforms/mister/platform.go index c62cdd7ab..318445c6a 100644 --- a/pkg/platforms/mister/platform.go +++ b/pkg/platforms/mister/platform.go @@ -1430,63 +1430,95 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(custom, ls...) } -func (p *Platform) ShowNotice( - _ *config.Instance, - args widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - p.platformMu.Lock() - needsDelay := time.Since(p.lastUIHidden) < 2*time.Second - p.platformMu.Unlock() - - if needsDelay { - log.Debug().Msg("waiting for previous notice to finish") - time.Sleep(3 * time.Second) - } - - completePath, err := showNotice(p, args.Text, false) - if err != nil { - return nil, 0, err +func (*Platform) MinimumUIDisplay(kind models.UIEventKind) time.Duration { + if kind == models.UIEventKindNotice { + return preNoticeTime() } - - return func() error { - p.platformMu.Lock() - defer p.platformMu.Unlock() - p.lastUIHidden = time.Now() - return hideNotice(p.filesystem(), completePath) - }, preNoticeTime(), nil + return 0 } -func (p *Platform) ShowLoader( - _ *config.Instance, - args widgetmodels.NoticeArgs, +func (p *Platform) PresentUI( + ctx context.Context, + event *models.UIEvent, ) (func() error, error) { + const orphanTimeoutSeconds = int(time.Hour / time.Second) + p.platformMu.Lock() needsDelay := time.Since(p.lastUIHidden) < 2*time.Second p.platformMu.Unlock() - if needsDelay { - log.Debug().Msg("waiting for previous notice to finish") - time.Sleep(3 * time.Second) + log.Debug().Msg("waiting for previous UI event to finish") + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + return nil, ctx.Err() + } } - completePath, err := showNotice(p, args.Text, true) - if err != nil { - return nil, err + closeEvent := func(argsPath string) func() error { + return func() error { + p.platformMu.Lock() + defer p.platformMu.Unlock() + p.lastUIHidden = time.Now() + return hideNotice(p.filesystem(), argsPath) + } } - return func() error { - p.platformMu.Lock() - defer p.platformMu.Unlock() - p.lastUIHidden = time.Now() - return hideNotice(p.filesystem(), completePath) - }, nil -} - -func (p *Platform) ShowPicker( - _ *config.Instance, - args widgetmodels.PickerArgs, -) error { - return showPicker(p, args) + switch event.Kind { + case models.UIEventKindNotice, models.UIEventKindLoader: + text := event.Message + if text == "" { + text = event.Title + } + argsPath, err := showNotice(p, widgetmodels.NoticeArgs{ + Text: text, + EventID: event.ID, + Timeout: orphanTimeoutSeconds, + Dismissible: event.Dismissible, + }, event.Kind == models.UIEventKindLoader) + if err != nil { + return nil, err + } + return closeEvent(argsPath), nil + case models.UIEventKindPicker, models.UIEventKindConfirm: + items := make([]widgetmodels.PickerItem, 0, len(event.Choices)) + selected := -1 + if event.Kind == models.UIEventKindConfirm { + items = append(items, widgetmodels.PickerItem{ + Name: "Confirm", + Action: models.UIResponseActionConfirm, + }) + selected = 0 + } else { + for i, choice := range event.Choices { + items = append(items, widgetmodels.PickerItem{ + ID: choice.ID, + Name: choice.Label, + Action: models.UIResponseActionSelect, + }) + if choice.ID == event.SelectedChoiceID { + selected = i + } + } + } + argsPath, err := showPicker(p, &widgetmodels.PickerArgs{ + Title: event.Title, + Message: event.Message, + EventID: event.ID, + Items: items, + Selected: selected, + Timeout: orphanTimeoutSeconds, + Dismissible: event.Dismissible, + }) + if err != nil { + return nil, err + } + return closeEvent(argsPath), nil + default: + return nil, platforms.ErrNotSupported + } } func (p *Platform) ConsoleManager() platforms.ConsoleManager { diff --git a/pkg/platforms/mister/platform_test.go b/pkg/platforms/mister/platform_test.go index 1bbb09477..1a4a1f7e2 100644 --- a/pkg/platforms/mister/platform_test.go +++ b/pkg/platforms/mister/platform_test.go @@ -37,7 +37,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" misterconfig "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mister/config" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -566,11 +565,41 @@ func TestGamepadPress_ValidButtonsWhenDisabled(t *testing.T) { } } -// TestShowLoader_NoDeadlockWithActiveMedia is a regression test for the deadlock -// that occurred when ShowLoader was called while a game was running. -// The deadlock happened because ShowLoader held platformMu while calling showNotice, -// which called runScript, which called StopActiveLauncher - which also needs platformMu. -func TestShowLoader_NoDeadlockWithActiveMedia(t *testing.T) { +func TestMinimumUIDisplay(t *testing.T) { + t.Parallel() + + p := NewPlatform() + assert.Equal(t, 5*time.Second, p.MinimumUIDisplay(models.UIEventKindNotice)) + assert.Zero(t, p.MinimumUIDisplay(models.UIEventKindLoader)) +} + +func TestPresentUIRejectsUnsupportedKind(t *testing.T) { + t.Parallel() + + p := NewPlatform() + closeFn, err := p.PresentUI(t.Context(), &models.UIEvent{Kind: "future"}) + require.ErrorIs(t, err, platforms.ErrNotSupported) + assert.Nil(t, closeFn) +} + +func TestPresentUIPreviousEventDelayRespectsCancellation(t *testing.T) { + t.Parallel() + + p := NewPlatform() + p.lastUIHidden = time.Now() + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + start := time.Now() + closeFn, err := p.PresentUI(ctx, &models.UIEvent{Kind: models.UIEventKindNotice}) + require.ErrorIs(t, err, context.Canceled) + assert.Nil(t, closeFn) + assert.Less(t, time.Since(start), time.Second) +} + +// TestPresentUI_NoDeadlockWithActiveMedia guards against holding platformMu +// while renderer startup calls StopActiveLauncher, which also needs platformMu. +func TestPresentUI_NoDeadlockWithActiveMedia(t *testing.T) { t.Parallel() p := NewPlatform() @@ -586,14 +615,13 @@ func TestShowLoader_NoDeadlockWithActiveMedia(t *testing.T) { } } - cfg := &config.Instance{} - - // Run ShowLoader in a goroutine with timeout detection + // Run PresentUI in a goroutine with timeout detection. done := make(chan struct{}) go func() { - // This will fail (no actual script/console), but should NOT deadlock - _, _ = p.ShowLoader(cfg, widgetmodels.NoticeArgs{ - Text: "Test loader", + // This will fail (no actual script/console), but should NOT deadlock. + _, _ = p.PresentUI(t.Context(), &models.UIEvent{ + Kind: models.UIEventKindLoader, + Message: "Test loader", }) close(done) }() @@ -603,6 +631,6 @@ func TestShowLoader_NoDeadlockWithActiveMedia(t *testing.T) { case <-done: // Success - no deadlock case <-time.After(5 * time.Second): - t.Fatal("ShowLoader deadlocked - platformMu held during showNotice/StopActiveLauncher") + t.Fatal("PresentUI deadlocked while stopping active launcher") } } diff --git a/pkg/platforms/mister/ui.go b/pkg/platforms/mister/ui.go index 39eab8620..c1b644891 100644 --- a/pkg/platforms/mister/ui.go +++ b/pkg/platforms/mister/ui.go @@ -22,10 +22,10 @@ func preNoticeTime() time.Duration { func showNotice( pl *Platform, - text string, + args widgetmodels.NoticeArgs, loader bool, ) (string, error) { - log.Info().Msgf("showing notice: %s", text) + log.Info().Msgf("showing notice: %s", args.Text) argsID, err := helpers.RandSeq(10) if err != nil { return "", fmt.Errorf("failed to generate random sequence: %w", err) @@ -35,13 +35,9 @@ func showNotice( argsName = "loader-" + argsID + ".json" } argsPath := filepath.Join(pl.Settings().TempDir, argsName) - completePath := argsPath + ".complete" + args.Complete = argsPath + ".complete" log.Debug().Msg("launching script notice") - args := widgetmodels.NoticeArgs{ - Text: text, - Complete: completePath, - } if writeErr := writeNoticeArgs(pl.filesystem(), argsPath, args); writeErr != nil { return "", writeErr } @@ -82,19 +78,25 @@ func hideNotice(fs afero.Fs, argsPath string) error { func showPicker( pl *Platform, - args widgetmodels.PickerArgs, -) error { - // Launch picker through the script UI. - argsPath := filepath.Join(pl.Settings().TempDir, "picker.json") - argsJSON, err := json.Marshal(args) + args *widgetmodels.PickerArgs, +) (string, error) { + argsID, err := helpers.RandSeq(10) if err != nil { - return fmt.Errorf("failed to marshal picker args: %w", err) + return "", fmt.Errorf("failed to generate random sequence: %w", err) } - err = afero.WriteFile(pl.filesystem(), argsPath, argsJSON, 0o600) + argsPath := filepath.Join(pl.Settings().TempDir, "picker-"+argsID+".json") + args.Complete = argsPath + ".complete" + argsJSON, err := json.Marshal(args) if err != nil { - return fmt.Errorf("failed to write picker args file: %w", err) + return "", fmt.Errorf("failed to marshal picker args: %w", err) + } + if err = afero.WriteFile(pl.filesystem(), argsPath, argsJSON, 0o600); err != nil { + return "", fmt.Errorf("failed to write picker args file: %w", err) } scriptPath := filepath.Join(misterconfig.ScriptsDir, "zaparoo.sh") - return runScript(pl, scriptPath, "'-show-picker' '"+argsPath+"'", false) + if runErr := runScript(pl, scriptPath, "'-show-picker' '"+argsPath+"'", false); runErr != nil { + return "", runErr + } + return argsPath, nil } diff --git a/pkg/platforms/mistex/platform.go b/pkg/platforms/mistex/platform.go index e6dfcbc6b..13bb2dcfd 100644 --- a/pkg/platforms/mistex/platform.go +++ b/pkg/platforms/mistex/platform.go @@ -38,7 +38,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/rs/zerolog/log" ) @@ -377,27 +376,6 @@ func (*Platform) Scrapers(_ *config.Instance) map[string]platforms.Scraper { return map[string]platforms.Scraper{gamelist.ID: gamelist, media.ID: media} } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - // SetArcadeCardLaunch caches the arcade setname when launching via card. func (p *Platform) SetArcadeCardLaunch(setname string) { p.arcadeCardLaunch.mu.Lock() diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go index c86f1775c..8c2fb4e87 100644 --- a/pkg/platforms/platforms.go +++ b/pkg/platforms/platforms.go @@ -23,7 +23,6 @@ import ( "context" "errors" "os" - "time" "github.com/ZaparooProject/go-zapscript" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" @@ -35,7 +34,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/spf13/afero" ) @@ -153,6 +152,7 @@ type CmdEnv struct { ServiceCtx context.Context WaitForMediaReady func(context.Context) error PlaybackManager audio.PlaybackManager + UI *uievents.Service Playlist playlists.PlaylistController Cfg *config.Instance Database *database.Database @@ -448,27 +448,6 @@ type Platform interface { // Launchers is the complete list of all launchers available on this // platform. Launchers(*config.Instance) []Launcher - // ShowNotice displays a string on-screen of the platform device. Returns - // a function that may be used to manually hide the notice and a minimum - // amount of time that should be waited until trying to close the notice, - // for platforms where initializing a notice takes time. - // TODO: can this just block instead of returning a delay? - ShowNotice( - *config.Instance, - widgetmodels.NoticeArgs, - ) (func() error, time.Duration, error) - // ShowLoader displays a string on-screen of the platform device alongside - // an animation indicating something is in progress. Returns a function - // that may be used to manually hide the loader and an optional delay to - // wait before hiding. - // TODO: does this need a close delay returned as well? - ShowLoader(*config.Instance, widgetmodels.NoticeArgs) (func() error, error) - // ShowPicker displays a list picker on-screen of the platform device with - // a list of Zap Link Cmds to choose from. The chosen action will be - // forwarded to the local API instance to be run. Returns a function that - // may be used to manually cancel and hide the picker. - // TODO: it appears to not return said function - ShowPicker(*config.Instance, widgetmodels.PickerArgs) error // ConsoleManager returns the platform's console manager for TTY/console switching. // Platforms without console switching return NoOpConsoleManager. ConsoleManager() ConsoleManager diff --git a/pkg/platforms/recalbox/platform.go b/pkg/platforms/recalbox/platform.go index 0cc71d992..9d8d41151 100644 --- a/pkg/platforms/recalbox/platform.go +++ b/pkg/platforms/recalbox/platform.go @@ -29,7 +29,6 @@ import ( "os" "os/exec" "path/filepath" - "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -52,7 +51,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/adrg/xdg" "github.com/rs/zerolog/log" ) @@ -240,27 +238,6 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(helpers.ParseCustomLaunchers(p, cfg.CustomLaunchers()), launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/platforms/replayos/platform.go b/pkg/platforms/replayos/platform.go index 16428a349..858469521 100644 --- a/pkg/platforms/replayos/platform.go +++ b/pkg/platforms/replayos/platform.go @@ -46,7 +46,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/jonboulle/clockwork" "github.com/rs/zerolog/log" ) @@ -317,27 +316,6 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(helpers.ParseCustomLaunchers(p, cfg.CustomLaunchers()), launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/platforms/replayos/platform_test.go b/pkg/platforms/replayos/platform_test.go index 84ea67973..c22fc5710 100644 --- a/pkg/platforms/replayos/platform_test.go +++ b/pkg/platforms/replayos/platform_test.go @@ -14,7 +14,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -413,23 +412,6 @@ func TestPlatform_TrivialReturns(t *testing.T) { assert.ErrorIs(t, p.LaunchSystem(nil, "snes"), platforms.ErrNotSupported) }) - t.Run("ShowNotice returns ErrNotSupported", func(t *testing.T) { - t.Parallel() - _, _, err := p.ShowNotice(nil, widgetmodels.NoticeArgs{}) - assert.ErrorIs(t, err, platforms.ErrNotSupported) - }) - - t.Run("ShowLoader returns ErrNotSupported", func(t *testing.T) { - t.Parallel() - _, err := p.ShowLoader(nil, widgetmodels.NoticeArgs{}) - assert.ErrorIs(t, err, platforms.ErrNotSupported) - }) - - t.Run("ShowPicker returns ErrNotSupported", func(t *testing.T) { - t.Parallel() - assert.ErrorIs(t, p.ShowPicker(nil, widgetmodels.PickerArgs{}), platforms.ErrNotSupported) - }) - t.Run("ConsoleManager returns NoOp", func(t *testing.T) { t.Parallel() assert.IsType(t, platforms.NoOpConsoleManager{}, p.ConsoleManager()) diff --git a/pkg/platforms/retropie/platform.go b/pkg/platforms/retropie/platform.go index dfa28f0eb..7f9e39f6e 100644 --- a/pkg/platforms/retropie/platform.go +++ b/pkg/platforms/retropie/platform.go @@ -29,7 +29,6 @@ import ( "os" "os/exec" "path/filepath" - "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -52,7 +51,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/adrg/xdg" "github.com/rs/zerolog/log" ) @@ -240,27 +238,6 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(helpers.ParseCustomLaunchers(p, cfg.CustomLaunchers()), launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/platforms/shared/installer/installer.go b/pkg/platforms/shared/installer/installer.go index 4ee263fa4..510710c6a 100644 --- a/pkg/platforms/shared/installer/installer.go +++ b/pkg/platforms/shared/installer/installer.go @@ -31,11 +31,12 @@ import ( "strings" "time" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/rs/zerolog/log" ) @@ -78,28 +79,38 @@ func namesFromURL(rawURL, defaultName string) mediaNames { } } -func showPreNotice(cfg *config.Instance, pl platforms.Platform, text string) error { - if text != "" { - hide, delay, err := pl.ShowNotice(cfg, widgetmodels.NoticeArgs{ - Text: text, - }) - if err != nil { - return fmt.Errorf("error showing pre-notice: %w", err) - } - if hide == nil { - hide = func() error { return nil } - } - - if delay > 0 { - log.Debug().Msgf("delaying pre-notice: %d", delay) - time.Sleep(delay) - } - - err = hide() - if err != nil { - return fmt.Errorf("error hiding pre-notice: %w", err) +func showPreNotice( + ctx context.Context, + ui *uievents.Service, + text string, +) error { + if text == "" { + return nil + } + if ui == nil { + return nil + } + handle, err := ui.Open(ctx, &uievents.Request{ + Kind: models.UIEventKindNotice, + Message: text, + Timeout: 30 * time.Second, + Dismissible: true, + }) + if err != nil { + return fmt.Errorf("error opening pre-notice: %w", err) + } + if handle.MinimumDisplay > 0 { + log.Debug().Dur("delay", handle.MinimumDisplay).Msg("delaying pre-notice") + timer := time.NewTimer(handle.MinimumDisplay) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): } } + if err = handle.Complete(models.UIOutcomeCompleted); err != nil { + return fmt.Errorf("error completing pre-notice: %w", err) + } return nil } @@ -145,6 +156,7 @@ func InstallRemoteFile( ctx context.Context, cfg *config.Instance, pl platforms.Platform, + ui *uievents.Service, fileURL string, systemID string, preNotice string, @@ -180,7 +192,7 @@ func InstallRemoteFile( // check if the file already exists if _, statErr := os.Stat(localPath); statErr == nil { - if err = showPreNotice(cfg, pl, preNotice); err != nil { + if err = showPreNotice(ctx, ui, preNotice); err != nil { log.Warn().Err(err).Msgf("error showing pre-notice") } return localPath, nil @@ -199,11 +211,19 @@ func InstallRemoteFile( itemDisplay := names.display loadingText := fmt.Sprintf("Downloading %s...", itemDisplay) - hideLoader, err := pl.ShowLoader(cfg, widgetmodels.NoticeArgs{ - Text: loadingText, - }) - if err != nil { - log.Warn().Err(err).Msgf("error showing loading dialog") + var hideLoader func() error + if ui != nil { + handle, openErr := ui.Open(ctx, &uievents.Request{ + Kind: models.UIEventKindLoader, + Message: loadingText, + }) + if openErr != nil { + log.Warn().Err(openErr).Msg("error opening loading event") + } else { + hideLoader = func() error { + return handle.Complete(models.UIOutcomeCompleted) + } + } } if hideLoader == nil { hideLoader = func() error { return nil } @@ -236,7 +256,7 @@ func InstallRemoteFile( log.Warn().Err(err).Msgf("error hiding loading dialog") } - err = showPreNotice(cfg, pl, preNotice) + err = showPreNotice(ctx, ui, preNotice) if err != nil { log.Warn().Err(err).Msgf("error showing pre-notice") } diff --git a/pkg/platforms/shared/installer/installer_test.go b/pkg/platforms/shared/installer/installer_test.go index f69fbf3a1..79fa1934f 100644 --- a/pkg/platforms/shared/installer/installer_test.go +++ b/pkg/platforms/shared/installer/installer_test.go @@ -28,11 +28,13 @@ import ( "testing" "time" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" + "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -45,30 +47,12 @@ func setupMockPlatformWithTempDir(t *testing.T, tempDir string) *mocks.MockPlatf return mockPlatform } -// setupShowLoader sets up the ShowLoader mock to return a noop function. -func setupShowLoader(mockPlatform *mocks.MockPlatform) { - noopFunc := func() error { return nil } - mockPlatform.On("ShowLoader", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ).Return(noopFunc, nil) -} - -// setupShowLoaderNil sets up the ShowLoader mock to return nil function. -func setupShowLoaderNil(mockPlatform *mocks.MockPlatform) { - mockPlatform.On("ShowLoader", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ).Return(nil, nil) -} - func TestInstallRemoteFile_NilContext(t *testing.T) { t.Parallel() cfg := &config.Instance{} tempDir := t.TempDir() mockPlatform := setupMockPlatformWithTempDir(t, tempDir) - setupShowLoader(mockPlatform) var receivedCtx context.Context downloader := func(args DownloaderArgs) error { @@ -85,6 +69,7 @@ func TestInstallRemoteFile_NilContext(t *testing.T) { nil, cfg, mockPlatform, + nil, "http://example.com/game.rom", "nes", "", @@ -112,6 +97,7 @@ func TestInstallRemoteFile_NilDownloader(t *testing.T) { context.Background(), cfg, mockPlatform, + nil, "http://example.com/game.rom", "nes", "", @@ -137,6 +123,7 @@ func TestInstallRemoteFile_EmptyURL(t *testing.T) { context.Background(), cfg, mockPlatform, + nil, "", // empty URL "nes", "", @@ -162,6 +149,7 @@ func TestInstallRemoteFile_EmptySystemID(t *testing.T) { context.Background(), cfg, mockPlatform, + nil, "http://example.com/game.rom", "", // empty system ID "", @@ -179,7 +167,6 @@ func TestInstallRemoteFile_ContextPassedToDownloader(t *testing.T) { cfg := &config.Instance{} tempDir := t.TempDir() mockPlatform := setupMockPlatformWithTempDir(t, tempDir) - setupShowLoader(mockPlatform) var receivedCtx context.Context downloader := func(args DownloaderArgs) error { @@ -198,6 +185,7 @@ func TestInstallRemoteFile_ContextPassedToDownloader(t *testing.T) { ctx, cfg, mockPlatform, + nil, "http://example.com/game.rom", "nes", "", @@ -219,7 +207,6 @@ func TestInstallRemoteFile_ContextCancellation(t *testing.T) { cfg := &config.Instance{} tempDir := t.TempDir() mockPlatform := setupMockPlatformWithTempDir(t, tempDir) - setupShowLoader(mockPlatform) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -246,6 +233,7 @@ func TestInstallRemoteFile_ContextCancellation(t *testing.T) { ctx, cfg, mockPlatform, + nil, "http://example.com/game.rom", "nes", "", @@ -257,74 +245,6 @@ func TestInstallRemoteFile_ContextCancellation(t *testing.T) { assert.ErrorIs(t, errors.Unwrap(err), context.Canceled) } -func TestInstallRemoteFile_NilShowLoaderFunction(t *testing.T) { - t.Parallel() - - cfg := &config.Instance{} - tempDir := t.TempDir() - mockPlatform := setupMockPlatformWithTempDir(t, tempDir) - setupShowLoaderNil(mockPlatform) - - downloader := func(args DownloaderArgs) error { - if err := os.WriteFile(args.tempPath, []byte("test"), 0o600); err != nil { - return fmt.Errorf("write temp file: %w", err) - } - return os.Rename(args.tempPath, args.finalPath) - } - - // This should not panic even though ShowLoader returns nil - path, err := InstallRemoteFile( - context.Background(), - cfg, - mockPlatform, - "http://example.com/game.rom", - "nes", - "", - "", - downloader, - ) - - require.NoError(t, err) - assert.NotEmpty(t, path) -} - -func TestInstallRemoteFile_NilShowNoticeFunction(t *testing.T) { - t.Parallel() - - cfg := &config.Instance{} - tempDir := t.TempDir() - mockPlatform := setupMockPlatformWithTempDir(t, tempDir) - setupShowLoader(mockPlatform) - - // Setup ShowNotice to return nil hide function - mockPlatform.On("ShowNotice", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ).Return(nil, time.Duration(0), nil) - - downloader := func(args DownloaderArgs) error { - if err := os.WriteFile(args.tempPath, []byte("test"), 0o600); err != nil { - return fmt.Errorf("write temp file: %w", err) - } - return os.Rename(args.tempPath, args.finalPath) - } - - // This should not panic even though ShowNotice returns nil hide function - path, err := InstallRemoteFile( - context.Background(), - cfg, - mockPlatform, - "http://example.com/game.rom", - "nes", - "Pre-download notice", // trigger showPreNotice - "", - downloader, - ) - - require.NoError(t, err) - assert.NotEmpty(t, path) -} - func TestInstallRemoteFile_FileAlreadyExists(t *testing.T) { t.Parallel() @@ -350,6 +270,7 @@ func TestInstallRemoteFile_FileAlreadyExists(t *testing.T) { context.Background(), cfg, mockPlatform, + nil, "http://example.com/game.rom", "nes", "", @@ -368,7 +289,6 @@ func TestInstallRemoteFile_DownloaderError(t *testing.T) { cfg := &config.Instance{} tempDir := t.TempDir() mockPlatform := setupMockPlatformWithTempDir(t, tempDir) - setupShowLoader(mockPlatform) expectedErr := errors.New("network error") downloader := func(_ DownloaderArgs) error { @@ -379,6 +299,7 @@ func TestInstallRemoteFile_DownloaderError(t *testing.T) { context.Background(), cfg, mockPlatform, + nil, "http://example.com/game.rom", "nes", "", @@ -390,86 +311,106 @@ func TestInstallRemoteFile_DownloaderError(t *testing.T) { assert.ErrorIs(t, err, expectedErr) } -func TestShowPreNotice_NilHideFunction(t *testing.T) { +func TestInstallRemoteFile_UIEventLoaderLifecycle(t *testing.T) { t.Parallel() cfg := &config.Instance{} - mockPlatform := mocks.NewMockPlatform() + tempDir := t.TempDir() + mockPlatform := setupMockPlatformWithTempDir(t, tempDir) + published := make(chan models.UIStateResponse, 2) + ui := uievents.New(clockwork.NewFakeClock(), nil, func(state models.UIStateResponse) { + published <- state + }) - // Return nil hide function - this should not cause a panic - mockPlatform.On("ShowNotice", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ).Return(nil, time.Duration(0), nil) + downloader := func(args DownloaderArgs) error { + state := ui.State() + require.Len(t, state.Events, 1) + assert.Equal(t, models.UIEventKindLoader, state.Events[0].Kind) + assert.Contains(t, state.Events[0].Message, "Downloading game") + require.NoError(t, os.WriteFile(args.tempPath, []byte("test"), 0o600)) + return os.Rename(args.tempPath, args.finalPath) + } - // This should not panic - err := showPreNotice(cfg, mockPlatform, "Test notice") + path, err := InstallRemoteFile( + t.Context(), cfg, mockPlatform, ui, + "http://example.com/game.rom", "nes", "", "", downloader, + ) require.NoError(t, err) + assert.NotEmpty(t, path) + + opened := <-published + assert.Equal(t, uint64(1), opened.Revision) + resolved := <-published + assert.Equal(t, uint64(2), resolved.Revision) + require.Len(t, resolved.Resolved, 1) + assert.Equal(t, models.UIOutcomeCompleted, resolved.Resolved[0].Outcome) + assert.Empty(t, ui.State().Events) } -func TestShowPreNotice_EmptyText(t *testing.T) { - t.Parallel() +type delayedUIRenderer struct { + delay time.Duration +} - cfg := &config.Instance{} - mockPlatform := mocks.NewMockPlatform() +func (*delayedUIRenderer) PresentUI( + _ context.Context, + _ *models.UIEvent, +) (func() error, error) { + return func() error { return nil }, nil +} - // ShowNotice should not be called with empty text - err := showPreNotice(cfg, mockPlatform, "") - require.NoError(t, err) +func (r *delayedUIRenderer) MinimumUIDisplay(_ models.UIEventKind) time.Duration { + return r.delay +} - // Verify ShowNotice was never called - mockPlatform.AssertNotCalled(t, "ShowNotice", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ) +func TestShowPreNotice_NoService(t *testing.T) { + t.Parallel() + + require.NoError(t, showPreNotice(t.Context(), nil, "Test notice")) } -func TestShowPreNotice_WithDelay(t *testing.T) { +func TestShowPreNotice_EmptyText(t *testing.T) { t.Parallel() - cfg := &config.Instance{} - mockPlatform := mocks.NewMockPlatform() + ui := uievents.New(clockwork.NewFakeClock(), nil, nil) + require.NoError(t, showPreNotice(t.Context(), ui, "")) + assert.Empty(t, ui.State().Events) +} - hideCalled := false - hideFunc := func() error { - hideCalled = true - return nil - } +func TestShowPreNotice_CompletesAfterRendererDelay(t *testing.T) { + t.Parallel() - // Return a delay to test the delay path - mockPlatform.On("ShowNotice", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ).Return(hideFunc, 10*time.Millisecond, nil) + const delay = 10 * time.Millisecond + published := make(chan models.UIStateResponse, 2) + ui := uievents.New(clockwork.NewRealClock(), &delayedUIRenderer{delay: delay}, + func(state models.UIStateResponse) { + published <- state + }) start := time.Now() - err := showPreNotice(cfg, mockPlatform, "Test notice") - elapsed := time.Since(start) - - require.NoError(t, err) - assert.True(t, hideCalled, "hide function should be called") - assert.GreaterOrEqual(t, elapsed, 10*time.Millisecond, "should have delayed") + require.NoError(t, showPreNotice(t.Context(), ui, "Test notice")) + assert.GreaterOrEqual(t, time.Since(start), delay) + + opened := <-published + require.Len(t, opened.Events, 1) + assert.Equal(t, models.UIEventKindNotice, opened.Events[0].Kind) + resolved := <-published + require.Len(t, resolved.Resolved, 1) + assert.Equal(t, models.UIOutcomeCompleted, resolved.Resolved[0].Outcome) } -func TestShowPreNotice_HideError(t *testing.T) { +func TestShowPreNotice_RendererDelayRespectsCancellation(t *testing.T) { t.Parallel() - cfg := &config.Instance{} - mockPlatform := mocks.NewMockPlatform() - - hideErr := errors.New("hide error") - hideFunc := func() error { - return hideErr - } + ui := uievents.New(clockwork.NewRealClock(), &delayedUIRenderer{delay: time.Minute}, nil) + ctx, cancel := context.WithCancel(t.Context()) + cancel() - mockPlatform.On("ShowNotice", - mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("models.NoticeArgs"), - ).Return(hideFunc, time.Duration(0), nil) - - err := showPreNotice(cfg, mockPlatform, "Test notice") - require.Error(t, err) - assert.Contains(t, err.Error(), "error hiding pre-notice") + start := time.Now() + require.NoError(t, showPreNotice(ctx, ui, "Test notice")) + assert.Less(t, time.Since(start), time.Second) + assert.Eventually(t, func() bool { + return len(ui.State().Events) == 0 + }, time.Second, time.Millisecond) } func TestNamesFromURL(t *testing.T) { diff --git a/pkg/platforms/shared/linuxbase/base.go b/pkg/platforms/shared/linuxbase/base.go index 57c946255..ae58cfa3f 100644 --- a/pkg/platforms/shared/linuxbase/base.go +++ b/pkg/platforms/shared/linuxbase/base.go @@ -39,7 +39,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/jonboulle/clockwork" "github.com/rs/zerolog/log" "github.com/shirou/gopsutil/v4/process" @@ -466,30 +465,6 @@ func (*Base) LookupMapping(_ *tokens.Token) (string, bool) { return "", false } -// ShowNotice returns ErrNotSupported (no UI widgets on Linux platforms). -func (*Base) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -// ShowLoader returns ErrNotSupported (no UI widgets on Linux platforms). -func (*Base) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -// ShowPicker returns ErrNotSupported (no UI widgets on Linux platforms). -func (*Base) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - // ConsoleManager returns a no-op console manager. func (*Base) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} diff --git a/pkg/platforms/shared/linuxbase/base_test.go b/pkg/platforms/shared/linuxbase/base_test.go index ee7b84c42..fba8b696c 100644 --- a/pkg/platforms/shared/linuxbase/base_test.go +++ b/pkg/platforms/shared/linuxbase/base_test.go @@ -36,7 +36,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -444,17 +443,6 @@ func TestNoOpMethods(t *testing.T) { assert.Empty(t, path) assert.False(t, found) - closeFunc, duration, err := base.ShowNotice(nil, widgetmodels.NoticeArgs{}) - assert.Nil(t, closeFunc) - assert.Zero(t, duration) - require.ErrorIs(t, err, platforms.ErrNotSupported) - - loaderClose, err := base.ShowLoader(nil, widgetmodels.NoticeArgs{}) - assert.Nil(t, loaderClose) - require.ErrorIs(t, err, platforms.ErrNotSupported) - - require.ErrorIs(t, base.ShowPicker(nil, widgetmodels.PickerArgs{}), platforms.ErrNotSupported) - assert.IsType(t, platforms.NoOpConsoleManager{}, base.ConsoleManager()) } diff --git a/pkg/platforms/windows/platform.go b/pkg/platforms/windows/platform.go index f1cac9635..196dcfe9f 100644 --- a/pkg/platforms/windows/platform.go +++ b/pkg/platforms/windows/platform.go @@ -61,7 +61,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers/tty2oled" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/adrg/xdg" "github.com/rs/zerolog/log" ) @@ -419,27 +418,6 @@ func (p *Platform) Launchers(cfg *config.Instance) []platforms.Launcher { return append(helpers.ParseCustomLaunchers(p, cfg.CustomLaunchers()), launchers...) } -func (*Platform) ShowNotice( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - return nil, 0, platforms.ErrNotSupported -} - -func (*Platform) ShowLoader( - _ *config.Instance, - _ widgetmodels.NoticeArgs, -) (func() error, error) { - return nil, platforms.ErrNotSupported -} - -func (*Platform) ShowPicker( - _ *config.Instance, - _ widgetmodels.PickerArgs, -) error { - return platforms.ErrNotSupported -} - func (*Platform) ConsoleManager() platforms.ConsoleManager { return platforms.NoOpConsoleManager{} } diff --git a/pkg/service/broker/broker.go b/pkg/service/broker/broker.go index 539251cdc..34033fb56 100644 --- a/pkg/service/broker/broker.go +++ b/pkg/service/broker/broker.go @@ -157,6 +157,12 @@ func (b *Broker) Start() { }() } +// Publish broadcasts directly to subscribers, bypassing source-channel +// back-pressure. Coalesceable methods retain latest-state delivery semantics. +func (b *Broker) Publish(notif models.Notification) { + b.broadcast(notif) +} + // broadcast sends a notification to all subscribers whose method filter admits it. // For coalesceable methods: tries a direct non-blocking send; if the channel is // full, stores the latest payload in the subscriber's coalesced slot and wakes diff --git a/pkg/service/broker/broker_test.go b/pkg/service/broker/broker_test.go index e9caca4e8..09d1fe21e 100644 --- a/pkg/service/broker/broker_test.go +++ b/pkg/service/broker/broker_test.go @@ -553,3 +553,29 @@ func TestBroker_CoalesceDoesNotAffectCriticalEvents(t *testing.T) { assert.Equal(t, models.NotificationTokensAdded, notif.Method, "critical notification must be delivered intact") } + +func TestBrokerPublishBypassesFullSourceQueue(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + source := make(chan models.Notification, 1) + source <- models.Notification{Method: "source.blocker"} + b := NewBroker(ctx, source, models.NotificationUIChanged) + defer b.Stop() + sub, _ := b.Subscribe(1) + + expected := models.Notification{ + Method: models.NotificationUIChanged, + Params: []byte(`{"revision":2}`), + } + b.Publish(expected) + + select { + case received := <-sub: + assert.Equal(t, expected, received) + case <-time.After(time.Second): + t.Fatal("directly published UI state was blocked by full source queue") + } +} diff --git a/pkg/service/context.go b/pkg/service/context.go index c14711cb9..c572a5f48 100644 --- a/pkg/service/context.go +++ b/pkg/service/context.go @@ -30,6 +30,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/profiles" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" ) // ServiceContext holds the shared dependencies threaded through all @@ -41,8 +42,10 @@ type ServiceContext struct { DB *database.Database Profiles *profiles.Service PlaybackManager audio.PlaybackManager + UI *uievents.Service LaunchSoftwareQueue chan *tokens.Token PlaylistQueue chan *playlists.Playlist ConfirmQueue chan chan error + LaunchGuardCancel chan struct{} BackgroundWG *sync.WaitGroup } diff --git a/pkg/service/queues.go b/pkg/service/queues.go index e16c8a0f2..6dae19c27 100644 --- a/pkg/service/queues.go +++ b/pkg/service/queues.go @@ -194,9 +194,14 @@ func runTokenZapScript( len(script.Cmds), i, svc.DB, - svc.State.LauncherManager(), - func(ctx context.Context) error { return waitForMediaReady(ctx, svc, mediaReadyGen) }, - svc.PlaybackManager, + zapscript.RunCommandOptions{ + LauncherManager: svc.State.LauncherManager(), + WaitForMediaReady: func(ctx context.Context) error { + return waitForMediaReady(ctx, svc, mediaReadyGen) + }, + PlaybackManager: svc.PlaybackManager, + UI: svc.UI, + }, &cmdEnv, ) if err != nil { diff --git a/pkg/service/reader_manager_test.go b/pkg/service/reader_manager_test.go index 7ba85d95e..c74b0af49 100644 --- a/pkg/service/reader_manager_test.go +++ b/pkg/service/reader_manager_test.go @@ -21,11 +21,14 @@ package service import ( "context" + "encoding/json" "errors" + "sync/atomic" "testing" "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/notifications" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" @@ -34,6 +37,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -50,15 +54,37 @@ type readerManagerEnv struct { scanQueue chan readers.Scan itq chan tokens.Token confirmQueue chan chan error + ui *uievents.Service notifCh <-chan models.Notification clock clockwork.Clock } +type countingUIRenderer struct { + presented atomic.Int32 +} + +func (r *countingUIRenderer) PresentUI( + _ context.Context, + _ *models.UIEvent, +) (func() error, error) { + r.presented.Add(1) + return func() error { return nil }, nil +} + func setupReaderManager(t *testing.T, opts ...func(*config.Instance)) *readerManagerEnv { return setupReaderManagerWithClock(t, nil, opts...) } func setupReaderManagerWithClock(t *testing.T, clk clockwork.Clock, opts ...func(*config.Instance)) *readerManagerEnv { + return setupReaderManagerWithRenderer(t, clk, nil, opts...) +} + +func setupReaderManagerWithRenderer( + t *testing.T, + clk clockwork.Clock, + renderer uievents.Renderer, + opts ...func(*config.Instance), +) *readerManagerEnv { t.Helper() fs := testhelpers.NewMemoryFS() @@ -77,6 +103,16 @@ func setupReaderManagerWithClock(t *testing.T, clk clockwork.Clock, opts ...func mockPlatform.On("LookupMapping", mock.Anything).Return("", false) st, notifCh := state.NewState(mockPlatform, "test-boot-uuid") + uiClock := clk + if uiClock == nil { + uiClock = clockwork.NewRealClock() + } + ui := uievents.New(uiClock, renderer, func(payload models.UIStateResponse) { + notifications.UIChanged(func(notification models.Notification) { + st.Notifications <- notification + }, payload) + }) + st.SetUIEvents(ui) mockUserDB := testhelpers.NewMockUserDBI() mockUserDB.On("GetEnabledMappings").Return([]database.Mapping{}, nil) @@ -90,6 +126,7 @@ func setupReaderManagerWithClock(t *testing.T, clk clockwork.Clock, opts ...func lsq := make(chan *tokens.Token, 10) plq := make(chan *playlists.Playlist, 10) cfq := make(chan chan error, 10) + lgcq := make(chan struct{}, 1) svc := &ServiceContext{ Platform: mockPlatform, @@ -99,7 +136,15 @@ func setupReaderManagerWithClock(t *testing.T, clk clockwork.Clock, opts ...func LaunchSoftwareQueue: lsq, PlaylistQueue: plq, ConfirmQueue: cfq, + LaunchGuardCancel: lgcq, + UI: ui, } + st.SetOnMediaStopHook(func() { + select { + case lgcq <- struct{}{}: + default: + } + }) go readerManager(svc, itq, scanQueue, mockPlayer, clk) @@ -119,6 +164,7 @@ func setupReaderManagerWithClock(t *testing.T, clk clockwork.Clock, opts ...func scanQueue: scanQueue, itq: itq, confirmQueue: cfq, + ui: ui, notifCh: notifCh, clock: clk, } @@ -163,6 +209,30 @@ func (env *readerManagerEnv) expectNotification(t *testing.T, method string) { } } +func (env *readerManagerEnv) expectUIState( + t *testing.T, + matches func(models.UIStateResponse) bool, +) models.UIStateResponse { + t.Helper() + timeout := time.After(tokenTimeout) + for { + select { + case notif := <-env.notifCh: + if notif.Method != models.NotificationUIChanged { + continue + } + var uiState models.UIStateResponse + require.NoError(t, json.Unmarshal(notif.Params, &uiState)) + if matches(uiState) { + return uiState + } + case <-timeout: + t.Fatal("timed out waiting for matching ui.changed notification") + return models.UIStateResponse{} + } + } +} + func TestReaderManager_NormalScanFlow(t *testing.T) { t.Parallel() env := setupReaderManager(t) @@ -731,9 +801,14 @@ func TestReaderManager_LaunchGuard_APIConfirm(t *testing.T) { err := <-result require.NoError(t, err) - // Token should now be on itq + // Token should now be on itq and legacy confirm must close global UI. tok := env.expectToken(t) assert.Equal(t, "card-b", tok.UID) + uiState := env.expectUIState(t, func(snapshot models.UIStateResponse) bool { + return len(snapshot.Events) == 0 && len(snapshot.Resolved) == 1 && + snapshot.Resolved[0].Outcome == models.UIOutcomeConfirmed + }) + assert.Empty(t, uiState.Events) } func TestReaderManager_LaunchGuard_APIConfirmNoStaged(t *testing.T) { @@ -976,6 +1051,153 @@ func TestReaderManager_LaunchGuard_EmitsStagedNotification(t *testing.T) { } } +func TestReaderManager_LaunchGuard_OpensGlobalConfirmEvent(t *testing.T) { + t.Parallel() + fakeClock := clockwork.NewFakeClock() + env := setupReaderManagerWithClock(t, fakeClock, withLaunchGuard) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes", Name: "Current Game"}) + + env.sendScan(readers.Scan{ + Source: "test-reader", + Token: &tokens.Token{ + UID: "card-b", + Text: "**launch.system:snes", + ScanTime: fakeClock.Now(), + }, + }) + env.expectNoToken(t) + + uiState := env.ui.State() + require.Len(t, uiState.Events, 1) + event := uiState.Events[0] + assert.Equal(t, models.UIEventKindConfirm, event.Kind) + assert.Equal(t, "Change game?", event.Title) + assert.Equal(t, "**launch.system:snes", event.Message) + require.NotNil(t, event.ExpiresAt) + assert.WithinDuration(t, fakeClock.Now().Add(15*time.Second), *event.ExpiresAt, time.Microsecond) +} + +func TestReaderManager_LaunchGuard_UsesUIDWhenTokenTextIsEmpty(t *testing.T) { + t.Parallel() + + env := setupReaderManager(t, withLaunchGuard) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes"}) + env.sendScan(readers.Scan{ + Source: "test-reader", + Token: &tokens.Token{UID: "card-without-text", ScanTime: time.Now()}, + }) + env.expectNoToken(t) + + uiState := env.ui.State() + require.Len(t, uiState.Events, 1) + assert.Equal(t, "card-without-text", uiState.Events[0].Message) +} + +func TestReaderManager_LaunchGuard_DoesNotRenderConfirmOnHost(t *testing.T) { + t.Parallel() + + renderer := &countingUIRenderer{} + env := setupReaderManagerWithRenderer(t, nil, renderer, withLaunchGuard) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes", Name: "Current Game"}) + + env.sendScan(readers.Scan{ + Source: "test-reader", + Token: &tokens.Token{ + UID: "card-b", + Text: "**launch.system:snes", + ScanTime: time.Now(), + }, + }) + env.expectNoToken(t) + + require.Len(t, env.ui.State().Events, 1) + assert.Equal(t, int32(0), renderer.presented.Load()) +} + +func TestReaderManager_LaunchGuard_UIConfirmAndDismiss(t *testing.T) { + t.Parallel() + + t.Run("confirm launches", func(t *testing.T) { + env := setupReaderManager(t, withLaunchGuardRequireConfirm) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes"}) + env.sendScan(readers.Scan{ + Source: "test-reader", + Token: &tokens.Token{UID: "card-a", Text: "**launch.system:snes", ScanTime: time.Now()}, + }) + env.expectNoToken(t) + + event := env.ui.State().Events[0] + require.NoError(t, env.ui.Respond(event.ID, models.UIResponseActionConfirm, "")) + assert.Equal(t, "card-a", env.expectToken(t).UID) + }) + + t.Run("dismiss clears stage", func(t *testing.T) { + env := setupReaderManager(t, withLaunchGuardRequireConfirm) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes"}) + env.sendScan(readers.Scan{ + Source: "test-reader", + Token: &tokens.Token{UID: "card-a", Text: "**launch.system:snes", ScanTime: time.Now()}, + }) + env.expectNoToken(t) + + event := env.ui.State().Events[0] + require.NoError(t, env.ui.Respond(event.ID, models.UIResponseActionDismiss, "")) + result := make(chan error, 1) + env.confirmQueue <- result + require.ErrorIs(t, <-result, ErrNoStagedToken) + env.expectNoToken(t) + }) +} + +func TestReaderManager_LaunchGuard_MediaStopCancelsGlobalEvent(t *testing.T) { + t.Parallel() + env := setupReaderManager(t, withLaunchGuardRequireConfirm) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes"}) + env.sendScan(readers.Scan{ + Source: "test-reader", + Token: &tokens.Token{UID: "card-a", Text: "**launch.system:snes", ScanTime: time.Now()}, + }) + env.expectNoToken(t) + require.Len(t, env.ui.State().Events, 1) + + env.st.SetActiveMedia(nil) + uiState := env.expectUIState(t, func(snapshot models.UIStateResponse) bool { + return len(snapshot.Events) == 0 && len(snapshot.Resolved) == 1 && + snapshot.Resolved[0].Outcome == models.UIOutcomeCancelled + }) + assert.Empty(t, uiState.Events) + + result := make(chan error, 1) + env.confirmQueue <- result + require.ErrorIs(t, <-result, ErrNoStagedToken) + env.expectNoToken(t) +} + +func TestReaderManager_LaunchGuard_DelayResetUpdatesSameEvent(t *testing.T) { + t.Parallel() + fakeClock := clockwork.NewFakeClock() + env := setupReaderManagerWithClock(t, fakeClock, withLaunchGuardDelay) + env.st.SetActiveMedia(&models.ActiveMedia{LauncherID: "test", SystemID: "nes"}) + card := &tokens.Token{UID: "card-a", Text: "**launch.system:snes", ScanTime: fakeClock.Now()} + + env.sendScan(readers.Scan{Source: "test-reader", Token: card}) + env.sendScan(readers.Scan{Source: "test-reader", Token: nil}) + original := env.ui.State() + require.Len(t, original.Events, 1) + + fakeClock.Advance(time.Second) + env.sendScan(readers.Scan{Source: "test-reader", Token: card}) + env.expectNoToken(t) + updated := env.ui.State() + require.Len(t, updated.Events, 1) + assert.Equal(t, original.Events[0].ID, updated.Events[0].ID) + assert.Greater(t, updated.Revision, original.Revision) + require.NotNil(t, updated.Events[0].ExpiresAt) + assert.WithinDuration( + t, fakeClock.Now().Add(15*time.Second), *updated.Events[0].ExpiresAt, time.Microsecond, + ) +} + // Utility commands pass through launch guard without staging func TestReaderManager_LaunchGuard_UtilityCommandPassesThrough(t *testing.T) { t.Parallel() diff --git a/pkg/service/readers.go b/pkg/service/readers.go index e0d9acc78..d78efba61 100644 --- a/pkg/service/readers.go +++ b/pkg/service/readers.go @@ -36,6 +36,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/jonboulle/clockwork" "github.com/rs/zerolog/log" ) @@ -321,10 +322,56 @@ func readerManager( var exitTimer clockwork.Timer var stagedToken *tokens.Token - var guardTimeout <-chan time.Time + var guardUI *uievents.Handle + var guardResults <-chan uievents.Result var guardDelay <-chan time.Time var delayExpired bool + resetGuardState := func() { + stagedToken = nil + guardUI = nil + guardResults = nil + guardDelay = nil + delayExpired = false + } + completeGuard := func(outcome models.UIOutcome) error { + var err error + if guardUI != nil { + err = guardUI.Complete(outcome) + } + resetGuardState() + if err != nil { + return fmt.Errorf("complete launch guard UI event: %w", err) + } + return nil + } + cancelGuard := func() { + if err := completeGuard(models.UIOutcomeCancelled); err != nil { + log.Debug().Err(err).Msg("launch guard: cancellation lost resolution race") + } + } + applyGuardResult := func(result uievents.Result) error { + staged := stagedToken + resetGuardState() + if result.Resolution.Outcome != models.UIOutcomeConfirmed { + log.Info().Str("outcome", string(result.Resolution.Outcome)). + Msg("launch guard: staged token resolved without launch") + return nil + } + if staged == nil || svc.State.ActiveMedia() == nil { + log.Info().Msg("launch guard: ignoring confirmation after media stopped") + return nil + } + confirmed := *staged + svc.State.SetActiveCard(confirmed) + select { + case itq <- confirmed: + return nil + case <-svc.State.GetContext().Done(): + return svc.State.GetContext().Err() + } + } + var autoDetector *AutoDetector if svc.Config.AutoDetect() { autoDetector = NewAutoDetector(svc.Config) @@ -449,18 +496,20 @@ preprocessing: svc.State.SetSoftwareToken(stoken) continue preprocessing case result := <-svc.ConfirmQueue: - // API confirm request — launch the staged token if one exists. - // API confirm bypasses any active delay. - if stagedToken == nil { + // Legacy API confirm remains launch-guard-specific and bypasses delay. + if stagedToken == nil || svc.State.ActiveMedia() == nil { + if stagedToken != nil { + cancelGuard() + } result <- ErrNoStagedToken continue preprocessing } log.Info().Msgf("launch guard: API confirmed staged token: %v", stagedToken) - guardTimeout = nil - guardDelay = nil - delayExpired = false confirmed := *stagedToken - stagedToken = nil + if err := completeGuard(models.UIOutcomeConfirmed); err != nil { + result <- ErrNoStagedToken + continue preprocessing + } svc.State.SetActiveCard(confirmed) select { case itq <- confirmed: @@ -470,8 +519,27 @@ preprocessing: } result <- nil continue preprocessing + case uiResult, ok := <-guardResults: + if !ok { + guardResults = nil + continue preprocessing + } + if err := applyGuardResult(uiResult); err != nil { + break preprocessing + } + continue preprocessing + case <-svc.LaunchGuardCancel: + if stagedToken != nil { + log.Info().Msg("launch guard: media stopped, cancelling staged token") + cancelGuard() + } + continue preprocessing case <-guardDelay: - // Delay period expired — token is now ready for re-tap confirmation + // Delay period expired — token is now ready for re-tap confirmation. + if stagedToken == nil { + guardDelay = nil + continue preprocessing + } log.Info().Msg("launch guard: delay expired, ready for confirmation") delayExpired = true guardDelay = nil @@ -485,37 +553,29 @@ preprocessing: path, enabled := svc.Config.ReadySoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.ReadySound, "ready") continue preprocessing - case <-guardTimeout: - // Staged token expired - log.Info().Msg("launch guard: staged token expired") - stagedToken = nil - guardTimeout = nil - guardDelay = nil - delayExpired = false - continue preprocessing } - // If a scan arrives while the timeout is also ready, process the timeout - // first. Otherwise Go's random select order can confirm an expired token. - if stagedToken != nil && guardTimeout != nil { + // If a scan races a terminal UI result, process resolution first. This + // prevents a re-tap from confirming a token whose Core timeout won. + if stagedToken != nil && guardResults != nil { select { - case <-guardTimeout: - log.Info().Msg("launch guard: staged token expired") - stagedToken = nil - guardTimeout = nil - guardDelay = nil - delayExpired = false + case uiResult, ok := <-guardResults: + if !ok { + guardResults = nil + continue preprocessing + } + if err := applyGuardResult(uiResult); err != nil { + break preprocessing + } + continue preprocessing default: } } - // Clear stale staged token if media has stopped since staging + // Clear stale staged token if media stopped before cancellation arrived. if stagedToken != nil && svc.State.ActiveMedia() == nil { log.Info().Msg("launch guard: media stopped, clearing stale staged token") - stagedToken = nil - guardTimeout = nil - guardDelay = nil - delayExpired = false + cancelGuard() } // Launch guard confirmation: check BEFORE the preprocessor so that @@ -526,12 +586,14 @@ preprocessing: svc.Config.LaunchGuardEnabled() && !svc.Config.LaunchGuardRequireConfirm() { if helpers.TokensEqual(scan, stagedToken) && svc.State.ActiveMedia() != nil { if !delayExpired { - // Re-tap during delay period — reset both timers as punishment + // Re-tap during delay period — reset both timers as punishment. log.Info().Msg("launch guard: re-tap during delay, resetting timers") - timeout := svc.Config.LaunchGuardTimeout() + timeout := time.Duration(svc.Config.LaunchGuardTimeout() * float32(time.Second)) delay := svc.Config.LaunchGuardDelay() - if timeout > 0 { - guardTimeout = clock.After(time.Duration(timeout * float32(time.Second))) + if guardUI != nil { + if err := guardUI.Update(uievents.Update{Timeout: &timeout}); err != nil { + log.Warn().Err(err).Msg("launch guard: failed to reset UI expiry") + } } if delay > 0 { guardDelay = clock.After(time.Duration(delay * float32(time.Second))) @@ -540,12 +602,13 @@ preprocessing: continue preprocessing } log.Info().Msg("launch guard: re-tap confirmed, launching staged token") - guardTimeout = nil - guardDelay = nil - delayExpired = false confirmed := *stagedToken - stagedToken = nil - // Let the preprocessor know what's on the reader now + if err := completeGuard(models.UIOutcomeConfirmed); err != nil { + log.Info().Err(err).Msg("launch guard: re-tap lost resolution race") + proc.Process(scan, readerError) + continue preprocessing + } + // Let the preprocessor know what's on the reader now. proc.Process(scan, readerError) svc.State.SetActiveCard(confirmed) select { @@ -653,10 +716,28 @@ preprocessing: path, enabled := svc.Config.PendingSoundPath(helpers.DataDir(svc.Platform)) helpers.PlayConfiguredSound(player, path, enabled, assets.PendingSound, "pending") - if timeout := svc.Config.LaunchGuardTimeout(); timeout > 0 { - guardTimeout = clock.After(time.Duration(timeout * float32(time.Second))) + message := scan.Text + if message == "" { + message = scan.UID + } + if svc.UI == nil { + log.Error().Msg("launch guard: UI event service unavailable") } else { - guardTimeout = nil + timeout := time.Duration(svc.Config.LaunchGuardTimeout() * float32(time.Second)) + handle, openErr := svc.UI.Open(svc.State.GetContext(), &uievents.Request{ + Kind: models.UIEventKindConfirm, + Title: "Change game?", + Message: message, + Timeout: timeout, + Dismissible: true, + SkipHostRenderer: true, + }) + if openErr != nil { + log.Error().Err(openErr).Msg("launch guard: failed to open UI event") + } else { + guardUI = handle + guardResults = handle.Results + } } if delay := svc.Config.LaunchGuardDelay(); delay > 0 { diff --git a/pkg/service/scan_to_launch_bench_test.go b/pkg/service/scan_to_launch_bench_test.go index c755f2903..8bddc15b7 100644 --- a/pkg/service/scan_to_launch_bench_test.go +++ b/pkg/service/scan_to_launch_bench_test.go @@ -156,7 +156,9 @@ func BenchmarkScanToLaunch_ExactMatch(b *testing.B) { _, err = zapscript.RunCommand( context.Background(), env.pl, env.cfg, playlists.PlaylistController{}, token, cmd, - len(script.Cmds), i, env.db, env.lm, nil, nil, &env.exprEnv, + len(script.Cmds), i, env.db, + zapscript.RunCommandOptions{LauncherManager: env.lm}, + &env.exprEnv, ) if err != nil { b.Fatal(err) @@ -198,7 +200,9 @@ func BenchmarkScanToLaunch_DirectPath(b *testing.B) { _, err = zapscript.RunCommand( context.Background(), env.pl, env.cfg, playlists.PlaylistController{}, token, cmd, - len(script.Cmds), i, env.db, env.lm, nil, nil, &env.exprEnv, + len(script.Cmds), i, env.db, + zapscript.RunCommandOptions{LauncherManager: env.lm}, + &env.exprEnv, ) if err != nil { b.Fatal(err) @@ -252,7 +256,9 @@ func BenchmarkScanToLaunch_WithMapping(b *testing.B) { _, err = zapscript.RunCommand( context.Background(), env.pl, env.cfg, playlists.PlaylistController{}, token, cmd, - len(script.Cmds), i, env.db, env.lm, nil, nil, &env.exprEnv, + len(script.Cmds), i, env.db, + zapscript.RunCommandOptions{LauncherManager: env.lm}, + &env.exprEnv, ) if err != nil { b.Fatal(err) diff --git a/pkg/service/service.go b/pkg/service/service.go index 63e2a778f..a90c8a1aa 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -30,6 +30,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/notifications" "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" @@ -50,6 +51,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/updater" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/google/uuid" "github.com/jonboulle/clockwork" @@ -198,16 +200,30 @@ func Start( st, ns := state.NewState(pl, bootUUID) // global state, notification queue (source) // Create and start notification broker to broadcast to all consumers. - // media.indexing is coalesceable: bursts during index/resume collapse to - // latest-wins so slow WebSocket consumers don't drop discrete events. - notifBroker := broker.NewBroker(st.GetContext(), ns, models.NotificationMediaIndexing) + // Coalesceable methods collapse bursts to latest state for slow consumers. + // UI state publishes directly to broker so source-queue pressure cannot drop it. + notifBroker := broker.NewBroker( + st.GetContext(), ns, + models.NotificationMediaIndexing, + models.NotificationUIChanged, + ) notifBroker.Start() + var uiRenderer uievents.Renderer + if renderer, ok := pl.(uievents.Renderer); ok { + uiRenderer = renderer + } + uiEvents := uievents.New(clockwork.NewRealClock(), uiRenderer, func(payload models.UIStateResponse) { + notifications.UIChanged(notifBroker.Publish, payload) + }) + st.SetUIEvents(uiEvents) + // TODO: convert this to a *token channel itq := make(chan tokens.Token) // input token queue lsq := make(chan *tokens.Token) // launch software queue plq := make(chan *playlists.Playlist) // playlist event queue cfq := make(chan chan error) // launch guard confirm queue + lgcq := make(chan struct{}, 1) // launch guard cancellation queue backgroundWG := &sync.WaitGroup{} setupStarted := time.Now() @@ -287,9 +303,11 @@ func Start( DB: db, Profiles: profilesSvc, PlaybackManager: playbackManager, + UI: uiEvents, LaunchSoftwareQueue: lsq, PlaylistQueue: plq, ConfirmQueue: cfq, + LaunchGuardCancel: lgcq, BackgroundWG: backgroundWG, } wireNativeAudioDrainCallbacks(playbackManager, svc) @@ -307,6 +325,10 @@ func Start( // Resume background music when a game quits, but only if we auto-paused it. st.SetOnMediaStopHook(func() { resumeBackgroundAfterMediaStop(svc) + select { + case svc.LaunchGuardCancel <- struct{}{}: + default: + } }) log.Info().Msg("loading mapping files") @@ -365,6 +387,7 @@ func Start( } limitsManager.Stop() dataSwap.Stop() + uiEvents.Shutdown() notifBroker.Stop() closeDatabase(db) return nil, fmt.Errorf("api startup failed: %w", apiErr) @@ -541,6 +564,7 @@ func Start( go func() { <-st.GetContext().Done() log.Info().Msg("service context cancelled, running cleanup") + uiEvents.Shutdown() indexPauser.Resume() scrapePauser.Resume() diff --git a/pkg/service/state/state.go b/pkg/service/state/state.go index 49c0a1412..ca696f138 100644 --- a/pkg/service/state/state.go +++ b/pkg/service/state/state.go @@ -34,6 +34,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/inbox" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/rs/zerolog/log" ) @@ -77,6 +78,7 @@ type State struct { onMediaStartHook func(*models.ActiveMedia, uint64) onMediaStopHook func() launcherManager *LauncherManager + uiEvents *uievents.Service bootUUID string lastScanned tokens.Token activeToken tokens.Token @@ -106,6 +108,20 @@ func NewState(platform platforms.Platform, bootUUID string) (state *State, notif }, ns } +// SetUIEvents stores the process-wide UI event service. +func (s *State) SetUIEvents(service *uievents.Service) { + s.mu.Lock() + defer s.mu.Unlock() + s.uiEvents = service +} + +// UIEvents returns the process-wide UI event service. +func (s *State) UIEvents() *uievents.Service { + s.mu.RLock() + defer s.mu.RUnlock() + return s.uiEvents +} + func (s *State) SetActiveCard(card tokens.Token) { //nolint:gocritic // single-use parameter in state setter s.mu.Lock() diff --git a/pkg/testing/helpers/esapi_server.go b/pkg/testing/helpers/esapi_server.go index 712b8b7f9..be4ac5967 100644 --- a/pkg/testing/helpers/esapi_server.go +++ b/pkg/testing/helpers/esapi_server.go @@ -22,6 +22,7 @@ package helpers import ( "context" "encoding/json" + "io" "net" "net/http" "testing" @@ -35,10 +36,11 @@ import ( // It listens on localhost:1234 (the hardcoded ES API port) to intercept API calls during tests. type MockESAPIServer struct { *http.Server - listener net.Listener - runningGame *esapi.RunningGameResponse - isRunning bool - mu syncutil.Mutex + listener net.Listener + runningGame *esapi.RunningGameResponse + notifications []string + isRunning bool + mu syncutil.Mutex } // NewMockESAPIServer creates a mock EmulationStation API server on localhost:1234. @@ -136,7 +138,20 @@ func (*MockESAPIServer) handleLaunch(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } -func (*MockESAPIServer) handleNotify(w http.ResponseWriter, _ *http.Request) { - // Simulate notification - just return OK +func (m *MockESAPIServer) Notifications() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.notifications...) +} + +func (m *MockESAPIServer) handleNotify(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read notification", http.StatusBadRequest) + return + } + m.mu.Lock() + m.notifications = append(m.notifications, string(body)) + m.mu.Unlock() w.WriteHeader(http.StatusOK) } diff --git a/pkg/testing/mocks/platform.go b/pkg/testing/mocks/platform.go index 31c964ee1..606e56ab8 100644 --- a/pkg/testing/mocks/platform.go +++ b/pkg/testing/mocks/platform.go @@ -23,7 +23,6 @@ import ( "context" "fmt" "os" - "time" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" @@ -33,7 +32,6 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" "github.com/stretchr/testify/mock" ) @@ -231,46 +229,6 @@ func (m *MockPlatform) Launchers(cfg *config.Instance) []platforms.Launcher { return []platforms.Launcher{} } -// ShowNotice displays a string on-screen of the platform device -func (m *MockPlatform) ShowNotice(cfg *config.Instance, args widgetmodels.NoticeArgs, -) (func() error, time.Duration, error) { - callArgs := m.Called(cfg, args) - var fn func() error - var duration time.Duration - if f, ok := callArgs.Get(0).(func() error); ok { - fn = f - } - if d, ok := callArgs.Get(1).(time.Duration); ok { - duration = d - } - if err := callArgs.Error(2); err != nil { - return fn, duration, fmt.Errorf("mock operation failed: %w", err) - } - return fn, duration, nil -} - -// ShowLoader displays a string on-screen alongside an animation -func (m *MockPlatform) ShowLoader(cfg *config.Instance, args widgetmodels.NoticeArgs) (func() error, error) { - callArgs := m.Called(cfg, args) - var fn func() error - if f, ok := callArgs.Get(0).(func() error); ok { - fn = f - } - if err := callArgs.Error(1); err != nil { - return fn, fmt.Errorf("mock operation failed: %w", err) - } - return fn, nil -} - -// ShowPicker displays a list picker on-screen of the platform device -func (m *MockPlatform) ShowPicker(cfg *config.Instance, args widgetmodels.PickerArgs) error { - callArgs := m.Called(cfg, args) - if err := callArgs.Error(0); err != nil { - return fmt.Errorf("mock operation failed: %w", err) - } - return nil -} - func (m *MockPlatform) ConsoleManager() platforms.ConsoleManager { args := m.Called() if manager, ok := args.Get(0).(platforms.ConsoleManager); ok { @@ -361,11 +319,4 @@ func (m *MockPlatform) SetupBasicMock() { m.On("Launchers", mock.AnythingOfType("*config.Instance")).Return([]platforms.Launcher{}) m.On("ManagedByPackageManager").Return(false) m.On("Scrapers", mock.AnythingOfType("*config.Instance")).Return(map[string]platforms.Scraper{}) - - // Setup common stub functions for UI methods - noopFunc := func() error { return nil } - m.On("ShowNotice", mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("widgetmodels.NoticeArgs")).Return(noopFunc, time.Duration(0), nil) - m.On("ShowLoader", mock.AnythingOfType("*config.Instance"), - mock.AnythingOfType("widgetmodels.NoticeArgs")).Return(noopFunc, nil) } diff --git a/pkg/ui/events/service.go b/pkg/ui/events/service.go new file mode 100644 index 000000000..1251b5c17 --- /dev/null +++ b/pkg/ui/events/service.go @@ -0,0 +1,749 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +// Package events manages global, transient UI requests. It owns presentation +// lifecycle and response arbitration; callers retain ownership of domain work. +package events + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/google/uuid" + "github.com/jonboulle/clockwork" + "github.com/rs/zerolog/log" +) + +const ( + MaxTitleBytes = 512 + MaxMessageBytes = 16 * 1024 + MaxChoices = 1000 + MaxLabelBytes = 512 +) + +var ( + ErrClosed = errors.New("UI event service is closed") + ErrNoActiveEvent = errors.New("no active UI event") + ErrEventNotActive = errors.New("UI event is not active") + ErrInvalidKind = errors.New("invalid UI event kind") + ErrInvalidAction = errors.New("invalid UI response action") + ErrNotDismissible = errors.New("UI event is not dismissible") + ErrChoiceRequired = errors.New("choice ID is required") + ErrChoiceNotFound = errors.New("UI choice was not found") + ErrInvalidRequest = errors.New("invalid UI event request") + ErrInvalidOutcome = errors.New("invalid UI event outcome") + ErrEventExpired = errors.New("UI event has expired") +) + +// Renderer presents UI events on the host platform. Renderer failure never +// cancels an event because remote clients remain valid fallback renderers. +type Renderer interface { + PresentUI(context.Context, *models.UIEvent) (closeFn func() error, err error) +} + +// UpdatingRenderer optionally applies producer updates to an existing host UI. +type UpdatingRenderer interface { + UpdateUI(context.Context, *models.UIEvent) error +} + +// TimedRenderer reports minimum time a producer should keep a newly presented +// event open before completing it. This accommodates host UI startup latency. +type TimedRenderer interface { + MinimumUIDisplay(models.UIEventKind) time.Duration +} + +// Publisher broadcasts an authoritative UI state snapshot. +type Publisher func(models.UIStateResponse) + +// Choice combines public display text with a private caller-owned value. Value +// is returned only to producer and is never serialized into public event. +type Choice struct { + Value any + Label string +} + +// Request describes one transient UI interaction. A positive Timeout creates +// authoritative expiry; zero or negative values leave event open until resolved. +type Request struct { + Kind models.UIEventKind + Title string + Message string + Choices []Choice + // SelectedChoice is picker choice index. Zero selects first choice; use -1 + // explicitly when no choice should be preselected. + SelectedChoice int + Timeout time.Duration + Dismissible bool + // SkipHostRenderer keeps the request available to API clients without + // presenting it through the platform renderer. + SkipHostRenderer bool +} + +// Update changes presentation of active event while preserving its ID. Nil +// fields remain unchanged. A non-positive Timeout removes authoritative expiry. +type Update struct { + Title *string + Message *string + Timeout *time.Duration + Dismissible *bool +} + +// Result is delivered once when an external response, timeout, cancellation, +// or supersession resolves request. Value contains private selected choice value. +type Result struct { + Value any + Resolution models.UIResolution +} + +// Handle lets producer update or complete request it opened. +type Handle struct { + service *Service + Results <-chan Result + ID string + MinimumDisplay time.Duration +} + +// Complete resolves event from producer side. Completing stale/superseded +// handle is harmless so deferred loader cleanup remains safe. +func (h *Handle) Complete(outcome models.UIOutcome) error { + if h == nil || h.service == nil { + return nil + } + err := h.service.complete(h.ID, outcome) + if outcome != models.UIOutcomeConfirmed && + (errors.Is(err, ErrNoActiveEvent) || errors.Is(err, ErrEventNotActive)) { + return nil + } + return err +} + +// Update changes active event. Updating stale handle is reported to caller. +func (h *Handle) Update(update Update) error { + if h == nil || h.service == nil { + return ErrEventNotActive + } + return h.service.Update(h.ID, update) +} + +type activeEvent struct { + stopContext func() bool + closeRenderer func() error + timer clockwork.Timer + choices map[string]Choice + result chan Result + event models.UIEvent + timerGeneration uint64 + skipHostRenderer bool +} + +// Service owns current global UI event and arbitrates all terminal outcomes. +type Service struct { + clock clockwork.Clock + renderer Renderer + publish Publisher + active *activeEvent + revision uint64 + closed bool + mu syncutil.Mutex +} + +// New creates UI event service. Nil clock uses real time. Renderer and +// publisher may be nil for headless use and tests. +func New(clock clockwork.Clock, renderer Renderer, publish Publisher) *Service { + if clock == nil { + clock = clockwork.NewRealClock() + } + return &Service{ + clock: clock, + renderer: renderer, + publish: publish, + } +} + +// Open replaces current request, if any, and returns producer handle. +func (s *Service) Open(ctx context.Context, request *Request) (*Handle, error) { + if err := validateRequest(request); err != nil { + return nil, err + } + if ctx == nil { + ctx = context.Background() + } + + now := s.clock.Now() + entry := newActiveEvent(request, now) + + var replaced *activeEvent + var replacedResolution *models.UIResolution + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil, ErrClosed + } + if s.active != nil { + replaced = s.active + stopActiveTimers(replaced) + resolution := models.UIResolution{ + ID: replaced.event.ID, + Outcome: models.UIOutcomeSuperseded, + } + replacedResolution = &resolution + } + s.active = entry + s.revision++ + state := s.stateLocked(resolutionSlice(replacedResolution)) + s.mu.Unlock() + + if replaced != nil { + closeRenderer(replaced) + s.deliverResult(replaced, Result{Resolution: *replacedResolution}) + } + s.publishState(state) + + s.attachCancellation(ctx, entry.event.ID) + s.attachTimer(entry.event.ID, entry.timerGeneration, request.Timeout) + + minimumDisplay := time.Duration(0) + if !entry.skipHostRenderer { + event := cloneEvent(&entry.event) + s.present(ctx, entry.event.ID, &event) + if timedRenderer, ok := s.renderer.(TimedRenderer); ok { + minimumDisplay = timedRenderer.MinimumUIDisplay(entry.event.Kind) + } + } + return &Handle{ + service: s, + Results: entry.result, + ID: entry.event.ID, + MinimumDisplay: minimumDisplay, + }, nil +} + +// State returns immutable current snapshot. Resolved is always empty for query. +func (s *Service) State() models.UIStateResponse { + s.mu.Lock() + defer s.mu.Unlock() + return s.stateLocked(nil) +} + +// Respond atomically resolves current event from API or host renderer input. +func (s *Service) Respond(id string, action models.UIResponseAction, choiceID string) error { + s.mu.Lock() + entry, err := s.activeForIDLocked(id) + if err != nil { + s.mu.Unlock() + return err + } + if s.eventExpired(entry) { + state, resolution := s.expireLocked(entry) + s.mu.Unlock() + closeRenderer(entry) + s.publishState(state) + s.deliverResult(entry, Result{Resolution: resolution}) + return ErrEventExpired + } + + resolution, value, err := validateResponse(entry, action, choiceID) + if err != nil { + s.mu.Unlock() + return err + } + + s.active = nil + stopActiveTimers(entry) + s.revision++ + state := s.stateLocked([]models.UIResolution{resolution}) + s.mu.Unlock() + + closeRenderer(entry) + s.publishState(state) + s.deliverResult(entry, Result{Resolution: resolution, Value: value}) + return nil +} + +// Update changes one active event and publishes newer revision. +func (s *Service) Update(id string, update Update) error { + if err := validateUpdate(update); err != nil { + return err + } + + var timeout time.Duration + var resetTimer bool + + s.mu.Lock() + entry, err := s.activeForIDLocked(id) + if err != nil { + s.mu.Unlock() + return err + } + if s.eventExpired(entry) { + state, resolution := s.expireLocked(entry) + s.mu.Unlock() + closeRenderer(entry) + s.publishState(state) + s.deliverResult(entry, Result{Resolution: resolution}) + return ErrEventExpired + } + + if update.Title != nil { + entry.event.Title = *update.Title + } + if update.Message != nil { + entry.event.Message = *update.Message + } + if update.Dismissible != nil { + entry.event.Dismissible = *update.Dismissible + } + if update.Timeout != nil { + resetTimer = true + timeout = *update.Timeout + entry.timerGeneration++ + if entry.timer != nil { + entry.timer.Stop() + entry.timer = nil + } + if timeout > 0 { + expiresAt := s.clock.Now().Add(timeout) + entry.event.ExpiresAt = &expiresAt + } else { + entry.event.ExpiresAt = nil + } + } + + s.revision++ + event := cloneEvent(&entry.event) + generation := entry.timerGeneration + skipHostRenderer := entry.skipHostRenderer + state := s.stateLocked(nil) + s.mu.Unlock() + + s.publishState(state) + if updatingRenderer, ok := s.renderer.(UpdatingRenderer); ok && !skipHostRenderer { + if err = updatingRenderer.UpdateUI(context.Background(), &event); err != nil { + log.Warn().Err(err).Str("event_id", id).Msg("host UI renderer update failed") + } + } + if resetTimer { + s.attachTimer(id, generation, timeout) + } + return nil +} + +// Cancel resolves active event and notifies producer. +func (s *Service) Cancel(id string) error { + return s.resolve(id, models.UIOutcomeCancelled, true) +} + +// Shutdown prevents new events and cancels active request, if present. +func (s *Service) Shutdown() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + entry := s.active + if entry == nil { + s.mu.Unlock() + return + } + s.active = nil + stopActiveTimers(entry) + s.revision++ + resolution := models.UIResolution{ID: entry.event.ID, Outcome: models.UIOutcomeCancelled} + state := s.stateLocked([]models.UIResolution{resolution}) + s.mu.Unlock() + + closeRenderer(entry) + s.publishState(state) + s.deliverResult(entry, Result{Resolution: resolution}) +} + +func (s *Service) complete(id string, outcome models.UIOutcome) error { + if !producerOutcomeAllowed(outcome) { + return ErrInvalidOutcome + } + + s.mu.Lock() + entry, err := s.activeForIDLocked(id) + if err != nil { + s.mu.Unlock() + return err + } + if s.eventExpired(entry) { + state, resolution := s.expireLocked(entry) + s.mu.Unlock() + closeRenderer(entry) + s.publishState(state) + s.deliverResult(entry, Result{Resolution: resolution}) + return ErrEventExpired + } + + s.active = nil + stopActiveTimers(entry) + s.revision++ + resolution := models.UIResolution{ID: entry.event.ID, Outcome: outcome} + state := s.stateLocked([]models.UIResolution{resolution}) + s.mu.Unlock() + + closeRenderer(entry) + s.publishState(state) + return nil +} + +func (s *Service) resolve(id string, outcome models.UIOutcome, notifyProducer bool) error { + s.mu.Lock() + entry, err := s.activeForIDLocked(id) + if err != nil { + s.mu.Unlock() + return err + } + + s.active = nil + stopActiveTimers(entry) + s.revision++ + resolution := models.UIResolution{ID: entry.event.ID, Outcome: outcome} + state := s.stateLocked([]models.UIResolution{resolution}) + s.mu.Unlock() + + closeRenderer(entry) + s.publishState(state) + if notifyProducer { + s.deliverResult(entry, Result{Resolution: resolution}) + } + return nil +} + +func (s *Service) eventExpired(entry *activeEvent) bool { + return entry.event.ExpiresAt != nil && !s.clock.Now().Before(*entry.event.ExpiresAt) +} + +func (s *Service) expireLocked(entry *activeEvent) (models.UIStateResponse, models.UIResolution) { + s.active = nil + stopActiveTimers(entry) + s.revision++ + resolution := models.UIResolution{ID: entry.event.ID, Outcome: models.UIOutcomeTimedOut} + return s.stateLocked([]models.UIResolution{resolution}), resolution +} + +func (s *Service) activeForIDLocked(id string) (*activeEvent, error) { + if s.active == nil { + return nil, ErrNoActiveEvent + } + if s.active.event.ID != id { + return nil, ErrEventNotActive + } + return s.active, nil +} + +func (s *Service) stateLocked(resolved []models.UIResolution) models.UIStateResponse { + events := make([]models.UIEvent, 0, 1) + if s.active != nil { + events = append(events, cloneEvent(&s.active.event)) + } + if resolved == nil { + resolved = make([]models.UIResolution, 0) + } else { + resolved = append([]models.UIResolution(nil), resolved...) + } + return models.UIStateResponse{ + Events: events, + Resolved: resolved, + Revision: s.revision, + } +} + +func (s *Service) attachCancellation(ctx context.Context, id string) { + stop := context.AfterFunc(ctx, func() { + if err := s.resolve(id, models.UIOutcomeCancelled, true); err != nil && + !errors.Is(err, ErrNoActiveEvent) && !errors.Is(err, ErrEventNotActive) { + log.Warn().Err(err).Str("event_id", id).Msg("failed to cancel UI event from context") + } + }) + + s.mu.Lock() + if s.active != nil && s.active.event.ID == id { + s.active.stopContext = stop + s.mu.Unlock() + return + } + s.mu.Unlock() + stop() +} + +func (s *Service) attachTimer(id string, generation uint64, timeout time.Duration) { + if timeout <= 0 { + return + } + + timer := s.clock.AfterFunc(timeout, func() { + s.resolveTimeout(id, generation) + }) + + s.mu.Lock() + if s.active != nil && s.active.event.ID == id && s.active.timerGeneration == generation { + s.active.timer = timer + s.mu.Unlock() + return + } + s.mu.Unlock() + timer.Stop() +} + +func (s *Service) resolveTimeout(id string, generation uint64) { + s.mu.Lock() + if s.active == nil || s.active.event.ID != id || s.active.timerGeneration != generation { + s.mu.Unlock() + return + } + entry := s.active + s.active = nil + stopActiveTimers(entry) + s.revision++ + resolution := models.UIResolution{ID: id, Outcome: models.UIOutcomeTimedOut} + state := s.stateLocked([]models.UIResolution{resolution}) + s.mu.Unlock() + + closeRenderer(entry) + s.publishState(state) + s.deliverResult(entry, Result{Resolution: resolution}) +} + +func (s *Service) present(ctx context.Context, id string, event *models.UIEvent) { + if s.renderer == nil { + return + } + closeFn, err := s.renderer.PresentUI(ctx, event) + if err != nil { + log.Warn().Err(err).Str("event_id", id).Msg("host UI renderer failed") + } + if closeFn == nil { + return + } + + s.mu.Lock() + if s.active != nil && s.active.event.ID == id { + s.active.closeRenderer = closeFn + s.mu.Unlock() + return + } + s.mu.Unlock() + if err = closeFn(); err != nil { + log.Warn().Err(err).Str("event_id", id).Msg("failed to close stale host UI renderer") + } +} + +func closeRenderer(entry *activeEvent) { + if entry == nil || entry.closeRenderer == nil { + return + } + if err := entry.closeRenderer(); err != nil { + log.Warn().Err(err).Str("event_id", entry.event.ID).Msg("failed to close host UI renderer") + } +} + +func (s *Service) publishState(state models.UIStateResponse) { + if s.publish != nil { + s.publish(state) + } +} + +func (*Service) deliverResult(entry *activeEvent, result Result) { + if entry == nil { + return + } + entry.result <- result + close(entry.result) +} + +func newActiveEvent(request *Request, now time.Time) *activeEvent { + id := uuid.NewString() + publicChoices := make([]models.UIChoice, 0, len(request.Choices)) + privateChoices := make(map[string]Choice, len(request.Choices)) + selectedChoiceID := "" + + for i, choice := range request.Choices { + choiceID := uuid.NewString() + publicChoices = append(publicChoices, models.UIChoice{ID: choiceID, Label: choice.Label}) + privateChoices[choiceID] = choice + if i == request.SelectedChoice { + selectedChoiceID = choiceID + } + } + + var expiresAt *time.Time + if request.Timeout > 0 { + expires := now.Add(request.Timeout) + expiresAt = &expires + } + + return &activeEvent{ + choices: privateChoices, + result: make(chan Result, 1), + skipHostRenderer: request.SkipHostRenderer, + event: models.UIEvent{ + ExpiresAt: expiresAt, + ID: id, + Kind: request.Kind, + Title: request.Title, + Message: request.Message, + Choices: publicChoices, + SelectedChoiceID: selectedChoiceID, + CreatedAt: now, + Dismissible: request.Dismissible, + }, + timerGeneration: 1, + } +} + +func validateRequest(request *Request) error { + if request == nil { + return fmt.Errorf("%w: request is required", ErrInvalidRequest) + } + switch request.Kind { + case models.UIEventKindNotice, models.UIEventKindLoader, models.UIEventKindPicker, models.UIEventKindConfirm: + default: + return fmt.Errorf("%w: %q", ErrInvalidKind, request.Kind) + } + if len(request.Title) > MaxTitleBytes { + return fmt.Errorf("%w: title exceeds %d bytes", ErrInvalidRequest, MaxTitleBytes) + } + if len(request.Message) > MaxMessageBytes { + return fmt.Errorf("%w: message exceeds %d bytes", ErrInvalidRequest, MaxMessageBytes) + } + if len(request.Choices) > MaxChoices { + return fmt.Errorf("%w: choices exceed %d items", ErrInvalidRequest, MaxChoices) + } + if request.Kind == models.UIEventKindPicker && len(request.Choices) == 0 { + return fmt.Errorf("%w: picker requires at least one choice", ErrInvalidRequest) + } + if request.Kind != models.UIEventKindPicker && len(request.Choices) != 0 { + return fmt.Errorf("%w: choices require picker kind", ErrInvalidRequest) + } + for _, choice := range request.Choices { + if choice.Label == "" { + return fmt.Errorf("%w: choice label is required", ErrInvalidRequest) + } + if len(choice.Label) > MaxLabelBytes { + return fmt.Errorf("%w: choice label exceeds %d bytes", ErrInvalidRequest, MaxLabelBytes) + } + } + if len(request.Choices) > 0 && + (request.SelectedChoice < -1 || request.SelectedChoice >= len(request.Choices)) { + return fmt.Errorf("%w: selected choice is out of range", ErrInvalidRequest) + } + return nil +} + +func validateUpdate(update Update) error { + if update.Title != nil && len(*update.Title) > MaxTitleBytes { + return fmt.Errorf("%w: title exceeds %d bytes", ErrInvalidRequest, MaxTitleBytes) + } + if update.Message != nil && len(*update.Message) > MaxMessageBytes { + return fmt.Errorf("%w: message exceeds %d bytes", ErrInvalidRequest, MaxMessageBytes) + } + return nil +} + +func validateResponse( + entry *activeEvent, + action models.UIResponseAction, + choiceID string, +) (models.UIResolution, any, error) { + resolution := models.UIResolution{ID: entry.event.ID} + + switch action { + case models.UIResponseActionDismiss: + if !entry.event.Dismissible { + return models.UIResolution{}, nil, ErrNotDismissible + } + resolution.Outcome = models.UIOutcomeDismissed + return resolution, nil, nil + case models.UIResponseActionConfirm: + if entry.event.Kind != models.UIEventKindConfirm { + return models.UIResolution{}, nil, ErrInvalidAction + } + resolution.Outcome = models.UIOutcomeConfirmed + return resolution, nil, nil + case models.UIResponseActionSelect: + if entry.event.Kind != models.UIEventKindPicker { + return models.UIResolution{}, nil, ErrInvalidAction + } + if choiceID == "" { + return models.UIResolution{}, nil, ErrChoiceRequired + } + choice, ok := entry.choices[choiceID] + if !ok { + return models.UIResolution{}, nil, ErrChoiceNotFound + } + resolution.Outcome = models.UIOutcomeSelected + resolution.ChoiceID = choiceID + return resolution, choice.Value, nil + default: + return models.UIResolution{}, nil, ErrInvalidAction + } +} + +func producerOutcomeAllowed(outcome models.UIOutcome) bool { + switch outcome { + case models.UIOutcomeConfirmed, + models.UIOutcomeDismissed, + models.UIOutcomeCompleted, + models.UIOutcomeCancelled: + return true + default: + return false + } +} + +func stopActiveTimers(entry *activeEvent) { + if entry == nil { + return + } + entry.timerGeneration++ + if entry.timer != nil { + entry.timer.Stop() + entry.timer = nil + } + if entry.stopContext != nil { + entry.stopContext() + entry.stopContext = nil + } +} + +func cloneEvent(event *models.UIEvent) models.UIEvent { + cloned := *event + if event.ExpiresAt != nil { + expiresAt := *event.ExpiresAt + cloned.ExpiresAt = &expiresAt + } + cloned.Choices = append([]models.UIChoice(nil), event.Choices...) + return cloned +} + +func resolutionSlice(resolution *models.UIResolution) []models.UIResolution { + if resolution == nil { + return nil + } + return []models.UIResolution{*resolution} +} diff --git a/pkg/ui/events/service_test.go b/pkg/ui/events/service_test.go new file mode 100644 index 000000000..00ff7feeb --- /dev/null +++ b/pkg/ui/events/service_test.go @@ -0,0 +1,574 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package events + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testRenderer struct { + presentErr error + presented chan models.UIEvent + minDisplay time.Duration + closed atomic.Int32 + updated atomic.Int32 +} + +func (r *testRenderer) PresentUI(_ context.Context, event *models.UIEvent) (func() error, error) { + if r.presented != nil { + r.presented <- *event + } + if r.presentErr != nil { + return nil, r.presentErr + } + return func() error { + r.closed.Add(1) + return nil + }, nil +} + +func (r *testRenderer) UpdateUI(_ context.Context, _ *models.UIEvent) error { + r.updated.Add(1) + return nil +} + +func (r *testRenderer) MinimumUIDisplay(_ models.UIEventKind) time.Duration { + return r.minDisplay +} + +func newTestService( + clock clockwork.Clock, + renderer Renderer, +) (service *Service, states <-chan models.UIStateResponse) { + published := make(chan models.UIStateResponse, 20) + service = New(clock, renderer, func(state models.UIStateResponse) { + published <- state + }) + return service, published +} + +func receiveResult(t *testing.T, results <-chan Result) Result { + t.Helper() + select { + case result := <-results: + return result + case <-time.After(time.Second): + t.Fatal("timed out waiting for UI result") + return Result{} + } +} + +func receiveState(t *testing.T, states <-chan models.UIStateResponse) models.UIStateResponse { + t.Helper() + select { + case state := <-states: + return state + case <-time.After(time.Second): + t.Fatal("timed out waiting for UI state") + return models.UIStateResponse{} + } +} + +func TestServiceOpenAndSelect(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClockAt(time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)) + service, published := newTestService(clock, nil) + + handle, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindPicker, + Title: "Choose", + Message: "Pick one", + Choices: []Choice{ + {Label: "First", Value: "**launch:first"}, + {Label: "Second", Value: "**launch:second"}, + }, + SelectedChoice: 1, + Dismissible: true, + Timeout: 30 * time.Second, + }) + require.NoError(t, err) + + opened := receiveState(t, published) + assert.Equal(t, uint64(1), opened.Revision) + require.Len(t, opened.Events, 1) + assert.Empty(t, opened.Resolved) + event := opened.Events[0] + assert.Equal(t, handle.ID, event.ID) + assert.Equal(t, event.Choices[1].ID, event.SelectedChoiceID) + require.NotNil(t, event.ExpiresAt) + assert.Equal(t, clock.Now().Add(30*time.Second), *event.ExpiresAt) + + encoded, err := json.Marshal(event) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "**launch") + + err = service.Respond(handle.ID, models.UIResponseActionSelect, event.Choices[1].ID) + require.NoError(t, err) + + result := receiveResult(t, handle.Results) + assert.Equal(t, models.UIOutcomeSelected, result.Resolution.Outcome) + assert.Equal(t, event.Choices[1].ID, result.Resolution.ChoiceID) + assert.Equal(t, "**launch:second", result.Value) + + resolved := receiveState(t, published) + assert.Equal(t, uint64(2), resolved.Revision) + assert.Empty(t, resolved.Events) + require.Len(t, resolved.Resolved, 1) + assert.Equal(t, models.UIOutcomeSelected, resolved.Resolved[0].Outcome) + + query := service.State() + assert.Equal(t, uint64(2), query.Revision) + assert.NotNil(t, query.Events) + assert.NotNil(t, query.Resolved) + assert.Empty(t, query.Resolved) +} + +func TestServiceStateIsImmutable(t *testing.T) { + t.Parallel() + + service, _ := newTestService(clockwork.NewFakeClock(), nil) + _, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindPicker, + Choices: []Choice{{Label: "Original"}}, + }) + require.NoError(t, err) + + state := service.State() + state.Events[0].Choices[0].Label = "Mutated" + + fresh := service.State() + assert.Equal(t, "Original", fresh.Events[0].Choices[0].Label) +} + +func TestServiceSupersedesActiveEvent(t *testing.T) { + t.Parallel() + + renderer := &testRenderer{presented: make(chan models.UIEvent, 2)} + service, published := newTestService(clockwork.NewFakeClock(), renderer) + + first, err := service.Open(t.Context(), &Request{Kind: models.UIEventKindLoader}) + require.NoError(t, err) + _ = receiveState(t, published) + + second, err := service.Open(t.Context(), &Request{Kind: models.UIEventKindNotice, Dismissible: true}) + require.NoError(t, err) + + result := receiveResult(t, first.Results) + assert.Equal(t, models.UIOutcomeSuperseded, result.Resolution.Outcome) + + state := receiveState(t, published) + assert.Equal(t, uint64(2), state.Revision) + require.Len(t, state.Events, 1) + assert.Equal(t, second.ID, state.Events[0].ID) + require.Len(t, state.Resolved, 1) + assert.Equal(t, first.ID, state.Resolved[0].ID) + assert.Equal(t, models.UIOutcomeSuperseded, state.Resolved[0].Outcome) + assert.Equal(t, int32(1), renderer.closed.Load()) + + assert.NoError(t, first.Complete(models.UIOutcomeCompleted)) +} + +func TestServiceTimeoutAndReset(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClock() + service, published := newTestService(clock, nil) + handle, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindConfirm, + Timeout: 10 * time.Second, + }) + require.NoError(t, err) + _ = receiveState(t, published) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + + newTimeout := 20 * time.Second + require.NoError(t, handle.Update(Update{Timeout: &newTimeout})) + updated := receiveState(t, published) + assert.Equal(t, uint64(2), updated.Revision) + require.NotNil(t, updated.Events[0].ExpiresAt) + assert.Equal(t, clock.Now().Add(newTimeout), *updated.Events[0].ExpiresAt) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + + clock.Advance(10 * time.Second) + select { + case result := <-handle.Results: + t.Fatalf("event resolved before reset timeout: %+v", result) + default: + } + + clock.Advance(10 * time.Second) + result := receiveResult(t, handle.Results) + assert.Equal(t, models.UIOutcomeTimedOut, result.Resolution.Outcome) + + resolved := receiveState(t, published) + assert.Equal(t, uint64(3), resolved.Revision) + assert.Equal(t, models.UIOutcomeTimedOut, resolved.Resolved[0].Outcome) +} + +func TestServiceFirstResponseWins(t *testing.T) { + t.Parallel() + + service, _ := newTestService(clockwork.NewRealClock(), nil) + handle, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindConfirm, + Dismissible: true, + }) + require.NoError(t, err) + + var successes atomic.Int32 + var wg sync.WaitGroup + for range 50 { + wg.Add(1) + go func() { + defer wg.Done() + if service.Respond(handle.ID, models.UIResponseActionConfirm, "") == nil { + successes.Add(1) + } + }() + } + wg.Wait() + + assert.Equal(t, int32(1), successes.Load()) + assert.Equal(t, models.UIOutcomeConfirmed, receiveResult(t, handle.Results).Resolution.Outcome) +} + +func TestServiceValidatesResponses(t *testing.T) { + t.Parallel() + + service, _ := newTestService(clockwork.NewFakeClock(), nil) + handle, err := service.Open(t.Context(), &Request{Kind: models.UIEventKindLoader}) + require.NoError(t, err) + + require.ErrorIs(t, service.Respond(handle.ID, models.UIResponseActionDismiss, ""), ErrNotDismissible) + require.ErrorIs(t, service.Respond(handle.ID, models.UIResponseActionConfirm, ""), ErrInvalidAction) + require.ErrorIs(t, service.Respond(handle.ID, models.UIResponseActionSelect, "choice"), ErrInvalidAction) + require.ErrorIs(t, service.Respond(handle.ID, "unknown", ""), ErrInvalidAction) + require.ErrorIs(t, service.Respond("stale", models.UIResponseActionDismiss, ""), ErrEventNotActive) + require.NoError(t, handle.Complete(models.UIOutcomeCompleted)) + require.ErrorIs(t, service.Respond(handle.ID, models.UIResponseActionDismiss, ""), ErrNoActiveEvent) + + picker, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindPicker, + Choices: []Choice{{Label: "Game"}}, + Dismissible: true, + }) + require.NoError(t, err) + require.ErrorIs(t, service.Respond(picker.ID, models.UIResponseActionSelect, ""), ErrChoiceRequired) + require.ErrorIs(t, service.Respond(picker.ID, models.UIResponseActionSelect, "unknown"), ErrChoiceNotFound) + require.ErrorIs(t, service.Respond(picker.ID, models.UIResponseActionConfirm, ""), ErrInvalidAction) + require.NoError(t, picker.Complete(models.UIOutcomeCancelled)) +} + +func TestServiceRejectsOperationsAfterExpiry(t *testing.T) { + t.Parallel() + + tests := []struct { + resolve func(*Service, *Handle) error + name string + }{ + { + name: "API response", + resolve: func(service *Service, handle *Handle) error { + return service.Respond(handle.ID, models.UIResponseActionConfirm, "") + }, + }, + { + name: "producer completion", + resolve: func(_ *Service, handle *Handle) error { + return handle.Complete(models.UIOutcomeCompleted) + }, + }, + { + name: "producer update", + resolve: func(_ *Service, handle *Handle) error { + message := "too late" + return handle.Update(Update{Message: &message}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClock() + service, published := newTestService(clock, nil) + handle, err := service.Open(t.Context(), &Request{Kind: models.UIEventKindConfirm}) + require.NoError(t, err) + _ = receiveState(t, published) + + expiresAt := clock.Now() + service.mu.Lock() + service.active.event.ExpiresAt = &expiresAt + service.mu.Unlock() + + require.ErrorIs(t, tt.resolve(service, handle), ErrEventExpired) + assert.Equal(t, models.UIOutcomeTimedOut, receiveResult(t, handle.Results).Resolution.Outcome) + resolved := receiveState(t, published) + assert.Empty(t, resolved.Events) + require.Len(t, resolved.Resolved, 1) + assert.Equal(t, models.UIOutcomeTimedOut, resolved.Resolved[0].Outcome) + }) + } +} + +func TestServiceCancelAndProducerOutcomeValidation(t *testing.T) { + t.Parallel() + + service, _ := newTestService(clockwork.NewFakeClock(), nil) + handle, err := service.Open(t.Context(), &Request{Kind: models.UIEventKindLoader}) + require.NoError(t, err) + + require.ErrorIs(t, handle.Complete(models.UIOutcomeTimedOut), ErrInvalidOutcome) + require.Len(t, service.State().Events, 1) + require.NoError(t, service.Cancel(handle.ID)) + assert.Equal(t, models.UIOutcomeCancelled, receiveResult(t, handle.Results).Resolution.Outcome) + require.ErrorIs(t, service.Cancel(handle.ID), ErrNoActiveEvent) +} + +func TestServiceRendererFailureKeepsEventActive(t *testing.T) { + t.Parallel() + + renderer := &testRenderer{presentErr: errors.New("renderer unavailable")} + service, published := newTestService(clockwork.NewFakeClock(), renderer) + handle, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindNotice, + Dismissible: true, + }) + require.NoError(t, err) + _ = receiveState(t, published) + + state := service.State() + require.Len(t, state.Events, 1) + assert.Equal(t, handle.ID, state.Events[0].ID) + require.NoError(t, service.Respond(handle.ID, models.UIResponseActionDismiss, "")) +} + +func TestServiceSkipsHostRenderer(t *testing.T) { + t.Parallel() + + renderer := &testRenderer{ + presented: make(chan models.UIEvent, 1), + minDisplay: time.Second, + } + service, published := newTestService(clockwork.NewFakeClock(), renderer) + handle, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindConfirm, + SkipHostRenderer: true, + }) + require.NoError(t, err) + assert.Zero(t, handle.MinimumDisplay) + + opened := receiveState(t, published) + require.Len(t, opened.Events, 1) + assert.Equal(t, handle.ID, opened.Events[0].ID) + select { + case event := <-renderer.presented: + t.Fatalf("host rendered suppressed event: %+v", event) + default: + } + + message := "Updated" + require.NoError(t, handle.Update(Update{Message: &message})) + updated := receiveState(t, published) + assert.Equal(t, message, updated.Events[0].Message) + assert.Equal(t, int32(0), renderer.updated.Load()) + + require.NoError(t, handle.Complete(models.UIOutcomeCompleted)) + assert.Equal(t, int32(0), renderer.closed.Load()) +} + +func TestServiceContextCancellationAndShutdown(t *testing.T) { + t.Parallel() + + service, published := newTestService(clockwork.NewFakeClock(), nil) + ctx, cancel := context.WithCancel(context.Background()) + handle, err := service.Open(ctx, &Request{Kind: models.UIEventKindConfirm}) + require.NoError(t, err) + _ = receiveState(t, published) + + cancel() + assert.Equal(t, models.UIOutcomeCancelled, receiveResult(t, handle.Results).Resolution.Outcome) + _ = receiveState(t, published) + + second, err := service.Open(t.Context(), &Request{Kind: models.UIEventKindLoader}) + require.NoError(t, err) + _ = receiveState(t, published) + service.Shutdown() + assert.Equal(t, models.UIOutcomeCancelled, receiveResult(t, second.Results).Resolution.Outcome) + _ = receiveState(t, published) + + _, err = service.Open(t.Context(), &Request{Kind: models.UIEventKindNotice}) + require.ErrorIs(t, err, ErrClosed) +} + +func TestServiceUpdateNotifiesRenderer(t *testing.T) { + t.Parallel() + + clock := clockwork.NewFakeClock() + renderer := &testRenderer{} + service, published := newTestService(clock, renderer) + handle, err := service.Open(t.Context(), &Request{ + Kind: models.UIEventKindLoader, + Timeout: 10 * time.Second, + }) + require.NoError(t, err) + _ = receiveState(t, published) + require.NoError(t, clock.BlockUntilContext(t.Context(), 1)) + + title := "Download" + message := "Halfway" + dismissible := true + noTimeout := time.Duration(0) + require.NoError(t, handle.Update(Update{ + Title: &title, + Message: &message, + Timeout: &noTimeout, + Dismissible: &dismissible, + })) + state := receiveState(t, published) + require.Len(t, state.Events, 1) + assert.Equal(t, title, state.Events[0].Title) + assert.Equal(t, message, state.Events[0].Message) + assert.True(t, state.Events[0].Dismissible) + assert.Nil(t, state.Events[0].ExpiresAt) + assert.Equal(t, int32(1), renderer.updated.Load()) + + clock.Advance(20 * time.Second) + require.Len(t, service.State().Events, 1, "removing timeout must stop previous timer") + + revision := service.State().Revision + tooLong := strings.Repeat("x", MaxMessageBytes+1) + require.ErrorIs(t, handle.Update(Update{Message: &tooLong}), ErrInvalidRequest) + assert.Equal(t, revision, service.State().Revision) + + require.NoError(t, handle.Complete(models.UIOutcomeCompleted)) + require.ErrorIs(t, handle.Update(Update{Title: &title}), ErrNoActiveEvent) +} + +func TestValidateRequestLimitsAndKinds(t *testing.T) { + t.Parallel() + + tooManyChoices := make([]Choice, MaxChoices+1) + for i := range tooManyChoices { + tooManyChoices[i].Label = "Choice" + } + tests := []struct { + request *Request + wantErr error + name string + }{ + {name: "nil request", request: nil, wantErr: ErrInvalidRequest}, + {name: "unknown kind", request: &Request{Kind: "unknown"}, wantErr: ErrInvalidKind}, + { + name: "long title", + request: &Request{ + Kind: models.UIEventKindNotice, + Title: strings.Repeat("x", MaxTitleBytes+1), + }, + wantErr: ErrInvalidRequest, + }, + { + name: "long message", + request: &Request{ + Kind: models.UIEventKindNotice, + Message: strings.Repeat("x", MaxMessageBytes+1), + }, + wantErr: ErrInvalidRequest, + }, + { + name: "too many choices", + request: &Request{ + Kind: models.UIEventKindPicker, + Choices: tooManyChoices, + }, + wantErr: ErrInvalidRequest, + }, + { + name: "picker without choices", + request: &Request{Kind: models.UIEventKindPicker}, + wantErr: ErrInvalidRequest, + }, + { + name: "choices on notice", + request: &Request{ + Kind: models.UIEventKindNotice, + Choices: []Choice{{Label: "Choice"}}, + }, + wantErr: ErrInvalidRequest, + }, + { + name: "empty choice label", + request: &Request{ + Kind: models.UIEventKindPicker, + Choices: []Choice{{Label: ""}}, + }, + wantErr: ErrInvalidRequest, + }, + { + name: "long choice label", + request: &Request{ + Kind: models.UIEventKindPicker, + Choices: []Choice{{Label: strings.Repeat("x", MaxLabelBytes+1)}}, + }, + wantErr: ErrInvalidRequest, + }, + { + name: "selected choice below range", + request: &Request{ + Kind: models.UIEventKindPicker, + Choices: []Choice{{Label: "Choice"}}, + SelectedChoice: -2, + }, + wantErr: ErrInvalidRequest, + }, + { + name: "selected choice above range", + request: &Request{ + Kind: models.UIEventKindPicker, + Choices: []Choice{{Label: "Choice"}}, + SelectedChoice: 1, + }, + wantErr: ErrInvalidRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + service, _ := newTestService(clockwork.NewFakeClock(), nil) + _, err := service.Open(t.Context(), tt.request) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/pkg/ui/widgets/models/models.go b/pkg/ui/widgets/models/models.go index e355485d0..524b6ba0d 100644 --- a/pkg/ui/widgets/models/models.go +++ b/pkg/ui/widgets/models/models.go @@ -19,21 +19,31 @@ package models +import apimodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + type NoticeArgs struct { - Text string `json:"text"` - Complete string `json:"complete"` - Timeout int `json:"timeout"` + Text string `json:"text"` + Complete string `json:"complete"` + EventID string `json:"eventId,omitempty"` + Timeout int `json:"timeout"` + Dismissible bool `json:"dismissible,omitempty"` } type PickerItem struct { - Name string `json:"name"` - ZapScript string `json:"zapscript"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + ZapScript string `json:"zapscript,omitempty"` + Action apimodels.UIResponseAction `json:"action,omitempty"` } type PickerArgs struct { - Title string `json:"title"` - Items []PickerItem `json:"items"` - Selected int `json:"selected"` - Timeout int `json:"timeout"` - Unsafe bool `json:"unsafe"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + Complete string `json:"complete,omitempty"` + EventID string `json:"eventId,omitempty"` + Items []PickerItem `json:"items"` + Selected int `json:"selected"` + Timeout int `json:"timeout"` + Unsafe bool `json:"unsafe"` + Dismissible bool `json:"dismissible,omitempty"` } diff --git a/pkg/ui/widgets/widgets.go b/pkg/ui/widgets/widgets.go index a01c5b802..1d186dcbd 100644 --- a/pkg/ui/widgets/widgets.go +++ b/pkg/ui/widgets/widgets.go @@ -30,6 +30,7 @@ import ( "runtime" "strconv" "strings" + "sync/atomic" "syscall" "time" @@ -42,13 +43,17 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/rs/zerolog/log" + "github.com/spf13/afero" ) const ( - DefaultTimeout = 30 // seconds - PIDFilename = "widget.pid" + DefaultTimeout = 30 // seconds + PIDFilename = "widget.pid" + uiResponseTimeout = 5 * time.Second ) +type localClientFunc func(context.Context, *config.Instance, string, string) (string, error) + func runningFromZapScript() bool { return os.Getenv("ZAPAROO_RUN_SCRIPT") == "2" } @@ -146,7 +151,7 @@ func killWidgetIfRunning(pl platforms.Platform) (bool, error) { // handleTimeout creates a timer that exits the app after the specified timeout. // Prevents hanging widget processes if the parent application closes unexpectedly. -func handleTimeout(_ *tview.Application, timeout int) (timer *time.Timer, actualTimeout int) { +func handleTimeout(timeout int, onTimeout func()) (timer *time.Timer, actualTimeout int) { switch { case timeout == 0: actualTimeout = DefaultTimeout @@ -157,17 +162,106 @@ func handleTimeout(_ *tview.Application, timeout int) (timer *time.Timer, actual } timer = time.AfterFunc(time.Duration(actualTimeout)*time.Second, func() { + if onTimeout != nil { + onTimeout() + } os.Exit(0) }) return timer, actualTimeout } -func NoticeUIBuilder(_ platforms.Platform, argsPath string, loader bool) (*tview.Application, error) { +func sendUIResponse( + cfg *config.Instance, + eventID string, + action models.UIResponseAction, + choiceID string, + localClient localClientFunc, +) error { + return sendUIResponseWithTimeout( + cfg, eventID, action, choiceID, uiResponseTimeout, localClient, + ) +} + +func sendUIResponseWithTimeout( + cfg *config.Instance, + eventID string, + action models.UIResponseAction, + choiceID string, + timeout time.Duration, + localClient localClientFunc, +) error { + params, err := json.Marshal(models.UIRespondParams{ + ID: eventID, + Action: action, + ChoiceID: choiceID, + }) + if err != nil { + return fmt.Errorf("failed to marshal UI response: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if _, err = localClient(ctx, cfg, models.MethodUIRespond, string(params)); err != nil { + return fmt.Errorf("failed to send UI response: %w", err) + } + return nil +} + +func watchCompletion( + ctx context.Context, + app *tview.Application, + fs afero.Fs, + completePath string, + stopApp func(), +) { + if completePath == "" { + return + } + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := fs.Stat(completePath); err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + log.Error().Err(err).Msg("error checking UI completion file") + return + } + if err := fs.Remove(completePath); err != nil { + log.Error().Err(err).Msg("error removing UI completion file") + } + app.QueueUpdateDraw(stopApp) + return + } + } + }() +} + +func NoticeUIBuilder( + cfg *config.Instance, + pl platforms.Platform, + argsPath string, + loader bool, +) (*tview.Application, error) { + return buildNoticeUI(cfg, pl, afero.NewOsFs(), argsPath, loader, client.LocalClient) +} + +func buildNoticeUI( + cfg *config.Instance, + _ platforms.Platform, + fs afero.Fs, + argsPath string, + loader bool, + localClient localClientFunc, +) (*tview.Application, error) { var noticeArgs widgetmodels.NoticeArgs - //nolint:gosec // Safe: reads widget argument files from controlled directories - args, err := os.ReadFile(argsPath) + args, err := afero.ReadFile(fs, argsPath) if err != nil { return nil, fmt.Errorf("failed to read args file: %w", err) } @@ -182,6 +276,11 @@ func NoticeUIBuilder(_ platforms.Platform, argsPath string, loader bool) (*tview } app := tview.NewApplication() + watchCtx, cancelWatch := context.WithCancel(context.Background()) + stopApp := func() { + cancelWatch() + app.Stop() + } tui.ApplyTheme(tui.CurrentTheme()) view := tview.NewTextView(). @@ -196,35 +295,30 @@ func NoticeUIBuilder(_ platforms.Platform, argsPath string, loader bool) (*tview return x, y, w, h }) - handleTimeout(app, noticeArgs.Timeout) + handleTimeout(noticeArgs.Timeout, cancelWatch) + watchCompletion(watchCtx, app, fs, noticeArgs.Complete, stopApp) - ticker := time.NewTicker(1 * time.Second) - if noticeArgs.Complete != "" { + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() != tcell.KeyEsc && event.Rune() != 'q' && event.Key() != tcell.KeyEnter { + return event + } + if noticeArgs.EventID == "" { + stopApp() + return nil + } + if !noticeArgs.Dismissible { + return nil + } go func() { - for range ticker.C { - if _, err := os.Stat(noticeArgs.Complete); err != nil { - continue - } - log.Debug().Msg("notice complete file exists, stopping") - err := os.Remove(noticeArgs.Complete) - if err != nil { - log.Error().Err(err).Msg("error removing complete file") - } - app.QueueUpdateDraw(func() { - app.Stop() - }) - os.Exit(0) + if err := sendUIResponse( + cfg, noticeArgs.EventID, models.UIResponseActionDismiss, "", localClient, + ); err != nil { + log.Error().Err(err).Msg("failed to dismiss UI notice") + return } + app.QueueUpdateDraw(stopApp) }() - } - - app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc || - event.Rune() == 'q' || - event.Key() == tcell.KeyEnter { - app.Stop() - } - return event + return nil }) centeredPages := tui.CenterWidget(75, 15, view) @@ -232,7 +326,7 @@ func NoticeUIBuilder(_ platforms.Platform, argsPath string, loader bool) (*tview } // NoticeUI displays a message with an optional loading spinner. -func NoticeUI(pl platforms.Platform, argsPath string, loader bool) error { +func NoticeUI(cfg *config.Instance, pl platforms.Platform, argsPath string, loader bool) error { log.Info().Str("args", argsPath).Msg("showing notice") pidFileCreated := false @@ -274,7 +368,7 @@ func NoticeUI(pl platforms.Platform, argsPath string, loader bool) error { } err := tui.BuildAndRetry(nil, func() (*tview.Application, error) { - return NoticeUIBuilder(pl, argsPath, loader) + return NoticeUIBuilder(cfg, pl, argsPath, loader) }) log.Debug().Msg("exiting notice widget") if err != nil { @@ -283,9 +377,22 @@ func NoticeUI(pl platforms.Platform, argsPath string, loader bool) error { return nil } -func PickerUIBuilder(cfg *config.Instance, _ platforms.Platform, argsPath string) (*tview.Application, error) { - //nolint:gosec // Safe: reads widget argument files from controlled directories - args, err := os.ReadFile(argsPath) +func PickerUIBuilder( + cfg *config.Instance, + pl platforms.Platform, + argsPath string, +) (*tview.Application, error) { + return buildPickerUI(cfg, pl, afero.NewOsFs(), argsPath, client.LocalClient) +} + +func buildPickerUI( + cfg *config.Instance, + _ platforms.Platform, + fs afero.Fs, + argsPath string, + localClient localClientFunc, +) (*tview.Application, error) { + args, err := afero.ReadFile(fs, argsPath) if err != nil { return nil, fmt.Errorf("failed to read picker args file: %w", err) } @@ -301,29 +408,70 @@ func PickerUIBuilder(cfg *config.Instance, _ platforms.Platform, argsPath string } app := tview.NewApplication() + watchCtx, cancelWatch := context.WithCancel(context.Background()) + stopApp := func() { + cancelWatch() + app.Stop() + } tui.ApplyTheme(tui.CurrentTheme()) + var responseInFlight atomic.Bool + sendResponse := func(action models.UIResponseAction, choiceID, errorMessage string) { + if !responseInFlight.CompareAndSwap(false, true) { + return + } + go func() { + if responseErr := sendUIResponse( + cfg, pickerArgs.EventID, action, choiceID, localClient, + ); responseErr != nil { + log.Error().Err(responseErr).Msg(errorMessage) + responseInFlight.Store(false) + return + } + app.QueueUpdateDraw(stopApp) + }() + } + run := func(item widgetmodels.PickerItem) { log.Info().Msgf("running picker selection: %v", item) + if pickerArgs.EventID != "" { + action := item.Action + if action == "" { + action = models.UIResponseActionSelect + } + sendResponse(action, item.ID, "failed to send picker response") + return + } + zsrp := models.RunParams{ Text: &item.ZapScript, Unsafe: pickerArgs.Unsafe, } - ps, err := json.Marshal(zsrp) - if err != nil { - log.Error().Err(err).Msg("error creating run params") - app.Stop() + ps, marshalErr := json.Marshal(zsrp) + if marshalErr != nil { + log.Error().Err(marshalErr).Msg("error creating run params") + stopApp() return } - _, err = client.LocalClient(context.Background(), cfg, models.MethodRun, string(ps)) - if err != nil { - log.Error().Err(err).Msg("error running local client") + if _, runErr := localClient(context.Background(), cfg, models.MethodRun, string(ps)); runErr != nil { + log.Error().Err(runErr).Msg("error running local client") } - app.Stop() + stopApp() + } + + dismiss := func() { + if pickerArgs.EventID == "" { + stopApp() + return + } + if !pickerArgs.Dismissible { + return + } + sendResponse(models.UIResponseActionDismiss, "", "failed to dismiss picker") } flex := tview.NewFlex().SetDirection(tview.FlexRow) @@ -344,6 +492,9 @@ func PickerUIBuilder(cfg *config.Instance, _ platforms.Platform, argsPath string titleText := tview.NewTextView(). SetText(title). SetTextAlign(tview.AlignCenter) + messageText := tview.NewTextView(). + SetText(pickerArgs.Message). + SetTextAlign(tview.AlignCenter) padding := tview.NewTextView() list := tview.NewList() @@ -352,6 +503,9 @@ func PickerUIBuilder(cfg *config.Instance, _ platforms.Platform, argsPath string if strings.TrimSpace(title) != "" { flex.AddItem(titleText, 1, 0, false) } + if strings.TrimSpace(pickerArgs.Message) != "" { + flex.AddItem(messageText, 2, 0, false) + } flex.AddItem(padding, 1, 0, false) flex.AddItem(list, 0, 1, true) @@ -386,20 +540,21 @@ func PickerUIBuilder(cfg *config.Instance, _ platforms.Platform, argsPath string list.SetCurrentItem(pickerArgs.Selected) } - list.AddItem("Cancel", "", 0, func() { - app.Stop() - }) + if pickerArgs.EventID == "" || pickerArgs.Dismissible { + list.AddItem("Cancel", "", 0, dismiss) + } - timer, cto := handleTimeout(app, pickerArgs.Timeout) + timer, cto := handleTimeout(pickerArgs.Timeout, cancelWatch) + watchCompletion(watchCtx, app, fs, pickerArgs.Complete, stopApp) list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEsc || event.Rune() == 'q' { - app.Stop() + dismiss() } if timer != nil { timer.Stop() } - timer, cto = handleTimeout(app, cto) + timer, cto = handleTimeout(cto, cancelWatch) return event }) diff --git a/pkg/ui/widgets/widgets_test.go b/pkg/ui/widgets/widgets_test.go new file mode 100644 index 000000000..429a3319b --- /dev/null +++ b/pkg/ui/widgets/widgets_test.go @@ -0,0 +1,320 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package widgets + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeWidgetArgs(t *testing.T, fs afero.Fs, name string, value any) string { + t.Helper() + + data, err := json.Marshal(value) + require.NoError(t, err) + path := filepath.Join("tmp", name) + require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, afero.WriteFile(fs, path, data, 0o600)) + return path +} + +func startWidgetApp( + t *testing.T, + app *tview.Application, +) (screen tcell.SimulationScreen, done <-chan error) { + t.Helper() + + screen = tcell.NewSimulationScreen("UTF-8") + require.NoError(t, screen.Init()) + screen.SetSize(80, 24) + app.SetScreen(screen) + doneCh := make(chan error, 1) + go func() { + doneCh <- app.Run() + }() + done = doneCh + return screen, done +} + +func waitForWidgetExit(t *testing.T, done <-chan error) { + t.Helper() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("widget did not stop") + } +} + +func TestSendUIResponseUsesBoundedContextAndPayload(t *testing.T) { + t.Parallel() + + expectedErr := errors.New("request failed") + tests := []struct { + clientErr error + action models.UIResponseAction + choiceID string + name string + }{ + {name: "dismiss", action: models.UIResponseActionDismiss}, + {name: "select", action: models.UIResponseActionSelect, choiceID: "choice-1"}, + {name: "client error", action: models.UIResponseActionConfirm, clientErr: expectedErr}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := sendUIResponse( + &config.Instance{}, "event-1", tt.action, tt.choiceID, + func(ctx context.Context, _ *config.Instance, method, params string) (string, error) { + _, hasDeadline := ctx.Deadline() + assert.True(t, hasDeadline) + assert.Equal(t, models.MethodUIRespond, method) + var response models.UIRespondParams + require.NoError(t, json.Unmarshal([]byte(params), &response)) + assert.Equal(t, "event-1", response.ID) + assert.Equal(t, tt.action, response.Action) + assert.Equal(t, tt.choiceID, response.ChoiceID) + return "", tt.clientErr + }, + ) + if tt.clientErr != nil { + require.ErrorIs(t, err, expectedErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestSendUIResponseReturnsClientTimeout(t *testing.T) { + t.Parallel() + + contextExpired := false + err := sendUIResponseWithTimeout( + &config.Instance{}, + "event-1", + models.UIResponseActionConfirm, + "", + 10*time.Millisecond, + func(ctx context.Context, _ *config.Instance, _, _ string) (string, error) { + <-ctx.Done() + contextExpired = errors.Is(ctx.Err(), context.DeadlineExceeded) + return "", ctx.Err() + }, + ) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.True(t, contextExpired) +} + +func TestWatchCompletionRemovesFileAndStopsApp(t *testing.T) { + fs := testhelpers.NewMemoryFS() + completePath := filepath.Join("tmp", "notice.complete") + require.NoError(t, fs.Fs.MkdirAll(filepath.Dir(completePath), 0o755)) + require.NoError(t, afero.WriteFile(fs.Fs, completePath, []byte{}, 0o600)) + + app := tview.NewApplication().SetRoot(tview.NewTextView(), true) + watchCtx, cancelWatch := context.WithCancel(t.Context()) + defer cancelWatch() + stopApp := func() { + cancelWatch() + app.Stop() + } + _, done := startWidgetApp(t, app) + watchCompletion(watchCtx, app, fs.Fs, completePath, stopApp) + waitForWidgetExit(t, done) + + exists, err := afero.Exists(fs.Fs, completePath) + require.NoError(t, err) + assert.False(t, exists) +} + +func TestNoticeUIDismissResponseControlsShutdown(t *testing.T) { + tests := []struct { + clientErr error + name string + }{ + {name: "success"}, + {name: "failure", clientErr: errors.New("request failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := testhelpers.NewMemoryFS() + argsPath := writeWidgetArgs(t, fs.Fs, "notice.json", widgetmodels.NoticeArgs{ + Text: "Notice", + EventID: "event-1", + Timeout: -1, + Dismissible: true, + }) + called := make(chan models.UIRespondParams, 1) + app, err := buildNoticeUI( + &config.Instance{}, nil, fs.Fs, argsPath, false, + func(_ context.Context, _ *config.Instance, method, params string) (string, error) { + if method != models.MethodUIRespond { + return "", fmt.Errorf("unexpected method: %s", method) + } + var response models.UIRespondParams + if unmarshalErr := json.Unmarshal([]byte(params), &response); unmarshalErr != nil { + return "", fmt.Errorf("unmarshal UI response: %w", unmarshalErr) + } + called <- response + return "", tt.clientErr + }, + ) + require.NoError(t, err) + screen, done := startWidgetApp(t, app) + require.NoError(t, screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))) + + select { + case response := <-called: + assert.Equal(t, models.UIResponseActionDismiss, response.Action) + case <-time.After(time.Second): + t.Fatal("notice did not send dismiss response") + } + if tt.clientErr == nil { + waitForWidgetExit(t, done) + return + } + select { + case err = <-done: + t.Fatalf("notice stopped after failed response: %v", err) + case <-time.After(100 * time.Millisecond): + } + app.Stop() + waitForWidgetExit(t, done) + }) + } +} + +func TestPickerUIResponses(t *testing.T) { + tests := []struct { + clientErr error + key *tcell.EventKey + action models.UIResponseAction + choiceID string + name string + selected int + }{ + { + name: "select success", + key: tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), + action: models.UIResponseActionSelect, + choiceID: "choice-1", + }, + { + name: "dismiss success", + key: tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone), + action: models.UIResponseActionDismiss, + }, + { + name: "select failure", + key: tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), + action: models.UIResponseActionSelect, + choiceID: "choice-1", + clientErr: errors.New("request failed"), + }, + { + name: "negative selection falls back to first", + key: tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), + action: models.UIResponseActionSelect, + choiceID: "choice-1", + selected: -1, + }, + { + name: "selection past end falls back to first", + key: tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), + action: models.UIResponseActionSelect, + choiceID: "choice-1", + selected: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := testhelpers.NewMemoryFS() + argsPath := writeWidgetArgs(t, fs.Fs, "picker.json", widgetmodels.PickerArgs{ + Title: "Pick", + EventID: "event-1", + Items: []widgetmodels.PickerItem{ + {ID: "choice-1", Name: "Game One"}, + {ID: "choice-2", Name: "Game Two"}, + }, + Selected: tt.selected, + Timeout: -1, + Dismissible: true, + }) + called := make(chan models.UIRespondParams, 1) + app, err := buildPickerUI( + &config.Instance{}, nil, fs.Fs, argsPath, + func(_ context.Context, _ *config.Instance, method, params string) (string, error) { + if method != models.MethodUIRespond { + return "", fmt.Errorf("unexpected method: %s", method) + } + var response models.UIRespondParams + if unmarshalErr := json.Unmarshal([]byte(params), &response); unmarshalErr != nil { + return "", fmt.Errorf("unmarshal UI response: %w", unmarshalErr) + } + called <- response + return "", tt.clientErr + }, + ) + require.NoError(t, err) + screen, done := startWidgetApp(t, app) + require.NoError(t, screen.PostEvent(tt.key)) + + select { + case response := <-called: + assert.Equal(t, tt.action, response.Action) + assert.Equal(t, tt.choiceID, response.ChoiceID) + case <-time.After(time.Second): + t.Fatal("picker did not send UI response") + } + if tt.clientErr == nil { + waitForWidgetExit(t, done) + return + } + select { + case err = <-done: + t.Fatalf("picker stopped after failed response: %v", err) + case <-time.After(100 * time.Millisecond): + } + app.Stop() + waitForWidgetExit(t, done) + }) + } +} diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go index d7f41d099..625309ef3 100644 --- a/pkg/zapscript/commands.go +++ b/pkg/zapscript/commands.go @@ -40,12 +40,21 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript/advargs" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript/titles" "github.com/rs/zerolog/log" "github.com/spf13/afero" ) +// RunCommandOptions groups optional services used by specific command types. +type RunCommandOptions struct { + WaitForMediaReady func(context.Context) error + PlaybackManager audio.PlaybackManager + UI *uievents.Service + LauncherManager *state.LauncherManager +} + var ( ErrArgCount = errors.New("invalid number of arguments") ErrRequiredArgs = errors.New("arguments are required") @@ -390,9 +399,6 @@ func GetExprEnv( } // RunCommand parses and runs a single ZapScript command. -// The lm parameter is only needed for media-launching commands (launch guard); -// pass nil for contexts where media launches are not allowed (e.g. control scripts). - func RunCommand( serviceCtx context.Context, pl platforms.Platform, @@ -403,9 +409,7 @@ func RunCommand( totalCmds int, currentIndex int, db *database.Database, - lm *state.LauncherManager, - waitForMediaReady func(context.Context) error, - playbackManager audio.PlaybackManager, + opts RunCommandOptions, exprEnv *zapscript.ArgExprEnv, ) (platforms.CmdResult, error) { unsafe := token.Unsafe @@ -470,8 +474,9 @@ func RunCommand( Cmd: cmd, Cfg: cfg, ServiceCtx: serviceCtx, - WaitForMediaReady: waitForMediaReady, - PlaybackManager: playbackManager, + WaitForMediaReady: opts.WaitForMediaReady, + PlaybackManager: opts.PlaybackManager, + UI: opts.UI, Playlist: plsc, Source: token.Source, TotalCommands: totalCmds, @@ -481,8 +486,8 @@ func RunCommand( ExprEnv: exprEnv, } - if lm != nil { - env.LauncherCtx = lm.GetContext() + if opts.LauncherManager != nil { + env.LauncherCtx = opts.LauncherManager.GetContext() } cmdFn, ok := lookupCmd(cmd.Name) @@ -496,14 +501,14 @@ func RunCommand( // Acquire launch guard for media-launching commands to prevent concurrent launches if IsMediaLaunchingCommand(cmd.Name) { - if lm == nil { + if opts.LauncherManager == nil { return platforms.CmdResult{}, errors.New("launcher manager required for media-launching commands") } - if guardErr := lm.TryStartLaunch(); guardErr != nil { + if guardErr := opts.LauncherManager.TryStartLaunch(); guardErr != nil { return platforms.CmdResult{}, fmt.Errorf("launch guard: %w", guardErr) } - defer lm.EndLaunch() - env.LauncherCtx = lm.GetContext() + defer opts.LauncherManager.EndLaunch() + env.LauncherCtx = opts.LauncherManager.GetContext() } logCmd := cmd.String() diff --git a/pkg/zapscript/commands_test.go b/pkg/zapscript/commands_test.go index 7cec9e59f..0a80c37e0 100644 --- a/pkg/zapscript/commands_test.go +++ b/pkg/zapscript/commands_test.go @@ -25,9 +25,16 @@ import ( "github.com/ZaparooProject/go-zapscript" apimodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func TestIsMediaLaunchingCommand(t *testing.T) { @@ -611,6 +618,35 @@ func TestGetExprEnv_NoActiveMedia(t *testing.T) { assert.Empty(t, env.ActiveMedia.Path) } +func TestRunCommandInjectsUIService(t *testing.T) { + t.Parallel() + + ui := uievents.New(nil, nil, nil) + mockPlatform := mocks.NewMockPlatform() + var receivedUI *uievents.Service + mockPlatform.On("ForwardCmd", mock.MatchedBy(func(env *platforms.CmdEnv) bool { + receivedUI = env.UI + return true + })).Return(platforms.CmdResult{}, nil).Once() + + _, err := RunCommand( + t.Context(), + mockPlatform, + &config.Instance{}, + playlists.PlaylistController{}, + tokens.Token{}, + zapscript.Command{Name: zapscript.ZapScriptCmdMisterINI}, + 1, + 0, + &database.Database{}, + RunCommandOptions{UI: ui}, + &zapscript.ArgExprEnv{}, + ) + require.NoError(t, err) + assert.Same(t, ui, receivedUI) + mockPlatform.AssertExpectations(t) +} + func TestIsValidCommand(t *testing.T) { t.Parallel() diff --git a/pkg/zapscript/control.go b/pkg/zapscript/control.go index 7899c16c5..9cf6f2006 100644 --- a/pkg/zapscript/control.go +++ b/pkg/zapscript/control.go @@ -120,9 +120,7 @@ func RunControlScript( len(parsed.Cmds), i, db, - nil, // lm not needed — control commands cannot launch media - nil, - nil, + RunCommandOptions{}, // Control commands use no optional launch or UI services. &env, ) if err != nil { diff --git a/pkg/zapscript/launch.go b/pkg/zapscript/launch.go index 7941829cc..edd7ba8c8 100644 --- a/pkg/zapscript/launch.go +++ b/pkg/zapscript/launch.go @@ -698,7 +698,7 @@ func cmdLaunch(pl platforms.Platform, env platforms.CmdEnv) (platforms.CmdResult if dler, ok := isValidRemoteFileURL(path); ok && args.System != "" { installPath, err := installer.InstallRemoteFile( env.LauncherCtx, - env.Cfg, pl, + env.Cfg, pl, env.UI, path, args.System, args.PreNotice, diff --git a/pkg/zapscript/playlist.go b/pkg/zapscript/playlist.go index f3a32b28b..0ea227803 100644 --- a/pkg/zapscript/playlist.go +++ b/pkg/zapscript/playlist.go @@ -20,6 +20,7 @@ package zapscript import ( + "context" "encoding/json" "errors" "fmt" @@ -33,12 +34,14 @@ import ( "time" "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/client" + apimodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mediaslot" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" - widgetmodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" "github.com/rs/zerolog/log" "github.com/spf13/afero" ) @@ -570,7 +573,7 @@ func cmdPlaylistOpen(pl platforms.Platform, env platforms.CmdEnv) (platforms.Cmd } } - items := make([]widgetmodels.PickerItem, 0, len(pls.Items)) + choices := make([]uievents.Choice, 0, len(pls.Items)) for i, m := range pls.Items { var name string @@ -592,9 +595,9 @@ func cmdPlaylistOpen(pl platforms.Platform, env platforms.CmdEnv) (platforms.Cmd zapscript := "**playlist.goto:" + strconv.Itoa(i+1) + "?slot=" + slot + "||**playlist.play?slot=" + slot - items = append(items, widgetmodels.PickerItem{ - Name: name, - ZapScript: zapscript, + choices = append(choices, uievents.Choice{ + Label: name, + Value: zapscript, }) } @@ -603,20 +606,52 @@ func cmdPlaylistOpen(pl platforms.Platform, env platforms.CmdEnv) (platforms.Cmd return platforms.CmdResult{}, err } - if err := pl.ShowPicker(env.Cfg, widgetmodels.PickerArgs{ - Title: pls.Name, - Items: items, - Selected: pls.Index, - }); err != nil { - return platforms.CmdResult{ - PlaylistChanged: true, - Playlist: pls, - }, fmt.Errorf("failed to show picker: %w", err) - } - return platforms.CmdResult{ + result := platforms.CmdResult{ PlaylistChanged: true, Playlist: pls, - }, nil + } + if env.UI == nil { + log.Warn().Msg("UI event service unavailable, skipping playlist picker") + return result, nil + } + handle, openErr := env.UI.Open(env.ServiceCtx, &uievents.Request{ + Kind: apimodels.UIEventKindPicker, + Title: pls.Name, + Choices: choices, + SelectedChoice: pls.Index, + Timeout: 30 * time.Second, + Dismissible: true, + }) + if openErr != nil { + log.Warn().Err(openErr).Msg("failed to open playlist picker") + return result, nil + } + go runPickerResult(env.Cfg, handle, client.LocalClient) + return result, nil +} + +func runPickerResult( + cfg *config.Instance, + handle *uievents.Handle, + run func(context.Context, *config.Instance, string, string) (string, error), +) { + result, ok := <-handle.Results + if !ok || result.Resolution.Outcome != apimodels.UIOutcomeSelected { + return + } + script, ok := result.Value.(string) + if !ok || script == "" { + log.Error().Str("event_id", handle.ID).Msg("picker returned invalid private action") + return + } + params, err := json.Marshal(apimodels.RunParams{Text: &script}) + if err != nil { + log.Error().Err(err).Str("event_id", handle.ID).Msg("failed to marshal picker action") + return + } + if _, err = run(context.Background(), cfg, apimodels.MethodRun, string(params)); err != nil { + log.Error().Err(err).Str("event_id", handle.ID).Msg("failed to run picker action") + } } //nolint:gocritic // single-use parameter in command handler diff --git a/pkg/zapscript/playlist_test.go b/pkg/zapscript/playlist_test.go index e0662a324..e2ca4cc98 100644 --- a/pkg/zapscript/playlist_test.go +++ b/pkg/zapscript/playlist_test.go @@ -21,12 +21,15 @@ package zapscript import ( "context" + "encoding/json" + "fmt" "os" "path/filepath" "testing" "time" "github.com/ZaparooProject/go-zapscript" + apimodels "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" "github.com/ZaparooProject/zaparoo-core/v2/pkg/audio" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" @@ -34,7 +37,8 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mediaslot" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" - "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/widgets/models" + uievents "github.com/ZaparooProject/zaparoo-core/v2/pkg/ui/events" + "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -47,6 +51,15 @@ func newPlaylistTestPlatform() *mocks.MockPlatform { return mp } +func selectedChoiceIndex(event *apimodels.UIEvent) int { + for i, choice := range event.Choices { + if choice.ID == event.SelectedChoiceID { + return i + } + } + return -1 +} + func TestQueuePlaylistUpdateReturnsWhenContextCancelled(t *testing.T) { t.Parallel() @@ -209,20 +222,17 @@ func TestCmdPlaylistOpen_NoArgs(t *testing.T) { mockPlatform := newPlaylistTestPlatform() cfg := &config.Instance{} - // Mock ShowPicker if we expect it to be called + var ui *uievents.Service if tt.expectPickerCall { - mockPlatform.On("ShowPicker", cfg, mock.MatchedBy(func(args models.PickerArgs) bool { - // Verify picker shows the active playlist - return args.Title == tt.activePlaylist.Name && - len(args.Items) == len(tt.activePlaylist.Items) && - args.Selected == tt.activePlaylist.Index - })).Return(nil) + ui = uievents.New(clockwork.NewFakeClock(), nil, nil) } // Create playlist queue channel playlistQueue := make(chan *playlists.Playlist, 1) env := platforms.CmdEnv{ + ServiceCtx: t.Context(), + UI: ui, Cmd: zapscript.Command{ Name: "playlist.open", Args: []string{}, // No arguments! @@ -252,12 +262,207 @@ func TestCmdPlaylistOpen_NoArgs(t *testing.T) { t.Fatal("expected playlist to be queued") } - mockPlatform.AssertExpectations(t) + state := ui.State() + require.Len(t, state.Events, 1) + assert.Equal(t, tt.activePlaylist.Index, selectedChoiceIndex(&state.Events[0])) + require.NoError(t, ui.Respond( + state.Events[0].ID, apimodels.UIResponseActionDismiss, "", + )) } }) } } +func TestCmdPlaylistOpen_UsesGlobalUIEvent(t *testing.T) { + t.Parallel() + + active := &playlists.Playlist{ + ID: "test-playlist", + Name: "Test Playlist", + Items: []playlists.PlaylistItem{ + {Name: "Item 1", ZapScript: "**test1"}, + {Name: "Item 2", ZapScript: "**test2"}, + }, + Index: 1, + } + ui := uievents.New(clockwork.NewFakeClock(), nil, nil) + queue := make(chan *playlists.Playlist, 1) + env := platforms.CmdEnv{ + ServiceCtx: t.Context(), + UI: ui, + Cmd: zapscript.Command{Name: "playlist.open"}, + Cfg: &config.Instance{}, + Playlist: playlists.PlaylistController{ + Active: active, + Queue: queue, + }, + } + + result, err := cmdPlaylistOpen(newPlaylistTestPlatform(), env) + require.NoError(t, err) + assert.True(t, result.PlaylistChanged) + + state := ui.State() + require.Len(t, state.Events, 1) + event := state.Events[0] + assert.Equal(t, apimodels.UIEventKindPicker, event.Kind) + assert.Equal(t, "Test Playlist", event.Title) + require.Len(t, event.Choices, 2) + assert.Equal(t, event.Choices[1].ID, event.SelectedChoiceID) + + encoded, err := json.Marshal(event) + require.NoError(t, err) + assert.NotContains(t, string(encoded), "**test") + require.NoError(t, ui.Respond(event.ID, apimodels.UIResponseActionDismiss, "")) +} + +func TestCmdPlaylistOpen_PickerUnavailableKeepsPlaylistUpdate(t *testing.T) { + t.Parallel() + + tests := []struct { + ui func() *uievents.Service + name string + }{ + {name: "missing service"}, + { + name: "closed service", + ui: func() *uievents.Service { + service := uievents.New(clockwork.NewFakeClock(), nil, nil) + service.Shutdown() + return service + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ui *uievents.Service + if tt.ui != nil { + ui = tt.ui() + } + active := &playlists.Playlist{ + ID: "test-playlist", + Name: "Test Playlist", + Items: []playlists.PlaylistItem{{Name: "Item", ZapScript: "**test"}}, + } + queue := make(chan *playlists.Playlist, 1) + env := platforms.CmdEnv{ + ServiceCtx: t.Context(), + UI: ui, + Cmd: zapscript.Command{Name: "playlist.open"}, + Cfg: &config.Instance{}, + Playlist: playlists.PlaylistController{ + Active: active, + Queue: queue, + }, + } + + result, err := cmdPlaylistOpen(newPlaylistTestPlatform(), env) + require.NoError(t, err) + assert.True(t, result.PlaylistChanged) + assert.Equal(t, active, result.Playlist) + select { + case queued := <-queue: + assert.Equal(t, active, queued) + case <-time.After(time.Second): + t.Fatal("playlist update was not queued") + } + if ui != nil { + assert.Empty(t, ui.State().Events) + } + }) + } +} + +func TestRunPickerResult_ExecutesPrivateActionOnce(t *testing.T) { + t.Parallel() + + ui := uievents.New(clockwork.NewFakeClock(), nil, nil) + handle, err := ui.Open(t.Context(), &uievents.Request{ + Kind: apimodels.UIEventKindPicker, + Choices: []uievents.Choice{{Label: "Game", Value: "**launch:game"}}, + }) + require.NoError(t, err) + + called := make(chan apimodels.RunParams, 1) + done := make(chan struct{}) + go func() { + defer close(done) + runPickerResult(&config.Instance{}, handle, func( + _ context.Context, + _ *config.Instance, + method string, + params string, + ) (string, error) { + assert.Equal(t, apimodels.MethodRun, method) + var runParams apimodels.RunParams + if unmarshalErr := json.Unmarshal([]byte(params), &runParams); unmarshalErr != nil { + assert.NoError(t, unmarshalErr) + return "", fmt.Errorf("unmarshal picker params: %w", unmarshalErr) + } + called <- runParams + return "", nil + }) + }() + + event := ui.State().Events[0] + require.NoError(t, ui.Respond( + event.ID, apimodels.UIResponseActionSelect, event.Choices[0].ID, + )) + + select { + case params := <-called: + require.NotNil(t, params.Text) + assert.Equal(t, "**launch:game", *params.Text) + case <-time.After(time.Second): + t.Fatal("picker action was not executed") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("picker result handler did not exit") + } +} + +func TestRunPickerResult_DismissExecutesNothing(t *testing.T) { + t.Parallel() + + ui := uievents.New(clockwork.NewFakeClock(), nil, nil) + handle, err := ui.Open(t.Context(), &uievents.Request{ + Kind: apimodels.UIEventKindPicker, + Choices: []uievents.Choice{{Label: "Game", Value: "**launch:game"}}, + Dismissible: true, + }) + require.NoError(t, err) + + called := make(chan struct{}, 1) + done := make(chan struct{}) + go func() { + defer close(done) + runPickerResult(&config.Instance{}, handle, func( + context.Context, *config.Instance, string, string, + ) (string, error) { + called <- struct{}{} + return "", nil + }) + }() + + event := ui.State().Events[0] + require.NoError(t, ui.Respond(event.ID, apimodels.UIResponseActionDismiss, "")) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("picker result handler did not exit") + } + select { + case <-called: + t.Fatal("dismissed picker executed an action") + default: + } +} + // TestCmdPlaylistOpen_PreservesPosition tests that position is preserved when reopening active playlist func TestCmdPlaylistOpen_PreservesPosition(t *testing.T) { t.Parallel() @@ -291,15 +496,12 @@ Title3=Item 3` Playing: true, } - // Mock ShowPicker - verify it's called with preserved index - mockPlatform.On("ShowPicker", cfg, mock.MatchedBy(func(args models.PickerArgs) bool { - // Should preserve the Index from active playlist - return args.Selected == 2 && len(args.Items) == 3 - })).Return(nil) - + ui := uievents.New(clockwork.NewFakeClock(), nil, nil) playlistQueue := make(chan *playlists.Playlist, 1) env := platforms.CmdEnv{ + ServiceCtx: t.Context(), + UI: ui, Cmd: zapscript.Command{ Name: "playlist.open", Args: []string{plsFile}, // Argument matches active playlist @@ -316,8 +518,10 @@ Title3=Item 3` require.NoError(t, err) assert.True(t, result.PlaylistChanged) assert.Equal(t, 2, result.Playlist.Index, "should preserve current position") - - mockPlatform.AssertExpectations(t) + state := ui.State() + require.Len(t, state.Events, 1) + assert.Equal(t, 2, selectedChoiceIndex(&state.Events[0])) + require.NoError(t, ui.Respond(state.Events[0].ID, apimodels.UIResponseActionDismiss, "")) } // makePlaylistEnv returns a 3-item playlist and a buffered queue channel for use in tests.