Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/mister/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
13 changes: 12 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,14 +232,16 @@ 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 |
| :------------------------------ | :------------------------------------------------------------------------------------ |
| launch | **Deprecated.** Alias for `run`. |
| 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. |
Expand Down Expand Up @@ -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. |
Expand Down
123 changes: 123 additions & 0 deletions docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Tokens

### tokens
Expand Down
70 changes: 70 additions & 0 deletions docs/api/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions pkg/api/methods/ui.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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, &params); 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
}
Loading
Loading