mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
1c16a1da07
commit
1a87c97479
7 changed files with 211 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
126
server/src/shared/artifact-cleanup.ts
Normal file
126
server/src/shared/artifact-cleanup.ts
Normal 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) };
|
||||
}
|
||||
Loading…
Reference in a new issue