# `config.json` and `state.json`

PlayGuard's settings, in `sd:/config/playguard/config.json`, shared by every
system on the SD card; and what one system's parental controls need
remembered (*Block all games*, the extra time, a pending relock, the firmware
choice), in its own `sd:/config/playguard/nand/<system>/state.json`
([below](#state-kept-between-launches)). Everything here is
set from the app; the file is only worth editing by hand to get back in (see
[`pin_lock`](#pin_lock)) or to check what PlayGuard remembered.

The code is the reference: `source/util/config.hpp` (fields and defaults) and
`source/util/config.cpp` (reading, checks, writing). The remote link's
settings are not here but in `sync.conf` ([`sync-protocol.md`](https://github.com/JigSawFr/PlayGuard/blob/main/docs/sync-protocol.md)).

## How the file is read

- **A missing file gives the defaults**, silently (a first run). PlayGuard
  writes the whole file at the next change.
- **A file that is there but cannot be read, or is not a JSON object, is kept
  as `config.json.bad`** before anything is saved (PlayGuard saves by itself
  at start-up), and the defaults are used. A `config.json.bad` already there
  becomes `config.json.bad.1` (an older `.bad.1` is deleted): the two most
  recent damaged files are kept. PlayGuard says so once, at start-up (*the
  settings could not be read and were reset*), and the change history
  records *Settings reset*. What the old file held (the limits *Block all games*
  saved, a pending relock or extra time, the firmware choice …) is not
  carried over: check those settings, or copy values back from the `.bad`
  file by hand. Should the file not be movable either, nothing is saved
  until the next start, so it is never overwritten.
- **Each key is read on its own.** A missing key keeps its default; a key of
  the wrong type keeps its default too, and the others are still read.
  Types are strict: `true`, not `1`; `90`, not `90.5` or `"90"`.
- **A value outside the list below goes back to its default**, without a
  message.
- Keys PlayGuard does not know are ignored, and dropped at the next save.
- Some groups of keys are all-or-nothing: the [firmware choice](#firmware-not-supported-yet)
  and the [extra time of the day](#extra-time-today). One key refused, and the
  whole group is cleared.
- The file is written atomically (a temporary file, then a rename), indented
  with two spaces.

## Preferences

| Key | Type | Values | Default | Set in |
|---|---|---|---|---|
| `schema` | integer | `1` | `1` | written by PlayGuard, not read |
| `language` | string | `"system"` (the console's language), `"en-US"`, `"fr"`, `"fr-CA"`, `"de"`, `"es"`, `"es-419"`, `"it"`, `"nl"`, `"pt"`, `"pt-BR"`, `"ru"`, `"ja"`, `"ko"`, `"zh-Hans"`, `"zh-Hant"` | `"system"` | Preferences |
| `theme` | string | `"dark"`, `"system"` (the console's), `"light"` | `"dark"` | Preferences |
| `theme_file` | string | the palette: `""` (PlayGuard's own colours), one of Aurore's presets (`"builtin:high-contrast-dark"`, `"builtin:high-contrast-light"`, `"builtin:ocean"`, `"builtin:amethyst"`, `"builtin:sand"`, a season such as `"builtin:halloween"`, or `"builtin:seasonal"`, which follows the calendar) or the path of a theme file in `config/playguard/themes/`, or of a theme folder's `theme.theme` there ([themes.md](https://guard.boreale.dev/docs/themes.md)); a palette made for one variant only (light or dark) sets `theme` to it; a file that cannot be read: PlayGuard's own colours | `""` | Preferences › Palette |
| `effects` | string | `"auto"` (in full on the dock, lighter in hand: no background glow, no pulse of the child screen's ring, no movement at all while the console is off its dock, charging or not), `"full"` (the glow and every movement of the look, in hand too) or `"reduced"` (no decorative movement, the glow kept) | `"auto"` | Preferences › Visual effects |
| `text_size` | string | `"auto"` (larger while the console is docked, on a TV), `"normal"` or `"large"`; larger text draws every label 25 % bigger, the sidebar's too, and the header's and the footer's 15 to 20 %, at once (no restart) | `"auto"` | Preferences › Text size, the header's AA |
| `rail` | string | the main screen's sidebar: `"auto"` (on its icons while a tab has the focus, open over the tab, which keeps its width, while the focus is on the sidebar), `"open"` (always open, the names beside the icons) or `"icons"` (always on its icons, the tab wider) | `"auto"` | Preferences › Sidebar |
| `start_tab` | string | the tab shown at start-up: `"dashboard"` (Today), `"play_timer"`, `"activity"`, `"restrictions"`, `"security"` (Security), `"console"`, `"history"`, `"preferences"`, `"about"`; `"clock"` and `"tools"`, from before they became Console, read as `"console"` | `"dashboard"` | Preferences |
| `advanced` | boolean | shows the play timer's debug-class actions | `false` | Play timer › Advanced |
| `auto_relock` | boolean | turns parental controls back on right after a change that needed the temporary unlock | `true` | Security |
| `pin_ask_again` | boolean | the PIN, once entered, holds 5 minutes instead of until PlayGuard closes: *Before a change* asks again after them, *To open PlayGuard* locks again after 5 minutes or more out of focus | `false` | Security (*Ask for the PIN again after 5 minutes*) |
| `pin_pauses` | boolean | once the PIN is entered, parental controls are paused (unlocked temporarily) while PlayGuard is open and in focus, so the time spent in it does not take from the child's; they come back on as it is left. Off, entering the PIN changes nothing on the console | `false` | Security (*Pause parental controls while I'm in PlayGuard*) |
| `onboarding_at_start` | boolean | *First steps* opens by itself at start-up while no PIN is set | `true` | First steps (*Show at start-up*) |
| `clock_check_at_start` | boolean | measures the network clock at start-up and says when it is off | `false` | Console › Network clock |
| `intro` | boolean | the loading screen stays 2 seconds at start-up (Ⓐ or Ⓑ skips it); off, the first screen comes as soon as it is ready | `true` | Preferences (*Show the intro for 2 seconds*) |
| `activity_period` | integer | Activity's period: `0` today, `1` last 7 days, `3` last 30 days, `2` all time | `1` | Activity, Activity › All games |
| `games_grid` | boolean | *Activity › All games* shows the games as a grid of covers; off, as a list | `true` | Activity › All games (Ⓨ) |
| `check_grid` | boolean | *Console › Installed games* shows the games as covers; off, as a list | `false` | Console › Installed games (*View*) |
| `activity_account` | string | the account Activity shows when PlayGuard starts: `"last"` (the one shown last, `activity_account_last`), `""` every account, or one user account's uid as 32 upper-case hex digits; an account no longer on the console reads as every account | `"last"` | Activity (*Account by default*) |
| `activity_account_last` | string | the account Activity showed last: `""` every account, or a uid as above | `""` | Activity (*Account*) |
| `export_format` | integer | the last export format: `0` CSV, `1` JSON, `2` XLSX, `3` PDF | `0` | Activity › Export |
| `backup_keep` | integer | backups kept after a new one: `0` (all of them), `5`, `10`, `20` | `0` | History |
| `logs_keep` | integer | saved reports kept, crash files kept, session logs kept and links of reports sent online kept (`logs/reports/`, `logs/crashes/`, `logs/sessions/`, `logs/uploads/`, each on its own): the newest `5`, `10`, `20` or `50`; `0` all of them. A file goes when any of the three `logs_keep*` says so, at start-up and when one changes; the newest file of each folder always stays | `20` | About › Help & diagnostics |
| `logs_keep_days` | integer | the same, by age: those of the last `7`, `30`, `90` or `365` days (from the date in the file name); `0` no limit | `0` | About › Help & diagnostics |
| `logs_keep_mb` | integer | the same, by size: the newest that fit in `1`, `5` or `20` MB together; `0` no limit | `0` | About › Help & diagnostics |
| `extra_amounts` | array of 3 integers | the *Extra time today* choices, in minutes: `[15, 30, 60]`, `[10, 20, 30]`, `[30, 60, 90]` or `[5, 10, 15]` | `[15, 30, 60]` | Play timer |

## `pin_lock`

*Security › Protect PlayGuard with the PIN*. A string:

| Value | In the app | What it does |
|---|---|---|
| `"off"` | Never | Nothing is asked, except before *Show the PIN* (the PIN opens everything else). |
| `"changes"` | Before a change | Anyone can look; the first change asks for the PIN, then nothing is asked until PlayGuard closes. The default. |
| `"open"` | To open PlayGuard | A lock screen first; the right PIN opens the app, B quits. Nothing is asked again until PlayGuard closes. |

- With `pin_ask_again` on, the PIN holds 5 minutes instead: *Before a change*
  asks again after them, and *To open PlayGuard*'s lock screen comes back when
  PlayGuard returns after 5 minutes or more out of focus.

- With no PIN on the console, nothing is asked whatever the value.
- *Show the PIN* asks for it every time, whatever the value, even right after
  it was entered.
- A missing file, a missing key or a value not in the list gives `"changes"`:
  a damaged file never turns the prompt off. Only `"off"` written on purpose
  does.
- In the app, a lower setting asks for the PIN first. The file itself is not
  protected: it keeps a child out of PlayGuard, not someone who edits the SD
  card.
- **PIN forgotten while this is `"changes"` or `"open"`:** put the SD card in a
  computer and set `"pin_lock": "off"`. *Show the PIN* still asks for it, and
  the recovery sysmodule's screen sets a new one rather than showing it.

## Network clock

| Key | Type | Values | Default |
|---|---|---|---|
| `ntp_server` | string | the NTP server used; empty picks one from the console's region. At most 253 characters, or it is cleared | `""` |
| `custom_servers` | array of strings | servers added by hand: at most 10, each 1 to 253 characters (empty or longer ones are dropped) | `[]` |

## Updates

`update_via` and `update_daily` are set in *Preferences › Updates*; *About › Check for updates* checks at once.

| Key | Type | Values | Default |
|---|---|---|---|
| `update_via` | string | how an update is installed: `""` (not chosen yet: asked on the first update), `"inplace"` (PlayGuard downloads the release, checks it, replaces itself and restarts), `"auto"` (Sphaira if installed, else the Homebrew App Store), `"sphaira"`, `"appstore"`, `"manual"` (only say how) | `""` |
| `update_daily` | boolean | checks for an update at start-up, once a day | `false` |
| `update_checked` | string | `"YYYY-MM-DD"` of the last check; anything else is cleared | `""` |
| `update_latest` | string | the latest release the last check found; while newer than the running version, start-up checks again and opens the update screen | `""` |
| `sys_latest` | string | the latest Atmosphère, hekate, firmware and sys-patch the last check found (`"atmosphere=1.12.0;hekate=6.5.4;firmware=23.0.1;syspatch=1.6.3.0"`); the notification bell says which of the first three are newer than what the console runs; at most 96 characters | `""` |
| `sys_checked` | string | `"YYYY-MM-DD"` of that check (once a day, with *Check for updates every day*); anything else is cleared | `""` |
| `ha_latest` | string | the latest release of PlayGuard's Home Assistant integration the last check found (checked only once the integration said which version Home Assistant runs); the notification bell says when it is newer; at most 32 characters | `""` |
| `ha_checked` | string | `"YYYY-MM-DD"` of that check (as `sys_checked`); anything else is cleared | `""` |
| `update_snooze_version` | string | the release whose screen *Later* postponed (cleared with `update_snooze_until` when either is malformed) | `""` |
| `update_snooze_until` | string | `"YYYY-MM-DD"`: that release is offered again from that day (30 days at most; a farther date is ignored) | `""` |

## Reminders

| Key | Type | Values | Default |
|---|---|---|---|
| `support_reminder` | boolean | *Support PlayGuard* once a month at start-up (*Preferences › Notifications*) | `true` |
| `support_reminded` | string | `"YYYY-MM-DD"` of the last reminder; anything else is cleared | `""` |
| `seen_version` | string | the version whose *What's new* was shown; at most 32 characters | `""` |
| `agent_update_skipped` | string | the SHA-256 of the remote link agent PlayGuard carries that the offer to update the installed one was answered *Later* to: not offered again at start-up for that build (*Console › Optional modules* still offers it); at most 64 characters | `""` |
| `notices_system` | boolean | the notification bell shows newer Atmosphère, hekate and firmware releases (*Preferences › Notifications*) | `true` |
| `notices_summary` | boolean | the bell shows the week's play time and a limit passed on several days | `true` |
| `notices_reminders` | boolean | the bell reminds of a settings backup to make, the other system's parental controls and *Block all games* on for a week | `true` |
| `notices_read` | array of strings | the bell's entries for information marked as read, `"id:hash"` of their text, the newest last, at most 48 (one longer than 96 characters is dropped); a read entry comes back when its text changes | `[]` |
| `notices_acked` | array of strings | the bell's entries to do the parent chose to ignore (a weak PIN, PlayGuard open without the PIN, the "time's up" alarm off, the SD card nearly full), `"id:print:YYYY-MM-DD"`: what it was about (the PIN's length for a weak PIN, never the PIN; empty otherwise) and the day; at most 16 (one longer than 96 characters is dropped); an entry comes back once what it says is fixed and then seen again, or when what it was about changes | `[]` |
| `notices_ack_remind` | boolean | an ignored entry is counted again 30 days after it was ignored (*Preferences › Notifications*); off, it stays ignored | `true` |
| `summary_seen` | string | `"YYYY-MM-DD"` the weekly summary was last read; it comes back 7 days later; anything else is cleared | `""` |
| `crash_seen` | string | the newest crash file (`logs/crashes/`) the notification bell told about; at most 64 characters | `""` |

## Developer

Set in *About › Help & diagnostics › Developer*, shown once developer mode is on.

| Key | Type | Values | Default |
|---|---|---|---|
| `dev_mode` | boolean | the developer tools at the end of *About › Help & diagnostics* (seven presses of Ⓨ on *About › Version* turn them on, *Developer mode* there turns them off) | `false` |
| `pt_log` | boolean | *Record the play timer* (`logs/play_timer/log.csv`) | `false` |
| `dev_builds_order` | string | how *Install another build* orders the pull requests (A on their tab when it is shown): `"newest"` (the newest build first) or `"number"` (the highest number first); main's commits are always newest first | `"newest"` |
| `frame_stats` | boolean | *About › Help & diagnostics › Developer › Frame statistics* (`logs/frame_stats.txt`: one line per second, the UI thread's time per frame and the late frames, to weigh one screen against another on the console) | `false` |
| `cadran_glow` | boolean | *About › Help & diagnostics › Developer › Background glow*: the screens' soft light behind the top left (or the child screen's ring); off, the frame statistics' mode says `docked-noglow` (`handheld-noglow`), to weigh what it costs | `true` |

## State kept between launches

Written by PlayGuard to finish what it started. Better left alone: a wrong
value here can make PlayGuard put back a limit nobody asked for.

These keys are in `state.json`, one per system: `nand/sysmmc/` for the
sysMMC, `nand/emummc/` for the emuMMC (every emuMMC of the SD card shares
it), `nand/unknown/` when the system could not be told
(`source/util/nand.hpp`). Each system has its own parental
controls: *Block all games* turned on on the sysMMC must not put its limits
back on an emuMMC. `state.json` is read like `config.json` (a damaged one is
kept as `state.json.bad`, with the same notice); when a system has none yet,
these keys are read from `config.json`, where the versions before it kept
them, and leave it at the next save. `extra_auto_restore`, a preference,
stays in `config.json`.

### Block all games

*Play timer › Block all games*: every day's limit set to 0, so the PIN is needed to
play.

| Key | Type | Values | Default |
|---|---|---|---|
| `console_lock` | boolean | *Block all games* is on | `false` |
| `console_lock_since` | string | `"YYYY-MM-DD"` *Block all games* was turned on (the bell says when it has been on for a week); anything else is cleared | `""` |
| `console_lock_prev` | array of 7 integers | the limits it replaced, Sunday to Saturday, in minutes: `0` to `1440`, or `65535` for no limit. Anything but seven valid values empties it; turning the lock off then clears the limits instead of putting them back | `[]` |

### Extra time today

*Extra time today* or *No more play today*: one day's limit changed for that
day only. All four keys go together.

| Key | Type | Values | Default |
|---|---|---|---|
| `extra_weekday` | integer | the day changed, `0` (Sunday) to `6`; `-1`: nothing pending | `-1` |
| `extra_date` | string | `"YYYY-MM-DD"` of the change | `""` |
| `extra_base` | integer | the limit before, in minutes: `0` to `1440`, or `65535` for no limit | `0` |
| `extra_value` | integer | the limit for that day, `0` to `1440` minutes | `0` |
| `extra_auto_restore` | boolean | after *No more play today*, put the limit back the next day as any change instead of asking (the temporary unlock and the PIN are still asked when needed; "no limit" is always asked); extra time comes back without asking whatever this says (either the next time PlayGuard is opened, from the next day on); set in Play timer | `false` |

### Extra time on another day

*Extra time › Another day…*: a later day's limit raised now, for that date
only, and put back by itself the next time PlayGuard is opened once the day is
over (unless that day's limit was changed since). All four keys go together.

| Key | Type | Values | Default |
|---|---|---|---|
| `bonus_weekday` | integer | the day raised, `0` (Sunday) to `6`; `-1`: nothing planned | `-1` |
| `bonus_date` | string | `"YYYY-MM-DD"` of the day it is for | `""` |
| `bonus_base` | integer | the limit before, `0` to `1440` minutes (never no limit) | `0` |
| `bonus_value` | integer | the limit for that day, above `bonus_base`, at most `1440` minutes | `0` |

### Scheduled profile

*Play timer › Profiles › Scheduled profile*: a profile for a period, set by
an earlier version (a new one is Home Assistant's: *Console › Remote access*). When the period begins, PlayGuard asks to put it in force (once a day, when it is
opened); once it is over, to put back the limits it replaced. A period that
went by with the profile never in force is forgotten. The six keys go
together.

| Key | Type | Values | Default |
|---|---|---|---|
| `schedule_file` | string | the profile's file (its stem, under `profiles/`); `""`: none scheduled | `""` |
| `schedule_name` | string | its name, as said in the questions | `""` |
| `schedule_from` | string | `"YYYY-MM-DD"` of the period's first day | `""` |
| `schedule_to` | string | `"YYYY-MM-DD"` of its last day, not before `schedule_from` | `""` |
| `schedule_applied` | boolean | the profile was put in force for the period | `false` |
| `schedule_prev` | array of 7 integers | once in force, the limits it replaced, Sunday to Saturday: `0` to `1440`, or `65535` for no limit | `[]` |

### Firmware not supported yet

The choice made on that screen, remembered for one firmware with one app
version. The three keys go together.

| Key | Type | Values | Default |
|---|---|---|---|
| `fw_gate_fw` | string | the firmware it applies to | `""` |
| `fw_gate_app` | string | the PlayGuard version it applies to | `""` |
| `fw_gate_choice` | string | `""` (none), `"read_only"`, `"probe"`, `"risk"` | `""` |

### Locking again

| Key | Type | Values | Default |
|---|---|---|---|
| `relock_pending` | boolean | set right before PlayGuard unlocks parental controls (for a change, or *Security › Unlock temporarily*), cleared once they are locked again (or *Quit, still unlocked* is chosen on quitting). Still `true` at start-up: the app stopped in between (or was closed from the HOME menu), so it locks again then | `false` |

## Example

Every key with *Protect PlayGuard with the PIN* set to *To open PlayGuard*, in the order of
this page: `config.json`, then a system's `state.json`. PlayGuard itself
writes the keys in alphabetical order, with each array element on its own
line; the order does not matter when it reads them:

```json
{
  "schema": 1,
  "language": "fr",
  "theme": "dark",
  "theme_file": "",
  "text_size": "auto",
  "effects": "auto",
  "rail": "auto",
  "start_tab": "dashboard",
  "advanced": false,
  "auto_relock": true,
  "pin_ask_again": false,
  "pin_pauses": false,
  "onboarding_at_start": true,
  "clock_check_at_start": false,
  "intro": true,
  "activity_period": 1,
  "games_grid": true,
  "activity_account": "last",
  "activity_account_last": "",
  "export_format": 0,
  "backup_keep": 0,
  "logs_keep": 20,
  "logs_keep_days": 0,
  "logs_keep_mb": 0,
  "extra_amounts": [15, 30, 60],
  "pin_lock": "open",
  "ntp_server": "",
  "custom_servers": [],
  "update_via": "",
  "update_daily": false,
  "update_checked": "",
  "update_latest": "",
  "sys_latest": "",
  "sys_checked": "",
  "ha_latest": "",
  "ha_checked": "",
  "update_snooze_version": "",
  "update_snooze_until": "",
  "support_reminder": true,
  "support_reminded": "",
  "seen_version": "",
  "agent_update_skipped": "",
  "crash_seen": "",
  "notices_system": true,
  "notices_summary": true,
  "notices_reminders": true,
  "notices_read": [],
  "summary_seen": "",
  "dev_mode": false,
  "pt_log": false,
  "frame_stats": false,
  "cadran_glow": true,
  "dev_builds_order": "newest",
  "extra_auto_restore": false
}
```

```json
{
  "schema": 1,
  "console_lock": false,
  "console_lock_prev": [],
  "console_lock_since": "",
  "extra_weekday": -1,
  "extra_date": "",
  "extra_base": 0,
  "extra_value": 0,
  "bonus_weekday": -1,
  "bonus_date": "",
  "bonus_base": 0,
  "bonus_value": 0,
  "schedule_file": "",
  "schedule_name": "",
  "schedule_from": "",
  "schedule_to": "",
  "schedule_applied": false,
  "schedule_prev": [],
  "fw_gate_fw": "",
  "fw_gate_app": "",
  "fw_gate_choice": "",
  "relock_pending": false
}
```

A file with only `{"pin_lock": "off"}` is valid too: every other key keeps its
default.

## `watch.json`

One per system, next to its `state.json`, in
`sd:/config/playguard/nand/<system>/watch.json`: what PlayGuard
last saw of the console, to tell the parent what changed outside it (the
amber card on *Today*, the `outside_*` and `new_account` entries of the
change history, the Activity tab's new-account flag).
The code: `source/action/outside_change_logic.cpp` (the rules),
`source/action/outside_watch.cpp` (the readings).

**Only what PlayGuard sees.** Nothing runs in the background: the file is
compared with the console at each refresh of *Today* or the Play timer
(every 5 s while one is on screen) and at the next start. A change made and
undone while PlayGuard was closed, or a clock changed and the day over before
PlayGuard opens again, goes unseen.

| Key | Type | Values |
|---|---|---|
| `schema` | integer | `1` |
| `date` | string | `"YYYY-MM-DD"`, the day `spent_s` belongs to |
| `spent_s` | integer | today's time spent, last seen (today's limit minus the time left), `0` to `86400` seconds. Written at most every 5 minutes of play, so it can be a little behind |
| `offset_s` | integer | the user clock minus the steady clock (which neither a clock change nor sleep mode moves), in seconds |
| `steady_id` | string | the steady clock's source; another one is not compared |
| `limits` | array of 7 integers | the limits PlayGuard last saw, Sunday to Saturday, in minutes (`0` to `1440`, or `65535` for no limit) |
| `notice` | integer | what *Today* says: `1` the play time started over, `2` the clock was changed, `4` the limits were changed outside PlayGuard (added up) |
| `notice_date` | string | `"YYYY-MM-DD"` the notice belongs to: it goes the next day, or when dismissed |
| `reset_at` | string | `"HH:MM"` the play time was seen starting over |
| `accounts` | array of objects | the user accounts PlayGuard has seen, oldest first (32 at most): `id` (the account's 32 hex digits), `since` (`"YYYY-MM-DD"` first seen), `new` (`true`: it appeared after the first listing) |

- **The play time started over:** `spent_s` went down by more than a minute
  on the same `date`. The console's own reset comes with a new day; when it
  happens exactly (midnight or the "allowed again" time) is not confirmed
  yet ([parental-controls.md](https://guard.boreale.dev/docs/parental-controls.md#time-spent-and-time-left)),
  so a reset at the "allowed again" time could show here.
- **The clock was changed:** `offset_s` moved by more than 5 minutes. The
  automatic correction moves it by seconds only.
- **The limits were changed outside PlayGuard:** they differ from `limits`.
- **A new user account:** the console lists one that is not in `accounts`
  (read once per start). It goes in the change history (`new_account`) and is
  flagged on the Activity tab for 30 days after `since`,
  not on *Today*. Without `accounts` (a first run, or a file from an
  older version) the accounts listed are only recorded.
- Every change PlayGuard makes itself (anything it records in the change
  history) makes it take what it reads next as is, so its own limit and clock
  changes are not reported.
- A missing or damaged file, or a key out of range, is a first reading:
  nothing is reported, the values are only recorded. Deleting the file is
  harmless.
