mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +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.
|
# pixel so there's literally nothing to render.
|
||||||
CURSOR_DIR=/usr/share/icons/betterframe-empty/cursors
|
CURSOR_DIR=/usr/share/icons/betterframe-empty/cursors
|
||||||
install -d -m 755 "$CURSOR_DIR"
|
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
|
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
|
# Generate valid 1x1 transparent Xcursor files. Previous generator had a
|
||||||
# missing version field → malformed → wlroots fell back to default cursor.
|
# 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
|
# packages AND nuke any leftover desktop/autostart files so nothing
|
||||||
# survives to flash "configure your raspberry" on screen.
|
# survives to flash "configure your raspberry" on screen.
|
||||||
apt-get -y purge piwiz userconf-pi pi-greeter rpd-plym-splash \
|
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/piwiz.desktop
|
||||||
rm -f /etc/xdg/autostart/setup-wizard.desktop
|
rm -f /etc/xdg/autostart/setup-wizard.desktop
|
||||||
rm -f /etc/xdg/autostart/initial-setup*.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/piwiz.desktop
|
||||||
rm -rf /usr/share/applications/initial-setup*.desktop
|
rm -rf /usr/share/applications/initial-setup*.desktop
|
||||||
# userconf-pi drops a first-boot service that prompts for user/pass.
|
# userconf-pi drops a first-boot service that prompts for user/pass.
|
||||||
systemctl disable userconfig.service 2>/dev/null || true
|
systemctl disable userconfig.service 2>/dev/null || true
|
||||||
systemctl mask 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 disable rpi-first-boot-wizard.service 2>/dev/null || true
|
||||||
systemctl mask 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.
|
# Remove any login program on console.
|
||||||
rm -f /etc/systemd/system/getty.target.wants/* 2>/dev/null || true
|
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
|
# 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.
|
# 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
|
DEBIAN_FRONTEND=noninteractive apt-get purge -y piwiz userconf-pi \
|
||||||
rm -f /etc/xdg/autostart/piwiz.desktop
|
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 disable --now userconf.service userconf-pi.service 2>/dev/null || true
|
||||||
systemctl mask 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.
|
# Suppress the Debian/Pi console motd and /etc/issue text on tty.
|
||||||
: > /etc/motd
|
: > /etc/motd
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ Environment=GST_DEBUG=1
|
||||||
# Invisible cursor: transparent theme + 1px size + software fallback.
|
# Invisible cursor: transparent theme + 1px size + software fallback.
|
||||||
# Three layers because Pi 5 GPU ignores XCURSOR_SIZE for HW cursors.
|
# Three layers because Pi 5 GPU ignores XCURSOR_SIZE for HW cursors.
|
||||||
Environment=XCURSOR_THEME=betterframe-empty
|
Environment=XCURSOR_THEME=betterframe-empty
|
||||||
|
Environment=WLR_XCURSOR_THEME=betterframe-empty
|
||||||
Environment=XCURSOR_SIZE=1
|
Environment=XCURSOR_SIZE=1
|
||||||
Environment=WLR_NO_HARDWARE_CURSORS=1
|
Environment=WLR_NO_HARDWARE_CURSORS=1
|
||||||
# Let the unprivileged kiosk process control the Pi fan PWM sysfs files.
|
# 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 server?: Server;
|
||||||
private cameraHealthChecker?: { stop: () => void };
|
private cameraHealthChecker?: { stop: () => void };
|
||||||
|
private artifactCleanup?: { stop: () => void };
|
||||||
|
|
||||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
super(cfg);
|
super(cfg);
|
||||||
|
|
@ -238,6 +239,13 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
warn: (m) => obs.log.warn(m as any, {}),
|
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
|
// 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
|
// to set server URL + API key manually. Best-effort with retries because
|
||||||
// Node-RED may still be starting.
|
// Node-RED may still be starting.
|
||||||
|
|
@ -307,6 +315,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
this.cameraHealthChecker?.stop();
|
this.cameraHealthChecker?.stop();
|
||||||
|
this.artifactCleanup?.stop();
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await this.server.close();
|
await this.server.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -958,6 +958,28 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
|
|
||||||
// --- display active layout ---
|
// --- display active layout ---
|
||||||
addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL");
|
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
|
// Per-kiosk encryption key. Replaces shared cluster_key for bundle
|
||||||
|
|
|
||||||
|
|
@ -1365,6 +1365,18 @@ export class Repository {
|
||||||
void this.notify("firmware_releases", "update", id);
|
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). */
|
/** Mark the per-kiosk firmware attempt state (called from /api/kiosk/firmware/applied). */
|
||||||
async recordKioskFirmwareAttempt(
|
async recordKioskFirmwareAttempt(
|
||||||
kioskId: number,
|
kioskId: number,
|
||||||
|
|
@ -1549,6 +1561,18 @@ export class Repository {
|
||||||
void this.notify("os_update_releases", "update", id);
|
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(
|
async recordKioskOsUpdateAttempt(
|
||||||
kioskId: number,
|
kioskId: number,
|
||||||
version: string,
|
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