User Guide

Configure & deploy

Run the web admin, understand the config file, then package a native installer and turn a Raspberry Pi into a kiosk.

On this page

Web admin

Backend is Ktor (Kotlin/JVM, org.speculum.configserver); the frontend is React + Vite + TypeScript. The app and the server share one config.json — edit it here and the mirror reloads automatically.

Development (two processes)

# 1) backend on :8080 (run from repo root so it shares config.json + plugins/)
MIRROR_ADMIN_PASSWORD=secret ./gradlew :config-server:run

# 2) UI on :5173 (proxies /api → :8080)
cd config-server/web && npm install && npm run dev
# open http://localhost:5173

Production (single origin)

cd config-server/web && npm install && npm run build   # → web/dist
./gradlew :config-server:run                            # serves UI + API on :8080
# open http://localhost:8080

How config resolves: a non-empty modules list is authoritative; an empty list means "use every installed plugin's default placement" (the out-of-box mirror). The admin's first load shows all modules; saving captures an explicit, editable set.

Environment variables

VarDefaultPurpose
MIRROR_ADMIN_PASSWORDadminBootstrap password (see below)
MIRROR_ADMIN_SECRETbuilt-in stringHMAC key for tokens — set in prod
MIRROR_ADMIN_PORT8080HTTP port
MIRROR_ADMIN_DISABLED—1 disables the config server entirely
MIRROR_CONFIGconfig.json (cwd)Config file shared with the app
MIRROR_PLUGINSplugins/ (or app resources)Where to scan modules for the picker

Password & security

Change the admin password from the UI (Security card) or via POST /api/password. Once changed it is persisted as a salted PBKDF2-HMAC-SHA256 hash in admin-auth.json, next to the config file (~/.speculum/ by default) — the plaintext is never stored.

Bootstrap only: MIRROR_ADMIN_PASSWORD applies until the first change writes admin-auth.json, after which the stored hash wins and the env var is ignored. Delete admin-auth.json to revert. New passwords need ≥ 4 characters. In production, always set MIRROR_ADMIN_SECRET.

Package a native build

The app builds a native .deb via Compose Desktop + jpackage, bundling a JRE, all plugin JARs, and the web admin. One process runs both the mirror and the admin on :8080.

No cross-compilation. jpackage only produces installers for the OS + CPU arch it runs on. Build a Raspberry Pi .deb on the Pi itself or any arm64 Linux box. Use 64-bit Raspberry Pi OS (arm64) — Skiko ships linux-arm64 natives, not 32-bit armhf.

Prerequisites on the Pi, then build:

sudo apt update
sudo apt install -y openjdk-17-jdk fakeroot binutils dpkg

git clone https://github.com/pierrejochem/SpeculumSmartMirror.git && cd SpeculumSmartMirror
(cd config-server/web && npm install && npm run build)   # build the admin UI first
./gradlew packageDeb
# → composeApp/build/compose/binaries/main/deb/speculum_1.0.0-1_arm64.deb

Also available: ./gradlew packageRpm (needs rpmbuild), and a PKGBUILD for Arch (makepkg -si on an aarch64 box).

Install & run

sudo apt install ./composeApp/build/compose/binaries/main/deb/speculum_1.0.0-1_arm64.deb
/opt/speculum/bin/speculum        # fullscreen mirror + admin on :8080

Run on boot (kiosk)

Speculum runs as a desktop-session autostart: the mirror launches fullscreen when your desktop logs in. This is the most reliable kiosk on Raspberry Pi OS — the session already owns the display, seat, and runtime dir, so there's none of the seat / XDG_RUNTIME_DIR fragility of a bare compositor. The package ships a one-shot helper:

# run as your normal desktop user (NOT root)
speculum-kiosk-enable        # installs a per-user autostart entry
# log out and back in (or reboot) — the mirror comes up fullscreen

speculum-kiosk-disable       # undo

speculum-kiosk-enable writes ~/.config/autostart/speculum.desktop pointing at a wrapper (/opt/speculum/libexec/speculum-kiosk-run) that also disables screen blanking and hides the idle cursor before starting the mirror (which opens fullscreen on its own).

Boot straight into the kiosk. The autostart fires when the desktop session starts, so enable desktop autologin — raspi-config → System Options → Boot / Auto Login → Desktop Autologin — and the mirror appears unattended after a reboot.

The autostart entry is honoured by the Pi OS desktops — LXDE and the Wayland labwc / wayfire sessions — via the XDG autostart spec. Speculum is an X11/AWT app and runs through Xwayland automatically on the Wayland sessions.

Cursor & blanking on Wayland

The wrapper's xset / unclutter hardening is X11-only (it works under Xwayland for the cursor). On a pure Wayland compositor, disable blanking in the compositor instead — wayfire's [idle] settings, or swayidle — or just turn the Pi's screen blanking off in raspi-config → Display Options. Install unclutter for cursor hiding: sudo apt install -y unclutter.

One launcher only. Don't also run the mirror by hand or as a separate service — two instances fight over port 8080. See Troubleshooting → two mirrors.

Low-power tuning (Pi 3B and similar)

Measured idle: ~45 MB live heap, ~390 MB RSS with default sizing. On a 1 GB Pi 3B that's tight, and the VideoCore IV GPU has weak desktop-OpenGL support, so Compose/Skiko likely renders in software — fine for static content, not smooth animation.

Already applied in nativeDistributions.jvmArgs: -Xmx160m -XX:+UseSerialGC -XX:MaxMetaspaceSize=96m.

Verifying releases

Every release ships a SHA256SUMS covering all packages (.deb / .rpm / .pkg.tar.*):

sha256sum -c SHA256SUMS --ignore-missing

When signing is configured, each asset and SHA256SUMS also gets a detached .asc GPG signature. The public key is published in KEYS:

curl -fsSL https://raw.githubusercontent.com/pierrejochem/SpeculumSmartMirror/main/KEYS | gpg --import
gpg --verify SHA256SUMS.asc SHA256SUMS
gpg --verify speculum_<version>_arm64.deb.asc speculum_<version>_arm64.deb

Key fingerprint: 4F73521A2DB000391BEF252AAD96D4494AF02FA1

Reporting a vulnerability: do it privately via the GitHub Security → Report a vulnerability advisory form, not a public issue.
Full Packaging guide ↗ API Reference →