Compare commits

..

2 Commits

Author SHA1 Message Date
nearxos
fd56ed4049 Add file editing functionality to dashboard
Implement API endpoints for retrieving and saving file content, allowing users to edit supported file types directly from the dashboard. Introduce a modal editor interface with syntax highlighting for various file formats. Update the HTML template to include the editor overlay and associated JavaScript for handling file operations, enhancing user experience and interactivity in managing portal files.
2026-02-22 16:29:14 +02:00
nearxos
16c796b8af Refactor first-boot process to introduce ordered execution and new one-shot scripts
Revise the first-boot script to implement a structured approach with 13 numbered steps, allowing for better control over the execution order. Introduce two new one-shot scripts: `01-set-rotation-once.sh` and `02-set-wallpaper-once.sh`, replacing the previous single script approach. Update documentation to reflect these changes, including the new configuration options for enabling/disabling steps and the revised file structure for one-shot scripts. Enhance the dashboard to display first-boot progress, improving user feedback during the initial setup.
2026-02-22 16:22:44 +02:00
16 changed files with 594 additions and 189 deletions

View File

@@ -35,7 +35,8 @@ emmc-provisioning/
│ ├── user-data-remote-gnss.example Example cloud-init user-data (curl first-boot.sh) │ ├── user-data-remote-gnss.example Example cloud-init user-data (curl first-boot.sh)
│ ├── config-files/ LightDM, Maliit, one-shots (.desktop + scripts) │ ├── config-files/ LightDM, Maliit, one-shots (.desktop + scripts)
│ │ ├── chromium-kiosk.desktop │ │ ├── chromium-kiosk.desktop
│ │ ├── set-rotation-once.desktop │ │ ├── 01-set-rotation-once.desktop
│ │ ├── 02-set-wallpaper-once.desktop
│ │ └── ... │ │ └── ...
│ ├── files-from-guard/ Plymouth, splash assets; README of required files │ ├── files-from-guard/ Plymouth, splash assets; README of required files
│ └── fix-reterminal-display.sh One-time fix script (splash, rotation, wallpaper) │ └── fix-reterminal-display.sh One-time fix script (splash, rotation, wallpaper)

View File

@@ -3,7 +3,8 @@
# Runs once as user pi at first login; deletes its autostart and this script so it never runs again. # Runs once as user pi at first login; deletes its autostart and this script so it never runs again.
# Logs to /var/log/first-boot.log. # Logs to /var/log/first-boot.log.
FIRST_BOOT_LOG="/var/log/first-boot.log" FIRST_BOOT_LOG="/var/log/first-boot.log"
log() { echo "[$(date -Iseconds)] [set-rotation-once] $*" >> "$FIRST_BOOT_LOG" 2>/dev/null || true; } BASE="$(basename "$0" .sh)"
log() { echo "[$(date -Iseconds)] [$BASE] $*" >> "$FIRST_BOOT_LOG" 2>/dev/null || true; }
log "started (labwc/wlr-randr)" log "started (labwc/wlr-randr)"
log "waiting 5s for compositor ..." log "waiting 5s for compositor ..."
@@ -26,5 +27,5 @@ else
fi fi
log "removing one-shot desktop and script" log "removing one-shot desktop and script"
rm -f /home/pi/.config/autostart/set-rotation-once.desktop /home/pi/set-rotation-once.sh rm -f "$HOME/.config/autostart/${BASE}.desktop" "$HOME/${BASE}.sh"
log "finished" log "finished"

View File

@@ -2,7 +2,8 @@
# One-shot: set desktop wallpaper for labwc (swaybg) and persist via labwc autostart, then remove self. # One-shot: set desktop wallpaper for labwc (swaybg) and persist via labwc autostart, then remove self.
# Runs as user pi at first login. Logs to /var/log/first-boot.log. # Runs as user pi at first login. Logs to /var/log/first-boot.log.
FIRST_BOOT_LOG="/var/log/first-boot.log" FIRST_BOOT_LOG="/var/log/first-boot.log"
log() { echo "[$(date -Iseconds)] [set-wallpaper-once] $*" >> "$FIRST_BOOT_LOG" 2>/dev/null || true; } BASE="$(basename "$0" .sh)"
log() { echo "[$(date -Iseconds)] [$BASE] $*" >> "$FIRST_BOOT_LOG" 2>/dev/null || true; }
WALLPAPER="/usr/share/rpd-wallpaper/splash.png" WALLPAPER="/usr/share/rpd-wallpaper/splash.png"
LABWC_AUTOSTART="$HOME/.config/labwc/autostart" LABWC_AUTOSTART="$HOME/.config/labwc/autostart"
@@ -32,5 +33,5 @@ else
fi fi
log "removing one-shot desktop and script" log "removing one-shot desktop and script"
rm -f /home/pi/.config/autostart/set-wallpaper-once.desktop /home/pi/set-wallpaper-once.sh rm -f "$HOME/.config/autostart/${BASE}.desktop" "$HOME/${BASE}.sh"
log "finished" log "finished"

View File

@@ -1,5 +1,5 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Set rotation once Name=01 Set rotation once
Exec=/home/pi/set-rotation-once.sh Exec=/home/pi/01-set-rotation-once.sh
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true

View File

@@ -0,0 +1,5 @@
[Desktop Entry]
Type=Application
Name=02 Set wallpaper once
Exec=/home/pi/02-set-wallpaper-once.sh
X-GNOME-Autostart-enabled=true

View File

