diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1193041d4..467238c62 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,6 +43,17 @@ Reference material for Zaparoo Core's architecture, APIs, and subsystems. For de - **Thread-safe**: `config.Instance` uses `syncutil.RWMutex` - Maintain backward compatibility — use migrations for breaking changes +## Profiles + +Device profiles are named buckets of preferences and limits, with no passwords or accounts. See `pkg/service/profiles/`. + +- **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:`. 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. +- **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//`, 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. + ## Reader Auto-Detection 10 reader types: acr122pcsc, externaldrive, file, libnfc, mqtt, opticaldrive, pn532, rs232barcode, simpleserial, tty2oled diff --git a/docs/api/encryption.md b/docs/api/encryption.md index d48bd8539..e9922adac 100644 --- a/docs/api/encryption.md +++ b/docs/api/encryption.md @@ -15,7 +15,7 @@ Set `encryption` in `[service]` of `config.toml`: | Value | Behavior | |---|---| -| `false` (default) | No encryption. All WebSocket connections accepted as plaintext. | +| `false` (default) | Encryption is optional. Plaintext and encrypted WebSocket connections are accepted. | | `true` | Remote WebSocket connections must send an encrypted first frame from a paired client. Localhost plaintext connections still work without pairing. | ## Pairing flow diff --git a/docs/api/methods.md b/docs/api/methods.md index bfd58cf77..d5263a42d 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -2171,8 +2171,11 @@ None. | readersScanExitDelay | number | Yes | Delay before exiting scan mode in seconds. | | readersScanIgnoreSystems | string[] | Yes | List of system IDs to ignore during scanning. | | errorReporting | boolean | Yes | Whether error reporting is enabled. | +| encryption | boolean | Yes | Whether paired encryption is required for remote WebSocket connections. Localhost remains exempt. | | readersConnect | [ReaderConnection](#reader-connection-object)[] | Yes | List of manually configured reader connections. | | systemDefaults | [SystemDefault](#system-default-object)[] | Yes | Per-system overrides for default launcher and exit ZapScript. | +| profilesRequireForLaunch | boolean | Yes | Whether media launches are blocked while no personal profile is active. | +| profilesSwapData | boolean | Yes | Whether profile switches also swap profile-scoped data (saves, save states) on supported platforms. Defaults to true. | ##### Reader connection object @@ -2218,6 +2221,7 @@ None. "readersScanExitDelay": 0.0, "readersScanIgnoreSystems": ["DOS"], "errorReporting": true, + "encryption": false, "readersConnect": [], "systemDefaults": [ { @@ -2249,8 +2253,11 @@ An object containing any of the following optional keys: | readersScanExitDelay | number | No | Delay before exiting scan mode in seconds. | | readersScanIgnoreSystems | string[] | No | List of system IDs to ignore during scanning. | | errorReporting | boolean | No | Whether error reporting is enabled. | +| encryption | boolean | No | Require paired encryption for remote WebSocket connections. This setting can only be changed from localhost. | | readersConnect | [ReaderConnection](#reader-connection-object)[] | No | List of manually configured reader connections. | | systemDefaults | [SystemDefault](#system-default-object)[] | No | Replace the full list of per-system launcher/exit-script overrides. Each `launcher` value, if non-empty, must match a known launcher ID or group (case-insensitive). | +| profilesRequireForLaunch | boolean | No | Whether media launches are blocked while no personal profile is active. | +| profilesSwapData | boolean | No | Whether profile switches also swap profile-scoped data. Turning it off converges data back to the shared state immediately. | #### Result @@ -2613,6 +2620,161 @@ Returns `null` on success. } ``` +## Profiles + +Profiles are lightweight runtime identities: named buckets of preferences, limits, and profile-owned data. One profile is active per device at a time, switched via the API or by scanning an NFC card containing the profile's switch ID (`**profile:`). Profile roles (`admin` or `member`) are separate from paired-client roles: profile roles identify who may authorize local household management, while client roles describe which remote device may call privileged APIs. + +When no personal profile is active the device is on the implicit **shared profile** — the device as it behaves when nobody is signed in. The shared profile's playtime limits are the global config limits, its history is unattributed, and it owns everything the device did before profiles existed. Deactivating means switching to the shared profile. To stop the shared profile launching media (parking the device until someone identifies themselves), enable the `profilesRequireForLaunch` setting (see [settings](#settings)). + +A profile's **switch ID is a bearer credential**: presenting it — by scanning the card it is written on, or by sending it over the API — authorizes switching to that profile with no PIN, on every path. Switch IDs are therefore only returned to privileged clients (local connections and admin-role paired clients) for card-writing; member clients never see them. The optional 4-8 digit **PIN** protects the remaining path: switching by `profileId` picked from the visible profile list. Leaving a profile is always free — PINs gate entry only. + +**Data swapping.** On platforms that support it (currently MiSTer), the active profile also owns its save files and save states. Switching profiles makes that profile's data live; the shared profile's data is simply the default locations, so creating profiles never moves existing saves. On MiSTer this is done with bind mounts — nothing on the SD card is ever moved, copied, or rewritten, the card keeps a completely standard layout, and the per-profile data lives in plain directories under `zaparoo/profiles//`. The mechanism follows main's own storage rules: a USB storage root (`device.bin`) is respected, and NAS-mounted saves (cifs mount over `saves/`) are layered on — the profile pool is created inside the share (`saves/.zaparoo-profiles//`) so saves stay centralized, and the user's mount is never touched. A swap requested while a game is running is deferred until the game stops (the running game keeps the data it launched with, matching the playtime pinning rule). Progress and failures are reported by the [`profiles.data`](notifications.md#profilesdata) notification; the `profilesSwapData` setting turns swapping off. What never swaps: core configs and input mappings (device properties), the Recently Played lists, arcade nvram, and computer-core disk images (saves live inside the shared image). Per-profile MiSTer INI setups need no data swap — write a combo card like `**profile:||mister.ini:2` to switch profile and INI together. Deleting a profile never deletes its data directory. Note that profile data lives under the `zaparoo/` directory: deleting a Zaparoo install directory deletes profile saves with it. + +**Administration and trust model.** The first profile is created as `admin` and must have a PIN; later profiles default `member`. The first paired client is `admin`; later pairings default `member`. Sensitive local UIs call `profiles.verify`, confirm the returned profile has the `admin` role, then send the ordinary management request. This is a client-side nuisance gate for parental and kiosk controls, not cryptographic request authorization; no unlock session is retained. Admin paired clients use their client capability directly. The last admin profile/client cannot be removed or demoted. Profiles remain a household convenience boundary, comparable to TV parental controls — not OS account security. Anyone with OS access still owns the device, and while `service.encryption` is off an unpaired remote client retains legacy admin API capability. Enabling encryption makes paired-client restrictions enforceable. + +### Profile object + +| Key | Type | Required | Description | +| :------------ | :------ | :------- | :------------------------------------------------------------------------------------------------------- | +| profileId | string | Yes | Unique identifier of the profile. | +| name | string | Yes | Display name, e.g. "Dad" or "Kid A". | +| role | string | Yes | `admin` or `member`. Admin profiles may authorize local management and must have a PIN. | +| switchId | string | No | Word phrase written to profile switch cards, e.g. `corn-arm-truck`. A bearer credential: presenting it switches to the profile with no PIN. Returned to local callers and admin clients; omitted for remote member clients. | +| hasPin | boolean | Yes | True when the profile has a PIN set. The PIN itself is never returned. | +| limitsEnabled | boolean | No | Playtime limits enabled override. Omitted = inherit the global setting. | +| dailyLimit | string | No | Daily playtime limit override as a duration string (e.g. `2h30m`). Omitted = inherit; `0` = unlimited. | +| sessionLimit | string | No | Session playtime limit override as a duration string. Omitted = inherit; `0` = unlimited. | +| lastUsedAt | number | No | Unix timestamp of most recent successful profile activation. Omitted if never activated. | +| createdAt | number | Yes | Unix timestamp of profile creation. | +| lastUpdatedAt | number | Yes | Unix timestamp of last modification. | + +### profiles + +List all profiles. + +#### Parameters + +None. + +#### Result + +| Key | Type | Required | Description | +| :------- | :--------------------------- | :------- | :---------------- | +| profiles | [Profile](#profile-object)[] | Yes | List of profiles. | + +### profiles.new + +Create a new profile. Remote callers require an admin client; local UIs may use `profiles.verify` as a nuisance gate. The switch ID is generated automatically; write it to a card as `**profile:`. + +#### Parameters + +| Key | Type | Required | Description | +| :------------ | :------ | :------- | :---------------------------------------------------------------- | +| name | string | Yes | Display name. | +| role | string | No | `admin` or `member`; later profiles default member. First profile is always admin. | +| pin | string | No | Optional 4-8 digit PIN required to switch by `profileId`; mandatory for admin profiles. | +| limitsEnabled | boolean | No | Playtime limits enabled override. | +| dailyLimit | string | No | Daily limit duration override. | +| sessionLimit | string | No | Session limit duration override. | + +#### Result + +The created [profile object](#profile-object). + +### profiles.update + +Update a profile. Remote callers require an admin client; local UIs may gate this action with `profiles.verify`. Migrated profiles without an administrator may still be recovered locally. Omitted fields are unchanged. If the updated profile is currently active, its limit changes apply immediately (without resetting the running session). + +#### Parameters + +| Key | Type | Required | Description | +| :----------------- | :------ | :------- | :---------------------------------------------------------------------- | +| profileId | string | Yes | Profile to update. | +| name | string | No | New display name. | +| role | string | No | Change between `admin` and `member`; final admin cannot be demoted. | +| pin | string | No | Set or replace the PIN; admin profiles must retain one. | +| clearPin | boolean | No | Remove the PIN. | +| limitsEnabled | boolean | No | Playtime limits enabled override. | +| dailyLimit | string | No | Daily limit duration override. | +| sessionLimit | string | No | Session limit duration override. | +| clearLimits | boolean | No | Reset all limit overrides back to inheriting the global config, before any limit fields in the same request are applied. | +| regenerateSwitchId | boolean | No | Issue a new switch ID (lost-card replacement). Old cards stop working. | + +#### Result + +The updated [profile object](#profile-object). + +### profiles.delete + +Delete a profile. Remote callers require an admin client; local UIs may gate this action with `profiles.verify`. The final admin profile cannot be deleted. If it is active, the device switches to the shared profile. Past play history keeps its attribution. + +#### Parameters + +| Key | Type | Required | Description | +| :-------- | :----- | :------- | :----------------- | +| profileId | string | Yes | Profile to delete. | + +#### Result + +Null. + +### profiles.active + +Get the device's currently active profile. + +#### Parameters + +None. + +#### Result + +The active profile (a subset of the [profile object](#profile-object) without `switchId` and timestamps), or null when no profile is active. + +### profiles.switch + +Switch the device's active profile. Switching by `profileId` requires the profile's PIN when one is set. Switching by `switchId` never requires a PIN: the switch ID is a bearer credential, and presenting it is equivalent to scanning the profile's card. Calling with neither `profileId` nor `switchId` switches to the shared profile (deactivates), which never requires a PIN. Providing both is an error. + +If a game is running when the profile changes, its playtime keeps counting against the profile that launched it: switching to another profile starts a fresh limit session for the new person, while deactivating leaves the launch profile's limits in force until the media stops. + +#### Parameters + +| Key | Type | Required | Description | +| :-------- | :----- | :------- | :------------------------------------------------------ | +| profileId | string | No | Profile to activate, by ID. Requires `pin` when the profile has one. | +| switchId | string | No | Profile to activate, by switch ID (bearer credential; no PIN needed). | +| pin | string | No | The profile's PIN, for `profileId` switching. | + +#### Result + +The new active profile, or null when deactivated (shared profile). + +### profiles.verify + +Verify a profile credential **without switching**: either a profile ID plus its PIN, or a switch ID (a bearer credential — resolving it is the verification, same as scanning the card). Success returns the profile's identity and changes nothing on the device: no session, no active-profile change, no server-side grant of any kind. Clients use this to gate their own ad-hoc UI items behind a credential — e.g. a kiosk frontend requiring a parent's PIN before opening its settings screen. The security of whatever the client unlocks is entirely the client's responsibility. + +PIN attempts share the same per-profile rate limiter as `profiles.switch`, so this method cannot be used to brute-force a PIN any faster than switching attempts could. + +#### Parameters + +| Key | Type | Required | Description | +| :-------- | :----- | :------- | :-------------------------------------------------------- | +| profileId | string | No | Profile to verify against. Requires `pin` when the profile has one. | +| switchId | string | No | Verify by switch ID (bearer credential; no PIN needed). | +| pin | string | No | The profile's PIN, for `profileId` verification. | + +Exactly one of `profileId` or `switchId` is required. + +#### Result + +| Key | Type | Required | Description | +| :-------- | :------ | :------- | :---------------------------------------- | +| profileId | string | Yes | ID of the verified profile. | +| name | string | Yes | Display name of the verified profile. | +| role | string | Yes | Profile role (`admin` or `member`). | +| hasPin | boolean | Yes | Whether the profile has a PIN set. | + +Verification failure (wrong PIN, unknown profile or switch ID, rate limited) returns an error, using the same errors as `profiles.switch`. + ## Mappings Mappings are used to modify the contents of tokens before they're launched, based on different types of matching parameters. Stored mappings are queried before every launch and applied to the token if there's a match. This allows, for example, adding ZapScript to a read-only NFC tag based on its UID. diff --git a/docs/api/notifications.md b/docs/api/notifications.md index 06ade847c..ff44e058c 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -408,3 +408,63 @@ Sent when a new inbox message is added to the server. } } ``` + +## Profiles + +### profiles.active + +Sent when the device's active profile changes, including deactivation. + +#### Parameters + +| Key | Type | Required | Description | +| :------ | :----- | :------- | :------------------------------------------------------------------ | +| profile | object \| null | Yes | The new active profile, or null when the device deactivated. | + +The profile object contains `profileId`, `name`, `hasPin` and any playtime limit overrides (`limitsEnabled`, `dailyLimit`, `sessionLimit`). + +#### Example + +```json +{ + "jsonrpc": "2.0", + "method": "profiles.active", + "params": { + "profile": { + "profileId": "1ad28b9a-7aef-11ef-9817-020304050607", + "name": "Kid A", + "hasPin": true, + "limitsEnabled": true, + "dailyLimit": "2h" + } + } +} +``` + +### profiles.data + +Sent when a profile data swap (save files, save states) changes state on +platforms that support data swapping. The profile switch itself always +succeeds independently of data swapping; this notification reports the +data side. + +#### Parameters + +| Key | Type | Required | Description | +| :-------- | :----- | :------- | :-------------------------------------------------------------------- | +| profileId | string | Yes | Profile whose data the swap targets. Empty for the shared profile. | +| status | string | Yes | One of `applied`, `deferred` (media is running; applies when it stops), `failed`, or `unavailable` (the storage setup does not permit swapping, e.g. a read-only network share). | +| reason | string | No | Human-readable explanation for `failed`/`unavailable`. | + +#### Example + +```json +{ + "jsonrpc": "2.0", + "method": "profiles.data", + "params": { + "profileId": "1ad28b9a-7aef-11ef-9817-020304050607", + "status": "deferred" + } +} +``` diff --git a/go.mod b/go.mod index 3d2cabc0c..40f044e0c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/ZaparooProject/go-gameid v0.2.0 github.com/ZaparooProject/go-pn532 v0.22.1 - github.com/ZaparooProject/go-zapscript v0.15.0 + github.com/ZaparooProject/go-zapscript v0.16.0 github.com/adrg/xdg v0.5.3 github.com/andygrunwald/vdf v1.1.0 github.com/bendahl/uinput v1.7.0 diff --git a/go.sum b/go.sum index 58d35319a..ac38f3fc3 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/ZaparooProject/go-gameid v0.2.0 h1:nYDxozhwsJXacdh/lwHOJofz4SlA609WKT github.com/ZaparooProject/go-gameid v0.2.0/go.mod h1:gPQg1jQ4jgkguOJeKUiIqZeucz0GsSR67UQ/JOgYUDI= github.com/ZaparooProject/go-pn532 v0.22.1 h1:Dtuc+sXYZtuNZP+8/DQv68V1MOHUxRAOMVYsqvVFAfQ= github.com/ZaparooProject/go-pn532 v0.22.1/go.mod h1:NwYx5IE0zAU70ZikNpoPiOF5MUlDn3fD8xImpZixW1k= -github.com/ZaparooProject/go-zapscript v0.15.0 h1:H8YVI2z4p4v6L9GRfwtsMb1TvNQ7M+ne+fUg+3tTnjs= -github.com/ZaparooProject/go-zapscript v0.15.0/go.mod h1:Z3rFyQq/GA+ESpYUtCOA/2Xyftbygv4MfDCajOVDmag= +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/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.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= diff --git a/pkg/api/integration_encryption_test.go b/pkg/api/integration_encryption_test.go index e0ec89a12..24d2d7a65 100644 --- a/pkg/api/integration_encryption_test.go +++ b/pkg/api/integration_encryption_test.go @@ -105,7 +105,7 @@ func pairAndDeriveKey( ) (storedClient *database.Client, pairingKey []byte) { t.Helper() - pin, _, err := mgr.StartPairing() + pin, _, err := mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte(pin), 0, pakeCurve) diff --git a/pkg/api/methods/clients.go b/pkg/api/methods/clients.go index a2b71b04a..0d97521eb 100644 --- a/pkg/api/methods/clients.go +++ b/pkg/api/methods/clients.go @@ -55,6 +55,7 @@ func HandleClients(env requests.RequestEnv) (any, error) { resp.Clients[i] = models.PairedClient{ ClientID: c.ClientID, ClientName: c.ClientName, + Role: c.Role, CreatedAt: c.CreatedAt, LastSeenAt: c.LastSeenAt, } @@ -81,6 +82,9 @@ func HandleClientsDelete(env requests.RequestEnv) (any, error) { if params.ClientID == "" { return nil, models.ClientErrf("clientId is required") } + if err := requireProfileManagement(&env); err != nil { + return nil, err + } if err := env.Database.UserDB.DeleteClient(params.ClientID); err != nil { return nil, models.ClientErrf("failed to delete client: %w", err) diff --git a/pkg/api/methods/clients_test.go b/pkg/api/methods/clients_test.go index 384564544..81082b64e 100644 --- a/pkg/api/methods/clients_test.go +++ b/pkg/api/methods/clients_test.go @@ -125,18 +125,13 @@ func TestHandleClients_RemoteRejected(t *testing.T) { func TestHandleClientsDelete_Success(t *testing.T) { t.Parallel() - mockUserDB := helpers.NewMockUserDBI() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = true mockUserDB.On("DeleteClient", "client-uuid").Return(nil) params, err := json.Marshal(models.ClientsDeleteParams{ClientID: "client-uuid"}) require.NoError(t, err) - - env := requests.RequestEnv{ - Context: context.Background(), - Database: &database.Database{UserDB: mockUserDB}, - IsLocal: true, - Params: params, - } + env.Params = params result, err := HandleClientsDelete(env) require.NoError(t, err) @@ -167,18 +162,13 @@ func TestHandleClientsDelete_MissingClientID(t *testing.T) { func TestHandleClientsDelete_DatabaseError(t *testing.T) { t.Parallel() - mockUserDB := helpers.NewMockUserDBI() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = true mockUserDB.On("DeleteClient", "client-uuid").Return(errors.New("not found")) params, err := json.Marshal(models.ClientsDeleteParams{ClientID: "client-uuid"}) require.NoError(t, err) - - env := requests.RequestEnv{ - Context: context.Background(), - Database: &database.Database{UserDB: mockUserDB}, - IsLocal: true, - Params: params, - } + env.Params = params _, err = HandleClientsDelete(env) require.Error(t, err) diff --git a/pkg/api/methods/pairing.go b/pkg/api/methods/pairing.go index 601f008ed..4c9985687 100644 --- a/pkg/api/methods/pairing.go +++ b/pkg/api/methods/pairing.go @@ -20,21 +20,28 @@ package methods import ( + "fmt" "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/api/permissions" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation" "github.com/rs/zerolog/log" ) // PairingController is the subset of PairingManager needed by the RPC handlers. type PairingController interface { - StartPairing() (pin string, expiresAt time.Time, err error) + StartPairing(role string) (pin string, expiresAt time.Time, err error) + CountClients() (int, error) CancelPairing() } // HandleClientsPairStart returns a handler that initiates a new pairing flow. -// Localhost-only — the user must have physical access to the device. +// Localhost-only — the user must have physical access to the device. The +// optional role param decides the permission role the paired client will +// receive (default member); this is the approval step, so the person +// physically at the device makes the choice. func HandleClientsPairStart(mgr PairingController) func(requests.RequestEnv) (any, error) { return func(env requests.RequestEnv) (any, error) { if !env.IsLocal { @@ -43,7 +50,34 @@ func HandleClientsPairStart(mgr PairingController) func(requests.RequestEnv) (an log.Info().Msg("received clients.pair.start request") - pin, expiresAt, err := mgr.StartPairing() + var params models.ClientsPairStartParams + if len(env.Params) > 0 { + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + log.Warn().Err(err).Msg("invalid params") + return nil, models.ClientErrf("invalid params: %w", err) + } + } + + count, err := mgr.CountClients() + if err != nil { + return nil, fmt.Errorf("failed to count paired clients: %w", err) + } + role := params.Role + if count == 0 { + role = string(permissions.RoleAdmin) + } else if role == "" { + role = string(permissions.RoleMember) + } + if !permissions.ValidRole(role) { + return nil, models.ClientErrf("invalid role: %s", role) + } + if count > 0 && role == string(permissions.RoleAdmin) { + if authErr := requireProfileManagement(&env); authErr != nil { + return nil, authErr + } + } + + pin, expiresAt, err := mgr.StartPairing(role) if err != nil { return nil, models.ClientErrf("failed to start pairing: %w", err) } diff --git a/pkg/api/methods/pairing_test.go b/pkg/api/methods/pairing_test.go index 5ab66889d..39e249409 100644 --- a/pkg/api/methods/pairing_test.go +++ b/pkg/api/methods/pairing_test.go @@ -34,13 +34,20 @@ type mockPairingController struct { expiresAt time.Time err error pin string + role string + count int cancelled bool } -func (m *mockPairingController) StartPairing() (string, time.Time, error) { +func (m *mockPairingController) StartPairing(role string) (string, time.Time, error) { + m.role = role return m.pin, m.expiresAt, m.err } +func (m *mockPairingController) CountClients() (int, error) { + return m.count, nil +} + func (m *mockPairingController) CancelPairing() { m.cancelled = true } @@ -58,6 +65,31 @@ func TestHandleClientsPairStart_Success(t *testing.T) { require.True(t, ok) assert.Equal(t, "123456", resp.PIN) assert.Equal(t, expires.Unix(), resp.ExpiresAt) + assert.Equal(t, "admin", mgr.role, "first client is forced to administrator") +} + +func TestHandleClientsPairStart_AdditionalAdminAllowedLocally(t *testing.T) { + t.Parallel() + mgr := &mockPairingController{pin: "123456", expiresAt: time.Now(), count: 1} + handler := HandleClientsPairStart(mgr) + env := requests.RequestEnv{ + IsLocal: true, + Params: []byte(`{"role":"admin"}`), + } + + _, err := handler(env) + require.NoError(t, err) + assert.Equal(t, "admin", mgr.role) +} + +func TestHandleClientsPairStart_AdditionalClientDefaultsMember(t *testing.T) { + t.Parallel() + mgr := &mockPairingController{pin: "123456", expiresAt: time.Now(), count: 1} + handler := HandleClientsPairStart(mgr) + + _, err := handler(requests.RequestEnv{IsLocal: true}) + require.NoError(t, err) + assert.Equal(t, "member", mgr.role) } func TestHandleClientsPairStart_RemoteRejected(t *testing.T) { diff --git a/pkg/api/methods/permissions.go b/pkg/api/methods/permissions.go new file mode 100644 index 000000000..05f4f7458 --- /dev/null +++ b/pkg/api/methods/permissions.go @@ -0,0 +1,59 @@ +// 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/permissions" +) + +// ErrForbidden is returned when a client's role does not grant the +// capability a method requires. +var ErrForbidden = errors.New("client role does not permit this method") + +// requestGrant builds the permission grant for a request. +func requestGrant(env *requests.RequestEnv) permissions.Grant { + return permissions.Grant{ + Role: permissions.Role(env.ClientRole), + IsLocal: env.IsLocal, + } +} + +// requireCapability returns a client error when the request's grant does +// not include the capability. +func requireCapability(env *requests.RequestEnv, capability permissions.Capability) error { + if !requestGrant(env).Has(capability) { + return models.ClientErrf("%w", ErrForbidden) + } + return nil +} + +// requireProfileManagement permits trusted local UI requests and requires +// the profile-management capability from remote clients. Local profile PIN +// prompts are a UI nuisance barrier, not API authorization. +func requireProfileManagement(env *requests.RequestEnv) error { + if env.IsLocal || requestGrant(env).Has(permissions.CapProfilesManage) { + return nil + } + return models.ClientErrf("%w", ErrForbidden) +} diff --git a/pkg/api/methods/profiles.go b/pkg/api/methods/profiles.go new file mode 100644 index 000000000..5f52c25bb --- /dev/null +++ b/pkg/api/methods/profiles.go @@ -0,0 +1,296 @@ +// 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/permissions" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/profiles" + "github.com/rs/zerolog/log" +) + +// errProfilesUnavailable is returned when the profiles service was not +// wired into the request environment (should not happen in production). +var errProfilesUnavailable = errors.New("profiles service not available") + +// canSeeSwitchIDs reports whether the requesting client may read profile +// switch IDs. Switch IDs are bearer credentials — presenting one authorizes +// a PIN-free switch on every path — so they are only exposed where the +// card-writing UX needs them. +func canSeeSwitchIDs(env *requests.RequestEnv) bool { + return env.IsLocal || requestGrant(env).Has(permissions.CapProfilesManage) +} + +// profileResponse renders a profile for API responses. SwitchID is a +// bearer credential and is only included for privileged requesters. +func profileResponse(p *database.Profile, includeSwitchID bool) models.ProfileResponse { + resp := models.ProfileResponse{ + ProfileID: p.ProfileID, + Name: p.Name, + Role: p.Role, + HasPIN: p.PINHash != "", + LimitsEnabled: p.LimitsEnabled, + DailyLimit: p.DailyLimit, + SessionLimit: p.SessionLimit, + LastUsedAt: p.LastUsedAt, + CreatedAt: p.CreatedAt, + LastUpdatedAt: p.UpdatedAt, + } + if includeSwitchID { + resp.SwitchID = p.SwitchID + } + return resp +} + +// profileError maps service errors to client errors where the client is at +// fault (bad PIN, unknown profile), passing other errors through. +func profileError(err error) error { + switch { + case errors.Is(err, profiles.ErrPINRequired), + errors.Is(err, profiles.ErrPINIncorrect), + errors.Is(err, profiles.ErrPINRateLimited), + errors.Is(err, profiles.ErrInvalidPINFormat), + errors.Is(err, profiles.ErrAdminPINRequired), + errors.Is(err, profiles.ErrInvalidRole), + errors.Is(err, profiles.ErrLastAdmin), + errors.Is(err, profiles.ErrNotFound): + return models.ClientErrf("%w", err) + default: + return err + } +} + +// HandleProfiles lists all profiles. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfiles(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles list request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + + list, err := env.Profiles.List() + if err != nil { + log.Error().Err(err).Msg("error listing profiles") + return nil, errors.New("error listing profiles") + } + + includeSwitchID := canSeeSwitchIDs(&env) + resp := models.ProfilesResponse{ + Profiles: make([]models.ProfileResponse, len(list)), + } + for i := range list { + resp.Profiles[i] = profileResponse(&list[i], includeSwitchID) + } + return resp, nil +} + +// HandleProfilesNew creates a new profile. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfilesNew(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles new request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + + var params models.NewProfileParams + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + log.Warn().Err(err).Msg("invalid params") + return nil, models.ClientErrf("invalid params: %w", err) + } + if err := requireProfileManagement(&env); err != nil { + return nil, err + } + + p, err := env.Profiles.Create(¶ms) + if err != nil { + return nil, profileError(err) + } + return profileResponse(p, canSeeSwitchIDs(&env)), nil +} + +// HandleProfilesUpdate updates an existing profile. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfilesUpdate(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles update request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + + var params models.UpdateProfileParams + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + log.Warn().Err(err).Msg("invalid params") + return nil, models.ClientErrf("invalid params: %w", err) + } + if err := requireProfileManagement(&env); err != nil { + return nil, err + } + + p, err := env.Profiles.Update(¶ms) + if err != nil { + return nil, profileError(err) + } + return profileResponse(p, canSeeSwitchIDs(&env)), nil +} + +// HandleProfilesDelete removes a profile, deactivating it first if it is +// the active profile. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfilesDelete(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles delete request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + var params models.DeleteProfileParams + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + log.Warn().Err(err).Msg("invalid params") + return nil, models.ClientErrf("invalid params: %w", err) + } + + if err := requireProfileManagement(&env); err != nil { + return nil, err + } + + if err := env.Profiles.Delete(params.ProfileID); err != nil { + return nil, profileError(err) + } + return NoContent{}, nil +} + +// HandleProfilesActive returns the active profile, or null when none. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfilesActive(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles active request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + return env.Profiles.Active(), nil +} + +// HandleProfilesVerify verifies a profile credential without switching: +// either a profile ID plus its PIN, or a switch ID (a bearer credential — +// resolving it is the verification). Success returns the profile's +// identity and grants nothing server-side; clients use this to gate their +// own ad-hoc UI items behind a credential. PIN attempts share the same +// rate limiter as switching. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfilesVerify(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles verify request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + + var params models.VerifyProfileParams + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + log.Warn().Err(err).Msg("invalid params") + return nil, models.ClientErrf("invalid params: %w", err) + } + + pin := "" + if params.PIN != nil { + pin = *params.PIN + } + + var p *database.Profile + var err error + switch { + case params.ProfileID != nil && *params.ProfileID != "" && + params.SwitchID != nil && *params.SwitchID != "": + return nil, models.ClientErrf("provide exactly one of profileId or switchId") + case params.ProfileID != nil && *params.ProfileID != "": + p, err = env.Profiles.VerifyByID(*params.ProfileID, pin) + case params.SwitchID != nil && *params.SwitchID != "": + p, err = env.Profiles.VerifyBySwitchID(*params.SwitchID) + default: + return nil, models.ClientErrf("either profileId or switchId is required") + } + if err != nil { + return nil, profileError(err) + } + + return models.ProfileVerifyResponse{ + ProfileID: p.ProfileID, + Name: p.Name, + Role: p.Role, + HasPIN: p.PINHash != "", + }, nil +} + +// HandleProfilesSwitch switches the active profile. A switch ID is a +// bearer credential: presenting one authorizes the switch with no PIN on +// every path (card scan, run API, or here), exactly like scanning the +// card. Switching by profile ID — picked from the visible list — is what +// the PIN protects. Passing neither profileId nor switchId deactivates +// (switches to the shared profile), which is always free: PINs gate entry +// only. +// +//nolint:gocritic // single-use parameter in API handler +func HandleProfilesSwitch(env requests.RequestEnv) (any, error) { + log.Info().Msg("received profiles switch request") + if env.Profiles == nil { + return nil, errProfilesUnavailable + } + + var params models.SwitchProfileParams + if len(env.Params) > 0 { + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + log.Warn().Err(err).Msg("invalid params") + return nil, models.ClientErrf("invalid params: %w", err) + } + } + + pin := "" + if params.PIN != nil { + pin = *params.PIN + } + + switch { + case params.ProfileID != nil && *params.ProfileID != "" && + params.SwitchID != nil && *params.SwitchID != "": + return nil, models.ClientErrf("provide exactly one of profileId or switchId") + case params.ProfileID != nil && *params.ProfileID != "": + active, err := env.Profiles.ActivateByID(*params.ProfileID, pin) + if err != nil { + return nil, profileError(err) + } + return active, nil + case params.SwitchID != nil && *params.SwitchID != "": + active, err := env.Profiles.ActivateBySwitchID(*params.SwitchID) + if err != nil { + return nil, profileError(err) + } + return active, nil + default: + if err := env.Profiles.Deactivate(); err != nil { + return nil, profileError(err) + } + return (*models.ActiveProfile)(nil), nil + } +} diff --git a/pkg/api/methods/profiles_test.go b/pkg/api/methods/profiles_test.go new file mode 100644 index 000000000..a55ecd4ec --- /dev/null +++ b/pkg/api/methods/profiles_test.go @@ -0,0 +1,452 @@ +// 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 ( + "context" + "encoding/json" + "testing" + + "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/permissions" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "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/testing/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newProfilesEnv builds a RequestEnv with a real profiles service over a +// mock user DB. +func newProfilesEnv(t *testing.T) (env requests.RequestEnv, mockUserDB *helpers.MockUserDBI, st *state.State) { + t.Helper() + mockUserDB = helpers.NewMockUserDBI() + st, ns := state.NewState(nil, "boot") + t.Cleanup(func() { + for { + select { + case <-ns: + default: + return + } + } + }) + db := &database.Database{UserDB: mockUserDB, MediaDB: nil} + env = requests.RequestEnv{ + Context: context.Background(), + Database: db, + State: st, + Profiles: profiles.NewService(db, st), + } + return env, mockUserDB, st +} + +func testProfileRow(t *testing.T, pin string) *database.Profile { + t.Helper() + p := &database.Profile{ + ProfileID: "profile-1", + Name: "Kid A", + Role: profiles.ProfileRoleMember, + SwitchID: "corn-arm-truck", + CreatedAt: 1700000000, + UpdatedAt: 1700000000, + } + if pin != "" { + hash, err := profiles.HashPIN(pin) + require.NoError(t, err) + p.PINHash = hash + } + return p +} + +func TestHandleProfiles_ListLocalIncludesSwitchIDButNeverPINHash(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = true + + mockUserDB.On("ListProfiles").Return([]database.Profile{*testProfileRow(t, "1234")}, nil) + + result, err := HandleProfiles(env) + require.NoError(t, err) + resp, ok := result.(models.ProfilesResponse) + require.True(t, ok) + require.Len(t, resp.Profiles, 1) + assert.Equal(t, "profile-1", resp.Profiles[0].ProfileID) + assert.Equal(t, "corn-arm-truck", resp.Profiles[0].SwitchID) + assert.True(t, resp.Profiles[0].HasPIN) + + // The PIN hash must never appear in the serialized response. + raw, err := json.Marshal(resp) + require.NoError(t, err) + assert.NotContains(t, string(raw), "pbkdf2") +} + +func TestHandleProfiles_LocalSeesSwitchIDs(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = true + admin := testProfileRow(t, "1234") + admin.Role = profiles.ProfileRoleAdmin + lastUsedAt := int64(1784079000) + admin.LastUsedAt = &lastUsedAt + mockUserDB.On("ListProfiles").Return([]database.Profile{*admin}, nil) + + result, err := HandleProfiles(env) + require.NoError(t, err) + resp, ok := result.(models.ProfilesResponse) + require.True(t, ok) + require.Len(t, resp.Profiles, 1) + assert.Equal(t, admin.SwitchID, resp.Profiles[0].SwitchID) + assert.Equal(t, profiles.ProfileRoleAdmin, resp.Profiles[0].Role) + require.NotNil(t, resp.Profiles[0].LastUsedAt) + assert.Equal(t, lastUsedAt, *resp.Profiles[0].LastUsedAt) +} + +func TestHandleProfiles_List_MemberOmitsSwitchIDs(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = false + env.ClientRole = string(permissions.RoleMember) + + mockUserDB.On("ListProfiles").Return([]database.Profile{*testProfileRow(t, "1234")}, nil) + + result, err := HandleProfiles(env) + require.NoError(t, err) + resp, ok := result.(models.ProfilesResponse) + require.True(t, ok) + require.Len(t, resp.Profiles, 1) + assert.Equal(t, "profile-1", resp.Profiles[0].ProfileID) + + // Switch IDs are bearer credentials: a member client must never see + // them, in the struct or on the wire. + assert.Empty(t, resp.Profiles[0].SwitchID) + raw, err := json.Marshal(resp) + require.NoError(t, err) + assert.NotContains(t, string(raw), "switchId") + assert.NotContains(t, string(raw), "corn-arm-truck") +} + +func TestHandleProfiles_List_UnpairedRemoteSeesSwitchIDs(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = false + env.ClientRole = "" // no paired identity + + mockUserDB.On("ListProfiles").Return([]database.Profile{*testProfileRow(t, "1234")}, nil) + + // A remote request with no paired identity is treated as admin: it + // predates the permission system (plaintext WS while encryption is + // off) and already has full API access, so hiding switch IDs from it + // would protect nothing. Enforcement starts when service.encryption + // requires pairing. + result, err := HandleProfiles(env) + require.NoError(t, err) + resp, ok := result.(models.ProfilesResponse) + require.True(t, ok) + require.Len(t, resp.Profiles, 1) + assert.Equal(t, "corn-arm-truck", resp.Profiles[0].SwitchID) +} + +func TestHandleProfilesUpdate_MemberForbidden(t *testing.T) { + t.Parallel() + env, _, _ := newProfilesEnv(t) + env.IsLocal = false + env.ClientRole = string(permissions.RoleMember) + + // A member client cannot mutate profiles — profiles.update could + // clear PINs and remove limits. + env.Params = json.RawMessage(`{"profileId": "profile-1", "clearPin": true}`) + _, err := HandleProfilesUpdate(env) + require.ErrorIs(t, err, ErrForbidden) + + _, err = HandleProfilesDelete(env) + require.Error(t, err) + + env.Params = json.RawMessage(`{"name": "Me"}`) + _, err = HandleProfilesNew(env) + require.ErrorIs(t, err, ErrForbidden) +} + +func TestHandleProfilesNew(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = true + + mockUserDB.On("ListProfiles").Return([]database.Profile{}, nil).Once() + mockUserDB.On("CreateProfile", mock.Anything).Run(func(args mock.Arguments) { + args.Get(0).(*database.Profile).Role = profiles.ProfileRoleAdmin + }).Return(nil) + + env.Params = json.RawMessage(`{"name": "Kid A", "pin": "1234", "dailyLimit": "2h"}`) + result, err := HandleProfilesNew(env) + require.NoError(t, err) + resp, ok := result.(models.ProfileResponse) + require.True(t, ok) + assert.Equal(t, "Kid A", resp.Name) + assert.NotEmpty(t, resp.SwitchID) + assert.Equal(t, profiles.ProfileRoleAdmin, resp.Role) + assert.True(t, resp.HasPIN) + require.NotNil(t, resp.DailyLimit) + assert.Equal(t, "2h", *resp.DailyLimit) +} + +func TestHandleProfilesNew_InvalidParams(t *testing.T) { + t.Parallel() + env, _, _ := newProfilesEnv(t) + + // Missing required name. + env.Params = json.RawMessage(`{"pin": "1234"}`) + _, err := HandleProfilesNew(env) + require.Error(t, err) + + // Non-numeric PIN. + env.Params = json.RawMessage(`{"name": "Kid A", "pin": "abcd"}`) + _, err = HandleProfilesNew(env) + require.Error(t, err) + + // Bad duration. + env.Params = json.RawMessage(`{"name": "Kid A", "dailyLimit": "2 hours"}`) + _, err = HandleProfilesNew(env) + require.Error(t, err) +} + +func TestHandleProfilesSwitch_PINFlow(t *testing.T) { + t.Parallel() + env, mockUserDB, st := newProfilesEnv(t) + + mockUserDB.On("GetProfile", "profile-1").Return(testProfileRow(t, "1234"), nil) + + // Missing PIN. + env.Params = json.RawMessage(`{"profileId": "profile-1"}`) + _, err := HandleProfilesSwitch(env) + require.Error(t, err) + assert.Contains(t, err.Error(), "PIN") + + // Wrong PIN. + env.Params = json.RawMessage(`{"profileId": "profile-1", "pin": "9999"}`) + _, err = HandleProfilesSwitch(env) + require.Error(t, err) + + assert.Nil(t, st.ActiveProfile()) + + // Correct PIN. + mockUserDB.On("ActivateProfile", "profile-1", mock.AnythingOfType("int64")).Return(nil) + env.Params = json.RawMessage(`{"profileId": "profile-1", "pin": "1234"}`) + result, err := HandleProfilesSwitch(env) + require.NoError(t, err) + active, ok := result.(*models.ActiveProfile) + require.True(t, ok) + assert.Equal(t, "profile-1", active.ProfileID) + require.NotNil(t, st.ActiveProfile()) +} + +func TestHandleProfilesSwitch_BySwitchIDNeedsNoPIN(t *testing.T) { + t.Parallel() + env, mockUserDB, st := newProfilesEnv(t) + + mockUserDB.On("GetProfileBySwitchID", "corn-arm-truck").Return(testProfileRow(t, "1234"), nil) + mockUserDB.On("ActivateProfile", "profile-1", mock.AnythingOfType("int64")).Return(nil) + + // The switch ID is a bearer credential: presenting it authorizes the + // switch with no PIN, identically to scanning the card it's written on. + env.Params = json.RawMessage(`{"switchId": "corn-arm-truck"}`) + result, err := HandleProfilesSwitch(env) + require.NoError(t, err) + active, ok := result.(*models.ActiveProfile) + require.True(t, ok) + assert.Equal(t, "profile-1", active.ProfileID) + require.NotNil(t, st.ActiveProfile()) +} + +func TestHandleProfilesSwitch_DeactivateIsFree(t *testing.T) { + t.Parallel() + env, mockUserDB, st := newProfilesEnv(t) + + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A", HasPIN: true}) + mockUserDB.On("DeleteDeviceState", database.DeviceStateKeyActiveProfile).Return(nil) + + // No params at all = deactivate; PINs gate entry only. + env.Params = nil + result, err := HandleProfilesSwitch(env) + require.NoError(t, err) + active, ok := result.(*models.ActiveProfile) + require.True(t, ok) + assert.Nil(t, active) + assert.Nil(t, st.ActiveProfile()) +} + +func TestHandleProfilesActive(t *testing.T) { + t.Parallel() + env, _, st := newProfilesEnv(t) + + result, err := HandleProfilesActive(env) + require.NoError(t, err) + assert.Nil(t, result.(*models.ActiveProfile)) + + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A"}) + result, err = HandleProfilesActive(env) + require.NoError(t, err) + active, ok := result.(*models.ActiveProfile) + require.True(t, ok) + assert.Equal(t, "profile-1", active.ProfileID) +} + +func TestHandleProfilesDelete(t *testing.T) { + t.Parallel() + env, mockUserDB, st := newProfilesEnv(t) + + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A"}) + mockUserDB.On("DeleteProfile", "profile-1").Return(nil) + + env.Params = json.RawMessage(`{"profileId": "profile-1"}`) + result, err := HandleProfilesDelete(env) + require.NoError(t, err) + assert.Equal(t, NoContent{}, result) + assert.Nil(t, st.ActiveProfile(), "deleting the active profile deactivates it") +} + +func TestHandleProfilesUpdate_LocalRecoveryPromotesAdmin(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + env.IsLocal = true + member := testProfileRow(t, "") + member.Role = profiles.ProfileRoleMember + mockUserDB.On("ListProfiles").Return([]database.Profile{*member}, nil) + mockUserDB.On("GetProfile", member.ProfileID).Return(member, nil) + mockUserDB.On("UpdateProfile", mock.MatchedBy(func(p *database.Profile) bool { + return p.Role == profiles.ProfileRoleAdmin && p.PINHash != "" + })).Return(nil) + env.Params = json.RawMessage(`{"profileId":"profile-1","role":"admin","pin":"1234"}`) + + result, err := HandleProfilesUpdate(env) + require.NoError(t, err) + resp, ok := result.(models.ProfileResponse) + require.True(t, ok) + assert.Equal(t, profiles.ProfileRoleAdmin, resp.Role) + assert.True(t, resp.HasPIN) +} + +func TestHandleProfilesUpdate_ClearPIN(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + + mockUserDB.On("GetProfile", "profile-1").Return(testProfileRow(t, "1234"), nil) + mockUserDB.On("UpdateProfile", mock.MatchedBy(func(p *database.Profile) bool { + return p.PINHash == "" + })).Return(nil) + + env.Params = json.RawMessage(`{"profileId": "profile-1", "clearPin": true}`) + result, err := HandleProfilesUpdate(env) + require.NoError(t, err) + resp, ok := result.(models.ProfileResponse) + require.True(t, ok) + assert.False(t, resp.HasPIN) + mockUserDB.AssertExpectations(t) +} + +func TestHandleProfilesVerify(t *testing.T) { + t.Parallel() + env, mockUserDB, st := newProfilesEnv(t) + + mockUserDB.On("GetProfile", "profile-1").Return(testProfileRow(t, "1234"), nil) + mockUserDB.On("GetProfileBySwitchID", "corn-arm-truck").Return(testProfileRow(t, "1234"), nil) + + // Wrong PIN fails. + env.Params = json.RawMessage(`{"profileId": "profile-1", "pin": "9999"}`) + _, err := HandleProfilesVerify(env) + require.Error(t, err) + + // Missing PIN fails when the profile has one. + env.Params = json.RawMessage(`{"profileId": "profile-1"}`) + _, err = HandleProfilesVerify(env) + require.Error(t, err) + assert.Contains(t, err.Error(), "PIN") + + // Correct PIN verifies and returns the identity. + env.Params = json.RawMessage(`{"profileId": "profile-1", "pin": "1234"}`) + result, err := HandleProfilesVerify(env) + require.NoError(t, err) + resp, ok := result.(models.ProfileVerifyResponse) + require.True(t, ok) + assert.Equal(t, "profile-1", resp.ProfileID) + assert.Equal(t, "Kid A", resp.Name) + assert.True(t, resp.HasPIN) + + // Switch ID is a bearer credential: resolving it is the verification. + env.Params = json.RawMessage(`{"switchId": "corn-arm-truck"}`) + result, err = HandleProfilesVerify(env) + require.NoError(t, err) + resp, ok = result.(models.ProfileVerifyResponse) + require.True(t, ok) + assert.Equal(t, "profile-1", resp.ProfileID) + + // Verification grants nothing: no profile was activated by any of the + // above, and no device state was written (the mock would have failed + // on an unexpected SetDeviceState call). + assert.Nil(t, st.ActiveProfile()) + + // Neither selector is an invalid request. + env.Params = json.RawMessage(`{}`) + _, err = HandleProfilesVerify(env) + require.Error(t, err) + + // Both selectors is an ambiguous request, rejected rather than + // silently preferring one. + env.Params = json.RawMessage(`{"profileId": "profile-1", "switchId": "corn-arm-truck", "pin": "1234"}`) + _, err = HandleProfilesVerify(env) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one") +} + +func TestHandleProfilesSwitch_RejectsBothSelectors(t *testing.T) { + t.Parallel() + env, _, st := newProfilesEnv(t) + + env.Params = json.RawMessage(`{"profileId": "profile-1", "switchId": "corn-arm-truck", "pin": "1234"}`) + _, err := HandleProfilesSwitch(env) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one") + assert.Nil(t, st.ActiveProfile()) +} + +func TestHandleProfilesVerify_SharesRateLimiterWithSwitch(t *testing.T) { + t.Parallel() + env, mockUserDB, _ := newProfilesEnv(t) + + mockUserDB.On("GetProfile", "profile-1").Return(testProfileRow(t, "1234"), nil) + + // Exhaust the PIN attempt limit through verify... + env.Params = json.RawMessage(`{"profileId": "profile-1", "pin": "9999"}`) + for range 5 { + _, err := HandleProfilesVerify(env) + require.Error(t, err) + } + + // ...and switching is now rate limited too, even with the correct PIN: + // verify cannot be used as a brute-force oracle that sidesteps the + // switch path's protection. + env.Params = json.RawMessage(`{"profileId": "profile-1", "pin": "1234"}`) + _, err := HandleProfilesSwitch(env) + require.Error(t, err) + assert.Contains(t, err.Error(), "too many PIN attempts") +} diff --git a/pkg/api/methods/settings.go b/pkg/api/methods/settings.go index 6e522042a..10f7ebc88 100644 --- a/pkg/api/methods/settings.go +++ b/pkg/api/methods/settings.go @@ -27,6 +27,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/api/permissions" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" @@ -70,10 +71,13 @@ func HandleSettings(env requests.RequestEnv) (any, error) { //nolint:gocritic // ReadersConnect: readersConnect, SystemDefaults: systemDefaults, ErrorReporting: env.Config.ErrorReporting(), + Encryption: env.Config.EncryptionEnabled(), LaunchGuardEnabled: env.Config.LaunchGuardEnabled(), LaunchGuardTimeout: env.Config.LaunchGuardTimeout(), LaunchGuardDelay: env.Config.LaunchGuardDelay(), LaunchGuardRequireConfirm: env.Config.LaunchGuardRequireConfirm(), + ProfilesRequireForLaunch: env.Config.ProfilesRequireForLaunch(), + ProfilesSwapData: env.Config.ProfilesSwapData(), } resp.ReadersScanIgnoreSystem = append(resp.ReadersScanIgnoreSystem, env.Config.ReadersScan().IgnoreSystem...) @@ -124,6 +128,18 @@ func HandleSettingsUpdate(env requests.RequestEnv) (any, error) { log.Warn().Err(err).Msg("invalid params") return nil, models.ClientErrf("invalid params: %w", err) } + // Encryption policy can only be changed from the device itself. Remote + // settings changes otherwise require an admin client. + if params.Encryption != nil && !env.IsLocal { + return nil, models.ClientErrf("encryption setting: %w", ErrLocalhostOnly) + } + // Local profile PIN prompts are enforced by the UI before sensitive + // settings requests. + if !env.IsLocal { + if err := requireCapability(&env, permissions.CapSettingsWrite); err != nil { + return nil, err + } + } // Pre-flight validation of inputs that depend on runtime state. Run before // any mutations are applied so a validation failure here does not leave @@ -183,6 +199,11 @@ func HandleSettingsUpdate(env requests.RequestEnv) (any, error) { env.Config.SetErrorReporting(*params.ErrorReporting) } + if params.Encryption != nil { + log.Debug().Bool("encryption", *params.Encryption).Msg("updating setting") + env.Config.SetEncryptionEnabled(*params.Encryption) + } + if params.ReadersScanMode != nil { log.Debug().Str("readersScanMode", *params.ReadersScanMode).Msg("updating setting") // empty string defaults to tap mode @@ -223,6 +244,21 @@ func HandleSettingsUpdate(env requests.RequestEnv) (any, error) { env.Config.SetLaunchGuardRequireConfirm(*params.LaunchGuardRequireConfirm) } + if params.ProfilesRequireForLaunch != nil { + log.Debug().Bool("profilesRequireForLaunch", *params.ProfilesRequireForLaunch).Msg("updating setting") + env.Config.SetProfilesRequireForLaunch(*params.ProfilesRequireForLaunch) + } + + if params.ProfilesSwapData != nil { + log.Debug().Bool("profilesSwapData", *params.ProfilesSwapData).Msg("updating setting") + env.Config.SetProfilesSwapData(*params.ProfilesSwapData) + // Converge mounts immediately: turning swapping off restores the + // shared data state rather than waiting for the next switch. + if env.Profiles != nil { + env.Profiles.ReconcileData() + } + } + if params.ReadersConnect != nil { log.Debug().Int("count", len(*params.ReadersConnect)).Msg("updating readers.connect") connections := make([]config.ReadersConnect, 0, len(*params.ReadersConnect)) @@ -341,6 +377,11 @@ func HandlePlaytimeLimitsUpdate(env requests.RequestEnv) (any, error) { log.Warn().Err(err).Msg("invalid params") return nil, models.ClientErrf("invalid params: %w", err) } + if !env.IsLocal { + if err := requireCapability(&env, permissions.CapSettingsWrite); err != nil { + return nil, err + } + } // Reload config from disk before applying mutations so that external // edits (e.g. user hand-editing config.toml) are not lost on save. diff --git a/pkg/api/methods/settings_test.go b/pkg/api/methods/settings_test.go index 6a2790370..9854ced67 100644 --- a/pkg/api/methods/settings_test.go +++ b/pkg/api/methods/settings_test.go @@ -29,6 +29,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/api/permissions" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" corehelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" @@ -237,6 +238,24 @@ func TestHandleSettings_ReaderConnections(t *testing.T) { assert.Empty(t, resp.ReadersConnect[1].Path) } +func TestHandleSettings_ReportsEncryptionSetting(t *testing.T) { + t.Parallel() + + cfg, err := config.NewConfig(t.TempDir(), config.Values{ + Service: config.Service{Encryption: true}, + }) + require.NoError(t, err) + mockPlatform := mocks.NewMockPlatform() + appState, ns := state.NewState(mockPlatform, "test-boot-uuid") + t.Cleanup(func() { drainCh(ns) }) + + result, err := HandleSettings(requests.RequestEnv{Config: cfg, State: appState}) + require.NoError(t, err) + resp, ok := result.(models.SettingsResponse) + require.True(t, ok) + assert.True(t, resp.Encryption) +} + // TestHandleSettings_EmptyReaderConnections tests that HandleSettings returns // an empty slice when no reader connections are configured. func TestHandleSettings_EmptyReaderConnections(t *testing.T) { @@ -271,6 +290,52 @@ func TestHandleSettings_EmptyReaderConnections(t *testing.T) { // TestHandleSettingsUpdate_ReaderConnections tests that HandleSettingsUpdate // properly updates reader connection configuration. +func TestHandleSettingsUpdate_RemoteMemberCannotChangeProfileGate(t *testing.T) { + t.Parallel() + env := requests.RequestEnv{ + ClientRole: string(permissions.RoleMember), + Params: json.RawMessage(`{"profilesRequireForLaunch":false}`), + } + + _, err := HandleSettingsUpdate(env) + require.ErrorIs(t, err, ErrForbidden) +} + +func TestHandleSettingsUpdate_EncryptionLocalOnly(t *testing.T) { + t.Parallel() + + enabled := true + params, err := json.Marshal(models.UpdateSettingsParams{Encryption: &enabled}) + require.NoError(t, err) + + _, err = HandleSettingsUpdate(requests.RequestEnv{ + ClientRole: string(permissions.RoleAdmin), + Params: params, + }) + require.ErrorIs(t, err, ErrLocalhostOnly) + + cfg, err := config.NewConfig(t.TempDir(), config.Values{}) + require.NoError(t, err) + _, err = HandleSettingsUpdate(requests.RequestEnv{ + Config: cfg, + IsLocal: true, + Params: params, + }) + require.NoError(t, err) + assert.True(t, cfg.EncryptionEnabled()) +} + +func TestHandlePlaytimeLimitsUpdate_RemoteMemberForbidden(t *testing.T) { + t.Parallel() + env := requests.RequestEnv{ + ClientRole: string(permissions.RoleMember), + Params: json.RawMessage(`{"enabled":false}`), + } + + _, err := HandlePlaytimeLimitsUpdate(env) + require.ErrorIs(t, err, ErrForbidden) +} + func TestHandleSettingsUpdate_ReaderConnections(t *testing.T) { t.Parallel() diff --git a/pkg/api/middleware/encryption.go b/pkg/api/middleware/encryption.go index 9570b749e..c7c6bc887 100644 --- a/pkg/api/middleware/encryption.go +++ b/pkg/api/middleware/encryption.go @@ -124,6 +124,12 @@ func (cs *ClientSession) AuthToken() string { return cs.client.AuthToken } +// ClientRole returns the paired client's permission role (immutable after +// construction, no lock needed). +func (cs *ClientSession) ClientRole() string { + return cs.client.Role +} + // DecryptIncoming decrypts with the next expected counter. Caller should // close the WebSocket on error. func (cs *ClientSession) DecryptIncoming(ciphertext []byte) ([]byte, error) { diff --git a/pkg/api/models/models.go b/pkg/api/models/models.go index 9abaf881b..426208c62 100644 --- a/pkg/api/models/models.go +++ b/pkg/api/models/models.go @@ -39,6 +39,16 @@ const ( NotificationPlaytimeLimitWarning = "playtime.limit.warning" NotificationInboxAdded = "inbox.added" NotificationClientsPaired = "clients.paired" + NotificationProfilesActive = "profiles.active" + NotificationProfilesData = "profiles.data" +) + +// Profile data swap statuses reported by the profiles.data notification. +const ( + ProfilesDataApplied = "applied" + ProfilesDataDeferred = "deferred" + ProfilesDataFailed = "failed" + ProfilesDataUnavailable = "unavailable" ) const ( @@ -93,6 +103,13 @@ const ( MethodClientsDelete = "clients.delete" MethodClientsPairStart = "clients.pair.start" MethodClientsPairCancel = "clients.pair.cancel" + MethodProfiles = "profiles" + MethodProfilesNew = "profiles.new" + MethodProfilesUpdate = "profiles.update" + MethodProfilesDelete = "profiles.delete" + MethodProfilesActive = "profiles.active" + MethodProfilesSwitch = "profiles.switch" + MethodProfilesVerify = "profiles.verify" MethodSystems = "systems" MethodLaunchers = "launchers" MethodLaunchersRefresh = "launchers.refresh" diff --git a/pkg/api/models/params.go b/pkg/api/models/params.go index 511586582..cefccfc6f 100644 --- a/pkg/api/models/params.go +++ b/pkg/api/models/params.go @@ -143,6 +143,7 @@ type UpdateSettingsParams struct { AudioScanFeedback *bool `json:"audioScanFeedback"` ReadersAutoDetect *bool `json:"readersAutoDetect"` ErrorReporting *bool `json:"errorReporting"` + Encryption *bool `json:"encryption"` UpdateChannel *string `json:"updateChannel" validate:"omitempty,oneof=stable beta"` ReadersScanMode *string `json:"readersScanMode" validate:"omitempty,oneof=tap hold"` ReadersScanExitDelay *float32 `json:"readersScanExitDelay" validate:"omitempty,gte=0"` @@ -154,6 +155,8 @@ type UpdateSettingsParams struct { LaunchGuardTimeout *float32 `json:"launchGuardTimeout" validate:"omitempty,gte=-1"` LaunchGuardDelay *float32 `json:"launchGuardDelay" validate:"omitempty,gte=0"` LaunchGuardRequireConfirm *bool `json:"launchGuardRequireConfirm"` + ProfilesRequireForLaunch *bool `json:"profilesRequireForLaunch"` + ProfilesSwapData *bool `json:"profilesSwapData"` } type UpdatePlaytimeLimitsParams struct { @@ -173,6 +176,64 @@ type DeleteClientParams struct { ID string `json:"id" validate:"required,min=1"` } +// ClientsPairStartParams configures a new pairing flow. Role is the +// permission role the paired client will receive ("admin" or "member"); +// empty defaults to member. +type ClientsPairStartParams struct { + Role string `json:"role" validate:"omitempty,oneof=admin member"` +} + +// NewProfileParams creates a profile. Nil limit fields inherit the global +// config; a "0" duration means explicitly unlimited. +type NewProfileParams struct { + PIN *string `json:"pin" validate:"omitempty,numeric,min=4,max=8"` + LimitsEnabled *bool `json:"limitsEnabled"` + DailyLimit *string `json:"dailyLimit" validate:"omitempty,duration"` + SessionLimit *string `json:"sessionLimit" validate:"omitempty,duration"` + Name string `json:"name" validate:"required,min=1,max=255"` + Role string `json:"role" validate:"omitempty,oneof=admin member"` +} + +// UpdateProfileParams updates a profile. Omitted fields are unchanged. +// ClearPIN removes the PIN; ClearLimits resets all limit overrides back to +// inheriting global config before any limit fields in the same request are +// applied (clear-then-set); RegenerateSwitchID issues a new switch ID +// (lost-card replacement). +type UpdateProfileParams struct { + Name *string `json:"name" validate:"omitempty,min=1,max=255"` + PIN *string `json:"pin" validate:"omitempty,numeric,min=4,max=8"` + LimitsEnabled *bool `json:"limitsEnabled"` + DailyLimit *string `json:"dailyLimit" validate:"omitempty,duration"` + SessionLimit *string `json:"sessionLimit" validate:"omitempty,duration"` + Role *string `json:"role" validate:"omitempty,oneof=admin member"` + ProfileID string `json:"profileId" validate:"required,min=1"` + ClearPIN bool `json:"clearPin"` + ClearLimits bool `json:"clearLimits"` + RegenerateSwitchID bool `json:"regenerateSwitchId"` +} + +type DeleteProfileParams struct { + ProfileID string `json:"profileId" validate:"required,min=1"` +} + +// SwitchProfileParams switches the device's active profile. Exactly one of +// ProfileID or SwitchID selects the target; both omitted (or null) means +// deactivate. PIN is required when the target profile has one set. +type SwitchProfileParams struct { + ProfileID *string `json:"profileId"` + SwitchID *string `json:"switchId"` + PIN *string `json:"pin"` +} + +// VerifyProfileParams verifies a profile credential without switching. +// Exactly one of ProfileID (with PIN when the profile has one) or SwitchID +// (a bearer credential) must be given. +type VerifyProfileParams struct { + ProfileID *string `json:"profileId"` + SwitchID *string `json:"switchId"` + PIN *string `json:"pin"` +} + type MediaStartedParams struct { SystemID string `json:"systemId" validate:"required"` SystemName string `json:"systemName" validate:"required"` diff --git a/pkg/api/models/requests/requests.go b/pkg/api/models/requests/requests.go index 5632c75be..70504dd40 100644 --- a/pkg/api/models/requests/requests.go +++ b/pkg/api/models/requests/requests.go @@ -30,6 +30,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "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" ) @@ -45,6 +46,7 @@ type RequestEnv struct { State *state.State Database *database.Database LimitsManager *playtime.LimitsManager + Profiles *profiles.Service LauncherCache *helpers.LauncherCache Player audio.Player PlaybackManager audio.PlaybackManager @@ -53,6 +55,10 @@ type RequestEnv struct { IndexPauser *syncutil.Pauser ScrapePauser *syncutil.Pauser ClientID string - Params json.RawMessage - IsLocal bool + // ClientRole is the paired client's permission role ("admin" or + // "member"), or "" when the request carries no paired identity (local + // connections, plaintext WebSocket, HTTP). See pkg/api/permissions. + ClientRole string + Params json.RawMessage + IsLocal bool } diff --git a/pkg/api/models/responses.go b/pkg/api/models/responses.go index a61093856..eb874b412 100644 --- a/pkg/api/models/responses.go +++ b/pkg/api/models/responses.go @@ -113,17 +113,20 @@ type SettingsResponse struct { ReadersScanIgnoreSystem []string `json:"readersScanIgnoreSystems"` ReadersConnect []ReaderConnection `json:"readersConnect"` SystemDefaults []SystemDefault `json:"systemDefaults"` - ReadersScanExitDelay float32 `json:"readersScanExitDelay"` + AudioVolume int `json:"audioVolume"` LaunchGuardTimeout float32 `json:"launchGuardTimeout"` LaunchGuardDelay float32 `json:"launchGuardDelay"` - AudioVolume int `json:"audioVolume"` + ReadersScanExitDelay float32 `json:"readersScanExitDelay"` RunZapScript bool `json:"runZapScript"` DebugLogging bool `json:"debugLogging"` AudioScanFeedback bool `json:"audioScanFeedback"` ReadersAutoDetect bool `json:"readersAutoDetect"` ErrorReporting bool `json:"errorReporting"` + Encryption bool `json:"encryption"` LaunchGuardEnabled bool `json:"launchGuardEnabled"` LaunchGuardRequireConfirm bool `json:"launchGuardRequireConfirm"` + ProfilesRequireForLaunch bool `json:"profilesRequireForLaunch"` + ProfilesSwapData bool `json:"profilesSwapData"` } type PlaytimeLimitsResponse struct { @@ -607,6 +610,7 @@ type InboxResponse struct { type PairedClient struct { ClientID string `json:"clientId"` ClientName string `json:"clientName"` + Role string `json:"role"` CreatedAt int64 `json:"createdAt"` LastSeenAt int64 `json:"lastSeenAt"` } @@ -634,6 +638,72 @@ type ClientsPairedNotification struct { ClientName string `json:"clientName"` } +// ProfileResponse represents a device profile in API responses. The PIN +// hash is never exposed — only whether a PIN is set. SwitchID is a bearer +// credential (presenting it authorizes a PIN-free switch on every path), +// so it is only included for privileged clients that need it for +// card-writing UX; for other clients it is omitted. +type ProfileResponse struct { + LimitsEnabled *bool `json:"limitsEnabled,omitempty"` + DailyLimit *string `json:"dailyLimit,omitempty"` + SessionLimit *string `json:"sessionLimit,omitempty"` + LastUsedAt *int64 `json:"lastUsedAt,omitempty"` + ProfileID string `json:"profileId"` + Name string `json:"name"` + Role string `json:"role"` + SwitchID string `json:"switchId,omitempty"` + CreatedAt int64 `json:"createdAt"` + LastUpdatedAt int64 `json:"lastUpdatedAt"` + HasPIN bool `json:"hasPin"` +} + +// ProfilesResponse is the response for the profiles RPC method. +type ProfilesResponse struct { + Profiles []ProfileResponse `json:"profiles"` +} + +// ProfileVerifyResponse is the response for the profiles.verify RPC +// method: the identity of the profile whose credential was verified. +// Verification grants nothing server-side — the client owns whatever it +// unlocks with it. +type ProfileVerifyResponse struct { + ProfileID string `json:"profileId"` + Name string `json:"name"` + Role string `json:"role"` + HasPIN bool `json:"hasPin"` +} + +// ActiveProfile is a snapshot of the device's active profile, held in +// service state and broadcast on the profiles.active notification. It +// carries the resolved limit overrides so the playtime hot path never +// touches the database. Nil limit fields mean "inherit global config". +type ActiveProfile struct { + LimitsEnabled *bool `json:"limitsEnabled,omitempty"` + DailyLimit *string `json:"dailyLimit,omitempty"` + SessionLimit *string `json:"sessionLimit,omitempty"` + ProfileID string `json:"profileId"` + Name string `json:"name"` + Role string `json:"role"` + HasPIN bool `json:"hasPin"` +} + +// ProfilesActiveNotification is the payload for the profiles.active +// notification. Profile is null when the device has no active profile. +type ProfilesActiveNotification struct { + Profile *ActiveProfile `json:"profile"` +} + +// ProfilesDataNotification is the payload for the profiles.data +// notification, reporting the state of profile data swapping (save files +// etc.) after a profile change. ProfileID is empty for the shared profile. +// Status is one of the ProfilesData* constants; Reason is a human-readable +// explanation for failed/unavailable statuses. +type ProfilesDataNotification struct { + ProfileID string `json:"profileId"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + type SettingsAuthClaimResponse struct { Domains []string `json:"domains"` } diff --git a/pkg/api/models/responses_test.go b/pkg/api/models/responses_test.go index dddf8b1c8..95a13c354 100644 --- a/pkg/api/models/responses_test.go +++ b/pkg/api/models/responses_test.go @@ -250,6 +250,7 @@ func TestPairedClient_JSONShape(t *testing.T) { pc := PairedClient{ ClientID: "client-123", ClientName: "Test Client", + Role: "member", CreatedAt: 1700000000, LastSeenAt: 1700001000, } @@ -262,6 +263,7 @@ func TestPairedClient_JSONShape(t *testing.T) { expectedKeys := map[string]bool{ "clientId": true, "clientName": true, + "role": true, "createdAt": true, "lastSeenAt": true, } diff --git a/pkg/api/notifications/notifications.go b/pkg/api/notifications/notifications.go index ca7c5726d..1bbb41fb8 100644 --- a/pkg/api/notifications/notifications.go +++ b/pkg/api/notifications/notifications.go @@ -132,3 +132,16 @@ func InboxAdded(ns chan<- models.Notification, payload *models.InboxMessage) { func ClientsPaired(ns chan<- models.Notification, payload models.ClientsPairedNotification) { sendNotification(ns, models.NotificationClientsPaired, payload) } + +// ProfilesActiveChanged broadcasts a change of the device's active profile. +// The payload profile is null when the device deactivated to no profile. +func ProfilesActiveChanged(ns chan<- models.Notification, payload models.ProfilesActiveNotification) { + sendNotification(ns, models.NotificationProfilesActive, payload) +} + +// ProfilesDataChanged broadcasts the state of profile data swapping after +// a profile change (applied, deferred until media stops, failed, or +// unavailable on this platform/storage setup). +func ProfilesDataChanged(ns chan<- models.Notification, payload models.ProfilesDataNotification) { + sendNotification(ns, models.NotificationProfilesData, payload) +} diff --git a/pkg/api/pairing.go b/pkg/api/pairing.go index 350988938..a6e9a902f 100644 --- a/pkg/api/pairing.go +++ b/pkg/api/pairing.go @@ -40,6 +40,7 @@ import ( apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware" "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/api/permissions" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" "github.com/google/uuid" @@ -112,6 +113,7 @@ type pairingSession struct { pake *pake.Pake sessionID string name string + role string // permission role the paired client will receive msgA []byte // raw bytes received from client at /pair/start msgB []byte // raw bytes sent to client at /pair/start sessionKey []byte // raw PAKE session key @@ -124,6 +126,7 @@ type PairingManager struct { notifChan chan<- models.Notification sessions map[string]*pairingSession pin string + pendingRole string // role granted to the client paired with the current PIN pinAttempts int maxClients int maxAttempts int @@ -214,8 +217,19 @@ func (m *PairingManager) PendingPIN() (pin string, expiresAt time.Time) { return m.pin, m.pinExpiresAt } +// CountClients returns the number of currently paired clients. +func (m *PairingManager) CountClients() (int, error) { + count, err := m.db.CountClients() + if err != nil { + return 0, fmt.Errorf("count paired clients: %w", err) + } + return count, nil +} + // StartPairing generates a new PIN (fails fast if clients are at max). -func (m *PairingManager) StartPairing() (pin string, expiresAt time.Time, err error) { +// role is the permission role the paired client will receive; it is chosen +// at this approval step because starting a pairing is a local-only action. +func (m *PairingManager) StartPairing(role string) (pin string, expiresAt time.Time, err error) { m.mu.Lock() defer m.mu.Unlock() @@ -239,6 +253,7 @@ func (m *PairingManager) StartPairing() (pin string, expiresAt time.Time, err er m.pin = pin m.pinExpiresAt = time.Now().Add(m.pinTTL) m.pinAttempts = 0 + m.pendingRole = role // Drop any leftover sessions from a previous PIN — they cannot succeed. m.sessions = make(map[string]*pairingSession) return pin, m.pinExpiresAt, nil @@ -350,6 +365,7 @@ func (m *PairingManager) startSession(name string, msgA []byte) (sessionID strin msgA: append([]byte(nil), msgA...), msgB: append([]byte(nil), msgB...), name: name, + role: m.pendingRole, createdAt: time.Now(), } return sessionID, msgB, nil @@ -437,11 +453,16 @@ func (m *PairingManager) finishSessionLocked( return nil, nil, errTooManyClients } + role := sess.role + if !permissions.ValidRole(role) { + role = string(permissions.RoleMember) + } now := time.Now().Unix() c := &database.Client{ ClientID: uuid.New().String(), ClientName: sess.name, AuthToken: uuid.New().String(), + Role: role, PairingKey: derivedPairingKey, CreatedAt: now, LastSeenAt: now, diff --git a/pkg/api/pairing_fuzz_test.go b/pkg/api/pairing_fuzz_test.go index df22c4dee..54ffbd16b 100644 --- a/pkg/api/pairing_fuzz_test.go +++ b/pkg/api/pairing_fuzz_test.go @@ -70,7 +70,7 @@ func FuzzStartSession(f *testing.F) { notifChan := make(chan models.Notification, 16) mgr := NewPairingManager(db, notifChan) - _, _, err := mgr.StartPairing() + _, _, err := mgr.StartPairing("member") if err != nil { t.Fatalf("StartPairing failed: %v", err) } diff --git a/pkg/api/pairing_test.go b/pkg/api/pairing_test.go index 8e50bda75..248b6203b 100644 --- a/pkg/api/pairing_test.go +++ b/pkg/api/pairing_test.go @@ -38,6 +38,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/crypto" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" "github.com/rs/zerolog" @@ -90,7 +91,7 @@ func newPairingHarness(t *testing.T, opts ...PairingOption) *pairingTestHarness // that need wrong-PIN behavior can call the lower-level methods directly. func (h *pairingTestHarness) runHandshake( t *testing.T, - pin, name string, + pin, name, expectedRole string, ) (clientResp *database.Client, pairingKey []byte) { t.Helper() clientPake, err := pake.InitCurve([]byte(pin), 0, pairingCurve) @@ -127,15 +128,28 @@ func (h *pairingTestHarness) runHandshake( expectedServer := computePairingHMAC(confirmKeyB, "server", name, msgA, msgB) require.Equal(t, expectedServer, result.ServerHMAC, "server HMAC must match what client computes") require.Equal(t, derivedPairingKey, result.Client.PairingKey, "pairing keys must agree") + require.Equal(t, expectedRole, result.Client.Role, + "pairing completion must preserve the role chosen at approval") return result.Client, result.Client.PairingKey } +func TestSuccessfulHandshake_AdminRole(t *testing.T) { + t.Parallel() + h := newPairingHarness(t) + + pin, _, err := h.mgr.StartPairing("admin") + require.NoError(t, err) + + client, _ := h.runHandshake(t, pin, "Admin App", string(permissions.RoleAdmin)) + assert.Equal(t, string(permissions.RoleAdmin), client.Role) +} + func TestStartPairing_GeneratesPIN(t *testing.T) { t.Parallel() h := newPairingHarness(t) - pin, expiresAt, err := h.mgr.StartPairing() + pin, expiresAt, err := h.mgr.StartPairing("member") require.NoError(t, err) assert.Len(t, pin, pairingPINLength) @@ -149,9 +163,9 @@ func TestStartPairing_AlreadyInProgress(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) - _, _, err = h.mgr.StartPairing() + _, _, err = h.mgr.StartPairing("member") require.ErrorIs(t, err, errPairingInProgress) } @@ -159,10 +173,10 @@ func TestStartPairing_AfterCancel(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) h.mgr.CancelPairing() - _, _, err = h.mgr.StartPairing() + _, _, err = h.mgr.StartPairing("member") require.NoError(t, err, "should be able to start a new pairing after cancel") } @@ -170,15 +184,25 @@ func TestStartPairing_AfterExpiry(t *testing.T) { t.Parallel() h := newPairingHarness(t, WithPairingPINTTL(50*time.Millisecond)) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) time.Sleep(100 * time.Millisecond) - _, _, err = h.mgr.StartPairing() + _, _, err = h.mgr.StartPairing("member") require.NoError(t, err, "expired PIN should not block a new one") } +func TestSuccessfulHandshake_InvalidRoleFallsBackToMember(t *testing.T) { + t.Parallel() + h := newPairingHarness(t) + + pin, _, err := h.mgr.StartPairing("invalid") + require.NoError(t, err) + client, _ := h.runHandshake(t, pin, "Test App", string(permissions.RoleMember)) + assert.Equal(t, string(permissions.RoleMember), client.Role) +} + func TestPendingPIN_Empty(t *testing.T) { t.Parallel() h := newPairingHarness(t) @@ -191,7 +215,7 @@ func TestPendingPIN_Active(t *testing.T) { t.Parallel() h := newPairingHarness(t) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) gotPIN, _ := h.mgr.PendingPIN() @@ -202,7 +226,7 @@ func TestPendingPIN_Expired(t *testing.T) { t.Parallel() h := newPairingHarness(t, WithPairingPINTTL(5*time.Millisecond)) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) time.Sleep(15 * time.Millisecond) @@ -218,7 +242,7 @@ func TestStartSession_RejectsOversizedPakeMessage(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) huge := make([]byte, pairingMaxPakeMessageBytes+1) @@ -230,10 +254,10 @@ func TestSuccessfulHandshake(t *testing.T) { t.Parallel() h := newPairingHarness(t) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) - c, pairingKey := h.runHandshake(t, pin, "Test App") + c, pairingKey := h.runHandshake(t, pin, "Test App", string(permissions.RoleMember)) assert.NotEmpty(t, c.ClientID) assert.NotEmpty(t, c.AuthToken) @@ -257,7 +281,7 @@ func TestWrongPIN_Rejected(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) // Use a wrong PIN — different session key, HMAC will not match. @@ -290,7 +314,7 @@ func TestMaxAttempts_PINInvalidatedAfterExhaustion(t *testing.T) { t.Parallel() h := newPairingHarness(t, WithPairingMaxAttempts(2)) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) for i := range 2 { @@ -317,7 +341,7 @@ func TestPairStart_NameTooLong(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte("000000"), 0, pairingCurve) @@ -330,7 +354,7 @@ func TestPairStart_NameEmpty(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte("000000"), 0, pairingCurve) @@ -353,7 +377,7 @@ func TestPairStart_PINExpired(t *testing.T) { t.Parallel() h := newPairingHarness(t, WithPairingPINTTL(5*time.Millisecond)) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) time.Sleep(15 * time.Millisecond) @@ -367,7 +391,7 @@ func TestPairFinish_SessionExpired(t *testing.T) { t.Parallel() h := newPairingHarness(t, WithPairingSessionTTL(5*time.Millisecond)) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte(pin), 0, pairingCurve) @@ -399,7 +423,7 @@ func TestPairFinish_ConcurrentCallsOneWins(t *testing.T) { t.Parallel() h := newPairingHarness(t) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte(pin), 0, pairingCurve) @@ -467,7 +491,7 @@ func TestMaxClients_StartPairingFailsFast(t *testing.T) { db.On("CountClients").Return(50, nil) mgr := NewPairingManager(db, notifChan, WithPairingMaxClients(50)) - _, _, err := mgr.StartPairing() + _, _, err := mgr.StartPairing("member") require.ErrorIs(t, err, errTooManyClients) } @@ -487,7 +511,7 @@ func TestMaxClients_FinishSessionDefenseInDepth(t *testing.T) { db.On("CountClients").Return(50, nil) mgr := NewPairingManager(db, notifChan, WithPairingMaxClients(50)) - pin, _, err := mgr.StartPairing() + pin, _, err := mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte(pin), 0, pairingCurve) @@ -517,7 +541,7 @@ func TestStartPairing_WipesOldSessions(t *testing.T) { t.Parallel() h := newPairingHarness(t) - pin1, _, err := h.mgr.StartPairing() + pin1, _, err := h.mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte(pin1), 0, pairingCurve) @@ -528,7 +552,7 @@ func TestStartPairing_WipesOldSessions(t *testing.T) { require.NoError(t, err) h.mgr.CancelPairing() - _, _, err = h.mgr.StartPairing() + _, _, err = h.mgr.StartPairing("member") require.NoError(t, err) // Old session should no longer be findable. @@ -540,7 +564,7 @@ func TestHTTPHandlers_FullFlow(t *testing.T) { t.Parallel() h := newPairingHarness(t) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) startHandler := h.mgr.HandlePairStart() @@ -639,7 +663,7 @@ func TestHandlePairFinish_AuditLogsHMACMismatch(t *testing.T) { t.Cleanup(func() { log.Logger = originalLogger }) h := newPairingHarness(t) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) // Drive a wrong-PIN handshake at the HTTP layer so the handler runs @@ -717,7 +741,7 @@ func TestHandlePairFinish_AuditLogsExhaustion(t *testing.T) { t.Cleanup(func() { log.Logger = originalLogger }) h := newPairingHarness(t, WithPairingMaxAttempts(1)) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) // One allowed attempt → first failure trips errPairingExhausted. @@ -812,7 +836,7 @@ func TestHTTPHandler_MalformedPakeMessage(t *testing.T) { t.Parallel() h := newPairingHarness(t) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) // Valid base64 but invalid PAKE wire format JSON inside. @@ -902,7 +926,7 @@ func TestCleanupExpired_RemovesOldSessions(t *testing.T) { WithPairingSessionTTL(5*time.Millisecond), ) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) clientPake, err := pake.InitCurve([]byte(pin), 0, pairingCurve) @@ -925,7 +949,7 @@ func TestCleanupExpired_RemovesExpiredPIN(t *testing.T) { t.Parallel() h := newPairingHarness(t, WithPairingPINTTL(5*time.Millisecond)) - _, _, err := h.mgr.StartPairing() + _, _, err := h.mgr.StartPairing("member") require.NoError(t, err) time.Sleep(15 * time.Millisecond) @@ -1021,7 +1045,7 @@ func TestPairFinish_PINExhaustionInvalidatesOtherInFlightSessions(t *testing.T) // the cross-session blast radius. h := newPairingHarness(t, WithPairingMaxAttempts(1)) - pin, _, err := h.mgr.StartPairing() + pin, _, err := h.mgr.StartPairing("member") require.NoError(t, err) // Client A starts a session. diff --git a/pkg/api/permissions/permissions.go b/pkg/api/permissions/permissions.go new file mode 100644 index 000000000..b3a3adecb --- /dev/null +++ b/pkg/api/permissions/permissions.go @@ -0,0 +1,123 @@ +// 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 permissions defines client roles and the capability lookup that +// gates privileged API methods. +// +// Three orthogonal properties describe a request's authority: +// +// - Locality: a connection from the device itself (loopback). Local +// means "physically at the device" — anyone with OS access owns the +// whole system anyway, so local connections default to admin. +// - Role: the identity of a paired client, chosen at pairing approval. +// - Session role: a voluntary downgrade a client declares for its own +// connection (e.g. a kiosk frontend restricting the UI it exposes). +// Reserved — nothing sets it yet, but the check honors it so kiosk +// support can land without touching handlers. +// +// Handlers never compare roles directly; they require a capability, and +// roles map to capability sets. Finer-grained roles later are new map +// entries, not handler changes. +// +// A remote request with no paired identity (plaintext WebSocket while +// service.encryption is off) is treated as admin: it predates the +// permission system and restricting it would break unpaired clients. +// Setting service.encryption = true requires every remote client to be +// paired, which is what makes member restrictions enforceable. +package permissions + +// Role is a paired client's permission level. +type Role string + +const ( + // RoleAdmin grants every capability. + RoleAdmin Role = "admin" + // RoleMember grants day-to-day use (browse, launch, switch profile + // with PIN) but none of the capabilities that could weaken another + // person's limits. + RoleMember Role = "member" +) + +// ValidRole reports whether s is a recognized role name. +func ValidRole(s string) bool { + return s == string(RoleAdmin) || s == string(RoleMember) +} + +// Capability names a privileged operation a handler can require. The +// guiding rule for what needs a capability: anything that can weaken +// playtime limits is admin. +type Capability string + +const ( + // CapProfilesManage covers creating, updating, and deleting device + // profiles, and reading profile switch IDs (bearer credentials that + // authorize PIN-free switching). + CapProfilesManage Capability = "profiles.manage" + // CapSettingsWrite covers device settings changes, which include + // disabling playtime limits and the require-profile launch gate. + CapSettingsWrite Capability = "settings.write" +) + +// roleCapabilities maps each role to its granted capabilities. +// +//nolint:gochecknoglobals // immutable capability table +var roleCapabilities = map[Role]map[Capability]bool{ + RoleAdmin: { + CapProfilesManage: true, + CapSettingsWrite: true, + }, + RoleMember: {}, +} + +// Grant describes the authority of a single request. +type Grant struct { + // Role is the paired client's stored role, or "" when the request + // carries no paired identity. + Role Role + // SessionRole is a voluntary downgrade declared by the client for + // this session. Empty means no downgrade. Reserved for kiosk mode. + SessionRole Role + // IsLocal is true for loopback connections. + IsLocal bool +} + +// EffectiveRole resolves the request's role: local connections and +// unpaired remote requests are admin (see the package doc for why), a +// paired identity uses its stored role (unknown values degrade to +// member), and a voluntary session downgrade to member always wins. +func (g Grant) EffectiveRole() Role { + role := RoleAdmin + if !g.IsLocal && g.Role != "" { + if g.Role == RoleAdmin { + role = RoleAdmin + } else { + // Member, or an unrecognized role: least privilege. + role = RoleMember + } + } + if g.SessionRole == RoleMember { + role = RoleMember + } + return role +} + +// Has reports whether the request may perform the given capability. +func (g Grant) Has(capability Capability) bool { + return roleCapabilities[g.EffectiveRole()][capability] +} diff --git a/pkg/api/permissions/permissions_test.go b/pkg/api/permissions/permissions_test.go new file mode 100644 index 000000000..15bb9498c --- /dev/null +++ b/pkg/api/permissions/permissions_test.go @@ -0,0 +1,80 @@ +// 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 permissions + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGrant_EffectiveRole(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want Role + grant Grant + }{ + {name: "local is admin", grant: Grant{IsLocal: true}, want: RoleAdmin}, + {name: "local ignores stored member role", grant: Grant{IsLocal: true, Role: RoleMember}, want: RoleAdmin}, + {name: "paired admin", grant: Grant{Role: RoleAdmin}, want: RoleAdmin}, + {name: "paired member", grant: Grant{Role: RoleMember}, want: RoleMember}, + {name: "unknown role degrades to member", grant: Grant{Role: "superuser"}, want: RoleMember}, + {name: "unpaired remote is admin (transitional)", grant: Grant{}, want: RoleAdmin}, + { + name: "session downgrade wins over local", + grant: Grant{IsLocal: true, SessionRole: RoleMember}, + want: RoleMember, + }, + { + name: "session downgrade wins over admin", + grant: Grant{Role: RoleAdmin, SessionRole: RoleMember}, + want: RoleMember, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, tt.grant.EffectiveRole()) + }) + } +} + +func TestGrant_Has(t *testing.T) { + t.Parallel() + + admin := Grant{Role: RoleAdmin} + member := Grant{Role: RoleMember} + + assert.True(t, admin.Has(CapProfilesManage)) + assert.True(t, admin.Has(CapSettingsWrite)) + assert.False(t, member.Has(CapProfilesManage)) + assert.False(t, member.Has(CapSettingsWrite)) +} + +func TestValidRole(t *testing.T) { + t.Parallel() + + assert.True(t, ValidRole("admin")) + assert.True(t, ValidRole("member")) + assert.False(t, ValidRole("")) + assert.False(t, ValidRole("root")) +} diff --git a/pkg/api/server.go b/pkg/api/server.go index da07eab5e..e9a5e91b2 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -52,6 +52,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/broker" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "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" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/updater" @@ -326,6 +327,14 @@ func NewMethodMap() *MethodMap { // clients (paired API clients) models.MethodClients: methods.HandleClients, models.MethodClientsDelete: methods.HandleClientsDelete, + + models.MethodProfiles: methods.HandleProfiles, + models.MethodProfilesNew: methods.HandleProfilesNew, + models.MethodProfilesUpdate: methods.HandleProfilesUpdate, + models.MethodProfilesDelete: methods.HandleProfilesDelete, + models.MethodProfilesActive: methods.HandleProfilesActive, + models.MethodProfilesSwitch: methods.HandleProfilesSwitch, + models.MethodProfilesVerify: methods.HandleProfilesVerify, // auth models.MethodSettingsAuthClaim: func(env requests.RequestEnv) (any, error) { return methods.HandleSettingsAuthClaim(env, zapscript.FetchWellKnown) @@ -970,6 +979,7 @@ func handleWSMessage( confirmQueue chan<- chan error, db *database.Database, limitsManager *playtime.LimitsManager, + profilesSvc *profiles.Service, player audio.Player, playbackManager audio.PlaybackManager, indexPauser *syncutil.Pauser, @@ -1080,6 +1090,7 @@ func handleWSMessage( State: st, Database: db, LimitsManager: limitsManager, + Profiles: profilesSvc, LauncherCache: helpers.GlobalLauncherCache, Player: player, PlaybackManager: playbackManager, @@ -1090,6 +1101,9 @@ func handleWSMessage( IsLocal: isLocal, ClientID: session.Request.RemoteAddr, } + if cs != nil { + env.ClientRole = cs.ClientRole() + } if err := enqueueWSRequest(dispatcher, methodMap, &env, plaintext, cs, tracker); err != nil { log.Warn().Err(err).Msg("failed to queue websocket request") @@ -1280,6 +1294,7 @@ func handlePostRequest( confirmQueue chan<- chan error, db *database.Database, limitsManager *playtime.LimitsManager, + profilesSvc *profiles.Service, player audio.Player, playbackManager audio.PlaybackManager, indexPauser *syncutil.Pauser, @@ -1335,6 +1350,7 @@ func handlePostRequest( State: st, Database: db, LimitsManager: limitsManager, + Profiles: profilesSvc, LauncherCache: helpers.GlobalLauncherCache, Player: player, PlaybackManager: playbackManager, @@ -1404,6 +1420,7 @@ func Start( confirmQueue chan<- chan error, db *database.Database, limitsManager *playtime.LimitsManager, + profilesSvc *profiles.Service, notifBroker *broker.Broker, mdnsHostname string, player audio.Player, @@ -1413,7 +1430,7 @@ func Start( tracker RequestTracker, ) error { return StartWithReady( - platform, cfg, st, inTokenQueue, confirmQueue, db, limitsManager, + platform, cfg, st, inTokenQueue, confirmQueue, db, limitsManager, profilesSvc, notifBroker, mdnsHostname, player, playbackManager, indexPauser, scrapePauser, tracker, nil, ) } @@ -1429,6 +1446,7 @@ func StartWithReady( confirmQueue chan<- chan error, db *database.Database, limitsManager *playtime.LimitsManager, + profilesSvc *profiles.Service, notifBroker *broker.Broker, mdnsHostname string, player audio.Player, @@ -1699,7 +1717,7 @@ func StartWithReady( postHandler := handlePostRequest( methodMap, platform, cfg, st, inTokenQueue, confirmQueue, - db, limitsManager, player, playbackManager, + db, limitsManager, profilesSvc, player, playbackManager, indexPauser, scrapePauser, tracker, ) r.Post("/api", postHandler) @@ -1742,7 +1760,7 @@ func StartWithReady( rateLimiter, handleWSMessage( methodMap, platform, cfg, st, inTokenQueue, confirmQueue, - db, limitsManager, player, playbackManager, indexPauser, scrapePauser, encGateway, + db, limitsManager, profilesSvc, player, playbackManager, indexPauser, scrapePauser, encGateway, lastSeenTracker, tracker, ), )) diff --git a/pkg/api/server_post_test.go b/pkg/api/server_post_test.go index 68bcb29b7..e6c58c709 100644 --- a/pkg/api/server_post_test.go +++ b/pkg/api/server_post_test.go @@ -117,7 +117,7 @@ func createTestPostHandler(t *testing.T) (http.HandlerFunc, *MethodMap, *fakeReq handler := handlePostRequest( methodMap, platform, cfg, st, tokenQueue, confirmQueue, db, - nil, nil, playbackManager, nil, nil, tracker, + nil, nil, nil, playbackManager, nil, nil, tracker, ) return handler, methodMap, tracker } @@ -145,7 +145,7 @@ func TestHandlePostRequest_InjectsPlaybackManager(t *testing.T) { confirmQueue := make(chan chan error, 1) handler := handlePostRequest( methodMap, platform, cfg, st, tokenQueue, confirmQueue, db, - nil, nil, playbackManager, nil, nil, nil, + nil, nil, nil, playbackManager, nil, nil, nil, ) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.playback"}` diff --git a/pkg/api/server_startup_test.go b/pkg/api/server_startup_test.go index bf3e08a4e..9ddfd1832 100644 --- a/pkg/api/server_startup_test.go +++ b/pkg/api/server_startup_test.go @@ -78,7 +78,7 @@ func TestStartWithReadyReportsBindFailure(t *testing.T) { go func() { serverErr <- StartWithReady( platform, cfg, st, tokenQueue, nil, db, - nil, notifBroker, "", nil, nil, nil, nil, nil, ready, + nil, nil, notifBroker, "", nil, nil, nil, nil, nil, ready, ) }() @@ -143,7 +143,7 @@ func TestServerStartupConcurrency(t *testing.T) { defer close(serverDone) serverErr <- StartWithReady( platform, cfg, st, tokenQueue, nil, db, - nil, notifBroker, "", nil, nil, nil, nil, nil, ready, + nil, nil, notifBroker, "", nil, nil, nil, nil, nil, ready, ) }() // Cleanup: stop service first, then wait for server goroutine to fully exit @@ -225,7 +225,7 @@ func TestServerStartupImmediateConnection(t *testing.T) { serverErr := make(chan error, 1) go func() { defer close(serverDone) - serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, notifBroker, "", nil, nil, nil, nil, nil) + serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil) }() // Cleanup: stop service first, then wait for server goroutine to fully exit defer func() { @@ -310,7 +310,7 @@ func TestServerListenContextCancellation(t *testing.T) { go func() { defer close(done) - serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, notifBroker, "", nil, nil, nil, nil, nil) + serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil) }() // Wait for completion or timeout @@ -674,7 +674,9 @@ func TestServerBindFailureStopsService(t *testing.T) { server1Err := make(chan error, 1) go func() { defer close(server1Done) - server1Err <- Start(platform1, cfg1, st1, tokenQueue1, nil, db1, nil, notifBroker1, "", nil, nil, nil, nil, nil) + server1Err <- Start( + platform1, cfg1, st1, tokenQueue1, nil, db1, nil, nil, notifBroker1, "", nil, nil, nil, nil, nil, + ) }() // Wait for first server to be ready @@ -718,7 +720,9 @@ func TestServerBindFailureStopsService(t *testing.T) { server2Err := make(chan error, 1) go func() { defer close(server2Done) - server2Err <- Start(platform2, cfg2, st2, tokenQueue2, nil, db2, nil, notifBroker2, "", nil, nil, nil, nil, nil) + server2Err <- Start( + platform2, cfg2, st2, tokenQueue2, nil, db2, nil, nil, notifBroker2, "", nil, nil, nil, nil, nil, + ) }() // Wait for the second server's context to be cancelled (StopService called) @@ -991,7 +995,7 @@ func TestSSE_ReceivesNotifications(t *testing.T) { serverErr := make(chan error, 1) go func() { defer close(serverDone) - serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, notifBroker, "", nil, nil, nil, nil, nil) + serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil) }() defer func() { st.StopService() diff --git a/pkg/api/server_ws_e2e_test.go b/pkg/api/server_ws_e2e_test.go index 8f6b6c431..3f449c874 100644 --- a/pkg/api/server_ws_e2e_test.go +++ b/pkg/api/server_ws_e2e_test.go @@ -155,7 +155,7 @@ func TestWSInjectsPlaybackManager(t *testing.T) { m := newWebSocketSession() m.HandleMessage(handleWSMessage( methodMap, platform, cfg, st, make(chan tokens.Token, 1), make(chan chan error, 1), db, - nil, nil, playbackManager, nil, nil, nil, nil, nil, + nil, nil, nil, playbackManager, nil, nil, nil, nil, nil, )) mux := http.NewServeMux() diff --git a/pkg/api/ws_dispatcher_test.go b/pkg/api/ws_dispatcher_test.go index 4e5dd718b..471be13d0 100644 --- a/pkg/api/ws_dispatcher_test.go +++ b/pkg/api/ws_dispatcher_test.go @@ -61,7 +61,7 @@ func startPriorityWSServer(t *testing.T, methodMap *MethodMap) (wsURL string, cl }) m.HandleMessage(handleWSMessage( methodMap, nil, cfg, st, nil, nil, - nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, )) diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 9f94f948a..04cf64f0d 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -21,6 +21,7 @@ package cli import ( "context" + "crypto/rand" "encoding/json" "errors" "flag" @@ -42,20 +43,23 @@ import ( ) type Flags struct { - Write *string - Read *bool - Run *string - Launch *string - API *string - Version *bool - Config *bool - ShowLoader *string - ShowPicker *string - Reload *bool - Pair *bool - Backup *bool - Backups *bool - Restore *string + Write *string + Read *bool + Run *string + Launch *string + API *string + Version *bool + Config *bool + ShowLoader *string + ShowPicker *string + Reload *bool + Pair *bool + Backup *bool + Backups *bool + Restore *string + Profiles *bool + ProfileResetPIN *string + ProfileResetSwitchID *string } // SetupFlags defines all common CLI flags between platforms. @@ -121,6 +125,21 @@ func SetupFlags() *Flags { "", "restore the database from the named backup", ), + Profiles: flag.Bool( + "profiles", + false, + "list profiles for host recovery", + ), + ProfileResetPIN: flag.String( + "profile-reset-pin", + "", + "reset profile PIN to a generated value", + ), + ProfileResetSwitchID: flag.String( + "profile-reset-switch-id", + "", + "regenerate profile switch ID", + ), } } @@ -315,6 +334,44 @@ func (f *Flags) Post(cfg *config.Instance, _ platforms.Platform) { _, _ = fmt.Fprint(os.Stderr, "Pairing successful!\n") _, _ = fmt.Println(sanitizeForOutput(result)) os.Exit(0) + case *f.Profiles: + if err := listProfiles(context.Background(), cfg, os.Stdout, client.LocalClient); err != nil { + logClientCommandError(err, "error listing profiles") + _, _ = fmt.Fprintf(os.Stderr, "Error listing profiles: %v\n", err) + os.Exit(1) + } + os.Exit(0) + case isFlagPassed("profile-reset-pin"): + if *f.ProfileResetPIN == "" { + _, _ = fmt.Fprint(os.Stderr, "Error: profile-reset-pin requires a profile ID\n") + os.Exit(1) + } + pin, err := resetProfilePIN( + context.Background(), cfg, *f.ProfileResetPIN, rand.Reader, client.LocalClient, + ) + if err != nil { + logClientCommandError(err, "error resetting profile PIN") + _, _ = fmt.Fprintf(os.Stderr, "Error resetting profile PIN: %v\n", err) + os.Exit(1) + } + _, _ = fmt.Printf("Profile %s PIN: %s\n", sanitizeForOutput(*f.ProfileResetPIN), pin) + os.Exit(0) + case isFlagPassed("profile-reset-switch-id"): + if *f.ProfileResetSwitchID == "" { + _, _ = fmt.Fprint(os.Stderr, "Error: profile-reset-switch-id requires a profile ID\n") + os.Exit(1) + } + switchID, err := resetProfileSwitchID( + context.Background(), cfg, *f.ProfileResetSwitchID, client.LocalClient, + ) + if err != nil { + logClientCommandError(err, "error resetting profile switch ID") + _, _ = fmt.Fprintf(os.Stderr, "Error resetting profile switch ID: %v\n", err) + os.Exit(1) + } + _, _ = fmt.Printf("Profile %s switch ID: %s\n", + sanitizeForOutput(*f.ProfileResetSwitchID), sanitizeForOutput(switchID)) + os.Exit(0) case *f.Reload: _, err := client.LocalClient(context.Background(), cfg, models.MethodSettingsReload, "") if err != nil { diff --git a/pkg/cli/profiles.go b/pkg/cli/profiles.go new file mode 100644 index 000000000..8a6d7ffd4 --- /dev/null +++ b/pkg/cli/profiles.go @@ -0,0 +1,139 @@ +// 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 cli + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "text/tabwriter" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" +) + +const generatedProfilePINLength = 8 + +type profileAPICaller func( + ctx context.Context, + cfg *config.Instance, + method string, + params string, +) (string, error) + +func listProfiles( + ctx context.Context, + cfg *config.Instance, + out io.Writer, + call profileAPICaller, +) error { + resp, err := call(ctx, cfg, models.MethodProfiles, "") + if err != nil { + return fmt.Errorf("failed to list profiles: %w", err) + } + + var profiles models.ProfilesResponse + if err := json.Unmarshal([]byte(resp), &profiles); err != nil { + return fmt.Errorf("failed to parse profiles response: %w", err) + } + + w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(w, "PROFILE ID\tROLE\tNAME"); err != nil { + return fmt.Errorf("failed to write profiles: %w", err) + } + for i := range profiles.Profiles { + profile := &profiles.Profiles[i] + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeForOutput(profile.ProfileID), + sanitizeForOutput(profile.Role), + sanitizeForOutput(profile.Name), + ); err != nil { + return fmt.Errorf("failed to write profiles: %w", err) + } + } + if err := w.Flush(); err != nil { + return fmt.Errorf("failed to flush profiles: %w", err) + } + return nil +} + +func resetProfilePIN( + ctx context.Context, + cfg *config.Instance, + profileID string, + random io.Reader, + call profileAPICaller, +) (string, error) { + pin, err := generateProfilePIN(random) + if err != nil { + return "", err + } + params, err := json.Marshal(models.UpdateProfileParams{ + ProfileID: profileID, + PIN: &pin, + }) + if err != nil { + return "", fmt.Errorf("failed to encode profile PIN reset: %w", err) + } + if _, err := call(ctx, cfg, models.MethodProfilesUpdate, string(params)); err != nil { + return "", fmt.Errorf("failed to reset profile PIN: %w", err) + } + return pin, nil +} + +func resetProfileSwitchID( + ctx context.Context, + cfg *config.Instance, + profileID string, + call profileAPICaller, +) (string, error) { + params, err := json.Marshal(models.UpdateProfileParams{ + ProfileID: profileID, + RegenerateSwitchID: true, + }) + if err != nil { + return "", fmt.Errorf("failed to encode switch ID reset: %w", err) + } + resp, err := call(ctx, cfg, models.MethodProfilesUpdate, string(params)) + if err != nil { + return "", fmt.Errorf("failed to reset profile switch ID: %w", err) + } + var profile models.ProfileResponse + if err := json.Unmarshal([]byte(resp), &profile); err != nil { + return "", fmt.Errorf("failed to parse profile response: %w", err) + } + if profile.SwitchID == "" { + return "", errors.New("profile response did not include a switch ID") + } + return profile.SwitchID, nil +} + +func generateProfilePIN(random io.Reader) (string, error) { + limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(generatedProfilePINLength), nil) + value, err := rand.Int(random, limit) + if err != nil { + return "", fmt.Errorf("failed to generate profile PIN: %w", err) + } + return fmt.Sprintf("%0*d", generatedProfilePINLength, value.Int64()), nil +} diff --git a/pkg/cli/profiles_test.go b/pkg/cli/profiles_test.go new file mode 100644 index 000000000..3d7531d39 --- /dev/null +++ b/pkg/cli/profiles_test.go @@ -0,0 +1,90 @@ +// 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 cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListProfiles(t *testing.T) { + t.Parallel() + call := func( + _ context.Context, _ *config.Instance, method, params string, + ) (string, error) { + assert.Equal(t, models.MethodProfiles, method) + assert.Empty(t, params) + return `{"profiles":[{"profileId":"parent","name":"Parent","role":"admin"},` + + `{"profileId":"kid","name":"Kid\nInjected","role":"member"}]}`, nil + } + + var out strings.Builder + err := listProfiles(context.Background(), nil, &out, call) + require.NoError(t, err) + assert.Contains(t, out.String(), "PROFILE ID") + assert.Contains(t, out.String(), "parent admin Parent") + assert.Contains(t, out.String(), "kid member KidInjected") +} + +func TestResetProfilePIN_GeneratesAndUpdates(t *testing.T) { + t.Parallel() + call := func( + _ context.Context, _ *config.Instance, method, params string, + ) (string, error) { + assert.Equal(t, models.MethodProfilesUpdate, method) + var update models.UpdateProfileParams + require.NoError(t, json.Unmarshal([]byte(params), &update)) + assert.Equal(t, "profile-1", update.ProfileID) + require.NotNil(t, update.PIN) + assert.Equal(t, "00000000", *update.PIN) + return `{"profileId":"profile-1"}`, nil + } + + pin, err := resetProfilePIN( + context.Background(), nil, "profile-1", strings.NewReader(strings.Repeat("\x00", 64)), call, + ) + require.NoError(t, err) + assert.Equal(t, "00000000", pin) +} + +func TestResetProfileSwitchID_ReturnsReplacement(t *testing.T) { + t.Parallel() + call := func( + _ context.Context, _ *config.Instance, method, params string, + ) (string, error) { + assert.Equal(t, models.MethodProfilesUpdate, method) + var update models.UpdateProfileParams + require.NoError(t, json.Unmarshal([]byte(params), &update)) + assert.Equal(t, "profile-1", update.ProfileID) + assert.True(t, update.RegenerateSwitchID) + return `{"profileId":"profile-1","switchId":"new-switch-id"}`, nil + } + + switchID, err := resetProfileSwitchID(context.Background(), nil, "profile-1", call) + require.NoError(t, err) + assert.Equal(t, "new-switch-id", switchID) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4ef690ce0..b1f2ce55b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -63,6 +63,7 @@ type Values struct { Service Service `toml:"service,omitempty"` Launchers Launchers `toml:"launchers,omitempty"` Playtime Playtime `toml:"playtime,omitempty"` + Profiles Profiles `toml:"profiles,omitempty"` Media Media `toml:"media,omitempty"` ZapScript ZapScript `toml:"zapscript,omitempty"` Mappings Mappings `toml:"mappings,omitempty"` diff --git a/pkg/config/configencryption.go b/pkg/config/configencryption.go index b69508de9..cc8499b85 100644 --- a/pkg/config/configencryption.go +++ b/pkg/config/configencryption.go @@ -22,8 +22,8 @@ package config // EncryptionEnabled returns whether WebSocket encryption is enabled. When // true, remote WebSocket clients must send an encrypted first frame derived // from a paired key; plaintext WebSocket connections from non-loopback -// addresses are rejected. When false (the default), all WebSocket -// connections are accepted as plaintext and API key authentication applies. +// addresses are rejected. When false (the default), plaintext and encrypted +// WebSocket connections are both accepted. // // Localhost connections are always allowed plaintext regardless of this // setting. diff --git a/pkg/config/configprofiles.go b/pkg/config/configprofiles.go new file mode 100644 index 000000000..f16461a48 --- /dev/null +++ b/pkg/config/configprofiles.go @@ -0,0 +1,65 @@ +// 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 config + +// Profiles configures device profile behavior. +type Profiles struct { + RequireForLaunch *bool `toml:"require_for_launch,omitempty"` + SwapData *bool `toml:"swap_data,omitempty"` +} + +// ProfilesRequireForLaunch returns true when media launches are blocked +// while no profile is active. Defaults to false: a profile-less device +// behaves exactly as before profiles existed. +func (c *Instance) ProfilesRequireForLaunch() bool { + c.mu.RLock() + defer c.mu.RUnlock() + if c.vals.Profiles.RequireForLaunch == nil { + return false + } + return *c.vals.Profiles.RequireForLaunch +} + +// SetProfilesRequireForLaunch enables or disables the require-profile +// launch gate. +func (c *Instance) SetProfilesRequireForLaunch(required bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.vals.Profiles.RequireForLaunch = &required +} + +// ProfilesSwapData returns true when profile switches also swap +// profile-scoped data (save files, save states) on platforms that support +// it. Defaults to true: data ownership is the point of profiles. +func (c *Instance) ProfilesSwapData() bool { + c.mu.RLock() + defer c.mu.RUnlock() + if c.vals.Profiles.SwapData == nil { + return true + } + return *c.vals.Profiles.SwapData +} + +// SetProfilesSwapData enables or disables profile data swapping. +func (c *Instance) SetProfilesSwapData(swap bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.vals.Profiles.SwapData = &swap +} diff --git a/pkg/config/configprofiles_test.go b/pkg/config/configprofiles_test.go new file mode 100644 index 000000000..33e7df6ff --- /dev/null +++ b/pkg/config/configprofiles_test.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 config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfilesRequireForLaunch(t *testing.T) { + t.Parallel() + + cfg, err := NewConfig(t.TempDir(), BaseDefaults) + require.NoError(t, err) + + // Default is false: profiles are purely additive. + assert.False(t, cfg.ProfilesRequireForLaunch()) + + cfg.SetProfilesRequireForLaunch(true) + assert.True(t, cfg.ProfilesRequireForLaunch()) + + cfg.SetProfilesRequireForLaunch(false) + assert.False(t, cfg.ProfilesRequireForLaunch()) +} + +func TestProfilesRequireForLaunch_TOML(t *testing.T) { + t.Parallel() + + cfg, err := NewConfig(t.TempDir(), BaseDefaults) + require.NoError(t, err) + + require.NoError(t, cfg.LoadTOML(`[profiles] +require_for_launch = true`)) + assert.True(t, cfg.ProfilesRequireForLaunch()) +} diff --git a/pkg/config/configservice.go b/pkg/config/configservice.go index e3164b1b7..0e51dea82 100644 --- a/pkg/config/configservice.go +++ b/pkg/config/configservice.go @@ -52,8 +52,8 @@ type Service struct { AllowedIPs []string `toml:"allowed_ips,omitempty"` Publishers Publishers `toml:"publishers,omitempty"` // Encryption enables PAKE pairing + AES-256-GCM on the WebSocket - // transport. False (the default) accepts plaintext WebSocket connections - // with API key authentication. True requires paired clients for remote + // transport. False (the default) accepts plaintext and encrypted WebSocket + // connections. True requires paired clients for remote // WebSocket connections; localhost is always exempt. Encryption bool `toml:"encryption,omitempty"` } diff --git a/pkg/database/database.go b/pkg/database/database.go index 4d4bfd81a..17a7881b8 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -74,6 +74,7 @@ type MediaHistoryEntry struct { EndTime *time.Time `json:"endTime,omitempty"` SyncedAt *time.Time `json:"syncedAt,omitempty"` DeviceID *string `json:"deviceId,omitempty"` + ProfileID *string `json:"profileId,omitempty"` BootUUID string `json:"bootUuid,omitempty"` ClockSource string `json:"clockSource,omitempty"` SystemID string `json:"systemId"` @@ -123,12 +124,39 @@ type InboxMessage struct { ProfileID int64 `json:"profileId"` } +// Profile represents a device profile: a named bucket of preferences and +// limits with no credentials. PINHash is hidden from JSON +// (API uses models.ProfileResponse instead). Nil limit fields mean +// "inherit the global config value"; a "0" duration string means +// "explicitly unlimited". +type Profile struct { + LimitsEnabled *bool `json:"limitsEnabled,omitempty"` + DailyLimit *string `json:"dailyLimit,omitempty"` + SessionLimit *string `json:"sessionLimit,omitempty"` + LastUsedAt *int64 `json:"lastUsedAt,omitempty"` + ProfileID string `json:"profileId"` + Name string `json:"name"` + Role string `json:"role"` + SwitchID string `json:"switchId"` + PINHash string `json:"-"` + DBID int64 `json:"-"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// DeviceStateKeyActiveProfile is the DeviceState key holding the +// ProfileID of the device's active profile. +const DeviceStateKeyActiveProfile = "active_profile_id" + // Client represents a paired API client. AuthToken and PairingKey are // hidden from JSON (API uses models.PairedClient instead). type Client struct { ClientID string `json:"clientId"` ClientName string `json:"clientName"` AuthToken string `json:"-"` + // Role is the client's permission role ("admin" or "member"), chosen + // at pairing approval. See pkg/api/permissions. + Role string `json:"role"` PairingKey []byte `json:"-"` DBID int64 `json:"-"` CreatedAt int64 `json:"createdAt"` @@ -768,6 +796,7 @@ type UserDBI interface { CleanupMediaHistory(retentionDays int) (int64, error) HealTimestamps(bootUUID string, trueBootTime time.Time) (int64, error) SumMediaPlayTimeForDay(dayStart time.Time) (int64, error) + SumMediaPlayTimeForDayByProfile(dayStart time.Time, profileID string) (int64, error) AddMapping(m *Mapping) error GetMapping(id int64) (Mapping, error) DeleteMapping(id int64) error @@ -796,6 +825,16 @@ type UserDBI interface { DeleteClient(clientID string) error UpdateClientLastSeen(authToken string, lastSeenAt int64) error CountClients() (int, error) + CreateProfile(p *Profile) error + GetProfile(profileID string) (*Profile, error) + GetProfileBySwitchID(switchID string) (*Profile, error) + ListProfiles() ([]Profile, error) + UpdateProfile(p *Profile) error + ActivateProfile(profileID string, lastUsedAt int64) error + DeleteProfile(profileID string) error + SetDeviceState(key, value string) error + GetDeviceState(key string) (string, bool, error) + DeleteDeviceState(key string) error Backup(reason string, manual bool) (BackupInfo, error) EnsureRecentBackup(maxAge time.Duration) (BackupInfo, bool, error) ListBackups() ([]BackupInfo, error) diff --git a/pkg/database/userdb/clients.go b/pkg/database/userdb/clients.go index f5eeea2e0..2222da240 100644 --- a/pkg/database/userdb/clients.go +++ b/pkg/database/userdb/clients.go @@ -20,9 +20,14 @@ package userdb import ( + "errors" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" ) +// ErrLastClientAdmin is returned when deleting the final admin client. +var ErrLastClientAdmin = errors.New("cannot delete the last admin client") + func (db *UserDB) CreateClient(c *database.Client) error { return sqlCreateClient(db.ctx, db.sql.Load(), c) } diff --git a/pkg/database/userdb/clients_test.go b/pkg/database/userdb/clients_test.go index 38d0a402a..818774c47 100644 --- a/pkg/database/userdb/clients_test.go +++ b/pkg/database/userdb/clients_test.go @@ -21,6 +21,7 @@ package userdb import ( "context" + "database/sql" "testing" "github.com/DATA-DOG/go-sqlmock" @@ -31,14 +32,14 @@ import ( ) const ( - clientSelectByTokenRe = `SELECT DBID, ClientID, ClientName, AuthToken, ` + + clientSelectByTokenRe = `SELECT DBID, ClientID, ClientName, AuthToken, Role, ` + `PairingKey, CreatedAt, LastSeenAt FROM Clients WHERE AuthToken = \?` - clientSelectListRe = `SELECT DBID, ClientID, ClientName, AuthToken, ` + + clientSelectListRe = `SELECT DBID, ClientID, ClientName, AuthToken, Role, ` + `PairingKey, CreatedAt, LastSeenAt FROM Clients ORDER BY CreatedAt DESC` ) var clientRowColumns = []string{ - "DBID", "ClientID", "ClientName", "AuthToken", "PairingKey", "CreatedAt", "LastSeenAt", + "DBID", "ClientID", "ClientName", "AuthToken", "Role", "PairingKey", "CreatedAt", "LastSeenAt", } func newTestClient() *database.Client { @@ -47,6 +48,7 @@ func newTestClient() *database.Client { ClientID: "client-uuid-1", ClientName: "Test App", AuthToken: "auth-token-uuid", + Role: "admin", PairingKey: []byte("0123456789abcdef0123456789abcdef"), CreatedAt: 1700000000, LastSeenAt: 1700000100, @@ -61,7 +63,7 @@ func TestSqlCreateClient_Success(t *testing.T) { c := newTestClient() mock.ExpectQuery(`INSERT INTO Clients`). - WithArgs(c.ClientID, c.ClientName, c.AuthToken, c.PairingKey, c.CreatedAt, c.LastSeenAt). + WithArgs(c.ClientID, c.ClientName, c.AuthToken, c.Role, c.PairingKey, c.CreatedAt, c.LastSeenAt). WillReturnRows(sqlmock.NewRows([]string{"DBID"}).AddRow(int64(42))) err = sqlCreateClient(context.Background(), db, c) @@ -95,7 +97,7 @@ func TestSqlCreateClient_DatabaseError(t *testing.T) { c := newTestClient() mock.ExpectQuery(`INSERT INTO Clients`). - WithArgs(c.ClientID, c.ClientName, c.AuthToken, c.PairingKey, c.CreatedAt, c.LastSeenAt). + WithArgs(c.ClientID, c.ClientName, c.AuthToken, c.Role, c.PairingKey, c.CreatedAt, c.LastSeenAt). WillReturnError(sqlmock.ErrCancelled) err = sqlCreateClient(context.Background(), db, c) @@ -114,7 +116,7 @@ func TestSqlGetClientByToken_Success(t *testing.T) { mock.ExpectQuery(clientSelectByTokenRe). WithArgs(c.AuthToken). WillReturnRows(sqlmock.NewRows(clientRowColumns). - AddRow(int64(7), c.ClientID, c.ClientName, c.AuthToken, c.PairingKey, c.CreatedAt, c.LastSeenAt)) + AddRow(int64(7), c.ClientID, c.ClientName, c.AuthToken, c.Role, c.PairingKey, c.CreatedAt, c.LastSeenAt)) got, err := sqlGetClientByToken(context.Background(), db, c.AuthToken) require.NoError(t, err) @@ -123,6 +125,7 @@ func TestSqlGetClientByToken_Success(t *testing.T) { assert.Equal(t, c.ClientID, got.ClientID) assert.Equal(t, c.ClientName, got.ClientName) assert.Equal(t, c.AuthToken, got.AuthToken) + assert.Equal(t, c.Role, got.Role) assert.Equal(t, c.PairingKey, got.PairingKey) assert.Equal(t, c.CreatedAt, got.CreatedAt) assert.Equal(t, c.LastSeenAt, got.LastSeenAt) @@ -155,8 +158,8 @@ func TestSqlListClients_Success(t *testing.T) { key1 := []byte("key-1-key-1-key-1-key-1-key-1-12") key2 := []byte("key-2-key-2-key-2-key-2-key-2-12") rows := sqlmock.NewRows(clientRowColumns). - AddRow(int64(1), "id-1", "App One", "tok-1", key1, int64(1000), int64(2000)). - AddRow(int64(2), "id-2", "App Two", "tok-2", key2, int64(1100), int64(2100)) + AddRow(int64(1), "id-1", "App One", "tok-1", "admin", key1, int64(1000), int64(2000)). + AddRow(int64(2), "id-2", "App Two", "tok-2", "member", key2, int64(1100), int64(2100)) mock.ExpectQuery(clientSelectListRe).WillReturnRows(rows) @@ -164,7 +167,9 @@ func TestSqlListClients_Success(t *testing.T) { require.NoError(t, err) require.Len(t, got, 2) assert.Equal(t, "id-1", got[0].ClientID) + assert.Equal(t, "admin", got[0].Role) assert.Equal(t, "id-2", got[1].ClientID) + assert.Equal(t, "member", got[1].Role) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -207,6 +212,9 @@ func TestSqlDeleteClient_NotFound(t *testing.T) { mock.ExpectExec(`DELETE FROM Clients WHERE ClientID = \?`). WithArgs("missing"). WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery(`SELECT Role FROM Clients WHERE ClientID = \?`). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) err = sqlDeleteClient(context.Background(), db, "missing") require.Error(t, err) @@ -214,6 +222,33 @@ func TestSqlDeleteClient_NotFound(t *testing.T) { assert.NoError(t, mock.ExpectationsWereMet()) } +func TestSqlDeleteClient_LastAdminProtected(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + first := newTestClient() + second := newTestClient() + second.ClientID = "client-uuid-2" + second.ClientName = "Second Admin" + second.AuthToken = "auth-token-uuid-2" + require.NoError(t, db.CreateClient(first)) + require.NoError(t, db.CreateClient(second)) + + err := sqlDeleteClient(context.Background(), db.sql.Load(), first.ClientID) + require.NoError(t, err) + _, err = db.GetClientByToken(first.AuthToken) + require.Error(t, err) + + err = sqlDeleteClient(context.Background(), db.sql.Load(), second.ClientID) + require.ErrorIs(t, err, ErrLastClientAdmin) + remaining, err := db.GetClientByToken(second.AuthToken) + require.NoError(t, err) + assert.Equal(t, second.ClientID, remaining.ClientID) +} + func TestSqlUpdateClientLastSeen_Success(t *testing.T) { t.Parallel() db, mock, err := testsqlmock.NewSQLMock() diff --git a/pkg/database/userdb/media_history.go b/pkg/database/userdb/media_history.go index 99e6c6412..cc45ccec0 100644 --- a/pkg/database/userdb/media_history.go +++ b/pkg/database/userdb/media_history.go @@ -61,7 +61,7 @@ func (db *UserDB) GetMediaHistory(systemIDs []string, lastID int64, limit int) ( if db.sql.Load() == nil { return nil, ErrNullSQL } - return sqlGetMediaHistory(db.ctx, db.sql.Load(), systemIDs, lastID, limit) + return sqlGetMediaHistory(db.ctx, db.sql.Load(), systemIDs, nil, lastID, limit) } // GetLatestMediaHistory retrieves the most recent media history entry with no enrichment. @@ -117,7 +117,19 @@ func (db *UserDB) SumMediaPlayTimeForDay(dayStart time.Time) (int64, error) { if db.sql.Load() == nil { return 0, ErrNullSQL } - return sqlSumMediaPlayTimeForDay(db.ctx, db.sql.Load(), dayStart) + return sqlSumMediaPlayTimeForDay(db.ctx, db.sql.Load(), dayStart, nil) +} + +// SumMediaPlayTimeForDayByProfile is SumMediaPlayTimeForDay scoped to +// history attributed to a single profile. History with no profile (the +// shared profile) is counted by SumMediaPlayTimeForDay, which sums all +// rows: shared limits are device-level, so deactivating a profile must not +// grant a fresh daily allowance. +func (db *UserDB) SumMediaPlayTimeForDayByProfile(dayStart time.Time, profileID string) (int64, error) { + if db.sql.Load() == nil { + return 0, ErrNullSQL + } + return sqlSumMediaPlayTimeForDay(db.ctx, db.sql.Load(), dayStart, &profileID) } /* @@ -129,8 +141,8 @@ func sqlAddMediaHistory(ctx context.Context, db *sql.DB, entry *database.MediaHi INSERT INTO MediaHistory( ID, StartTime, SystemID, SystemName, MediaPath, MediaName, LauncherID, PlayTime, BootUUID, MonotonicStart, DurationSec, WallDuration, TimeSkewFlag, - ClockReliable, ClockSource, CreatedAt, UpdatedAt, DeviceID - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + ClockReliable, ClockSource, CreatedAt, UpdatedAt, DeviceID, ProfileID + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); `) if err != nil { return 0, fmt.Errorf("failed to prepare media history insert statement: %w", err) @@ -145,6 +157,10 @@ func sqlAddMediaHistory(ctx context.Context, db *sql.DB, entry *database.MediaHi if entry.DeviceID != nil { deviceID = *entry.DeviceID } + var profileID any + if entry.ProfileID != nil { + profileID = *entry.ProfileID + } result, err := stmt.ExecContext(ctx, entry.ID, @@ -165,6 +181,7 @@ func sqlAddMediaHistory(ctx context.Context, db *sql.DB, entry *database.MediaHi entry.CreatedAt.Unix(), entry.UpdatedAt.Unix(), deviceID, + profileID, ) if err != nil { return 0, fmt.Errorf("failed to execute media history insert: %w", err) @@ -228,7 +245,7 @@ func sqlCloseMediaHistory(ctx context.Context, db *sql.DB, dbid int64, endTime t } func sqlGetMediaHistory( - ctx context.Context, db *sql.DB, systemIDs []string, lastID int64, limit int, + ctx context.Context, db *sql.DB, systemIDs []string, profileID *string, lastID int64, limit int, ) ([]database.MediaHistoryEntry, error) { if limit <= 0 { limit = 25 @@ -245,7 +262,7 @@ func sqlGetMediaHistory( } conditions := []string{"DBID < ?"} - args := make([]any, 0, len(systemIDs)+2) + args := make([]any, 0, len(systemIDs)+3) args = append(args, lastID) if len(systemIDs) == 1 { @@ -260,6 +277,11 @@ func sqlGetMediaHistory( conditions = append(conditions, "SystemID IN ("+strings.Join(placeholders, ", ")+")") } + if profileID != nil { + conditions = append(conditions, "ProfileID = ?") + args = append(args, *profileID) + } + where := strings.Join(conditions, " AND ") args = append(args, limit) queryStarted := time.Now() @@ -270,7 +292,7 @@ func sqlGetMediaHistory( DBID, ID, StartTime, EndTime, SystemID, SystemName, MediaPath, MediaName, LauncherID, PlayTime, BootUUID, MonotonicStart, DurationSec, WallDuration, TimeSkewFlag, - ClockReliable, ClockSource, CreatedAt, UpdatedAt, DeviceID + ClockReliable, ClockSource, CreatedAt, UpdatedAt, DeviceID, ProfileID FROM MediaHistory WHERE %s ORDER BY DBID DESC @@ -303,7 +325,7 @@ func sqlGetMediaHistory( var endTimeUnix sql.NullInt64 var createdAtUnix, updatedAtUnix int64 var id, clockSource sql.NullString - var deviceID sql.NullString + var deviceID, rowProfileID sql.NullString err = rows.Scan( &entry.DBID, @@ -326,6 +348,7 @@ func sqlGetMediaHistory( &createdAtUnix, &updatedAtUnix, &deviceID, + &rowProfileID, ) if err != nil { return list, fmt.Errorf("failed to scan media history row: %w", err) @@ -341,6 +364,10 @@ func sqlGetMediaHistory( deviceStr := deviceID.String entry.DeviceID = &deviceStr } + if rowProfileID.Valid { + profileStr := rowProfileID.String + entry.ProfileID = &profileStr + } entry.StartTime = time.Unix(startTimeUnix, 0) if endTimeUnix.Valid { @@ -463,13 +490,13 @@ func sqlCleanupMediaHistory(ctx context.Context, db *sql.DB, retentionDays int) return rowsAffected, nil } -func sqlSumMediaPlayTimeForDay(ctx context.Context, db *sql.DB, dayStart time.Time) (int64, error) { +func sqlSumMediaPlayTimeForDay(ctx context.Context, db *sql.DB, dayStart time.Time, profileID *string) (int64, error) { dayStartUnix := dayStart.Unix() // Sum completed sessions that overlap [dayStart, ∞). // Sessions spanning midnight are pro-rated: only the portion after dayStart counts. // The active session (EndTime IS NULL) is excluded; callers add it separately. - stmt, err := db.PrepareContext(ctx, ` + query := ` SELECT COALESCE(SUM( CASE WHEN StartTime < ? THEN EndTime - ? @@ -478,8 +505,16 @@ func sqlSumMediaPlayTimeForDay(ctx context.Context, db *sql.DB, dayStart time.Ti ), 0) FROM MediaHistory WHERE EndTime IS NOT NULL - AND EndTime > ?; - `) + AND EndTime > ?` + args := []any{dayStartUnix, dayStartUnix, dayStartUnix} + if profileID != nil { + query += ` + AND ProfileID = ?` + args = append(args, *profileID) + } + query += ";" + + stmt, err := db.PrepareContext(ctx, query) if err != nil { return 0, fmt.Errorf("failed to prepare daily play time sum statement: %w", err) } @@ -490,7 +525,7 @@ func sqlSumMediaPlayTimeForDay(ctx context.Context, db *sql.DB, dayStart time.Ti }() var total int64 - if scanErr := stmt.QueryRowContext(ctx, dayStartUnix, dayStartUnix, dayStartUnix).Scan(&total); scanErr != nil { + if scanErr := stmt.QueryRowContext(ctx, args...).Scan(&total); scanErr != nil { return 0, fmt.Errorf("failed to scan daily play time sum: %w", scanErr) } diff --git a/pkg/database/userdb/media_history_property_test.go b/pkg/database/userdb/media_history_property_test.go index 09a9cb1b7..a237cdfa6 100644 --- a/pkg/database/userdb/media_history_property_test.go +++ b/pkg/database/userdb/media_history_property_test.go @@ -237,7 +237,7 @@ func TestPropertyGetMediaHistoryLimitClamping(t *testing.T) { MediaPath TEXT, MediaName TEXT, LauncherID TEXT, PlayTime INTEGER, BootUUID TEXT, MonotonicStart INTEGER, DurationSec INTEGER, WallDuration INTEGER, TimeSkewFlag INTEGER, ClockReliable INTEGER, ClockSource TEXT, - CreatedAt INTEGER, UpdatedAt INTEGER, DeviceID TEXT + CreatedAt INTEGER, UpdatedAt INTEGER, DeviceID TEXT, ProfileID TEXT ) `) require.NoError(t, err) @@ -246,7 +246,7 @@ func TestPropertyGetMediaHistoryLimitClamping(t *testing.T) { limit := rapid.IntRange(-100, 200).Draw(t, "limit") // The function should clamp limit to valid range - entries, err := sqlGetMediaHistory(ctx, db, nil, 0, limit) + entries, err := sqlGetMediaHistory(ctx, db, nil, nil, 0, limit) require.NoError(t, err) // With empty table, we get empty results regardless of limit @@ -274,7 +274,7 @@ func TestPropertyGetMediaHistoryLastIDPagination(t *testing.T) { MediaPath TEXT, MediaName TEXT, LauncherID TEXT, PlayTime INTEGER, BootUUID TEXT, MonotonicStart INTEGER, DurationSec INTEGER, WallDuration INTEGER, TimeSkewFlag INTEGER, ClockReliable INTEGER, ClockSource TEXT, - CreatedAt INTEGER, UpdatedAt INTEGER, DeviceID TEXT + CreatedAt INTEGER, UpdatedAt INTEGER, DeviceID TEXT, ProfileID TEXT ) `) require.NoError(t, err) @@ -296,7 +296,7 @@ func TestPropertyGetMediaHistoryLastIDPagination(t *testing.T) { lastID := int64(rapid.IntRange(-10, 30).Draw(t, "lastID")) limit := rapid.IntRange(1, 100).Draw(t, "limit") - entries, err := sqlGetMediaHistory(ctx, db, nil, lastID, limit) + entries, err := sqlGetMediaHistory(ctx, db, nil, nil, lastID, limit) require.NoError(t, err) // Verify all returned entries have DBID < lastID (or lastID=0 means all) diff --git a/pkg/database/userdb/media_history_test.go b/pkg/database/userdb/media_history_test.go index 10257326a..605b61ff1 100644 --- a/pkg/database/userdb/media_history_test.go +++ b/pkg/database/userdb/media_history_test.go @@ -83,6 +83,7 @@ func TestSqlAddMediaHistory_Success(t *testing.T) { entry.CreatedAt.Unix(), entry.UpdatedAt.Unix(), nil, + nil, ). WillReturnResult(sqlmock.NewResult(expectedDBID, 1)) @@ -140,6 +141,7 @@ func TestSqlAddMediaHistory_DatabaseError(t *testing.T) { entry.CreatedAt.Unix(), entry.UpdatedAt.Unix(), nil, + nil, ). WillReturnError(sqlmock.ErrCancelled) @@ -246,19 +248,19 @@ func TestSqlGetMediaHistory_Success(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }). AddRow( int64(1), "uuid-1", startTime, endTime, "nes", "Nintendo Entertainment System", "/games/mario.nes", "Super Mario Bros.", "retroarch", 3600, "boot-1", int64(1000), 3600, 3600, false, - true, "system", startTime, startTime, nil, + true, "system", startTime, startTime, nil, nil, ). AddRow( int64(2), "uuid-2", startTime, endTime, "snes", "Super Nintendo", "/games/zelda.sfc", "The Legend of Zelda", "retroarch", 7200, "boot-1", int64(2000), 7200, 7200, false, - true, "system", startTime, startTime, nil, + true, "system", startTime, startTime, nil, nil, ) mock.ExpectPrepare(`SELECT.*FROM MediaHistory.*ORDER BY DBID DESC LIMIT`). @@ -266,7 +268,7 @@ func TestSqlGetMediaHistory_Success(t *testing.T) { WithArgs(int64(math.MaxInt64), limit). // lastID=0 becomes math.MaxInt64 in implementation WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, nil, lastID, limit) + entries, err := sqlGetMediaHistory(context.Background(), db, nil, nil, lastID, limit) require.NoError(t, err) assert.Len(t, entries, 2) assert.Equal(t, int64(1), entries[0].DBID) @@ -288,7 +290,7 @@ func TestSqlGetMediaHistory_EmptyResult(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }) mock.ExpectPrepare(`SELECT.*FROM MediaHistory.*ORDER BY DBID DESC LIMIT`). @@ -296,7 +298,7 @@ func TestSqlGetMediaHistory_EmptyResult(t *testing.T) { WithArgs(int64(math.MaxInt64), limit). // lastID=0 becomes math.MaxInt64 in implementation WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, nil, lastID, limit) + entries, err := sqlGetMediaHistory(context.Background(), db, nil, nil, lastID, limit) require.NoError(t, err) assert.Empty(t, entries) assert.NoError(t, mock.ExpectationsWereMet()) @@ -384,7 +386,7 @@ func TestSqlGetMediaHistory_DatabaseError(t *testing.T) { mock.ExpectPrepare(`SELECT.*FROM MediaHistory.*ORDER BY DBID DESC LIMIT`). WillReturnError(sqlmock.ErrCancelled) - entries, err := sqlGetMediaHistory(context.Background(), db, nil, lastID, limit) + entries, err := sqlGetMediaHistory(context.Background(), db, nil, nil, lastID, limit) require.Error(t, err) assert.NotNil(t, entries) // Returns empty slice, not nil assert.Empty(t, entries) @@ -402,7 +404,7 @@ func TestSqlGetMediaHistory_SentinelUsesMaxInt64(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }) // Verify that lastID=0 uses math.MaxInt64 as sentinel, not the old MaxInt32 @@ -411,7 +413,7 @@ func TestSqlGetMediaHistory_SentinelUsesMaxInt64(t *testing.T) { WithArgs(int64(math.MaxInt64), 10). WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, nil, 0, 10) + entries, err := sqlGetMediaHistory(context.Background(), db, nil, nil, 0, 10) require.NoError(t, err) assert.Empty(t, entries) assert.NoError(t, mock.ExpectationsWereMet()) @@ -431,12 +433,12 @@ func TestSqlGetMediaHistory_LargeLastID(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }).AddRow( int64(math.MaxInt32)+50, "uuid-1", time.Now().Unix(), nil, "nes", "NES", "/games/mario.nes", "Mario", "retroarch", 100, "boot-1", int64(1000), 100, 100, false, - true, "system", time.Now().Unix(), time.Now().Unix(), nil, + true, "system", time.Now().Unix(), time.Now().Unix(), nil, nil, ) mock.ExpectPrepare(`SELECT.*FROM MediaHistory.*ORDER BY DBID DESC LIMIT`). @@ -444,7 +446,7 @@ func TestSqlGetMediaHistory_LargeLastID(t *testing.T) { WithArgs(lastID, limit). WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, nil, lastID, limit) + entries, err := sqlGetMediaHistory(context.Background(), db, nil, nil, lastID, limit) require.NoError(t, err) assert.Len(t, entries, 1) assert.Equal(t, int64(math.MaxInt32)+50, entries[0].DBID) @@ -465,13 +467,13 @@ func TestSqlGetMediaHistory_SingleSystemFilter(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }). AddRow( int64(1), "uuid-1", startTime, endTime, "SNES", "Super Nintendo", "/games/zelda.sfc", "The Legend of Zelda", "retroarch", 3600, "boot-1", int64(1000), 3600, 3600, false, - true, "system", startTime, startTime, nil, + true, "system", startTime, startTime, nil, nil, ) mock.ExpectPrepare(`SELECT.*FROM MediaHistory.*WHERE DBID < \? AND SystemID = \?.*ORDER BY DBID DESC LIMIT`). @@ -479,7 +481,7 @@ func TestSqlGetMediaHistory_SingleSystemFilter(t *testing.T) { WithArgs(int64(math.MaxInt64), "SNES", 10). WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, []string{"SNES"}, 0, 10) + entries, err := sqlGetMediaHistory(context.Background(), db, []string{"SNES"}, nil, 0, 10) require.NoError(t, err) assert.Len(t, entries, 1) assert.Equal(t, "SNES", entries[0].SystemID) @@ -500,19 +502,19 @@ func TestSqlGetMediaHistory_MultipleSystemIDs(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }). AddRow( int64(2), "uuid-2", startTime, endTime, "SNES", "Super Nintendo", "/games/zelda.sfc", "Zelda", "retroarch", 3600, "boot-1", int64(1000), 3600, 3600, false, - true, "system", startTime, startTime, nil, + true, "system", startTime, startTime, nil, nil, ). AddRow( int64(1), "uuid-1", startTime, endTime, "NES", "NES", "/games/mario.nes", "Mario", "retroarch", 1800, "boot-1", int64(2000), 1800, 1800, false, - true, "system", startTime, startTime, nil, + true, "system", startTime, startTime, nil, nil, ) mock.ExpectPrepare( @@ -522,7 +524,7 @@ func TestSqlGetMediaHistory_MultipleSystemIDs(t *testing.T) { WithArgs(int64(math.MaxInt64), "SNES", "NES", 10). WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, []string{"SNES", "NES"}, 0, 10) + entries, err := sqlGetMediaHistory(context.Background(), db, []string{"SNES", "NES"}, nil, 0, 10) require.NoError(t, err) assert.Len(t, entries, 2) assert.Equal(t, "SNES", entries[0].SystemID) @@ -544,13 +546,13 @@ func TestSqlGetMediaHistory_SystemFilterWithPagination(t *testing.T) { "DBID", "ID", "StartTime", "EndTime", "SystemID", "SystemName", "MediaPath", "MediaName", "LauncherID", "PlayTime", "BootUUID", "MonotonicStart", "DurationSec", "WallDuration", "TimeSkewFlag", - "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", + "ClockReliable", "ClockSource", "CreatedAt", "UpdatedAt", "DeviceID", "ProfileID", }). AddRow( int64(8), "uuid-8", startTime, endTime, "SNES", "Super Nintendo", "/games/zelda.sfc", "Zelda", "retroarch", 3600, "boot-1", int64(1000), 3600, 3600, false, - true, "system", startTime, startTime, nil, + true, "system", startTime, startTime, nil, nil, ) // lastID=10 + SystemID filter — both conditions in WHERE clause @@ -559,7 +561,7 @@ func TestSqlGetMediaHistory_SystemFilterWithPagination(t *testing.T) { WithArgs(int64(10), "SNES", 25). WillReturnRows(rows) - entries, err := sqlGetMediaHistory(context.Background(), db, []string{"SNES"}, 10, 25) + entries, err := sqlGetMediaHistory(context.Background(), db, []string{"SNES"}, nil, 10, 25) require.NoError(t, err) assert.Len(t, entries, 1) assert.Equal(t, int64(8), entries[0].DBID) @@ -908,7 +910,7 @@ func TestSqlSumMediaPlayTimeForDay_Success(t *testing.T) { WithArgs(dayStartUnix, dayStartUnix, dayStartUnix). WillReturnRows(rows) - total, err := sqlSumMediaPlayTimeForDay(context.Background(), db, dayStart) + total, err := sqlSumMediaPlayTimeForDay(context.Background(), db, dayStart, nil) require.NoError(t, err) assert.Equal(t, expectedTotal, total) assert.NoError(t, mock.ExpectationsWereMet()) @@ -930,7 +932,7 @@ func TestSqlSumMediaPlayTimeForDay_NoSessions(t *testing.T) { WithArgs(dayStartUnix, dayStartUnix, dayStartUnix). WillReturnRows(rows) - total, err := sqlSumMediaPlayTimeForDay(context.Background(), db, dayStart) + total, err := sqlSumMediaPlayTimeForDay(context.Background(), db, dayStart, nil) require.NoError(t, err) assert.Equal(t, int64(0), total) assert.NoError(t, mock.ExpectationsWereMet()) @@ -948,7 +950,7 @@ func TestSqlSumMediaPlayTimeForDay_DatabaseError(t *testing.T) { ExpectQuery(). WillReturnError(sqlmock.ErrCancelled) - total, err := sqlSumMediaPlayTimeForDay(context.Background(), db, dayStart) + total, err := sqlSumMediaPlayTimeForDay(context.Background(), db, dayStart, nil) require.Error(t, err) assert.Equal(t, int64(0), total) assert.Contains(t, err.Error(), "failed to scan daily play time sum") diff --git a/pkg/database/userdb/migrations/20260714000000_create_profiles_table.sql b/pkg/database/userdb/migrations/20260714000000_create_profiles_table.sql new file mode 100644 index 000000000..f4c52af9f --- /dev/null +++ b/pkg/database/userdb/migrations/20260714000000_create_profiles_table.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- +goose StatementBegin + +create table Profiles +( + DBID INTEGER PRIMARY KEY, + ProfileID text not null unique, + Name text not null, + SwitchID text not null unique, + PINHash text, + LimitsEnabled integer, + DailyLimit text, + SessionLimit text, + CreatedAt integer not null, + UpdatedAt integer not null +); + +create table DeviceState +( + Key text primary key, + Value text not null, + UpdatedAt integer not null +); + +ALTER TABLE MediaHistory ADD COLUMN ProfileID TEXT; +CREATE INDEX idx_media_history_profile ON MediaHistory (ProfileID) WHERE ProfileID IS NOT NULL; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_media_history_profile; +ALTER TABLE MediaHistory DROP COLUMN ProfileID; +DROP TABLE IF EXISTS DeviceState; +DROP TABLE IF EXISTS Profiles; +-- +goose StatementEnd diff --git a/pkg/database/userdb/migrations/20260714010000_client_roles.sql b/pkg/database/userdb/migrations/20260714010000_client_roles.sql new file mode 100644 index 000000000..baff993bd --- /dev/null +++ b/pkg/database/userdb/migrations/20260714010000_client_roles.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE Clients ADD COLUMN Role text not null default 'member'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE Clients DROP COLUMN Role; +-- +goose StatementEnd diff --git a/pkg/database/userdb/migrations/20260714020000_profile_roles.sql b/pkg/database/userdb/migrations/20260714020000_profile_roles.sql new file mode 100644 index 000000000..58e219944 --- /dev/null +++ b/pkg/database/userdb/migrations/20260714020000_profile_roles.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE Profiles ADD COLUMN Role text not null default 'member'; +CREATE INDEX idx_profiles_role ON Profiles (Role); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP INDEX IF EXISTS idx_profiles_role; +ALTER TABLE Profiles DROP COLUMN Role; + +-- +goose StatementEnd diff --git a/pkg/database/userdb/migrations/20260714030000_profile_last_used.sql b/pkg/database/userdb/migrations/20260714030000_profile_last_used.sql new file mode 100644 index 000000000..5a7124681 --- /dev/null +++ b/pkg/database/userdb/migrations/20260714030000_profile_last_used.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE Profiles ADD COLUMN LastUsedAt integer; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE Profiles DROP COLUMN LastUsedAt; + +-- +goose StatementEnd diff --git a/pkg/database/userdb/profiles.go b/pkg/database/userdb/profiles.go new file mode 100644 index 000000000..5c8d3c41a --- /dev/null +++ b/pkg/database/userdb/profiles.go @@ -0,0 +1,392 @@ +// 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 userdb + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/rs/zerolog/log" +) + +var ( + // ErrProfileNotFound is returned when a profile lookup matches no row. + ErrProfileNotFound = errors.New("profile not found") + // ErrLastProfileAdmin is returned when an operation would remove the + // final administrator profile. + ErrLastProfileAdmin = errors.New("cannot remove the last admin profile") +) + +func (db *UserDB) CreateProfile(p *database.Profile) error { + if db.sql.Load() == nil { + return ErrNullSQL + } + return sqlCreateProfile(db.ctx, db.sql.Load(), p) +} + +func (db *UserDB) GetProfile(profileID string) (*database.Profile, error) { + if db.sql.Load() == nil { + return nil, ErrNullSQL + } + return sqlGetProfile(db.ctx, db.sql.Load(), "ProfileID", profileID) +} + +func (db *UserDB) GetProfileBySwitchID(switchID string) (*database.Profile, error) { + if db.sql.Load() == nil { + return nil, ErrNullSQL + } + return sqlGetProfile(db.ctx, db.sql.Load(), "SwitchID", switchID) +} + +func (db *UserDB) ListProfiles() ([]database.Profile, error) { + if db.sql.Load() == nil { + return nil, ErrNullSQL + } + return sqlListProfiles(db.ctx, db.sql.Load()) +} + +func (db *UserDB) UpdateProfile(p *database.Profile) error { + if db.sql.Load() == nil { + return ErrNullSQL + } + return sqlUpdateProfile(db.ctx, db.sql.Load(), p) +} + +// ActivateProfile atomically records profile use and persists it as active. +func (db *UserDB) ActivateProfile(profileID string, lastUsedAt int64) error { + if db.sql.Load() == nil { + return ErrNullSQL + } + return sqlActivateProfile(db.ctx, db.sql.Load(), profileID, lastUsedAt) +} + +// DeleteProfile removes a profile. If the profile is the device's active +// profile, the active-profile device state is cleared in the same +// transaction. +func (db *UserDB) DeleteProfile(profileID string) error { + if db.sql.Load() == nil { + return ErrNullSQL + } + return sqlDeleteProfile(db.ctx, db.sql.Load(), profileID) +} + +func (db *UserDB) SetDeviceState(key, value string) error { + if db.sql.Load() == nil { + return ErrNullSQL + } + return sqlSetDeviceState(db.ctx, db.sql.Load(), key, value) +} + +// GetDeviceState returns the value for key and whether it exists. +func (db *UserDB) GetDeviceState(key string) (value string, found bool, err error) { + if db.sql.Load() == nil { + return "", false, ErrNullSQL + } + return sqlGetDeviceState(db.ctx, db.sql.Load(), key) +} + +func (db *UserDB) DeleteDeviceState(key string) error { + if db.sql.Load() == nil { + return ErrNullSQL + } + return sqlDeleteDeviceState(db.ctx, db.sql.Load(), key) +} + +/* + * Internal SQL functions + */ + +const profileColumns = `DBID, ProfileID, Name, Role, SwitchID, PINHash, LimitsEnabled, + DailyLimit, SessionLimit, CreatedAt, UpdatedAt, LastUsedAt` + +func sqlCreateProfile(ctx context.Context, db *sql.DB, p *database.Profile) error { + var dbid int64 + err := db.QueryRowContext(ctx, ` + INSERT INTO Profiles (ProfileID, Name, Role, SwitchID, PINHash, LimitsEnabled, + DailyLimit, SessionLimit, CreatedAt, UpdatedAt) + VALUES (?, ?, + CASE WHEN NOT EXISTS (SELECT 1 FROM Profiles) THEN 'admin' ELSE ? END, + ?, ?, ?, ?, ?, ?, ?) + RETURNING DBID, Role; + `, p.ProfileID, p.Name, p.Role, p.SwitchID, nullableString(p.PINHash), + nullableBool(p.LimitsEnabled), p.DailyLimit, p.SessionLimit, + p.CreatedAt, p.UpdatedAt).Scan(&dbid, &p.Role) + if err != nil { + return fmt.Errorf("failed to insert profile: %w", err) + } + p.DBID = dbid + return nil +} + +func sqlGetProfile(ctx context.Context, db *sql.DB, column, value string) (*database.Profile, error) { + //nolint:gosec // column is a hardcoded column name, not user input + row := db.QueryRowContext(ctx, ` + SELECT `+profileColumns+` + FROM Profiles + WHERE `+column+` = ?; + `, value) + + p, err := scanProfile(row.Scan) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %s=%s", ErrProfileNotFound, column, value) + } + return nil, fmt.Errorf("failed to scan profile row: %w", err) + } + return p, nil +} + +func sqlListProfiles(ctx context.Context, db *sql.DB) ([]database.Profile, error) { + list := make([]database.Profile, 0) + + rows, err := db.QueryContext(ctx, ` + SELECT `+profileColumns+` + FROM Profiles + ORDER BY CreatedAt ASC; + `) + if err != nil { + return list, fmt.Errorf("failed to query profiles: %w", err) + } + defer func() { + if closeErr := rows.Close(); closeErr != nil { + log.Warn().Err(closeErr).Msg("failed to close sql rows") + } + }() + + for rows.Next() { + p, scanErr := scanProfile(rows.Scan) + if scanErr != nil { + return list, fmt.Errorf("failed to scan profile row: %w", scanErr) + } + list = append(list, *p) + } + + if err = rows.Err(); err != nil { + return list, fmt.Errorf("error iterating profile rows: %w", err) + } + return list, nil +} + +func sqlUpdateProfile(ctx context.Context, db *sql.DB, p *database.Profile) error { + result, err := db.ExecContext(ctx, ` + UPDATE Profiles + SET Name = ?, Role = ?, SwitchID = ?, PINHash = ?, LimitsEnabled = ?, + DailyLimit = ?, SessionLimit = ?, UpdatedAt = ? + WHERE ProfileID = ? + AND (Role <> 'admin' OR ? = 'admin' OR + (SELECT COUNT(*) FROM Profiles WHERE Role = 'admin') > 1); + `, p.Name, p.Role, p.SwitchID, nullableString(p.PINHash), nullableBool(p.LimitsEnabled), + p.DailyLimit, p.SessionLimit, p.UpdatedAt, p.ProfileID, p.Role) + if err != nil { + return fmt.Errorf("failed to execute profile update: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + if rowsAffected == 0 { + var role string + queryErr := db.QueryRowContext(ctx, `SELECT Role FROM Profiles WHERE ProfileID = ?;`, p.ProfileID).Scan(&role) + if errors.Is(queryErr, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", ErrProfileNotFound, p.ProfileID) + } + if queryErr != nil { + return fmt.Errorf("failed to inspect rejected profile update: %w", queryErr) + } + return ErrLastProfileAdmin + } + return nil +} + +func sqlActivateProfile(ctx context.Context, db *sql.DB, profileID string, lastUsedAt int64) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin profile activation transaction: %w", err) + } + defer func() { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + log.Warn().Err(rollbackErr).Msg("failed to rollback profile activation transaction") + } + }() + + result, err := tx.ExecContext(ctx, ` + UPDATE Profiles SET LastUsedAt = ? WHERE ProfileID = ?; + `, lastUsedAt, profileID) + if err != nil { + return fmt.Errorf("failed to update profile last used time: %w", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get profile activation rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("%w: %s", ErrProfileNotFound, profileID) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO DeviceState (Key, Value, UpdatedAt) + VALUES (?, ?, ?) + ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value, UpdatedAt = excluded.UpdatedAt; + `, database.DeviceStateKeyActiveProfile, profileID, lastUsedAt) + if err != nil { + return fmt.Errorf("failed to persist active profile state: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit profile activation transaction: %w", err) + } + return nil +} + +func sqlDeleteProfile(ctx context.Context, db *sql.DB, profileID string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin profile delete transaction: %w", err) + } + defer func() { + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + log.Warn().Err(rollbackErr).Msg("failed to rollback profile delete transaction") + } + }() + + result, err := tx.ExecContext(ctx, ` + DELETE FROM Profiles + WHERE ProfileID = ? + AND (Role <> 'admin' OR + (SELECT COUNT(*) FROM Profiles WHERE Role = 'admin') > 1); + `, profileID) + if err != nil { + return fmt.Errorf("failed to execute profile delete: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + if rowsAffected == 0 { + var role string + queryErr := tx.QueryRowContext(ctx, `SELECT Role FROM Profiles WHERE ProfileID = ?;`, profileID).Scan(&role) + if errors.Is(queryErr, sql.ErrNoRows) { + return fmt.Errorf("%w: %s", ErrProfileNotFound, profileID) + } + if queryErr != nil { + return fmt.Errorf("failed to inspect rejected profile delete: %w", queryErr) + } + return ErrLastProfileAdmin + } + + _, err = tx.ExecContext(ctx, ` + DELETE FROM DeviceState WHERE Key = ? AND Value = ?; + `, database.DeviceStateKeyActiveProfile, profileID) + if err != nil { + return fmt.Errorf("failed to clear active profile device state: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit profile delete transaction: %w", err) + } + return nil +} + +func sqlSetDeviceState(ctx context.Context, db *sql.DB, key, value string) error { + _, err := db.ExecContext(ctx, ` + INSERT INTO DeviceState (Key, Value, UpdatedAt) + VALUES (?, ?, ?) + ON CONFLICT(Key) DO UPDATE SET Value = excluded.Value, UpdatedAt = excluded.UpdatedAt; + `, key, value, time.Now().Unix()) + if err != nil { + return fmt.Errorf("failed to set device state: %w", err) + } + return nil +} + +func sqlGetDeviceState(ctx context.Context, db *sql.DB, key string) (value string, found bool, err error) { + err = db.QueryRowContext(ctx, `SELECT Value FROM DeviceState WHERE Key = ?;`, key).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("failed to query device state: %w", err) + } + return value, true, nil +} + +func sqlDeleteDeviceState(ctx context.Context, db *sql.DB, key string) error { + _, err := db.ExecContext(ctx, `DELETE FROM DeviceState WHERE Key = ?;`, key) + if err != nil { + return fmt.Errorf("failed to delete device state: %w", err) + } + return nil +} + +// scanProfile reads a profile row using the given scan function, converting +// nullable columns to their pointer/empty-string Go representations. +func scanProfile(scan func(dest ...any) error) (*database.Profile, error) { + var p database.Profile + var pinHash sql.NullString + var limitsEnabled sql.NullBool + var dailyLimit, sessionLimit sql.NullString + var lastUsedAt sql.NullInt64 + + err := scan( + &p.DBID, &p.ProfileID, &p.Name, &p.Role, &p.SwitchID, &pinHash, + &limitsEnabled, &dailyLimit, &sessionLimit, &p.CreatedAt, &p.UpdatedAt, &lastUsedAt, + ) + if err != nil { + return nil, err //nolint:wrapcheck // callers wrap with query context + } + + if pinHash.Valid { + p.PINHash = pinHash.String + } + if limitsEnabled.Valid { + p.LimitsEnabled = &limitsEnabled.Bool + } + if dailyLimit.Valid { + p.DailyLimit = &dailyLimit.String + } + if sessionLimit.Valid { + p.SessionLimit = &sessionLimit.String + } + if lastUsedAt.Valid { + p.LastUsedAt = &lastUsedAt.Int64 + } + return &p, nil +} + +// nullableString stores empty strings as NULL. +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} + +// nullableBool stores nil as NULL and otherwise 0/1. +func nullableBool(b *bool) any { + if b == nil { + return nil + } + return *b +} diff --git a/pkg/database/userdb/profiles_test.go b/pkg/database/userdb/profiles_test.go new file mode 100644 index 000000000..e74a7d871 --- /dev/null +++ b/pkg/database/userdb/profiles_test.go @@ -0,0 +1,328 @@ +// 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 userdb + +import ( + "path/filepath" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProfile(profileID, switchID string) *database.Profile { + return &database.Profile{ + ProfileID: profileID, + Name: "Test Profile", + SwitchID: switchID, + CreatedAt: 1700000000, + UpdatedAt: 1700000000, + } +} + +func TestProfiles_CRUD_Integration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + p := newTestProfile("profile-uuid-1", "corn-arm-truck") + limitsEnabled := true + daily := "2h30m" + p.LimitsEnabled = &limitsEnabled + p.DailyLimit = &daily + p.PINHash = "fake-pin-hash" + + require.NoError(t, db.CreateProfile(p)) + assert.Positive(t, p.DBID) + assert.Equal(t, "admin", p.Role, "first profile is atomically assigned admin") + + got, err := db.GetProfile("profile-uuid-1") + require.NoError(t, err) + assert.Equal(t, "Test Profile", got.Name) + assert.Equal(t, "corn-arm-truck", got.SwitchID) + assert.Equal(t, "fake-pin-hash", got.PINHash) + require.NotNil(t, got.LimitsEnabled) + assert.True(t, *got.LimitsEnabled) + require.NotNil(t, got.DailyLimit) + assert.Equal(t, "2h30m", *got.DailyLimit) + assert.Nil(t, got.SessionLimit) + + bySwitch, err := db.GetProfileBySwitchID("corn-arm-truck") + require.NoError(t, err) + assert.Equal(t, "profile-uuid-1", bySwitch.ProfileID) + + list, err := db.ListProfiles() + require.NoError(t, err) + require.Len(t, list, 1) + + got.Name = "Renamed" + got.PINHash = "" + got.LimitsEnabled = nil + got.DailyLimit = nil + got.UpdatedAt = 1700000100 + require.NoError(t, db.UpdateProfile(got)) + + updated, err := db.GetProfile("profile-uuid-1") + require.NoError(t, err) + assert.Equal(t, "Renamed", updated.Name) + assert.Empty(t, updated.PINHash) + assert.Nil(t, updated.LimitsEnabled) + assert.Nil(t, updated.DailyLimit) + + backupAdmin := newTestProfile("profile-uuid-2", "blue-fox-lamp") + backupAdmin.Role = "admin" + require.NoError(t, db.CreateProfile(backupAdmin)) + require.NoError(t, db.DeleteProfile("profile-uuid-1")) + _, err = db.GetProfile("profile-uuid-1") + require.ErrorIs(t, err, ErrProfileNotFound) +} + +func TestProfiles_ActivateTracksLastUsed(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + profile := newTestProfile("p1", "switch-one") + require.NoError(t, db.CreateProfile(profile)) + got, err := db.GetProfile(profile.ProfileID) + require.NoError(t, err) + assert.Nil(t, got.LastUsedAt) + + const usedAt int64 = 1784079000 + require.NoError(t, db.ActivateProfile(profile.ProfileID, usedAt)) + got, err = db.GetProfile(profile.ProfileID) + require.NoError(t, err) + require.NotNil(t, got.LastUsedAt) + assert.Equal(t, usedAt, *got.LastUsedAt) + + activeID, found, err := db.GetDeviceState(database.DeviceStateKeyActiveProfile) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, profile.ProfileID, activeID) + + // Ordinary profile edits must not overwrite usage tracking with stale data. + profile.Name = "Renamed" + require.NoError(t, db.UpdateProfile(profile)) + got, err = db.GetProfile(profile.ProfileID) + require.NoError(t, err) + require.NotNil(t, got.LastUsedAt) + assert.Equal(t, usedAt, *got.LastUsedAt) +} + +func TestProfiles_NotFoundErrors(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + _, err := db.GetProfile("missing") + require.ErrorIs(t, err, ErrProfileNotFound) + + _, err = db.GetProfileBySwitchID("missing-switch") + require.ErrorIs(t, err, ErrProfileNotFound) + + err = db.UpdateProfile(newTestProfile("missing", "a-b-c")) + require.ErrorIs(t, err, ErrProfileNotFound) + + err = db.ActivateProfile("missing", 1700000000) + require.ErrorIs(t, err, ErrProfileNotFound) + + err = db.DeleteProfile("missing") + require.ErrorIs(t, err, ErrProfileNotFound) +} + +func TestProfiles_LastAdminProtected(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + admin := newTestProfile("admin", "admin-switch") + require.NoError(t, db.CreateProfile(admin)) + require.Equal(t, "admin", admin.Role) + + err := db.DeleteProfile(admin.ProfileID) + require.ErrorIs(t, err, ErrLastProfileAdmin) + + admin.Role = "member" + err = db.UpdateProfile(admin) + require.ErrorIs(t, err, ErrLastProfileAdmin) +} + +func TestProfiles_SwitchIDUnique(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + require.NoError(t, db.CreateProfile(newTestProfile("p1", "same-switch-id"))) + err := db.CreateProfile(newTestProfile("p2", "same-switch-id")) + require.Error(t, err) + assert.Contains(t, err.Error(), "UNIQUE") +} + +func TestProfiles_DeleteClearsActiveState(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + require.NoError(t, db.CreateProfile(newTestProfile("p1", "switch-one"))) + p2 := newTestProfile("p2", "switch-two") + p2.Role = "admin" + require.NoError(t, db.CreateProfile(p2)) + require.NoError(t, db.SetDeviceState(database.DeviceStateKeyActiveProfile, "p1")) + + // Deleting a non-active profile keeps the active state. + require.NoError(t, db.DeleteProfile("p2")) + value, found, err := db.GetDeviceState(database.DeviceStateKeyActiveProfile) + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "p1", value) + + // Keep a second administrator before deleting the active admin. + p3 := newTestProfile("p3", "switch-three") + p3.Role = "admin" + require.NoError(t, db.CreateProfile(p3)) + + // Deleting the active profile clears it. + require.NoError(t, db.DeleteProfile("p1")) + _, found, err = db.GetDeviceState(database.DeviceStateKeyActiveProfile) + require.NoError(t, err) + assert.False(t, found) +} + +func TestDeviceState_SetGetDelete(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + _, found, err := db.GetDeviceState("some_key") + require.NoError(t, err) + assert.False(t, found) + + require.NoError(t, db.SetDeviceState("some_key", "value1")) + value, found, err := db.GetDeviceState("some_key") + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "value1", value) + + // Upsert overwrites. + require.NoError(t, db.SetDeviceState("some_key", "value2")) + value, found, err = db.GetDeviceState("some_key") + require.NoError(t, err) + assert.True(t, found) + assert.Equal(t, "value2", value) + + require.NoError(t, db.DeleteDeviceState("some_key")) + _, found, err = db.GetDeviceState("some_key") + require.NoError(t, err) + assert.False(t, found) +} + +func TestMediaHistory_ProfileAttribution(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + db, cleanup := setupTempUserDB(t) + defer cleanup() + + now := time.Now() + year, month, day := now.Date() + dayStart := time.Date(year, month, day, 0, 0, 0, 0, now.Location()) + startTime := dayStart.Add(1 * time.Hour) + profileID := "profile-uuid-1" + + attributedEnd := startTime.Add(120 * time.Second) + attributed := &database.MediaHistoryEntry{ + StartTime: startTime, + EndTime: &attributedEnd, + SystemID: "SNES", + SystemName: "Super Nintendo", + MediaPath: filepath.Join("snes", "game.sfc"), + MediaName: "Game", + LauncherID: "test", + PlayTime: 120, + CreatedAt: startTime, + UpdatedAt: startTime, + ProfileID: &profileID, + } + dbid, err := db.AddMediaHistory(attributed) + require.NoError(t, err) + require.NoError(t, db.CloseMediaHistory(dbid, attributedEnd, 120)) + + unattributedEnd := startTime.Add(60 * time.Second) + unattributed := &database.MediaHistoryEntry{ + StartTime: startTime, + EndTime: &unattributedEnd, + SystemID: "NES", + SystemName: "Nintendo", + MediaPath: filepath.Join("nes", "other.nes"), + MediaName: "Other", + LauncherID: "test", + PlayTime: 60, + CreatedAt: startTime, + UpdatedAt: startTime, + } + dbid, err = db.AddMediaHistory(unattributed) + require.NoError(t, err) + require.NoError(t, db.CloseMediaHistory(dbid, unattributedEnd, 60)) + + // Rows carry their attribution. + all, err := db.GetMediaHistory(nil, 0, 10) + require.NoError(t, err) + require.Len(t, all, 2) + for i := range all { + if all[i].SystemID == "SNES" { + require.NotNil(t, all[i].ProfileID) + assert.Equal(t, profileID, *all[i].ProfileID) + } else { + assert.Nil(t, all[i].ProfileID) + } + } + + // Profile-scoped daily sum counts only the attributed session. + scoped, err := db.SumMediaPlayTimeForDayByProfile(dayStart, profileID) + require.NoError(t, err) + assert.Equal(t, int64(120), scoped) + + // Unscoped daily sum counts everything (shared-profile / device-level + // accounting). + total, err := db.SumMediaPlayTimeForDay(dayStart) + require.NoError(t, err) + assert.Equal(t, int64(180), total) + + // Unknown profile matches nothing. + none, err := db.SumMediaPlayTimeForDayByProfile(dayStart, "unknown") + require.NoError(t, err) + assert.Equal(t, int64(0), none) +} diff --git a/pkg/database/userdb/sql.go b/pkg/database/userdb/sql.go index 35f7c2478..5f88934e2 100644 --- a/pkg/database/userdb/sql.go +++ b/pkg/database/userdb/sql.go @@ -751,10 +751,10 @@ func sqlCreateClient(ctx context.Context, db *sql.DB, c *database.Client) error } var dbid int64 err := db.QueryRowContext(ctx, ` - INSERT INTO Clients (ClientID, ClientName, AuthToken, PairingKey, CreatedAt, LastSeenAt) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO Clients (ClientID, ClientName, AuthToken, Role, PairingKey, CreatedAt, LastSeenAt) + VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING DBID; - `, c.ClientID, c.ClientName, c.AuthToken, c.PairingKey, c.CreatedAt, c.LastSeenAt).Scan(&dbid) + `, c.ClientID, c.ClientName, c.AuthToken, c.Role, c.PairingKey, c.CreatedAt, c.LastSeenAt).Scan(&dbid) if err != nil { return fmt.Errorf("failed to insert client: %w", err) } @@ -764,13 +764,14 @@ func sqlCreateClient(ctx context.Context, db *sql.DB, c *database.Client) error func sqlGetClientByToken(ctx context.Context, db *sql.DB, authToken string) (*database.Client, error) { row := db.QueryRowContext(ctx, ` - SELECT DBID, ClientID, ClientName, AuthToken, PairingKey, CreatedAt, LastSeenAt + SELECT DBID, ClientID, ClientName, AuthToken, Role, PairingKey, CreatedAt, LastSeenAt FROM Clients WHERE AuthToken = ?; `, authToken) c := database.Client{} - err := row.Scan(&c.DBID, &c.ClientID, &c.ClientName, &c.AuthToken, &c.PairingKey, &c.CreatedAt, &c.LastSeenAt) + err := row.Scan(&c.DBID, &c.ClientID, &c.ClientName, &c.AuthToken, &c.Role, + &c.PairingKey, &c.CreatedAt, &c.LastSeenAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("client not found: %w", err) @@ -784,7 +785,7 @@ func sqlListClients(ctx context.Context, db *sql.DB) ([]database.Client, error) list := make([]database.Client, 0) rows, err := db.QueryContext(ctx, ` - SELECT DBID, ClientID, ClientName, AuthToken, PairingKey, CreatedAt, LastSeenAt + SELECT DBID, ClientID, ClientName, AuthToken, Role, PairingKey, CreatedAt, LastSeenAt FROM Clients ORDER BY CreatedAt DESC; `) @@ -800,7 +801,7 @@ func sqlListClients(ctx context.Context, db *sql.DB) ([]database.Client, error) for rows.Next() { c := database.Client{} if scanErr := rows.Scan( - &c.DBID, &c.ClientID, &c.ClientName, &c.AuthToken, + &c.DBID, &c.ClientID, &c.ClientName, &c.AuthToken, &c.Role, &c.PairingKey, &c.CreatedAt, &c.LastSeenAt, ); scanErr != nil { return list, fmt.Errorf("failed to scan client row: %w", scanErr) @@ -815,7 +816,12 @@ func sqlListClients(ctx context.Context, db *sql.DB) ([]database.Client, error) } func sqlDeleteClient(ctx context.Context, db *sql.DB, clientID string) error { - result, err := db.ExecContext(ctx, `DELETE FROM Clients WHERE ClientID = ?;`, clientID) + result, err := db.ExecContext(ctx, ` + DELETE FROM Clients + WHERE ClientID = ? + AND (Role <> 'admin' OR + (SELECT COUNT(*) FROM Clients WHERE Role = 'admin') > 1); + `, clientID) if err != nil { return fmt.Errorf("failed to execute client delete: %w", err) } @@ -826,7 +832,15 @@ func sqlDeleteClient(ctx context.Context, db *sql.DB, clientID string) error { } if rowsAffected == 0 { - return fmt.Errorf("client not found: %s", clientID) + var role string + queryErr := db.QueryRowContext(ctx, `SELECT Role FROM Clients WHERE ClientID = ?;`, clientID).Scan(&role) + if errors.Is(queryErr, sql.ErrNoRows) { + return fmt.Errorf("client not found: %s", clientID) + } + if queryErr != nil { + return fmt.Errorf("failed to inspect rejected client delete: %w", queryErr) + } + return ErrLastClientAdmin } return nil } diff --git a/pkg/platforms/mister/mounts.go b/pkg/platforms/mister/mounts.go new file mode 100644 index 000000000..435d72cbb --- /dev/null +++ b/pkg/platforms/mister/mounts.go @@ -0,0 +1,294 @@ +//go:build linux + +// 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 mister + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/spf13/afero" + "golang.org/x/sys/unix" +) + +var mountInfoPath = filepath.Join(string(filepath.Separator), "proc", "self", "mountinfo") + +// mountEntry is one line of /proc/self/mountinfo, reduced to the fields +// mount ownership decisions need. Entries appear in mount order, so of two +// entries with the same Mountpoint the later one shadows the earlier. +type mountEntry struct { + Root string // path inside the source filesystem, e.g. /zaparoo/profiles/x/saves + Mountpoint string // where it is mounted, e.g. /media/fat/saves + FSType string // e.g. exfat, cifs + Source string // device or share, e.g. /dev/mmcblk0p1, //nas/share +} + +// mounter abstracts the mount syscalls and mount table so the swap logic +// is unit-testable without root or a MiSTer. +type mounter interface { + Mounts() ([]mountEntry, error) + // BindMount returns the new mount's identity. On error it must leave no + // bind mounted at target, so callers never own an untracked mount. + BindMount(source, target string) (mountEntry, error) + Unmount(target string) error +} + +type sysMounter struct{} + +func (sysMounter) Mounts() ([]mountEntry, error) { + data, err := os.ReadFile(mountInfoPath) //nolint:gosec // fixed procfs path assembled from constants + if err != nil { + return nil, fmt.Errorf("failed to read mountinfo: %w", err) + } + return parseMountInfo(string(data)), nil +} + +func (m sysMounter) BindMount(source, target string) (mountEntry, error) { + if err := unix.Mount(source, target, "", unix.MS_BIND, ""); err != nil { + return mountEntry{}, fmt.Errorf("failed to bind mount %s on %s: %w", source, target, err) + } + + mounts, err := m.Mounts() + if err == nil { + if stack := mountsAt(mounts, target); len(stack) > 0 { + return stack[len(stack)-1], nil + } + err = errors.New("bind mount not visible in mountinfo") + } + + // Verification failed after the syscall succeeded. Remove the bind now; + // returning an error with it still live would leave an untracked mount + // that later reconciles cannot safely identify or remove. + if unmountErr := unix.Unmount(target, 0); unmountErr != nil { + return mountEntry{}, errors.Join( + fmt.Errorf("failed to verify bind mount %s on %s: %w", source, target, err), + fmt.Errorf("failed to roll back unverified bind mount: %w", unmountErr), + ) + } + return mountEntry{}, fmt.Errorf("failed to verify bind mount %s on %s: %w", source, target, err) +} + +// Unmount removes the topmost mount at target. EBUSY gets a few brief +// retries (a file may be transiently open), then a lazy detach so the +// mount at least disappears from the namespace once its users exit. +func (sysMounter) Unmount(target string) error { + var err error + for range 3 { + err = unix.Unmount(target, 0) + if err == nil { + return nil + } + if !errors.Is(err, unix.EBUSY) { + return fmt.Errorf("failed to unmount %s: %w", target, err) + } + time.Sleep(100 * time.Millisecond) + } + log.Warn().Str("target", target). + Msg("profiles: unmount busy, detaching lazily") + if err := unix.Unmount(target, unix.MNT_DETACH); err != nil { + return fmt.Errorf("failed to lazily unmount %s: %w", target, err) + } + return nil +} + +// parseMountInfo parses /proc/self/mountinfo content. Format per line: +// +// 36 35 98:0 /mnt1 /mnt2 rw,noatime master:1 - ext3 /dev/root rw +// (1) (2) (3) (root) (mountpoint) (opts) (optional...) - (fstype) (source) (superopts) +// +// Malformed lines are skipped. +func parseMountInfo(data string) []mountEntry { + var entries []mountEntry + for line := range strings.Lines(data) { + fields := strings.Fields(line) + sep := -1 + for i, f := range fields { + if f == "-" && i >= 6 { + sep = i + break + } + } + if sep < 0 || sep+2 >= len(fields) || len(fields) < 5 { + continue + } + entries = append(entries, mountEntry{ + Root: unescapeMountField(fields[3]), + Mountpoint: unescapeMountField(fields[4]), + FSType: fields[sep+1], + Source: unescapeMountField(fields[sep+2]), + }) + } + return entries +} + +// unescapeMountField decodes the octal escapes the kernel uses for +// whitespace in mountinfo fields (e.g. \040 for space). +func unescapeMountField(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+3 < len(s) { + if code, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil { + _ = b.WriteByte(byte(code)) + i += 3 + continue + } + } + _ = b.WriteByte(s[i]) + } + return b.String() +} + +// mountsAt returns the mounts stacked on target in mount order: the last +// entry is the one paths currently resolve through. +func mountsAt(mounts []mountEntry, target string) []mountEntry { + var stack []mountEntry + for _, m := range mounts { + if m.Mountpoint == target { + stack = append(stack, m) + } + } + return stack +} + +// mountLedgerEntry records one bind mount we created: enough of its +// mountinfo identity to prove a mount at Target is ours before we ever +// unmount it. +type mountLedgerEntry struct { + Target string `json:"target"` + Root string `json:"root"` + Source string `json:"source"` + ProfileID string `json:"profileId"` + Item string `json:"item"` +} + +// mountLedger persists the binds we own. It lives on tmpfs (/run) so it +// has exactly the lifetime of the kernel mount state it describes: both +// survive a service restart and both vanish on reboot. +type mountLedger struct { + fs afero.Fs + path string + entries []mountLedgerEntry +} + +func loadMountLedger(fs afero.Fs, path string) *mountLedger { + l := &mountLedger{fs: fs, path: path} + data, err := afero.ReadFile(fs, path) + if err != nil { + return l + } + if err := json.Unmarshal(data, &l.entries); err != nil { + log.Warn().Err(err).Str("path", path). + Msg("profiles: discarding unreadable mount ledger") + l.entries = nil + } + return l +} + +func (l *mountLedger) save() error { + data, err := json.Marshal(l.entries) + if err != nil { + return fmt.Errorf("failed to encode mount ledger: %w", err) + } + if err := l.fs.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return fmt.Errorf("failed to create mount ledger dir: %w", err) + } + if err := afero.WriteFile(l.fs, l.path, data, 0o644); err != nil { + return fmt.Errorf("failed to write mount ledger: %w", err) + } + return nil +} + +// find returns our ledger entry for the given live mount, or nil when the +// mount is not one we created. +func (l *mountLedger) find(m *mountEntry) *mountLedgerEntry { + for i := range l.entries { + e := &l.entries[i] + if e.Target == m.Mountpoint && e.Root == m.Root && e.Source == m.Source { + return e + } + } + return nil +} + +// owns reports whether the given mount entry at target is one we created. +func (l *mountLedger) owns(m *mountEntry) bool { + return l.find(m) != nil +} + +func (l *mountLedger) add(entry *mountLedgerEntry) error { + l.entries = append(l.entries, *entry) + return l.save() +} + +// remove drops the ledger entry matching the given mount. +func (l *mountLedger) remove(m *mountEntry) { + l.removeInMemory(m) + if err := l.save(); err != nil { + log.Error().Err(err).Msg("profiles: failed to update mount ledger") + } +} + +func (l *mountLedger) removeInMemory(m *mountEntry) { + kept := l.entries[:0] + for _, e := range l.entries { + if e.Target == m.Mountpoint && e.Root == m.Root && e.Source == m.Source { + continue + } + kept = append(kept, e) + } + l.entries = kept +} + +// prune drops ledger entries whose mounts no longer exist (e.g. manually +// unmounted while we weren't looking). +func (l *mountLedger) prune(mounts []mountEntry) { + kept := l.entries[:0] + for _, e := range l.entries { + found := false + for i := range mounts { + m := &mounts[i] + if e.Target == m.Mountpoint && e.Root == m.Root && e.Source == m.Source { + found = true + break + } + } + if found { + kept = append(kept, e) + } + } + if len(kept) != len(l.entries) { + l.entries = kept + if err := l.save(); err != nil { + log.Error().Err(err).Msg("profiles: failed to prune mount ledger") + } + } +} diff --git a/pkg/platforms/mister/platform.go b/pkg/platforms/mister/platform.go index c44684cb4..c62cdd7ab 100644 --- a/pkg/platforms/mister/platform.go +++ b/pkg/platforms/mister/platform.go @@ -108,6 +108,7 @@ type Platform struct { activeMedia func() *models.ActiveMedia textMap map[string]string consoleManager *MiSTerConsoleManager + profileData *profileDataManager launchShortCore func(string) error closeConsole func() error lastLauncher platforms.Launcher @@ -125,6 +126,7 @@ func NewPlatform() *Platform { launchShortCore: mgls.LaunchShortCore, } p.consoleManager = newConsoleManager(p) + p.profileData = newProfileDataManager(p.fs) return p } diff --git a/pkg/platforms/mister/profiledata.go b/pkg/platforms/mister/profiledata.go new file mode 100644 index 000000000..c06313473 --- /dev/null +++ b/pkg/platforms/mister/profiledata.go @@ -0,0 +1,400 @@ +//go:build linux + +// 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 mister + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + misterconfig "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mister/config" + "github.com/rs/zerolog/log" + "github.com/spf13/afero" + "golang.org/x/sys/unix" +) + +// Profile data swapping on MiSTer works by bind-mounting a per-profile +// directory over the live saves/savestates directories. Main hardcodes +// SAVE_DIR/SAVESTATE_DIR relative to its storage root, so directory-level +// redirection is the only mechanism — and bind mounts do it with zero +// on-disk mutation: the SD card stays a completely standard exFAT layout, +// nothing moves or copies, and a power cut mid-switch can't tear anything. +// Mount state survives a service crash and clears on reboot; the boot +// reconcile pass reapplies it. +const ( + // profileDataItemSaves and ...Savestates are the swappable item IDs. + profileDataItemSaves = "saves" + profileDataItemSavestates = "savestates" + + // nasPoolDirName is the pool directory created inside a foreign mount + // (e.g. a NAS share bind-mounted over saves/ by cifs_mount.sh). The + // share root IS the saves directory, so inside it is the only place on + // that storage we can reach. + nasPoolDirName = ".zaparoo-profiles" + + // usbRootCount matches main's isUSBMounted probe of /media/usb0-3. + usbRootCount = 4 +) + +var ( + // mountLedgerPath records the binds we own. On tmpfs so it lives and + // dies with the kernel mount state it describes. + mountLedgerPath = filepath.Join(string(filepath.Separator), "run", "zaparoo", "mounts.json") + + // deviceBinPath is where main's OSD Storage menu persists the storage + // root selection (FileSave of an int: 0 = SD, nonzero = USB). + deviceBinPath = filepath.Join(misterconfig.CoreConfigFolder, "device.bin") + mediaRootPath = filepath.Join(string(filepath.Separator), "media") +) + +func profileItemDir(item string) string { + // Item IDs happen to equal main's directory names. + return item +} + +// profileDataManager owns the mount decisions. All paths are derived per +// apply so storage-root changes (SD vs USB) and foreign mounts (NAS saves) +// are re-resolved every time. +type profileDataManager struct { + fs afero.Fs + m mounter + ledger *mountLedger + mu syncutil.Mutex +} + +func newProfileDataManager(fs afero.Fs) *profileDataManager { + return &profileDataManager{ + fs: fs, + m: sysMounter{}, + ledger: loadMountLedger(fs, mountLedgerPath), + } +} + +// ProfileItems implements platforms.ProfileDataSwapper. +func (*Platform) ProfileItems() []platforms.ProfileItem { + return []platforms.ProfileItem{ + {ID: profileDataItemSaves, Label: "Save files", Owner: platforms.ProfileItemOwnerProfile}, + {ID: profileDataItemSavestates, Label: "Save states", Owner: platforms.ProfileItemOwnerProfile}, + } +} + +// ApplyProfile implements platforms.ProfileDataSwapper. +func (p *Platform) ApplyProfile(ref platforms.ProfileRef, enabledItems []string) error { + return p.profileData.apply(ref, enabledItems) +} + +type profileItemPlan struct { + ref platforms.ProfileRef + previous platforms.ProfileRef + item string + target string + pool string +} + +func (d *profileDataManager) apply(ref platforms.ProfileRef, items []string) error { + d.mu.Lock() + defer d.mu.Unlock() + + mounts, err := d.m.Mounts() + if err != nil { + return fmt.Errorf("failed to read mount table: %w", err) + } + d.ledger.prune(mounts) + + root, err := d.resolveStorageRoot(mounts) + if err != nil { + return err + } + + // Prepare every destination before changing any live mount. A missing or + // read-only pool therefore leaves all currently active profile data intact. + plans := make([]profileItemPlan, 0, len(items)) + for _, item := range items { + plan, planErr := d.prepareItem(root, item, ref) + if planErr != nil { + return fmt.Errorf("%s: %w", item, planErr) + } + plans = append(plans, plan) + } + + for i := range plans { + if applyErr := d.applyItem(&plans[i]); applyErr != nil { + rollbackErr := d.rollbackItems(root, plans[:i+1]) + return errors.Join( + fmt.Errorf("%s: %w", plans[i].item, applyErr), + rollbackErr, + ) + } + } + return nil +} + +func (d *profileDataManager) prepareItem(root, item string, ref platforms.ProfileRef) (profileItemPlan, error) { + plan := profileItemPlan{ + ref: ref, + item: item, + target: filepath.Join(root, profileItemDir(item)), + } + + mounts, err := d.m.Mounts() + if err != nil { + return profileItemPlan{}, fmt.Errorf("failed to read mount table: %w", err) + } + stack := mountsAt(mounts, plan.target) + for len(stack) > 0 { + top := stack[len(stack)-1] + entry := d.ledger.find(&top) + if entry == nil { + break + } + if plan.previous.ID == "" { + plan.previous.ID = entry.ProfileID + } + stack = stack[:len(stack)-1] + } + + if ref.ID == "" { + return plan, nil + } + + var profileDir string + if len(stack) > 0 { + // A foreign mount (e.g. NAS saves) owns the target: the pool must + // live inside it, and our bind stacks on top. Saves stay on the + // user's storage; we never touch their mount. + profileDir = filepath.Join(plan.target, nasPoolDirName, ref.ID) + } else { + profileDir = filepath.Join(root, "zaparoo", "profiles", ref.ID) + } + plan.pool = filepath.Join(profileDir, profileItemDir(item)) + + if mkdirErr := d.fs.MkdirAll(plan.pool, 0o755); mkdirErr != nil { + if len(stack) > 0 { + return profileItemPlan{}, fmt.Errorf( + "cannot create profile pool inside mounted %s (%s): %w", + plan.target, stack[len(stack)-1].FSType, platforms.ErrProfileDataUnavailable) + } + return profileItemPlan{}, fmt.Errorf("failed to create profile pool %s: %w", plan.pool, mkdirErr) + } + d.writeNameFile(profileDir, ref) + return plan, nil +} + +func (d *profileDataManager) rollbackItems(root string, plans []profileItemPlan) error { + var errs []error + for i := len(plans) - 1; i >= 0; i-- { + rollback, err := d.prepareItem(root, plans[i].item, plans[i].previous) + if err == nil { + err = d.applyItem(&rollback) + } + if err != nil { + errs = append(errs, fmt.Errorf("failed to restore %s: %w", plans[i].item, err)) + } + } + return errors.Join(errs...) +} + +// resolveStorageRoot mirrors main's FindStorage: config/device.bin selects +// SD or USB, and USB means the first mounted non-ext filesystem of +// /media/usb0-3 (main's isPathMounted quirk: an ext-formatted drive is +// never a storage root). +func (d *profileDataManager) resolveStorageRoot(mounts []mountEntry) (string, error) { + data, err := afero.ReadFile(d.fs, deviceBinPath) + if err != nil || len(data) < 4 { + return misterconfig.SDRootDir, nil //nolint:nilerr // absent/short file means SD root + } + if binary.LittleEndian.Uint32(data[:4]) == 0 { + return misterconfig.SDRootDir, nil + } + + for i := range usbRootCount { + path := filepath.Join(mediaRootPath, fmt.Sprintf("usb%d", i)) + stack := mountsAt(mounts, path) + if len(stack) == 0 { + continue + } + if strings.HasPrefix(stack[len(stack)-1].FSType, "ext") { + continue + } + return path, nil + } + return "", fmt.Errorf( + "USB storage root selected but no USB drive mounted: %w", + platforms.ErrProfileDataUnavailable) +} + +func (d *profileDataManager) applyItem(plan *profileItemPlan) error { + // Idempotency: if the topmost mount is already our bind for this + // profile, there is nothing to do. Reconciles run on every mount-table + // change and must not churn mounts. + if plan.ref.ID != "" { + mounts, err := d.m.Mounts() + if err != nil { + return fmt.Errorf("failed to read mount table: %w", err) + } + if stack := mountsAt(mounts, plan.target); len(stack) > 0 { + top := stack[len(stack)-1] + if e := d.ledger.find(&top); e != nil && e.ProfileID == plan.ref.ID && e.Item == plan.item { + return nil + } + } + } + + // Remove our own binds from the top of the stack. Anything of ours + // buried under a later foreign mount is unreachable without touching + // the foreign mount, so it stays until reboot — harmless, since the + // foreign mount defines what paths resolve to. + for { + mounts, err := d.m.Mounts() + if err != nil { + return fmt.Errorf("failed to read mount table: %w", err) + } + stack := mountsAt(mounts, plan.target) + if len(stack) == 0 { + break + } + top := stack[len(stack)-1] + if !d.ledger.owns(&top) { + break + } + if unmountErr := d.m.Unmount(plan.target); unmountErr != nil { + return fmt.Errorf("failed to remove profile bind on %s: %w", plan.target, unmountErr) + } + d.ledger.remove(&top) + } + + if plan.ref.ID == "" { + // Shared profile: the un-mounted state (plain directory or the + // user's own NAS mount) is the shared data. + return nil + } + + top, bindErr := d.m.BindMount(plan.pool, plan.target) + if bindErr != nil { + return fmt.Errorf("failed to bind profile pool: %w", bindErr) + } + entry := &mountLedgerEntry{ + Target: plan.target, + Root: top.Root, + Source: top.Source, + ProfileID: plan.ref.ID, + Item: plan.item, + } + if ledgerErr := d.ledger.add(entry); ledgerErr != nil { + // Ownership must be durable before reporting success. Undo the bind; + // otherwise a service restart could no longer prove it is ours. + unmountErr := d.m.Unmount(plan.target) + d.ledger.removeInMemory(&top) + return errors.Join( + fmt.Errorf("failed to record profile bind: %w", ledgerErr), + unmountErr, + ) + } + log.Info().Str("profileId", plan.ref.ID).Str("pool", plan.pool).Str("target", plan.target). + Msg("profiles: bind mounted profile data") + return nil +} + +// writeNameFile labels the profile pool directory with the profile's +// display name so the UUID directories are decodable by a human browsing +// the storage outside Zaparoo. Best-effort. +func (d *profileDataManager) writeNameFile(profileDir string, ref platforms.ProfileRef) { + if ref.Name == "" { + return + } + path := filepath.Join(profileDir, "name.txt") + if err := afero.WriteFile(d.fs, path, []byte(ref.Name+"\n"), 0o644); err != nil { + log.Warn().Err(err).Str("path", path). + Msg("profiles: failed to write profile name file") + } +} + +// WatchProfileData implements platforms.ProfileDataWatcher: it invokes +// onChange whenever the mount table changes (the kernel signals POLLPRI on +// /proc/self/mountinfo), debounced, until ctx is done. This is how the +// service notices a cifs boot script mounting over saves after us, a USB +// root drive appearing late, or a manual unmount. +func (*Platform) WatchProfileData(ctx context.Context, onChange func()) { + go func() { + file, err := os.Open(mountInfoPath) + if err != nil { + log.Error().Err(err).Msg("profiles: mount watcher unavailable") + return + } + defer func() { _ = file.Close() }() + + rawFd := file.Fd() + if rawFd > math.MaxInt32 { + log.Error().Uint64("fd", uint64(rawFd)).Msg("profiles: mount watcher fd out of range") + return + } + fd := int32(rawFd) + + lastSum := mountInfoSum() + for { + select { + case <-ctx.Done(): + return + default: + } + + fds := []unix.PollFd{{Fd: fd, Events: unix.POLLPRI}} + if _, err := unix.Poll(fds, 1000); err != nil && !errors.Is(err, unix.EINTR) { + log.Warn().Err(err).Msg("profiles: mount watcher poll failed, stopping") + return + } + if fds[0].Revents == 0 { + continue + } + + // Debounce bursts (a cifs script mounts several dirs at once). + time.Sleep(200 * time.Millisecond) + + sum := mountInfoSum() + if sum == lastSum { + continue + } + lastSum = sum + log.Debug().Msg("profiles: mount table changed, reconciling") + onChange() + } + }() +} + +// mountInfoSum fingerprints the current mount table so poll wakeups that +// end up changing nothing (mount + matching unmount) don't trigger work. +func mountInfoSum() [32]byte { + data, err := os.ReadFile(mountInfoPath) + if err != nil { + return [32]byte{} + } + return sha256.Sum256(data) +} diff --git a/pkg/platforms/mister/profiledata_test.go b/pkg/platforms/mister/profiledata_test.go new file mode 100644 index 000000000..6d506818c --- /dev/null +++ b/pkg/platforms/mister/profiledata_test.go @@ -0,0 +1,473 @@ +//go:build linux + +// 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 mister + +import ( + "encoding/binary" + "errors" + "fmt" + iofs "io/fs" + "path/filepath" + "strings" + "testing" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + misterconfig "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mister/config" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeMounter simulates the kernel mount table: binds resolve their source +// through the current table (so a bind of a path inside a cifs mount +// carries the cifs identity, exactly like the kernel), and unmount removes +// the topmost entry at a target. +type fakeMounter struct { + bindErr error + unmountErr error + mounts []mountEntry + binds int + bindAttempts int + failBindAtTry int +} + +type failMkdirFS struct { + afero.Fs + path string +} + +func (f failMkdirFS) MkdirAll(path string, perm iofs.FileMode) error { + if strings.Contains(path, f.path) { + return errors.New("injected mkdir failure") + } + if err := f.Fs.MkdirAll(path, perm); err != nil { + return fmt.Errorf("failed to create test directory: %w", err) + } + return nil +} + +func (f *fakeMounter) Mounts() ([]mountEntry, error) { + out := make([]mountEntry, len(f.mounts)) + copy(out, f.mounts) + return out, nil +} + +func (f *fakeMounter) BindMount(source, target string) (mountEntry, error) { + f.bindAttempts++ + if f.bindErr != nil && (f.failBindAtTry == 0 || f.bindAttempts == f.failBindAtTry) { + return mountEntry{}, f.bindErr + } + f.binds++ + + // Resolve source through the deepest, most recent mount covering it. + entry := mountEntry{ + Root: source, Mountpoint: target, FSType: "exfat", Source: "/dev/mmcblk0p1", + } + bestLen := -1 + for _, m := range f.mounts { + if source == m.Mountpoint || strings.HasPrefix(source, m.Mountpoint+"/") { + if len(m.Mountpoint) >= bestLen { + bestLen = len(m.Mountpoint) + rel := strings.TrimPrefix(source, m.Mountpoint) + entry.Root = strings.TrimSuffix(m.Root, "/") + rel + entry.FSType = m.FSType + entry.Source = m.Source + } + } + } + f.mounts = append(f.mounts, entry) + return entry, nil +} + +func (f *fakeMounter) Unmount(target string) error { + if f.unmountErr != nil { + return f.unmountErr + } + for i := len(f.mounts) - 1; i >= 0; i-- { + if f.mounts[i].Mountpoint == target { + f.mounts = append(f.mounts[:i], f.mounts[i+1:]...) + return nil + } + } + return errors.New("not mounted") +} + +func newTestManager(m *fakeMounter) (*profileDataManager, afero.Fs) { + fs := afero.NewMemMapFs() + return &profileDataManager{ + fs: fs, + m: m, + ledger: loadMountLedger(fs, mountLedgerPath), + }, fs +} + +func writeDeviceBin(t *testing.T, fs afero.Fs, dev uint32) { + t.Helper() + data := make([]byte, 4) + binary.LittleEndian.PutUint32(data, dev) + require.NoError(t, afero.WriteFile(fs, deviceBinPath, data, 0o644)) +} + +func kidA() platforms.ProfileRef { + return platforms.ProfileRef{ID: "11111111-aaaa-bbbb-cccc-000000000001", Name: "Kid A"} +} + +func kidB() platforms.ProfileRef { + return platforms.ProfileRef{ID: "22222222-aaaa-bbbb-cccc-000000000002", Name: "Kid B"} +} + +func allItems() []string { + return []string{profileDataItemSaves, profileDataItemSavestates} +} + +func TestParseMountInfo(t *testing.T) { + t.Parallel() + data := `21 26 179:1 / /media/fat rw,noatime - exfat /dev/mmcblk0p1 rw,iocharset=utf8 +36 21 0:41 /saves /media/fat/saves rw,relatime shared:1 - cifs //10.0.0.3/MiSTer rw,username=x +40 21 179:1 /System\040Volume\040Information /media/fat/svi rw - exfat /dev/mmcblk0p1 rw +garbage line +` + entries := parseMountInfo(data) + require.Len(t, entries, 3) + assert.Equal(t, mountEntry{ + Root: "/", Mountpoint: "/media/fat", FSType: "exfat", Source: "/dev/mmcblk0p1", + }, entries[0]) + assert.Equal(t, mountEntry{ + Root: "/saves", Mountpoint: "/media/fat/saves", FSType: "cifs", Source: "//10.0.0.3/MiSTer", + }, entries[1]) + assert.Equal(t, "/System Volume Information", entries[2].Root, "octal escapes decoded") +} + +func TestResolveStorageRoot(t *testing.T) { + t.Parallel() + + t.Run("no device.bin means SD", func(t *testing.T) { + t.Parallel() + d, _ := newTestManager(&fakeMounter{}) + root, err := d.resolveStorageRoot(nil) + require.NoError(t, err) + assert.Equal(t, "/media/fat", root) + }) + + t.Run("device.bin zero means SD", func(t *testing.T) { + t.Parallel() + d, fs := newTestManager(&fakeMounter{}) + writeDeviceBin(t, fs, 0) + root, err := d.resolveStorageRoot(nil) + require.NoError(t, err) + assert.Equal(t, "/media/fat", root) + }) + + t.Run("USB root picks first mounted non-ext drive", func(t *testing.T) { + t.Parallel() + d, fs := newTestManager(&fakeMounter{}) + writeDeviceBin(t, fs, 1) + mounts := []mountEntry{ + {Root: "/", Mountpoint: "/media/usb0", FSType: "ext4", Source: "/dev/sda1"}, + {Root: "/", Mountpoint: "/media/usb1", FSType: "vfat", Source: "/dev/sdb1"}, + } + root, err := d.resolveStorageRoot(mounts) + require.NoError(t, err) + // usb0 is skipped: main's isPathMounted treats ext-formatted drives + // as not-a-storage-root. + assert.Equal(t, "/media/usb1", root) + }) + + t.Run("USB root with no drive is unavailable", func(t *testing.T) { + t.Parallel() + d, fs := newTestManager(&fakeMounter{}) + writeDeviceBin(t, fs, 1) + _, err := d.resolveStorageRoot(nil) + require.ErrorIs(t, err, platforms.ErrProfileDataUnavailable) + }) +} + +func TestApply_SDPlainDir(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, fs := newTestManager(m) + + require.NoError(t, d.apply(kidA(), allItems())) + + // Both items bind their pool dirs over the live dirs. + mediaRoot := filepath.Join(string(filepath.Separator), "media", "fat") + liveSaves := filepath.Join(mediaRoot, "saves") + liveStates := filepath.Join(mediaRoot, "savestates") + poolDir := filepath.Join(mediaRoot, "zaparoo", "profiles", kidA().ID) + saves := mountsAt(m.mounts, liveSaves) + require.Len(t, saves, 1) + assert.Equal(t, filepath.Join(poolDir, "saves"), saves[0].Root) + states := mountsAt(m.mounts, liveStates) + require.Len(t, states, 1) + + // Pool dirs and the human-readable label exist. + name, err := afero.ReadFile(fs, filepath.Join(poolDir, "name.txt")) + require.NoError(t, err) + assert.Equal(t, "Kid A\n", string(name)) + exists, err := afero.DirExists(fs, filepath.Join(poolDir, "saves")) + require.NoError(t, err) + assert.True(t, exists) + + // Idempotent: reapplying the same profile does not churn mounts. + binds := m.binds + require.NoError(t, d.apply(kidA(), allItems())) + assert.Equal(t, binds, m.binds) + assert.Len(t, mountsAt(m.mounts, liveSaves), 1) + + // Deactivating removes our binds and only ours. + require.NoError(t, d.apply(platforms.ProfileRef{}, allItems())) + assert.Empty(t, mountsAt(m.mounts, liveSaves)) + assert.Empty(t, mountsAt(m.mounts, liveStates)) + assert.Empty(t, d.ledger.entries) +} + +func TestApply_SwitchBetweenProfiles(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, _ := newTestManager(m) + + require.NoError(t, d.apply(kidA(), allItems())) + require.NoError(t, d.apply(kidB(), allItems())) + + saves := mountsAt(m.mounts, "/media/fat/saves") + require.Len(t, saves, 1, "old profile's bind is removed, not stacked under") + assert.Contains(t, saves[0].Root, kidB().ID) +} + +func TestApply_PreparationFailureLeavesPreviousProfileUntouched(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, _ := newTestManager(m) + + require.NoError(t, d.apply(kidA(), allItems())) + bindsBefore := m.binds + d.fs = failMkdirFS{Fs: d.fs, path: filepath.Join(kidB().ID, profileDataItemSavestates)} + + err := d.apply(kidB(), allItems()) + require.Error(t, err) + assert.Equal(t, bindsBefore, m.binds, "preparation failure must not change mounts") + for _, item := range allItems() { + stack := mountsAt(m.mounts, filepath.Join(misterconfig.SDRootDir, item)) + require.Len(t, stack, 1) + assert.Contains(t, stack[0].Root, kidA().ID) + } +} + +func TestApply_SecondItemFailureRestoresPreviousProfile(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, _ := newTestManager(m) + + require.NoError(t, d.apply(kidA(), allItems())) + // Kid B's saves bind succeeds, then its savestates bind fails. Both + // items must return to Kid A before ApplyProfile reports the failure. + m.bindErr = errors.New("mount: I/O error") + m.failBindAtTry = m.bindAttempts + 2 + + err := d.apply(kidB(), allItems()) + require.Error(t, err) + + for _, item := range allItems() { + stack := mountsAt(m.mounts, filepath.Join(misterconfig.SDRootDir, item)) + require.Len(t, stack, 1) + assert.Contains(t, stack[0].Root, kidA().ID, "%s should be restored to Kid A", item) + } + require.Len(t, d.ledger.entries, 2) + for _, entry := range d.ledger.entries { + assert.Equal(t, kidA().ID, entry.ProfileID) + } +} + +func TestApply_LedgerFailureRollsBackNewBind(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, fs := newTestManager(m) + // Pool creation remains writable, but durable ownership recording does + // not. Apply must remove the already-created bind before returning. + d.ledger.fs = afero.NewReadOnlyFs(fs) + + err := d.apply(kidA(), []string{profileDataItemSaves}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to record profile bind") + assert.Empty(t, mountsAt(m.mounts, "/media/fat/saves")) + assert.Empty(t, d.ledger.entries) +} + +func TestApply_LedgerAndUnmountFailureDropsOwnership(t *testing.T) { + t.Parallel() + m := &fakeMounter{unmountErr: errors.New("injected unmount failure")} + d, fs := newTestManager(m) + d.ledger.fs = afero.NewReadOnlyFs(fs) + + err := d.apply(kidA(), []string{profileDataItemSaves}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to record profile bind") + assert.Contains(t, err.Error(), "injected unmount failure") + assert.Empty(t, d.ledger.entries, "failed bind must not remain owned in memory") +} + +func TestApply_USBRoot(t *testing.T) { + t.Parallel() + m := &fakeMounter{mounts: []mountEntry{ + {Root: "/", Mountpoint: "/media/usb0", FSType: "vfat", Source: "/dev/sda1"}, + }} + d, fs := newTestManager(m) + writeDeviceBin(t, fs, 1) + + require.NoError(t, d.apply(kidA(), allItems())) + + // Targets and pool both follow the USB storage root. + saves := mountsAt(m.mounts, "/media/usb0/saves") + require.Len(t, saves, 1) + assert.Equal(t, "/zaparoo/profiles/"+kidA().ID+"/saves", saves[0].Root) + assert.Equal(t, "/dev/sda1", saves[0].Source) + assert.Empty(t, mountsAt(m.mounts, "/media/fat/saves")) +} + +func TestApply_NASSavesStackOnForeignMount(t *testing.T) { + t.Parallel() + // A cifs saves mount (RetroNAS-style) already owns the target. + nas := mountEntry{ + Root: "/saves", Mountpoint: "/media/fat/saves", + FSType: "cifs", Source: "//10.0.0.3/MiSTer", + } + m := &fakeMounter{mounts: []mountEntry{nas}} + d, fs := newTestManager(m) + + require.NoError(t, d.apply(kidA(), []string{profileDataItemSaves})) + + stack := mountsAt(m.mounts, "/media/fat/saves") + require.Len(t, stack, 2, "our bind stacks on top; the NAS mount is untouched") + assert.Equal(t, nas, stack[0]) + // The pool lives inside the share, so saves stay on the NAS. + assert.Equal(t, "cifs", stack[1].FSType) + assert.Equal(t, "//10.0.0.3/MiSTer", stack[1].Source) + assert.Equal(t, "/saves/"+nasPoolDirName+"/"+kidA().ID+"/saves", stack[1].Root) + + // The pool directory was created through the (still-mounted) share. + exists, err := afero.DirExists(fs, + "/media/fat/saves/"+nasPoolDirName+"/"+kidA().ID+"/saves") + require.NoError(t, err) + assert.True(t, exists) + + // Deactivating removes only our layer; the NAS mount remains. + require.NoError(t, d.apply(platforms.ProfileRef{}, []string{profileDataItemSaves})) + stack = mountsAt(m.mounts, "/media/fat/saves") + require.Len(t, stack, 1) + assert.Equal(t, nas, stack[0]) +} + +func TestApply_ForeignMountLandsOnTopOfOurs(t *testing.T) { + t.Parallel() + // Our bind is active, then a late cifs boot script mounts the NAS + // share on top of it (mount order decides visibility). + m := &fakeMounter{} + d, _ := newTestManager(m) + require.NoError(t, d.apply(kidA(), []string{profileDataItemSaves})) + + nas := mountEntry{ + Root: "/saves", Mountpoint: "/media/fat/saves", + FSType: "cifs", Source: "//10.0.0.3/MiSTer", + } + m.mounts = append(m.mounts, nas) + + // Reconcile re-stacks a fresh bind sourced inside the NAS mount; the + // buried old bind and the NAS mount are both left alone. + require.NoError(t, d.apply(kidA(), []string{profileDataItemSaves})) + stack := mountsAt(m.mounts, "/media/fat/saves") + require.Len(t, stack, 3) + assert.Equal(t, "cifs", stack[2].FSType) + assert.Equal(t, "/saves/"+nasPoolDirName+"/"+kidA().ID+"/saves", stack[2].Root) +} + +func TestApply_SharedNeverTouchesForeignMounts(t *testing.T) { + t.Parallel() + nas := mountEntry{ + Root: "/saves", Mountpoint: "/media/fat/saves", + FSType: "cifs", Source: "//10.0.0.3/MiSTer", + } + m := &fakeMounter{mounts: []mountEntry{nas}} + d, _ := newTestManager(m) + + require.NoError(t, d.apply(platforms.ProfileRef{}, []string{profileDataItemSaves})) + stack := mountsAt(m.mounts, "/media/fat/saves") + require.Len(t, stack, 1, "shared profile leaves the user's NAS mount in place") + assert.Equal(t, nas, stack[0]) +} + +func TestApply_BindFailureReportsError(t *testing.T) { + t.Parallel() + m := &fakeMounter{bindErr: errors.New("mount: permission denied")} + d, _ := newTestManager(m) + + err := d.apply(kidA(), []string{profileDataItemSaves}) + require.Error(t, err) + assert.NotErrorIs(t, err, platforms.ErrProfileDataUnavailable) +} + +func TestApply_ReadOnlyShareIsUnavailable(t *testing.T) { + t.Parallel() + nas := mountEntry{ + Root: "/saves", Mountpoint: "/media/fat/saves", + FSType: "cifs", Source: "//10.0.0.3/MiSTer", + } + m := &fakeMounter{mounts: []mountEntry{nas}} + fs := afero.NewMemMapFs() + d := &profileDataManager{ + fs: afero.NewReadOnlyFs(fs), + m: m, + ledger: &mountLedger{fs: fs, path: mountLedgerPath}, + } + + err := d.apply(kidA(), []string{profileDataItemSaves}) + require.ErrorIs(t, err, platforms.ErrProfileDataUnavailable, + "a share we cannot write to makes the swap unavailable, not broken") + assert.Len(t, mountsAt(m.mounts, "/media/fat/saves"), 1, "no bind was attempted") +} + +func TestLedger_PersistsAcrossReload(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, fs := newTestManager(m) + require.NoError(t, d.apply(kidA(), []string{profileDataItemSaves})) + + // A restarted service (fresh manager, same tmpfs) still owns its bind + // and can cleanly deactivate it. + d2 := &profileDataManager{fs: fs, m: m, ledger: loadMountLedger(fs, mountLedgerPath)} + require.Len(t, d2.ledger.entries, 1) + require.NoError(t, d2.apply(platforms.ProfileRef{}, []string{profileDataItemSaves})) + assert.Empty(t, mountsAt(m.mounts, "/media/fat/saves")) +} + +func TestLedger_PrunesVanishedMounts(t *testing.T) { + t.Parallel() + m := &fakeMounter{} + d, _ := newTestManager(m) + require.NoError(t, d.apply(kidA(), []string{profileDataItemSaves})) + + // Someone manually unmounted our bind behind our back. + require.NoError(t, m.Unmount("/media/fat/saves")) + require.NoError(t, d.apply(kidA(), []string{profileDataItemSaves})) + + require.Len(t, d.ledger.entries, 1, "stale entry pruned, fresh bind recorded") + assert.Len(t, mountsAt(m.mounts, "/media/fat/saves"), 1) +} diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go index 211b9e2b3..c86f1775c 100644 --- a/pkg/platforms/platforms.go +++ b/pkg/platforms/platforms.go @@ -164,11 +164,24 @@ type CmdEnv struct { Unsafe bool } +// ProfileSwitchRequest asks the script runner to change the device's +// active profile. SwitchID selects a profile by its card switch ID; Clear +// deactivates the current profile instead. +type ProfileSwitchRequest struct { + SwitchID string + Clear bool +} + // CmdResult returns a summary of what global side effects may or may not have // happened as a result of a single ZapScript command running. type CmdResult struct { // Playlist is the result of the playlist change. Playlist *playlists.Playlist + // ProfileSwitch requests the active profile be changed. Commands return + // the request as intent; the service layer applies it (same pattern as + // Playlist). The scan path activates without a PIN check — possession + // of the card is the authorization. + ProfileSwitch *ProfileSwitchRequest // Strategy indicates which matching strategy was used for title-based launches. // Empty for non-title commands. Used for testing and debugging title resolution. Strategy string diff --git a/pkg/platforms/profiles.go b/pkg/platforms/profiles.go new file mode 100644 index 000000000..741ab8e41 --- /dev/null +++ b/pkg/platforms/profiles.go @@ -0,0 +1,83 @@ +// 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 platforms + +import ( + "context" + "errors" +) + +// ErrProfileDataUnavailable is returned (wrapped) by ApplyProfile when the +// swap cannot run on the current storage setup — e.g. saves are on a +// read-only network mount — as opposed to an operation failing. Callers +// report it as "unavailable" rather than "failed". +var ErrProfileDataUnavailable = errors.New("profile data swap unavailable") + +// ProfileItem owner classes. "profile" data swaps with the active profile, +// "device" data never swaps (it belongs to the hardware/display), and +// "shared" data is ambiguous by default and needs an explicit user choice +// before it would ever swap. +const ( + ProfileItemOwnerProfile = "profile" + ProfileItemOwnerDevice = "device" + ProfileItemOwnerShared = "shared" +) + +// ProfileItem describes one category of data a profile change can affect +// on a platform. It is purely descriptive: no paths, no mechanisms. +type ProfileItem struct { + ID string // stable identity, e.g. "saves", "savestates" + Label string // for client UI, e.g. "Save files" + Owner string // one of the ProfileItemOwner* constants +} + +// ProfileRef identifies the profile whose data should be made current. An +// empty ID means the shared profile (the device's un-profiled state). Name +// is the display name, used by platforms to label per-profile storage for +// humans browsing it outside Zaparoo. +type ProfileRef struct { + ID string + Name string +} + +// ProfileDataWatcher is an optional platform capability: platforms whose +// profile data state can change underneath the service (e.g. MiSTer's +// mount table, where a cifs boot script or a USB drive can appear at any +// time) implement it so the service can re-reconcile on those changes. +type ProfileDataWatcher interface { + // WatchProfileData invokes onChange (possibly from another goroutine) + // whenever platform storage state changes, until ctx is done. + WatchProfileData(ctx context.Context, onChange func()) +} + +// ProfileDataSwapper is an optional platform capability: platforms that can +// swap profile-scoped data (save files, save states) implement it and core +// discovers it by type assertion. Platforms without the capability simply +// don't implement it — profiles there are limits + attribution only. +type ProfileDataSwapper interface { + // ProfileItems reports what a profile change can affect on this + // platform. + ProfileItems() []ProfileItem + // ApplyProfile makes the platform's profile-scoped data current for + // the given profile, applying only the enabled item IDs. It is called + // only while no media is running, must be idempotent, and must leave + // existing data intact on failure. + ApplyProfile(ref ProfileRef, enabledItems []string) error +} diff --git a/pkg/service/context.go b/pkg/service/context.go index 3b3524628..c14711cb9 100644 --- a/pkg/service/context.go +++ b/pkg/service/context.go @@ -27,6 +27,7 @@ import ( "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/profiles" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" ) @@ -38,6 +39,7 @@ type ServiceContext struct { Config *config.Instance State *state.State DB *database.Database + Profiles *profiles.Service PlaybackManager audio.PlaybackManager LaunchSoftwareQueue chan *tokens.Token PlaylistQueue chan *playlists.Playlist diff --git a/pkg/service/media_history_tracker.go b/pkg/service/media_history_tracker.go index 76a04ea0b..b37f0f49e 100644 --- a/pkg/service/media_history_tracker.go +++ b/pkg/service/media_history_tracker.go @@ -128,6 +128,12 @@ func (t *mediaHistoryTracker) listen(notificationChan <-chan models.Notification CreatedAt: now, UpdatedAt: now, } + // Attribution is fixed at launch time: the row keeps this + // profile even if the active profile switches mid-game. + if activeProfile := t.st.ActiveProfile(); activeProfile != nil { + profileID := activeProfile.ProfileID + entry.ProfileID = &profileID + } dbid, addErr := t.db.UserDB.AddMediaHistory(entry) if addErr != nil { log.Error().Err(addErr).Msg("failed to add media history entry") diff --git a/pkg/service/playtime/limits.go b/pkg/service/playtime/limits.go index 69f3eb220..8c93b5e07 100644 --- a/pkg/service/playtime/limits.go +++ b/pkg/service/playtime/limits.go @@ -74,6 +74,18 @@ func (s SessionState) String() string { return [...]string{"reset", "active", "cooldown"}[s] } +// pinnedLimits captures the limits context in force when a game launched. +// Everything about a running game belongs to the profile that launched it: +// if the profile deactivates mid-game (switch to the shared profile), the +// pinned values keep governing until the media stops, so clearing a +// profile cannot be used to escape its limits. +type pinnedLimits struct { + profileID string + daily time.Duration + session time.Duration + enabled bool +} + // LimitsManager enforces time limits and warnings for gameplay sessions. type LimitsManager struct { sessionStart time.Time @@ -88,9 +100,12 @@ type LimitsManager struct { db *database.Database notificationsSend chan<- models.Notification cfg *config.Instance + limits LimitsProvider + sessionLimits *pinnedLimits // launch-time limits for the running game; nil between sessions player audio.Player cancel context.CancelFunc sessionCancel context.CancelFunc // cancels checkLoop for the current game session; nil between sessions + lastProfileID string // last-seen active profile ID, for identity-change detection state SessionState sessionCumulativeTime time.Duration subscriptionID int @@ -123,6 +138,7 @@ func NewLimitsManager( db: db, platform: platform, cfg: cfg, + limits: globalProvider{cfg: cfg}, clock: clock, player: player, ctx: ctx, @@ -133,6 +149,12 @@ func NewLimitsManager( } } +// SetLimitsProvider replaces the source of limit values, e.g. with the +// profile-aware resolver. Must be called before Start. +func (tm *LimitsManager) SetLimitsProvider(limits LimitsProvider) { + tm.limits = limits +} + // Broker is the interface for subscribing to notifications. type Broker interface { Subscribe(bufferSize int, methods ...string) (<-chan models.Notification, int) @@ -148,12 +170,17 @@ func (tm *LimitsManager) Start(broker Broker, notificationsSend chan<- models.No tm.notificationsSend = notificationsSend tm.done = done tm.stopping = false + // Seed identity-change detection with the boot-restored profile: its + // profiles.active notification fired before this subscription existed. + tm.lastProfileID = tm.limits.ActiveProfileID() tm.mu.Unlock() - // Subscribe to broker for media.started and media.stopped events only. + // Subscribe to broker for media lifecycle and profile-switch events only. // The method filter prevents indexing-storm traffic from filling the buffer - // and dropping these rare lifecycle events. - notifChan, subID := broker.Subscribe(32, models.NotificationStarted, models.NotificationStopped) + // and dropping these rare events. profiles.active MUST be in this filter or + // profile switches never reset the session. + notifChan, subID := broker.Subscribe(32, + models.NotificationStarted, models.NotificationStopped, models.NotificationProfilesActive) tm.subscriptionID = subID go func() { @@ -178,9 +205,13 @@ func (tm *LimitsManager) Stop() { tm.wg.Wait() } -// SetEnabled enables or disables limit enforcement at runtime. -// When disabling, resets the session state completely (clears cooldown and cumulative time). -// When re-enabling, session starts fresh but daily usage from history is still enforced. +// SetEnabled records the runtime enabled state and, when disabling, resets +// the session completely (clears cooldown and cumulative time). Whether +// limits are actually enforced is decided by the LimitsProvider on every +// check (global config, possibly overridden by the active profile) — this +// flag exists for its session-reset side effect when the user toggles +// limits off, and is kept in sync with global config by the settings +// handler. func (tm *LimitsManager) SetEnabled(enabled bool) { tm.enabledMu.Lock() tm.enabled = enabled @@ -210,11 +241,159 @@ func (tm *LimitsManager) SetEnabled(enabled bool) { tm.lastStopTime = time.Time{} tm.sessionStartReliable = false tm.warningsGiven = make(map[time.Duration]bool) + tm.sessionLimits = nil } tm.mu.Unlock() } } +// onProfileChanged handles a profiles.active notification. The session is +// reset only when the profile *identity* changes to a different person: +// +// - Re-activating the already-active profile (rescanning your own card) +// or editing the active profile (which refreshes the snapshot and +// re-broadcasts) does nothing — otherwise a rescan every 50 minutes +// would defeat a 1h session limit. +// - Deactivating (switching to the shared profile) never resets: a +// running game keeps its launch-pinned limits until it stops, and +// cooldown/cumulative time survives so scanning a **profile.clear +// card cannot be used to escape a session limit. +// - Switching to a different profile resets the session: a different +// person is playing. If a game is running, the session is re-pinned +// to the new profile and tracking restarts from now. +func (tm *LimitsManager) onProfileChanged() { + current := tm.limits.ActiveProfileID() + + tm.mu.Lock() + if current == tm.lastProfileID { + tm.mu.Unlock() + return + } + tm.lastProfileID = current + + if current == "" { + tm.mu.Unlock() + log.Info().Msg("playtime: profile deactivated, session state retained") + return + } + + if tm.state == StateActive { + // The running game now belongs to the new profile. + tm.sessionLimits = tm.snapshotLimits() + } + tm.mu.Unlock() + + tm.ResetSession() +} + +// snapshotLimits captures the provider's current values. Callers may hold +// tm.mu: the provider reads service state under its own lock. +func (tm *LimitsManager) snapshotLimits() *pinnedLimits { + return &pinnedLimits{ + profileID: tm.limits.ActiveProfileID(), + enabled: tm.limits.PlaytimeLimitsEnabled(), + daily: tm.limits.DailyLimit(), + session: tm.limits.SessionLimit(), + } +} + +// pinned returns the launch-time limits when they should govern instead of +// the live provider: a game session exists, it was launched (or re-pinned) +// under a profile, and the device has since deactivated to the shared +// profile. Returns nil when live values apply. Must be called without +// tm.mu held. +func (tm *LimitsManager) pinned() *pinnedLimits { + tm.mu.Lock() + p := tm.sessionLimits + tm.mu.Unlock() + if p == nil || p.profileID == "" { + return nil + } + if tm.limits.ActiveProfileID() != "" { + return nil + } + return p +} + +// effectiveEnabled reports whether limits are enforced for the current +// session, honoring launch-time pinning. Must be called without tm.mu held. +func (tm *LimitsManager) effectiveEnabled() bool { + if p := tm.pinned(); p != nil { + return p.enabled + } + return tm.limits.PlaytimeLimitsEnabled() +} + +// effectiveDailyLimit returns the daily limit for the current session, +// honoring launch-time pinning. Must be called without tm.mu held. +func (tm *LimitsManager) effectiveDailyLimit() time.Duration { + if p := tm.pinned(); p != nil { + return p.daily + } + return tm.limits.DailyLimit() +} + +// effectiveSessionLimit returns the session limit for the current session, +// honoring launch-time pinning. Must be called without tm.mu held. +func (tm *LimitsManager) effectiveSessionLimit() time.Duration { + if p := tm.pinned(); p != nil { + return p.session + } + return tm.limits.SessionLimit() +} + +// effectiveProfileID returns the profile whose history scopes daily usage +// accounting, honoring launch-time pinning. Must be called without tm.mu +// held. +func (tm *LimitsManager) effectiveProfileID() string { + if p := tm.pinned(); p != nil { + return p.profileID + } + return tm.limits.ActiveProfileID() +} + +// ResetSession starts a fresh limit session, called when the active +// profile identity changes: a different person is playing, so accumulated +// session time belongs to the previous profile. Daily usage is unaffected +// — it is recalculated from the (profile-attributed) history on every +// check. +// +// If a game is running, tracking restarts from now under the new profile's +// limits rather than stopping: the running game's already-played time was +// the previous profile's. +func (tm *LimitsManager) ResetSession() { + tm.mu.Lock() + defer tm.mu.Unlock() + + if tm.cooldownTimer != nil { + tm.cooldownTimer.Stop() + tm.cooldownTimer = nil + log.Debug().Msg("playtime: cancelled cooldown timer (profile switched)") + } + + switch tm.state { + case StateActive: + log.Info().Msg("playtime: profile switched mid-game, restarting session tracking") + now := tm.clock.Now() + tm.sessionStart = now + tm.sessionStartMono = time.Now() + tm.sessionStartReliable = helpers.IsClockReliable(now) + tm.sessionCumulativeTime = 0 + tm.warningsGiven = make(map[time.Duration]bool) + case StateCooldown: + log.Info().Msg("playtime: profile switched, resetting session state") + tm.transitionTo(StateReset) + tm.sessionStart = time.Time{} + tm.sessionStartMono = time.Time{} + tm.sessionCumulativeTime = 0 + tm.lastStopTime = time.Time{} + tm.sessionStartReliable = false + tm.warningsGiven = make(map[time.Duration]bool) + case StateReset: + tm.sessionCumulativeTime = 0 + } +} + // IsEnabled returns whether limits are currently enforced. func (tm *LimitsManager) IsEnabled() bool { tm.enabledMu.Lock() @@ -265,6 +444,8 @@ func (tm *LimitsManager) handleNotifications(notifChan <-chan models.Notificatio tm.OnMediaStarted() case models.NotificationStopped: tm.OnMediaStopped() + case models.NotificationProfilesActive: + tm.onProfileChanged() } case <-tm.ctx.Done(): @@ -275,7 +456,7 @@ func (tm *LimitsManager) handleNotifications(notifChan <-chan models.Notificatio // OnMediaStarted handles media.started events and begins time tracking. func (tm *LimitsManager) OnMediaStarted() { - if !tm.cfg.PlaytimeLimitsEnabled() { + if !tm.limits.PlaytimeLimitsEnabled() { return } @@ -320,6 +501,11 @@ func (tm *LimitsManager) OnMediaStarted() { tm.sessionStartReliable = helpers.IsClockReliable(now) tm.warningsGiven = make(map[time.Duration]bool) + // Pin the launch-time limits context: everything about this game + // belongs to the profile that launched it, even if the profile + // deactivates mid-game. + tm.sessionLimits = tm.snapshotLimits() + // Cancel any stale check loop (defensive; shouldn't fire in normal state transitions). if tm.sessionCancel != nil { tm.sessionCancel() @@ -380,6 +566,7 @@ func (tm *LimitsManager) OnMediaStopped() { tm.sessionStartMono = time.Time{} // Clear monotonic start tm.sessionStartReliable = false tm.warningsGiven = make(map[time.Duration]bool) + tm.sessionLimits = nil // launch-time pinning ends with the game // Start cooldown timer if timeout is configured (read live from config). if sessionResetTimeout := tm.cfg.SessionResetTimeout(); sessionResetTimeout > 0 { @@ -454,8 +641,9 @@ func (tm *LimitsManager) checkLoop(ctx context.Context) { // checkLimits evaluates all rules and handles warnings/limits. func (tm *LimitsManager) checkLimits() { - // Respect both config and runtime enabled state - if !tm.cfg.PlaytimeLimitsEnabled() || !tm.IsEnabled() { + // Enforcement is decided by the effective limits: the live provider, + // or the launch-pinned context after a mid-game deactivation. + if !tm.effectiveEnabled() { return } @@ -566,7 +754,7 @@ func (tm *LimitsManager) buildRuleContext( bothClocksReliable := sessionStartWasReliable && currentClockReliable var dailyUsage time.Duration - if bothClocksReliable && tm.cfg.DailyLimit() > 0 { + if bothClocksReliable && tm.effectiveDailyLimit() > 0 { // Both clocks appear valid and a daily limit is configured - calculate daily usage. year, month, day := now.Date() todayStart := time.Date(year, month, day, 0, 0, 0, 0, now.Location()) @@ -626,6 +814,9 @@ func (tm *LimitsManager) buildRuleContext( // calculateDailyUsage returns the total play time for today. // completedSeconds is the sum of all closed sessions overlapping today (from SQL). +// When a profile is active, only history attributed to that profile is +// counted; the shared profile (no active profile) counts all history, so +// deactivating never grants a fresh daily allowance. // currentSessionDuration is the current game's contribution to today (computed by // the caller and already clamped to the today window). // Active sessions (EndTime IS NULL) are excluded by the SQL query; callers add @@ -634,7 +825,13 @@ func (tm *LimitsManager) calculateDailyUsage( todayStart time.Time, currentSessionDuration time.Duration, ) (time.Duration, error) { - completedSeconds, err := tm.db.UserDB.SumMediaPlayTimeForDay(todayStart) + var completedSeconds int64 + var err error + if profileID := tm.effectiveProfileID(); profileID != "" { + completedSeconds, err = tm.db.UserDB.SumMediaPlayTimeForDayByProfile(todayStart, profileID) + } else { + completedSeconds, err = tm.db.UserDB.SumMediaPlayTimeForDay(todayStart) + } if err != nil { return 0, fmt.Errorf("failed to sum daily play time: %w", err) } @@ -645,11 +842,11 @@ func (tm *LimitsManager) calculateDailyUsage( func (tm *LimitsManager) createRules() []Rule { rules := make([]Rule, 0, 2) - if limit := tm.cfg.SessionLimit(); limit > 0 { + if limit := tm.effectiveSessionLimit(); limit > 0 { rules = append(rules, &SessionLimitRule{Limit: limit}) } - if limit := tm.cfg.DailyLimit(); limit > 0 { + if limit := tm.effectiveDailyLimit(); limit > 0 { rules = append(rules, &DailyLimitRule{Limit: limit}) } @@ -658,7 +855,7 @@ func (tm *LimitsManager) createRules() []Rule { // handleWarnings checks if warnings should be emitted based on remaining time. func (tm *LimitsManager) handleWarnings(remaining time.Duration) { - intervals := tm.cfg.WarningIntervals() + intervals := tm.limits.WarningIntervals() // Sort intervals in descending order (largest first) sort.Slice(intervals, func(i, j int) bool { @@ -736,7 +933,7 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { // Calculate daily usage/remaining even during reset - this data is valid // regardless of session state (the user has used time today and has // time remaining in their daily allowance) - dailyLimit := tm.cfg.DailyLimit() + dailyLimit := tm.effectiveDailyLimit() if dailyLimit > 0 && helpers.IsClockReliable(now) { year, month, day := now.Date() todayStart := time.Date(year, month, day, 0, 0, 0, 0, now.Location()) @@ -768,8 +965,8 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { // Calculate remaining times based on cumulative time var sessionRemaining time.Duration - sessionLimit := tm.cfg.SessionLimit() - dailyLimit := tm.cfg.DailyLimit() + sessionLimit := tm.effectiveSessionLimit() + dailyLimit := tm.effectiveDailyLimit() if sessionLimit > 0 { sessionRemaining = sessionLimit - cumulativeTime @@ -823,8 +1020,8 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { // Calculate session remaining time var sessionRemaining time.Duration - sessionLimit := tm.cfg.SessionLimit() - dailyLimit := tm.cfg.DailyLimit() + sessionLimit := tm.effectiveSessionLimit() + dailyLimit := tm.effectiveDailyLimit() if sessionLimit > 0 { sessionRemaining = sessionLimit - ctx.SessionDuration @@ -876,13 +1073,14 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { // - Remaining time < MinimumViableSession (prevents launching a game that will be immediately killed) // On success, reason is "" and error is nil. func (tm *LimitsManager) CheckBeforeLaunch() (string, error) { - // Check if limits are enabled (both config and runtime) - if !tm.cfg.PlaytimeLimitsEnabled() || !tm.IsEnabled() { + // Whether limits are enforced is decided by the LimitsProvider (global + // config, possibly overridden by the active profile). + if !tm.limits.PlaytimeLimitsEnabled() { return "", nil } - dailyLimit := tm.cfg.DailyLimit() - sessionLimit := tm.cfg.SessionLimit() + dailyLimit := tm.limits.DailyLimit() + sessionLimit := tm.limits.SessionLimit() // If no limits configured, allow launch if dailyLimit == 0 && sessionLimit == 0 { diff --git a/pkg/service/playtime/profile_limits_test.go b/pkg/service/playtime/profile_limits_test.go new file mode 100644 index 000000000..5c3b5e8d9 --- /dev/null +++ b/pkg/service/playtime/profile_limits_test.go @@ -0,0 +1,380 @@ +// 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 playtime + +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/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// stubProvider is a fixed-value LimitsProvider for tests. +type stubProvider struct { + profileID string + warnings []time.Duration + daily time.Duration + session time.Duration + enabled bool +} + +func (s stubProvider) PlaytimeLimitsEnabled() bool { return s.enabled } +func (s stubProvider) DailyLimit() time.Duration { return s.daily } +func (s stubProvider) SessionLimit() time.Duration { return s.session } +func (s stubProvider) WarningIntervals() []time.Duration { return s.warnings } +func (s stubProvider) ActiveProfileID() string { return s.profileID } + +func TestCalculateDailyUsage_ProfileScoped(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + todayStart := time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC) + profileID := "kid-a" + + mockDB := testhelpers.NewMockUserDBI() + // Only the profile-scoped sum may be used; an unscoped + // SumMediaPlayTimeForDay call would fail the mock expectations. + mockDB.On("SumMediaPlayTimeForDayByProfile", todayStart, profileID). + Return(int64(3600), nil) + + tm := NewLimitsManager( + &database.Database{UserDB: mockDB}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer(), + ) + tm.SetLimitsProvider(stubProvider{enabled: true, daily: 2 * time.Hour, profileID: profileID}) + + usage, err := tm.calculateDailyUsage(todayStart, 0) + require.NoError(t, err) + assert.Equal(t, time.Hour, usage) + mockDB.AssertExpectations(t) +} + +func TestCalculateDailyUsage_NoProfile_SumsAllHistory(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + todayStart := time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC) + + mockDB := testhelpers.NewMockUserDBI() + // Without an active profile, the unscoped sum is used — device-level + // accounting is byte-identical to pre-profile behavior. + mockDB.On("SumMediaPlayTimeForDay", todayStart).Return(int64(1800), nil) + + tm := NewLimitsManager( + &database.Database{UserDB: mockDB}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer(), + ) + + usage, err := tm.calculateDailyUsage(todayStart, 0) + require.NoError(t, err) + assert.Equal(t, 30*time.Minute, usage) + mockDB.AssertExpectations(t) +} + +func TestCheckBeforeLaunch_ProfileLimitsWithGlobalDisabled(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + profileID := "kid-a" + + mockDB := testhelpers.NewMockUserDBI() + // 2 hours played today by this profile. + mockDB.On("SumMediaPlayTimeForDayByProfile", mock.AnythingOfType("time.Time"), profileID). + Return(int64(7200), nil) + + // Global config has limits disabled — the profile override alone must + // enforce its 1 hour daily limit (the issue #883 use case). + cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults) + require.NoError(t, err) + cfg.SetPlaytimeLimitsEnabled(false) + + tm := NewLimitsManager( + &database.Database{UserDB: mockDB}, nil, cfg, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer(), + ) + tm.SetLimitsProvider(stubProvider{enabled: true, daily: time.Hour, profileID: profileID}) + + reason, err := tm.CheckBeforeLaunch() + require.Error(t, err) + assert.Contains(t, err.Error(), "daily playtime limit reached") + assert.Equal(t, models.PlaytimeLimitReasonDaily, reason) +} + +func TestCheckBeforeLaunch_ProviderDisabledSkipsChecks(t *testing.T) { + t.Parallel() + + mockDB := testhelpers.NewMockUserDBI() + tm := NewLimitsManager( + &database.Database{UserDB: mockDB}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC)), + newNoOpMockPlayer(), + ) + tm.SetLimitsProvider(stubProvider{enabled: false, daily: time.Nanosecond}) + + reason, err := tm.CheckBeforeLaunch() + require.NoError(t, err) + assert.Empty(t, reason) + mockDB.AssertExpectations(t) +} + +func TestResetSession_ActiveGameRestartsTracking(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + clock := clockwork.NewFakeClockAt(now) + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, clock, newNoOpMockPlayer()) + + // Simulate an active session with accumulated time. + tm.mu.Lock() + tm.state = StateActive + tm.sessionStart = now.Add(-time.Hour) + tm.sessionStartMono = time.Now().Add(-time.Hour) + tm.sessionCumulativeTime = 45 * time.Minute + tm.warningsGiven[5*time.Minute] = true + tm.mu.Unlock() + + tm.ResetSession() + + tm.mu.Lock() + defer tm.mu.Unlock() + assert.Equal(t, StateActive, tm.state, "running game keeps tracking under the new profile") + assert.True(t, tm.sessionStart.Equal(now), "session restarts from the switch moment") + assert.Equal(t, time.Duration(0), tm.sessionCumulativeTime) + assert.Empty(t, tm.warningsGiven) +} + +func TestResetSession_CooldownResets(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + clock := clockwork.NewFakeClockAt(now) + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, clock, newNoOpMockPlayer()) + + tm.mu.Lock() + tm.state = StateCooldown + tm.sessionCumulativeTime = 30 * time.Minute + tm.lastStopTime = now.Add(-time.Minute) + tm.cooldownTimer = clock.NewTimer(20 * time.Minute) + tm.mu.Unlock() + + tm.ResetSession() + + tm.mu.Lock() + defer tm.mu.Unlock() + assert.Equal(t, StateReset, tm.state) + assert.Equal(t, time.Duration(0), tm.sessionCumulativeTime) + assert.Nil(t, tm.cooldownTimer) + assert.True(t, tm.lastStopTime.IsZero()) +} + +// swappableProvider is a LimitsProvider whose values tests mutate to +// simulate profile switches at runtime. +type swappableProvider struct { + cur stubProvider + mu syncutil.Mutex +} + +func (s *swappableProvider) set(p stubProvider) { + s.mu.Lock() + defer s.mu.Unlock() + s.cur = p +} + +func (s *swappableProvider) get() stubProvider { + s.mu.Lock() + defer s.mu.Unlock() + return s.cur +} + +func (s *swappableProvider) PlaytimeLimitsEnabled() bool { return s.get().enabled } +func (s *swappableProvider) DailyLimit() time.Duration { return s.get().daily } +func (s *swappableProvider) SessionLimit() time.Duration { return s.get().session } +func (s *swappableProvider) WarningIntervals() []time.Duration { return s.get().warnings } +func (s *swappableProvider) ActiveProfileID() string { return s.get().profileID } + +// captureBroker records the method filter passed to Subscribe. +type captureBroker struct { + ch chan models.Notification + methods []string +} + +func (b *captureBroker) Subscribe(_ int, methods ...string) (notifChan <-chan models.Notification, id int) { + b.methods = methods + b.ch = make(chan models.Notification) + return b.ch, 1 +} + +func (*captureBroker) Unsubscribe(int) {} + +func TestStart_SubscribesToProfileNotifications(t *testing.T) { + t.Parallel() + + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC)), newNoOpMockPlayer()) + b := &captureBroker{} + tm.Start(b, nil) + defer tm.Stop() + + // The broker filter must include profiles.active or profile switches + // silently never reach the limits manager. + assert.Contains(t, b.methods, models.NotificationProfilesActive) + assert.Contains(t, b.methods, models.NotificationStarted) + assert.Contains(t, b.methods, models.NotificationStopped) +} + +func TestOnProfileChanged_SameProfileDoesNotResetSession(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer()) + provider := &swappableProvider{} + provider.set(stubProvider{enabled: true, session: time.Hour, profileID: "kid-a"}) + tm.SetLimitsProvider(provider) + + sessionStart := now.Add(-50 * time.Minute) + tm.mu.Lock() + tm.lastProfileID = "kid-a" + tm.state = StateActive + tm.sessionStart = sessionStart + tm.sessionStartMono = time.Now().Add(-50 * time.Minute) + tm.sessionCumulativeTime = 10 * time.Minute + tm.mu.Unlock() + + // Rescanning your own profile card re-broadcasts profiles.active with + // the same identity — the session must NOT restart, or rescanning + // every 50 minutes defeats a 1h session limit. + tm.onProfileChanged() + + tm.mu.Lock() + defer tm.mu.Unlock() + assert.True(t, tm.sessionStart.Equal(sessionStart), "session start unchanged") + assert.Equal(t, 10*time.Minute, tm.sessionCumulativeTime) +} + +func TestOnProfileChanged_DifferentProfileResetsAndRepins(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer()) + provider := &swappableProvider{} + provider.set(stubProvider{enabled: true, session: 2 * time.Hour, profileID: "kid-b"}) + tm.SetLimitsProvider(provider) + + tm.mu.Lock() + tm.lastProfileID = "kid-a" + tm.state = StateActive + tm.sessionStart = now.Add(-50 * time.Minute) + tm.sessionStartMono = time.Now().Add(-50 * time.Minute) + tm.sessionCumulativeTime = 10 * time.Minute + tm.sessionLimits = &pinnedLimits{profileID: "kid-a", enabled: true, session: time.Hour} + tm.mu.Unlock() + + tm.onProfileChanged() + + tm.mu.Lock() + defer tm.mu.Unlock() + assert.Equal(t, "kid-b", tm.lastProfileID) + assert.True(t, tm.sessionStart.Equal(now), "session restarts for the new person") + assert.Equal(t, time.Duration(0), tm.sessionCumulativeTime) + require.NotNil(t, tm.sessionLimits) + assert.Equal(t, "kid-b", tm.sessionLimits.profileID, "running game re-pinned to the new profile") + assert.Equal(t, 2*time.Hour, tm.sessionLimits.session) +} + +func TestOnProfileChanged_DeactivationMidGameKeepsLaunchLimits(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer()) + provider := &swappableProvider{} + provider.set(stubProvider{enabled: true, session: time.Hour, daily: 2 * time.Hour, profileID: "kid-a"}) + tm.SetLimitsProvider(provider) + + sessionStart := now.Add(-30 * time.Minute) + tm.mu.Lock() + tm.lastProfileID = "kid-a" + tm.state = StateActive + tm.sessionStart = sessionStart + tm.sessionStartMono = time.Now().Add(-30 * time.Minute) + tm.sessionLimits = &pinnedLimits{ + profileID: "kid-a", enabled: true, session: time.Hour, daily: 2 * time.Hour, + } + tm.mu.Unlock() + + // Scan a **profile.clear card mid-game: live limits drop to the shared + // profile (disabled here), but the running game keeps its launch + // profile's limits and identity. + provider.set(stubProvider{enabled: false, profileID: ""}) + tm.onProfileChanged() + + tm.mu.Lock() + assert.True(t, tm.sessionStart.Equal(sessionStart), "deactivation does not reset the session") + assert.Empty(t, tm.lastProfileID) + tm.mu.Unlock() + + assert.True(t, tm.effectiveEnabled(), "pinned enablement survives deactivation") + assert.Equal(t, time.Hour, tm.effectiveSessionLimit()) + assert.Equal(t, 2*time.Hour, tm.effectiveDailyLimit()) + assert.Equal(t, "kid-a", tm.effectiveProfileID(), "daily usage stays scoped to the launch profile") + + // Once the game stops, pinning ends and live (shared) limits apply. + tm.OnMediaStopped() + assert.False(t, tm.effectiveEnabled()) + assert.Empty(t, tm.effectiveProfileID()) +} + +func TestOnProfileChanged_DeactivationInCooldownRetainsCumulative(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + clock := clockwork.NewFakeClockAt(now) + tm := NewLimitsManager(&database.Database{}, nil, &config.Instance{}, clock, newNoOpMockPlayer()) + provider := &swappableProvider{} + provider.set(stubProvider{enabled: false, profileID: ""}) + tm.SetLimitsProvider(provider) + + tm.mu.Lock() + tm.lastProfileID = "kid-a" + tm.state = StateCooldown + tm.sessionCumulativeTime = 50 * time.Minute + tm.lastStopTime = now.Add(-time.Minute) + tm.mu.Unlock() + + // Stop the game, clear the profile, relaunch: cumulative session time + // must survive the deactivation or the cooldown mechanism is + // escapable with a **profile.clear card. + tm.onProfileChanged() + + tm.mu.Lock() + defer tm.mu.Unlock() + assert.Equal(t, StateCooldown, tm.state) + assert.Equal(t, 50*time.Minute, tm.sessionCumulativeTime) +} diff --git a/pkg/service/playtime/provider.go b/pkg/service/playtime/provider.go new file mode 100644 index 000000000..0d97329f9 --- /dev/null +++ b/pkg/service/playtime/provider.go @@ -0,0 +1,58 @@ +// 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 playtime + +import ( + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" +) + +// LimitsProvider is the source of playtime limit values for the +// LimitsManager. The default implementation reads global config directly; +// the profiles service provides an implementation that layers the active +// profile's overrides over global config (see +// pkg/service/profiles.LimitsResolver). +type LimitsProvider interface { + // PlaytimeLimitsEnabled reports whether limits are enforced. + PlaytimeLimitsEnabled() bool + // DailyLimit returns the daily limit, or 0 for no limit. + DailyLimit() time.Duration + // SessionLimit returns the per-session limit, or 0 for no limit. + SessionLimit() time.Duration + // WarningIntervals returns the remaining-time warning thresholds. + WarningIntervals() []time.Duration + // ActiveProfileID returns the active profile's ID, or "" when no + // profile is active. Daily usage accounting is scoped to this + // profile's attributed history; "" sums all history (device-level). + ActiveProfileID() string +} + +// globalProvider is the default LimitsProvider: global config values, no +// profile scoping. +type globalProvider struct { + cfg *config.Instance +} + +func (g globalProvider) PlaytimeLimitsEnabled() bool { return g.cfg.PlaytimeLimitsEnabled() } +func (g globalProvider) DailyLimit() time.Duration { return g.cfg.DailyLimit() } +func (g globalProvider) SessionLimit() time.Duration { return g.cfg.SessionLimit() } +func (g globalProvider) WarningIntervals() []time.Duration { return g.cfg.WarningIntervals() } +func (globalProvider) ActiveProfileID() string { return "" } diff --git a/pkg/service/profiles/dataswap.go b/pkg/service/profiles/dataswap.go new file mode 100644 index 000000000..69e16f416 --- /dev/null +++ b/pkg/service/profiles/dataswap.go @@ -0,0 +1,339 @@ +// 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 profiles + +import ( + "errors" + "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/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/rs/zerolog/log" +) + +// switchApplyTimeout bounds how long a profile switch waits inline for its +// data swap. Bind mounts are a few syscalls, so this only expires when +// storage misbehaves (e.g. a dead NAS); the apply keeps running in the +// worker and reports via notification when it finishes. +const switchApplyTimeout = time.Second + +// Broker is the interface for subscribing to notifications. +type Broker interface { + Subscribe(bufferSize int, methods ...string) (<-chan models.Notification, int) + Unsubscribe(id int) +} + +// swapRequest is one unit of work for the apply worker. emitSuccess +// controls whether a successful apply broadcasts a profiles.data +// notification: switch-originated applies do, quiet reconciles (boot, +// mount watcher) only report errors. +type swapRequest struct { + done chan struct{} + ref platforms.ProfileRef + emitSuccess bool +} + +type pendingSwap struct { + ref platforms.ProfileRef + sequence uint64 +} + +// DataSwapCoordinator applies profile data swaps through a platform's +// ProfileDataSwapper. The profile switch itself is instant and never fails +// because of file operations: swaps run in a worker goroutine, switches +// wait for it only briefly, swaps while media is running are deferred +// until it stops, and queued targets coalesce to the most recent one. +type DataSwapCoordinator struct { + cfg *config.Instance + st *state.State + swapper platforms.ProfileDataSwapper + notify chan<- models.Notification + targetCh chan swapRequest + quit chan struct{} + done chan struct{} + pending *pendingSwap + mu syncutil.Mutex + sequence uint64 + subID int + started bool +} + +// NewDataSwapCoordinator creates a coordinator for the given platform +// swapper. A nil swapper is valid and makes every method a no-op, so +// callers never need to branch on platform capability. +func NewDataSwapCoordinator( + cfg *config.Instance, st *state.State, swapper platforms.ProfileDataSwapper, +) *DataSwapCoordinator { + return &DataSwapCoordinator{ + cfg: cfg, + st: st, + swapper: swapper, + targetCh: make(chan swapRequest, 1), + } +} + +// Start subscribes to media lifecycle events and starts the apply worker. +func (c *DataSwapCoordinator) Start(broker Broker, notificationsSend chan<- models.Notification) { + if c.swapper == nil { + return + } + + c.mu.Lock() + c.notify = notificationsSend + c.quit = make(chan struct{}) + c.done = make(chan struct{}) + c.started = true + c.pending = nil + c.sequence = 0 + done := c.done + c.mu.Unlock() + + notifChan, subID := broker.Subscribe(16, models.NotificationStopped) + c.mu.Lock() + c.subID = subID + c.mu.Unlock() + + go func() { + defer close(done) + c.run(notifChan, broker) + }() +} + +// Stop shuts down the coordinator and waits for the worker to exit. +func (c *DataSwapCoordinator) Stop() { + c.mu.Lock() + if !c.started { + c.mu.Unlock() + return + } + c.started = false + quit := c.quit + done := c.done + c.mu.Unlock() + + close(quit) + <-done +} + +func (c *DataSwapCoordinator) run(notifChan <-chan models.Notification, broker Broker) { + defer broker.Unsubscribe(c.subID) + for { + select { + case <-c.quit: + return + case req := <-c.targetCh: + c.apply(&req) + case n, ok := <-notifChan: + if !ok { + return + } + if n.Method == models.NotificationStopped { + c.onMediaStopped() + } + } + } +} + +// RequestSwitch is called by the profiles service after the active profile +// changes. When no media is running it waits briefly for the swap so the +// common combo-card flow (switch then launch in one scan) launches with +// the new profile's data already mounted. While media runs, the swap is +// deferred until the media stops and the most recent target wins. +func (c *DataSwapCoordinator) RequestSwitch(ref platforms.ProfileRef) { + if c.swapper == nil { + return + } + ref = c.effectiveRef(ref) + + c.mu.Lock() + if !c.started { + c.mu.Unlock() + return + } + c.sequence++ + sequence := c.sequence + if c.st.ActiveMedia() != nil { + c.pending = &pendingSwap{ref: ref, sequence: sequence} + notify := c.notify + c.mu.Unlock() + log.Info().Str("profileId", ref.ID). + Msg("profiles: media running, data swap deferred until it stops") + notifications.ProfilesDataChanged(notify, models.ProfilesDataNotification{ + ProfileID: ref.ID, + Status: models.ProfilesDataDeferred, + }) + return + } + c.pending = nil + c.mu.Unlock() + + done := c.enqueueCurrent( + swapRequest{ref: ref, emitSuccess: true, done: make(chan struct{})}, sequence) + select { + case <-done: + case <-time.After(switchApplyTimeout): + log.Warn().Str("profileId", ref.ID). + Msg("profiles: data swap still running after switch, continuing in background") + } +} + +// Reconcile re-applies the current active profile's data state. Used at +// boot (after profile restore), when the swap_data setting changes, and by +// platform storage watchers when the mount table changes underneath us. +// Successful no-op reconciles are quiet; only errors notify. +func (c *DataSwapCoordinator) Reconcile() { + if c.swapper == nil { + return + } + + ref := platforms.ProfileRef{} + if active := c.st.ActiveProfile(); active != nil { + ref = platforms.ProfileRef{ID: active.ProfileID, Name: active.Name} + } + ref = c.effectiveRef(ref) + + c.mu.Lock() + if !c.started { + c.mu.Unlock() + return + } + c.sequence++ + sequence := c.sequence + if c.st.ActiveMedia() != nil { + c.pending = &pendingSwap{ref: ref, sequence: sequence} + c.mu.Unlock() + return + } + c.pending = nil + c.enqueue(swapRequest{ref: ref, emitSuccess: false, done: make(chan struct{})}) + c.mu.Unlock() +} + +func (c *DataSwapCoordinator) takePending() *pendingSwap { + c.mu.Lock() + defer c.mu.Unlock() + pending := c.pending + c.pending = nil + return pending +} + +func (c *DataSwapCoordinator) enqueuePending(pending *pendingSwap) { + if pending == nil { + return + } + // Re-resolve against config in case swap_data flipped while deferred. + ref := c.effectiveRef(pending.ref) + c.mu.Lock() + defer c.mu.Unlock() + if !c.started || pending.sequence != c.sequence { + return + } + c.enqueue(swapRequest{ref: ref, emitSuccess: true, done: make(chan struct{})}) +} + +func (c *DataSwapCoordinator) onMediaStopped() { + c.enqueuePending(c.takePending()) +} + +func (c *DataSwapCoordinator) enqueueCurrent(req swapRequest, sequence uint64) <-chan struct{} { + c.mu.Lock() + defer c.mu.Unlock() + if !c.started || sequence != c.sequence { + close(req.done) + return req.done + } + return c.enqueue(req) +} + +// effectiveRef maps the requested profile to the shared profile when data +// swapping is disabled, so turning the setting off converges all mounts +// back to the default state. +func (c *DataSwapCoordinator) effectiveRef(ref platforms.ProfileRef) platforms.ProfileRef { + if !c.cfg.ProfilesSwapData() { + return platforms.ProfileRef{} + } + return ref +} + +// enqueue hands a request to the worker, displacing any not-yet-started +// request so targets coalesce to the most recent. The displaced request's +// done channel is closed so nothing waits on superseded work. +func (c *DataSwapCoordinator) enqueue(req swapRequest) <-chan struct{} { + for { + select { + case c.targetCh <- req: + return req.done + default: + select { + case stale := <-c.targetCh: + close(stale.done) + default: + } + } + } +} + +func (c *DataSwapCoordinator) apply(req *swapRequest) { + defer close(req.done) + + items := make([]string, 0, 2) + for _, item := range c.swapper.ProfileItems() { + if item.Owner == platforms.ProfileItemOwnerProfile { + items = append(items, item.ID) + } + } + + err := c.swapper.ApplyProfile(req.ref, items) + + c.mu.Lock() + notify := c.notify + c.mu.Unlock() + + switch { + case err == nil: + if req.emitSuccess { + log.Info().Str("profileId", req.ref.ID).Msg("profiles: data swap applied") + notifications.ProfilesDataChanged(notify, models.ProfilesDataNotification{ + ProfileID: req.ref.ID, + Status: models.ProfilesDataApplied, + }) + } + case errors.Is(err, platforms.ErrProfileDataUnavailable): + log.Warn().Err(err).Str("profileId", req.ref.ID). + Msg("profiles: data swap unavailable") + notifications.ProfilesDataChanged(notify, models.ProfilesDataNotification{ + ProfileID: req.ref.ID, + Status: models.ProfilesDataUnavailable, + Reason: err.Error(), + }) + default: + log.Error().Err(err).Str("profileId", req.ref.ID). + Msg("profiles: data swap failed, existing data untouched") + notifications.ProfilesDataChanged(notify, models.ProfilesDataNotification{ + ProfileID: req.ref.ID, + Status: models.ProfilesDataFailed, + Reason: err.Error(), + }) + } +} diff --git a/pkg/service/profiles/dataswap_test.go b/pkg/service/profiles/dataswap_test.go new file mode 100644 index 000000000..ac5213090 --- /dev/null +++ b/pkg/service/profiles/dataswap_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 profiles + +import ( + "errors" + "fmt" + "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/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeSwapper records ApplyProfile calls. The device-owned item proves +// only profile-owned items are passed through. +type fakeSwapper struct { + err error + applies []platforms.ProfileRef + items [][]string + mu syncutil.Mutex +} + +func (*fakeSwapper) ProfileItems() []platforms.ProfileItem { + return []platforms.ProfileItem{ + {ID: "saves", Label: "Save files", Owner: platforms.ProfileItemOwnerProfile}, + {ID: "savestates", Label: "Save states", Owner: platforms.ProfileItemOwnerProfile}, + {ID: "display", Label: "Display settings", Owner: platforms.ProfileItemOwnerDevice}, + } +} + +func (f *fakeSwapper) ApplyProfile(ref platforms.ProfileRef, enabledItems []string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.applies = append(f.applies, ref) + f.items = append(f.items, enabledItems) + return f.err +} + +func (f *fakeSwapper) applyCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.applies) +} + +func (f *fakeSwapper) lastApply() (ref platforms.ProfileRef, items []string) { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.applies) == 0 { + return platforms.ProfileRef{}, nil + } + return f.applies[len(f.applies)-1], f.items[len(f.items)-1] +} + +// stubBroker returns a test-fed notification channel. +type stubBroker struct { + ch chan models.Notification + methods []string +} + +func (b *stubBroker) Subscribe(_ int, methods ...string) (notifChan <-chan models.Notification, id int) { + b.methods = methods + b.ch = make(chan models.Notification, 4) + return b.ch, 1 +} + +func (*stubBroker) Unsubscribe(int) {} + +type coordFixture struct { + coord *DataSwapCoordinator + st *state.State + broker *stubBroker + notifs chan models.Notification +} + +func newTestCoordinator(t *testing.T, swapper platforms.ProfileDataSwapper) coordFixture { + t.Helper() + st, ns := state.NewState(nil, "boot") + t.Cleanup(func() { + for { + select { + case <-ns: + default: + return + } + } + }) + + coord := NewDataSwapCoordinator(&config.Instance{}, st, swapper) + broker := &stubBroker{} + notifs := make(chan models.Notification, 16) + coord.Start(broker, notifs) + t.Cleanup(coord.Stop) + return coordFixture{coord: coord, st: st, broker: broker, notifs: notifs} +} + +func waitForNotification(t *testing.T, notifs <-chan models.Notification, method string) models.Notification { + t.Helper() + deadline := time.After(2 * time.Second) + for { + select { + case n := <-notifs: + if n.Method == method { + return n + } + case <-deadline: + t.Fatalf("timed out waiting for %s notification", method) + } + } +} + +func TestDataSwap_SwitchApplies(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + fix := newTestCoordinator(t, swapper) + + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"}) + + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + 2*time.Second, 5*time.Millisecond) + ref, items := swapper.lastApply() + assert.Equal(t, "profile-1", ref.ID) + assert.Equal(t, "Kid A", ref.Name) + // Only profile-owned items are applied; device-owned items never swap. + assert.Equal(t, []string{"saves", "savestates"}, items) + + n := waitForNotification(t, fix.notifs, models.NotificationProfilesData) + assert.Contains(t, string(n.Params), models.ProfilesDataApplied) +} + +func TestDataSwap_DeferredWhileMediaRunning(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + fix := newTestCoordinator(t, swapper) + + fix.st.SetActiveMedia(&models.ActiveMedia{SystemID: "SNES", Path: "game.sfc", Name: "Game"}) + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"}) + + // Nothing applies while media is running. + n := waitForNotification(t, fix.notifs, models.NotificationProfilesData) + assert.Contains(t, string(n.Params), models.ProfilesDataDeferred) + assert.Equal(t, 0, swapper.applyCount()) + + // Media stops: the deferred swap applies. + fix.st.SetActiveMedia(nil) + fix.broker.ch <- models.Notification{Method: models.NotificationStopped} + + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + 2*time.Second, 5*time.Millisecond) + ref, _ := swapper.lastApply() + assert.Equal(t, "profile-1", ref.ID) +} + +func TestDataSwap_DeferredSwitchesCoalesce(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + fix := newTestCoordinator(t, swapper) + + fix.st.SetActiveMedia(&models.ActiveMedia{SystemID: "SNES", Path: "game.sfc", Name: "Game"}) + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"}) + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-2", Name: "Kid B"}) + fix.coord.RequestSwitch(platforms.ProfileRef{}) + + fix.st.SetActiveMedia(nil) + fix.broker.ch <- models.Notification{Method: models.NotificationStopped} + + // Only the most recent target (shared) is applied. + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + 2*time.Second, 5*time.Millisecond) + ref, _ := swapper.lastApply() + assert.Empty(t, ref.ID) + + // A later media stop with nothing pending applies nothing. + fix.broker.ch <- models.Notification{Method: models.NotificationStopped} + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, swapper.applyCount()) +} + +func TestDataSwap_StaleDeferredTargetCannotDisplaceNewerSwitch(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + fix := newTestCoordinator(t, swapper) + + fix.st.SetActiveMedia(&models.ActiveMedia{SystemID: "SNES", Path: "game.sfc", Name: "Game"}) + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"}) + + // Simulate media.stopped extracting A immediately before a concurrent + // direct switch to B. Enqueueing extracted A afterwards must be rejected. + stale := fix.coord.takePending() + fix.st.SetActiveMedia(nil) + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-2", Name: "Kid B"}) + fix.coord.enqueuePending(stale) + + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + 2*time.Second, 5*time.Millisecond) + ref, _ := swapper.lastApply() + assert.Equal(t, "profile-2", ref.ID) + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, swapper.applyCount()) +} + +func TestDataSwap_DisabledConvergesToShared(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + st, ns := state.NewState(nil, "boot") + t.Cleanup(func() { + for { + select { + case <-ns: + default: + return + } + } + }) + cfg := &config.Instance{} + cfg.SetProfilesSwapData(false) + + coord := NewDataSwapCoordinator(cfg, st, swapper) + coord.Start(&stubBroker{}, make(chan models.Notification, 16)) + t.Cleanup(coord.Stop) + + coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"}) + + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + 2*time.Second, 5*time.Millisecond) + ref, _ := swapper.lastApply() + assert.Empty(t, ref.ID, "swap_data off maps every target to the shared profile") +} + +func TestDataSwap_FailureAndUnavailableNotify(t *testing.T) { + t.Parallel() + + swapper := &fakeSwapper{err: errors.New("mount failed")} + fix := newTestCoordinator(t, swapper) + fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1"}) + n := waitForNotification(t, fix.notifs, models.NotificationProfilesData) + assert.Contains(t, string(n.Params), models.ProfilesDataFailed) + + swapper2 := &fakeSwapper{ + err: fmt.Errorf("saves are network-mounted: %w", platforms.ErrProfileDataUnavailable), + } + fix2 := newTestCoordinator(t, swapper2) + fix2.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1"}) + n2 := waitForNotification(t, fix2.notifs, models.NotificationProfilesData) + assert.Contains(t, string(n2.Params), models.ProfilesDataUnavailable) +} + +func TestDataSwap_ReconcileAppliesActiveProfileQuietly(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + fix := newTestCoordinator(t, swapper) + + fix.st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A"}) + fix.coord.Reconcile() + + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + 2*time.Second, 5*time.Millisecond) + ref, _ := swapper.lastApply() + assert.Equal(t, "profile-1", ref.ID) + + // Quiet on success: no profiles.data notification for reconciles. + select { + case n := <-fix.notifs: + if n.Method == models.NotificationProfilesData { + t.Fatal("unexpected profiles.data notification for quiet reconcile") + } + case <-time.After(50 * time.Millisecond): + } +} + +func TestDataSwap_NilSwapperInert(t *testing.T) { + t.Parallel() + st, ns := state.NewState(nil, "boot") + t.Cleanup(func() { + for { + select { + case <-ns: + default: + return + } + } + }) + + coord := NewDataSwapCoordinator(&config.Instance{}, st, nil) + coord.Start(&stubBroker{}, nil) + coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1"}) + coord.Reconcile() + coord.Stop() +} + +func TestDataSwap_SubscribesToMediaStopped(t *testing.T) { + t.Parallel() + swapper := &fakeSwapper{} + fix := newTestCoordinator(t, swapper) + + // The filter must include media.stopped or deferred swaps never apply. + assert.Contains(t, fix.broker.methods, models.NotificationStopped) +} diff --git a/pkg/service/profiles/pin.go b/pkg/service/profiles/pin.go new file mode 100644 index 000000000..c53530c9c --- /dev/null +++ b/pkg/service/profiles/pin.go @@ -0,0 +1,115 @@ +// 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 profiles + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" +) + +// Profile PINs are short convenience barriers, not credentials. They are +// still stored only as PBKDF2-SHA256 hashes, with brute force limited by +// the in-memory rate limiter in Service. The iteration count is modest by +// design: a 4-8 digit keyspace falls instantly to offline cracking at any +// feasible count (the online rate limiter is the real defense), and each +// verify blocks an API handler on ARM-class device CPU. The count is +// encoded in the hash, so raising it later costs nothing. +const ( + pinMinLen = 4 + pinMaxLen = 8 + pinIterations = 50_000 + pinSaltLen = 16 + pinKeyLen = 32 + pinHashPrefix = "pbkdf2-sha256" +) + +// ErrInvalidPINFormat is returned when a PIN is not 4-8 digits. +var ErrInvalidPINFormat = errors.New("PIN must be 4 to 8 digits") + +// HashPIN validates and hashes a PIN for storage. The encoded form is +// "pbkdf2-sha256$$$". +func HashPIN(pin string) (string, error) { + if err := validatePIN(pin); err != nil { + return "", err + } + + salt := make([]byte, pinSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate PIN salt: %w", err) + } + + key, err := pbkdf2.Key(sha256.New, pin, salt, pinIterations, pinKeyLen) + if err != nil { + return "", fmt.Errorf("failed to derive PIN hash: %w", err) + } + + encoded := pinHashPrefix + "$" + + strconv.Itoa(pinIterations) + "$" + + base64.RawStdEncoding.EncodeToString(salt) + "$" + + base64.RawStdEncoding.EncodeToString(key) + return encoded, nil +} + +// VerifyPIN reports whether pin matches the encoded hash. Malformed hashes +// verify as false. +func VerifyPIN(pin, encoded string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[0] != pinHashPrefix { + return false + } + + iterations, err := strconv.Atoi(parts[1]) + if err != nil || iterations <= 0 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + + key, err := pbkdf2.Key(sha256.New, pin, salt, iterations, len(expected)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(key, expected) == 1 +} + +func validatePIN(pin string) error { + if len(pin) < pinMinLen || len(pin) > pinMaxLen { + return ErrInvalidPINFormat + } + for _, r := range pin { + if r < '0' || r > '9' { + return ErrInvalidPINFormat + } + } + return nil +} diff --git a/pkg/service/profiles/pin_test.go b/pkg/service/profiles/pin_test.go new file mode 100644 index 000000000..2d499b334 --- /dev/null +++ b/pkg/service/profiles/pin_test.go @@ -0,0 +1,70 @@ +// 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 profiles + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHashPIN_RoundTrip(t *testing.T) { + t.Parallel() + + hash, err := HashPIN("1234") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(hash, "pbkdf2-sha256$")) + assert.True(t, VerifyPIN("1234", hash)) + assert.False(t, VerifyPIN("4321", hash)) + assert.False(t, VerifyPIN("", hash)) +} + +func TestHashPIN_SaltsDiffer(t *testing.T) { + t.Parallel() + + hash1, err := HashPIN("12345678") + require.NoError(t, err) + hash2, err := HashPIN("12345678") + require.NoError(t, err) + assert.NotEqual(t, hash1, hash2) + assert.True(t, VerifyPIN("12345678", hash1)) + assert.True(t, VerifyPIN("12345678", hash2)) +} + +func TestHashPIN_RejectsInvalidFormats(t *testing.T) { + t.Parallel() + + for _, pin := range []string{"", "123", "123456789", "12a4", "12.4", "abcd"} { + _, err := HashPIN(pin) + require.ErrorIs(t, err, ErrInvalidPINFormat, "pin %q", pin) + } +} + +func TestVerifyPIN_MalformedHash(t *testing.T) { + t.Parallel() + + assert.False(t, VerifyPIN("1234", "")) + assert.False(t, VerifyPIN("1234", "not-a-hash")) + assert.False(t, VerifyPIN("1234", "pbkdf2-sha256$abc$def$ghi")) + assert.False(t, VerifyPIN("1234", "pbkdf2-sha256$0$AAAA$AAAA")) + assert.False(t, VerifyPIN("1234", "other-scheme$600000$AAAA$AAAA")) +} diff --git a/pkg/service/profiles/profiles.go b/pkg/service/profiles/profiles.go new file mode 100644 index 000000000..a74d33a31 --- /dev/null +++ b/pkg/service/profiles/profiles.go @@ -0,0 +1,556 @@ +// 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 profiles implements device profiles: named buckets of preferences +// and limits. One profile is active per device at a time; the un-profiled +// state is the implicit "shared profile" — the device as it behaves when +// nobody is signed in, with global-config limits and unattributed history. +// +// A profile's switch ID is a bearer credential: presenting it (by scanning +// the card it is written on, or by knowing its value) authorizes switching +// to that profile with no PIN on every path. Switch IDs are therefore only +// exposed over the API to privileged clients. The optional per-profile PIN +// protects the remaining path: switching by profile ID picked from the +// visible profile list. Leaving a profile is always free — PINs gate entry +// only. +package profiles + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/userdb" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +const ( + ProfileRoleAdmin = "admin" + ProfileRoleMember = "member" + + // pinAttemptWindow and pinAttemptLimit bound PIN guesses per profile: + // at most pinAttemptLimit failures within pinAttemptWindow. + pinAttemptWindow = time.Minute + pinAttemptLimit = 5 + + // switchIDRetries bounds regeneration attempts on the (vanishingly + // unlikely) unique-constraint collision of a generated switch ID. + switchIDRetries = 5 +) + +var ( + // ErrPINRequired is returned when switching to a PIN-protected profile + // without supplying a PIN. + ErrPINRequired = errors.New("profile requires a PIN") + // ErrPINIncorrect is returned when the supplied PIN does not match. + ErrPINIncorrect = errors.New("incorrect PIN") + // ErrPINRateLimited is returned when too many failed PIN attempts have + // been made against a profile. + ErrPINRateLimited = errors.New("too many PIN attempts, try again later") + // ErrNotFound is returned when a profile does not exist. + ErrNotFound = userdb.ErrProfileNotFound + // ErrAdminPINRequired is returned when an administrator profile would + // have no PIN protecting management authorization. + ErrAdminPINRequired = errors.New("admin profiles require a PIN") + // ErrLastAdmin is returned when deleting or demoting the final admin. + ErrLastAdmin = userdb.ErrLastProfileAdmin + // ErrInvalidRole is returned for unknown profile roles. + ErrInvalidRole = errors.New("invalid profile role") +) + +// Service owns the device's profile lifecycle: CRUD, the active-profile +// state, and PIN-checked switching. All activation paths (API, ZapScript +// card scans, boot restore) go through here. +type Service struct { + db *database.Database + st *state.State + dataSwap *DataSwapCoordinator + now func() time.Time + pinAttempts map[string][]time.Time + mu syncutil.Mutex + // manageMu serializes CRUD/bootstrap decisions around profile roles. + manageMu syncutil.Mutex + // activateMu serializes activate/deactivate so the persisted device + // state and the in-memory snapshot cannot diverge under concurrency. + activateMu syncutil.Mutex +} + +// NewService creates a profiles service backed by the user database and +// service state. +func NewService(db *database.Database, st *state.State) *Service { + return &Service{ + db: db, + st: st, + now: time.Now, + pinAttempts: make(map[string][]time.Time), + } +} + +// SetDataSwap attaches the data swap coordinator. Optional: without it, +// profile switches change limits and attribution only. +func (s *Service) SetDataSwap(c *DataSwapCoordinator) { + s.dataSwap = c +} + +// ReconcileData re-applies the active profile's data state, e.g. after the +// swap_data setting changes. No-op when data swapping is not wired. +func (s *Service) ReconcileData() { + if s.dataSwap != nil { + s.dataSwap.Reconcile() + } +} + +// List returns all profiles. +func (s *Service) List() ([]database.Profile, error) { + profiles, err := s.db.UserDB.ListProfiles() + if err != nil { + return nil, fmt.Errorf("failed to list profiles: %w", err) + } + return profiles, nil +} + +// Get returns a profile by its profile ID. +func (s *Service) Get(profileID string) (*database.Profile, error) { + p, err := s.db.UserDB.GetProfile(profileID) + if err != nil { + return nil, fmt.Errorf("failed to get profile: %w", err) + } + return p, nil +} + +// Create creates a new profile with a generated profile ID and switch ID, +// hashing the PIN if one is given. The first profile is always an explicit +// administrator and therefore must have a PIN; later profiles default member. +func (s *Service) Create(params *models.NewProfileParams) (*database.Profile, error) { + s.manageMu.Lock() + defer s.manageMu.Unlock() + + if err := validateLimitDurations(params.DailyLimit, params.SessionLimit); err != nil { + return nil, err + } + + list, err := s.List() + if err != nil { + return nil, err + } + role := params.Role + if len(list) == 0 { + role = ProfileRoleAdmin + } else if role == "" { + role = ProfileRoleMember + } + if role != ProfileRoleAdmin && role != ProfileRoleMember { + return nil, ErrInvalidRole + } + if role == ProfileRoleAdmin && (params.PIN == nil || *params.PIN == "") { + return nil, ErrAdminPINRequired + } + + p := &database.Profile{ + ProfileID: uuid.New().String(), + Name: params.Name, + Role: role, + LimitsEnabled: params.LimitsEnabled, + DailyLimit: params.DailyLimit, + SessionLimit: params.SessionLimit, + CreatedAt: s.now().Unix(), + UpdatedAt: s.now().Unix(), + } + + if params.PIN != nil && *params.PIN != "" { + hash, err := HashPIN(*params.PIN) + if err != nil { + return nil, err + } + p.PINHash = hash + } + + if err := s.insertWithSwitchID(p); err != nil { + return nil, err + } + + return p, nil +} + +// Update applies an update to a profile. If the profile is currently +// active, the in-memory snapshot is refreshed so changed limits apply +// immediately. +func (s *Service) Update(params *models.UpdateProfileParams) (*database.Profile, error) { + s.manageMu.Lock() + defer s.manageMu.Unlock() + + if err := validateLimitDurations(params.DailyLimit, params.SessionLimit); err != nil { + return nil, err + } + + p, err := s.db.UserDB.GetProfile(params.ProfileID) + if err != nil { + return nil, fmt.Errorf("failed to get profile: %w", err) + } + + if params.Name != nil { + p.Name = *params.Name + } + if params.Role != nil { + if *params.Role != ProfileRoleAdmin && *params.Role != ProfileRoleMember { + return nil, ErrInvalidRole + } + p.Role = *params.Role + } + switch { + case params.ClearPIN: + p.PINHash = "" + case params.PIN != nil && *params.PIN != "": + hash, hashErr := HashPIN(*params.PIN) + if hashErr != nil { + return nil, hashErr + } + p.PINHash = hash + } + if p.Role == ProfileRoleAdmin && p.PINHash == "" { + return nil, ErrAdminPINRequired + } + // ClearLimits resets all overrides back to inherit, then any limit + // fields in the same request are applied on top. This lets a form + // submit its full desired state in one call: clear-then-set. + if params.ClearLimits { + p.LimitsEnabled = nil + p.DailyLimit = nil + p.SessionLimit = nil + } + if params.LimitsEnabled != nil { + p.LimitsEnabled = params.LimitsEnabled + } + if params.DailyLimit != nil { + p.DailyLimit = params.DailyLimit + } + if params.SessionLimit != nil { + p.SessionLimit = params.SessionLimit + } + p.UpdatedAt = s.now().Unix() + + if params.RegenerateSwitchID { + if regenErr := s.updateWithNewSwitchID(p); regenErr != nil { + return nil, regenErr + } + } else if updateErr := s.db.UserDB.UpdateProfile(p); updateErr != nil { + return nil, fmt.Errorf("failed to update profile: %w", updateErr) + } + + // Refresh the active snapshot if this profile is active so limit + // changes take effect without a re-switch. + s.activateMu.Lock() + if active := s.st.ActiveProfile(); active != nil && active.ProfileID == p.ProfileID { + s.st.SetActiveProfile(snapshot(p)) + } + s.activateMu.Unlock() + return p, nil +} + +// Delete removes a profile. If it is the active profile, the device +// deactivates (the persisted active state is cleared transactionally by +// the database layer). +func (s *Service) Delete(profileID string) error { + s.manageMu.Lock() + defer s.manageMu.Unlock() + s.activateMu.Lock() + defer s.activateMu.Unlock() + + wasActive := false + if active := s.st.ActiveProfile(); active != nil { + wasActive = active.ProfileID == profileID + } + if err := s.db.UserDB.DeleteProfile(profileID); err != nil { + return fmt.Errorf("failed to delete profile: %w", err) + } + + if wasActive { + s.st.SetActiveProfile(nil) + if s.dataSwap != nil { + s.dataSwap.RequestSwitch(platforms.ProfileRef{}) + } + } + return nil +} + +// ActivateByID switches the device to a profile, enforcing its PIN if one +// is set. This is the API path; card scans use ActivateBySwitchID. +func (s *Service) ActivateByID(profileID, pin string) (*models.ActiveProfile, error) { + p, err := s.db.UserDB.GetProfile(profileID) + if err != nil { + return nil, fmt.Errorf("failed to get profile: %w", err) + } + if err := s.checkPIN(p, pin); err != nil { + return nil, err + } + return s.activate(p) +} + +// ActivateBySwitchID switches to a profile selected by switch ID without a +// PIN check. Switch IDs are bearer credentials: possessing the card or +// knowing its content is the authorization, on every path (scan, run API, +// profiles.switch). They are only readable via the API by privileged +// clients. +func (s *Service) ActivateBySwitchID(switchID string) (*models.ActiveProfile, error) { + p, err := s.db.UserDB.GetProfileBySwitchID(switchID) + if err != nil { + return nil, fmt.Errorf("failed to get profile by switch ID: %w", err) + } + return s.activate(p) +} + +// VerifyByID checks a profile's PIN without switching to it. It shares the +// PIN rate limiter with activation, so it cannot be used to brute-force a +// PIN any faster than switching attempts could. Clients use this to gate +// their own ad-hoc UI items behind a profile credential; success grants +// nothing server-side. +func (s *Service) VerifyByID(profileID, pin string) (*database.Profile, error) { + p, err := s.db.UserDB.GetProfile(profileID) + if err != nil { + return nil, fmt.Errorf("failed to get profile: %w", err) + } + if err := s.checkPIN(p, pin); err != nil { + return nil, err + } + return p, nil +} + +// VerifyBySwitchID resolves a switch ID without switching. The switch ID +// is a bearer credential, so resolving it IS the verification — no PIN. +// Success grants nothing server-side. +func (s *Service) VerifyBySwitchID(switchID string) (*database.Profile, error) { + p, err := s.db.UserDB.GetProfileBySwitchID(switchID) + if err != nil { + return nil, fmt.Errorf("failed to get profile by switch ID: %w", err) + } + return p, nil +} + +// Deactivate clears the active profile. Leaving a profile is always free +// (PINs gate entry only); restricting what a profile-less device can do is +// handled by the require-profile launch setting. +func (s *Service) Deactivate() error { + s.activateMu.Lock() + defer s.activateMu.Unlock() + + if err := s.db.UserDB.DeleteDeviceState(database.DeviceStateKeyActiveProfile); err != nil { + return fmt.Errorf("failed to clear active profile state: %w", err) + } + s.st.SetActiveProfile(nil) + if s.dataSwap != nil { + s.dataSwap.RequestSwitch(platforms.ProfileRef{}) + } + return nil +} + +// Active returns the active profile snapshot, or nil when none is active. +func (s *Service) Active() *models.ActiveProfile { + return s.st.ActiveProfile() +} + +// RestoreOnBoot restores the persisted active profile into service state. +// A dangling reference to a deleted profile is cleaned up silently. +func (s *Service) RestoreOnBoot() error { + profileID, found, err := s.db.UserDB.GetDeviceState(database.DeviceStateKeyActiveProfile) + if err != nil { + return fmt.Errorf("failed to read active profile state: %w", err) + } + if !found { + return nil + } + + p, err := s.db.UserDB.GetProfile(profileID) + if err != nil { + if errors.Is(err, ErrNotFound) { + log.Warn().Str("profileId", profileID). + Msg("persisted active profile no longer exists, clearing") + if delErr := s.db.UserDB.DeleteDeviceState(database.DeviceStateKeyActiveProfile); delErr != nil { + return fmt.Errorf("failed to clear dangling active profile state: %w", delErr) + } + return nil + } + return fmt.Errorf("failed to get persisted active profile: %w", err) + } + + s.st.SetActiveProfile(snapshot(p)) + log.Info().Str("profileId", p.ProfileID).Str("name", p.Name). + Msg("restored active profile") + return nil +} + +func (s *Service) activate(p *database.Profile) (*models.ActiveProfile, error) { + // Serialize activations so two concurrent switches cannot interleave + // the persisted device state with the in-memory snapshot. + s.activateMu.Lock() + defer s.activateMu.Unlock() + + lastUsedAt := s.now().Unix() + if err := s.db.UserDB.ActivateProfile(p.ProfileID, lastUsedAt); err != nil { + return nil, fmt.Errorf("failed to persist active profile: %w", err) + } + p.LastUsedAt = &lastUsedAt + snap := snapshot(p) + s.st.SetActiveProfile(snap) + log.Info().Str("profileId", p.ProfileID).Str("name", p.Name). + Msg("switched active profile") + if s.dataSwap != nil { + s.dataSwap.RequestSwitch(platforms.ProfileRef{ID: p.ProfileID, Name: p.Name}) + } + return snap, nil +} + +// checkPIN enforces a profile's PIN with per-profile rate limiting. A +// profile without a PIN always passes. The expensive VerifyPIN runs +// outside the mutex; attempt accounting re-reads state under a single lock +// acquisition afterwards so concurrent failures cannot lose records. +func (s *Service) checkPIN(p *database.Profile, pin string) error { + if p.PINHash == "" { + return nil + } + if pin == "" { + return ErrPINRequired + } + + s.mu.Lock() + now := s.now() + recent := recentAttempts(s.pinAttempts[p.ProfileID], now) + s.pinAttempts[p.ProfileID] = recent + if len(recent) >= pinAttemptLimit { + s.mu.Unlock() + return ErrPINRateLimited + } + s.mu.Unlock() + + if !VerifyPIN(pin, p.PINHash) { + s.mu.Lock() + now = s.now() + s.pinAttempts[p.ProfileID] = append(recentAttempts(s.pinAttempts[p.ProfileID], now), now) + s.mu.Unlock() + return ErrPINIncorrect + } + + s.mu.Lock() + delete(s.pinAttempts, p.ProfileID) + s.mu.Unlock() + return nil +} + +// recentAttempts filters attempt timestamps down to those still inside the +// rate-limit window, returning a fresh slice so callers never alias the +// old backing array. +func recentAttempts(attempts []time.Time, now time.Time) []time.Time { + recent := make([]time.Time, 0, len(attempts)) + for _, at := range attempts { + if now.Sub(at) < pinAttemptWindow { + recent = append(recent, at) + } + } + return recent +} + +// insertWithSwitchID inserts a profile, generating a fresh switch ID and +// retrying on the unlikely unique-constraint collision. +func (s *Service) insertWithSwitchID(p *database.Profile) error { + for range switchIDRetries { + switchID, err := GenerateSwitchID() + if err != nil { + return err + } + p.SwitchID = switchID + err = s.db.UserDB.CreateProfile(p) + if err == nil { + return nil + } + if !isSwitchIDConflict(err) { + return fmt.Errorf("failed to create profile: %w", err) + } + } + return errors.New("failed to generate a unique switch ID") +} + +// updateWithNewSwitchID updates a profile with a regenerated switch ID, +// retrying on collision. +func (s *Service) updateWithNewSwitchID(p *database.Profile) error { + for range switchIDRetries { + switchID, err := GenerateSwitchID() + if err != nil { + return err + } + p.SwitchID = switchID + err = s.db.UserDB.UpdateProfile(p) + if err == nil { + return nil + } + if !isSwitchIDConflict(err) { + return fmt.Errorf("failed to update profile: %w", err) + } + } + return errors.New("failed to generate a unique switch ID") +} + +// isSwitchIDConflict detects a unique-constraint violation on SwitchID. +// SQLite reports these as "UNIQUE constraint failed: Profiles.SwitchID". +func isSwitchIDConflict(err error) bool { + return err != nil && + strings.Contains(err.Error(), "UNIQUE") && + strings.Contains(err.Error(), "SwitchID") +} + +// snapshot builds the in-memory active-profile snapshot from a profile +// row. It carries resolved limit overrides so the playtime hot path never +// touches the database. +func snapshot(p *database.Profile) *models.ActiveProfile { + return &models.ActiveProfile{ + ProfileID: p.ProfileID, + Name: p.Name, + Role: p.Role, + HasPIN: p.PINHash != "", + LimitsEnabled: p.LimitsEnabled, + DailyLimit: p.DailyLimit, + SessionLimit: p.SessionLimit, + } +} + +// validateLimitDurations rejects unparseable and negative limit duration +// strings. An empty string is allowed (it means "clear to inherit" on +// update); "0" means explicitly unlimited. Negative values are rejected +// rather than silently behaving as "no limit", which would invert the +// intent of whoever typed them. +func validateLimitDurations(durations ...*string) error { + for _, d := range durations { + if d == nil || *d == "" { + continue + } + parsed, err := time.ParseDuration(*d) + if err != nil { + return fmt.Errorf("invalid limit duration %q: %w", *d, err) + } + if parsed < 0 { + return fmt.Errorf("invalid limit duration %q: must not be negative", *d) + } + } + return nil +} diff --git a/pkg/service/profiles/profiles_test.go b/pkg/service/profiles/profiles_test.go new file mode 100644 index 000000000..c0c336349 --- /dev/null +++ b/pkg/service/profiles/profiles_test.go @@ -0,0 +1,370 @@ +// 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 profiles + +import ( + "strings" + "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/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/userdb" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func newTestService(t *testing.T) (svc *Service, mockDB *helpers.MockUserDBI, st *state.State) { + t.Helper() + mockDB = helpers.NewMockUserDBI() + st, ns := state.NewState(nil, "boot") + t.Cleanup(func() { + for { + select { + case <-ns: + default: + return + } + } + }) + svc = NewService(&database.Database{UserDB: mockDB, MediaDB: nil}, st) + return svc, mockDB, st +} + +func pinProfile(t *testing.T, pin string) *database.Profile { + t.Helper() + p := &database.Profile{ + ProfileID: "profile-1", + Name: "Kid A", + SwitchID: "corn-arm-truck", + } + if pin != "" { + hash, err := HashPIN(pin) + require.NoError(t, err) + p.PINHash = hash + } + return p +} + +func TestActivateByID_NoPIN(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + usedAt := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC) + svc.now = func() time.Time { return usedAt } + + mockDB.On("GetProfile", "profile-1").Return(pinProfile(t, ""), nil) + mockDB.On("ActivateProfile", "profile-1", usedAt.Unix()).Return(nil) + + snap, err := svc.ActivateByID("profile-1", "") + require.NoError(t, err) + assert.Equal(t, "Kid A", snap.Name) + assert.False(t, snap.HasPIN) + + active := st.ActiveProfile() + require.NotNil(t, active) + assert.Equal(t, "profile-1", active.ProfileID) + mockDB.AssertExpectations(t) +} + +func TestActivateByID_PINEnforced(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + mockDB.On("GetProfile", "profile-1").Return(pinProfile(t, "1234"), nil) + + _, err := svc.ActivateByID("profile-1", "") + require.ErrorIs(t, err, ErrPINRequired) + + _, err = svc.ActivateByID("profile-1", "9999") + require.ErrorIs(t, err, ErrPINIncorrect) + + assert.Nil(t, st.ActiveProfile()) + + mockDB.On("ActivateProfile", "profile-1", mock.AnythingOfType("int64")).Return(nil) + snap, err := svc.ActivateByID("profile-1", "1234") + require.NoError(t, err) + assert.True(t, snap.HasPIN) + require.NotNil(t, st.ActiveProfile()) +} + +func TestActivateByID_RateLimited(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return now } + + mockDB.On("GetProfile", "profile-1").Return(pinProfile(t, "1234"), nil) + + for range pinAttemptLimit { + _, err := svc.ActivateByID("profile-1", "9999") + require.ErrorIs(t, err, ErrPINIncorrect) + } + + // Even the correct PIN is rejected while rate limited. + _, err := svc.ActivateByID("profile-1", "1234") + require.ErrorIs(t, err, ErrPINRateLimited) + + // After the window passes, attempts work again. + now = now.Add(pinAttemptWindow + time.Second) + mockDB.On("ActivateProfile", "profile-1", now.Unix()).Return(nil) + _, err = svc.ActivateByID("profile-1", "1234") + require.NoError(t, err) +} + +func TestActivateBySwitchID_BypassesPIN(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + // The switch ID is a bearer credential: presenting it activates a + // PIN-protected profile with no PIN, on every path. + mockDB.On("GetProfileBySwitchID", "corn-arm-truck").Return(pinProfile(t, "1234"), nil) + mockDB.On("ActivateProfile", "profile-1", mock.AnythingOfType("int64")).Return(nil) + + snap, err := svc.ActivateBySwitchID("corn-arm-truck") + require.NoError(t, err) + assert.Equal(t, "profile-1", snap.ProfileID) + require.NotNil(t, st.ActiveProfile()) +} + +func TestDeactivate(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A"}) + mockDB.On("DeleteDeviceState", database.DeviceStateKeyActiveProfile).Return(nil) + + require.NoError(t, svc.Deactivate()) + assert.Nil(t, st.ActiveProfile()) +} + +func TestRestoreOnBoot_Restores(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + mockDB.On("GetDeviceState", database.DeviceStateKeyActiveProfile).Return("profile-1", true, nil) + mockDB.On("GetProfile", "profile-1").Return(pinProfile(t, ""), nil) + + require.NoError(t, svc.RestoreOnBoot()) + active := st.ActiveProfile() + require.NotNil(t, active) + assert.Equal(t, "profile-1", active.ProfileID) +} + +func TestRestoreOnBoot_NothingPersisted(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + mockDB.On("GetDeviceState", database.DeviceStateKeyActiveProfile).Return("", false, nil) + + require.NoError(t, svc.RestoreOnBoot()) + assert.Nil(t, st.ActiveProfile()) +} + +func TestRestoreOnBoot_DanglingCleared(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + mockDB.On("GetDeviceState", database.DeviceStateKeyActiveProfile).Return("deleted-profile", true, nil) + mockDB.On("GetProfile", "deleted-profile").Return(nil, userdb.ErrProfileNotFound) + mockDB.On("DeleteDeviceState", database.DeviceStateKeyActiveProfile).Return(nil) + + require.NoError(t, svc.RestoreOnBoot()) + assert.Nil(t, st.ActiveProfile()) + mockDB.AssertExpectations(t) +} + +func TestCreate_GeneratesSwitchIDAndHashesPIN(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + + pin := "1234" + mockDB.On("ListProfiles").Return([]database.Profile{}, nil) + mockDB.On("CreateProfile", mock.MatchedBy(func(p *database.Profile) bool { + return p.ProfileID != "" && + len(strings.Split(p.SwitchID, "-")) == switchIDWords && + strings.HasPrefix(p.PINHash, "pbkdf2-sha256$") + })).Return(nil) + + p, err := svc.Create(&models.NewProfileParams{Name: "Kid A", PIN: &pin}) + require.NoError(t, err) + assert.Equal(t, "Kid A", p.Name) + assert.True(t, VerifyPIN("1234", p.PINHash)) + mockDB.AssertExpectations(t) +} + +func TestCreate_FirstProfileIsAdminAndRequiresPIN(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + mockDB.On("ListProfiles").Return([]database.Profile{}, nil) + + _, err := svc.Create(&models.NewProfileParams{Name: "Parent"}) + require.ErrorIs(t, err, ErrAdminPINRequired) + mockDB.AssertNotCalled(t, "CreateProfile", mock.Anything) +} + +func TestCreate_SubsequentProfileDefaultsMember(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + admin := *pinProfile(t, "1234") + admin.Role = ProfileRoleAdmin + mockDB.On("ListProfiles").Return([]database.Profile{admin}, nil) + mockDB.On("CreateProfile", mock.MatchedBy(func(p *database.Profile) bool { + return p.Role == ProfileRoleMember + })).Return(nil) + + created, err := svc.Create(&models.NewProfileParams{Name: "Kid"}) + require.NoError(t, err) + assert.Equal(t, ProfileRoleMember, created.Role) + mockDB.AssertExpectations(t) +} + +func TestUpdate_AdminCannotClearPIN(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + admin := pinProfile(t, "1234") + admin.Role = ProfileRoleAdmin + mockDB.On("GetProfile", admin.ProfileID).Return(admin, nil) + + _, err := svc.Update(&models.UpdateProfileParams{ProfileID: admin.ProfileID, ClearPIN: true}) + require.ErrorIs(t, err, ErrAdminPINRequired) + mockDB.AssertNotCalled(t, "UpdateProfile", mock.Anything) +} + +func TestCreate_RejectsBadDuration(t *testing.T) { + t.Parallel() + svc, _, _ := newTestService(t) + + bad := "2 hours" + _, err := svc.Create(&models.NewProfileParams{Name: "Kid A", DailyLimit: &bad}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid limit duration") + + // Negative durations would silently behave as "no limit" downstream — + // reject them at validation instead. + negative := "-5m" + _, err = svc.Create(&models.NewProfileParams{Name: "Kid A", SessionLimit: &negative}) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be negative") +} + +func TestUpdate_RefreshesActiveSnapshot(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + + existing := pinProfile(t, "") + st.SetActiveProfile(&models.ActiveProfile{ProfileID: existing.ProfileID, Name: existing.Name}) + + mockDB.On("GetProfile", "profile-1").Return(existing, nil) + mockDB.On("UpdateProfile", mock.Anything).Return(nil) + + daily := "2h" + enabled := true + _, err := svc.Update(&models.UpdateProfileParams{ + ProfileID: "profile-1", + DailyLimit: &daily, + LimitsEnabled: &enabled, + }) + require.NoError(t, err) + + active := st.ActiveProfile() + require.NotNil(t, active) + require.NotNil(t, active.DailyLimit) + assert.Equal(t, "2h", *active.DailyLimit) + require.NotNil(t, active.LimitsEnabled) + assert.True(t, *active.LimitsEnabled) +} + +func TestUpdate_ClearLimits(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + + existing := pinProfile(t, "") + enabled := true + daily := "1h" + existing.LimitsEnabled = &enabled + existing.DailyLimit = &daily + + mockDB.On("GetProfile", "profile-1").Return(existing, nil) + mockDB.On("UpdateProfile", mock.MatchedBy(func(p *database.Profile) bool { + return p.LimitsEnabled == nil && p.DailyLimit == nil && p.SessionLimit == nil + })).Return(nil) + + _, err := svc.Update(&models.UpdateProfileParams{ProfileID: "profile-1", ClearLimits: true}) + require.NoError(t, err) + mockDB.AssertExpectations(t) +} + +// TestUpdate_ClearLimitsThenSet pins the clear-then-set contract: a form +// can submit its full desired limit state in one call by combining +// ClearLimits with the fields that should remain set. +func TestUpdate_ClearLimitsThenSet(t *testing.T) { + t.Parallel() + svc, mockDB, _ := newTestService(t) + + existing := pinProfile(t, "") + enabled := true + daily := "1h" + session := "30m" + existing.LimitsEnabled = &enabled + existing.DailyLimit = &daily + existing.SessionLimit = &session + + newDaily := "2h" + mockDB.On("GetProfile", "profile-1").Return(existing, nil) + mockDB.On("UpdateProfile", mock.MatchedBy(func(p *database.Profile) bool { + // DailyLimit is re-set after the clear; the other overrides are + // back to inherit. + return p.DailyLimit != nil && *p.DailyLimit == newDaily && + p.LimitsEnabled == nil && p.SessionLimit == nil + })).Return(nil) + + _, err := svc.Update(&models.UpdateProfileParams{ + ProfileID: "profile-1", + ClearLimits: true, + DailyLimit: &newDaily, + }) + require.NoError(t, err) + mockDB.AssertExpectations(t) +} + +func TestDelete_ActiveProfileDeactivates(t *testing.T) { + t.Parallel() + svc, mockDB, st := newTestService(t) + swapper := &fakeSwapper{} + coord := NewDataSwapCoordinator(&config.Instance{}, st, swapper) + coord.Start(&stubBroker{}, make(chan models.Notification, 4)) + t.Cleanup(coord.Stop) + svc.dataSwap = coord + + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A"}) + mockDB.On("DeleteProfile", "profile-1").Return(nil) + + require.NoError(t, svc.Delete("profile-1")) + assert.Nil(t, st.ActiveProfile()) + require.Eventually(t, func() bool { return swapper.applyCount() == 1 }, + time.Second, 5*time.Millisecond) + ref, _ := swapper.lastApply() + assert.Empty(t, ref.ID) +} diff --git a/pkg/service/profiles/resolver.go b/pkg/service/profiles/resolver.go new file mode 100644 index 000000000..794e8e49d --- /dev/null +++ b/pkg/service/profiles/resolver.go @@ -0,0 +1,103 @@ +// 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 profiles + +import ( + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" +) + +// LimitsResolver layers the active profile's playtime limit overrides over +// the global config. It satisfies the playtime.LimitsProvider interface. +// Reads come from the in-memory active-profile snapshot, never the +// database, so it is safe on the limit-check hot path. +// +// An explicit (non-nil) profile field wins; a nil field inherits the +// global config value. A "0" duration string means explicitly unlimited. +// Warning intervals are device UX, not per-person policy, and always come +// from global config. +type LimitsResolver struct { + cfg *config.Instance + st *state.State +} + +// NewLimitsResolver creates a resolver over the global config and the +// service state holding the active profile. +func NewLimitsResolver(cfg *config.Instance, st *state.State) *LimitsResolver { + return &LimitsResolver{cfg: cfg, st: st} +} + +// PlaytimeLimitsEnabled returns the active profile's enabled override, or +// the global config value when no profile is active or it has no override. +func (r *LimitsResolver) PlaytimeLimitsEnabled() bool { + if p := r.st.ActiveProfile(); p != nil && p.LimitsEnabled != nil { + return *p.LimitsEnabled + } + return r.cfg.PlaytimeLimitsEnabled() +} + +// DailyLimit returns the active profile's daily limit override, or the +// global config value. Returns 0 for "no limit". +func (r *LimitsResolver) DailyLimit() time.Duration { + if p := r.st.ActiveProfile(); p != nil && p.DailyLimit != nil { + return parseLimit(*p.DailyLimit) + } + return r.cfg.DailyLimit() +} + +// SessionLimit returns the active profile's session limit override, or the +// global config value. Returns 0 for "no limit". +func (r *LimitsResolver) SessionLimit() time.Duration { + if p := r.st.ActiveProfile(); p != nil && p.SessionLimit != nil { + return parseLimit(*p.SessionLimit) + } + return r.cfg.SessionLimit() +} + +// WarningIntervals always returns the global config warning intervals. +func (r *LimitsResolver) WarningIntervals() []time.Duration { + return r.cfg.WarningIntervals() +} + +// ActiveProfileID returns the active profile's ID, or "" when no profile +// is active. The playtime limits manager uses this to scope daily usage +// accounting to the active profile's attributed history. +func (r *LimitsResolver) ActiveProfileID() string { + if p := r.st.ActiveProfile(); p != nil { + return p.ProfileID + } + return "" +} + +// parseLimit parses a stored limit duration string. Strings are validated +// at write time; an unparseable value degrades to 0 (no limit), matching +// the config accessors' behavior. +func parseLimit(s string) time.Duration { + if s == "" { + return 0 + } + d, err := time.ParseDuration(s) + if err != nil { + return 0 + } + return d +} diff --git a/pkg/service/profiles/resolver_test.go b/pkg/service/profiles/resolver_test.go new file mode 100644 index 000000000..920710ac6 --- /dev/null +++ b/pkg/service/profiles/resolver_test.go @@ -0,0 +1,136 @@ +// 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 profiles + +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/service/state" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newResolver(t *testing.T) (resolver *LimitsResolver, cfg *config.Instance, st *state.State) { + t.Helper() + fs := helpers.NewMemoryFS() + cfg, err := helpers.NewTestConfig(fs, t.TempDir()) + require.NoError(t, err) + st, ns := state.NewState(nil, "boot") + t.Cleanup(func() { + for { + select { + case <-ns: + default: + return + } + } + }) + return NewLimitsResolver(cfg, st), cfg, st +} + +func strPtr(s string) *string { return &s } + +func boolPtr(b bool) *bool { return &b } + +func TestLimitsResolver_NoProfileInheritsGlobal(t *testing.T) { + t.Parallel() + resolver, cfg, _ := newResolver(t) + + cfg.SetPlaytimeLimitsEnabled(true) + require.NoError(t, cfg.SetDailyLimit("2h")) + require.NoError(t, cfg.SetSessionLimit("45m")) + + assert.True(t, resolver.PlaytimeLimitsEnabled()) + assert.Equal(t, 2*time.Hour, resolver.DailyLimit()) + assert.Equal(t, 45*time.Minute, resolver.SessionLimit()) + assert.Empty(t, resolver.ActiveProfileID()) +} + +func TestLimitsResolver_ProfileOverrides(t *testing.T) { + t.Parallel() + resolver, cfg, st := newResolver(t) + + cfg.SetPlaytimeLimitsEnabled(false) + require.NoError(t, cfg.SetDailyLimit("8h")) + + st.SetActiveProfile(&models.ActiveProfile{ + ProfileID: "kid-a", + Name: "Kid A", + LimitsEnabled: boolPtr(true), + DailyLimit: strPtr("1h30m"), + SessionLimit: strPtr("30m"), + }) + + assert.True(t, resolver.PlaytimeLimitsEnabled()) + assert.Equal(t, 90*time.Minute, resolver.DailyLimit()) + assert.Equal(t, 30*time.Minute, resolver.SessionLimit()) + assert.Equal(t, "kid-a", resolver.ActiveProfileID()) +} + +func TestLimitsResolver_PartialOverridesInheritRest(t *testing.T) { + t.Parallel() + resolver, cfg, st := newResolver(t) + + cfg.SetPlaytimeLimitsEnabled(true) + require.NoError(t, cfg.SetDailyLimit("4h")) + require.NoError(t, cfg.SetSessionLimit("1h")) + + // Profile overrides only the daily limit; everything else inherits. + st.SetActiveProfile(&models.ActiveProfile{ + ProfileID: "kid-b", + Name: "Kid B", + DailyLimit: strPtr("2h"), + }) + + assert.True(t, resolver.PlaytimeLimitsEnabled()) + assert.Equal(t, 2*time.Hour, resolver.DailyLimit()) + assert.Equal(t, time.Hour, resolver.SessionLimit()) +} + +func TestLimitsResolver_ZeroMeansUnlimited(t *testing.T) { + t.Parallel() + resolver, cfg, st := newResolver(t) + + cfg.SetPlaytimeLimitsEnabled(true) + require.NoError(t, cfg.SetDailyLimit("2h")) + + // "0" is an explicit override to unlimited, distinct from nil/inherit. + st.SetActiveProfile(&models.ActiveProfile{ + ProfileID: "parent", + Name: "Parent", + DailyLimit: strPtr("0"), + }) + + assert.Equal(t, time.Duration(0), resolver.DailyLimit()) +} + +func TestLimitsResolver_WarningsAlwaysGlobal(t *testing.T) { + t.Parallel() + resolver, cfg, st := newResolver(t) + + require.NoError(t, cfg.SetWarningIntervals([]string{"10m", "1m"})) + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "kid-a", Name: "Kid A"}) + + assert.Equal(t, []time.Duration{10 * time.Minute, time.Minute}, resolver.WarningIntervals()) +} diff --git a/pkg/service/profiles/switchid.go b/pkg/service/profiles/switchid.go new file mode 100644 index 000000000..af07b7543 --- /dev/null +++ b/pkg/service/profiles/switchid.go @@ -0,0 +1,59 @@ +// 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 profiles + +import ( + "crypto/rand" + _ "embed" + "fmt" + "math/big" + "strings" +) + +// wordlistRaw is derived from the EFF short wordlist #1 +// (https://www.eff.org/dice, CC-BY 3.0), with hyphenated words removed so +// every word is a plain lowercase token. Switch IDs are selectors, not +// secrets — the list only needs to be large enough to avoid accidental +// collisions and easy to read aloud or print on a card. +// +//go:embed wordlist.txt +var wordlistRaw string + +// switchIDWords is the number of words in a generated switch ID. +const switchIDWords = 3 + +//nolint:gochecknoglobals // immutable parsed copy of the embedded wordlist +var wordlist = strings.Fields(wordlistRaw) + +// GenerateSwitchID returns a new random word-phrase switch ID, e.g. +// "corn-arm-truck". Uniqueness is enforced by the database; callers should +// retry on a unique-constraint conflict. +func GenerateSwitchID() (string, error) { + parts := make([]string, switchIDWords) + maxIndex := big.NewInt(int64(len(wordlist))) + for i := range parts { + n, err := rand.Int(rand.Reader, maxIndex) + if err != nil { + return "", fmt.Errorf("failed to generate switch ID word: %w", err) + } + parts[i] = wordlist[n.Int64()] + } + return strings.Join(parts, "-"), nil +} diff --git a/pkg/service/profiles/switchid_test.go b/pkg/service/profiles/switchid_test.go new file mode 100644 index 000000000..d283aa7a9 --- /dev/null +++ b/pkg/service/profiles/switchid_test.go @@ -0,0 +1,53 @@ +// 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 profiles + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWordlist_Loaded(t *testing.T) { + t.Parallel() + + assert.Len(t, wordlist, 1295) + wordRe := regexp.MustCompile(`^[a-z]+$`) + for _, w := range wordlist { + assert.True(t, wordRe.MatchString(w), "word %q must be plain lowercase", w) + } +} + +func TestGenerateSwitchID_Format(t *testing.T) { + t.Parallel() + + for range 50 { + id, err := GenerateSwitchID() + require.NoError(t, err) + parts := strings.Split(id, "-") + require.Len(t, parts, switchIDWords, "switch ID %q", id) + for _, part := range parts { + assert.Contains(t, wordlist, part) + } + } +} diff --git a/pkg/service/profiles/wordlist.txt b/pkg/service/profiles/wordlist.txt new file mode 100644 index 000000000..028ca87e7 --- /dev/null +++ b/pkg/service/profiles/wordlist.txt @@ -0,0 +1,1295 @@ +acid +acorn +acre +acts +afar +affix +aged +agent +agile +aging +agony +ahead +aide +aids +aim +ajar +alarm +alias +alibi +alien +alike +alive +aloe +aloft +aloha +alone +amend +amino +ample +amuse +angel +anger +angle +ankle +apple +april +apron +aqua +area +arena +argue +arise +armed +armor +army +aroma +array +arson +art +ashen +ashes +atlas +atom +attic +audio +avert +avoid +awake +award +awoke +axis +bacon +badge +bagel +baggy +baked +baker +balmy +banjo +barge +barn +bash +basil +bask +batch +bath +baton +bats +blade +blank +blast +blaze +bleak +blend +bless +blimp +blink +bloat +blob +blog +blot +blunt +blurt +blush +boast +boat +body +boil +bok +bolt +boned +boney +bonus +bony +book +booth +boots +boss +botch +both +boxer +breed +bribe +brick +bride +brim +bring +brink +brisk +broad +broil +broke +brook +broom +brush +buck +bud +buggy +bulge +bulk +bully +bunch +bunny +bunt +bush +bust +busy +buzz +cable +cache +cadet +cage +cake +calm +cameo +canal +candy +cane +canon +cape +card +cargo +carol +carry +carve +case +cash +cause +cedar +chain +chair +chant +chaos +charm +chase +cheek +cheer +chef +chess +chest +chew +chief +chili +chill +chip +chomp +chop +chow +chuck +chump +chunk +churn +chute +cider +cinch +city +civic +civil +clad +claim +clamp +clap +clash +clasp +class +claw +clay +clean +clear +cleat +cleft +clerk +click +cling +clink +clip +cloak +clock +clone +cloth +cloud +clump +coach +coast +coat +cod +coil +coke +cola +cold +colt +coma +come +comic +comma +cone +cope +copy +coral +cork +cost +cot +couch +cough +cover +cozy +craft +cramp +crane +crank +crate +crave +crawl +crazy +creme +crepe +crept +crib +cried +crisp +crook +crop +cross +crowd +crown +crumb +crush +crust +cub +cult +cupid +cure +curl +curry +curse +curve +curvy +cushy +cut +cycle +dab +dad +daily +dairy +daisy +dance +dandy +darn +dart +dash +data +date +dawn +deaf +deal +dean +debit +debt +debug +decaf +decal +decay +deck +decor +decoy +deed +delay +denim +dense +dent +depth +derby +desk +dial +diary +dice +dig +dill +dime +dimly +diner +dingy +disco +dish +disk +ditch +ditzy +dizzy +dock +dodge +doing +doll +dome +donor +donut +dose +dot +dove +down +dowry +doze +drab +drama +drank +draw +dress +dried +drift +drill +drive +drone +droop +drove +drown +drum +dry +duck +duct +dude +dug +duke +duo +dusk +dust +duty +dwarf +dwell +eagle +early +earth +easel +east +eaten +eats +ebay +ebony +ebook +echo +edge +eel +eject +elbow +elder +elf +elk +elm +elope +elude +elves +email +emit +empty +emu +enter +entry +envoy +equal +erase +error +erupt +essay +etch +evade +even +evict +evil +evoke +exact +exit +fable +faced +fact +fade +fall +false +fancy +fang +fax +feast +feed +femur +fence +fend +ferry +fetal +fetch +fever +fiber +fifth +fifty +film +filth +final +finch +fit +five +flag +flaky +flame +flap +flask +fled +flick +fling +flint +flip +flirt +float +flock +flop +floss +flyer +foam +foe +fog +foil +folic +folk +food +fool +found +fox +foyer +frail +frame +fray +fresh +fried +frill +frisk +from +front +frost +froth +frown +froze +fruit +gag +gains +gala +game +gap +gas +gave +gear +gecko +geek +gem +genre +gift +gig +gills +given +giver +glad +glass +glide +gloss +glove +glow +glue +goal +going +golf +gong +good +gooey +goofy +gore +gown +grab +grain +grant +grape +graph +grasp +grass +grave +gravy +gray +green +greet +grew +grid +grief +grill +grip +grit +groom +grope +growl +grub +grunt +guide +gulf +gulp +gummy +guru +gush +gut +guy +habit +half +halo +halt +happy +harm +hash +hasty +hatch +hate +haven +hazel +hazy +heap +heat +heave +hedge +hefty +help +herbs +hers +hub +hug +hula +hull +human +humid +hump +hung +hunk +hunt +hurry +hurt +hush +hut +ice +icing +icon +icy +igloo +image +ion +iron +islam +issue +item +ivory +ivy +jab +jam +jaws +jazz +jeep +jelly +jet +jiffy +job +jog +jolly +jolt +jot +joy +judge +juice +juicy +july +jumbo +jump +junky +juror +jury +keep +keg +kept +kick +kilt +king +kite +kitty +kiwi +knee +knelt +koala +kung +ladle +lady +lair +lake +lance +land +lapel +large +lash +lasso +last +latch +late +lazy +left +legal +lemon +lend +lens +lent +level +lever +lid +life +lift +lilac +lily +limb +limes +line +lint +lion +lip +list +lived +liver +lunar +lunch +lung +lurch +lure +lurk +lying +lyric +mace +maker +malt +mama +mango +manor +many +map +march +mardi +marry +mash +match +mate +math +moan +mocha +moist +mold +mom +moody +mop +morse +most +motor +motto +mount +mouse +mousy +mouth +move +movie +mower +mud +mug +mulch +mule +mull +mumbo +mummy +mural +muse +music +musky +mute +nacho +nag +nail +name +nanny +nap +navy +near +neat +neon +nerd +nest +net +next +niece +ninth +nutty +oak +oasis +oat +ocean +oil +old +olive +omen +onion +only +ooze +opal +open +opera +opt +otter +ouch +ounce +outer +oval +oven +owl +ozone +pace +pagan +pager +palm +panda +panic +pants +panty +paper +park +party +pasta +patch +path +patio +payer +pecan +penny +pep +perch +perky +perm +pest +petal +petri +petty +photo +plank +plant +plaza +plead +plot +plow +pluck +plug +plus +poach +pod +poem +poet +pogo +point +poise +poker +polar +polio +polka +polo +pond +pony +poppy +pork +poser +pouch +pound +pout +power +prank +press +print +prior +prism +prize +probe +prong +proof +props +prude +prune +pry +pug +pull +pulp +pulse +puma +punch +punk +pupil +puppy +purr +purse +push +putt +quack +quake +query +quiet +quill +quilt +quit +quota +quote +rabid +race +rack +radar +radio +raft +rage +raid +rail +rake +rally +ramp +ranch +range +rank +rant +rash +raven +reach +react +ream +rebel +recap +relax +relay +relic +remix +repay +repel +reply +rerun +reset +rhyme +rice +rich +ride +rigid +rigor +rinse +riot +ripen +rise +risk +ritzy +rival +river +roast +robe +robin +rock +rogue +roman +romp +rope +rover +royal +ruby +rug +ruin +rule +runny +rush +rust +rut +sadly +sage +said +saint +salad +salon +salsa +salt +same +sandy +santa +satin +sauna +saved +savor +sax +say +scale +scam +scan +scare +scarf +scary +scoff +scold +scoop +scoot +scope +score +scorn +scout +scowl +scrap +scrub +scuba +scuff +sect +sedan +self +send +sepia +serve +set +seven +shack +shade +shady +shaft +shaky +sham +shape +share +sharp +shed +sheep +sheet +shelf +shell +shine +shiny +ship +shirt +shock +shop +shore +shout +shove +shown +showy +shred +shrug +shun +shush +shut +shy +sift +silk +silly +silo +sip +siren +sixth +size +skate +skew +skid +skier +skies +skip +skirt +skit +sky +slab +slack +slain +slam +slang +slash +slate +slaw +sled +sleek +sleep +sleet +slept +slice +slick +slimy +sling +slip +slit +slob +slot +slug +slum +slurp +slush +small +smash +smell +smile +smirk +smog +snack +snap +snare +snarl +sneak +sneer +sniff +snore +snort +snout +snowy +snub +snuff +speak +speed +spend +spent +spew +spied +spill +spiny +spoil +spoke +spoof +spool +spoon +sport +spot +spout +spray +spree +spur +squad +squat +squid +stack +staff +stage +stain +stall +stamp +stand +stank +stark +start +stash +state +stays +steam +steep +stem +step +stew +stick +sting +stir +stock +stole +stomp +stony +stood +stool +stoop +stop +storm +stout +stove +straw +stray +strut +stuck +stud +stuff +stump +stung +stunt +suds +sugar +sulk +surf +sushi +swab +swan +swarm +sway +swear +sweat +sweep +swell +swept +swim +swing +swipe +swirl +swoop +swore +syrup +tacky +taco +tag +take +tall +talon +tamer +tank +taper +taps +tarot +tart +task +taste +tasty +taunt +thank +thaw +theft +theme +thigh +thing +think +thong +thorn +those +throb +thud +thumb +thump +thus +tiara +tidal +tidy +tiger +tile +tilt +tint +tiny +trace +track +trade +train +trait +trap +trash +tray +treat +tree +trek +trend +trial +tribe +trick +trio +trout +truce +truck +trump +trunk +try +tug +tulip +tummy +turf +tusk +tutor +tutu +tux +tweak +tweet +twice +twine +twins +twirl +twist +uncle +uncut +undo +unify +union +unit +untie +upon +upper +urban +used +user +usher +utter +value +vapor +vegan +venue +verse +vest +veto +vice +video +view +viral +virus +visa +visor +vixen +vocal +voice +void +volt +voter +vowel +wad +wafer +wager +wages +wagon +wake +walk +wand +wasp +watch +water +wavy +wheat +whiff +whole +whoop +wick +widen +widow +width +wife +wifi +wilt +wimp +wind +wing +wink +wipe +wired +wiry +wise +wish +wispy +wok +wolf +womb +wool +woozy +word +work +worry +wound +woven +wrath +wreck +wrist +xerox +yahoo +yam +yard +year +yeast +yelp +yield +yodel +yoga +yoyo +yummy +zebra +zero +zesty +zippy +zone +zoom diff --git a/pkg/service/queues.go b/pkg/service/queues.go index 689337bca..e16c8a0f2 100644 --- a/pkg/service/queues.go +++ b/pkg/service/queues.go @@ -247,6 +247,12 @@ func runTokenZapScript( } } + if result.ProfileSwitch != nil { + if profileErr := applyProfileSwitch(svc, result.ProfileSwitch); profileErr != nil { + return profileErr + } + } + if result.Unsafe { log.Warn().Msg("token has been flagged as unsafe") token.Unsafe = true @@ -263,6 +269,25 @@ func runTokenZapScript( return nil } +// applyProfileSwitch applies a profile switch requested by a ZapScript +// command. This is the physical-scan path, so activation bypasses any +// profile PIN — possession of the card is the authorization. +func applyProfileSwitch(svc *ServiceContext, req *platforms.ProfileSwitchRequest) error { + if svc.Profiles == nil { + return errors.New("profiles service not available") + } + if req.Clear { + if err := svc.Profiles.Deactivate(); err != nil { + return fmt.Errorf("failed to clear active profile: %w", err) + } + return nil + } + if _, err := svc.Profiles.ActivateBySwitchID(req.SwitchID); err != nil { + return fmt.Errorf("failed to switch profile: %w", err) + } + return nil +} + func stopNativePlaybackBeforePrimaryCommand( svc *ServiceContext, cmd gozapscript.Command, @@ -605,6 +630,28 @@ func processTokenQueue( // Check if any command in the script launches media hasMediaLaunchCmd := parseErr == nil && scriptHasMediaLaunchingCommand(&script) + // When require_for_launch is enabled, media launches are blocked + // until a profile is active (profile switch commands still run — + // scanning a profile card is how the device gets unparked). A + // combo card that switches profile before launching passes: the + // switch activates a profile before the launch command runs, or + // fails and aborts the whole script. + if hasMediaLaunchCmd && svc.Config.ProfilesRequireForLaunch() && + svc.State.ActiveProfile() == nil && !scriptActivatesProfileBeforeLaunch(&script) { + log.Warn().Msg("profiles: launch blocked, no active profile and require_for_launch is set") + + path, enabled := svc.Config.FailSoundPath(helpers.DataDir(svc.Platform)) + helpers.PlayConfiguredSound(player, path, enabled, assets.FailSound, "fail") + + he.Success = false + if histErr := svc.DB.UserDB.AddHistory(&he); histErr != nil { + log.Error().Err(histErr).Msgf("error adding history") + } + + // Skip launch + continue + } + // Only check playtime limits if the script contains media-launching commands if hasMediaLaunchCmd { if limitReason, limitErr := limitsManager.CheckBeforeLaunch(); limitErr != nil { diff --git a/pkg/service/queues_helpers.go b/pkg/service/queues_helpers.go index 2cd912ce3..a84cdbaf3 100644 --- a/pkg/service/queues_helpers.go +++ b/pkg/service/queues_helpers.go @@ -72,6 +72,26 @@ func scriptHasMediaLaunchingCommand(script *zapscript.Script) bool { return false } +// scriptActivatesProfileBeforeLaunch reports whether the script contains a +// profile command before its first media-launching command. Such a +// combo card (profile switch + favorite game in one scan) satisfies the +// require-profile launch gate: by the time the launch command runs, the +// switch will have activated a profile, or failed and aborted the script. +func scriptActivatesProfileBeforeLaunch(script *zapscript.Script) bool { + if script == nil { + return false + } + for _, cmd := range script.Cmds { + if cmd.Name == zapscript.ZapScriptCmdProfile { + return true + } + if zscript.IsMediaLaunchingCommand(cmd.Name) { + return false + } + } + return false +} + // scriptHasMediaDisruptingCommand checks if any command in the script would // change or stop the currently playing media. Used by launch guard. func scriptHasMediaDisruptingCommand(script *zapscript.Script) bool { diff --git a/pkg/service/require_profile_test.go b/pkg/service/require_profile_test.go new file mode 100644 index 000000000..94b240a09 --- /dev/null +++ b/pkg/service/require_profile_test.go @@ -0,0 +1,152 @@ +// 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 service + +import ( + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestScanBehavior_RequireProfile_BlocksLaunchWithoutProfile(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, "tap", 0) + + env.cfg.SetProfilesRequireForLaunch(true) + + env.sendGameScan("card1", env.gamePath("game1.gba")) + env.expectNoLaunch(t) +} + +func TestScanBehavior_RequireProfile_AllowsLaunchWithActiveProfile(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, "tap", 0) + + env.cfg.SetProfilesRequireForLaunch(true) + env.st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Dad"}) + + env.sendGameScan("card1", env.gamePath("game1.gba")) + env.waitForLaunch(t) +} + +func TestScanBehavior_RequireProfile_OffByDefault(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, "tap", 0) + + // No profile active and require_for_launch unset: launches work exactly + // as they did before profiles existed. + env.sendGameScan("card1", env.gamePath("game1.gba")) + env.waitForLaunch(t) +} + +// TestScanBehavior_ProfileSwitchCard covers the signature card interaction: +// scanning a **profile token activates the profile with no PIN +// check, and **profile.clear deactivates it. +func TestScanBehavior_ProfileSwitchCard(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, "tap", 0) + + profile := &database.Profile{ + ProfileID: "profile-1", + Name: "Kid A", + SwitchID: "corn-arm-truck", + PINHash: "pbkdf2-sha256$1$AAAA$AAAA", // PIN set, but card scans bypass it + } + env.userDB.On("GetProfileBySwitchID", "corn-arm-truck").Return(profile, nil) + env.userDB.On("ActivateProfile", "profile-1", mock.AnythingOfType("int64")).Return(nil) + env.userDB.On("DeleteDeviceState", database.DeviceStateKeyActiveProfile).Return(nil) + + env.sendCommandScan("switch-card", "**profile:corn-arm-truck") + env.waitForActiveProfile(t, "profile-1") + + env.sendCommandScan("clear-card", "**profile.clear") + env.waitForNoActiveProfile(t) +} + +// TestScanBehavior_RequireProfile_ComboCardSwitchThenLaunch covers a card +// carrying both a profile switch and a game launch: the gate must let it +// through because the switch activates a profile before the launch runs. +func TestScanBehavior_RequireProfile_ComboCardSwitchThenLaunch(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, "tap", 0) + + env.cfg.SetProfilesRequireForLaunch(true) + + profile := &database.Profile{ + ProfileID: "profile-1", + Name: "Kid A", + SwitchID: "corn-arm-truck", + } + env.userDB.On("GetProfileBySwitchID", "corn-arm-truck").Return(profile, nil) + env.userDB.On("ActivateProfile", "profile-1", mock.AnythingOfType("int64")).Return(nil) + + env.sendCommandScan("combo-card", + "**profile:corn-arm-truck||**launch:"+env.gamePath("game1.gba")) + env.waitForLaunch(t) + env.waitForActiveProfile(t, "profile-1") +} + +// TestScanBehavior_RequireProfile_LaunchBeforeSwitchStillBlocked pins the +// gate's ordering rule: a profile switch AFTER the launch command does not +// satisfy require_for_launch. +func TestScanBehavior_RequireProfile_LaunchBeforeSwitchStillBlocked(t *testing.T) { + t.Parallel() + env := setupScanBehavior(t, "tap", 0) + + env.cfg.SetProfilesRequireForLaunch(true) + + env.sendCommandScan("backwards-combo-card", + "**launch:"+env.gamePath("game1.gba")+"||**profile:corn-arm-truck") + env.expectNoLaunch(t) +} + +func (env *scanBehaviorEnv) waitForActiveProfile(t *testing.T, profileID string) { + t.Helper() + deadline := time.After(behaviorTimeout) + for { + if active := env.st.ActiveProfile(); active != nil && active.ProfileID == profileID { + return + } + select { + case <-deadline: + require.FailNow(t, "timed out waiting for active profile", "profileID=%s", profileID) + case <-time.After(time.Millisecond): + } + } +} + +func (env *scanBehaviorEnv) waitForNoActiveProfile(t *testing.T) { + t.Helper() + deadline := time.After(behaviorTimeout) + for { + if env.st.ActiveProfile() == nil { + return + } + select { + case <-deadline: + require.FailNow(t, "timed out waiting for profile deactivation") + case <-time.After(time.Millisecond): + } + } +} diff --git a/pkg/service/scan_behavior_test.go b/pkg/service/scan_behavior_test.go index fcb201c0e..6a1f149b2 100644 --- a/pkg/service/scan_behavior_test.go +++ b/pkg/service/scan_behavior_test.go @@ -33,6 +33,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "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" testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" @@ -52,6 +53,7 @@ const ( type scanBehaviorEnv struct { st *state.State cfg *config.Instance + userDB *testhelpers.MockUserDBI scanQueue chan readers.Scan clock *clockwork.FakeClock launchCh chan string @@ -168,6 +170,7 @@ mode = "unrestricted"`)) Config: cfg, State: st, DB: db, + Profiles: profiles.NewService(db, st), LaunchSoftwareQueue: lsq, PlaylistQueue: plq, } @@ -199,6 +202,7 @@ mode = "unrestricted"`)) return &scanBehaviorEnv{ st: st, cfg: cfg, + userDB: mockUserDB, scanQueue: scanQueue, clock: fakeClock, romsDir: romsDir, diff --git a/pkg/service/service.go b/pkg/service/service.go index 87987eacd..63e2a778f 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -46,6 +46,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/playtime" + "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" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/updater" @@ -240,22 +241,51 @@ func Start( log.Info().Msg("initializing inbox service") st.SetInbox(inbox.NewService(db.UserDB, st.Notifications)) + // Initialize profiles and restore the persisted active profile before + // the limits manager starts, so limit checks see the right profile. + log.Info().Msg("initializing profiles service") + profilesSvc := profiles.NewService(db, st) + if restoreErr := profilesSvc.RestoreOnBoot(); restoreErr != nil { + log.Error().Err(restoreErr).Msg("error restoring active profile") + } + // Initialize playtime limits system (always create for runtime enable/disable) log.Info().Msg("initializing playtime limits") limitsManager := playtime.NewLimitsManager(db, pl, cfg, clockwork.NewRealClock(), player) + limitsResolver := profiles.NewLimitsResolver(cfg, st) + limitsManager.SetLimitsProvider(limitsResolver) limitsManager.Start(notifBroker, st.Notifications) // Restore session state from history so session limits survive restarts within - // the cooldown window. Must run after CloseHangingMediaHistory (called above). + // the cooldown window. Must run after CloseHangingMediaHistory (called above) + // and after the active profile is restored, so the session is judged + // against the right profile's limits. limitsManager.RestoreSessionFromHistory(time.Now()) - if cfg.PlaytimeLimitsEnabled() { + if limitsResolver.PlaytimeLimitsEnabled() { limitsManager.SetEnabled(true) } + // Data swapping: on platforms with the capability, profile switches + // also swap profile-scoped data (saves, save states). The boot + // reconcile runs after profile and session restore so a game already + // running across a service restart defers the swap until it stops. + var dataSwapper platforms.ProfileDataSwapper + if swapper, ok := pl.(platforms.ProfileDataSwapper); ok { + dataSwapper = swapper + } + dataSwap := profiles.NewDataSwapCoordinator(cfg, st, dataSwapper) + dataSwap.Start(notifBroker, st.Notifications) + profilesSvc.SetDataSwap(dataSwap) + dataSwap.Reconcile() + if watcher, ok := pl.(platforms.ProfileDataWatcher); ok && dataSwapper != nil { + watcher.WatchProfileData(st.GetContext(), dataSwap.Reconcile) + } + svc := &ServiceContext{ Platform: pl, Config: cfg, State: st, DB: db, + Profiles: profilesSvc, PlaybackManager: playbackManager, LaunchSoftwareQueue: lsq, PlaylistQueue: plq, @@ -318,7 +348,7 @@ func Start( apiDone := make(chan error, 1) go func() { apiDone <- api.StartWithReady( - pl, cfg, st, itq, cfq, db, limitsManager, + pl, cfg, st, itq, cfq, db, limitsManager, profilesSvc, notifBroker, discoveryService.InstanceName(), player, playbackManager, indexPauser, scrapePauser, idleSched, apiReady, ) @@ -334,6 +364,7 @@ func Start( log.Debug().Err(apiDoneErr).Msg("API service returned after startup failure") } limitsManager.Stop() + dataSwap.Stop() notifBroker.Stop() closeDatabase(db) return nil, fmt.Errorf("api startup failed: %w", apiErr) @@ -533,6 +564,7 @@ func Start( log.Error().Err(apiErr).Msg("API service stopped with error") } limitsManager.Stop() + dataSwap.Stop() notifBroker.Stop() <-historyListenDone <-historyUpdateDone diff --git a/pkg/service/state/active_profile_test.go b/pkg/service/state/active_profile_test.go new file mode 100644 index 000000000..cc3dca44e --- /dev/null +++ b/pkg/service/state/active_profile_test.go @@ -0,0 +1,87 @@ +// 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 state_test + +import ( + "encoding/json" + "testing" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetActiveProfile_StoresCopy(t *testing.T) { + t.Parallel() + + st, _ := state.NewState(nil, "boot") + assert.Nil(t, st.ActiveProfile()) + + profile := &models.ActiveProfile{ + ProfileID: "profile-1", + Name: "Kid A", + HasPIN: true, + } + st.SetActiveProfile(profile) + + // Mutating the caller's struct must not affect stored state. + profile.Name = "mutated" + + got := st.ActiveProfile() + require.NotNil(t, got) + assert.Equal(t, "Kid A", got.Name) + assert.Equal(t, "profile-1", got.ProfileID) + assert.True(t, got.HasPIN) + + // Mutating the returned copy must not affect stored state either. + got.Name = "also mutated" + assert.Equal(t, "Kid A", st.ActiveProfile().Name) +} + +func TestSetActiveProfile_Notifications(t *testing.T) { + t.Parallel() + + st, ns := state.NewState(nil, "boot") + + st.SetActiveProfile(&models.ActiveProfile{ProfileID: "profile-1", Name: "Kid A"}) + activated := <-ns + assert.Equal(t, models.NotificationProfilesActive, activated.Method) + var payload models.ProfilesActiveNotification + require.NoError(t, json.Unmarshal(activated.Params, &payload)) + require.NotNil(t, payload.Profile) + assert.Equal(t, "profile-1", payload.Profile.ProfileID) + + st.SetActiveProfile(nil) + deactivated := <-ns + assert.Equal(t, models.NotificationProfilesActive, deactivated.Method) + var nilPayload models.ProfilesActiveNotification + require.NoError(t, json.Unmarshal(deactivated.Params, &nilPayload)) + assert.Nil(t, nilPayload.Profile) + assert.Nil(t, st.ActiveProfile()) + + // Duplicate deactivation emits no second notification. + st.SetActiveProfile(nil) + select { + case n := <-ns: + t.Fatalf("unexpected notification: %s", n.Method) + default: + } +} diff --git a/pkg/service/state/state.go b/pkg/service/state/state.go index 1ee780a72..49c0a1412 100644 --- a/pkg/service/state/state.go +++ b/pkg/service/state/state.go @@ -69,6 +69,7 @@ type State struct { Notifications chan<- models.Notification activeMedia *models.ActiveMedia backgroundMedia *models.ActiveMedia + activeProfile *models.ActiveProfile activePlaylist *playlists.Playlist backgroundPlaylist *playlists.Playlist activeMediaReadyCh chan struct{} @@ -145,6 +146,49 @@ func (s *State) GetActiveCard() tokens.Token { return s.activeToken } +// SetActiveProfile sets or clears (nil) the device's active profile and +// broadcasts a profiles.active notification. The snapshot is stored by +// value internally so callers cannot mutate state through the pointer. +func (s *State) SetActiveProfile(profile *models.ActiveProfile) { + s.mu.Lock() + + if profile == nil && s.activeProfile == nil { + // ignore duplicate deactivations + s.mu.Unlock() + return + } + + var stored *models.ActiveProfile + if profile != nil { + profileCopy := *profile + stored = &profileCopy + } + s.activeProfile = stored + + // Prepare notification payload inside lock, send outside + var payload *models.ActiveProfile + if stored != nil { + payloadCopy := *stored + payload = &payloadCopy + } + + s.mu.Unlock() + + notifications.ProfilesActiveChanged(s.Notifications, models.ProfilesActiveNotification{Profile: payload}) +} + +// ActiveProfile returns a copy of the device's active profile snapshot, or +// nil when no profile is active. +func (s *State) ActiveProfile() *models.ActiveProfile { + s.mu.RLock() + defer s.mu.RUnlock() + if s.activeProfile == nil { + return nil + } + profileCopy := *s.activeProfile + return &profileCopy +} + func (s *State) GetLastScanned() tokens.Token { s.mu.RLock() defer s.mu.RUnlock() diff --git a/pkg/testing/helpers/db_mocks.go b/pkg/testing/helpers/db_mocks.go index 8d69c564d..6ae111fc0 100644 --- a/pkg/testing/helpers/db_mocks.go +++ b/pkg/testing/helpers/db_mocks.go @@ -452,6 +452,18 @@ func (m *MockUserDBI) SumMediaPlayTimeForDay(dayStart time.Time) (int64, error) return total, nil } +func (m *MockUserDBI) SumMediaPlayTimeForDayByProfile(dayStart time.Time, profileID string) (int64, error) { + args := m.Called(dayStart, profileID) + total, ok := args.Get(0).(int64) + if !ok { + total = 0 + } + if err := args.Error(1); err != nil { + return total, fmt.Errorf("mock UserDBI sum media play time for day by profile failed: %w", err) + } + return total, nil +} + func (m *MockUserDBI) AddInboxMessage(msg *database.InboxMessage) (*database.InboxMessage, error) { args := m.Called(msg) if result, ok := args.Get(0).(*database.InboxMessage); ok { @@ -564,6 +576,108 @@ func (m *MockUserDBI) CountClients() (int, error) { return count, nil } +func (m *MockUserDBI) CreateProfile(p *database.Profile) error { + args := m.Called(p) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock UserDBI create profile failed: %w", err) + } + return nil +} + +func (m *MockUserDBI) GetProfile(profileID string) (*database.Profile, error) { + args := m.Called(profileID) + if result, ok := args.Get(0).(*database.Profile); ok { + if err := args.Error(1); err != nil { + return nil, fmt.Errorf("mock UserDBI get profile failed: %w", err) + } + return result, nil + } + if err := args.Error(1); err != nil { + return nil, fmt.Errorf("mock UserDBI get profile failed: %w", err) + } + return nil, nil //nolint:nilnil // mock returns nil when no profile is configured +} + +func (m *MockUserDBI) GetProfileBySwitchID(switchID string) (*database.Profile, error) { + args := m.Called(switchID) + if result, ok := args.Get(0).(*database.Profile); ok { + if err := args.Error(1); err != nil { + return nil, fmt.Errorf("mock UserDBI get profile by switch ID failed: %w", err) + } + return result, nil + } + if err := args.Error(1); err != nil { + return nil, fmt.Errorf("mock UserDBI get profile by switch ID failed: %w", err) + } + return nil, nil //nolint:nilnil // mock returns nil when no profile is configured +} + +func (m *MockUserDBI) ListProfiles() ([]database.Profile, error) { + args := m.Called() + if profiles, ok := args.Get(0).([]database.Profile); ok { + if err := args.Error(1); err != nil { + return profiles, fmt.Errorf("mock UserDBI list profiles failed: %w", err) + } + return profiles, nil + } + if err := args.Error(1); err != nil { + return nil, fmt.Errorf("mock UserDBI list profiles failed: %w", err) + } + return nil, nil +} + +func (m *MockUserDBI) UpdateProfile(p *database.Profile) error { + args := m.Called(p) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock UserDBI update profile failed: %w", err) + } + return nil +} + +func (m *MockUserDBI) ActivateProfile(profileID string, lastUsedAt int64) error { + args := m.Called(profileID, lastUsedAt) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock UserDBI activate profile failed: %w", err) + } + return nil +} + +func (m *MockUserDBI) DeleteProfile(profileID string) error { + args := m.Called(profileID) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock UserDBI delete profile failed: %w", err) + } + return nil +} + +func (m *MockUserDBI) SetDeviceState(key, value string) error { + args := m.Called(key, value) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock UserDBI set device state failed: %w", err) + } + return nil +} + +func (m *MockUserDBI) GetDeviceState(key string) (value string, found bool, err error) { + args := m.Called(key) + if v, ok := args.Get(0).(string); ok { + value = v + } + found = args.Bool(1) + if err := args.Error(2); err != nil { + return value, found, fmt.Errorf("mock UserDBI get device state failed: %w", err) + } + return value, found, nil +} + +func (m *MockUserDBI) DeleteDeviceState(key string) error { + args := m.Called(key) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock UserDBI delete device state failed: %w", err) + } + return nil +} + func (m *MockUserDBI) Backup(reason string, manual bool) (database.BackupInfo, error) { args := m.Called(reason, manual) info, ok := args.Get(0).(database.BackupInfo) diff --git a/pkg/ui/tui/clients.go b/pkg/ui/tui/clients.go new file mode 100644 index 000000000..4db79a197 --- /dev/null +++ b/pkg/ui/tui/clients.go @@ -0,0 +1,287 @@ +// 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 tui + +import ( + "fmt" + "strings" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/rivo/tview" + "github.com/rs/zerolog/log" +) + +const clientRoleModalPage = "client_role_modal" + +func formatPairingPIN(pin string) string { + if len(pin) != 6 { + return pin + } + return pin[:3] + " " + pin[3:] +} + +func formatPairingRole(role string) string { + if role == "" { + return "" + } + return strings.ToUpper(role[:1]) + role[1:] +} + +func formatPairingCountdown(expiresAt, now time.Time) string { + remaining := expiresAt.Sub(now) + if remaining <= 0 { + return "0:00" + } + seconds := int64((remaining + time.Second - 1) / time.Second) + return fmt.Sprintf("%d:%02d", seconds/60, seconds%60) +} + +func showClientPairingModal( + pages *tview.Pages, + app *tview.Application, + pairing *models.ClientsPairStartResponse, + role string, + onDone func(), +) { + expiresAt := time.Unix(pairing.ExpiresAt, 0) + message := func(now time.Time) string { + return fmt.Sprintf("Pairing PIN: %s\nRole: %s\nExpires in: %s\n\nEnter this PIN in the client app.", + formatPairingPIN(pairing.PIN), formatPairingRole(role), formatPairingCountdown(expiresAt, now)) + } + + done := make(chan struct{}) + modal := ShowInfoModal(pages, app, "Pair Client", message(time.Now()), func() { + close(done) + if onDone != nil { + onDone() + } + }) + + go func() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case now := <-ticker.C: + app.QueueUpdateDraw(func() { + modal.SetText(message(now)) + }) + if !now.Before(expiresAt) { + return + } + case <-done: + return + } + } + }() +} + +func startClientPairing( + svc SettingsService, + pages *tview.Pages, + app *tview.Application, + role string, + onDone func(), +) { + ctx, cancel := tuiContext() + pairing, err := svc.StartClientPairing(ctx, role) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to start pairing: "+err.Error(), onDone) + return + } + showClientPairingModal(pages, app, pairing, role, onDone) +} + +func showClientRolePicker( + svc SettingsService, + pages *tview.Pages, + app *tview.Application, + profiles []models.ProfileResponse, + onDone func(), +) { + menu := NewSettingsList(pages, PageClients) + cleanup := func() { + pages.HidePage(clientRoleModalPage) + pages.RemovePage(clientRoleModalPage) + } + menu.SetRebuildPrevious(func() { + cleanup() + onDone() + }) + menu.SetDynamicHelpMode(true) + menu.AddAction("Member", "Day-to-day access without management permissions", func() { + cleanup() + startClientPairing(svc, pages, app, "member", onDone) + }) + menu.AddAction("Admin", "Full settings and profile management access", func() { + promptProfileManagement(svc, pages, app, profiles, func() { + cleanup() + startClientPairing(svc, pages, app, "admin", onDone) + }, func() { + app.SetFocus(menu.List) + }) + }) + menu.SetBorder(true) + SetBoxTitle(menu.Box, "Client Role") + pages.AddPage(clientRoleModalPage, CenterWidget(42, 4, menu.List), true, true) + app.SetFocus(menu.List) +} + +// BuildClientsPage manages paired client devices and local pairing approval. +func BuildClientsPage(svc SettingsService, pages *tview.Pages, app *tview.Application) { + ctx, cancel := tuiContext() + clientsResp, err := svc.GetClients(ctx) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to load paired clients", func() { + pages.SwitchToPage(PageSettingsMain) + }) + return + } + ctx, cancel = tuiContext() + settings, err := svc.GetSettings(ctx) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to load encryption setting", func() { + pages.SwitchToPage(PageSettingsMain) + }) + return + } + ctx, cancel = tuiContext() + profilesResp, profilesErr := svc.GetProfiles(ctx) + cancel() + if profilesErr != nil { + profilesResp = &models.ProfilesResponse{} + } + + frame := NewPageFrame(app).SetTitle("Settings", "Clients") + goBack := func() { pages.SwitchToPage(PageSettingsMain) } + frame.SetOnEscape(goBack) + + menu := NewSettingsList(pages, PageSettingsMain) + menu.SetRebuildPrevious(goBack) + menu.SetDynamicHelpMode(true).SetHelpCallback(func(text string) { + frame.SetHelpText(text) + }) + + rebuild := func() { BuildClientsPage(svc, pages, app) } + encryption := settings.Encryption + menu.AddToggle("Require encryption", + "Require paired encryption remotely; local connections remain allowed", + &encryption, func(value bool) { + currentIdx := menu.GetCurrentItem() + if value { + encryption = false + menu.refreshAllItems(currentIdx) + ShowConfirmModal(pages, app, + "Require encrypted remote connections?\n\n"+ + "Unpaired remote clients will disconnect. Local connections remain available.", + func() { + enabled := true + ctx, cancel := tuiContext() + err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{Encryption: &enabled}) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to require encryption", func() { + app.SetFocus(menu.List) + }) + return + } + encryption = true + menu.refreshAllItems(currentIdx) + app.SetFocus(menu.List) + }, func() { + app.SetFocus(menu.List) + }) + return + } + + ctx, cancel := tuiContext() + err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{Encryption: &value}) + cancel() + if err != nil { + encryption = true + menu.refreshAllItems(currentIdx) + ShowErrorModal(pages, app, "Failed to allow plaintext connections", func() { + app.SetFocus(menu.List) + }) + } + }) + + for i := range clientsResp.Clients { + paired := clientsResp.Clients[i] + menu.AddAction(paired.ClientName+" ("+paired.Role+")", "Revoke this paired client", func() { + promptProfileManagement(svc, pages, app, profilesResp.Profiles, func() { + ShowConfirmModal(pages, app, "Revoke "+paired.ClientName+"?", func() { + ctx, cancel := tuiContext() + err := svc.DeleteClient(ctx, paired.ClientID) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to revoke client: "+err.Error(), nil) + return + } + rebuild() + }, nil) + }, func() { + app.SetFocus(menu.List) + }) + }) + } + if len(clientsResp.Clients) == 0 { + menu.AddAction("(no paired clients)", "First paired client will be administrator", func() {}) + } + + buttonBar := NewButtonBar(app) + buttonBar.AddButtonWithHelp("Pair", "Approve a new client pairing", func() { + if len(clientsResp.Clients) == 0 { + ShowConfirmModal(pages, app, + "The first paired client receives administrator access. Continue?", + func() { + startClientPairing(svc, pages, app, "admin", rebuild) + }, nil) + return + } + showClientRolePicker(svc, pages, app, profilesResp.Profiles, rebuild) + }) + buttonBar.AddButtonWithHelp("Cancel Pair", "Cancel any active pairing approval", func() { + ctx, cancel := tuiContext() + err := svc.CancelClientPairing(ctx) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to cancel pairing", nil) + return + } + ShowInfoModal(pages, app, "Pairing", "Pairing approval cancelled.", rebuild) + }) + buttonBar.AddButtonWithHelp("Back", "Return to settings", goBack) + buttonBar.SetupNavigation(goBack) + buttonBar.SetHelpCallback(func(text string) { + frame.SetHelpText(text) + }) + frame.SetButtonBar(buttonBar) + frame.SetContent(menu.List) + menu.TriggerInitialHelp() + frame.SetupContentToButtonNavigation() + pages.AddAndSwitchToPage(PageClients, frame, true) + app.SetFocus(menu.List) + log.Debug().Int("clients", len(clientsResp.Clients)).Msg("built paired clients page") +} diff --git a/pkg/ui/tui/clients_test.go b/pkg/ui/tui/clients_test.go new file mode 100644 index 000000000..bc7d619d3 --- /dev/null +++ b/pkg/ui/tui/clients_test.go @@ -0,0 +1,194 @@ +// 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 tui + +import ( + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPairingDisplayFormatting(t *testing.T) { + t.Parallel() + + assert.Equal(t, "123 456", formatPairingPIN("123456")) + assert.Equal(t, "Admin", formatPairingRole("admin")) + now := time.Date(2026, time.July, 15, 12, 0, 0, 0, time.UTC) + assert.Equal(t, "1:05", formatPairingCountdown(now.Add(65*time.Second), now)) + assert.Equal(t, "0:00", formatPairingCountdown(now, now)) +} + +func TestBuildClientsPage_FirstClientAdmin_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageSettingsMain, tview.NewTextView().SetText("Settings"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{}, nil) + mockSvc.SetupGetSettings(&models.SettingsResponse{}) + mockSvc.SetupGetProfiles(&models.ProfilesResponse{}) + mockSvc.On("StartClientPairing", mock.Anything, "admin"). + Return(&models.ClientsPairStartResponse{PIN: "123456", ExpiresAt: time.Now().Add(2 * time.Second).Unix()}, nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + BuildClientsPage(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("Clients", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("no paired clients")) + assert.True(t, runner.ContainsText("Pair")) + + // Move from list to button bar, select Pair, then confirm first-client + // administrator assignment. + runner.SimulateTab() + runner.SimulateEnter() + require.True(t, runner.WaitForText("first paired client", 100*time.Millisecond)) + runner.SimulateEnter() + require.True(t, runner.WaitForText("Pairing PIN: 123 456", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Role: Admin")) + assert.True(t, runner.ContainsText("Expires in:")) + require.True(t, runner.WaitForText("Expires in: 0:00", 3*time.Second)) + runner.SimulateEnter() + mockSvc.AssertExpectations(t) +} + +func TestBuildClientsPage_RequireEncryptionConfirmation_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageSettingsMain, tview.NewTextView().SetText("Settings"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{}, nil) + mockSvc.SetupGetSettings(&models.SettingsResponse{}) + mockSvc.SetupGetProfiles(&models.ProfilesResponse{}) + mockSvc.On("UpdateSettings", mock.Anything, mock.MatchedBy(func(params *models.UpdateSettingsParams) bool { + return params.Encryption != nil && *params.Encryption + })).Return(nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + BuildClientsPage(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("Require encryption", 100*time.Millisecond)) + runner.SimulateArrowRight() + require.True(t, runner.WaitForText("Require encrypted remote", 100*time.Millisecond)) + runner.SimulateEnter() + mockSvc.AssertExpectations(t) +} + +func TestBuildClientsPage_DisableEncryptionImmediately_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageSettingsMain, tview.NewTextView().SetText("Settings"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{}, nil) + mockSvc.SetupGetSettings(&models.SettingsResponse{Encryption: true}) + mockSvc.SetupGetProfiles(&models.ProfilesResponse{}) + mockSvc.On("UpdateSettings", mock.Anything, mock.MatchedBy(func(params *models.UpdateSettingsParams) bool { + return params.Encryption != nil && !*params.Encryption + })).Return(nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + BuildClientsPage(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("Require encryption", 100*time.Millisecond)) + runner.SimulateArrowLeft() + mockSvc.AssertExpectations(t) +} + +func TestShowClientRolePicker_AdminWithoutProfilesStartsPairing_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageClients, tview.NewTextView().SetText("Clients"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.On("StartClientPairing", mock.Anything, "admin"). + Return(&models.ClientsPairStartResponse{PIN: "654321", ExpiresAt: time.Now().Add(time.Minute).Unix()}, nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + showClientRolePicker(mockSvc, pages, runner.App(), nil, func() {}) + }) + + require.True(t, runner.WaitForText("Client Role", 100*time.Millisecond)) + runner.SimulateArrowDown() + runner.SimulateEnter() + require.True(t, runner.WaitForText("Pairing PIN: 654 321", 100*time.Millisecond)) + assert.False(t, runner.ContainsText("Profile PIN")) + runner.SimulateEnter() + mockSvc.AssertExpectations(t) +} + +func TestShowClientRolePicker_AdminPromptsCredential_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageClients, tview.NewTextView().SetText("Clients"), true, false) + + mockSvc := NewMockSettingsService() + profiles := testProfilesResponse().Profiles + mockSvc.On("VerifyProfileManagement", mock.Anything, "p1", "1234").Return(nil) + mockSvc.On("StartClientPairing", mock.Anything, "admin"). + Return(&models.ClientsPairStartResponse{PIN: "654321", ExpiresAt: time.Now().Add(time.Minute).Unix()}, nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + showClientRolePicker(mockSvc, pages, runner.App(), profiles, func() {}) + }) + + require.True(t, runner.WaitForText("Client Role", 100*time.Millisecond)) + runner.SimulateArrowDown() // Admin + runner.SimulateEnter() + require.True(t, runner.WaitForText("Profile PIN", 100*time.Millisecond)) + runner.SimulateString("1234") + runner.SimulateEnter() + require.True(t, runner.WaitForText("Pairing PIN: 654 321", 100*time.Millisecond)) + runner.SimulateEnter() + mockSvc.AssertExpectations(t) +} diff --git a/pkg/ui/tui/main.go b/pkg/ui/tui/main.go index 4a4f0dcbc..5a30df459 100644 --- a/pkg/ui/tui/main.go +++ b/pkg/ui/tui/main.go @@ -52,10 +52,14 @@ const ( PageSettingsAudioMenu = "settings_audio_menu" PageSettingsReadersMenu = "settings_readers_menu" PageSettingsTUI = "settings_tui" + PageSettingsProfiles = "settings_profiles" PageSettingsAbout = "settings_about" PageSearchMedia = "search_media" PageExportLog = "export_log" PageGenerateDB = "generate_db" + PageProfilesList = "profiles_list" + PageProfilesEdit = "profiles_edit" + PageClients = "clients" ) // ButtonGridItem represents a button in the grid with its help text. @@ -697,6 +701,10 @@ func BuildMainPage( saveFocus() BuildSettingsMainMenu(cfg, pages, app, pl, rebuildMainPage, logDestPath, logDestName) }) + profilesButton := tview.NewButton("Profiles").SetSelectedFunc(func() { + saveFocus() + BuildProfilesPage(svc, pages, app) + }) exitButton := tview.NewButton("Exit").SetSelectedFunc(func() { notifyCancel() app.Stop() @@ -708,6 +716,7 @@ func BuildMainPage( writeButton.SetDisabled(true) updateDBButton.SetDisabled(true) settingsButton.SetDisabled(true) + profilesButton.SetDisabled(true) } exitHelpText := "Exit TUI app" @@ -722,7 +731,7 @@ func BuildMainPage( ) buttonGrid.AddRow( &ButtonGridItem{settingsButton, "Manage settings for Core service", disableRow1}, - nil, + &ButtonGridItem{profilesButton, "Manage device profiles and write switch cards", disableRow1}, &ButtonGridItem{exitButton, exitHelpText, false}, ) buttonGrid.SetOnHelp(func(text string) { diff --git a/pkg/ui/tui/mock_api_client_test.go b/pkg/ui/tui/mock_api_client_test.go index 98b31df8a..bc309492a 100644 --- a/pkg/ui/tui/mock_api_client_test.go +++ b/pkg/ui/tui/mock_api_client_test.go @@ -234,3 +234,122 @@ func (m *MockSettingsService) SetupSearchMedia(results *models.SearchResults) { func (m *MockSettingsService) SetupSearchMediaError(err error) { m.On("SearchMedia", mock.Anything, mock.Anything).Return(nil, err) } + +// VerifyProfileManagement mocks one administrator credential check. +func (m *MockSettingsService) VerifyProfileManagement(ctx context.Context, profileID, pin string) error { + args := m.Called(ctx, profileID, pin) + return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors +} + +// GetProfiles mocks fetching profiles. +func (m *MockSettingsService) GetProfiles(ctx context.Context) (*models.ProfilesResponse, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + profiles, ok := args.Get(0).(*models.ProfilesResponse) + if !ok { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + return profiles, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors +} + +// GetActiveProfile mocks fetching the active profile. +func (m *MockSettingsService) GetActiveProfile(ctx context.Context) (*models.ActiveProfile, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + active, ok := args.Get(0).(*models.ActiveProfile) + if !ok { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + return active, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors +} + +// NewProfile mocks creating a profile. +func (m *MockSettingsService) NewProfile( + ctx context.Context, + params *models.NewProfileParams, +) (*models.ProfileResponse, error) { + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + profile, ok := args.Get(0).(*models.ProfileResponse) + if !ok { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + return profile, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors +} + +// UpdateProfile mocks updating a profile. +func (m *MockSettingsService) UpdateProfile( + ctx context.Context, + params *models.UpdateProfileParams, +) (*models.ProfileResponse, error) { + args := m.Called(ctx, params) + if args.Get(0) == nil { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + profile, ok := args.Get(0).(*models.ProfileResponse) + if !ok { + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + return profile, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors +} + +// DeleteProfile mocks removing a profile. +func (m *MockSettingsService) DeleteProfile(ctx context.Context, profileID string) error { + args := m.Called(ctx, profileID) + return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors +} + +// SwitchProfile mocks switching the active profile. +func (m *MockSettingsService) SwitchProfile(ctx context.Context, params *models.SwitchProfileParams) error { + args := m.Called(ctx, params) + return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors +} + +// SetupGetProfiles configures the mock to return profiles. +func (m *MockSettingsService) SetupGetProfiles(profiles *models.ProfilesResponse) { + m.On("GetProfiles", mock.Anything).Return(profiles, nil) +} + +// SetupGetActiveProfile configures the mock to return the active profile +// (nil means the shared profile). +func (m *MockSettingsService) SetupGetActiveProfile(active *models.ActiveProfile) { + m.On("GetActiveProfile", mock.Anything).Return(active, nil) +} + +// GetClients mocks fetching paired clients. +func (m *MockSettingsService) GetClients(ctx context.Context) (*models.ClientsResponse, error) { + args := m.Called(ctx) + if clients, ok := args.Get(0).(*models.ClientsResponse); ok { + return clients, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors +} + +// StartClientPairing mocks starting client pairing. +func (m *MockSettingsService) StartClientPairing( + ctx context.Context, role string, +) (*models.ClientsPairStartResponse, error) { + args := m.Called(ctx, role) + if pairing, ok := args.Get(0).(*models.ClientsPairStartResponse); ok { + return pairing, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors + } + return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors +} + +// CancelClientPairing mocks canceling pairing. +func (m *MockSettingsService) CancelClientPairing(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors +} + +// DeleteClient mocks revoking a paired client. +func (m *MockSettingsService) DeleteClient(ctx context.Context, clientID string) error { + args := m.Called(ctx, clientID) + return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors +} diff --git a/pkg/ui/tui/profiles.go b/pkg/ui/tui/profiles.go new file mode 100644 index 000000000..080227e8b --- /dev/null +++ b/pkg/ui/tui/profiles.go @@ -0,0 +1,924 @@ +// 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 tui + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/rs/zerolog/log" +) + +const ( + profileListHelp = "Select profile to edit; use New to create or Switch to activate" + profileSwitchModalPage = "profile_switch_modal" + profilePINModalPage = "profile_pin_modal" + profilePINEditModalPage = "profile_pin_edit_modal" + profileSwitchIDModalPage = "profile_switch_id_modal" +) + +// profileCardZapScript builds the ZapScript written to a profile switch +// card. Profile lists keep the bearer credential hidden; the verified +// profile editor reveals it only through an intentional modal action. +func profileCardZapScript(switchID string) string { + return "**" + zapscript.ZapScriptCmdProfile + ":" + switchID +} + +func profileRoleLabel(role string) string { + switch role { + case "admin": + return "Admin" + case "member": + return "Member" + default: + return role + } +} + +func formatProfileLastUsed(lastUsedAt *int64, now time.Time) string { + if lastUsedAt == nil { + return "Never used" + } + used := time.Unix(*lastUsedAt, 0) + elapsed := now.Sub(used) + if elapsed < 0 { + elapsed = 0 + } + switch { + case elapsed < time.Minute: + return "Last used just now" + case elapsed < time.Hour: + return fmt.Sprintf("Last used %dm ago", int(elapsed.Minutes())) + case elapsed < 24*time.Hour: + return fmt.Sprintf("Last used %dh ago", int(elapsed.Hours())) + case elapsed < 7*24*time.Hour: + return fmt.Sprintf("Last used %dd ago", int(elapsed.Hours()/24)) + default: + return "Last used " + used.In(now.Location()).Format("Jan 2, 2006") + } +} + +func numericPINAcceptance(text string, _ rune) bool { + if len(text) > 8 { + return false + } + for _, char := range text { + if char < '0' || char > '9' { + return false + } + } + return true +} + +func validPIN(text string, required bool) bool { + if text == "" { + return !required + } + return len(text) >= 4 && numericPINAcceptance(text, 0) +} + +// showProfilePINModal prompts for the PIN used when switching from a visible +// profile list. Switch cards remain bearer credentials and do not use this +// path. +func showProfilePINModal( + pages *tview.Pages, + app *tview.Application, + profileName string, + onSubmit func(string), + onCancel func(), +) { + pinInput := tview.NewInputField(). + SetFieldWidth(8). + SetMaskCharacter('*'). + SetAcceptanceFunc(numericPINAcceptance) + SetInputLabel(pinInput, "PIN") + setupInputFieldFocus(pinInput) + + cleanup := func() { + pages.HidePage(profilePINModalPage) + pages.RemovePage(profilePINModalPage) + } + submit := func() { + pin := pinInput.GetText() + if !validPIN(pin, true) { + ShowErrorModal(pages, app, "PIN must be 4 to 8 digits", func() { + app.SetFocus(pinInput) + }) + return + } + cleanup() + onSubmit(pin) + } + cancel := func() { + cleanup() + if onCancel != nil { + onCancel() + } + } + + pinInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + if config.GetTUIConfig().OnScreenKeyboard { + ShowOSKModal(pages, app, pinInput.GetText(), func(text string) { + pinInput.SetText(text) + submit() + }, func() { + app.SetFocus(pinInput) + }) + return nil + } + submit() + return nil + case tcell.KeyEscape: + cancel() + return nil + default: + return event + } + }) + + content := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(tview.NewTextView().SetText("Enter PIN for "+profileName), 1, 0, false). + AddItem(pinInput, 1, 0, true). + AddItem(tview.NewTextView().SetText("Enter to switch · Esc to cancel"), 1, 0, false) + content.SetBorder(true) + SetBoxTitle(content.Box, "Profile PIN") + pages.AddPage(profilePINModalPage, CenterWidget(38, 5, content), true, true) + app.SetFocus(pinInput) +} + +func showProfilePINEditModal( + pages *tview.Pages, + app *tview.Application, + allowClear bool, + onSet func(string), + onClear func(), + onCancel func(), +) { + input := tview.NewInputField(). + SetFieldWidth(8). + SetMaskCharacter('*'). + SetAcceptanceFunc(numericPINAcceptance). + SetPlaceholder("new PIN") + SetInputLabel(input, "New PIN") + setupInputFieldFocus(input) + + cleanup := func() { + pages.HidePage(profilePINEditModalPage) + pages.RemovePage(profilePINEditModalPage) + } + cancel := func() { + cleanup() + if onCancel != nil { + onCancel() + } + } + submit := func() { + pin := input.GetText() + if !validPIN(pin, true) { + ShowErrorModal(pages, app, "PIN must be 4 to 8 digits", func() { + app.SetFocus(input) + }) + return + } + cleanup() + onSet(pin) + } + + buttons := NewButtonBar(app) + buttons.AddButton("Set PIN", submit) + if allowClear { + buttons.AddButton("Clear PIN", func() { + ShowConfirmModal(pages, app, "Clear this profile PIN?", func() { + cleanup() + onClear() + }, func() { + app.SetFocus(buttons) + }) + }) + } + buttons.AddButton("Cancel", cancel) + buttons.SetupNavigation(cancel). + SetOnUp(func() { app.SetFocus(input) }). + SetOnWrap(func() { app.SetFocus(input) }) + + input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + if config.GetTUIConfig().OnScreenKeyboard { + ShowOSKModal(pages, app, input.GetText(), func(text string) { + input.SetText(text) + submit() + }, func() { + app.SetFocus(input) + }) + return nil + } + submit() + return nil + case tcell.KeyTab, tcell.KeyDown: + app.SetFocus(buttons) + return nil + case tcell.KeyEscape: + cancel() + return nil + default: + return event + } + }) + + guidance := tview.NewTextView(). + SetText("Enter a new 4 to 8 digit PIN.") + content := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(guidance, 2, 0, false). + AddItem(input, 1, 0, true). + AddItem(buttons, 1, 0, false) + content.SetBorder(true) + SetBoxTitle(content.Box, "Profile PIN") + pages.AddPage(profilePINEditModalPage, CenterWidget(54, 6, content), true, true) + app.SetFocus(input) +} + +func showProfileSwitchIDModal( + pages *tview.Pages, + app *tview.Application, + switchID string, + onReset func(), + onCancel func(), +) { + modal := tview.NewModal(). + SetText("Switch ID:\n" + switchID + "\n\nResetting invalidates existing switch cards."). + AddButtons([]string{"Reset", "Cancel"}). + SetDoneFunc(func(buttonIndex int, _ string) { + pages.HidePage(profileSwitchIDModalPage) + pages.RemovePage(profileSwitchIDModalPage) + if buttonIndex == 0 { + onReset() + } else if onCancel != nil { + onCancel() + } + }) + SetBoxTitle(modal.Box, "Switch ID") + pages.AddPage(profileSwitchIDModalPage, modal, false, true) + app.SetFocus(modal) +} + +func hasAdminProfile(profiles []models.ProfileResponse) bool { + for i := range profiles { + if profiles[i].Role == "admin" { + return true + } + } + return false +} + +func promptProfileManagement( + svc SettingsService, + pages *tview.Pages, + app *tview.Application, + profiles []models.ProfileResponse, + onAuthorized func(), + onCancel func(), +) { + if len(profiles) == 0 { + onAuthorized() + return + } + + admins := make([]models.ProfileResponse, 0, len(profiles)) + for i := range profiles { + if profiles[i].Role == "admin" { + admins = append(admins, profiles[i]) + } + } + if len(admins) == 0 { + ShowErrorModal(pages, app, "No administrator profile exists. Create one to finish setup.", onCancel) + return + } + if len(admins) == 1 { + admin := &admins[0] + showProfilePINModal(pages, app, admin.Name, func(pin string) { + ctx, cancel := tuiContext() + err := svc.VerifyProfileManagement(ctx, admin.ProfileID, pin) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Administrator PIN was not accepted", func() { + promptProfileManagement(svc, pages, app, profiles, onAuthorized, onCancel) + }) + return + } + onAuthorized() + }, onCancel) + return + } + + menu := NewSettingsList(pages, PageProfilesList) + cleanup := func() { + pages.HidePage(profileSwitchModalPage) + pages.RemovePage(profileSwitchModalPage) + } + menu.SetRebuildPrevious(func() { + cleanup() + if onCancel != nil { + onCancel() + } + }) + menu.SetDynamicHelpMode(true) + for i := range admins { + admin := admins[i] + menu.AddAction(admin.Name, "", func() { + showProfilePINModal(pages, app, admin.Name, func(pin string) { + ctx, cancel := tuiContext() + err := svc.VerifyProfileManagement(ctx, admin.ProfileID, pin) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Administrator PIN was not accepted", func() { + app.SetFocus(menu.List) + }) + return + } + cleanup() + onAuthorized() + }, func() { + app.SetFocus(menu.List) + }) + }) + } + menu.SetBorder(true) + SetBoxTitle(menu.Box, "Administrator") + pages.AddPage(profileSwitchModalPage, CenterWidget(36, min(len(admins)+2, 12), menu.List), true, true) + app.SetFocus(menu.List) +} + +func switchProfileFromList( + svc SettingsService, + pages *tview.Pages, + app *tview.Application, + profile *models.ProfileResponse, + onComplete func(error), + onCancel func(), +) { + switchWithPIN := func(pin *string) { + ctx, cancel := tuiContext() + defer cancel() + profileID := profile.ProfileID + onComplete(svc.SwitchProfile(ctx, &models.SwitchProfileParams{ + ProfileID: &profileID, + PIN: pin, + })) + } + if !profile.HasPIN { + switchWithPIN(nil) + return + } + showProfilePINModal(pages, app, profile.Name, func(pin string) { + switchWithPIN(&pin) + }, onCancel) +} + +// showProfileSwitchModal displays a quick-switch picker: the shared +// profile plus every personal profile, with the active one marked. +// Selecting an entry switches it, prompting first when it has a PIN. The +// modal owns its own selection, so it needs no pre-selected list item. +func showProfileSwitchModal( + svc SettingsService, + pages *tview.Pages, + app *tview.Application, + profiles []models.ProfileResponse, + activeID string, + onDone func(switched bool), +) { + cleanup := func() { + pages.HidePage(profileSwitchModalPage) + pages.RemovePage(profileSwitchModalPage) + } + + menu := NewSettingsList(pages, PageProfilesList) + // Escape closes the modal instead of navigating pages. + menu.SetRebuildPrevious(func() { + cleanup() + onDone(false) + }) + // Names only, no inline descriptions: keeps the modal compact and, as + // everywhere in the profiles UI, no credentials on screen. + menu.SetDynamicHelpMode(true) + + doSwitch := func(params *models.SwitchProfileParams) { + cleanup() + ctx, cancel := tuiContext() + defer cancel() + if err := svc.SwitchProfile(ctx, params); err != nil { + log.Warn().Err(err).Msg("error switching profile") + ShowErrorModal(pages, app, "Failed to switch profile", func() { + onDone(false) + }) + return + } + onDone(true) + } + + sharedLabel := "Shared profile" + if activeID == "" { + sharedLabel += " (active)" + } + menu.AddAction(sharedLabel, "", func() { + doSwitch(nil) + }) + + for i := range profiles { + p := profiles[i] + label := p.Name + if p.ProfileID == activeID { + label += " (active)" + } + menu.AddAction(label, "", func() { + switchProfileFromList(svc, pages, app, &p, func(err error) { + if err != nil { + log.Warn().Err(err).Msg("error switching profile") + ShowErrorModal(pages, app, "Failed to switch profile", func() { + app.SetFocus(menu.List) + }) + return + } + cleanup() + onDone(true) + }, func() { + app.SetFocus(menu.List) + }) + }) + } + + menu.SetBorder(true) + SetBoxTitle(menu.Box, "Switch Profile") + + height := len(profiles) + 1 + 2 + if height > 12 { + height = 12 + } + centered := CenterWidget(36, height, menu.List) + pages.AddPage(profileSwitchModalPage, centered, true, true) + app.SetFocus(menu.List) +} + +// BuildProfilesPage creates the profiles list page. Selecting a profile +// verifies an administrator PIN before opening its settings editor. +func BuildProfilesPage(svc SettingsService, pages *tview.Pages, app *tview.Application) { + ctx, cancel := tuiContext() + profilesResp, err := svc.GetProfiles(ctx) + cancel() + if err != nil { + log.Warn().Err(err).Msg("error fetching profiles") + ShowErrorModal(pages, app, "Failed to load profiles", func() { + pages.SwitchToPage(PageMain) + }) + return + } + profiles := profilesResp.Profiles + + ctx, cancel = tuiContext() + active, err := svc.GetActiveProfile(ctx) + cancel() + if err != nil { + log.Warn().Err(err).Msg("error fetching active profile") + } + activeID := "" + if active != nil { + activeID = active.ProfileID + } + + frame := NewPageFrame(app). + SetTitle("Profiles") + + goBack := func() { + pages.SwitchToPage(PageMain) + } + frame.SetOnEscape(goBack) + + menu := NewSettingsList(pages, PageMain) + menu.SetDynamicHelpMode(true). + SetHelpCallback(func(desc string) { + frame.SetHelpText(desc) + }) + + buttonBar := NewButtonBar(app) + hasAdmin := hasAdminProfile(profiles) + buttonBar.AddButtonWithHelp("New", "Create a new profile", func() { + if !hasAdmin { + buildProfileEditPage(svc, pages, app, nil) + return + } + promptProfileManagement(svc, pages, app, profiles, func() { + buildProfileEditPage(svc, pages, app, nil) + }, func() { + app.SetFocus(buttonBar) + }) + }) + buttonBar.AddButtonWithHelp("Switch", "Quickly switch the active profile", func() { + showProfileSwitchModal(svc, pages, app, profiles, activeID, func(switched bool) { + if switched { + BuildProfilesPage(svc, pages, app) + return + } + app.SetFocus(buttonBar) + }) + }) + buttonBar.AddButtonWithHelp("Back", "Return to main menu", goBack) + buttonBar.SetupNavigation(goBack) + buttonBar.SetHelpCallback(func(help string) { + frame.SetHelpText(help) + }) + frame.SetButtonBar(buttonBar) + + for i := range profiles { + p := profiles[i] + label := p.Name + if p.ProfileID == activeID { + label += " (active)" + } + label += " · " + profileRoleLabel(p.Role) + " · " + formatProfileLastUsed(p.LastUsedAt, time.Now()) + menu.AddNavAction(label, profileListHelp, func() { + if !hasAdmin { + buildProfileEditPage(svc, pages, app, &p) + return + } + promptProfileManagement(svc, pages, app, profiles, func() { + buildProfileEditPage(svc, pages, app, &p) + }, func() { + app.SetFocus(menu.List) + }) + }) + } + if len(profiles) == 0 { + menu.AddAction("(no profiles)", profileListHelp, func() {}) + } + + frame.SetContent(menu.List) + menu.TriggerInitialHelp() + frame.SetupContentToButtonNavigation() + + pages.AddAndSwitchToPage(PageProfilesList, frame, true) +} + +// limitsEnabledStates are the tri-state options for the limits override: +// inherit the global setting, force on, or force off. +var ( + limitsEnabledStates = []string{"Inherit", "On", "Off"} + profileRoleStates = []string{"Admin", "Member"} +) + +// validDuration reports whether the text is empty (inherit) or a valid +// duration string ("0" means unlimited). +func validDuration(text string) bool { + if text == "" { + return true + } + _, err := time.ParseDuration(text) + return err == nil +} + +// buildProfileEditPage creates the profile settings editor. A nil profile +// creates a new one; saving or cancelling returns to the profile list. +// +//nolint:gocyclo,funlen // profile state, validation, and privileged actions stay together +func buildProfileEditPage( + svc SettingsService, + pages *tview.Pages, + app *tview.Application, + profile *models.ProfileResponse, +) { + isNew := profile == nil + ctx, cancel := tuiContext() + profilesResp, profilesErr := svc.GetProfiles(ctx) + cancel() + if profilesErr != nil { + ShowErrorModal(pages, app, "Failed to load profiles", func() { + BuildProfilesPage(svc, pages, app) + }) + return + } + adminSetup := !hasAdminProfile(profilesResp.Profiles) + + goBack := func() { + BuildProfilesPage(svc, pages, app) + } + + titleParts := []string{"Profiles", "New"} + if !isNew { + titleParts = []string{"Profiles", profile.Name, "Edit"} + } + frame := NewPageFrame(app). + SetTitle(titleParts...). + SetHelpText("Tab between fields, Save when done") + frame.SetOnEscape(goBack) + + name := "" + pin := "" + limitsState := 0 + roleState := 1 + if adminSetup { + roleState = 0 + } + daily := "" + session := "" + hasPIN := false + if !isNew { + name = profile.Name + if profile.Role == "admin" { + roleState = 0 + } + hasPIN = profile.HasPIN + if profile.LimitsEnabled != nil { + if *profile.LimitsEnabled { + limitsState = 1 + } else { + limitsState = 2 + } + } + if profile.DailyLimit != nil { + daily = *profile.DailyLimit + } + if profile.SessionLimit != nil { + session = *profile.SessionLimit + } + } + clearPIN := false + + buttonBar := NewButtonBar(app) + requestAdmin := func(onAuthorized func()) { + if adminSetup { + onAuthorized() + return + } + promptProfileManagement(svc, pages, app, profilesResp.Profiles, onAuthorized, func() { + app.SetFocus(buttonBar) + }) + } + + menu := NewSettingsList(pages, PageProfilesList) + menu.SetRebuildPrevious(goBack). + SetDynamicHelpMode(true). + SetHelpCallback(func(help string) { + frame.SetHelpText(help) + }) + menu.AddTextEdit("Name", "Profile display name", &name, &SettingsTextEditOptions{ + App: app, + FieldWidth: 30, + HelpText: "Enter the name shown in profile menus.", + Validate: func(text string) error { + if strings.TrimSpace(text) == "" { + return errors.New("name is required") + } + return nil + }, + }, nil) + menu.AddValueAction("PIN", "Set, replace, or clear the profile PIN", func() string { + switch { + case clearPIN: + return "Will be cleared" + case pin != "" || hasPIN: + return "******" + default: + return "Not set" + } + }, func() { + allowClear := roleState != 0 && (pin != "" || (!isNew && hasPIN)) + showProfilePINEditModal(pages, app, allowClear, func(newPIN string) { + pin = newPIN + clearPIN = false + menu.refreshAllItems(menu.GetCurrentItem()) + app.SetFocus(menu.List) + }, func() { + pin = "" + clearPIN = !isNew && hasPIN + menu.refreshAllItems(menu.GetCurrentItem()) + app.SetFocus(menu.List) + }, func() { + app.SetFocus(menu.List) + }) + }) + if !isNew { + menu.AddValueAction("Switch ID", "Reveal or reset the switch-card identifier", func() string { + return "******" + }, func() { + showProfileSwitchIDModal(pages, app, profile.SwitchID, func() { + reset := func() { + ctx, cancel := tuiContext() + updated, err := svc.UpdateProfile(ctx, &models.UpdateProfileParams{ + ProfileID: profile.ProfileID, + RegenerateSwitchID: true, + }) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to reset switch ID", func() { + app.SetFocus(menu.List) + }) + return + } + profile.SwitchID = updated.SwitchID + ShowInfoModal(pages, app, "Switch ID", "New switch ID issued.", func() { + app.SetFocus(menu.List) + }) + } + if adminSetup { + reset() + return + } + promptProfileManagement(svc, pages, app, profilesResp.Profiles, reset, func() { + app.SetFocus(menu.List) + }) + }, func() { + app.SetFocus(menu.List) + }) + }) + } + if !adminSetup { + menu.AddCycle("Role", "Administrator or member profile", profileRoleStates, &roleState, nil) + } + menu.AddCycle("Limits", "Override global playtime limits", limitsEnabledStates, &limitsState, nil) + durationValidator := func(text string) error { + if !validDuration(strings.TrimSpace(text)) { + return errors.New("duration must look like 2h30m; use 0 for unlimited") + } + return nil + } + limitEditOptions := func() *SettingsTextEditOptions { + return &SettingsTextEditOptions{ + App: app, + FieldWidth: 12, + EmptyDisplay: "Inherit", + HelpText: "Examples: 30m, 2h30m. Empty inherits; 0 means unlimited.", + Validate: durationValidator, + } + } + menu.AddTextEdit("Daily limit", "Empty inherits global; 0 means unlimited", &daily, limitEditOptions(), nil) + menu.AddTextEdit("Session limit", "Empty inherits global; 0 means unlimited", &session, limitEditOptions(), nil) + + doSave := func() { + nameVal := strings.TrimSpace(name) + if nameVal == "" { + ShowErrorModal(pages, app, "Name is required", func() { + app.SetFocus(menu.List) + }) + return + } + pinVal := strings.TrimSpace(pin) + if roleState == 0 && pinVal == "" && (isNew || !hasPIN || clearPIN) { + ShowErrorModal(pages, app, "Administrator profiles require a PIN", func() { + app.SetFocus(menu.List) + }) + return + } + if !validPIN(pinVal, false) { + ShowErrorModal(pages, app, "PIN must be 4 to 8 digits", func() { + app.SetFocus(menu.List) + }) + return + } + dailyVal := strings.TrimSpace(daily) + sessionVal := strings.TrimSpace(session) + if !validDuration(dailyVal) || !validDuration(sessionVal) { + ShowErrorModal(pages, app, "Limits must be durations like 2h30m (0 = unlimited)", func() { + app.SetFocus(menu.List) + }) + return + } + + var limitsEnabled *bool + switch limitsState { + case 1: + v := true + limitsEnabled = &v + case 2: + v := false + limitsEnabled = &v + } + var dailyPtr, sessionPtr *string + if dailyVal != "" { + dailyPtr = &dailyVal + } + if sessionVal != "" { + sessionPtr = &sessionVal + } + + role := strings.ToLower(profileRoleStates[roleState]) + if isNew { + params := &models.NewProfileParams{ + Name: nameVal, + Role: role, + LimitsEnabled: limitsEnabled, + DailyLimit: dailyPtr, + SessionLimit: sessionPtr, + } + if pinVal != "" { + params.PIN = &pinVal + } + ctx, cancel := tuiContext() + created, err := svc.NewProfile(ctx, params) + cancel() + if err != nil { + log.Warn().Err(err).Msg("error creating profile") + ShowErrorModal(pages, app, "Failed to create profile: "+err.Error(), func() { + app.SetFocus(menu.List) + }) + return + } + _ = created + BuildProfilesPage(svc, pages, app) + return + } + + // Clear-then-set: the form's state is the profile's full desired + // limit configuration, so empty fields become inherited again. + params := &models.UpdateProfileParams{ + ProfileID: profile.ProfileID, + Name: &nameVal, + Role: &role, + ClearLimits: true, + LimitsEnabled: limitsEnabled, + DailyLimit: dailyPtr, + SessionLimit: sessionPtr, + ClearPIN: clearPIN && pinVal == "", + } + if pinVal != "" { + params.PIN = &pinVal + } + ctx, cancel := tuiContext() + updated, err := svc.UpdateProfile(ctx, params) + cancel() + if err != nil { + log.Warn().Err(err).Msg("error updating profile") + ShowErrorModal(pages, app, "Failed to update profile: "+err.Error(), func() { + app.SetFocus(menu.List) + }) + return + } + _ = updated + BuildProfilesPage(svc, pages, app) + } + + buttonBar.AddButtonWithHelp("Save", "Save the profile", func() { + requestAdmin(func() { + doSave() + }) + }) + if !isNew { + buttonBar.AddButtonWithHelp("Write Card", "Write a switch card for this profile", func() { + requestAdmin(func() { + WriteTagWithModal(pages, app, svc, profileCardZapScript(profile.SwitchID), func(_ bool) { + app.SetFocus(buttonBar) + }) + }) + }) + buttonBar.AddButtonWithHelp("Delete", "Delete profile; saved data remains on disk", func() { + requestAdmin(func() { + ShowConfirmModal(pages, app, "Delete profile "+profile.Name+"?", func() { + ctx, cancel := tuiContext() + err := svc.DeleteProfile(ctx, profile.ProfileID) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to delete profile: "+err.Error(), nil) + return + } + BuildProfilesPage(svc, pages, app) + }, nil) + }) + }) + } + buttonBar.AddButtonWithHelp("Back", "Discard changes and return to profiles", goBack) + buttonBar.SetupNavigation(goBack) + buttonBar.SetHelpCallback(func(help string) { + frame.SetHelpText(help) + }) + + frame.SetContent(menu.List) + frame.SetButtonBar(buttonBar) + menu.TriggerInitialHelp() + frame.SetupContentToButtonNavigation() + + pages.AddAndSwitchToPage(PageProfilesEdit, frame, true) +} diff --git a/pkg/ui/tui/profiles_test.go b/pkg/ui/tui/profiles_test.go new file mode 100644 index 000000000..7a59fd181 --- /dev/null +++ b/pkg/ui/tui/profiles_test.go @@ -0,0 +1,650 @@ +// 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 tui + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestProfileCardZapScript(t *testing.T) { + t.Parallel() + assert.Equal(t, "**profile:corn-arm-truck", profileCardZapScript("corn-arm-truck")) +} + +func TestValidDuration(t *testing.T) { + t.Parallel() + assert.True(t, validDuration("")) + assert.True(t, validDuration("2h30m")) + assert.True(t, validDuration("0")) + assert.False(t, validDuration("2 hours")) + assert.False(t, validDuration("abc")) +} + +func TestPINValidation(t *testing.T) { + t.Parallel() + + assert.True(t, validPIN("", false)) + assert.False(t, validPIN("", true)) + assert.False(t, validPIN("123", false)) + assert.True(t, validPIN("1234", true)) + assert.True(t, validPIN("12345678", true)) + assert.False(t, validPIN("123456789", true)) + assert.False(t, numericPINAcceptance("12a4", '4')) +} + +func TestDefaultSettingsService_GetProfiles(t *testing.T) { + t.Parallel() + + mockClient := mocks.NewMockAPIClient() + mockClient.On("Call", mock.Anything, models.MethodProfiles, ""). + Return(`{"profiles":[{"profileId":"p1","name":"Kid A","switchId":"corn-arm-truck","hasPin":true}]}`, nil) + + svc := NewSettingsService(mockClient) + profiles, err := svc.GetProfiles(context.Background()) + + require.NoError(t, err) + require.Len(t, profiles.Profiles, 1) + assert.Equal(t, "Kid A", profiles.Profiles[0].Name) + assert.Equal(t, "corn-arm-truck", profiles.Profiles[0].SwitchID) + assert.True(t, profiles.Profiles[0].HasPIN) + mockClient.AssertExpectations(t) +} + +func TestDefaultSettingsService_GetActiveProfile(t *testing.T) { + t.Parallel() + + t.Run("active profile", func(t *testing.T) { + t.Parallel() + mockClient := mocks.NewMockAPIClient() + mockClient.On("Call", mock.Anything, models.MethodProfilesActive, ""). + Return(`{"profileId":"p1","name":"Kid A","hasPin":false}`, nil) + + svc := NewSettingsService(mockClient) + active, err := svc.GetActiveProfile(context.Background()) + require.NoError(t, err) + require.NotNil(t, active) + assert.Equal(t, "p1", active.ProfileID) + }) + + t.Run("shared profile is null", func(t *testing.T) { + t.Parallel() + mockClient := mocks.NewMockAPIClient() + mockClient.On("Call", mock.Anything, models.MethodProfilesActive, ""). + Return("null", nil) + + svc := NewSettingsService(mockClient) + active, err := svc.GetActiveProfile(context.Background()) + require.NoError(t, err) + assert.Nil(t, active) + }) +} + +func TestDefaultSettingsService_NewProfile(t *testing.T) { + t.Parallel() + + mockClient := mocks.NewMockAPIClient() + mockClient.On("Call", mock.Anything, models.MethodProfilesNew, + mock.MatchedBy(func(params string) bool { + return assert.ObjectsAreEqual(true, params != "") && + containsAll(params, `"name":"Kid A"`, `"pin":"1234"`) + })). + Return(`{"profileId":"p1","name":"Kid A","switchId":"corn-arm-truck","hasPin":true}`, nil) + + svc := NewSettingsService(mockClient) + pin := "1234" + created, err := svc.NewProfile(context.Background(), &models.NewProfileParams{ + Name: "Kid A", + PIN: &pin, + }) + + require.NoError(t, err) + assert.Equal(t, "corn-arm-truck", created.SwitchID) + mockClient.AssertExpectations(t) +} + +func TestDefaultSettingsService_ProfileManagementCredential(t *testing.T) { + t.Parallel() + + mockClient := mocks.NewMockAPIClient() + mockClient.On("Call", mock.Anything, models.MethodProfilesVerify, + mock.MatchedBy(func(params string) bool { + return containsAll(params, `"profileId":"p1"`, `"pin":"1234"`) + })).Return(`{"profileId":"p1","name":"Parent","role":"admin","hasPin":true}`, nil) + svc := NewSettingsService(mockClient) + require.NoError(t, svc.VerifyProfileManagement(context.Background(), "p1", "1234")) + mockClient.AssertExpectations(t) +} + +func TestDefaultSettingsService_DeleteAndSwitchProfile(t *testing.T) { + t.Parallel() + + mockClient := mocks.NewMockAPIClient() + mockClient.On("Call", mock.Anything, models.MethodProfilesDelete, `{"profileId":"p1"}`). + Return("null", nil) + mockClient.On("Call", mock.Anything, models.MethodProfilesSwitch, ""). + Return("null", nil) + mockClient.On("Call", mock.Anything, models.MethodProfilesSwitch, + mock.MatchedBy(func(params string) bool { + return containsAll(params, `"switchId":"corn-arm-truck"`) + })). + Return(`{"profileId":"p1","name":"Kid A","hasPin":false}`, nil) + + svc := NewSettingsService(mockClient) + require.NoError(t, svc.DeleteProfile(context.Background(), "p1")) + + switchID := "corn-arm-truck" + require.NoError(t, svc.SwitchProfile(context.Background(), &models.SwitchProfileParams{ + SwitchID: &switchID, + })) + + // Nil params deactivates: the request carries no params at all. + require.NoError(t, svc.SwitchProfile(context.Background(), nil)) + mockClient.AssertExpectations(t) +} + +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +} + +func testProfilesResponse() *models.ProfilesResponse { + limitsOn := true + daily := "2h" + return &models.ProfilesResponse{ + Profiles: []models.ProfileResponse{ + { + ProfileID: "p1", + Name: "Kid A", + Role: "admin", + SwitchID: "corn-arm-truck", + HasPIN: true, + LimitsEnabled: &limitsOn, + DailyLimit: &daily, + }, + { + ProfileID: "p2", + Name: "Kid B", + Role: "member", + SwitchID: "blue-fox-lamp", + }, + }, + } +} + +func TestFormatProfileLastUsed(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + unix := func(at time.Time) *int64 { + value := at.Unix() + return &value + } + tests := []struct { + lastUsedAt *int64 + name string + expected string + }{ + {name: "never", expected: "Never used"}, + {name: "just now", lastUsedAt: unix(now.Add(-30 * time.Second)), expected: "Last used just now"}, + {name: "minutes", lastUsedAt: unix(now.Add(-12 * time.Minute)), expected: "Last used 12m ago"}, + {name: "hours", lastUsedAt: unix(now.Add(-3 * time.Hour)), expected: "Last used 3h ago"}, + {name: "days", lastUsedAt: unix(now.Add(-4 * 24 * time.Hour)), expected: "Last used 4d ago"}, + {name: "date", lastUsedAt: unix(now.Add(-8 * 24 * time.Hour)), expected: "Last used Jul 7, 2026"}, + {name: "future clock", lastUsedAt: unix(now.Add(time.Hour)), expected: "Last used just now"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, formatProfileLastUsed(tt.lastUsedAt, now)) + }) + } +} + +func TestBuildProfilesSettingsMenu_ToggleRequiresAdminPIN_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageSettingsMain, tview.NewTextView().SetText("Settings"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.On("GetSettings", mock.Anything).Return(&models.SettingsResponse{ + ProfilesRequireForLaunch: false, + }, nil) + mockSvc.SetupGetProfiles(testProfilesResponse()) + mockSvc.On("VerifyProfileManagement", mock.Anything, "p1", "1234").Return(nil) + updated := make(chan struct{}, 1) + mockSvc.On("UpdateSettings", mock.Anything, mock.MatchedBy(func(params *models.UpdateSettingsParams) bool { + return params.ProfilesRequireForLaunch != nil && *params.ProfilesRequireForLaunch + })).Run(func(mock.Arguments) { + updated <- struct{}{} + }).Return(nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + buildProfilesSettingsMenu(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("Require profile for launch", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Block launches until a profile is active")) + runner.SimulateEnter() + require.True(t, runner.WaitForText("Profile PIN", 100*time.Millisecond)) + runner.SimulateString("1234") + runner.SimulateEnter() + select { + case <-updated: + case <-time.After(time.Second): + t.Fatal("timed out waiting for profile setting update") + } + mockSvc.AssertExpectations(t) +} + +func TestBuildProfilesPage_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + profiles := testProfilesResponse() + lastUsedAt := time.Now().Add(-3 * time.Hour).Unix() + profiles.Profiles[0].LastUsedAt = &lastUsedAt + mockSvc.SetupGetProfiles(profiles) + mockSvc.SetupGetActiveProfile(&models.ActiveProfile{ProfileID: "p1", Name: "Kid A"}) + + runner.Start(pages) + runner.Draw() + + runner.QueueUpdateDraw(func() { + BuildProfilesPage(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("Profiles", 100*time.Millisecond), "Profiles title should appear") + assert.True(t, runner.ContainsText("Kid A"), "profile names should be listed") + assert.True(t, runner.ContainsText("Kid B"), "profile names should be listed") + assert.True(t, runner.ContainsText("active"), "active profile should be marked") + assert.True(t, runner.ContainsText("Admin"), "administrator role should be title-cased in the row") + assert.True(t, runner.ContainsText("Member"), "member role should be title-cased in the row") + assert.True(t, runner.ContainsText("Last used 3h ago"), "recent usage should be shown in the row") + assert.True(t, runner.ContainsText("Never used"), "profiles without usage should be identified") + assert.True(t, runner.ContainsText("Select profile to edit"), "profile rows should use static page help") + assert.True(t, runner.ContainsText("New"), "stable New action should be visible") + // Bearer credentials and PIN status must never render in the list: a + // bystander watching someone use this menu must not learn them. + assert.False(t, runner.ContainsText("corn-arm-truck"), "switch IDs must not be displayed") + assert.False(t, runner.ContainsText("PIN"), "PIN status must not be displayed") +} + +func TestBuildProfilesPage_SelectPromptsThenOpensEdit_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + profiles := testProfilesResponse() + mockSvc.SetupGetProfiles(profiles) + mockSvc.SetupGetActiveProfile(nil) + mockSvc.On("VerifyProfileManagement", mock.Anything, "p1", "1234").Return(nil) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + BuildProfilesPage(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("Kid A", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("New")) + assert.True(t, runner.ContainsText("Switch")) + assert.True(t, runner.ContainsText("Back")) + runner.SimulateEnter() + require.True(t, runner.WaitForText("Profile PIN", 100*time.Millisecond)) + runner.SimulateString("1234") + runner.SimulateEnter() + require.True(t, runner.WaitForText("Edit", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Write Card")) + assert.True(t, runner.ContainsText("Switch ID: ******")) + assert.False(t, runner.ContainsText("New ID")) + assert.True(t, runner.ContainsText("Delete")) + mockSvc.AssertExpectations(t) +} + +func TestProfileSwitchModal_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + // Selecting the first entry (shared profile) deactivates: nil params. + mockSvc.On("SwitchProfile", mock.Anything, (*models.SwitchProfileParams)(nil)).Return(nil) + + runner.Start(pages) + runner.Draw() + + switched := make(chan bool, 1) + runner.QueueUpdateDraw(func() { + showProfileSwitchModal(mockSvc, pages, runner.App(), + testProfilesResponse().Profiles, "p1", func(ok bool) { + switched <- ok + }) + }) + + require.True(t, runner.WaitForText("Switch Profile", 100*time.Millisecond), "modal title should appear") + assert.True(t, runner.ContainsText("Shared profile"), "shared profile entry should be listed") + assert.True(t, runner.ContainsText("Kid A (active)"), "active profile should be marked") + assert.True(t, runner.ContainsText("Kid B"), "profiles should be listed") + assert.False(t, runner.ContainsText("corn-arm-truck"), "switch IDs must not be displayed") + + // Enter on the first entry (shared) switches and closes the modal. + runner.SimulateEnter() + select { + case ok := <-switched: + assert.True(t, ok, "switch should be reported successful") + case <-time.After(time.Second): + t.Fatal("timed out waiting for switch callback") + } + mockSvc.AssertExpectations(t) +} + +func TestProfileSwitchModal_ProtectedProfileRequiresPIN_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.On("SwitchProfile", mock.Anything, mock.MatchedBy(func(params *models.SwitchProfileParams) bool { + return params != nil && params.ProfileID != nil && *params.ProfileID == "p1" && + params.SwitchID == nil && params.PIN != nil && *params.PIN == "1234" + })).Return(nil) + + runner.Start(pages) + runner.Draw() + + switched := make(chan bool, 1) + runner.QueueUpdateDraw(func() { + showProfileSwitchModal(mockSvc, pages, runner.App(), + testProfilesResponse().Profiles, "", func(ok bool) { + switched <- ok + }) + }) + + // Shared is first; move to protected Kid A. Selecting it opens a PIN + // prompt and does not send its bearer switch ID behind the user's back. + runner.SimulateArrowDown() + runner.SimulateEnter() + require.True(t, runner.WaitForText("Profile PIN", 100*time.Millisecond)) + mockSvc.AssertNotCalled(t, "SwitchProfile", mock.Anything, mock.Anything) + + // Non-digits are rejected by the field. A short PIN remains local and + // displays a validation error instead of making an API request. + runner.SimulateString("a123") + assert.False(t, runner.ContainsText("123"), "entered PIN must be masked") + runner.SimulateEnter() + require.True(t, runner.WaitForText("PIN must be 4 to 8 digits", 100*time.Millisecond)) + mockSvc.AssertNotCalled(t, "SwitchProfile", mock.Anything, mock.Anything) + runner.SimulateEnter() // dismiss validation error + + runner.SimulateRune('4') + runner.SimulateEnter() + select { + case ok := <-switched: + assert.True(t, ok) + case <-time.After(time.Second): + t.Fatal("timed out waiting for protected profile switch") + } + mockSvc.AssertExpectations(t) +} + +func TestPromptProfileManagement_NoProfilesAllowsLocalAction(t *testing.T) { + t.Parallel() + + authorized := false + promptProfileManagement(NewMockSettingsService(), nil, nil, nil, func() { + authorized = true + }, nil) + + assert.True(t, authorized) +} + +func TestPromptProfileManagement_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + profiles := testProfilesResponse().Profiles + mockSvc.On("VerifyProfileManagement", mock.Anything, "p1", "1234").Return(nil) + + runner.Start(pages) + runner.Draw() + completed := make(chan struct{}, 1) + runner.QueueUpdateDraw(func() { + promptProfileManagement(mockSvc, pages, runner.App(), profiles, func() { + completed <- struct{}{} + }, nil) + }) + + require.True(t, runner.WaitForText("Profile PIN", 100*time.Millisecond)) + runner.SimulateString("1234") + runner.SimulateEnter() + select { + case <-completed: + case <-time.After(time.Second): + t.Fatal("timed out waiting for administrator credential") + } + mockSvc.AssertExpectations(t) +} + +func TestPromptProfileManagement_MultipleAdminsShowsChooser_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + profiles := testProfilesResponse().Profiles + profiles[1].Role = "admin" + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + promptProfileManagement(mockSvc, pages, runner.App(), profiles, func() {}, nil) + }) + + require.True(t, runner.WaitForText("Administrator", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Kid A")) + assert.True(t, runner.ContainsText("Kid B")) +} + +func TestProfilePINEditModal_ShowsContextualClear_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + runner.Start(pages) + runner.Draw() + + runner.QueueUpdateDraw(func() { + showProfilePINEditModal(pages, runner.App(), true, func(string) {}, func() {}, nil) + }) + + require.True(t, runner.WaitForText("Profile PIN", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Set PIN")) + assert.True(t, runner.ContainsText("Clear PIN")) + assert.True(t, runner.ContainsText("Cancel")) +} + +func TestProfileSwitchIDModal_RevealsOnlyOnSelection_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + runner.Start(pages) + runner.Draw() + + runner.QueueUpdateDraw(func() { + showProfileSwitchIDModal(pages, runner.App(), "corn-arm-truck", func() {}, nil) + }) + + require.True(t, runner.WaitForText("corn-arm-truck", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Reset")) +} + +func TestBuildProfilesPage_EmptyState_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.SetupGetProfiles(&models.ProfilesResponse{}) + mockSvc.SetupGetActiveProfile(nil) + + runner.Start(pages) + runner.Draw() + + runner.QueueUpdateDraw(func() { + BuildProfilesPage(mockSvc, pages, runner.App()) + }) + + require.True(t, runner.WaitForText("no profiles", 100*time.Millisecond), + "empty state should explain profile setup") + assert.True(t, runner.ContainsText("New"), "stable New action should be visible") +} + +func TestBuildProfileEditPage_ProfileLookupFailureFailsClosed_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + mockSvc := NewMockSettingsService() + mockSvc.On("GetProfiles", mock.Anything).Return(nil, errors.New("database unavailable")) + + runner.Start(pages) + runner.Draw() + runner.QueueUpdateDraw(func() { + buildProfileEditPage(mockSvc, pages, runner.App(), nil) + }) + + require.True(t, runner.WaitForText("Failed to load profiles", 100*time.Millisecond)) + assert.False(t, runner.ContainsText("initial administrator")) + mockSvc.AssertNotCalled(t, "NewProfile", mock.Anything, mock.Anything) +} + +func TestBuildProfileEditPage_New_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + mockSvc.SetupGetProfiles(&models.ProfilesResponse{}) + + runner.Start(pages) + runner.Draw() + + runner.QueueUpdateDraw(func() { + buildProfileEditPage(mockSvc, pages, runner.App(), nil) + }) + + require.True(t, runner.WaitForText("New", 100*time.Millisecond), "New title should appear") + assert.True(t, runner.ContainsText("Name"), "Name field should be visible") + assert.True(t, runner.ContainsText("PIN"), "PIN field should be visible") + assert.True(t, runner.ContainsText("Limits"), "Limits toggle should be visible") + assert.True(t, runner.ContainsText("Daily limit"), "Daily limit field should be visible") + assert.True(t, runner.ContainsText("Session limit"), "Session limit field should be visible") + assert.True(t, runner.ContainsText("Save"), "Save button should be visible") +} + +func TestBuildProfileEditPage_Edit_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + pages.AddPage(PageMain, tview.NewTextView().SetText("Main"), true, false) + + mockSvc := NewMockSettingsService() + profiles := testProfilesResponse() + mockSvc.SetupGetProfiles(profiles) + + runner.Start(pages) + runner.Draw() + + runner.QueueUpdateDraw(func() { + buildProfileEditPage(mockSvc, pages, runner.App(), &profiles.Profiles[0]) + }) + + require.True(t, runner.WaitForText("Edit", 100*time.Millisecond), "Edit title should appear") + assert.True(t, runner.ContainsText("Kid A"), "existing name should be pre-filled") + assert.True(t, runner.ContainsText("PIN: ******"), "existing PIN should use a fixed mask") + assert.True(t, runner.ContainsText("Switch ID: ******"), "switch ID should be masked until selected") + assert.False(t, runner.ContainsText("corn-arm-truck"), "switch ID should not appear directly in editor") + assert.False(t, runner.ContainsText("Current PIN"), "implementation state should not be exposed") + assert.True(t, runner.ContainsText("2h"), "existing daily limit should be pre-filled") +} diff --git a/pkg/ui/tui/settings.go b/pkg/ui/tui/settings.go index 06549b8fa..3b013326c 100644 --- a/pkg/ui/tui/settings.go +++ b/pkg/ui/tui/settings.go @@ -104,6 +104,12 @@ func BuildSettingsMainMenuWithService( AddNavAction("TUI", "Theme and display preferences", func() { buildTUISettingsMenu(pages, app, pl, rebuildSettingsMain) }). + AddNavAction("Profiles", "Profile-wide launch behavior", func() { + buildProfilesSettingsMenu(svc, pages, app) + }). + AddNavAction("Clients", "Pair and revoke client devices", func() { + BuildClientsPage(svc, pages, app) + }). AddNavAction("Advanced", "Debug and system options", func() { buildAdvancedSettingsMenu(svc, pages, app) }). @@ -122,6 +128,75 @@ func BuildSettingsMainMenuWithService( pages.AddAndSwitchToPage(PageSettingsMain, frame, true) } +// buildProfilesSettingsMenu creates profile-wide behavior settings. +func buildProfilesSettingsMenu(svc SettingsService, pages *tview.Pages, app *tview.Application) { + ctx, cancel := tuiContext() + settings, err := svc.GetSettings(ctx) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to load profile settings", func() { + pages.SwitchToPage(PageSettingsMain) + }) + return + } + ctx, cancel = tuiContext() + profiles, err := svc.GetProfiles(ctx) + cancel() + if err != nil { + ShowErrorModal(pages, app, "Failed to load profiles", func() { + pages.SwitchToPage(PageSettingsMain) + }) + return + } + + frame := NewPageFrame(app).SetTitle("Settings", "Profiles") + goBack := func() { pages.SwitchToPage(PageSettingsMain) } + frame.SetOnEscape(goBack) + buttonBar := NewButtonBar(app). + AddButton("Back", goBack). + SetupNavigation(goBack) + frame.SetButtonBar(buttonBar) + + requireForLaunch := settings.ProfilesRequireForLaunch + menu := NewSettingsList(pages, PageSettingsMain) + menu.SetDynamicHelpMode(true). + SetHelpCallback(func(help string) { + frame.SetHelpText(help) + }) + menu.AddToggle( + "Require profile for launch", + "Block launches until a profile is active; switch cards still work", + &requireForLaunch, + func(value bool) { + restore := func() { + requireForLaunch = !value + menu.refreshAllItems(menu.GetCurrentItem()) + app.SetFocus(menu.List) + } + promptProfileManagement(svc, pages, app, profiles.Profiles, func() { + ctx, cancel := tuiContext() + err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{ + ProfilesRequireForLaunch: &value, + }) + cancel() + if err != nil { + restore() + ShowErrorModal(pages, app, "Failed to save profile settings", func() { + app.SetFocus(menu.List) + }) + return + } + app.SetFocus(menu.List) + }, restore) + }, + ) + + frame.SetContent(menu.List) + menu.TriggerInitialHelp() + frame.SetupContentToButtonNavigation() + pages.AddAndSwitchToPage(PageSettingsProfiles, frame, true) +} + // buildAudioSettingsMenu creates the audio settings submenu. func buildAudioSettingsMenu(svc SettingsService, pages *tview.Pages, app *tview.Application) { ctx, cancel := tuiContext() diff --git a/pkg/ui/tui/settings_components.go b/pkg/ui/tui/settings_components.go index d2ed1e418..32860c592 100644 --- a/pkg/ui/tui/settings_components.go +++ b/pkg/ui/tui/settings_components.go @@ -27,6 +27,37 @@ import ( "github.com/rivo/tview" ) +const settingsTextEditModalPage = "settings_text_edit_modal" + +// SettingsTextEditOptions configures a text-value row and its edit modal. +type SettingsTextEditOptions struct { + App *tview.Application + AcceptanceFunc func(string, rune) bool + DisplayValue func(string) string + Validate func(string) error + Title string + HelpText string + Placeholder string + EmptyDisplay string + FieldWidth int + MaskCharacter rune +} + +// settingsItem stores data for a single list item. +type settingsItem struct { + toggleValue *bool + toggleOnChange func(bool) + textValue *string + textDisplay func(string) string + valueDisplay func() string + cycleIndex *int + cycleOnChange func(string, int) + itemType string + label string + description string + cycleOptions []string +} + func setupInputFieldFocus(field *tview.InputField) *tview.InputField { field.SetFieldBackgroundColor(CurrentTheme().FieldUnfocusedBg) field.SetFocusFunc(func() { @@ -115,6 +146,19 @@ func formatToggle(value bool, label string, selected bool) string { } // formatCycle renders a cycle value. When selected, label and value are highlighted. +func formatTextValue(label, value string, selected bool) string { + t := CurrentTheme() + escapedValue := tview.Escape(value) + if selected { + return fmt.Sprintf("[%s:%s]- [%s:%s]%s: %s[-:%s]", + t.AccentColorName, t.BgColorName, + t.HighlightFgName, t.HighlightBgName, label, escapedValue, t.BgColorName) + } + return fmt.Sprintf("[%s:%s]- [%s:%s]%s: %s[-:-]", + t.AccentColorName, t.BgColorName, + t.TextColorName, t.BgColorName, label, escapedValue) +} + func formatCycle(label, currentValue string, selected bool) string { t := CurrentTheme() if selected { @@ -158,16 +202,6 @@ func formatDesc(desc string) string { return " " + desc } -// settingsItem stores data for a single list item. -type settingsItem struct { - toggleValue *bool - cycleIndex *int - itemType string - label string - description string - cycleOptions []string -} - // SettingsList wraps a tview.List with consistent navigation and manual highlight management. type SettingsList struct { *tview.List @@ -212,11 +246,43 @@ func NewSettingsList(pages *tview.Pages, previousPage string) *SettingsList { }) list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { + switch event.Key() { + case tcell.KeyEscape: sl.goBack() return nil + case tcell.KeyLeft, tcell.KeyRight: + index := sl.GetCurrentItem() + if index < 0 || index >= len(sl.items) { + return event + } + item := &sl.items[index] + switch item.itemType { + case "toggle": + value := event.Key() == tcell.KeyRight + if *item.toggleValue != value { + *item.toggleValue = value + if item.toggleOnChange != nil { + item.toggleOnChange(value) + } + } + case "cycle": + delta := 1 + if event.Key() == tcell.KeyLeft { + delta = -1 + } + count := len(item.cycleOptions) + *item.cycleIndex = (*item.cycleIndex + delta + count) % count + if item.cycleOnChange != nil { + item.cycleOnChange(item.cycleOptions[*item.cycleIndex], *item.cycleIndex) + } + default: + return event + } + sl.refreshAllItems(index) + return nil + default: + return event } - return event }) return sl @@ -281,7 +347,8 @@ func (sl *SettingsList) goBack() { // refreshAllItems updates all items to reflect current selection state. func (sl *SettingsList) refreshAllItems(selectedIndex int) { - for i, item := range sl.items { + for i := range sl.items { + item := &sl.items[i] // Only show highlight when the list has focus selected := sl.hasFocus && i == selectedIndex desc := formatDesc(item.description) @@ -292,6 +359,10 @@ func (sl *SettingsList) refreshAllItems(selectedIndex int) { mainText = formatToggle(*item.toggleValue, item.label, selected) case "cycle": mainText = formatCycle(item.label, item.cycleOptions[*item.cycleIndex], selected) + case "text": + mainText = formatTextValue(item.label, item.textDisplay(*item.textValue), selected) + case "value": + mainText = formatTextValue(item.label, item.valueDisplay(), selected) case "action": mainText = formatAction(item.label, selected) case "nav": @@ -318,10 +389,11 @@ func (sl *SettingsList) AddToggle( selected := index == 0 // First item is selected by default sl.items = append(sl.items, settingsItem{ - itemType: "toggle", - label: label, - description: description, - toggleValue: value, + itemType: "toggle", + label: label, + description: description, + toggleValue: value, + toggleOnChange: onChange, }) sl.AddItem(formatToggle(*value, label, selected), formatDesc(description), 0, func() { @@ -345,22 +417,172 @@ func (sl *SettingsList) AddCycle( selected := index == 0 sl.items = append(sl.items, settingsItem{ - itemType: "cycle", - label: label, - description: description, - cycleOptions: options, - cycleIndex: currentIndex, + itemType: "cycle", + label: label, + description: description, + cycleOptions: options, + cycleIndex: currentIndex, + cycleOnChange: onChange, }) sl.AddItem(formatCycle(label, options[*currentIndex], selected), formatDesc(description), 0, func() { *currentIndex = (*currentIndex + 1) % len(options) - onChange(options[*currentIndex], *currentIndex) + if onChange != nil { + onChange(options[*currentIndex], *currentIndex) + } sl.refreshAllItems(sl.GetCurrentItem()) }) return sl } +// AddValueAction adds a read-only value row that opens a contextual action. +// The display callback is evaluated on every refresh so staged state is shown. +func (sl *SettingsList) AddValueAction( + label string, + description string, + display func() string, + action func(), +) *SettingsList { + index := sl.GetItemCount() + selected := index == 0 + sl.items = append(sl.items, settingsItem{ + itemType: "value", + label: label, + description: description, + valueDisplay: display, + }) + sl.AddItem(formatTextValue(label, display(), selected), formatDesc(description), 0, action) + return sl +} + +// AddTextEdit adds a text-value row that opens a focused edit modal. +func (sl *SettingsList) AddTextEdit( + label string, + description string, + value *string, + options *SettingsTextEditOptions, + onChange func(string), +) *SettingsList { + if options == nil || options.App == nil { + panic("SettingsTextEditOptions.App is required") + } + if options.Title == "" { + options.Title = "Edit " + label + } + if options.FieldWidth <= 0 { + options.FieldWidth = 30 + } + display := options.DisplayValue + if display == nil { + display = func(text string) string { + if text == "" && options.EmptyDisplay != "" { + return options.EmptyDisplay + } + return text + } + } + + index := sl.GetItemCount() + selected := index == 0 + item := settingsItem{ + itemType: "text", + label: label, + description: description, + textValue: value, + textDisplay: display, + } + sl.items = append(sl.items, item) + sl.AddItem(formatTextValue(label, display(*value), selected), formatDesc(description), 0, func() { + sl.showTextEditModal(label, value, options, onChange) + }) + return sl +} + +func (sl *SettingsList) showTextEditModal( + label string, + value *string, + options *SettingsTextEditOptions, + onChange func(string), +) { + input := tview.NewInputField(). + SetText(*value). + SetFieldWidth(options.FieldWidth). + SetPlaceholder(options.Placeholder) + if options.MaskCharacter != 0 { + input.SetMaskCharacter(options.MaskCharacter) + } + if options.AcceptanceFunc != nil { + input.SetAcceptanceFunc(options.AcceptanceFunc) + } + SetInputLabel(input, label) + setupInputFieldFocus(input) + + cleanup := func() { + sl.pages.HidePage(settingsTextEditModalPage) + sl.pages.RemovePage(settingsTextEditModalPage) + } + commit := func() { + text := input.GetText() + if options.Validate != nil { + if err := options.Validate(text); err != nil { + ShowErrorModal(sl.pages, options.App, err.Error(), func() { + options.App.SetFocus(input) + }) + return + } + } + *value = text + if onChange != nil { + onChange(text) + } + cleanup() + sl.refreshAllItems(sl.GetCurrentItem()) + options.App.SetFocus(sl.List) + } + cancel := func() { + cleanup() + options.App.SetFocus(sl.List) + } + + input.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEnter: + if config.GetTUIConfig().OnScreenKeyboard { + ShowOSKModal(sl.pages, options.App, input.GetText(), func(text string) { + input.SetText(text) + commit() + }, func() { + options.App.SetFocus(input) + }) + return nil + } + commit() + return nil + case tcell.KeyEscape: + cancel() + return nil + default: + return event + } + }) + + content := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(input, 1, 0, true) + modalWidth := max(options.FieldWidth+8, 32) + modalHeight := 4 + if options.HelpText != "" { + content.AddItem(tview.NewTextView().SetWordWrap(true).SetText(options.HelpText), 2, 0, false) + modalWidth = max(modalWidth, 56) + modalHeight += 2 + } + content.AddItem(tview.NewTextView().SetText("Enter to save · Esc to cancel"), 1, 0, false) + content.SetBorder(true) + SetBoxTitle(content.Box, options.Title) + sl.pages.AddPage(settingsTextEditModalPage, CenterWidget(modalWidth, modalHeight, content), true, true) + options.App.SetFocus(input) +} + // AddAction adds a simple action item (like a submenu link or button). func (sl *SettingsList) AddAction( label string, diff --git a/pkg/ui/tui/settings_components_test.go b/pkg/ui/tui/settings_components_test.go index f9aa387cd..47c87f9ea 100644 --- a/pkg/ui/tui/settings_components_test.go +++ b/pkg/ui/tui/settings_components_test.go @@ -28,6 +28,16 @@ import ( "github.com/stretchr/testify/require" ) +func TestFormatTextValue_EscapesDynamicValue(t *testing.T) { + t.Parallel() + value := "[red]Injected[-]" + + formatted := formatTextValue("Name", value, false) + + assert.Contains(t, formatted, tview.Escape(value)) + assert.NotContains(t, formatted, "Name: "+value) +} + func TestFormatToggle(t *testing.T) { t.Parallel() diff --git a/pkg/ui/tui/settings_integration_test.go b/pkg/ui/tui/settings_integration_test.go index d1df3434b..b2ae2a92c 100644 --- a/pkg/ui/tui/settings_integration_test.go +++ b/pkg/ui/tui/settings_integration_test.go @@ -97,6 +97,71 @@ func TestSettingsList_ToggleActivation_Integration(t *testing.T) { assert.True(t, toggleValue, "toggle value should be true after activation") } +func TestSettingsList_TextEditActivation_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + + pages := tview.NewPages() + sl := NewSettingsList(pages, "main") + value := "" + changed := make(chan string, 1) + sl.AddTextEdit("Name", "Profile name", &value, &SettingsTextEditOptions{ + App: runner.App(), + EmptyDisplay: "Not set", + HelpText: "Enter the profile display name.", + }, func(text string) { + changed <- text + }) + + pages.AddPage("settings", sl.List, true, true) + runner.Start(pages) + runner.Draw() + + assert.True(t, runner.ContainsText("Name: Not set")) + runner.SimulateEnter() + require.True(t, runner.WaitForText("Edit Name", 100*time.Millisecond)) + assert.True(t, runner.ContainsText("Enter the profile display name.")) + runner.SimulateString("Kid A") + runner.SimulateEnter() + + select { + case got := <-changed: + assert.Equal(t, "Kid A", got) + case <-time.After(time.Second): + t.Fatal("timed out waiting for text edit") + } + require.True(t, runner.WaitForText("Name: Kid A", 100*time.Millisecond)) + assert.Equal(t, "Kid A", value) +} + +func TestSettingsList_LeftRightAdjustValues_Integration(t *testing.T) { + t.Parallel() + + runner := NewTestAppRunner(t, 80, 25) + defer runner.Stop() + pages := tview.NewPages() + sl := NewSettingsList(pages, "main") + toggle := false + cycle := 1 + sl.AddToggle("Enabled", "Toggle value", &toggle, func(bool) {}) + sl.AddCycle("Mode", "Cycle value", []string{"A", "B", "C"}, &cycle, func(string, int) {}) + pages.AddPage("settings", sl.List, true, true) + runner.Start(pages) + runner.Draw() + + runner.SimulateArrowRight() + assert.True(t, toggle) + runner.SimulateArrowLeft() + assert.False(t, toggle) + runner.SimulateArrowDown() + runner.SimulateArrowLeft() + assert.Equal(t, 0, cycle) + runner.SimulateArrowRight() + assert.Equal(t, 1, cycle) +} + func TestSettingsList_EscapeGoesBack_Integration(t *testing.T) { t.Parallel() diff --git a/pkg/ui/tui/settings_service.go b/pkg/ui/tui/settings_service.go index fdce730e7..94428183c 100644 --- a/pkg/ui/tui/settings_service.go +++ b/pkg/ui/tui/settings_service.go @@ -22,6 +22,7 @@ package tui import ( "context" "encoding/json" + "errors" "fmt" "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/client" @@ -53,6 +54,42 @@ type SettingsService interface { // SearchMedia searches for media matching the given parameters. SearchMedia(ctx context.Context, params models.SearchParams) (*models.SearchResults, error) + + // GetProfiles fetches profiles without privileged switch IDs. + GetProfiles(ctx context.Context) (*models.ProfilesResponse, error) + + // VerifyProfileManagement verifies one admin credential as a UI gate. + VerifyProfileManagement(ctx context.Context, profileID, pin string) error + + // GetActiveProfile fetches the active profile, or nil when the device + // is on the shared profile. + GetActiveProfile(ctx context.Context) (*models.ActiveProfile, error) + + // NewProfile creates a profile and returns it (including its + // generated switch ID). + NewProfile(ctx context.Context, params *models.NewProfileParams) (*models.ProfileResponse, error) + + // UpdateProfile updates a profile. + UpdateProfile(ctx context.Context, params *models.UpdateProfileParams) (*models.ProfileResponse, error) + + // DeleteProfile removes a profile. + DeleteProfile(ctx context.Context, profileID string) error + + // SwitchProfile switches the active profile. Nil params deactivates + // (switches to the shared profile). + SwitchProfile(ctx context.Context, params *models.SwitchProfileParams) error + + // GetClients fetches paired clients. + GetClients(ctx context.Context) (*models.ClientsResponse, error) + + // StartClientPairing starts a local pairing approval flow. + StartClientPairing(ctx context.Context, role string) (*models.ClientsPairStartResponse, error) + + // CancelClientPairing cancels an active pairing flow. + CancelClientPairing(ctx context.Context) error + + // DeleteClient revokes a paired client. + DeleteClient(ctx context.Context, clientID string) error } // DefaultSettingsService implements SettingsService using an APIClient. @@ -172,3 +209,181 @@ func (s *DefaultSettingsService) SearchMedia( } return &results, nil } + +// VerifyProfileManagement checks one administrator credential without +// retaining any client-side authorization state. +func (s *DefaultSettingsService) VerifyProfileManagement( + ctx context.Context, profileID, pin string, +) error { + data, err := json.Marshal(models.VerifyProfileParams{ProfileID: &profileID, PIN: &pin}) + if err != nil { + return fmt.Errorf("failed to marshal management verification: %w", err) + } + resp, err := s.apiClient.Call(ctx, models.MethodProfilesVerify, string(data)) + if err != nil { + return fmt.Errorf("failed to verify profile management: %w", err) + } + var verified models.ProfileVerifyResponse + if err := json.Unmarshal([]byte(resp), &verified); err != nil { + return fmt.Errorf("failed to parse management verification: %w", err) + } + if verified.Role != "admin" { + return errors.New("administrator profile required") + } + return nil +} + +// GetProfiles fetches profiles without privileged switch IDs. +func (s *DefaultSettingsService) GetProfiles(ctx context.Context) (*models.ProfilesResponse, error) { + return s.callProfiles(ctx, "") +} + +func (s *DefaultSettingsService) callProfiles(ctx context.Context, params string) (*models.ProfilesResponse, error) { + resp, err := s.apiClient.Call(ctx, models.MethodProfiles, params) + if err != nil { + return nil, fmt.Errorf("failed to get profiles: %w", err) + } + var profiles models.ProfilesResponse + if err := json.Unmarshal([]byte(resp), &profiles); err != nil { + return nil, fmt.Errorf("failed to parse profiles: %w", err) + } + return &profiles, nil +} + +// GetActiveProfile fetches the active profile, or nil when the device is +// on the shared profile. +func (s *DefaultSettingsService) GetActiveProfile(ctx context.Context) (*models.ActiveProfile, error) { + resp, err := s.apiClient.Call(ctx, models.MethodProfilesActive, "") + if err != nil { + return nil, fmt.Errorf("failed to get active profile: %w", err) + } + var active *models.ActiveProfile + if resp != "" { + if err := json.Unmarshal([]byte(resp), &active); err != nil { + return nil, fmt.Errorf("failed to parse active profile: %w", err) + } + } + return active, nil +} + +// NewProfile creates a profile. +func (s *DefaultSettingsService) NewProfile( + ctx context.Context, + params *models.NewProfileParams, +) (*models.ProfileResponse, error) { + data, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal profile params: %w", err) + } + resp, err := s.apiClient.Call(ctx, models.MethodProfilesNew, string(data)) + if err != nil { + return nil, fmt.Errorf("failed to create profile: %w", err) + } + var profile models.ProfileResponse + if err := json.Unmarshal([]byte(resp), &profile); err != nil { + return nil, fmt.Errorf("failed to parse profile: %w", err) + } + return &profile, nil +} + +// UpdateProfile updates a profile. +func (s *DefaultSettingsService) UpdateProfile( + ctx context.Context, + params *models.UpdateProfileParams, +) (*models.ProfileResponse, error) { + data, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal profile params: %w", err) + } + resp, err := s.apiClient.Call(ctx, models.MethodProfilesUpdate, string(data)) + if err != nil { + return nil, fmt.Errorf("failed to update profile: %w", err) + } + var profile models.ProfileResponse + if err := json.Unmarshal([]byte(resp), &profile); err != nil { + return nil, fmt.Errorf("failed to parse profile: %w", err) + } + return &profile, nil +} + +// DeleteProfile removes a profile. +func (s *DefaultSettingsService) DeleteProfile(ctx context.Context, profileID string) error { + data, err := json.Marshal(models.DeleteProfileParams{ProfileID: profileID}) + if err != nil { + return fmt.Errorf("failed to marshal profile params: %w", err) + } + _, err = s.apiClient.Call(ctx, models.MethodProfilesDelete, string(data)) + if err != nil { + return fmt.Errorf("failed to delete profile: %w", err) + } + return nil +} + +// SwitchProfile switches the active profile. Nil params deactivates. +func (s *DefaultSettingsService) SwitchProfile(ctx context.Context, params *models.SwitchProfileParams) error { + paramsJSON := "" + if params != nil { + data, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to marshal switch params: %w", err) + } + paramsJSON = string(data) + } + _, err := s.apiClient.Call(ctx, models.MethodProfilesSwitch, paramsJSON) + if err != nil { + return fmt.Errorf("failed to switch profile: %w", err) + } + return nil +} + +// GetClients fetches paired clients. +func (s *DefaultSettingsService) GetClients(ctx context.Context) (*models.ClientsResponse, error) { + resp, err := s.apiClient.Call(ctx, models.MethodClients, "") + if err != nil { + return nil, fmt.Errorf("failed to get paired clients: %w", err) + } + var clients models.ClientsResponse + if err := json.Unmarshal([]byte(resp), &clients); err != nil { + return nil, fmt.Errorf("failed to parse paired clients: %w", err) + } + return &clients, nil +} + +// StartClientPairing starts a local pairing approval flow. +func (s *DefaultSettingsService) StartClientPairing( + ctx context.Context, role string, +) (*models.ClientsPairStartResponse, error) { + data, err := json.Marshal(models.ClientsPairStartParams{Role: role}) + if err != nil { + return nil, fmt.Errorf("failed to marshal pairing params: %w", err) + } + resp, err := s.apiClient.Call(ctx, models.MethodClientsPairStart, string(data)) + if err != nil { + return nil, fmt.Errorf("failed to start client pairing: %w", err) + } + var pairing models.ClientsPairStartResponse + if err := json.Unmarshal([]byte(resp), &pairing); err != nil { + return nil, fmt.Errorf("failed to parse pairing response: %w", err) + } + return &pairing, nil +} + +// CancelClientPairing cancels an active pairing flow. +func (s *DefaultSettingsService) CancelClientPairing(ctx context.Context) error { + if _, err := s.apiClient.Call(ctx, models.MethodClientsPairCancel, ""); err != nil { + return fmt.Errorf("failed to cancel client pairing: %w", err) + } + return nil +} + +// DeleteClient revokes a paired client. +func (s *DefaultSettingsService) DeleteClient(ctx context.Context, clientID string) error { + data, err := json.Marshal(models.ClientsDeleteParams{ClientID: clientID}) + if err != nil { + return fmt.Errorf("failed to marshal client delete params: %w", err) + } + if _, err := s.apiClient.Call(ctx, models.MethodClientsDelete, string(data)); err != nil { + return fmt.Errorf("failed to delete paired client: %w", err) + } + return nil +} diff --git a/pkg/ui/tui/utils.go b/pkg/ui/tui/utils.go index ee1db06fd..bdc7ccc1b 100644 --- a/pkg/ui/tui/utils.go +++ b/pkg/ui/tui/utils.go @@ -91,7 +91,8 @@ func ResponsiveMaxWidget(maxWidth, maxHeight int, p tview.Primitive) tview.Primi // Draw implements tview.Primitive. func (r *responsiveWrapper) Draw(screen tcell.Screen) { - r.DrawForSubclass(screen, r) + // The wrapper only positions its child. Drawing its Box would fill the + // entire terminal with the theme background outside the centered dialog. x, y, width, height := r.GetInnerRect() actualWidth := r.maxWidth @@ -149,8 +150,11 @@ const ( ) // ShowInfoModal displays an informational modal with a title and OK button. -// onDismiss is called when the modal is closed and should restore focus. -func ShowInfoModal(pages *tview.Pages, app *tview.Application, title, message string, onDismiss func()) { +// onDismiss is called when the modal is closed and should restore focus. The +// returned modal supports callers that need to update displayed information. +func ShowInfoModal( + pages *tview.Pages, app *tview.Application, title, message string, onDismiss func(), +) *tview.Modal { modal := tview.NewModal(). SetText(message). AddButtons([]string{"OK"}). @@ -166,6 +170,7 @@ func ShowInfoModal(pages *tview.Pages, app *tview.Application, title, message st SetTitleAlign(tview.AlignCenter) pages.AddPage(infoModalPage, modal, false, true) app.SetFocus(modal) + return modal } // ShowErrorModal displays an error message modal to the user. diff --git a/pkg/ui/tui/utils_test.go b/pkg/ui/tui/utils_test.go index a2103440c..3bbbbf4a0 100644 --- a/pkg/ui/tui/utils_test.go +++ b/pkg/ui/tui/utils_test.go @@ -212,6 +212,29 @@ func TestResponsiveMaxWidget(t *testing.T) { assert.Equal(t, content, rw.child) } +func TestResponsiveWrapper_DrawLeavesOuterBackgroundUntouched(t *testing.T) { + t.Parallel() + + screen := tcell.NewSimulationScreen("UTF-8") + require.NoError(t, screen.Init()) + defer screen.Fini() + screen.SetSize(120, 40) + screen.Fill(' ', tcell.StyleDefault.Background(tcell.ColorBlack)) + + content := tview.NewBox().SetBackgroundColor(tcell.ColorBlue) + wrapper := ResponsiveMaxWidget(100, 30, content) + wrapper.SetRect(0, 0, 120, 40) + wrapper.Draw(screen) + + _, outerStyle, _ := screen.Get(0, 0) + _, outerBG, _ := outerStyle.Decompose() + assert.Equal(t, tcell.ColorBlack, outerBG, "space outside centered dialog must keep terminal background") + + _, innerStyle, _ := screen.Get(10, 5) + _, innerBG, _ := innerStyle.Decompose() + assert.Equal(t, tcell.ColorBlue, innerBG, "centered child must draw its own background") +} + func TestResponsiveWrapper_Focus(t *testing.T) { t.Parallel() diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go index 3ca430402..d7f41d099 100644 --- a/pkg/zapscript/commands.go +++ b/pkg/zapscript/commands.go @@ -109,6 +109,9 @@ func lookupCmd(name string) (cmdFunc, bool) { zapscript.ZapScriptCmdControl: cmdControl, zapscript.ZapScriptCmdScreenshot: cmdScreenshot, + zapscript.ZapScriptCmdProfile: cmdProfile, + zapscript.ZapScriptCmdProfileClear: cmdProfileClear, + zapscript.ZapScriptCmdMisterINI: forwardCmd, zapscript.ZapScriptCmdMisterCore: forwardCmd, zapscript.ZapScriptCmdMisterScript: forwardCmd, diff --git a/pkg/zapscript/profile.go b/pkg/zapscript/profile.go new file mode 100644 index 000000000..f95c2af53 --- /dev/null +++ b/pkg/zapscript/profile.go @@ -0,0 +1,65 @@ +// 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 zapscript + +import ( + "errors" + "fmt" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" +) + +// cmdProfile handles **profile: — the card-scan path +// for changing the device's active profile. The switch ID is resolved here +// so an unknown card fails the script (and plays the fail sound); the +// actual activation is applied by the service layer from the returned +// CmdResult. No PIN is checked on this path: possession of the card is the +// authorization. +// +//nolint:gocritic // single-use parameter in command handler +func cmdProfile(_ platforms.Platform, env platforms.CmdEnv) (platforms.CmdResult, error) { + if len(env.Cmd.Args) != 1 || env.Cmd.Args[0] == "" { + return platforms.CmdResult{}, ErrArgCount + } + switchID := env.Cmd.Args[0] + + if env.Database == nil || env.Database.UserDB == nil { + return platforms.CmdResult{}, errors.New("user database not available") + } + if _, err := env.Database.UserDB.GetProfileBySwitchID(switchID); err != nil { + return platforms.CmdResult{}, fmt.Errorf("unknown profile switch ID: %w", err) + } + + return platforms.CmdResult{ + ProfileSwitch: &platforms.ProfileSwitchRequest{SwitchID: switchID}, + }, nil +} + +// cmdProfileClear handles **profile.clear — deactivates the active profile. +// +//nolint:gocritic // single-use parameter in command handler +func cmdProfileClear(_ platforms.Platform, env platforms.CmdEnv) (platforms.CmdResult, error) { + if len(env.Cmd.Args) > 0 { + return platforms.CmdResult{}, ErrArgCount + } + return platforms.CmdResult{ + ProfileSwitch: &platforms.ProfileSwitchRequest{Clear: true}, + }, nil +} diff --git a/pkg/zapscript/profile_test.go b/pkg/zapscript/profile_test.go new file mode 100644 index 000000000..136965dd8 --- /dev/null +++ b/pkg/zapscript/profile_test.go @@ -0,0 +1,108 @@ +// 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 zapscript + +import ( + "testing" + + "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database/userdb" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func profileCmdEnv(mockDB *helpers.MockUserDBI, name string, args []string) platforms.CmdEnv { + return platforms.CmdEnv{ + Cmd: zapscript.Command{ + Name: name, + Args: args, + }, + Database: &database.Database{UserDB: mockDB, MediaDB: nil}, + } +} + +func TestCmdProfileSwitch_Success(t *testing.T) { + t.Parallel() + + mockDB := helpers.NewMockUserDBI() + mockDB.On("GetProfileBySwitchID", "corn-arm-truck"). + Return(&database.Profile{ProfileID: "p1", Name: "Kid A", SwitchID: "corn-arm-truck"}, nil) + + result, err := cmdProfile(nil, profileCmdEnv(mockDB, "profile", []string{"corn-arm-truck"})) + require.NoError(t, err) + require.NotNil(t, result.ProfileSwitch) + assert.Equal(t, "corn-arm-truck", result.ProfileSwitch.SwitchID) + assert.False(t, result.ProfileSwitch.Clear) + assert.False(t, result.MediaChanged, "profile switch must not count as a media change") +} + +func TestCmdProfileSwitch_UnknownSwitchID(t *testing.T) { + t.Parallel() + + mockDB := helpers.NewMockUserDBI() + mockDB.On("GetProfileBySwitchID", "no-such-card").Return(nil, userdb.ErrProfileNotFound) + + _, err := cmdProfile(nil, profileCmdEnv(mockDB, "profile", []string{"no-such-card"})) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown profile switch ID") +} + +func TestCmdProfileSwitch_ArgValidation(t *testing.T) { + t.Parallel() + + mockDB := helpers.NewMockUserDBI() + + _, err := cmdProfile(nil, profileCmdEnv(mockDB, "profile", nil)) + require.ErrorIs(t, err, ErrArgCount) + + _, err = cmdProfile(nil, profileCmdEnv(mockDB, "profile", []string{""})) + require.ErrorIs(t, err, ErrArgCount) + + _, err = cmdProfile(nil, profileCmdEnv(mockDB, "profile", []string{"a", "b"})) + require.ErrorIs(t, err, ErrArgCount) +} + +func TestCmdProfileClear(t *testing.T) { + t.Parallel() + + mockDB := helpers.NewMockUserDBI() + + result, err := cmdProfileClear(nil, profileCmdEnv(mockDB, "profile.clear", nil)) + require.NoError(t, err) + require.NotNil(t, result.ProfileSwitch) + assert.True(t, result.ProfileSwitch.Clear) + + _, err = cmdProfileClear(nil, profileCmdEnv(mockDB, "profile.clear", []string{"extra"})) + require.ErrorIs(t, err, ErrArgCount) +} + +func TestProfileCommands_NotMediaLaunching(t *testing.T) { + t.Parallel() + + // Profile switching must never be blocked by playtime limits — a kid + // who has hit their limit can still hand the device to a parent card. + assert.False(t, IsMediaLaunchingCommand(zapscript.ZapScriptCmdProfile)) + assert.False(t, IsMediaLaunchingCommand(zapscript.ZapScriptCmdProfileClear)) + assert.False(t, IsMediaDisruptingCommand(zapscript.ZapScriptCmdProfile)) + assert.False(t, IsMediaDisruptingCommand(zapscript.ZapScriptCmdProfileClear)) +}