Developers

Developer start

On this page

This page is for people who want to change the code: fix a bug, add a widget, or understand how the pieces fit. When you finish it you will know where everything lives, how a request travels through the server, how to run the tests and a local server, how to build the kiosk, and which rules the project does not bend.

If you want to run the system rather than change it, start with the documentation home instead.

Repository map

text
.
├── docs/                    Documentation
│   └── dev/                 These developer pages
├── web/                     The server: PHP 8.5 and SQLite
│   ├── public/              The two front controllers and their .htaccess files
│   │   ├── index.php        Frames, dashboard, public pages
│   │   └── admin/index.php  The admin
│   ├── app/                 Everything the front controllers load
│   │   ├── bootstrap.php    Config, constants, database helpers, errors
│   │   ├── auth_admin.php   Admin passwords, sessions, TOTP, re-authentication
│   │   ├── auth_frame.php   Frame key check
│   │   ├── widgets.php      Widget registry and hook runners
│   │   ├── routes/          Core routes (frames, admin, admin login, kiosk releases)
│   │   ├── widgets/<id>/    One folder per widget
│   │   ├── fetch/           The cron fetcher: the only code that goes out to the internet
│   │   ├── migrations/      001 to 014
│   │   ├── bin/             migrate.php, user.php, kiosk-release.php
│   │   ├── assets/          Core frame.js, admin.js and their CSS
│   │   └── views/           Dashboard, admin shell, login page
│   ├── tests/               Test runner and tests
│   └── config.example.php   Copy to config.php
├── kiosk/                   The Android app: Java, no Gradle
│   ├── src/com/calevans/frame/
│   ├── tools/               PinHash.java, ReleaseSign.java
│   └── build.sh             Builds the signed APK
├── device/frame-fixmac.sh   Boot script that runs as root on the frame
├── scripts/                 provision.sh, boot-ums.sh, leak-check.sh, pre-commit.sh, tests
├── tools/                   Boot animation and unlock helpers
├── uboot/                   Unlock patch and build files (GPL-2.0, kept apart)
└── .env.example             Settings for the kiosk build and provisioning

The Java package is com.calevans.frame. It is the app's identity on every installed frame, so it does not change.

How the pieces fit

flowchart LR
    subgraph device["Frame, Android 6"]
        native["Kiosk app<br/>native Java"]
        webview["WebView<br/>frame.js and widget scripts"]
        native --- webview
    end
    subgraph server["Server"]
        fc["public/index.php<br/>frame front controller"]
        ac["public/admin/index.php<br/>admin front controller"]
        reg["Widget registry<br/>app/widgets/*/widget.php"]
        fdb[("frames.db<br/>your decisions<br/>the web user writes")]
        cdb[("cache.db<br/>fetched data<br/>cron writes")]
        vard[("var/<br/>photos, kiosk releases,<br/>card art, sessions")]
        cron["app/fetch/fetch.php<br/>cron, every minute"]
        worker["Image worker<br/>separate process"]
    end
    adminjs["Admin browser<br/>admin.js and widget panels"]
    src["Sources<br/>weather.gov, calendar feeds,<br/>Nextcloud albums"]
    ha["Home Assistant<br/>LAN, optional"]
    adminjs -->|"HTTPS, session, CSRF"| ac
    native -->|"self, telemetry,<br/>screenshots, downloads"| fc
    webview -->|"dashboard JSON, assets"| fc
    fc --> fdb
    fc -.->|"read only"| cdb
    ac --> fdb
    ac --> vard
    fc --> reg
    ac --> reg
    fc --> worker
    ac --> worker
    cron -.->|"read only"| fdb
    cron --> cdb
    cron -->|"HTTPS, allow-listed hosts"| src
    native -.-> ha

Three rules hold this shape together:

  • Two databases, two writers. frames.db holds your decisions and only the web user writes it; cron opens it read-only. cache.db holds fetched data and only cron writes it; the web user opens it read-only. Neither process can corrupt the other's file.
  • Only the fetcher goes out to the internet. Web code never makes an outbound request. A test enforces this for every widget.
  • The kiosk is thin. Layout and behavior decisions are made on the server. The kiosk polls, shows the page, and does the few things only native code can (install, dim, capture the screen, hold secrets).

How a request is handled

Frame front controller (public/index.php):

  1. bootstrap.php reads config.php, defines the constants and the database helpers.
  2. The path must start with /frames.
  3. frames_dispatch() builds the route list: the core routes first, then every widget's routes.php.
  4. Every route that matches the path is noted; the first one with the right method runs. If none matches the path, the answer is 404; if some match but not the method, 405 with Allow.
  5. Each handler authenticates itself (frame_auth() for frames, a link secret for public pages) and ends with json_out(), empty_out() or an asset response. These never return.