@@ -8,6 +8,5 @@ first-boot.sh downloads these from `FILE_SERVER` (e.g. `http://10.20.50.1:5000/f
| 99-wallpaper.conf | /etc/lightdm/lightdm.conf.d/99-wallpaper.conf | | 99-wallpaper.conf | /etc/lightdm/lightdm.conf.d/99-wallpaper.conf |
| 99-default-session.conf | /etc/lightdm/lightdm.conf.d/99-default-session.conf (rpd-labwc) | | 99-default-session.conf | /etc/lightdm/lightdm.conf.d/99-default-session.conf (rpd-labwc) |
| maliit-keyboard.desktop | /home/pi/.config/autostart/maliit-keyboard.desktop | | maliit-keyboard.desktop | /home/pi/.config/autostart/maliit-keyboard.desktop |
| set-rotation-once.desktop | /home/pi/.config/autostart/set-rotation-once.desktop (with set-rotation-once.sh) | | 01-set-rotation-once.desktop | /home/pi/.config/autostart/01-set-rotation-once.desktop (with 01-set-rotation-once.sh) |
| 02-set-wallpaper-once.desktop | /home/pi/.config/autostart/02-set-wallpaper-once.desktop (with 02-set-wallpaper-once.sh). Wallpaper is also set during first-boot via pcmanfm. |
Wallpaper is set once during first-boot via pcmanfm config; no set-wallpaper-once one-shot.

View File

@@ -1,5 +0,0 @@
[Desktop Entry]
Type=Application
Name=Set wallpaper once
Exec=/home/pi/set-wallpaper-once.sh
X-GNOME-Autostart-enabled=true

View File

@@ -14,9 +14,9 @@ first-boot.sh downloads from **`.../files/first-boot/`** (e.g. `http://10.20.50.
| **99-wallpaper.conf** | LightDM greeter wallpaper (from `config-files/`). | | **99-wallpaper.conf** | LightDM greeter wallpaper (from `config-files/`). |
| **99-default-session.conf** | LightDM default session rpd-labwc (from `config-files/`). | | **99-default-session.conf** | LightDM default session rpd-labwc (from `config-files/`). |
| **maliit-keyboard.desktop** | Maliit on-screen keyboard autostart (from `config-files/`). | | **maliit-keyboard.desktop** | Maliit on-screen keyboard autostart (from `config-files/`). |
| **set-rotation-once.sh** + **.desktop** | One-shot: wlr-randr rotation (Left) at first login. | | **01-set-rotation-once.sh** + **.desktop** | One-shot: wlr-randr rotation (Left) at first login. |
Desktop wallpaper is set once during first-boot via pcmanfm config (first-boot.sh); no set-wallpaper-once one-shot needed. Desktop wallpaper is set once during first-boot via pcmanfm config (first-boot.sh). Optional one-shot: **02-set-wallpaper-once.sh**.
--- ---
@@ -27,4 +27,4 @@ Desktop wallpaper is set once during first-boot via pcmanfm config (first-boot.s
- **plymouth-custom/custom.script** — Plymouth script that draws splash.png; host as `custom.script`. - **plymouth-custom/custom.script** — Plymouth script that draws splash.png; host as `custom.script`.
- **lightdm/RPiSystem_dark.png** — Unused; only `splash.png` is used now. - **lightdm/RPiSystem_dark.png** — Unused; only `splash.png` is used now.
- **start-chromium.sh** and **chromium-kiosk.desktop** live in `cloud-init/` and `cloud-init/config-files/`; `scripts/sync-portal-files-to-lxc.sh` copies them to the portal first-boot folder. - **start-chromium.sh** and **chromium-kiosk.desktop** live in `cloud-init/` and `cloud-init/config-files/`; `scripts/sync-portal-files-to-lxc.sh` copies them to the portal first-boot folder.
- **set-rotation-once.sh** (and its .desktop) are in `cloud-init/` and synced to `portal-files/first-boot/` by the same script. - **01-set-rotation-once.sh**, **02-set-wallpaper-once.sh** (and their .desktop files) are in `cloud-init/` and synced to `portal-files/first-boot/` by the same script.

View File

@@ -0,0 +1,60 @@
# first-boot.conf.example
# Copy to first-boot.conf and edit. Loaded by first-boot.sh from:
# - same directory as first-boot.sh, or
# - /tmp/first-boot.conf (when run via cloud-init), or
# - /etc/cm4-provisioning/first-boot.conf
# Unset variables keep the script's built-in defaults.
# --- File server & host ---
# Base URL for first-boot assets (scripts, splash, configs). No trailing slash.
FILE_SERVER="http://10.20.50.1:5000/files/first-boot"
# Hostname set on the device.
HOSTNAME="guard"
# --- User ---
# Login user (must exist; cloud-init user-data must create the same user).
PI_USER="pi"
# --- Paths (optional overrides) ---
# First-boot log file.
LOGFILE="/var/log/first-boot.log"
# Plymouth custom theme directory.
PLYMOUTH_DIR="/usr/share/plymouth/themes/custom"
# Wallpaper path (splash.png is also copied here).
WALLPAPER_PATH="/usr/share/rpd-wallpaper/splash.png"
# --- Packages ---
# Space-separated list of packages to install. Must include: git chromium wmctrl openssh-server
# swaybg wlr-randr maliit-keyboard xinput-calibrator (for kiosk + labwc + touch).
PACKAGES="git chromium wmctrl openssh-server swaybg wlr-randr maliit-keyboard xinput-calibrator"
# --- Desktop & theme ---
# LightDM session (rpd-labwc for Wayland + labwc).
LIGHTDM_SESSION="rpd-labwc"
# GTK dark theme name (e.g. PiXnoir; fallback is Adwaita-dark if missing).
GTK_THEME_NAME="PiXnoir"
# Wallpaper mode for pcmanfm (crop, stretch, fit, center, tile, screen, color).
WALLPAPER_MODE="crop"
# --- Display (reTerminal DM) ---
# Kernel cmdline: DSI rotation. 90 = 90° clockwise; use 180 or 270 for other orientations.
DSI_ROTATE="270"
# Kernel cmdline: swiotlb size (for vc4-drm/DSI). Leave empty to skip.
SWIOTLB_SIZE="65536"
# --- reTerminal (Seeed) ---
# Device passed to reTerminal.sh (reTerminal-DM for reTerminal DM).
RETERMINAL_DEVICE="reTerminal-DM"
# Seeed overlays repo (clone URL). Leave empty to skip driver install.
RETERMINAL_REPO_URL="https://github.com/Seeed-Studio/seeed-linux-dtoverlays"
# --- One-shots ---
# Space-separated names of one-shot scripts (numbered = run order at first login). Leave empty for none.
ONESHOT_SCRIPTS="01-set-rotation-once 02-set-wallpaper-once"

View File

@@ -57,5 +57,12 @@
# --- One-shots --- # --- One-shots ---
# Space-separated names of one-shot scripts to install from FILE_SERVER (each name gets name.sh + name.desktop). # Space-separated names of one-shot scripts to install from FILE_SERVER (each name gets name.sh + name.desktop).
# Example: "set-rotation-once set-wallpaper-once". Leave empty for none. # Use numbered names so they run in order at first login. Leave empty for none.
# ONESHOT_SCRIPTS="" # Example: "01-set-rotation-once 02-set-wallpaper-once"
# ONESHOT_SCRIPTS="01-set-rotation-once 02-set-wallpaper-once"
# --- Step enable flags (1 = run, 0 = skip). Default: all 1. Set in config to disable a step. ---
# 01=hostname, 02=packages, 03=kiosk_files, 04=splash_wallpaper, 05=lightdm, 06=maliit,
# 07=dark_theme, 08=reterminal_drivers, 09=reapply_splash, 10=cmdline, 11=oneshots, 12=log_permissions, 13=reboot
# ENABLE_STEP_08=0
# ENABLE_STEP_13=0

View File

@@ -16,7 +16,9 @@ Only set variables you want to change; the rest use built-in defaults. See `firs
--- ---
## Structure (sections) ## Structure (steps and enable flags)
The script runs **13 numbered steps** (`step_01_hostname``step_13_reboot`). Disable a step by setting `ENABLE_STEP_NN=0` in config (default: all `1`). Step table: 01=hostname, 02=packages, 03=kiosk_files, 04=splash_wallpaper, 05=lightdm, 06=maliit, 07=dark_theme, 08=reterminal_drivers, 09=reapply_splash, 10=cmdline, 11=oneshots, 12=log_permissions, 13=reboot.
1. **Config & constants** — Load optional `first-boot.conf`; then `FILE_SERVER`, `PI_USER`, paths, log file (defaults or from config). 1. **Config & constants** — Load optional `first-boot.conf`; then `FILE_SERVER`, `PI_USER`, paths, log file (defaults or from config).
2. **Logging** — All output teed to `/var/log/first-boot.log`. 2. **Logging** — All output teed to `/var/log/first-boot.log`.
@@ -29,8 +31,6 @@ Only set variables you want to change; the rest use built-in defaults. See `firs
9. **reTerminal DM drivers** — Seeed repo clone and `reTerminal.sh`. 9. **reTerminal DM drivers** — Seeed repo clone and `reTerminal.sh`.
10. **Re-apply splash** — Set `disable_splash=0`, Plymouth theme to `custom` only, `update-initramfs`. 10. **Re-apply splash** — Set `disable_splash=0`, Plymouth theme to `custom` only, `update-initramfs`.
11. **Dark theme** — Set GTK dark theme for user `pi`: `~/.config/gtk-3.0/settings.ini` with `gtk-application-prefer-dark-theme=1` and `gtk-theme-name=PiXnoir` (Raspberry Pi OS dark theme). 11. **Dark theme** — Set GTK dark theme for user `pi`: `~/.config/gtk-3.0/settings.ini` with `gtk-application-prefer-dark-theme=1` and `gtk-theme-name=PiXnoir` (Raspberry Pi OS dark theme).
12. **One-shots** — Download `set-rotation-once.sh` + `.desktop` from file server (wlr-randr for labwc). Wallpaper is set once via pcmanfm config during first-boot.
13. **Reboot.**
--- ---

View File

@@ -3,6 +3,7 @@
# Run by cloud-init (user-data-remote-gnss.example). Run as root. # Run by cloud-init (user-data-remote-gnss.example). Run as root.
# Optional: copy first-boot.conf.example to first-boot.conf and edit; it is loaded # Optional: copy first-boot.conf.example to first-boot.conf and edit; it is loaded
# from the script dir, /tmp/first-boot.conf, or /etc/cm4-provisioning/first-boot.conf. # from the script dir, /tmp/first-boot.conf, or /etc/cm4-provisioning/first-boot.conf.
# Set ENABLE_STEP_01=0 .. ENABLE_STEP_13=0 in config to disable a step (default: all enabled).
set -e set -e
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
@@ -24,6 +25,20 @@ SWIOTLB_SIZE="${SWIOTLB_SIZE:-65536}"
RETERMINAL_DEVICE="${RETERMINAL_DEVICE:-reTerminal-DM}" RETERMINAL_DEVICE="${RETERMINAL_DEVICE:-reTerminal-DM}"
RETERMINAL_REPO_URL="${RETERMINAL_REPO_URL:-https://github.com/Seeed-Studio/seeed-linux-dtoverlays}" RETERMINAL_REPO_URL="${RETERMINAL_REPO_URL:-https://github.com/Seeed-Studio/seeed-linux-dtoverlays}"
ONESHOT_SCRIPTS="${ONESHOT_SCRIPTS:-}" ONESHOT_SCRIPTS="${ONESHOT_SCRIPTS:-}"
# Step enable flags (1 = run, 0 = skip). Default all enabled.
ENABLE_STEP_01="${ENABLE_STEP_01:-1}"
ENABLE_STEP_02="${ENABLE_STEP_02:-1}"
ENABLE_STEP_03="${ENABLE_STEP_03:-1}"
ENABLE_STEP_04="${ENABLE_STEP_04:-1}"
ENABLE_STEP_05="${ENABLE_STEP_05:-1}"
ENABLE_STEP_06="${ENABLE_STEP_06:-1}"
ENABLE_STEP_07="${ENABLE_STEP_07:-1}"
ENABLE_STEP_08="${ENABLE_STEP_08:-1}"
ENABLE_STEP_09="${ENABLE_STEP_09:-1}"
ENABLE_STEP_10="${ENABLE_STEP_10:-1}"
ENABLE_STEP_11="${ENABLE_STEP_11:-1}"
ENABLE_STEP_12="${ENABLE_STEP_12:-1}"
ENABLE_STEP_13="${ENABLE_STEP_13:-1}"
# --- Load config file (first found) --- # --- Load config file (first found) ---
FIRST_BOOT_CONF="" FIRST_BOOT_CONF=""
@@ -40,6 +55,13 @@ done
PI_HOME="/home/$PI_USER" PI_HOME="/home/$PI_USER"
AUTOSTART="$PI_HOME/.config/autostart" AUTOSTART="$PI_HOME/.config/autostart"
# Portal base URL for first-boot status API (derived from FILE_SERVER, e.g. http://10.20.50.1:5000)
if [[ "$FILE_SERVER" =~ ^(https?://[^/]+) ]]; then
PORTAL_BASE="${BASH_REMATCH[1]}"
else
PORTAL_BASE=""
fi
# --- Logging --- # --- Logging ---
log() { echo "[$(date -Iseconds)] $*"; } log() { echo "[$(date -Iseconds)] $*"; }
exec > >(tee -a "$LOGFILE") 2>&1 exec > >(tee -a "$LOGFILE") 2>&1
@@ -47,22 +69,31 @@ log "=== first-boot.sh started ==="
[[ -n "$FIRST_BOOT_CONF" ]] && log "Config loaded from $FIRST_BOOT_CONF" || log "Using built-in defaults (no config file found)" [[ -n "$FIRST_BOOT_CONF" ]] && log "Config loaded from $FIRST_BOOT_CONF" || log "Using built-in defaults (no config file found)"
log "FILE_SERVER=$FILE_SERVER PI_USER=$PI_USER HOSTNAME=$HOSTNAME LOGFILE=$LOGFILE" log "FILE_SERVER=$FILE_SERVER PI_USER=$PI_USER HOSTNAME=$HOSTNAME LOGFILE=$LOGFILE"
# --- 0. Hostname and /etc/hosts (avoids "unable to resolve host" with sudo) --- # --- Report status to portal (no-op if PORTAL_BASE empty; failures ignored) ---
log "--- Hostname: $HOSTNAME ---" report_status() {
echo "$HOSTNAME" > /etc/hostname local phase="$1" message="$2" step="$3" step_name="$4" ip="$5"
hostnamectl set-hostname "$HOSTNAME" 2>/dev/null || true [[ -z "$PORTAL_BASE" ]] && return 0
# Ensure hostname resolves so sudo and other tools don't warn local json="{\"phase\":\"${phase}\",\"message\":\"${message}\",\"step\":\"${step}\",\"step_name\":\"${step_name}\",\"hostname\":\"${HOSTNAME}\",\"ip\":\"${ip}\"}"
if ! grep -q "127.0.1.1[[:space:]]*$HOSTNAME" /etc/hosts 2>/dev/null; then curl -s -X POST -H "Content-Type: application/json" -d "$json" "${PORTAL_BASE}/api/first-boot-status" || true
sed -i "/127.0.1.1[[:space:]].*$/d" /etc/hosts }
echo "127.0.1.1 $HOSTNAME" >> /etc/hosts report_status "started" "First-boot started" "" "" ""
fi
log "Hostname set to $HOSTNAME; /etc/hosts updated"
# --- Helpers --- # --- Helper: run step if enabled ---
# Download script + .desktop from FILE_SERVER and install as one-shot autostart (runs once at pi's first login, then deletes itself). run_step() {
local n="$1" name="$2" enable_var="ENABLE_STEP_${n}"
if [[ "${!enable_var}" == "1" ]]; then
log "--- Step $n: $name ---"
"step_${n}_${name}" || return $?
report_status "running" "Step $n: $name completed" "$n" "$name" ""
else
log "--- Step $n: $name (disabled) ---"
fi
}
# --- Helper: install one-shot from FILE_SERVER ---
install_oneshot() { install_oneshot() {
local name="$1" local name="$1"
log "--- Installing one-shot: $name ---" log "Installing one-shot: $name"
if curl -fsSL "${FILE_SERVER}/${name}.sh" -o "$PI_HOME/${name}.sh"; then if curl -fsSL "${FILE_SERVER}/${name}.sh" -o "$PI_HOME/${name}.sh"; then
log "Downloaded ${name}.sh to $PI_HOME/${name}.sh" log "Downloaded ${name}.sh to $PI_HOME/${name}.sh"
else else
@@ -78,170 +109,205 @@ install_oneshot() {
log "One-shot $name installed (will run at first login and then remove itself)" log "One-shot $name installed (will run at first login and then remove itself)"
} }
# --- 1. Packages --- # --- Step 01: Hostname and /etc/hosts ---
log "--- Installing packages ---" step_01_hostname() {
log "Running apt-get update ..." echo "$HOSTNAME" > /etc/hostname
apt-get update -qq hostnamectl set-hostname "$HOSTNAME" 2>/dev/null || true
log "Installing: $PACKAGES" if ! grep -q "127.0.1.1[[:space:]]*$HOSTNAME" /etc/hosts 2>/dev/null; then
apt-get install -y -qq $PACKAGES sed -i "/127.0.1.1[[:space:]].*$/d" /etc/hosts
log "Packages installed successfully" echo "127.0.1.1 $HOSTNAME" >> /etc/hosts
# --- 2. Dirs and kiosk files from file server ---
log "--- Kiosk files ---"
log "Creating $AUTOSTART"
mkdir -p "$AUTOSTART"
log "Downloading start-chromium.sh from ${FILE_SERVER}/start-chromium.sh"
curl -fsSL "${FILE_SERVER}/start-chromium.sh" -o "$PI_HOME/start-chromium.sh"
log "Downloading chromium-kiosk.desktop from ${FILE_SERVER}/chromium-kiosk.desktop"
curl -fsSL "${FILE_SERVER}/chromium-kiosk.desktop" -o "$AUTOSTART/chromium-kiosk.desktop"
chmod 755 "$PI_HOME/start-chromium.sh" && chmod 644 "$AUTOSTART/chromium-kiosk.desktop"
chown -R "$PI_USER:$PI_USER" "$PI_HOME/start-chromium.sh" "$AUTOSTART/chromium-kiosk.desktop"
log "Kiosk files installed under $PI_HOME and $AUTOSTART"
# --- 3. Boot splash and wallpaper (splash.png + Plymouth theme from file server) ---
log "--- Boot splash and wallpaper ---"
log "Creating $PLYMOUTH_DIR and /usr/share/rpd-wallpaper"
mkdir -p "$PLYMOUTH_DIR" /usr/share/rpd-wallpaper
if curl -fsSL "${FILE_SERVER}/splash.png" -o "$PLYMOUTH_DIR/splash.png"; then
log "Downloaded splash.png; copying to $WALLPAPER_PATH"
cp "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
chmod 644 "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
if curl -fsSL "${FILE_SERVER}/custom.plymouth" -o "$PLYMOUTH_DIR/custom.plymouth" \
&& curl -fsSL "${FILE_SERVER}/custom.script" -o "$PLYMOUTH_DIR/custom.script"; then
chmod 644 "$PLYMOUTH_DIR/custom.plymouth" "$PLYMOUTH_DIR/custom.script"
log "Plymouth theme files (custom.plymouth, custom.script) installed"
else
log "WARNING: Could not download custom.plymouth/custom.script; boot splash theme may be incomplete"
fi fi
grep -q '^Theme=custom' /etc/plymouth/plymouthd.conf 2>/dev/null || printf '%s\n' '[Daemon]' 'Theme=custom' >> /etc/plymouth/plymouthd.conf log "Hostname set to $HOSTNAME; /etc/hosts updated"
log "Running update-initramfs (may take a moment) ..." }
update-initramfs -u -k all 2>/dev/null || true
mkdir -p /etc/lightdm/lightdm.conf.d # --- Step 02: Packages ---
curl -fsSL "${FILE_SERVER}/99-wallpaper.conf" -o /etc/lightdm/lightdm.conf.d/99-wallpaper.conf 2>/dev/null || log "WARNING: Could not download 99-wallpaper.conf" step_02_packages() {
# Set desktop wallpaper once via pcmanfm config (rpd-labwc uses pcmanfm-pi; profile LXDE-pi or default) log "Running apt-get update ..."
for PROFILE in LXDE-pi default; do apt-get update -qq
PCMANFM_DESKTOP="$PI_HOME/.config/pcmanfm/$PROFILE/desktop-items-0.conf" log "Installing: $PACKAGES"
mkdir -p "$(dirname "$PCMANFM_DESKTOP")" apt-get install -y -qq $PACKAGES
if [[ ! -f "$PCMANFM_DESKTOP" ]]; then log "Packages installed successfully"
printf '%s\n' '[*]' "wallpaper=$WALLPAPER_PATH" "wallpaper_mode=$WALLPAPER_MODE" 'wallpaper_common=1' > "$PCMANFM_DESKTOP" }
# --- Step 03: Kiosk files from file server ---
step_03_kiosk_files() {
log "Creating $AUTOSTART"
mkdir -p "$AUTOSTART"
log "Downloading start-chromium.sh from ${FILE_SERVER}/start-chromium.sh"
curl -fsSL "${FILE_SERVER}/start-chromium.sh" -o "$PI_HOME/start-chromium.sh"
log "Downloading chromium-kiosk.desktop from ${FILE_SERVER}/chromium-kiosk.desktop"
curl -fsSL "${FILE_SERVER}/chromium-kiosk.desktop" -o "$AUTOSTART/chromium-kiosk.desktop"
chmod 755 "$PI_HOME/start-chromium.sh" && chmod 644 "$AUTOSTART/chromium-kiosk.desktop"
chown -R "$PI_USER:$PI_USER" "$PI_HOME/start-chromium.sh" "$AUTOSTART/chromium-kiosk.desktop"
log "Kiosk files installed under $PI_HOME and $AUTOSTART"
}
# --- Step 04: Boot splash and wallpaper ---
step_04_splash_wallpaper() {
log "Creating $PLYMOUTH_DIR and /usr/share/rpd-wallpaper"
mkdir -p "$PLYMOUTH_DIR" /usr/share/rpd-wallpaper
if curl -fsSL "${FILE_SERVER}/splash.png" -o "$PLYMOUTH_DIR/splash.png"; then
log "Downloaded splash.png; copying to $WALLPAPER_PATH"
cp "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
chmod 644 "$PLYMOUTH_DIR/splash.png" "$WALLPAPER_PATH"
if curl -fsSL "${FILE_SERVER}/custom.plymouth" -o "$PLYMOUTH_DIR/custom.plymouth" \
&& curl -fsSL "${FILE_SERVER}/custom.script" -o "$PLYMOUTH_DIR/custom.script"; then
chmod 644 "$PLYMOUTH_DIR/custom.plymouth" "$PLYMOUTH_DIR/custom.script"
log "Plymouth theme files (custom.plymouth, custom.script) installed"
else else
grep -q '^wallpaper=' "$PCMANFM_DESKTOP" && sed -i "s|^wallpaper=.*|wallpaper=$WALLPAPER_PATH|" "$PCMANFM_DESKTOP" || echo "wallpaper=$WALLPAPER_PATH" >> "$PCMANFM_DESKTOP" log "WARNING: Could not download custom.plymouth/custom.script; boot splash theme may be incomplete"
grep -q '^wallpaper_mode=' "$PCMANFM_DESKTOP" && sed -i "s/^wallpaper_mode=.*/wallpaper_mode=$WALLPAPER_MODE/" "$PCMANFM_DESKTOP" || echo "wallpaper_mode=$WALLPAPER_MODE" >> "$PCMANFM_DESKTOP"
fi fi
chown -R "$PI_USER:$PI_USER" "$(dirname "$PCMANFM_DESKTOP")" grep -q '^Theme=custom' /etc/plymouth/plymouthd.conf 2>/dev/null || printf '%s\n' '[Daemon]' 'Theme=custom' >> /etc/plymouth/plymouthd.conf
done log "Running update-initramfs (may take a moment) ..."
log "Set desktop wallpaper via pcmanfm config (LXDE-pi and default)" update-initramfs -u -k all 2>/dev/null || true
log "Splash and wallpaper set from file server" mkdir -p /etc/lightdm/lightdm.conf.d
else curl -fsSL "${FILE_SERVER}/99-wallpaper.conf" -o /etc/lightdm/lightdm.conf.d/99-wallpaper.conf 2>/dev/null || log "WARNING: Could not download 99-wallpaper.conf"
log "WARNING: Could not download splash.png" for PROFILE in LXDE-pi default; do
fi PCMANFM_DESKTOP="$PI_HOME/.config/pcmanfm/$PROFILE/desktop-items-0.conf"
mkdir -p "$(dirname "$PCMANFM_DESKTOP")"
if [[ ! -f "$PCMANFM_DESKTOP" ]]; then
printf '%s\n' '[*]' "wallpaper=$WALLPAPER_PATH" "wallpaper_mode=$WALLPAPER_MODE" 'wallpaper_common=1' > "$PCMANFM_DESKTOP"
else
grep -q '^wallpaper=' "$PCMANFM_DESKTOP" && sed -i "s|^wallpaper=.*|wallpaper=$WALLPAPER_PATH|" "$PCMANFM_DESKTOP" || echo "wallpaper=$WALLPAPER_PATH" >> "$PCMANFM_DESKTOP"
grep -q '^wallpaper_mode=' "$PCMANFM_DESKTOP" && sed -i "s/^wallpaper_mode=.*/wallpaper_mode=$WALLPAPER_MODE/" "$PCMANFM_DESKTOP" || echo "wallpaper_mode=$WALLPAPER_MODE" >> "$PCMANFM_DESKTOP"
fi
chown -R "$PI_USER:$PI_USER" "$(dirname "$PCMANFM_DESKTOP")"
done
log "Set desktop wallpaper via pcmanfm config (LXDE-pi and default)"
log "Splash and wallpaper set from file server"
else
log "WARNING: Could not download splash.png"
fi
}
# --- 4. LightDM: rpd-labwc session + configs from file server --- # --- Step 05: LightDM session ---
log "--- LightDM session (rpd-labwc) ---" step_05_lightdm() {
mkdir -p /etc/lightdm/lightdm.conf.d mkdir -p /etc/lightdm/lightdm.conf.d
if curl -fsSL "${FILE_SERVER}/99-default-session.conf" -o /etc/lightdm/lightdm.conf.d/99-default-session.conf 2>/dev/null; then if curl -fsSL "${FILE_SERVER}/99-default-session.conf" -o /etc/lightdm/lightdm.conf.d/99-default-session.conf 2>/dev/null; then
log "99-default-session.conf installed" log "99-default-session.conf installed"
else else
log "WARNING: Could not download 99-default-session.conf" log "WARNING: Could not download 99-default-session.conf"
fi fi
# Raspberry Pi OS may apply main lightdm.conf after .conf.d; force session in main config too if [[ -f /etc/lightdm/lightdm.conf ]]; then
if [[ -f /etc/lightdm/lightdm.conf ]]; then sed -i "s/^user-session=.*/user-session=$LIGHTDM_SESSION/" /etc/lightdm/lightdm.conf
sed -i "s/^user-session=.*/user-session=$LIGHTDM_SESSION/" /etc/lightdm/lightdm.conf sed -i "s/^autologin-session=.*/autologin-session=$LIGHTDM_SESSION/" /etc/lightdm/lightdm.conf
sed -i "s/^autologin-session=.*/autologin-session=$LIGHTDM_SESSION/" /etc/lightdm/lightdm.conf log "Patched /etc/lightdm/lightdm.conf to use $LIGHTDM_SESSION"
log "Patched /etc/lightdm/lightdm.conf to use $LIGHTDM_SESSION" fi
fi }
# --- 5. Maliit on-screen keyboard (from file server) --- # --- Step 06: Maliit on-screen keyboard ---
log "--- Maliit ---" step_06_maliit() {
mkdir -p "$AUTOSTART" "$PI_HOME/.config" mkdir -p "$AUTOSTART" "$PI_HOME/.config"
curl -fsSL "${FILE_SERVER}/maliit-keyboard.desktop" -o "$AUTOSTART/maliit-keyboard.desktop" 2>/dev/null && log "maliit-keyboard.desktop installed" || log "WARNING: Could not download maliit-keyboard.desktop" curl -fsSL "${FILE_SERVER}/maliit-keyboard.desktop" -o "$AUTOSTART/maliit-keyboard.desktop" 2>/dev/null && log "maliit-keyboard.desktop installed" || log "WARNING: Could not download maliit-keyboard.desktop"
}
# --- 5b. Dark theme (GTK + prefer dark for apps) --- # --- Step 07: Dark theme (GTK) ---
log "--- Dark theme ---" step_07_dark_theme() {
GTK_SETTINGS="$PI_HOME/.config/gtk-3.0/settings.ini" GTK_SETTINGS="$PI_HOME/.config/gtk-3.0/settings.ini"
mkdir -p "$(dirname "$GTK_SETTINGS")" mkdir -p "$(dirname "$GTK_SETTINGS")"
if [[ ! -f "$GTK_SETTINGS" ]]; then if [[ ! -f "$GTK_SETTINGS" ]]; then
printf '%s\n' '[Settings]' 'gtk-application-prefer-dark-theme=1' "gtk-theme-name=$GTK_THEME_NAME" > "$GTK_SETTINGS" printf '%s\n' '[Settings]' 'gtk-application-prefer-dark-theme=1' "gtk-theme-name=$GTK_THEME_NAME" > "$GTK_SETTINGS"
else else
grep -q '^gtk-application-prefer-dark-theme=' "$GTK_SETTINGS" && sed -i 's/^gtk-application-prefer-dark-theme=.*/gtk-application-prefer-dark-theme=1/' "$GTK_SETTINGS" || echo 'gtk-application-prefer-dark-theme=1' >> "$GTK_SETTINGS" grep -q '^gtk-application-prefer-dark-theme=' "$GTK_SETTINGS" && sed -i 's/^gtk-application-prefer-dark-theme=.*/gtk-application-prefer-dark-theme=1/' "$GTK_SETTINGS" || echo 'gtk-application-prefer-dark-theme=1' >> "$GTK_SETTINGS"
grep -q '^gtk-theme-name=' "$GTK_SETTINGS" && sed -i "s/^gtk-theme-name=.*/gtk-theme-name=$GTK_THEME_NAME/" "$GTK_SETTINGS" || echo "gtk-theme-name=$GTK_THEME_NAME" >> "$GTK_SETTINGS" grep -q '^gtk-theme-name=' "$GTK_SETTINGS" && sed -i "s/^gtk-theme-name=.*/gtk-theme-name=$GTK_THEME_NAME/" "$GTK_SETTINGS" || echo "gtk-theme-name=$GTK_THEME_NAME" >> "$GTK_SETTINGS"
fi fi
# Fallback if theme not installed (e.g. older image): Adwaita-dark log "Set dark theme ($GTK_THEME_NAME) in gtk-3.0/settings.ini"
log "Set dark theme ($GTK_THEME_NAME) in gtk-3.0/settings.ini" chown -R "$PI_USER:$PI_USER" "$PI_HOME/.config"
chown -R "$PI_USER:$PI_USER" "$PI_HOME/.config" }
# --- 6. reTerminal DM drivers (Seeed) --- # --- Step 08: reTerminal DM drivers (Seeed) ---
if [[ -n "$RETERMINAL_REPO_URL" ]]; then step_08_reterminal_drivers() {
log "--- reTerminal DM drivers ---" if [[ -z "$RETERMINAL_REPO_URL" ]]; then
log "Skipping reTerminal drivers (RETERMINAL_REPO_URL not set)"
return 0
fi
REPO_DIR="/tmp/seeed-linux-dtoverlays" REPO_DIR="/tmp/seeed-linux-dtoverlays"
log "Cloning seeed-linux-dtoverlays to $REPO_DIR ..." log "Cloning seeed-linux-dtoverlays to $REPO_DIR ..."
git clone --depth 1 "$RETERMINAL_REPO_URL" "$REPO_DIR" git clone --depth 1 "$RETERMINAL_REPO_URL" "$REPO_DIR"
# Script must run from repo root (it uses pwd for MOD_PATH). On bookworm+ --compat-kernel is not supported.
log "Running reTerminal.sh --device $RETERMINAL_DEVICE from $REPO_DIR ..." log "Running reTerminal.sh --device $RETERMINAL_DEVICE from $REPO_DIR ..."
if ( cd "$REPO_DIR" && "$REPO_DIR/scripts/reTerminal.sh" --device "$RETERMINAL_DEVICE" ); then if ( cd "$REPO_DIR" && "$REPO_DIR/scripts/reTerminal.sh" --device "$RETERMINAL_DEVICE" ); then
log "reTerminal DM drivers installed (reboot will apply)" log "reTerminal DM drivers installed (reboot will apply)"
else else
log "WARNING: reTerminal.sh failed (see log above). Display/touch may still work; you can retry later with: cd $REPO_DIR && sudo ./scripts/reTerminal.sh --device $RETERMINAL_DEVICE" log "WARNING: reTerminal.sh failed (see log above). Display/touch may still work; you can retry later with: cd $REPO_DIR && sudo ./scripts/reTerminal.sh --device $RETERMINAL_DEVICE"
fi fi
log "Removing $REPO_DIR"
rm -rf "$REPO_DIR" rm -rf "$REPO_DIR"
else }
log "--- Skipping reTerminal drivers (RETERMINAL_REPO_URL not set) ---"
fi
# --- 6b. Re-apply splash and display (Seeed script sets disable_splash=1 and can duplicate Plymouth theme) --- # --- Step 09: Re-apply splash and Plymouth theme ---
log "--- Re-applying boot splash and Plymouth theme ---" step_09_reapply_splash() {
CFG_PATH="/boot/firmware/config.txt" CFG_PATH="/boot/firmware/config.txt"
[[ -f /boot/firmware/config.txt ]] || CFG_PATH="/boot/config.txt" [[ -f /boot/firmware/config.txt ]] || CFG_PATH="/boot/config.txt"
if [[ -f "$CFG_PATH" ]]; then if [[ -f "$CFG_PATH" ]] && grep -q '^disable_splash=1' "$CFG_PATH"; then
if grep -q '^disable_splash=1' "$CFG_PATH"; then
sed -i 's/^disable_splash=1$/disable_splash=0/' "$CFG_PATH" sed -i 's/^disable_splash=1$/disable_splash=0/' "$CFG_PATH"
log "Set disable_splash=0 so Plymouth splash is shown" log "Set disable_splash=0 so Plymouth splash is shown"
fi fi
fi if [[ -f /etc/plymouth/plymouthd.conf ]]; then
# Ensure Plymouth uses our custom theme only (single [Daemon], Theme=custom) sed -i '/^Theme=/d' /etc/plymouth/plymouthd.conf
if [[ -f /etc/plymouth/plymouthd.conf ]]; then sed -i '/^\[Daemon\]$/d' /etc/plymouth/plymouthd.conf
sed -i '/^Theme=/d' /etc/plymouth/plymouthd.conf grep -q '^\[Daemon\]' /etc/plymouth/plymouthd.conf || echo '[Daemon]' >> /etc/plymouth/plymouthd.conf
sed -i '/^\[Daemon\]$/d' /etc/plymouth/plymouthd.conf echo 'Theme=custom' >> /etc/plymouth/plymouthd.conf
grep -q '^\[Daemon\]' /etc/plymouth/plymouthd.conf || echo '[Daemon]' >> /etc/plymouth/plymouthd.conf log "Plymouth theme set to custom only"
echo 'Theme=custom' >> /etc/plymouth/plymouthd.conf
log "Plymouth theme set to custom only"
fi
log "Running update-initramfs to apply Plymouth theme ..."
update-initramfs -u -k all 2>/dev/null || true
# --- 6b2. Kernel cmdline: swiotlb + DSI rotation (KMS, persistent across reboots) ---
CMDLINE_PATH="/boot/firmware/cmdline.txt"
[[ -f "$CMDLINE_PATH" ]] || CMDLINE_PATH="/boot/cmdline.txt"
if [[ -f "$CMDLINE_PATH" ]]; then
if [[ -n "$SWIOTLB_SIZE" ]] && ! grep -q 'swiotlb=' "$CMDLINE_PATH"; then
sed -i "s/rootwait/rootwait swiotlb=$SWIOTLB_SIZE/" "$CMDLINE_PATH"
log "Added swiotlb=$SWIOTLB_SIZE to kernel cmdline (vc4-drm / DSI)"
fi fi
# Persistent rotation for DSI-1 (KMS): append at end of single line. 90 = 90° clockwise. log "Running update-initramfs to apply Plymouth theme ..."
if [[ -n "$DSI_ROTATE" ]] && ! grep -q 'video=DSI-1:rotate=' "$CMDLINE_PATH"; then update-initramfs -u -k all 2>/dev/null || true
sed -i "s/\$/ video=DSI-1:rotate=$DSI_ROTATE/" "$CMDLINE_PATH" }
log "Added video=DSI-1:rotate=$DSI_ROTATE to kernel cmdline (DSI rotation)"
# --- Step 10: Kernel cmdline (swiotlb + DSI rotation) ---
step_10_cmdline() {
CMDLINE_PATH="/boot/firmware/cmdline.txt"
[[ -f "$CMDLINE_PATH" ]] || CMDLINE_PATH="/boot/cmdline.txt"
if [[ -f "$CMDLINE_PATH" ]]; then
if [[ -n "$SWIOTLB_SIZE" ]] && ! grep -q 'swiotlb=' "$CMDLINE_PATH"; then
sed -i "s/rootwait/rootwait swiotlb=$SWIOTLB_SIZE/" "$CMDLINE_PATH"
log "Added swiotlb=$SWIOTLB_SIZE to kernel cmdline (vc4-drm / DSI)"
fi
if [[ -n "$DSI_ROTATE" ]] && ! grep -q 'video=DSI-1:rotate=' "$CMDLINE_PATH"; then
sed -i "s/\$/ video=DSI-1:rotate=$DSI_ROTATE/" "$CMDLINE_PATH"
log "Added video=DSI-1:rotate=$DSI_ROTATE to kernel cmdline (DSI rotation)"
fi
fi fi
fi }
# --- 7. One-shots (wallpaper already set in pcmanfm config above; rotation is via cmdline.txt) --- # --- Step 11: One-shot scripts ---
log "--- One-shot scripts (if any) ---" step_11_oneshots() {
if [[ -n "$DSI_ROTATE" ]]; then if [[ -n "$DSI_ROTATE" ]]; then
log "Rotation is set via kernel cmdline (video=DSI-1:rotate=$DSI_ROTATE)" log "Rotation is set via kernel cmdline (video=DSI-1:rotate=$DSI_ROTATE)"
fi fi
if [[ -n "$ONESHOT_SCRIPTS" ]]; then if [[ -n "$ONESHOT_SCRIPTS" ]]; then
for _name in $ONESHOT_SCRIPTS; do for _name in $ONESHOT_SCRIPTS; do
install_oneshot "$_name" || true install_oneshot "$_name" || true
done done
else else
log "No one-shot scripts configured (ONESHOT_SCRIPTS empty)" log "No one-shot scripts configured (ONESHOT_SCRIPTS empty)"
fi fi
}
# --- 8. Allow pi to append to first-boot.log (for one-shot scripts) --- # --- Step 12: Log file permissions ---
chmod 666 "$LOGFILE" step_12_log_permissions() {
log "Log file $LOGFILE is now appendable by user $PI_USER for one-shot scripts" chmod 666 "$LOGFILE"
log "Log file $LOGFILE is now appendable by user $PI_USER for one-shot scripts"
}
# --- 9. Reboot --- # --- Step 13: Reboot ---
log "=== first-boot.sh finished, rebooting ===" step_13_reboot() {
reboot DEVICE_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
report_status "done" "First-boot complete" "13" "reboot" "$DEVICE_IP"
log "Device IP: ${DEVICE_IP:-unknown}"
log "=== first-boot.sh finished, rebooting ==="
reboot
}
# --- Main: run steps in order ---
run_step 01 hostname
run_step 02 packages
run_step 03 kiosk_files
run_step 04 splash_wallpaper
run_step 05 lightdm
run_step 06 maliit
run_step 07 dark_theme
run_step 08 reterminal_drivers
run_step 09 reapply_splash
run_step 10 cmdline
run_step 11 oneshots
run_step 12 log_permissions
run_step 13 reboot

View File

@@ -46,6 +46,7 @@ BUILD_STATUS_FILE = Path(os.environ.get("CM4_BUILD_STATUS_FILE", str(BASE_DIR /
BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR / "build_cloudinit_request.json"))) BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR / "build_cloudinit_request.json")))
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json"))) SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json"))) SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
FIRST_BOOT_STATUS_FILE = Path(os.environ.get("CM4_FIRST_BOOT_STATUS_FILE", str(BASE_DIR / "first_boot_status.json")))
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json"))) CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json"))) PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", str(BASE_DIR / "portal_descriptions.json")))
DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db"))) DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db")))
@@ -587,6 +588,59 @@ def api_status_clear():
return jsonify({"ok": False, "error": "Could not write status"}), 500 return jsonify({"ok": False, "error": "Could not write status"}), 500
def _first_boot_status_read():
"""Read first-boot status (device reports progress during first-boot.sh)."""
try:
with open(FIRST_BOOT_STATUS_FILE, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {"phase": "idle", "message": "", "step": "", "step_name": "", "hostname": "", "ip": "", "updated": None}
def _first_boot_status_write(phase, message="", step="", step_name="", hostname="", ip="", error=None):
"""Write first-boot status (called by POST from device)."""
try:
os.makedirs(os.path.dirname(FIRST_BOOT_STATUS_FILE) or ".", exist_ok=True)
data = {
"phase": phase,
"message": message,
"step": step,
"step_name": step_name,
"hostname": hostname,
"ip": ip or "",
"updated": time.time(),
}
if error:
data["error"] = error
with open(FIRST_BOOT_STATUS_FILE, "w") as f:
json.dump(data, f, indent=2)
except (PermissionError, OSError):
pass
@app.route("/api/first-boot-status", methods=["GET"])
def api_first_boot_status_get():
"""Return current first-boot progress (for dashboard to poll)."""
return jsonify(_first_boot_status_read())
@app.route("/api/first-boot-status", methods=["POST"])
def api_first_boot_status_post():
"""Called by device during first-boot.sh to report progress. No auth (device on local net)."""
body = request.get_json(silent=True) or {}
phase = (body.get("phase") or "running").strip().lower()
if phase not in ("started", "running", "done", "error"):
phase = "running"
message = (body.get("message") or "").strip()[:500]
step = (body.get("step") or "").strip()[:10]
step_name = (body.get("step_name") or "").strip()[:80]
hostname = (body.get("hostname") or "").strip()[:64]
ip = (body.get("ip") or "").strip()[:45]
error = (body.get("error") or "").strip()[:500] if phase == "error" else None
_first_boot_status_write(phase=phase, message=message, step=step, step_name=step_name, hostname=hostname, ip=ip, error=error)
return jsonify({"ok": True})
@app.route("/api/log") @app.route("/api/log")
def api_log(): def api_log():
return jsonify({"log": read_log_tail()}) return jsonify({"log": read_log_tail()})
@@ -653,6 +707,8 @@ def api_device_action():
pass # host may still have SHRINK_BACKUP=1 pass # host may still have SHRINK_BACKUP=1
with open(ACTION_REQUEST_FILE, "w") as f: with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action) f.write(action)
if action == "deploy":
_first_boot_status_write("idle", "", hostname="", ip="")
return jsonify({"ok": True}) return jsonify({"ok": True})
except (PermissionError, OSError): except (PermissionError, OSError):
return jsonify({"ok": False, "error": "Could not write action file"}), 500 return jsonify({"ok": False, "error": "Could not write action file"}), 500
@@ -669,6 +725,7 @@ def api_device_action():
ip = d.get("ip") or mac ip = d.get("ip") or mac
if action == "deploy": if action == "deploy":
_write_status("flashing", f"Deploying to {ip} (network)...") _write_status("flashing", f"Deploying to {ip} (network)...")
_first_boot_status_write("idle", "", hostname="", ip="")
elif action == "backup": elif action == "backup":
_write_status("backup", f"Backing up {ip} (network)...") _write_status("backup", f"Backing up {ip} (network)...")
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -1006,6 +1063,96 @@ def api_portal_files_upload():
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": False, "error": str(e)}), 500
# Text extensions allowed for editor (others are read-only or binary)
_PORTAL_EDITABLE_EXTENSIONS = frozenset(
".sh .bash .conf .cfg .ini .desktop .json .py .yml .yaml .md .txt .plymouth .script".split()
)
def _portal_language_for_path(name):
"""Return Ace/editor language mode from filename."""
n = (name or "").lower()
if n.endswith(".sh") or n.endswith(".bash"):
return "sh"
if n.endswith(".json"):
return "json"
if n.endswith(".py"):
return "python"
if n.endswith(".yml") or n.endswith(".yaml"):
return "yaml"
if n.endswith(".md"):
return "markdown"
if n.endswith(".conf") or n.endswith(".cfg") or n.endswith(".ini") or n.endswith(".desktop") or n.endswith(".plymouth"):
return "ini"
if n.endswith(".script"):
return "sh"
return "plain_text"
@app.route("/api/portal-files/content", methods=["GET"])
@require_admin
def api_portal_file_content_get():
"""Return file content as text for the editor. path= query param. Rejects binary."""
path_arg = (request.args.get("path") or "").strip()
if not path_arg or ".." in path_arg or "\\" in path_arg:
return jsonify({"ok": False, "error": "invalid path"}), 400
path = (PORTAL_FILES_DIR / path_arg).resolve()
try:
path.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"ok": False, "error": "invalid path"}), 400
if not path.is_file():
return jsonify({"ok": False, "error": "not found"}), 404
ext = path.suffix.lower() if path.suffix else ""
if ext and ext not in _PORTAL_EDITABLE_EXTENSIONS:
return jsonify({"ok": False, "error": "binary or unsupported file type for editing"}), 400
try:
raw = path.read_bytes()
text = raw.decode("utf-8")
except UnicodeDecodeError:
return jsonify({"ok": False, "error": "binary or unsupported encoding"}), 400
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({
"ok": True,
"path": path_arg,
"content": text,
"language": _portal_language_for_path(path.name),
})
@app.route("/api/portal-files/content", methods=["PUT"])
@require_admin
def api_portal_file_content_put():
"""Write file content from editor. JSON body: { \"path\": \"...\", \"content\": \"...\" }."""
data = request.get_json(silent=True) or {}
path_arg = (data.get("path") or "").strip()
content = data.get("content")
if not path_arg or ".." in path_arg or "\\" in path_arg:
return jsonify({"ok": False, "error": "invalid path"}), 400
if content is None:
return jsonify({"ok": False, "error": "content required"}), 400
path = (PORTAL_FILES_DIR / path_arg).resolve()
try:
path.relative_to(PORTAL_FILES_DIR.resolve())
except ValueError:
return jsonify({"ok": False, "error": "invalid path"}), 400
if path.is_dir():
return jsonify({"ok": False, "error": "cannot edit a folder"}), 400
ext = path.suffix.lower() if path.suffix else ""
if path.exists() and ext and ext not in _PORTAL_EDITABLE_EXTENSIONS:
return jsonify({"ok": False, "error": "unsupported file type for editing"}), 400
if not isinstance(content, str):
content = str(content)
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
admin_log("portal_edit", path_arg)
return jsonify({"ok": True, "path": path_arg})
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/portal-files/<path:name>", methods=["DELETE"]) @app.route("/api/portal-files/<path:name>", methods=["DELETE"])
@require_admin @require_admin
def api_portal_file_delete(name): def api_portal_file_delete(name):

