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)
│ ├── config-files/ LightDM, Maliit, one-shots (.desktop + scripts)
│ │ ├── 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
│ └── 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.
# Logs to /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 "waiting 5s for compositor ..."
@@ -26,5 +27,5 @@ else
fi
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"

View File

@@ -2,7 +2,8 @@
# 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.
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"
LABWC_AUTOSTART="$HOME/.config/labwc/autostart"
@@ -32,5 +33,5 @@ else
fi
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"

View File

@@ -1,5 +1,5 @@
[Desktop Entry]
Type=Application
Name=Set rotation once
Exec=/home/pi/set-rotation-once.sh
Name=01 Set rotation once
Exec=/home/pi/01-set-rotation-once.sh
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-default-session.conf | /etc/lightdm/lightdm.conf.d/99-default-session.conf (rpd-labwc) |
| 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) |
Wallpaper is set once during first-boot via pcmanfm config; no set-wallpaper-once one-shot.
| 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. |

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-default-session.conf** | LightDM default session rpd-labwc (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`.
- **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.
- **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 ---
# 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.
# ONESHOT_SCRIPTS=""
# Use numbered names so they run in order at first login. Leave empty for none.
# 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).
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`.
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).
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.
# 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.
# Set ENABLE_STEP_01=0 .. ENABLE_STEP_13=0 in config to disable a step (default: all enabled).
set -e
export DEBIAN_FRONTEND=noninteractive
@@ -24,6 +25,20 @@ SWIOTLB_SIZE="${SWIOTLB_SIZE:-65536}"
RETERMINAL_DEVICE="${RETERMINAL_DEVICE:-reTerminal-DM}"
RETERMINAL_REPO_URL="${RETERMINAL_REPO_URL:-https://github.com/Seeed-Studio/seeed-linux-dtoverlays}"
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) ---
FIRST_BOOT_CONF=""
@@ -40,6 +55,13 @@ done
PI_HOME="/home/$PI_USER"
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 ---
log() { echo "[$(date -Iseconds)] $*"; }
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)"
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) ---
log "--- Hostname: $HOSTNAME ---"
echo "$HOSTNAME" > /etc/hostname
hostnamectl set-hostname "$HOSTNAME" 2>/dev/null || true
# Ensure hostname resolves so sudo and other tools don't warn
if ! grep -q "127.0.1.1[[:space:]]*$HOSTNAME" /etc/hosts 2>/dev/null; then
sed -i "/127.0.1.1[[:space:]].*$/d" /etc/hosts
echo "127.0.1.1 $HOSTNAME" >> /etc/hosts
fi
log "Hostname set to $HOSTNAME; /etc/hosts updated"
# --- Report status to portal (no-op if PORTAL_BASE empty; failures ignored) ---
report_status() {
local phase="$1" message="$2" step="$3" step_name="$4" ip="$5"
[[ -z "$PORTAL_BASE" ]] && return 0
local json="{\"phase\":\"${phase}\",\"message\":\"${message}\",\"step\":\"${step}\",\"step_name\":\"${step_name}\",\"hostname\":\"${HOSTNAME}\",\"ip\":\"${ip}\"}"
curl -s -X POST -H "Content-Type: application/json" -d "$json" "${PORTAL_BASE}/api/first-boot-status" || true
}
report_status "started" "First-boot started" "" "" ""
# --- Helpers ---
# Download script + .desktop from FILE_SERVER and install as one-shot autostart (runs once at pi's first login, then deletes itself).
# --- Helper: run step if enabled ---
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() {
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
log "Downloaded ${name}.sh to $PI_HOME/${name}.sh"
else
@@ -78,170 +109,205 @@ install_oneshot() {
log "One-shot $name installed (will run at first login and then remove itself)"
}
# --- 1. Packages ---
log "--- Installing packages ---"
log "Running apt-get update ..."
apt-get update -qq
log "Installing: $PACKAGES"
apt-get install -y -qq $PACKAGES
log "Packages installed successfully"
# --- 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"
# --- Step 01: Hostname and /etc/hosts ---
step_01_hostname() {
echo "$HOSTNAME" > /etc/hostname
hostnamectl set-hostname "$HOSTNAME" 2>/dev/null || true
if ! grep -q "127.0.1.1[[:space:]]*$HOSTNAME" /etc/hosts 2>/dev/null; then
sed -i "/127.0.1.1[[:space:]].*$/d" /etc/hosts
echo "127.0.1.1 $HOSTNAME" >> /etc/hosts
fi
grep -q '^Theme=custom' /etc/plymouth/plymouthd.conf 2>/dev/null || printf '%s\n' '[Daemon]' 'Theme=custom' >> /etc/plymouth/plymouthd.conf
log "Running update-initramfs (may take a moment) ..."
update-initramfs -u -k all 2>/dev/null || true
mkdir -p /etc/lightdm/lightdm.conf.d
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"
# Set desktop wallpaper once via pcmanfm config (rpd-labwc uses pcmanfm-pi; profile LXDE-pi or default)
for PROFILE in LXDE-pi default; do
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"
log "Hostname set to $HOSTNAME; /etc/hosts updated"
}
# --- Step 02: Packages ---
step_02_packages() {
log "Running apt-get update ..."
apt-get update -qq
log "Installing: $PACKAGES"
apt-get install -y -qq $PACKAGES
log "Packages installed successfully"
}
# --- 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
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"
log "WARNING: Could not download custom.plymouth/custom.script; boot splash theme may be incomplete"
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
grep -q '^Theme=custom' /etc/plymouth/plymouthd.conf 2>/dev/null || printf '%s\n' '[Daemon]' 'Theme=custom' >> /etc/plymouth/plymouthd.conf
log "Running update-initramfs (may take a moment) ..."
update-initramfs -u -k all 2>/dev/null || true
mkdir -p /etc/lightdm/lightdm.conf.d
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"
for PROFILE in LXDE-pi default; do
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 ---
log "--- LightDM session (rpd-labwc) ---"
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
log "99-default-session.conf installed"
else
log "WARNING: Could not download 99-default-session.conf"
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
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
log "Patched /etc/lightdm/lightdm.conf to use $LIGHTDM_SESSION"
fi
# --- Step 05: LightDM session ---
step_05_lightdm() {
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
log "99-default-session.conf installed"
else
log "WARNING: Could not download 99-default-session.conf"
fi
if [[ -f /etc/lightdm/lightdm.conf ]]; then
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
log "Patched /etc/lightdm/lightdm.conf to use $LIGHTDM_SESSION"
fi
}
# --- 5. Maliit on-screen keyboard (from file server) ---
log "--- Maliit ---"
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"
# --- Step 06: Maliit on-screen keyboard ---
step_06_maliit() {
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"
}
# --- 5b. Dark theme (GTK + prefer dark for apps) ---
log "--- Dark theme ---"
GTK_SETTINGS="$PI_HOME/.config/gtk-3.0/settings.ini"
mkdir -p "$(dirname "$GTK_SETTINGS")"
if [[ ! -f "$GTK_SETTINGS" ]]; then
printf '%s\n' '[Settings]' 'gtk-application-prefer-dark-theme=1' "gtk-theme-name=$GTK_THEME_NAME" > "$GTK_SETTINGS"
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-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
# Fallback if theme not installed (e.g. older image): Adwaita-dark
log "Set dark theme ($GTK_THEME_NAME) in gtk-3.0/settings.ini"
chown -R "$PI_USER:$PI_USER" "$PI_HOME/.config"
# --- Step 07: Dark theme (GTK) ---
step_07_dark_theme() {
GTK_SETTINGS="$PI_HOME/.config/gtk-3.0/settings.ini"
mkdir -p "$(dirname "$GTK_SETTINGS")"
if [[ ! -f "$GTK_SETTINGS" ]]; then
printf '%s\n' '[Settings]' 'gtk-application-prefer-dark-theme=1' "gtk-theme-name=$GTK_THEME_NAME" > "$GTK_SETTINGS"
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-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
log "Set dark theme ($GTK_THEME_NAME) in gtk-3.0/settings.ini"
chown -R "$PI_USER:$PI_USER" "$PI_HOME/.config"
}
# --- 6. reTerminal DM drivers (Seeed) ---
if [[ -n "$RETERMINAL_REPO_URL" ]]; then
log "--- reTerminal DM drivers ---"
# --- Step 08: reTerminal DM drivers (Seeed) ---
step_08_reterminal_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"
log "Cloning seeed-linux-dtoverlays to $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 ..."
if ( cd "$REPO_DIR" && "$REPO_DIR/scripts/reTerminal.sh" --device "$RETERMINAL_DEVICE" ); then
log "reTerminal DM drivers installed (reboot will apply)"
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"
fi
log "Removing $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) ---
log "--- Re-applying boot splash and Plymouth theme ---"
CFG_PATH="/boot/firmware/config.txt"
[[ -f /boot/firmware/config.txt ]] || CFG_PATH="/boot/config.txt"
if [[ -f "$CFG_PATH" ]]; then
if grep -q '^disable_splash=1' "$CFG_PATH"; then
# --- Step 09: Re-apply splash and Plymouth theme ---
step_09_reapply_splash() {
CFG_PATH="/boot/firmware/config.txt"
[[ -f /boot/firmware/config.txt ]] || CFG_PATH="/boot/config.txt"
if [[ -f "$CFG_PATH" ]] && grep -q '^disable_splash=1' "$CFG_PATH"; then
sed -i 's/^disable_splash=1$/disable_splash=0/' "$CFG_PATH"
log "Set disable_splash=0 so Plymouth splash is shown"
fi
fi
# Ensure Plymouth uses our custom theme only (single [Daemon], Theme=custom)
if [[ -f /etc/plymouth/plymouthd.conf ]]; then
sed -i '/^Theme=/d' /etc/plymouth/plymouthd.conf
sed -i '/^\[Daemon\]$/d' /etc/plymouth/plymouthd.conf
grep -q '^\[Daemon\]' /etc/plymouth/plymouthd.conf || echo '[Daemon]' >> /etc/plymouth/plymouthd.conf
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)"
if [[ -f /etc/plymouth/plymouthd.conf ]]; then
sed -i '/^Theme=/d' /etc/plymouth/plymouthd.conf
sed -i '/^\[Daemon\]$/d' /etc/plymouth/plymouthd.conf
grep -q '^\[Daemon\]' /etc/plymouth/plymouthd.conf || echo '[Daemon]' >> /etc/plymouth/plymouthd.conf
echo 'Theme=custom' >> /etc/plymouth/plymouthd.conf
log "Plymouth theme set to custom only"
fi
# Persistent rotation for DSI-1 (KMS): append at end of single line. 90 = 90° clockwise.
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)"
log "Running update-initramfs to apply Plymouth theme ..."
update-initramfs -u -k all 2>/dev/null || true
}
# --- 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
}
# --- 7. One-shots (wallpaper already set in pcmanfm config above; rotation is via cmdline.txt) ---
log "--- One-shot scripts (if any) ---"
if [[ -n "$DSI_ROTATE" ]]; then
log "Rotation is set via kernel cmdline (video=DSI-1:rotate=$DSI_ROTATE)"
fi
if [[ -n "$ONESHOT_SCRIPTS" ]]; then
for _name in $ONESHOT_SCRIPTS; do
install_oneshot "$_name" || true
done
else
log "No one-shot scripts configured (ONESHOT_SCRIPTS empty)"
fi
# --- Step 11: One-shot scripts ---
step_11_oneshots() {
if [[ -n "$DSI_ROTATE" ]]; then
log "Rotation is set via kernel cmdline (video=DSI-1:rotate=$DSI_ROTATE)"
fi
if [[ -n "$ONESHOT_SCRIPTS" ]]; then
for _name in $ONESHOT_SCRIPTS; do
install_oneshot "$_name" || true
done
else
log "No one-shot scripts configured (ONESHOT_SCRIPTS empty)"
fi
}
# --- 8. Allow pi to append to first-boot.log (for one-shot scripts) ---
chmod 666 "$LOGFILE"
log "Log file $LOGFILE is now appendable by user $PI_USER for one-shot scripts"
# --- Step 12: Log file permissions ---
step_12_log_permissions() {
chmod 666 "$LOGFILE"
log "Log file $LOGFILE is now appendable by user $PI_USER for one-shot scripts"
}
# --- 9. Reboot ---
log "=== first-boot.sh finished, rebooting ==="
reboot
# --- Step 13: Reboot ---
step_13_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")))
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")))
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")))
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")))
@@ -587,6 +588,59 @@ def api_status_clear():
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")
def api_log():
return jsonify({"log": read_log_tail()})
@@ -653,6 +707,8 @@ def api_device_action():
pass # host may still have SHRINK_BACKUP=1
with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action)
if action == "deploy":
_first_boot_status_write("idle", "", hostname="", ip="")
return jsonify({"ok": True})
except (PermissionError, OSError):
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
if action == "deploy":
_write_status("flashing", f"Deploying to {ip} (network)...")
_first_boot_status_write("idle", "", hostname="", ip="")
elif action == "backup":
_write_status("backup", f"Backing up {ip} (network)...")
return jsonify({"ok": True})
@@ -1006,6 +1063,96 @@ def api_portal_files_upload():
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"])
@require_admin
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>
<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">
<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>
@@ -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 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 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 fetchLog() { fetch('/api/log').then(function(r){ return r.json(); }).then(function(d){ document.getElementById('log').textContent = d.log || ''; }).catch(function(){}); }
function fetchGolden() {
@@ -286,10 +317,11 @@
.then(function(d){ if(d.ok) fetchDhcpNetboot(); else alert(d.error || 'Failed'); })
.catch(function(){ alert('Request failed'); });
});
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases();
fetchStatus(); fetchLog(); fetchPending(); fetchGolden(); fetchDhcpNetboot(); fetchDhcpLeases(); fetchFirstBootStatus();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchFirstBootStatus, 3000);
setInterval(fetchGolden, 10000);
setInterval(fetchDhcpNetboot, 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; }
.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; }
/* 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>
</head>
<body>
@@ -80,8 +89,24 @@
</table>
<p id="portalEmpty" class="empty-msg" style="display:none;">No files or folders. Create a folder or upload a file.</p>
</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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.35.0/ace.min.js" crossorigin="anonymous"></script>
<script>
function authFetch(url, opts) {
opts = opts || {};
@@ -142,7 +167,7 @@
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>';
} 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>';
tbody.appendChild(tr);
@@ -154,6 +179,9 @@
tbody.querySelectorAll('.open-folder').forEach(function(btn) {
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) {
btn.onclick = function() {
var path = btn.getAttribute('data-path');
@@ -209,6 +237,50 @@
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();
</script>
</body>

View File

@@ -2,6 +2,18 @@
# Sync portal (file server) content from the repo to the LXC.
# Updates /var/lib/cm4-provisioning/portal-files/ so first-boot and the
# 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]
# 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" "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/"
# 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' \
"$CLOUDINIT_DIR/config-files/" \
"$LXC:$REMOTE_FIRST_BOOT/"
# start-chromium.sh → portal-files/first-boot/
rsync -avz "$CLOUDINIT_DIR/start-chromium.sh" "$LXC:$REMOTE_FIRST_BOOT/"
# Kiosk and scripts
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 \
"$CLOUDINIT_DIR/files-from-guard/plymouth-custom/" \
"$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 "Note: Add splash.png to $REMOTE_FIRST_BOOT/ (or plymouth-custom/) if you want a custom boot splash."