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
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Device profiles are named buckets of preferences and limits, with no passwords o
- **Active profile**: one per device, held as a snapshot in service state (`pkg/service/state/`) and persisted in the UserDB `DeviceState` table so it survives restarts. The un-profiled state is the implicit **shared profile** — the device as it behaves when nobody is signed in: global-config limits, unattributed history, default data locations. It is an interpretation, not a database row; deactivating means switching to it.
- **Switching**: via API (`profiles.switch`) or by scanning a card containing `**profile:<switchId>`. The switch ID is a word phrase (e.g. `corn-arm-truck`) generated from an embedded wordlist and is a **bearer credential**: presenting it authorizes a PIN-free switch on every path, so the API only returns switch IDs to privileged (local/admin) clients. The PIN protects pick-from-list switching by `profileId`. PINs gate entry only; deactivating is always free.
- **Playtime limits**: profiles can override the global daily/session limits. `pkg/service/playtime.LimitsManager` reads limits through a `LimitsProvider`; the profile-aware resolver (`pkg/service/profiles.LimitsResolver`) layers the active profile's overrides over global config. Daily usage accounting is scoped to the active profile via the `ProfileID` column on `MediaHistory` (rows are attributed at launch time). Everything about a running game belongs to the profile that launched it: the limits context is pinned at media start, so deactivating mid-game keeps the launch profile's limits until the media stops. The session resets only when the profile *identity* changes (switching to a different person), never on rescans, edits, or deactivation.
- **Playtime extensions**: an administrator can grant extra time to the session currently being limited, without stopping what is playing or editing a limit. Two entry points reach one grant path on `LimitsManager`: the `playtime.extend` API method (gated on the `playtime.extend` capability) and a scanned card holding `**playtime.extend:<amount>?profile=<switchId>`, where the switch ID is an admin profile's bearer credential and the card is rejected from any source but a physical reader. A grant either adds a bounded duration to the session's allowance or waives the session limit until the next local midnight; the daily limit is untouched by both. Grants flow through `effectiveSessionLimit` so every consumer — the periodic check, the pre-launch gate, and status — sees them, and are pinned to the profile that owned the session, persisted in `DeviceState` so a restart inside the cooldown window does not revoke them, and cleared when the session resets. Because both commands carry a switch ID, `pkg/zapscript.RedactScript` strips credentials from logs, stored history, and the token APIs, failing closed on anything it cannot parse.
- **Require-profile gate**: the `[profiles] require_for_launch` config setting stops the shared profile launching media (profile switch commands still run, so scanning a card unparks the device; a combo card that switches then launches passes).
- **Data swapping**: on platforms implementing the optional `platforms.ProfileDataSwapper` capability, the active profile also owns its save files and save states. `pkg/service/profiles.DataSwapCoordinator` drives it: switches apply through a single worker (briefly waited on so combo-card launches see the new data), swaps while media runs are deferred until it stops and coalesce to the last target, and errors only ever notify (`profiles.data`) — the switch itself never fails on file operations. MiSTer implements it with bind mounts (`pkg/platforms/mister/profiledata.go`): zero on-disk mutation, pools under `zaparoo/profiles/<id>/`, main's storage root (`device.bin` SD/USB) resolved per apply, foreign mounts (NAS saves) layered on with the pool inside the share and never touched, ownership proven via a tmpfs ledger (`/run/zaparoo/mounts.json`), and a `/proc/self/mountinfo` watcher re-reconciling when the mount table changes. The `[profiles] swap_data` setting (default on) disables it, converging mounts back to shared.
- **Roles and permissions**: profile roles and client roles are separate. Profiles represent people/kiosk identities; the first profile is explicitly created as `admin` with a mandatory PIN and later profiles default `member`. Paired clients represent trusted devices; the first paired client is explicitly confirmed as `admin` and later clients default `member`. Remote profile management requires an admin client. Sensitive local TUI actions use `profiles.verify` as a client-side nuisance gate before sending ordinary requests; there is no retained unlock session or server-side linkage between verification and action. The last admin profile/client cannot be removed or demoted. Existing databases with profiles but no admin enter local setup recovery, allowing one profile to be promoted with a PIN. While `service.encryption` is off, unpaired remote clients retain legacy admin capability; enabling it requires pairing and makes member restrictions enforceable.
Expand Down
2 changes: 2 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ Methods execute actions and return data from Core. See [API Methods](./methods)
| settings.playtime.limits | Return playtime limit configuration. | All clients |
| settings.playtime.limits.update | Update playtime limits. | `settings.write` |
| playtime | Return playtime session status and usage. | All clients |
| playtime.extend | Grant extra time to the session currently being limited. | `playtime.extend` |
| systems | List indexed or supported systems. | All clients |
| launchers | List launchers known to running service. | All clients |
| launchers.refresh | Refresh launcher cache. | All clients |
Expand Down Expand Up @@ -372,5 +373,6 @@ Notifications let a server or client know an event has occurred. See the [API No
| media.scraping | Progress updates emitted during media scraping (includes progress/status details). |
| playtime.limit.reached | A playtime limit (session or daily) has been reached and enforced. |
| playtime.limit.warning | A playtime warning notification sent at configured intervals before limit reached. |
| playtime.extended | Extra playtime was granted to the session currently being limited. |
| inbox.added | A new inbox message was added to the server. |
| update.state | Progress of an update being applied. |
92 changes: 92 additions & 0 deletions docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -3606,9 +3606,13 @@ None.
| cooldownRemaining | string | No | Time until session auto-resets. Only present during `"cooldown"` state. |
| dailyUsageToday | string | No | Total playtime accumulated today. Available in all states when data is available. |
| dailyRemaining | string | No | Time remaining before daily limit reached. Available in all states if daily limit is configured. |
| sessionExtension | string | No | Extra time granted to the current session on top of the configured session limit. Omitted when nothing was granted. |
| sessionExtendedUntil | string | No | RFC 3339 timestamp when a session-limit waiver lapses. While set, the session limit is not enforced and `sessionRemaining` is omitted; the daily limit still applies. |

**Note:** All duration fields use Go's duration format (e.g., `"1h30m45s"`, `"45m"`, `"2h"`).

`sessionRemaining` already accounts for any granted extension, so a client showing time left needs no extra arithmetic. `sessionExtension` is reported separately so a client can show that time was granted rather than silently displaying a larger allowance. See [`playtime.extend`](#playtimeextend).

#### Examples

##### Request
Expand Down Expand Up @@ -3677,6 +3681,94 @@ None.
}
```

### playtime.extend

**Access:** `playtime.extend` capability (localhost, or an authenticated admin client).

Grant extra time to the playtime session currently being limited, without stopping what is playing and without changing any configured limit.

The recipient is never named by the caller: a grant always applies to the profile playtime is being enforced against at that moment, so it cannot be aimed at someone else's session. A `duration` grant is held against the current session only and is cleared when that session resets — when a different profile becomes active, when the cooldown window expires, or when limits are disabled. A `today` waiver survives all three because it is day-scoped: it lapses at the next local midnight and nowhere else.

**The daily limit is never affected.** It remains the hard ceiling in both modes; raising it is a settings change, not a grant.

**Modes:**

- `duration` adds time to the current session's allowance. It requires a session to extend, so it is accepted during `active` and `cooldown` states but rejected during `reset`. Cooldown is the common case: the limit stopped the game and the player is about to relaunch.
- `today` waives the session limit for the recipient profile until the next local midnight. It is day-scoped rather than session-scoped, so it is accepted in any state, and it is rejected when the system clock is unreliable.

A single duration grant must be between 1 minute and 24 hours, and the total accumulated across one session is capped at 24 hours. A grant that would exceed the cap is rejected rather than reduced, so a caller is never told less time was added than it asked for.

The same grant can also be made by scanning a physical card holding `**playtime.extend`, authorized by an administrator profile's switch ID rather than by a paired client.

#### Parameters

| Key | Type | Required | Description |
| :-------- | :----- | :------- | :---------------------------------------------------------------------------------------------------------------- |
| mode | string | Yes | `"duration"` or `"today"`. |
| duration | string | No | Time to add, in Go duration format (e.g. `"15m"`, `"1h30m"`). Required for `"duration"` mode, ignored for `"today"`. |
| requestId | string | No | Idempotency key. Repeating a request ID reports the original grant instead of adding more time. |

#### Result

| Key | Type | Required | Description |
| :--------------- | :------ | :------- | :----------------------------------------------------------------------------------- |
| mode | string | Yes | The mode that was applied. |
| replayed | boolean | Yes | True when a repeated `requestId` matched an earlier grant and no time was added. |
| duration | string | No | Time this grant added. Omitted for `"today"`. |
| expires | string | No | RFC 3339 timestamp when a `"today"` waiver lapses. Omitted for `"duration"`. |
| sessionExtension | string | No | The session's accumulated extension after this grant. |
| profileId | string | No | Recipient profile. Omitted for the shared profile. |

A successful grant emits [`playtime.extended`](./notifications.md#playtimeextended). A replayed request granted nothing, so it emits no notification.

#### Examples

##### Request

```json
{
"jsonrpc": "2.0",
"id": "a1b2c3d4-7a5e-11ef-9c7b-020304050607",
"method": "playtime.extend",
"params": {
"mode": "duration",
"duration": "15m",
"requestId": "5f2c9a10-1d44-4f8e-9f0b-6d1c2a3b4c5d"
}
}
```

##### Response

```json
{
"jsonrpc": "2.0",
"id": "a1b2c3d4-7a5e-11ef-9c7b-020304050607",
"result": {
"mode": "duration",
"duration": "15m0s",
"sessionExtension": "15m0s",
"profileId": "0194e2a1-6c3f-7b21-9d4e-8a5b6c7d8e9f",
"replayed": false
}
}
```

##### Response (waiving the session limit for the rest of the day)

```json
{
"jsonrpc": "2.0",
"id": "a1b2c3d4-7a5e-11ef-9c7b-020304050607",
"result": {
"mode": "today",
"expires": "2025-01-23T00:00:00Z",
"profileId": "0194e2a1-6c3f-7b21-9d4e-8a5b6c7d8e9f",
"replayed": false
}
}
```

### settings.playtime.limits

**Access:** All clients.
Expand Down
35 changes: 35 additions & 0 deletions docs/api/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,41 @@ The warning applies to whichever limit will be reached first (session or daily).
}
```

### playtime.extended

Sent when extra playtime was granted to the session currently being limited, either through the [`playtime.extend`](./methods.md#playtimeextend) method or by scanning a physical extension card. Warning thresholds are re-armed by a grant, so they fire again against the newly granted time.

Profiles are identified by ID only. The switch ID authorizing a card grant is a bearer credential and is never published.

A repeated request that granted no additional time emits no notification.

#### Parameters

| Key | Type | Required | Description |
| :--------------- | :----- | :------- | :------------------------------------------------------------------------------- |
| mode | string | Yes | `"duration"` when time was added, `"today"` when the session limit was waived. |
| duration | string | No | Time this grant added (Go duration format). Omitted for `"today"`. |
| expires | string | No | RFC 3339 timestamp when a `"today"` waiver lapses. Omitted for `"duration"`. |
| sessionExtension | string | No | The session's accumulated extension after this grant. |
| profileId | string | No | Recipient profile. Omitted for the shared profile. |
| grantedBy | string | No | Profile that authorized the grant. Omitted when authorized by an admin client rather than a profile credential. |

#### Example

```json
{
"jsonrpc": "2.0",
"method": "playtime.extended",
"params": {
"mode": "duration",
"duration": "15m0s",
"sessionExtension": "15m0s",
"profileId": "0194e2a1-6c3f-7b21-9d4e-8a5b6c7d8e9f",
"grantedBy": "0194e2a1-9f8e-7c65-b432-1a0f9e8d7c6b"
}
}
```

## Inbox

### inbox.added
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ require (
github.com/Microsoft/go-winio v0.6.2
github.com/ZaparooProject/go-gameid v0.2.0
github.com/ZaparooProject/go-pn532 v0.23.0
github.com/ZaparooProject/go-zapscript v0.16.0
github.com/ZaparooProject/go-zapscript v0.18.0
github.com/adrg/xdg v0.5.3
github.com/andygrunwald/vdf v1.1.0
github.com/bendahl/uinput v1.7.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ github.com/ZaparooProject/go-pn532 v0.23.0 h1:iJ5taBHFXQxYhS8zncMonWEW6pOIyklLpn
github.com/ZaparooProject/go-pn532 v0.23.0/go.mod h1:ao2ojvudUN8ZqcBAkjodRhCjm2jDf/efS0mdSC6eDHg=
github.com/ZaparooProject/go-zapscript v0.16.0 h1:2m4NwU+l5xedOEZqubMBLO9lK6tfDzPienYB4tPE4aU=
github.com/ZaparooProject/go-zapscript v0.16.0/go.mod h1:Z3rFyQq/GA+ESpYUtCOA/2Xyftbygv4MfDCajOVDmag=
github.com/ZaparooProject/go-zapscript v0.18.0 h1:zDZI8Ll+XF5y7h50Sr7Ioq10+CeEmoqJsa37jXyYOXc=
github.com/ZaparooProject/go-zapscript v0.18.0/go.mod h1:ofo4vj6lFW0eUuSyPLt0R0JjJxExhn9eitSmFBWQVoU=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
Expand Down
1 change: 1 addition & 0 deletions pkg/api/methods/clients_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func TestHandleClientsCurrent(t *testing.T) {

adminCapabilities := []string{
string(permissions.CapInput),
string(permissions.CapPlaytimeExtend),
string(permissions.CapProfilesManage),
string(permissions.CapScreenshot),
string(permissions.CapSettingsWrite),
Expand Down
18 changes: 12 additions & 6 deletions pkg/api/methods/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (

"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/zapscript"
"github.com/rs/zerolog/log"
)

Expand All @@ -36,22 +37,24 @@ func HandleTokens(env requests.RequestEnv) (any, error) { //nolint:gocritic // s

active := env.State.GetActiveCard()
if !active.ScanTime.IsZero() {
text, data := zapscript.RedactToken(active.Text, active.Data)
resp.Active = append(resp.Active, models.TokenResponse{
Type: active.Type,
UID: active.UID,
Text: active.Text,
Data: active.Data,
Text: text,
Data: data,
ScanTime: active.ScanTime,
})
}

last := env.State.GetLastScanned()
if !last.ScanTime.IsZero() {
text, data := zapscript.RedactToken(last.Text, last.Data)
resp.Last = &models.TokenResponse{
Type: last.Type,
UID: last.UID,
Text: last.Text,
Data: last.Data,
Text: text,
Data: data,
ScanTime: last.ScanTime,
}
}
Expand All @@ -73,12 +76,15 @@ func HandleHistory(env requests.RequestEnv) (any, error) { //nolint:gocritic //
}

for i := range entries {
// Redact on the way out as well as on the way in: rows written by
// earlier versions still hold credentials in the clear.
text, data := zapscript.RedactToken(entries[i].TokenValue, entries[i].TokenData)
resp.Entries[i] = models.HistoryResponseEntry{
Time: entries[i].Time,
Type: entries[i].Type,
UID: entries[i].TokenID,
Text: entries[i].TokenValue,
Data: entries[i].TokenData,
Text: text,
Data: data,
Success: entries[i].Success,
}
}
Expand Down
Loading
Loading