View File

@@ -108,6 +108,15 @@
<div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div> <div id="progressWrap" class="progress-track" style="display:none;"><div id="progressFill" class="progress-fill"></div></div>
</div> </div>
<div id="firstBootCard" class="card" style="margin-bottom: 1rem; display:none;">
<h2 class="card-title">First-boot progress</h2>
<div id="firstBootStatus" class="status-row">
<span id="firstBootStep" class="status-pill idle"></span>
<span id="firstBootMsg" class="status-msg"></span>
</div>
<p id="firstBootIpWrap" style="margin-top: 0.5rem; font-size: 0.9rem; display:none;"><strong>Device IP:</strong> <code id="firstBootIp" class="mono"></code></p>
</div>
<div class="card"> <div class="card">
<h2 class="card-title">Capture or deploy</h2> <h2 class="card-title">Capture or deploy</h2>
<p id="dhcpNetbootWrap" class="status-row" style="margin-bottom: 0.5rem; font-size: 0.85rem;"><span class="text-dim">Network boot (DHCP):</span> <span id="dhcpNetbootState"></span> <button type="button" id="dhcpNetbootDisableBtn" class="btn btn-outline btn-sm" style="display:none;">Disable network boot</button> <button type="button" id="dhcpNetbootEnableBtn" class="btn btn-outline btn-sm" style="display:none;">Enable network boot</button></p> <p id="dhcpNetbootWrap" class="status-row" style="margin-bottom: 0.5rem; font-size: 0.85rem;"><span class="text-dim">Network boot (DHCP):</span> <span id="dhcpNetbootState"></span> <button type="button" id="dhcpNetbootDisableBtn" class="btn btn-outline btn-sm" style="display:none;">Disable network boot</button> <button type="button" id="dhcpNetbootEnableBtn" class="btn btn-outline btn-sm" style="display:none;">Enable network boot</button></p>
@@ -230,6 +239,28 @@
function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; } function fmtSize(n) { if (n >= 1e9) return (n/1e9).toFixed(1)+' GB'; if (n >= 1e6) return (n/1e6).toFixed(1)+' MB'; return (n/1e3).toFixed(0)+' KB'; }
function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); } function fmtDate(ts) { return new Date(ts*1000).toLocaleString(); }
function fetchStatus() { fetch('/api/status').then(function(r){ return r.json(); }).then(renderStatus).catch(function(){ renderStatus({ phase: 'error', message: 'Could not load status.' }); }); } function fetchStatus() { fetch('/api/status').then(function(r){ return r.json(); }).then(renderStatus).catch(function(){ renderStatus({ phase: 'error', message: 'Could not load status.' }); }); }
function renderFirstBootStatus(data) {
const phase = data.phase || 'idle';
const card = document.getElementById('firstBootCard');
if (phase === 'idle' || phase === '') { card.style.display = 'none'; return; }
card.style.display = 'block';
const stepEl = document.getElementById('firstBootStep');
const msgEl = document.getElementById('firstBootMsg');
const ipWrap = document.getElementById('firstBootIpWrap');
const ipEl = document.getElementById('firstBootIp');
stepEl.textContent = data.step ? 'Step ' + data.step : (phase === 'done' ? 'Done' : phase);
stepEl.className = 'status-pill ' + (phase === 'done' ? 'done' : phase === 'error' ? 'error' : 'flashing');
msgEl.textContent = data.message || (phase === 'done' && data.ip ? 'First-boot finished. Device IP: ' + data.ip : '');
if (phase === 'done' && data.ip) {
ipWrap.style.display = 'block';
ipEl.textContent = data.ip;
} else {
ipWrap.style.display = 'none';
}
}
function fetchFirstBootStatus() {
fetch('/api/first-boot-status').then(function(r){ return r.json(); }).then(renderFirstBootStatus).catch(function(){});
}
function fetchPending() { fetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){ renderPending(d.usb || null, d.network || []); }).catch(function(){ renderPending(null, []); }); } function fetchPending() { fetch('/api/pending-devices').then(function(r){ return r.json(); }).then(function(d){ renderPending(d.usb || null, d.network || []); }).catch(function(){ renderPending(null, []); }); }
function fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); } function fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); }
function fetchGolden() { function fetchGolden() {
@@ -286,10 +317,11 @@
.then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); }) .then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); })
.catch(function(){ alert('Request failed'); }); .catch(function(){ alert('Request failed'); });
}); });
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases(); fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases(); fetchFirstBootStatus();
setInterval(fetchStatus, 2000); setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000); setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000); setInterval(fetchPending, 2000);
setInterval(fetchFirstBootStatus, 3000);
setInterval(fetchGolden, 10000); setInterval(fetchGolden, 10000);
setInterval(fetchDhcpNetboot, 10000); setInterval(fetchDhcpNetboot, 10000);
setInterval(fetchDhcpLeases, 10000); setInterval(fetchDhcpLeases, 10000);

