# AGENTS.md

Instructions for AI coding and ops agents working in this repository for a human. Follow them literally. When a rule here conflicts with a request, stop and ask the human.

## 1. What this repo is

Picture Frame System: unlock a PF1007L Wi-Fi photo frame (Allwinner A33, Android 6), replace the vendor software with a locked-down kiosk app, and run it from a PHP 8.5 + SQLite server the human owns.
Three parts: the frame (root ADB + kiosk app + boot script), the server (Apache, `/frames/` path, one cron fetcher), the admin (desktop web page).
Repo: https://gitlab.com/calevans/picture-frame-system (branch `master`). License: MIT, except `uboot/` (GPL-2.0-or-later).

| Path | What it is |
| --- | --- |
| `web/app/` | Server code: `bootstrap.php`, `routes/`, `widgets/<id>/`, `fetch/` (cron fetcher), `migrations/`, `bin/` (`migrate.php`, `user.php`, `kiosk-release.php`) |
| `web/public/` | Web root for `/frames/` (two front controllers + `.htaccess`) |
| `web/tests/` | Test runner `run.php`, `*_test.php`, `run-in-docker.sh`, `docker/` |
| `web/config.example.php` | Copy to `config.php` (never commit) |
| `kiosk/` | Android kiosk app (Java, no Gradle). `build.sh`, `.lando.yml` |
| `device/frame-fixmac.sh` | Root boot script on the frame (watchdog, MAC, clock, ADB allow-list) |
| `scripts/` | `boot-ums.sh` (unlock), `provision.sh`, `lib/env.sh`, `pre-commit.sh`, `leak-check.sh`, `tests/*.sh` |
| `tools/` | `derive-sector.sh`, `make-bootanimation.php`, `slice-stamps.php` |
| `uboot/` | U-Boot patch + `build.sh`. No binaries are committed |
| `*.ps1` (repo root) | Windows scripts: `write-sd.ps1`, `backup-emmc.ps1`, `verify-backup.ps1`, `patch-sector.ps1` |
| `.env.example` | Every setting for build and provisioning scripts. Copy to `.env` |

Human docs: start at `docs/index.md`. Read the matching doc before each task:
`docs/hardware.md`, `docs/unlock.md`, `docs/server-install.md`, `docs/admin.md`, `docs/provisioning.md`, `docs/photos.md`, `docs/widgets.md`, `docs/kiosk-updates.md`, `docs/troubleshooting.md`, `docs/security.md`, developer pages in `docs/dev/` (`index.md`, `widgets.md`, `api.md`, `database.md`, `kiosk-update-protocol.md`).
If this file and a doc disagree, the code wins; fix the doc in the same change.

## 2. Hard rules (non-negotiable)

- Server side is PHP 8.5 + SQLite only. No frameworks, no ORM, no database server.
- No Node and no Python in project code, tests or tooling. Do not ask to install them.
- Ask the human before installing system packages on their machine. Ready-made setups exist where they help: the kiosk builds with Lando in `kiosk/` (or natively with a JDK and the Android build tools), and the photo-decoder tests run in Docker via `web/tests/run-in-docker.sh`.
- Composer: the only runtime dependency is `sabre/vobject` (loaded by the fetcher only); `phpstan/phpstan` is dev-only. Ask the human before adding any package.
- Content Security Policy is strict: `default-src 'none'`, `script-src 'self'`, `style-src 'self'`. No `unsafe-inline`, no `unsafe-eval`.
- No inline `style=`, no `on...=` handlers, no `<script>` in PHP fragments. JS never uses `innerHTML`, `document.write` or `eval`. Build DOM with `textContent`/`createElement`; in the admin use the `h()` helper. Setting `element.style` from script is allowed.
- The core never names a widget. Do not write a widget id in `web/app/*.php`, `routes/`, `fetch/`, `views/`, `bin/` or `web/public/`. A test fails if you do.
- Only `web/app/fetch/fetch.php` makes outbound requests (via `http_get()`: HTTPS, host allow-list, no redirects, size cap). Never call cURL or sockets from web code or widgets.
- Never decode an image inside a web request. Uploads go through the worker process. Never build a shell command from request data; use argument lists.
- Every PHP file starts with `declare(strict_types=1);`. PHPStan level 8 must be clean.
- Comments explain non-obvious why only. State current behavior, not history.
- Never weaken a control listed in `docs/security.md` (see "Things you must not undo": `__Host-` cookies, `Referrer-Policy: same-origin`, rate-limit order, dummy hash, secrets only on the `Authorization` path, no signing key on the server, no `DISALLOW_INSTALL_APPS`, `status = 'approved'` on frame-side photo queries, `LimitRequestBody` exceptions, leak check, fail-closed `ADB_ALLOW`).
- Never commit or print: `.env`, `.leak-patterns`, `config.php`, keys (`*.pem`, `*.key`), keystores, `*.db`, `*.apk`, `backups/`, `patch/`, firmware dumps, vendor APKs, `kiosk/build/`, `kiosk/pin.default`. Do not read `.env`, `backups/` or `private/` unless the human asks; never paste their contents into output, issues or commits.
- Never redistribute vendor firmware, eMMC images, or files extracted from a frame.
- Use placeholders in all docs, tests and examples: `example.com`, `192.0.2.x` / `198.51.100.x`, `/srv/frame`, `Alex`. No real names, hosts, IPs or MACs.
- Install the pre-commit hook (section 5). The leak check must pass. Never use `--no-verify`.
- Never push, force-push, or open merge requests without the human's explicit OK.
- Never hit the human's servers in loops: no polling, no watchers, no repeated SSH. One batched connection per step; each login may alert the owner. When waiting on the human, ask and wait.

