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
.
├── 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.dbholds your decisions and only the web user writes it; cron opens it read-only.cache.dbholds 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):
bootstrap.phpreadsconfig.php, defines the constants and the database helpers.- The path must start with
/frames. frames_dispatch()builds the route list: the core routes first, then every widget'sroutes.php.- 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,405withAllow. - Each handler authenticates itself (
frame_auth()for frames, a link secret for public pages) and ends withjson_out(),empty_out()or an asset response. These never return.
Admin front controller (public/admin/index.php):
- Bootstrap and the fixed security headers. Refuse to run unless
frames_urlis HTTPS. - Work out the path after
/admin. Only that part comes from the request. /loginand/assets/admin.cssneed no sign-in. Everything else callsadmin_auth(): no valid session means303to the login page for/, and401 auth_requiredfor anything else./api/…goes toadmin_api(), which checks the origin, matches the route (core routes first, then each widget'sadmin_routes.php), and requires the CSRF token for anything that is not aGET./assets/admin.js, a widget'sadmin/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'. Nounsafe-inline, nounsafe-eval. -
No
innerHTML, nodocument.write, noeval, no inlinestyle=, noon…=handlers, no<script>in fragments. Build DOM withtextContentandcreateElement. The admin has a smallh()helper for this. Settingelement.stylefrom script is allowed. - Core code names no widget. If you write a widget's id in
app/*.php,routes/,fetch/,views/,bin/orpublic/, a test fails (two documented exceptions). - Validate at the edge. Every request body goes through
request_json()(allowed fields, size limit,415for the wrong type,422for 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:
STRICTtables, Unix integer timestamps,AUTOINCREMENTonly 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:
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.
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
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.
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.
- Copy
.env.exampleto.envin the repository root and fill it in.FRAME_SERVER_URL(HTTPS, ending in/),KIOSK_PINandKEYSTORE_PASSare required.RELEASE_KEYandRELEASE_PUBKEYenable self-update; withoutRELEASE_PUBKEYthe kiosk refuses every update. - From
kiosk/, runlando build-kiosk. To set a version, pass arguments after--:lando build-kiosk -- --version-code 15 --version-name 15. - Take the result from
kiosk/build/frame-kiosk.apk. WithRELEASE_KEYset, the build also writesframe-kiosk.apk.sigand 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.
ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
On every commit the hook:
- Removes
*:Zone.Identifierfiles, which Windows adds next to anything copied in from Windows. - Runs
scripts/leak-check.shon 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:
- Run the PHP tests and PHPStan, and
tests/run-in-docker.shif you touched photos. - Add or update tests for the behavior you changed.
- Read the security page if you touched authentication, uploads, updates, headers or the boot script.
- 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 |