View File

@@ -47,6 +47,15 @@
.desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; } .desc-input { width: 100%; max-width: 280px; font-size: 0.8rem; background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.3rem 0.5rem; border-radius: 4px; }
.folder-name { font-weight: 500; } .folder-name { font-weight: 500; }
input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; } input[type="text"] { background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text); padding: 0.35rem 0.5rem; border-radius: 4px; font: inherit; }
/* Editor modal */
.editor-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 1rem; }
.editor-overlay.visible { display: flex; }
.editor-modal { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); width: 100%; max-width: 900px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.4); }
.editor-header { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.editor-title { font-family: 'JetBrains Mono', monospace; font-size: 0.9rem; color: var(--text); }
.editor-actions { display: flex; gap: 0.5rem; }
.editor-body { flex: 1; min-height: 400px; overflow: hidden; }
#editorHost { width: 100%; height: 100%; min-height: 420px; }
</style> </style>
</head> </head>
<body> <body>
@@ -80,8 +89,24 @@
</table> </table>
<p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p> <p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p>
</div> </div>
<div id="editorOverlay" class="editor-overlay">
<div class="editor-modal">
<div class="editor-header">
<span id="editorTitle" class="editor-title"></span>
<div class="editor-actions">
<button type="button" class="btn btn-primary btn-sm" id="editorSaveBtn">Save</button>
<button type="button" class="btn btn-outline btn-sm" id="editorCancelBtn">Cancel</button>
</div>
</div>
<div class="editor-body">
<div id="editorHost"></div>
</div>
</div>
</div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.0/ace.min.js" crossorigin="anonymous"></script>
<script> <script>
function authFetch(url, opts) { function authFetch(url, opts) {
opts = opts || {}; opts = opts || {};
@@ -142,7 +167,7 @@
if (it.type === 'folder') { if (it.type === 'folder') {
actions = '<button type="button" class="btn btn-outline btn-sm open-folder" data-path="' + escapeHtml(it.path) + '">Open</button> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="folder">Delete</button>'; actions = '<button type="button" class="btn btn-outline btn-sm open-folder" data-path="' + escapeHtml(it.path) + '">Open</button> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="folder">Delete</button>';
} else { } else {
actions = '<a href="' + escapeHtml(baseUrl + it.path) + '" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open</a> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="file">Delete</button>'; actions = '<button type="button" class="btn btn-outline btn-sm edit-file" data-path="' + escapeHtml(it.path) + '">Edit</button> <a href="' + escapeHtml(baseUrl + it.path) + '" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open</a> <button type="button" class="btn btn-danger btn-sm delete-item" data-path="' + escapeHtml(it.path) + '" data-type="file">Delete</button>';
} }
tr.innerHTML = '<td class="' + (it.type === 'folder' ? 'folder-name' : '') + '">' + escapeHtml(it.name) + (it.type === 'folder' ? ' /' : '') + '</td><td>' + typeLabel + '</td>' + sizeCell + descCell + '<td class="actions-cell">' + actions + '</td>'; tr.innerHTML = '<td class="' + (it.type === 'folder' ? 'folder-name' : '') + '">' + escapeHtml(it.name) + (it.type === 'folder' ? ' /' : '') + '</td><td>' + typeLabel + '</td>' + sizeCell + descCell + '<td class="actions-cell">' + actions + '</td>';
tbody.appendChild(tr); tbody.appendChild(tr);
@@ -154,6 +179,9 @@
tbody.querySelectorAll('.open-folder').forEach(function(btn) { tbody.querySelectorAll('.open-folder').forEach(function(btn) {
btn.onclick = function() { currentPath = btn.getAttribute('data-path'); fetchPortal(); }; btn.onclick = function() { currentPath = btn.getAttribute('data-path'); fetchPortal(); };
}); });
tbody.querySelectorAll('.edit-file').forEach(function(btn) {
btn.onclick = function() { openEditor(btn.getAttribute('data-path')); };
});
tbody.querySelectorAll('.delete-item').forEach(function(btn) { tbody.querySelectorAll('.delete-item').forEach(function(btn) {
btn.onclick = function() { btn.onclick = function() {
var path = btn.getAttribute('data-path'); var path = btn.getAttribute('data-path');
@@ -209,6 +237,50 @@
this.value = ''; this.value = '';
}; };
var editorInstance = null;
var editorCurrentPath = null;
var aceModeMap = { sh: 'sh', json: 'json', python: 'python', yaml: 'yaml', markdown: 'markdown', ini: 'ini', plain_text: 'plain_text' };
function initAce() {
if (editorInstance) return editorInstance;
var host = document.getElementById('editorHost');
editorInstance = ace.edit(host);
editorInstance.setTheme('ace/theme/tomorrow_night_eighties');
editorInstance.setOptions({ fontSize: '13px', showPrintMargin: false, wrap: true, useSoftTabs: true, tabSize: 2 });
editorInstance.session.setUseWorker(false);
return editorInstance;
}
function openEditor(path) {
editorCurrentPath = path;
document.getElementById('editorTitle').textContent = path;
document.getElementById('editorOverlay').classList.add('visible');
authFetch('/api/portal-files/content?path=' + encodeURIComponent(path)).then(function(r) { return r.json(); }).then(function(d) {
if (!d.ok) { alert(d.error || 'Could not load file'); closeEditor(); return; }
var ace = initAce();
ace.session.setValue(d.content || '');
var mode = aceModeMap[d.language] || 'plain_text';
ace.session.setMode('ace/mode/' + mode);
ace.focus();
}).catch(function() { alert('Could not load file'); closeEditor(); });
}
function closeEditor() {
editorCurrentPath = null;
document.getElementById('editorOverlay').classList.remove('visible');
}
document.getElementById('editorSaveBtn').onclick = function() {
if (!editorCurrentPath) return;
var content = editorInstance.getSession().getValue();
authFetch('/api/portal-files/content', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: editorCurrentPath, content: content }) })
.then(function(r) { return r.json(); })
.then(function(d) { if (d.ok) { closeEditor(); fetchPortal(); } else alert(d.error || 'Save failed'); })
.catch(function() { alert('Save failed'); });
};
document.getElementById('editorCancelBtn').onclick = closeEditor;
document.getElementById('editorOverlay').onclick = function(e) { if (e.target === document.getElementById('editorOverlay')) closeEditor(); };
fetchPortal(); fetchPortal();
</script> </script>
</body> </body>

View File

@@ -2,6 +2,18 @@
# Sync portal (file server) content from the repo to the LXC. # Sync portal (file server) content from the repo to the LXC.
# Updates /var/lib/cm4-provisioning/portal-files/ so first-boot and the # Updates /var/lib/cm4-provisioning/portal-files/ so first-boot and the
# dashboard /files/ serve the same scripts and assets as in the repo. # dashboard /files/ serve the same scripts and assets as in the repo.
#
# Files first-boot.sh downloads from FILE_SERVER (../files/first-boot/):
# Required: start-chromium.sh, chromium-kiosk.desktop, splash.png,
# custom.plymouth, custom.script, 99-wallpaper.conf,
# 99-default-session.conf, maliit-keyboard.desktop
# One-shots (if ONESHOT_SCRIPTS set): <name>.sh + <name>.desktop
# e.g. 01-set-rotation-once.sh, 01-set-rotation-once.desktop,
# 02-set-wallpaper-once.sh, 02-set-wallpaper-once.desktop
# Optional: first-boot.conf (downloaded to /tmp by cloud-init runcmd)
# Note: splash.png is not in the repo; add it to plymouth-custom/ or upload
# via the portal if you want a custom boot splash.
#
# Usage: ./sync-portal-files-to-lxc.sh [user@lxc_ip] # Usage: ./sync-portal-files-to-lxc.sh [user@lxc_ip]
# Example: ./sync-portal-files-to-lxc.sh root@10.130.60.141 # Example: ./sync-portal-files-to-lxc.sh root@10.130.60.141
@@ -22,25 +34,32 @@ echo "Syncing portal files to $LXC ($REMOTE_PORTAL) ..."
ssh "$LXC" "command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y rsync)" ssh "$LXC" "command -v rsync >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y rsync)"
ssh "$LXC" "mkdir -p $REMOTE_FIRST_BOOT" ssh "$LXC" "mkdir -p $REMOTE_FIRST_BOOT"
# first-boot.sh at portal root (cloud-init downloads it by URL, not from first-boot/ subfolder) # --- Portal root (URL /files/...) ---
# first-boot.sh: cloud-init runcmd downloads this
rsync -avz "$CLOUDINIT_DIR/first-boot.sh" "$LXC:$REMOTE_PORTAL/" rsync -avz "$CLOUDINIT_DIR/first-boot.sh" "$LXC:$REMOTE_PORTAL/"
# Optional config: cloud-init can download to /tmp/first-boot.conf before running first-boot.sh
rsync -avz "$CLOUDINIT_DIR/first-boot.conf.example" "$LXC:$REMOTE_PORTAL/"
[[ -f "$CLOUDINIT_DIR/first-boot.conf" ]] && rsync -avz "$CLOUDINIT_DIR/first-boot.conf" "$LXC:$REMOTE_PORTAL/" || true
# config-files/* (includes chromium-kiosk.desktop) → portal-files/first-boot/ # --- first-boot/ (URL /files/first-boot/...) ---
# Config files: LightDM, Maliit, Chromium kiosk, one-shot .desktop files
rsync -avz --exclude='README.md' \ rsync -avz --exclude='README.md' \
"$CLOUDINIT_DIR/config-files/" \ "$CLOUDINIT_DIR/config-files/" \
"$LXC:$REMOTE_FIRST_BOOT/" "$LXC:$REMOTE_FIRST_BOOT/"
# start-chromium.sh → portal-files/first-boot/ # Kiosk and scripts
rsync -avz "$CLOUDINIT_DIR/start-chromium.sh" "$LXC:$REMOTE_FIRST_BOOT/" rsync -avz \
"$CLOUDINIT_DIR/start-chromium.sh" \
"$CLOUDINIT_DIR/01-set-rotation-once.sh" \
"$CLOUDINIT_DIR/02-set-wallpaper-once.sh" \
"$CLOUDINIT_DIR/set-rotation-at-login.sh" \
"$CLOUDINIT_DIR/fix-reterminal-display.sh" \
"$LXC:$REMOTE_FIRST_BOOT/"
# plymouth-custom/* (custom.plymouth, custom.script, splash.png if present) → portal-files/first-boot/ # Plymouth theme (custom.plymouth, custom.script; add splash.png to this dir or upload via portal)
rsync -avz \ rsync -avz \
"$CLOUDINIT_DIR/files-from-guard/plymouth-custom/" \ "$CLOUDINIT_DIR/files-from-guard/plymouth-custom/" \
"$LXC:$REMOTE_FIRST_BOOT/" "$LXC:$REMOTE_FIRST_BOOT/"
# one-shot scripts from cloud-init root → portal-files/first-boot/ (wallpaper set in first-boot via labwc autostart)
rsync -avz \
"$CLOUDINIT_DIR/set-rotation-once.sh" \
"$LXC:$REMOTE_FIRST_BOOT/"
echo "Done. Portal files at http://$(echo "$LXC" | cut -d@ -f2):5000/files/" echo "Done. Portal files at http://$(echo "$LXC" | cut -d@ -f2):5000/files/"
echo "Note: Add splash.png to $REMOTE_FIRST_BOOT/ (or plymouth-custom/) if you want a custom boot splash."