Compare commits
2 Commits
79a7f76a12
...
fd56ed4049
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd56ed4049 | ||
|
|
16c796b8af |
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=Set wallpaper once
|
|
||||||
Exec=/home/pi/set-wallpaper-once.sh
|
|
||||||
X-GNOME-Autostart-enabled=true
|
|
||||||
@@ -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.
|
||||||
|
|||||||
60
emmc-provisioning/cloud-init/first-boot.conf
Normal file
60
emmc-provisioning/cloud-init/first-boot.conf
Normal 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 tee’d to `/var/log/first-boot.log`.
|
2. **Logging** — All output tee’d 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.**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user