fix(kiosk): piwiz + cursor + migration backfill + artifact cleanup

Cursor: install theme as index.theme (XCursor spec) not just
cursor.theme. Add WLR_XCURSOR_THEME env var for wlroots compat.

Piwiz: broader purge (rpi-first-boot-wizard, raspi-config triggers,
profile.d scripts, firstrun.sh). Mark first-boot done via userconf
marker file.

Migration: add encrypt_key_encrypted, cloud_accounts, and ONVIF event
columns to catch-all backfill so PRAGMA user_version skips can't miss
them.

Artifact cleanup: delete yanked firmware/OS files + prune to 5 most
recent per channel. Runs every 6h. Stops disk from filling up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-23 02:56:56 +02:00
parent 1c16a1da07
commit 1a87c97479
No known key found for this signature in database
7 changed files with 211 additions and 5 deletions

View file

@ -105,6 +105,9 @@ plymouth-set-default-theme betterframe || true
# pixel so there's literally nothing to render.
CURSOR_DIR=/usr/share/icons/betterframe-empty/cursors
install -d -m 755 "$CURSOR_DIR"
# XCursor/wlroots looks for index.theme (not cursor.theme) when resolving
# XCURSOR_THEME. Install as both so update-alternatives and XCursor agree.
install -m 644 /tmp/bf-files/cursor.theme /usr/share/icons/betterframe-empty/index.theme
install -m 644 /tmp/bf-files/cursor.theme /usr/share/icons/betterframe-empty/cursor.theme
# Generate valid 1x1 transparent Xcursor files. Previous generator had a
# missing version field → malformed → wlroots fell back to default cursor.
@ -172,19 +175,31 @@ systemctl mask bluetooth.service hciuart.service 2>/dev/null || true
# packages AND nuke any leftover desktop/autostart files so nothing
# survives to flash "configure your raspberry" on screen.
apt-get -y purge piwiz userconf-pi pi-greeter rpd-plym-splash \
initial-setup initial-setup-gui 2>/dev/null || true
initial-setup initial-setup-gui rpi-first-boot-wizard 2>/dev/null || true
# Nuke ALL autostart entries related to setup/wizard/greeter.
rm -f /etc/xdg/autostart/piwiz.desktop
rm -f /etc/xdg/autostart/setup-wizard.desktop
rm -f /etc/xdg/autostart/initial-setup*.desktop
rm -f /etc/xdg/autostart/*wizard*.desktop
rm -f /etc/xdg/autostart/*setup*.desktop
rm -f /etc/xdg/autostart/*greeter*.desktop
rm -rf /usr/share/applications/piwiz.desktop
rm -rf /usr/share/applications/initial-setup*.desktop
# userconf-pi drops a first-boot service that prompts for user/pass.
systemctl disable userconfig.service 2>/dev/null || true
systemctl mask userconfig.service 2>/dev/null || true
# Pi OS Bookworm+ uses rpi-first-boot-wizard.
apt-get -y purge rpi-first-boot-wizard 2>/dev/null || true
systemctl disable rpi-first-boot-wizard.service 2>/dev/null || true
systemctl mask rpi-first-boot-wizard.service 2>/dev/null || true
# raspi-config has first-boot triggers via /etc/profile.d and init scripts.
rm -f /etc/profile.d/raspi-config.sh 2>/dev/null || true
rm -f /etc/init.d/resize2fs_once 2>/dev/null || true
systemctl disable raspi-config.service 2>/dev/null || true
systemctl mask raspi-config.service 2>/dev/null || true
# Mark first-boot as done so any surviving checks think setup completed.
mkdir -p /var/lib/userconf-pi
touch /var/lib/userconf-pi/userconf
# Prevent pi-gen firstrun.sh from triggering on boot.
if [ -f /boot/firmware/firstrun.sh ]; then rm -f /boot/firmware/firstrun.sh; fi
# Remove any login program on console.
rm -f /etc/systemd/system/getty.target.wants/* 2>/dev/null || true

View file

@ -226,10 +226,19 @@ if [ "${INSTALL_KIOSK}" = "1" ]; then
# piwiz = "Welcome to Raspberry Pi" first-run wizard. userconf-pi runs at
# first boot if no user is configured. Purge both so they can't fire.
DEBIAN_FRONTEND=noninteractive apt-get purge -y piwiz userconf-pi 2>/dev/null || true
rm -f /etc/xdg/autostart/piwiz.desktop
DEBIAN_FRONTEND=noninteractive apt-get purge -y piwiz userconf-pi \
rpi-first-boot-wizard pi-greeter rpd-plym-splash 2>/dev/null || true
rm -f /etc/xdg/autostart/piwiz.desktop /etc/xdg/autostart/*wizard*.desktop \
/etc/xdg/autostart/*setup*.desktop /etc/xdg/autostart/*greeter*.desktop
systemctl disable --now userconf.service userconf-pi.service 2>/dev/null || true
systemctl mask userconf.service userconf-pi.service 2>/dev/null || true
systemctl disable --now rpi-first-boot-wizard.service 2>/dev/null || true
systemctl mask rpi-first-boot-wizard.service 2>/dev/null || true
rm -f /etc/profile.d/raspi-config.sh 2>/dev/null || true
systemctl disable --now raspi-config.service 2>/dev/null || true
systemctl mask raspi-config.service 2>/dev/null || true
mkdir -p /var/lib/userconf-pi && touch /var/lib/userconf-pi/userconf
rm -f /boot/firmware/firstrun.sh 2>/dev/null || true
# Suppress the Debian/Pi console motd and /etc/issue text on tty.
: > /etc/motd

View file

@ -36,6 +36,7 @@ Environment=GST_DEBUG=1
# Invisible cursor: transparent theme + 1px size + software fallback.
# Three layers because Pi 5 GPU ignores XCURSOR_SIZE for HW cursors.
Environment=XCURSOR_THEME=betterframe-empty
Environment=WLR_XCURSOR_THEME=betterframe-empty
Environment=XCURSOR_SIZE=1
Environment=WLR_NO_HARDWARE_CURSORS=1
# Let the unprivileged kiosk process control the Pi fan PWM sysfs files.

View file

@ -105,6 +105,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
private server?: Server;
private cameraHealthChecker?: { stop: () => void };
private artifactCleanup?: { stop: () => void };
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
@ -238,6 +239,13 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
warn: (m) => obs.log.warn(m as any, {}),
});
// Artifact cleanup — prune yanked + old firmware/OS files every 6h.
const { startArtifactCleanup } = await import("../../shared/artifact-cleanup.js");
this.artifactCleanup = startArtifactCleanup(repo, {
info: (m) => obs.log.info(m as any, {}),
warn: (m) => obs.log.warn(m as any, {}),
});
// Auto-provision the Node-RED bf-server-config so the user doesn't have
// to set server URL + API key manually. Best-effort with retries because
// Node-RED may still be starting.
@ -307,6 +315,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
async dispose(): Promise<void> {
this.cameraHealthChecker?.stop();
this.artifactCleanup?.stop();
if (this.server) {
await this.server.close();
}

View file

@ -958,6 +958,28 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
// --- display active layout ---
addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL");
// --- per-kiosk encryption key ---
addColumnIfNotExists(db, "kiosks", "encrypt_key_encrypted", "TEXT");
// --- ONVIF event routing ---
addColumnIfNotExists(db, "cameras", "event_source", "TEXT NOT NULL DEFAULT 'auto'");
addColumnIfNotExists(db, "cameras", "event_sink", "TEXT NOT NULL DEFAULT 'auto'");
addColumnIfNotExists(db, "cameras", "supported_event_topics", "TEXT NOT NULL DEFAULT '[]'");
// --- cloud accounts table ---
db.exec(`CREATE TABLE IF NOT EXISTS cloud_accounts (
id TEXT PRIMARY KEY,
vendor TEXT NOT NULL,
name TEXT NOT NULL,
credentials_encrypted TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
last_sync_at TEXT,
last_sync_error TEXT,
camera_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_cloud_accounts_vendor ON cloud_accounts(vendor)`);
},
// Per-kiosk encryption key. Replaces shared cluster_key for bundle

View file

@ -1365,6 +1365,18 @@ export class Repository {
void this.notify("firmware_releases", "update", id);
}
async deleteFirmwareRelease(id: string): Promise<void> {
await this._run("DELETE FROM firmware_releases WHERE id = ?", [id]);
void this.notify("firmware_releases", "delete", id);
}
async listYankedFirmwareReleases(): Promise<FirmwareRelease[]> {
const rs = await this._all(
"SELECT * FROM firmware_releases WHERE yanked_at IS NOT NULL ORDER BY yanked_at ASC",
);
return rs.map((r) => rowToFirmwareRelease(r as Record<string, unknown>));
}
/** Mark the per-kiosk firmware attempt state (called from /api/kiosk/firmware/applied). */
async recordKioskFirmwareAttempt(
kioskId: number,
@ -1549,6 +1561,18 @@ export class Repository {
void this.notify("os_update_releases", "update", id);
}
async deleteOsUpdateRelease(id: string): Promise<void> {
await this._run("DELETE FROM os_update_releases WHERE id = ?", [id]);
void this.notify("os_update_releases", "delete", id);
}
async listYankedOsUpdateReleases(): Promise<OsUpdateRelease[]> {
const rs = await this._all(
"SELECT * FROM os_update_releases WHERE yanked_at IS NOT NULL ORDER BY yanked_at ASC",
);
return rs.map((r) => rowToOsUpdateRelease(r as Record<string, unknown>));
}
async recordKioskOsUpdateAttempt(
kioskId: number,
version: string,

View file

@ -0,0 +1,126 @@
/**
* Periodic cleanup of firmware + OS update artifact files.
*
* Strategy:
* 1. Delete artifact files for yanked releases (DB row kept for audit).
* 2. For each channel, keep only the N most recent non-yanked releases.
* Older ones are yanked + artifact deleted.
*
* Runs every 6 hours. Safe to call concurrently worst case is two passes
* trying to delete the same file (ENOENT is swallowed).
*/
import { unlink } from "node:fs/promises";
import type { Repository } from "../plugins/service-store/repository.js";
interface CleanupLog {
info(msg: string): void;
warn(msg: string): void;
}
const KEEP_PER_CHANNEL = 5;
const INTERVAL_MS = 6 * 60 * 60 * 1000;
async function removeFile(path: string): Promise<boolean> {
try {
await unlink(path);
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
throw err;
}
}
async function cleanupFirmware(repo: Repository, log: CleanupLog): Promise<number> {
let cleaned = 0;
const yanked = await repo.listYankedFirmwareReleases();
for (const r of yanked) {
if (await removeFile(r.artifact_path)) {
log.info(`firmware cleanup: deleted artifact for yanked ${r.version} (${r.arch})`);
cleaned++;
}
await repo.deleteFirmwareRelease(r.id);
}
const all = await repo.listFirmwareReleases();
const active = all.filter((r) => !r.yanked_at);
const byKey = new Map<string, typeof active>();
for (const r of active) {
const key = `${r.channel}:${r.arch}`;
const list = byKey.get(key) ?? [];
list.push(r);
byKey.set(key, list);
}
for (const [, releases] of byKey) {
releases.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at));
const excess = releases.slice(KEEP_PER_CHANNEL);
for (const r of excess) {
if (await removeFile(r.artifact_path)) {
log.info(`firmware cleanup: pruned old ${r.version} (${r.arch})`);
cleaned++;
}
await repo.deleteFirmwareRelease(r.id);
}
}
return cleaned;
}
async function cleanupOsUpdates(repo: Repository, log: CleanupLog): Promise<number> {
let cleaned = 0;
const yanked = await repo.listYankedOsUpdateReleases();
for (const r of yanked) {
if (await removeFile(r.artifact_path)) {
log.info(`os-update cleanup: deleted artifact for yanked ${r.version}`);
cleaned++;
}
await repo.deleteOsUpdateRelease(r.id);
}
const all = await repo.listOsUpdateReleases();
const active = all.filter((r) => !r.yanked_at);
const byKey = new Map<string, typeof active>();
for (const r of active) {
const key = `${r.channel}:${r.compatibility}`;
const list = byKey.get(key) ?? [];
list.push(r);
byKey.set(key, list);
}
for (const [, releases] of byKey) {
releases.sort((a, b) => b.uploaded_at.localeCompare(a.uploaded_at));
const excess = releases.slice(KEEP_PER_CHANNEL);
for (const r of excess) {
if (await removeFile(r.artifact_path)) {
log.info(`os-update cleanup: pruned old ${r.version}`);
cleaned++;
}
await repo.deleteOsUpdateRelease(r.id);
}
}
return cleaned;
}
export function startArtifactCleanup(
repo: Repository,
log: CleanupLog,
): { stop: () => void } {
async function run() {
try {
const fw = await cleanupFirmware(repo, log);
const os = await cleanupOsUpdates(repo, log);
if (fw + os > 0) {
log.info(`artifact cleanup: removed ${fw} firmware + ${os} OS artifacts`);
}
} catch (err) {
log.warn(`artifact cleanup failed: ${(err as Error).message}`);
}
}
void run();
const timer = setInterval(() => void run(), INTERVAL_MS);
return { stop: () => clearInterval(timer) };
}