## 3. Hardware safety protocol

Applies to every action that touches a frame (USB, FEL, ADB, `scripts/provision.sh`, `*.ps1`).

1. Back up the whole eMMC and verify it (section 4, phase 6) before any write to the frame's storage. No backup, no write.
2. Get the human's explicit OK for each device write, per step, in this conversation. An earlier OK for another step does not carry over. `scripts/provision.sh` prints a plan and waits for `y`; you cannot and must not answer that prompt for the human.
3. Log every device change with its undo in a private notes file (suggest `device/CHANGES.md`; it is gitignored with the rest of `device/` except `frame-fixmac.sh`). Use one entry per change: what, command, undo.
4. USB ADB is the recovery path. It is never filtered. Keep the FEL microSD card and `backups/`.
5. Root ADB on port 5555 has no authentication. Before the boot script goes on, ask the human for `ADB_ALLOW` (their ADB host IPv4 addresses/CIDRs). An empty value means no limit; never leave it empty without the human saying so.
6. Never flash or patch without the sector derived from the human's own backup (`tools/derive-sector.sh`). Never reuse a sector file from another frame or from the internet.
7. Only the tested firmware is verified (`AEEZO/astar_m86_8723cs:6.0.1/MOB30R/20260113:eng/test-keys`). If any script stops with an error about what it found, stop. Do not work around guards (`-ExpectSize`, hash checks, sector comparison) without the human's decision.
8. Never `usbipd attach` USB id `1f3a:1010` (U-Boot's disk). Cancel every Windows "format this disk" prompt.
9. Losing the APK keystore or the release key is expensive (see `docs/troubleshooting.md`, "I lost the APK keystore or the release key"). Never delete, overwrite or regenerate them. Never put them on the server, in the repo or in an image.

## 4. Setup runbook

Order: hardware check, host setup, unlock (phases 3-9), server (10-14), kiosk (15-19), claim and run (20-22). The unlock and the server are independent; provisioning needs both.
Conventions: `<repo>` is the clone path. WSL = shell in Ubuntu on Windows. PowerShell = Administrator PowerShell. Tags: [DESTRUCTIVE] changes data that is hard or impossible to undo. "STOP — HUMAN:" means do nothing further until the human has done the thing and told you.
Unlock scripts require Windows 10/11 with WSL2 and usbipd-win 4+. The server runs on any Linux host.

### Phase 1. Hardware and requirements check
- Confirm: model PF1007L, USB-C cable, microSD card (contents survive), about 16 GB free disk, about 90 minutes for the backup, Linux server with Apache 2.4 + HTTPS certificate, Docker + Lando.
- Verify (WSL): `command -v adb openssl unzip php docker lando` prints a path for each.
- STOP — HUMAN: confirm the frame model and that they own it.

### Phase 2. Host setup (WSL + PowerShell)
```
sudo apt install -y sunxi-tools adb e2fsprogs fdisk
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="1f3a", ATTR{idProduct}=="efe8", MODE="0666"' | sudo tee /etc/udev/rules.d/61-sunxi-fel.rules
sudo udevadm control --reload
cp .env.example .env && chmod 600 .env
```
- Verify: `sunxi-fel --help | head -1` prints usage; `ls -l .env` shows `-rw-------`.
- STOP — HUMAN: type the `sudo` password.
- STOP — HUMAN: in PowerShell run `usbipd policy add --effect Allow --operation AutoBind --hardware-id 1f3a:efe8` and the same with `1f3a:1001`; run `Get-StorageSetting | Select NewDiskPolicy`, write down the value, then `Set-StorageSetting -NewDiskPolicy OfflineAll`. Restore the noted value at the end of phase 9.

### Phase 3. Build U-Boot
```
sudo apt install -y gcc-arm-linux-gnueabihf bison flex swig device-tree-compiler python3-pyelftools python3-setuptools libgnutls28-dev uuid-dev git make
uboot/build.sh ~/frame-uboot-build
```
- `python3-*` here are U-Boot's own build dependencies from apt, not project code.
- Ask the human before running `apt`: it installs system packages.
- Verify: `ls -l uboot/u-boot-sunxi-with-spl.bin uboot/sunxi-spl-ck5.bin` lists both; the script printed a SHA-256 for each.
- STOP — HUMAN: type the `sudo` password.

### Phase 4. Get `fel-sdboot.sunxi` and write the FEL card
- Get `uboot/fel-sdboot.sunxi` from linux-sunxi/sunxi-tools (`docs/unlock.md`, step 3; the build command there is marked unverified). Verify: `sha256sum uboot/fel-sdboot.sunxi` prints `19db8994363b1464a1de82e1c90ab63c5a985d1c96b57403d039d69bfff39f14`. On a different hash, stop; the human decides whether to edit `$ImageSha` in `write-sd.ps1`.
- STOP — HUMAN: insert the microSD card in the PC. In PowerShell run `Get-Disk` and note the card's Number and exact Size in bytes. Check twice.
- [DESTRUCTIVE] STOP — HUMAN: run in PowerShell `powershell -ExecutionPolicy Bypass -File <repo>\write-sd.ps1 -DiskNumber <n> -ExpectSize <bytes>`. It refuses unless the disk is a USB disk of that size, backs up the first 16 MiB to `backups\sd-<size>-first-16MiB.bin`, writes at offset 8192 and reads back.
- Verify: output contains `OK: fel-sdboot.sunxi written at offset 8192 and verified.`
- STOP — HUMAN: put the card into the frame's microSD slot. With the card in, RESET enters FEL mode; with it out, RESET boots Android.

### Phase 5. Show the eMMC to Windows
- STOP — HUMAN: plug the frame's USB-C directly into the PC (no hub). In PowerShell run `usbipd list` and read the frame's bus ID (looks like `1-13`; FEL mode shows `1f3a:efe8`).
- Put it in `.env` as `USBIP_BUSID=<id>` (the human edits it, or tells you the value).
```
scripts/boot-ums.sh
```
- STOP — HUMAN: press the frame's RESET pinhole when the script waits (it waits up to 30 minutes).
- Verify: output ends with `eMMC visible to Windows` and a table with size `15758000128`.
- If it prints `warm-up SPL did not return`: STOP — HUMAN: press RESET, rerun. If the disk drops, rerun the script.

### Phase 6. Back up and verify the eMMC
- [DESTRUCTIVE if skipped] This is the only recovery for a bricked frame. About 90 minutes.
- STOP — HUMAN: in PowerShell run `powershell -ExecutionPolicy Bypass -File <repo>\backup-emmc.ps1` (add `-ExpectSize <bytes>` if the disk size differs). If the second pass is interrupted, `verify-backup.ps1` (with `-Start <offset>` after a disk drop).
- Verify: output contains `OK: ... verified by two independent reads.` and `ls -l backups/emmc-full.img backups/emmc-full.img.sha256` lists both.
- Then check the system partition read-only (WSL): `fdisk -l backups/emmc-full.img`; find the start sector and size of partition 7; `dd if=backups/emmc-full.img of=system.img bs=512 skip=<start> count=<sectors>`; `e2fsck -fn system.img`; `rm system.img`. Expect only `padding at end of inode bitmap`.
- STOP — HUMAN: copy `backups/` somewhere private. Do not continue until they confirm.

### Phase 7. Derive the patch
```
tools/derive-sector.sh
debugfs -R 'stat /property/persist.sys.usb.config' 'backups/emmc-full.img?offset=2235564032'
```
- Verify: first command prints `bytes changed (expect up to 12): 12` and `sector <n>, in-sector offset <m>; wrote patch/sector-...`; `ls patch/` lists `sector-number.txt sector-orig.bin sector-adb.bin`; second command prints `File not found`.
- If it prints `'persist.sys.usb.config=mass_storage' not found`, stop. Set `SYSTEM_PART=<n>` only if the system partition is not number 7. On other firmware, take the data-partition offset from `fdisk -l`.

### Phase 8. Apply the patch
- [DESTRUCTIVE] Writes one 512-byte sector of the frame's eMMC. Needs the human's OK for this step and a verified backup.
- STOP — HUMAN: with the disk still visible, in PowerShell run `powershell -ExecutionPolicy Bypass -File <repo>\patch-sector.ps1` (add `-ExpectSize <bytes>` if needed).
- Verify: output contains `OK: sector <n> now holds the ADB-enabled build.prop line, verified by readback.`
- Undo: rerun `scripts/boot-ums.sh`, then `patch-sector.ps1 -Restore`.

### Phase 9. First boot with ADB
- STOP — HUMAN: remove the microSD card (keep it), press RESET, and in PowerShell run `usbipd list` then `usbipd attach --wsl --busid <id>` (frame is now `1f3a:1001`). Restore the Windows disk policy noted in phase 2: `Set-StorageSetting -NewDiskPolicy <noted value>`.
```
adb devices
adb shell id
```
- Verify: the frame is listed and `adb shell id` prints `uid=0(root)`.
- Log the unlock in the private change log.

### Phase 10. Server install: files and library
Server paths below use `/srv/frame`, web user `www-data`, `<cron-user>` for the cron user. Ask the human to confirm them.
```
sudo install -d -o $USER -g $USER /srv/frame
rsync -rlpt --chmod=D2750,F0640 --chown=<cron-user>:www-data web/app/ you@server:/srv/frame/app/
rsync -rlpt --chmod=D2750,F0640 --chown=<cron-user>:www-data web/public/ you@server:/srv/frame/public/
scp web/composer.json web/composer.lock web/config.example.php you@server:/srv/frame/
cd /srv/frame && composer install --no-dev --optimize-autoloader && chmod 700 vendor && chmod 0640 composer.json composer.lock config.example.php
sudo install -d -o www-data -g $USER -m 2750 /srv/frame/var
sudo install -d -o $USER -g www-data -m 2750 /srv/frame/cache
```
- Run server commands in one batched SSH session if the server is remote.
- `--chown` needs root on the server; as another user rsync skips it silently. If `ls -l /srv/frame/app` does not show group `www-data`, run `sudo chgrp -R www-data /srv/frame/app /srv/frame/public` on the server instead (section 6, rule 2).
- Verify: `ls -ld /srv/frame/var /srv/frame/cache` shows `drwxr-s---` with the owners above; `php -v` on the server shows 8.5.
- Required PHP extensions: `pdo_sqlite curl mbstring gd dom xmlreader xmlwriter zlib`. Check with `php -m`.
- STOP — HUMAN: type `sudo` passwords; give SSH access.

### Phase 11. Config and Apache
```
cd /srv/frame && cp config.example.php config.php && sudo chgrp www-data config.php && chmod 0640 config.php
```
- STOP — HUMAN: provide `frames_url` (must start with `https://`, end with `/frames/`), `owner_name`, `calendar_hosts`, `photo_hosts`. Edit `config.php` with those. Unknown keys are an error.
- Apache: `sudo a2enmod alias env dir setenvif`; add to the HTTPS virtual host: `SetEnv FRAME_APP_DIR /srv/frame/app`, `Alias /frames /srv/frame/public`, and `<Directory /srv/frame/public>` with `AllowOverride AuthConfig FileInfo Indexes Options` and `Require all granted`. Then `sudo apachectl configtest && sudo systemctl reload apache2`.
- STOP — HUMAN: approve and apply the virtual-host change.
- Verify: `sudo -u www-data php -r 'require "/srv/frame/app/bootstrap.php"; echo "ok\n";'` prints `ok`. A bad value prints `frame: config error:` plus the key.
- PHP settings: `post_max_size` and `upload_max_filesize` at least `31M`.

### Phase 12. Migrate
```
sudo -u www-data php /srv/frame/app/bin/migrate.php
```
- Verify: lines `applied 1` ... then `schema at 14` (the number grows with each new migration). `ls -l /srv/frame/var/` shows `frames.db` as `-rw-r----- www-data <cron-user>`.
- `WARNING (photos)` / `NOTE (photos)` lines on stderr do not fail the migration. They are handled in phase 15.
- STOP — HUMAN: type the `sudo` password.

### Phase 13. Admin user
```
sudo -u www-data php /srv/frame/app/bin/user.php add <name>
```
- STOP — HUMAN: choose and type the password (12+ characters, twice, echo off). Never take a password as an argument or put one in a file. Recommend `totp-enable <name>`; the human scans the secret and types the code.
- Verify: `sudo -u www-data php /srv/frame/app/bin/user.php list` lists the user.

### Phase 14. Cron fetcher and server checks
```
php /srv/frame/app/fetch/fetch.php
crontab -e     # add the line below
* * * * * /usr/bin/timeout -s KILL 58 /usr/bin/php /srv/frame/app/fetch/fetch.php >/dev/null
```
- Run the first command as the cron user. It takes about 45 seconds and prints nothing when well. Verify: `ls -l /srv/frame/cache/` shows `cache.db` and `fetch.lock`.
- STOP — HUMAN: add the crontab line (or approve you doing it). Check `command -v php` for the path.
- Verify HTTP (replace host):
  - `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://frames.example.com/frames/admin/` prints `303 .../frames/admin/login`
  - `curl -s -w '\n%{http_code}\n' https://frames.example.com/frames/admin/api/session` prints `"code":"auth_required"` and `401`
  - `curl -s -w '\n%{http_code}\n' https://frames.example.com/frames/api/v1/nope` prints `"code":"not_found"` and `404`
  - `curl -s -o /dev/null -w '%{http_code}\n' https://frames.example.com/frames/` prints `401`
  - Stray PHP refusal: create `x.php` and `x.phar` in `/srv/frame/public`, `curl` each, expect `403` for both, then delete both.
- STOP — HUMAN: sign in at `https://frames.example.com/frames/admin/` to confirm the login works.

### Phase 15. Photo decoder sandbox (only if the human wants uploads: admin upload or drop links)
Skip if photos come only from a Nextcloud album. Uploads then stay off; frames are unaffected.
Decide (ask the human where a fact is missing):
1. Packages: `sudo apt install bubblewrap libheif-examples libheif-plugin-libde265 php8.5-gd` (`php-gd` can pull another PHP version's GD; HEIC needs the two libheif packages; `prlimit`, `nice` are normally present).
2. Run `sudo -u www-data php /srv/frame/app/bin/migrate.php` and read the `WARNING (photos)` lines.
3. Choose `sandbox` (under `widgets` > `photos` in `config.php`):
   - `bwrap` (default): use if `bwrap` works. No warning about bubblewrap means it works.
   - `user`: use when `bwrap` is blocked. On Ubuntu 24.04 `sysctl kernel.apparmor_restrict_unprivileged_userns` prints `= 1` and `bwrap` fails with `setting up uid map: Permission denied`. Recommended there.
   - `auto`: `bwrap` if it works, else no sandbox. Not for drop links.
   - `none`: no sandbox. Only if the human alone uploads from their own computer and uses no drop links, and accepts the risk in writing in this conversation.
   - Drop links (relatives upload) require `bwrap` or `user`.
4. Mode `user` setup: the root script is the fenced `sh` block under "Sandbox mode user: a dedicated decoder account" in `docs/photos.md`. Copy it verbatim to a file outside the repo, set `APP`, `VAR`, `PHP`, `HEIF`, `WEB` at its top to match the server (`HEIF` is `/usr/bin/heif-convert` on Ubuntu 24.04, which has no `heif-dec`), show it to the human, and have the human run it. Do not retype it. It installs a `framephoto` user, a root-owned `/usr/local/bin/frame-photo-run`, a sudoers rule, ACLs, and a firewall rule; it never installs or removes a firewall manager. It is safe to rerun.
   - STOP — HUMAN: review, then run as root (`sudo -i`, `sh <file>`).
   - Then add `'widgets' => ['photos' => ['sandbox' => 'user']]` to `config.php`, and run the checks block from the same section of `docs/photos.md`.
   - Verify: `sudo -u www-data sudo -n -u framephoto id` fails; `sudo -u framephoto cat /srv/frame/var/frames.db` is `Permission denied`; the `find ... -print` check lists only files under `app/widgets/photos/`; `migrate.php` prints `schema at ...` and no `WARNING (photos)` lines.
5. After a failed check is fixed, delete `var/photos/.sandbox-state`, `.sandbox-state-user` and `.probe-ok` to retest now.

### Phase 16. Kiosk keys and `.env`
- STOP — HUMAN: edit `.env`: `FRAME_SERVER_URL` (HTTPS, trailing slash, equal to `frames_url`), `KIOSK_PIN` (4-8 digits, chosen by the human), `KEYSTORE_PASS` (chosen by the human), `OWNER_NAME`, `ADB_ALLOW`, `NTP_SERVER`, `TZ`, `RELEASE_KEY`, `RELEASE_PUBKEY`. Do not read or echo `.env`. The reader is strict: unknown keys and CRLF line endings stop every script.
- Make the release key BEFORE the first build (the public half is baked into the app; a build without it refuses every self-update):
```
mkdir -p ~/.android
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ~/.android/frame-release-key.pem
openssl pkey -in ~/.android/frame-release-key.pem -pubout -out ~/.android/frame-release-pub.pem
chmod 600 ~/.android/frame-release-key.pem
```
- The APK keystore is created by the first build at `KEYSTORE` (default `~/.android/frame-kiosk.keystore`) with `KEYSTORE_PASS`. Keep it under `$HOME` (Lando sees only that).
- Verify: `ls -l ~/.android/` shows the release key (`-rw-------`) and public key.
- STOP — HUMAN: back up the release key and the keystore somewhere private and offline. Irreversible if lost.

### Phase 17. Get the files you provide, prepare the system partition
- STOP — HUMAN: download into `~/frame-files/` (outside the repo): WebView 106 arm APK as `webview.apk` (LineageOS prebuilt; SHA-256 `e2edc1f89ec1608b169da9e7ffc037190b70c7825f0be29694f4cae719980580`), and `isrgrootx1.pem` from letsencrypt.org/certs/isrgrootx1.pem. Verify `sha256sum ~/frame-files/webview.apk` matches.
- [DESTRUCTIVE: edits `/system`; keep the `.bak` files] Needs the human's OK for this step. Commands are in `docs/provisioning.md`, step 4: enable Wi-Fi ADB (`service.adb.tcp.port=5555` in `build.prop`), disable `com.yhk.qeota` / `com.DeviceTest` / `com.android.gl2jni`, install WebView 106 (unpack `lib/armeabi-v7a/*.so`, push, copy to `/system/app/webview/`), install the Let's Encrypt root as `6187b673.0` in `/system/etc/security/cacerts/`. Wrap each `/system` change in `mount -o remount,rw /system` and `remount,ro`. Do not disable `com.efercro.os.aimor` yet.
- `adb reboot`, `adb wait-for-device`, then verify: `adb shell dumpsys package com.android.webview | grep versionName` prints `versionName=106.0.5249.126`.
- Log each change with its undo.

### Phase 18. Build the kiosk
```
cd kiosk && lando start && lando build-kiosk
# later builds: lando build-kiosk -- --version-code 16 --version-name 16
```
- Verify: output has `built kiosk/build/frame-kiosk.apk`, `versionCode N`, a `sha256`, and `signature kiosk/build/frame-kiosk.apk.sig`. A line starting `WARNING:` means a release key setting is missing; stop and fix `.env` before relying on self-update. `RELEASE_KEY and RELEASE_PUBKEY are not a pair.` means mismatched keys.
- STOP — HUMAN: first `lando start` downloads Docker images; approve it. Never use `--test-crash` except for a watchdog test on the human's own frame; never upload such a build.
- `build.sh` can run natively, but that installs a JDK and Android tools on the host. Do not.

### Phase 19. Provision the frame
Run in this order, each only after the human OKs it. Each command prints its plan and waits for `y`.
```
scripts/provision.sh kiosk kiosk/build/frame-kiosk.apk
adb reboot
adb shell dumpsys device_policy | grep -A2 'Device Owner'
adb shell dumpsys activity activities | grep -i locktask
scripts/provision.sh time
scripts/provision.sh boot-script
```
- Add `-s <serial>` (before the subcommand, and on `adb`) when several devices are connected.
- [DESTRUCTIVE, hard to undo] `kiosk` makes the app device owner (lock-task; blocks factory reset and safe boot). Undo: `adb shell am broadcast -n com.calevans.frame/.AdbReceiver -a com.calevans.frame.action.RELEASE_DEVICE_OWNER`. It stops if the frame has more than one user or any account.
- Verify (`kiosk`): the Device Owner line names the kiosk package and `mLockTaskModeState=LOCKED`.
- Verify (`time`): it prints the NTP server and time zone back.
- Verify (`boot-script`): output `Installed and verified.` Then reboot and `adb logcat -d -s frame-fixmac`. Run `boot-script` after `kiosk`, never before. Rerun it after changing `ADB_ALLOW`.
- STOP — HUMAN: answer `y` at each `Proceed? [y/N]` prompt.
- Optional boot logo and animation: `docs/provisioning.md`, step 10 (`tools/make-bootanimation.php`). Back up the stock files first.

### Phase 20. Wi-Fi and claim
- STOP — HUMAN: `adb reboot`. On the frame, pick the Wi-Fi network in the picker and type the password. Read the six-digit pairing code from the screen.
- STOP — HUMAN: in the admin, sign in, select **Claim a frame**, enter the code, **Find frame**, name it, **Claim frame**. The code is valid 24 hours. You cannot do this: it needs the admin login.
- Optional: set the frame's PIN in the admin (frame, **Security**, **Set PIN**).
- Verify: `adb connect 192.0.2.50:5555` (the frame's DHCP address), then `adb shell id` prints `uid=0(root)`. The admin shows the frame as active.

### Phase 21. Widgets
- STOP — HUMAN: in the admin, open the frame, **Widgets**, turn on what they want. Library items (weather locations, calendar links) are added under **Library**; Nextcloud album links in the frame's **Photos** section; Home Assistant URL and token per frame; card designs under **Card designs**. Secret values are entered only in the admin UI, never by you. Details: `docs/widgets.md`, `docs/photos.md`.
- Set the time zone in the admin (frame, **Schedule**, **Time zone**), never by editing the database.
- Verify: the frame shows the dashboard within a minute.

### Phase 22. First self-update
Always target one frame first. Needs a release key from phase 16.
```
cd kiosk && lando build-kiosk -- --version-code <higher> --version-name <label>
sudo -u www-data php app/bin/kiosk-release.php add frame-kiosk.apk frame-kiosk.apk.sig --notes "text"
sudo -u www-data php app/bin/kiosk-release.php list
```
- Run the `kiosk-release.php` commands on the server from `/srv/frame` after copying both files to a folder the web user can read. Or upload in the admin (**Library**, **Kiosk app**, **Upload release**).
- Version code must be higher than every earlier release; a lower number is never installed.
- STOP — HUMAN: in the admin, open the frame, **Details**, **Kiosk version**, choose the release (password re-check).
- Verify: the row shows **Up to date** and `list` shows `proven`. Only then the human may select **Roll out to all** (password re-check).
- A frame without the boot-script watchdog accepts only an already-proven release.

## 5. Contributing

Work on `master`-based branches. Open a merge request only when the human asks.

Install the hook once per clone:
```
ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
```
- It deletes `*:Zone.Identifier` files and runs `scripts/leak-check.sh` on staged files. It refuses private file types and any staged text matching a pattern in `.leak-patterns`. Without `.leak-patterns` it refuses the commit.
- STOP — HUMAN: create `.leak-patterns` (one extended regex per line: their own name, hosts, addresses). It is gitignored. You do not invent or commit it. `.leak-allow` lists files exempt from content matching.
- Before pushing: `scripts/leak-check.sh --all`.

Set up a dev copy (from `web/`):
```
composer install
cp config.example.php config.php
mkdir -p /tmp/frame-dev/var /tmp/frame-dev/cache
export FRAME_VAR_DIR=/tmp/frame-dev/var FRAME_CACHE_DIR=/tmp/frame-dev/cache
php app/bin/migrate.php
php app/bin/user.php add dev
php -r 'require "app/bootstrap.php"; require "app/fetch/store.php"; cache_rw();'
php -S 127.0.0.1:8099 -t public tests/dev-router.php
```
Admin: `http://127.0.0.1:8099/frames/admin/`. Env overrides work only on the CLI and PHP's built-in server. `user.php add` asks for a password: STOP — HUMAN, or pipe one line from stdin using a throwaway dev password.

Run tests after every change:

| What | Command (from `web/` unless noted) |
| --- | --- |
| PHP suite | `php tests/run.php` (prints `N passed, M failed`; non-zero exit on failure) |
| Static analysis | `vendor/bin/phpstan analyse` (level 8, must be clean) |
| Photo decoder + sandbox | `tests/run-in-docker.sh` (Docker; run after any change under `app/widgets/photos/`) |
| Rollback watchdog checks | `bash ../scripts/tests/watchdog_test.sh` |
| `.env` reader | `bash ../scripts/tests/env_test.sh` |
| Photo decoder wrapper | `../scripts/tests/frame-photo-run-test.sh` |

- The host run uses sandbox mode `none` (`tests/config.test.php`); sandbox and HEIC paths run only in the container.
- No test framework. Helpers: `test()`, `eq()`, `throws()`, `fresh_db()`, `seed_cache_db()`. Core tests go in `web/tests/<area>_test.php`; widget tests in `web/app/widgets/<id>/tests/<name>_test.php`. Every security control gets a test that fails when the control is removed.
- Done means: tests green, PHPStan clean, and the changed behavior exercised against the dev server (not only by tests).

JavaScript syntax check (there is no JS linter and no Node):
- Each frame widget is its own script, so a syntax error loses that widget. Check every `.js` file you edit by parsing it with any JS engine you have; never add one to the repo.
- Approach: a JDK 8-14 image provides `jjs` (Nashorn; removed from JDK 15 and later, so the Lando kiosk image, JDK 17, does not have it). Mount the source read-only and parse without running (for example `jjs --parse-only <file>`). This flag and approach are not verified in this repo; check `jjs -help` first. If it does not work, ask the human which JS engine to use. Do not add Node or Python to the project.

Adding a widget: follow `docs/dev/widgets.md` ("Build your first widget"). One folder `web/app/widgets/<id>/` with `widget.php`, `icon.svg`, optional `frame/`, `admin/`, `routes.php`, `fetch.php`, `tests/`. Do not edit core files to register it. Register nothing by name. A widget that needs a table adds a core migration.

API changes: update `docs/dev/api.md` in the same change. The kiosk's self GET is a frozen contract: add keys, never rename or remove. Every request body goes through `request_json()`. Secrets travel only on the `Authorization` header path. Kiosk update protocol changes: `docs/dev/kiosk-update-protocol.md`.

Migrations (`docs/dev/database.md`):
1. Add `web/app/migrations/NNN_name.sql` (next number; never edit an applied one). SQL only.
2. Raise `SCHEMA_VERSION` in `web/app/bootstrap.php` to `NNN`.
3. Update `web/tests/migrate_test.php`.
4. Tables are `STRICT`; timestamps are integer UTC seconds; hashes are lowercase hex with `CHECK`.
5. Mention the new schema number in `docs/server-install.md` only if it quotes the number.

Kiosk changes: bump the version code on release; keep `Api.APP_VERSION` (contract level) separate from `versionCode`. Never add a restriction that blocks app installs. Java package `com.calevans.frame` never changes.

Security review: before calling work done, ask for (or perform with the human) a security review of any change touching authentication, sessions, uploads, file writes, outbound requests, headers/CSP, device scripts (`device/frame-fixmac.sh`, `scripts/provision.sh`), the photo worker or sandbox, `.htaccess`, or secrets. Report vulnerabilities in a confidential GitLab issue, not a public one.

Docs: write for readers mid-task; plain words; placeholders only; update `docs/` in the same change as behavior. Mark anything you did not test as unverified.

## 6. Deploying the server for a human

Only when the human asks, and only to the host they name. Use one batched SSH session. Do not poll.
Rules:
1. Do not deploy `config.php`, `var/`, `cache/` or `vendor/` from the repo. Deploy only `web/app/` and `web/public/`.
2. Do not use `rsync -a` from a checkout with local permissions. Set owner, group and modes explicitly, files `0640`, directories `2750`, group = web group (`www-data`):
```
rsync -rlpt --chmod=D2750,F0640 --chown=<owner>:www-data web/app/ you@server:/srv/frame/app/
rsync -rlpt --chmod=D2750,F0640 --chown=<owner>:www-data web/public/ you@server:/srv/frame/public/
```
   `--chown` needs privileges on the receiving side; if it fails, set owner and group afterwards with `sudo chown -R` and `sudo chmod`. Verify with `ls -l`. Not verified here: adjust to the server.
3. Do not pass `-A` or `-X`: without them rsync leaves the existing ACLs alone, and new files in `app/widgets/photos/` inherit the folder's default ACL, so the `framephoto` user keeps read access. After a deploy, in `user` sandbox mode, rerun the `find ... -print` check from `docs/photos.md`. If `app/widgets/photos/bin/frame-photo-run` changed:
   `sudo install -o root -g root -m 0755 /srv/frame/app/widgets/photos/bin/frame-photo-run /usr/local/bin/frame-photo-run`
4. Ask before using `--delete` (removes files a release deleted). Never use it on a directory that holds anything but repo code.
5. Frames reload when the newest modification time under `app/` (files `.php .js .css .sql .svg`) changes. rsync keeps source times, which may be older than the server's. Always touch one file after deploy: `touch /srv/frame/app/bootstrap.php`.
6. Run the migration as the web user after every deploy. Until it finishes, every request answers `503 maintenance`:
   `sudo -u www-data php /srv/frame/app/bin/migrate.php`
   STOP — HUMAN: type the `sudo` password. Verify the output ends with `schema at <N>` and read any `WARNING` lines.
7. If `web/composer.lock` changed, copy it and `composer.json` and rerun `composer install --no-dev --optimize-autoloader` in `/srv/frame` as the cron user.
8. A server deploy does not update the kiosk app on the frames. Use phase 22.
9. Verify with the `curl` checks from phase 14 (once, not in a loop).

## 7. Troubleshooting pointers

Read `docs/troubleshooting.md` first; it is organized by symptom. Shortcuts:

| Symptom | Section |
| --- | --- |
| Every request returns 503 `maintenance` | "Every request answers 503 maintenance": run `migrate.php` as the web user |
| 500 on a new install | "Every request answers 500 on a new install": no database yet |
| 500 `not_configured` | "The server answers 500 server not configured": read `frame: config error:` in the PHP log |
| Admin 500 | "The admin answers 500": `frames_url` not HTTPS, or `AllowOverride` incomplete |
| Uploads refused | "Photo uploads are off", "An upload fails with 413"; `docs/photos.md`, "Troubleshooting" |
| No pairing code on the frame | "The frame never shows a pairing code" |
| Clock says 1969 | "The frame's clock is wrong" |
| Cannot reach the frame over Wi-Fi ADB | "I cannot connect over the network": bad `ADB_ALLOW`; use USB, fix `.env`, rerun `provision.sh boot-script` |
| Frame will not boot | "The frame does not boot": power-cycle, remove the card, ADB over USB, FEL + `scripts/boot-ums.sh`, `patch-sector.ps1 -Restore`, then restore `backups/emmc-full.img` |
| Update not taken | `docs/kiosk-updates.md`, "Failure states" |
| Lost PIN / keystore / release key | "I lost the PIN", "I lost the APK keystore or the release key" |
| Locked out of admin | `user.php passwd <name>` or `totp-disable <name>` as the web user |

Useful facts:
- Server log lines start `frame:`, `frame admin:`, `frame photos:`. Read the PHP error log first.
- `adb` after a reboot: `adb disconnect` then `adb connect <ip>:5555`.
- Use `adb exec-out`, not `adb shell`, for binary files. Do not run `grep -r /` on the frame.
- Writing `backups/emmc-full.img` back is a raw whole-disk write with no script. Never do it without the human choosing the exact disk number.
- `tools/derive-sector.sh`, `*.ps1` and `provision.sh` stop on anything unexpected. Treat a stop as information; report it, do not bypass it.