Admin front controller (public/admin/index.php):

  1. Bootstrap and the fixed security headers. Refuse to run unless frames_url is HTTPS.
  2. Work out the path after /admin. Only that part comes from the request.
  3. /login and /assets/admin.css need no sign-in. Everything else calls admin_auth(): no valid session means 303 to the login page for /, and 401 auth_required for anything else.
  4. /api/… goes to admin_api(), which checks the origin, matches the route (core routes first, then each widget's admin_routes.php), and requires the CSRF token for anything that is not a GET.
  5. /assets/admin.js, a widget's admin/widget.js, and / (the shell) are the only other things served.

All errors travel as one exception type, HttpError, and are turned into JSON {"error", "code"} (plus fields for validation) by one handler. See the API reference.

Principles

These come from the owner's constraints and from past security reviews. A change that breaks one will be asked to change.

Technology

  • PHP 8.5 and SQLite. Plain functions and a few small classes. No framework, no ORM.
  • No Node and no Python anywhere in project code, tests or tooling. JavaScript is plain scripts that run in the browser; there is no bundler and no JavaScript linter, so each frame widget is its own script (a syntax error in one loses that widget, not the page).
  • Composer packages are few and approved. Today the only runtime dependency is sabre/vobject (calendar parsing), and it is loaded by the fetcher only. PHPStan is a development tool. Ask before adding a package.
  • The kiosk is Java built with Ubuntu's Android tools. No Gradle, no downloaded SDK.

Security

  • Content Security Policy is strict: default-src 'none', scripts and styles only from 'self'. No unsafe-inline, no unsafe-eval.
  • No innerHTML, no document.write, no eval, no inline style=, no on…= handlers, no <script> in fragments. Build DOM with textContent and createElement. The admin has a small h() helper for this. Setting element.style from script is allowed.
  • Core code names no widget. If you write a widget's id in app/*.php, routes/, fetch/, views/, bin/ or public/, a test fails (two documented exceptions).
  • Validate at the edge. Every request body goes through request_json() (allowed fields, size limit, 415 for the wrong type, 422 for unknown fields). Check formats with a regular expression anchored with ^…$.
  • Secrets stay on the header path and never appear in dashboard JSON or admin responses. Read Security before you touch authentication, updates or uploads.

Code style

  • Every PHP file starts with declare(strict_types=1);. PHPStan runs at level 8 and must be clean.
  • Comments explain why, not what. A comment that restates the next line goes.
  • Database: STRICT tables, Unix integer timestamps, AUTOINCREMENT only where ids must never be reused. See Database.
  • State the current behavior in comments and docs, not the history.

Set up a development copy

You need PHP 8.5 on the command line with these extensions: pdo_sqlite, mbstring, curl, gd (with JPEG, PNG and WebP), zlib, dom, xmlreader, xmlwriter, and openssl if you set kiosk_release_pubkey. The Argon2id password hash needs PHP built with Argon2; without it the code uses bcrypt. You also need Composer.

From the repository root:

sh
cd web
composer install
cp config.example.php config.php          # gitignored; the example values are fine for development

Keep the data outside the repository and point the app at it. The environment overrides work only for the command line and PHP's built-in server; a web server under Apache ignores them.

sh
mkdir -p /tmp/frame-dev/var /tmp/frame-dev/cache
export FRAME_VAR_DIR=/tmp/frame-dev/var
export FRAME_CACHE_DIR=/tmp/frame-dev/cache

php app/bin/migrate.php                   # creates frames.db, prints "schema at 14" (the number grows with each new migration)
php app/bin/user.php add dev              # asks for a password twice
php -r 'require "app/bootstrap.php"; require "app/fetch/store.php"; cache_rw();'   # creates an empty cache.db

The web side opens cache.db read-only, so it must exist before the first dashboard request. In production the cron fetcher creates it.

Run the development server

sh
php -S 127.0.0.1:8099 -t public tests/dev-router.php

tests/dev-router.php does what the .htaccess FallbackResource rules do under Apache: it strips a leading /frames for routing and sends everything to the right front controller. Then:

Address What you get
http://127.0.0.1:8099/frames/admin/ The admin. Sign in as dev.
http://127.0.0.1:8099/frames/api/v1/frames The frame API (POST to enroll)

Under the built-in server the admin accepts plain http, because that is the only case where it does not insist on HTTPS. Whether your browser keeps Secure cookies from a plain-http localhost address varies by browser; this has not been checked here. The tests sign in with curl and do not depend on a browser.

To act as a frame without a device, enroll with curl, claim the frame in the admin using the pairing code, then call the API with the key you generated. The API reference shows each request.

Change the server code

There is no build step. Edit a file and reload. The dashboard page reloads itself when the code version changes (the newest modification time under app/, plus the frame's config_version).

Run the tests

All commands run from web/ unless noted.

What Command Notes
The whole PHP suite php tests/run.php Prints N passed, M failed; exit code is non-zero on any failure
Static analysis vendor/bin/phpstan analyse Level 8 over app/ and public/; widget tests/ folders are excluded
Photo decoder with the sandbox tests/run-in-docker.sh Needs Docker. Builds an image with bubblewrap and heif-dec, then runs the whole suite inside it
Boot-script validation bash ../scripts/tests/watchdog_test.sh Tests the version-number checks of the rollback watchdog under bash and dash
.env parsing bash ../scripts/tests/env_test.sh
The photo-decoder wrapper ../scripts/tests/frame-photo-run-test.sh Runs a copy of the wrapper with its paths changed; needs neither root nor sudo

Run the PHP suite and PHPStan after every change, and run tests/run-in-docker.sh after any change to app/widgets/photos/. The default host run uses sandbox mode none (set in tests/config.test.php), so the sandbox and HEIC paths are exercised only in the container.

How tests are written

There is no test framework. tests/run.php creates a temporary data folder, sets FRAME_VAR_DIR, FRAME_CACHE_DIR and FRAME_CONFIG (tests/config.test.php), loads bootstrap.php, and then requires every tests/*_test.php and every app/widgets/*/tests/*_test.php.

php
test('a name that says what must be true', function () {
    $db = fresh_db();                       // in-memory database with every migration applied
    eq(200, some_call($db), 'what this checks');
    throws(HttpError::class, fn() => some_call_that_fails($db));
});
Helper Use
test($name, $fn) Registers and runs one test. A thrown exception is a failure.
eq($expected, $actual, $what) Strict (===) comparison.
throws($class, $fn) The callable must throw that class.
fresh_db() An in-memory frames.db with all migrations.
seed_cache_db($dir) An empty cache.db for servers under test.
t_core($ids) Drops optional widget ids a test must not rely on.

HTTP tests start a real php -S on a free port with tests/dev-router.php and talk to it with curl. tests/admin_login_helper.php gives them al_user_insert() (a user with a known password), al_login() and al_session() (sign in and get the CSRF token).

Where to put a test:

  • Core behavior: web/tests/<area>_test.php.
  • A widget's behavior: app/widgets/<id>/tests/<name>_test.php. It travels with the widget and disappears when you delete the folder, which keeps widget isolation true.
  • A security control: always. Most controls in Security have a test that fails if the control is removed.

Build the kiosk

The kiosk builds with the included Lando setup, or natively if you have a JDK and the Android build tools.

  1. Copy .env.example to .env in the repository root and fill it in. FRAME_SERVER_URL (HTTPS, ending in /), KIOSK_PIN and KEYSTORE_PASS are required. RELEASE_KEY and RELEASE_PUBKEY enable self-update; without RELEASE_PUBKEY the kiosk refuses every update.
  2. From kiosk/, run lando build-kiosk. To set a version, pass arguments after --: lando build-kiosk -- --version-code 15 --version-name 15.
  3. Take the result from kiosk/build/frame-kiosk.apk. With RELEASE_KEY set, the build also writes frame-kiosk.apk.sig and prints the hash and version code.

What the script does, in order: writes BuildCfg.java from .env (the server address, owner name, release public key, and a salted PBKDF2 hash of the PIN, never the PIN itself); creates the keystore on first use; packages with aapt; compiles with javac --release 8; converts to a Dalvik executable with dx; aligns with zipalign; signs with apksigner using both v1 and v2 signatures; verifies both; and signs <sha256>:<versionCode> with the release key.

--test-crash builds an APK that crashes on start. It exists only to test the rollback watchdog; never upload one as a real release.

Installing the APK, becoming device owner and the rest of the frame setup are in the provisioning guide. The version rules for updates are in Kiosk update protocol.

Two numbers are easy to confuse. versionCode identifies a build and only ever increases. Api.APP_VERSION (in Api.java) is the contract level the kiosk reports as app_version; the server uses it to decide what the kiosk can do, for example screenshots need level 9 or higher.

Install the pre-commit hook

The repository is public and your working folder is not. A hook stops private files and private words from being committed.

sh
ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit

On every commit the hook:

  1. Removes *:Zone.Identifier files, which Windows adds next to anything copied in from Windows.
  2. Runs scripts/leak-check.sh on the staged files. It refuses private file types (.env, databases, config.php, .htpasswd, APKs, keystores, keys and certificates, disk images, boot binaries) and any staged text that matches a pattern in .leak-patterns.

Create two local files in the repository root. Both are gitignored, so the list of what is private is never published.

File Contents
.leak-patterns One extended regular expression per line (grep -E -i). Put your own name, host names, addresses and anything else that must not appear in a commit.
.leak-allow Exact file paths that may contain a listed word, one per line. For example, a code-of-conduct file with a contact name.

Without .leak-patterns the check refuses the commit rather than pass silently. scripts/leak-check.sh --all checks every tracked file, which is useful before you push. --types-only checks file types only and says so. Never commit with --no-verify.

The project name com.calevans.frame and the project's own GitLab path are removed before matching, because they are public by design.

Contribute

Issues and merge requests are welcome on GitLab. Before you open one:

  1. Run the PHP tests and PHPStan, and tests/run-in-docker.sh if you touched photos.
  2. Add or update tests for the behavior you changed.
  3. Read the security page if you touched authentication, uploads, updates, headers or the boot script.
  4. Report vulnerabilities in a confidential issue, not a public one.

Where to go next

You want to… Read
Add a feature to the frame Writing a widget
Call or implement an endpoint API reference
Change the self-update path Kiosk update protocol
Change the schema Database
Understand a security control Security