mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Three reports from the same operator, two of them the script describing a state it never reached — the same shape as the first round of #245. Guide missing sudo: frontend/guides/raspberry-pi-digital-signage.html said `curl -sL … | bash` while the script's own header and its root check both say `| sudo bash`. The script fails loudly with the right command, so nothing is half-installed, but the guide should not have to be corrected by an error message. Also documents the --player-only form, which the guide never showed. MOTD advertised commands that mode did not create: section 11 creates screentinker-status/update/logs only when PLAYER_ONLY is false, while section 12 wrote an /etc/motd listing all three unconditionally. A Player-Only Pi therefore greeted its operator with three commands that were not on it, at every SSH login. The command list is now appended per-mode. The cheap fix would have been to print nothing on a player. That trades a wrong banner for a machine nobody can inspect over SSH, so Player-Only now gets its own screentinker-status (kiosk state, which server it points at, and whether that server is actually reachable) and screentinker-logs (kiosk). screentinker-update is genuinely not applicable — there is no local server to update — and is not offered. Wayland cursor never hidden: the launcher stated the compositor cursor config was written "below when wayfire.ini exists". It never was — wayfire.ini and hide_cursor each appeared exactly once in the whole script, both inside that comment. unclutter is installed but only runs on the X11 branch, so a Wayland Pi kept a mouse pointer on the sign while the install looked complete. Now configures wayfire's hide-cursor plugin at install time, idempotently and after backing the file up, and says plainly that labwc has no equivalent rather than failing silently. Tests: raspberry-pi-setup.test.js gains a check that no MOTD advertises a command its mode does not install (both modes, extracted from the script rather than re-typed), that a player is not left with zero diagnostics, and that the Wayland cursor claim is backed by code outside a comment. Both mutations verified to fail: putting screentinker-update back in the player MOTD, and removing the hide-cursor write. NOT fixed, and not guessed at: the ALT+F4-on-first-pairing symptom and the reconnect storm. The crash-restore fix those would need is already in 1.9.33 and targets a different symptom, and `observed=6/5 per 10000ms` is six reconnects in ten seconds, which matches neither the solo-widget cycle nor the kiosk RestartSec=10. Both need the kiosk-side log — which, until this commit, a Player-Only Pi had no command to read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
851 lines
30 KiB
Bash
851 lines
30 KiB
Bash
#!/bin/bash
|
|
# ScreenTinker - Raspberry Pi Setup Script
|
|
#
|
|
# All-in-One: runs the ScreenTinker server AND kiosk player on one Pi
|
|
# Player-Only: connects to an existing ScreenTinker server
|
|
#
|
|
# Usage:
|
|
# All-in-One: curl -sSL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash
|
|
# Player-Only: curl -sSL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://screentinker.com
|
|
#
|
|
# Or clone and run:
|
|
# git clone https://github.com/screentinker/screentinker.git
|
|
# cd screentinker/scripts && sudo ./raspberry-pi-setup.sh
|
|
#
|
|
# Works on Raspberry Pi OS Lite or Desktop (Bookworm / Bullseye)
|
|
# Tested on Pi 3B+, Pi 4, Pi 5
|
|
|
|
set -euo pipefail
|
|
|
|
# -- Configuration --
|
|
SCREENTINKER_DIR="/opt/screentinker"
|
|
SCREENTINKER_PORT=3001
|
|
NODE_MAJOR=20
|
|
LOG_FILE="/var/log/screentinker-setup.log"
|
|
|
|
# -- Colors --
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
log() { echo -e "${GREEN}[ScreenTinker]${NC} $1"; }
|
|
warn() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
|
err() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
|
|
|
# -- Parse arguments --
|
|
PLAYER_ONLY=false
|
|
SERVER_URL=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--player-only) PLAYER_ONLY=true; shift ;;
|
|
--help|-h)
|
|
echo "Usage: sudo ./raspberry-pi-setup.sh [OPTIONS] [SERVER_URL]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " --player-only URL Player-only mode (no local server)"
|
|
echo " --help Show this help"
|
|
echo ""
|
|
echo "Examples:"
|
|
echo " sudo ./raspberry-pi-setup.sh # All-in-One (interactive)"
|
|
echo " sudo ./raspberry-pi-setup.sh --player-only https://screentinker.com"
|
|
exit 0
|
|
;;
|
|
http*) SERVER_URL="$1"; shift ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
|
|
# -- Prompting when we are being piped --
|
|
#
|
|
# The documented install is `curl -sL … | sudo bash`, which makes stdin the SCRIPT, not the
|
|
# operator. bash has already consumed it by the time any `read` runs, so every prompt got EOF
|
|
# instantly: the mode menu "chose" All-in-One without the operator touching anything, and the
|
|
# Player-Only branch could never be reached that way at all. It looked like the menu was being
|
|
# skipped, because it was.
|
|
#
|
|
# So prompts read from the controlling terminal instead. When there genuinely is no terminal
|
|
# (cloud-init, a provisioning pipeline), we say so and take the documented default rather than
|
|
# pretending a choice was made — the operator can pass --player-only / --server-url to decide
|
|
# without a prompt.
|
|
if [ -r /dev/tty ] && [ -t 1 ]; then
|
|
exec 3</dev/tty
|
|
HAVE_TTY=true
|
|
else
|
|
HAVE_TTY=false
|
|
fi
|
|
|
|
# ask <varname> <prompt> [read-args…]
|
|
ask() {
|
|
local __var="$1"; shift
|
|
local __prompt="$1"; shift
|
|
if [ "$HAVE_TTY" = true ]; then
|
|
read "$@" -u 3 -r -p "$__prompt" "$__var"
|
|
else
|
|
eval "$__var=''"
|
|
fi
|
|
}
|
|
|
|
# -- Root check --
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
err "This script must be run as root. Try: curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash"
|
|
fi
|
|
|
|
# -- Architecture check --
|
|
ARCH=$(uname -m)
|
|
if [[ "$ARCH" != "aarch64" && "$ARCH" != "armv7l" ]]; then
|
|
warn "Detected architecture: $ARCH (expected aarch64 or armv7l for Raspberry Pi)"
|
|
if [ "$HAVE_TTY" = true ]; then
|
|
ask REPLY "Continue anyway? (y/N) " -n 1; echo
|
|
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
|
|
else
|
|
err "Refusing to continue on $ARCH without a terminal to confirm at. Re-run interactively, or on the intended hardware."
|
|
fi
|
|
fi
|
|
|
|
# -- Interactive mode selection (if no flags passed) --
|
|
if [ "$PLAYER_ONLY" = false ] && [ -z "$SERVER_URL" ]; then
|
|
echo ""
|
|
echo -e "${BLUE}======================================${NC}"
|
|
echo -e "${BLUE} ScreenTinker Raspberry Pi Setup${NC}"
|
|
echo -e "${BLUE}======================================${NC}"
|
|
echo ""
|
|
echo " 1) All-in-One (recommended)"
|
|
echo " Runs the server AND player on this Pi."
|
|
echo " Manage everything from your phone."
|
|
echo ""
|
|
echo " 2) Player Only"
|
|
echo " Connects to an existing ScreenTinker server."
|
|
echo " This Pi just displays content."
|
|
echo ""
|
|
if [ "$HAVE_TTY" = false ]; then
|
|
# No terminal to ask at. Say which way we went, rather than letting an empty answer
|
|
# look like a decision — this is the exact confusion the piped-stdin bug produced.
|
|
warn "No terminal available for the menu — defaulting to All-in-One."
|
|
warn "To choose Player-Only non-interactively: ... | sudo bash -s -- --player-only https://your-server"
|
|
else
|
|
ask MODE_CHOICE "Choose [1/2]: "
|
|
case "$MODE_CHOICE" in
|
|
2)
|
|
PLAYER_ONLY=true
|
|
while [ -z "$SERVER_URL" ]; do
|
|
ask SERVER_URL "Server URL (e.g., https://screentinker.com): "
|
|
[ -z "$SERVER_URL" ] && warn "Player-Only needs a server URL."
|
|
done
|
|
;;
|
|
*) ;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
# Strip trailing slash from server URL
|
|
SERVER_URL="${SERVER_URL%/}"
|
|
|
|
# Set kiosk URL
|
|
if [ "$PLAYER_ONLY" = true ]; then
|
|
[ -z "$SERVER_URL" ] && err "Player-only mode requires a server URL"
|
|
KIOSK_URL="${SERVER_URL}/player"
|
|
log "Player-only mode: $SERVER_URL"
|
|
else
|
|
KIOSK_URL="http://localhost:${SCREENTINKER_PORT}/player"
|
|
log "All-in-One mode: server + player"
|
|
fi
|
|
|
|
echo ""
|
|
log "Setup log: $LOG_FILE"
|
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
|
|
|
# -- Detect Pi OS variant --
|
|
HAS_DESKTOP=false
|
|
if dpkg -l xserver-xorg 2>/dev/null | grep -q "^ii"; then
|
|
HAS_DESKTOP=true
|
|
log "Detected: Pi OS with Desktop"
|
|
else
|
|
log "Detected: Pi OS Lite (headless)"
|
|
fi
|
|
|
|
# ============================================================
|
|
# 1. System packages
|
|
# ============================================================
|
|
log "Updating system packages..."
|
|
apt-get update -qq
|
|
apt-get upgrade -y -qq
|
|
|
|
log "Installing base dependencies..."
|
|
apt-get install -y -qq \
|
|
git curl wget unzip htop \
|
|
avahi-daemon \
|
|
fonts-liberation fonts-noto-color-emoji \
|
|
>> "$LOG_FILE" 2>&1
|
|
|
|
# ============================================================
|
|
# 2. Node.js (all-in-one only)
|
|
# ============================================================
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
NEED_NODE=true
|
|
if command -v node &>/dev/null; then
|
|
CUR=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
|
if [ "$CUR" -ge "$NODE_MAJOR" ]; then
|
|
log "Node.js $(node -v) already installed"
|
|
NEED_NODE=false
|
|
fi
|
|
fi
|
|
if [ "$NEED_NODE" = true ]; then
|
|
log "Installing Node.js ${NODE_MAJOR}.x..."
|
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - >> "$LOG_FILE" 2>&1
|
|
apt-get install -y -qq nodejs >> "$LOG_FILE" 2>&1
|
|
log "Node.js $(node -v) installed"
|
|
fi
|
|
fi
|
|
|
|
# ============================================================
|
|
# 3. Clone / update ScreenTinker (all-in-one only)
|
|
# ============================================================
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
if [ -d "$SCREENTINKER_DIR/.git" ]; then
|
|
log "Repo exists at $SCREENTINKER_DIR, pulling latest..."
|
|
cd "$SCREENTINKER_DIR" && git pull origin main >> "$LOG_FILE" 2>&1
|
|
else
|
|
log "Cloning ScreenTinker..."
|
|
git clone https://github.com/screentinker/screentinker.git "$SCREENTINKER_DIR" >> "$LOG_FILE" 2>&1
|
|
fi
|
|
|
|
log "Installing Node.js dependencies..."
|
|
cd "$SCREENTINKER_DIR/server"
|
|
npm install --production >> "$LOG_FILE" 2>&1
|
|
|
|
# Data directories
|
|
mkdir -p "$SCREENTINKER_DIR/server/db"
|
|
mkdir -p "$SCREENTINKER_DIR/server/uploads"
|
|
fi
|
|
|
|
# Determine the runtime user
|
|
PI_USER="${SUDO_USER:-pi}"
|
|
PI_HOME=$(eval echo "~$PI_USER")
|
|
|
|
# Set ownership (all-in-one only)
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
chown -R "$PI_USER":"$PI_USER" "$SCREENTINKER_DIR"
|
|
fi
|
|
|
|
# ============================================================
|
|
# 4. Server systemd service (all-in-one only)
|
|
# ============================================================
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
log "Creating screentinker-server service..."
|
|
cat > /etc/systemd/system/screentinker-server.service << EOF
|
|
[Unit]
|
|
Description=ScreenTinker Digital Signage Server
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${PI_USER}
|
|
WorkingDirectory=${SCREENTINKER_DIR}/server
|
|
ExecStart=/usr/bin/node server.js
|
|
Restart=always
|
|
RestartSec=5
|
|
StartLimitBurst=5
|
|
StartLimitIntervalSec=60
|
|
|
|
Environment=NODE_ENV=production
|
|
Environment=PORT=${SCREENTINKER_PORT}
|
|
Environment=SELF_HOSTED=true
|
|
Environment=HOST=0.0.0.0
|
|
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=screentinker-server
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable screentinker-server.service
|
|
log "Server service enabled"
|
|
fi
|
|
|
|
# ============================================================
|
|
# 5. Kiosk display packages
|
|
# ============================================================
|
|
log "Installing kiosk packages..."
|
|
# Chromium's package name differs by distro: Raspberry Pi OS / Ubuntu ship
|
|
# 'chromium-browser', Debian ships 'chromium'. Try the Pi/Ubuntu name first, then
|
|
# fall back to Debian's, so a bundled apt-get can't hard-fail (set -e) on a
|
|
# Debian-based player. No-op if a chromium is already present.
|
|
install_chromium() {
|
|
if command -v chromium-browser &>/dev/null || command -v chromium &>/dev/null; then
|
|
return 0
|
|
fi
|
|
apt-get install -y -qq chromium-browser >> "$LOG_FILE" 2>&1 || \
|
|
apt-get install -y -qq chromium >> "$LOG_FILE" 2>&1
|
|
}
|
|
if [ "$HAS_DESKTOP" = false ]; then
|
|
# Lite: install X11 + helpers, then Chromium (portable package name)
|
|
apt-get install -y -qq \
|
|
xserver-xorg x11-xserver-utils xinit \
|
|
unclutter xdotool \
|
|
>> "$LOG_FILE" 2>&1
|
|
install_chromium
|
|
else
|
|
# Desktop: X already running, just ensure Chromium + helpers
|
|
apt-get install -y -qq unclutter xdotool >> "$LOG_FILE" 2>&1
|
|
install_chromium
|
|
fi
|
|
|
|
# Find Chromium binary
|
|
CHROMIUM_BIN=$(command -v chromium-browser 2>/dev/null || command -v chromium 2>/dev/null || echo "/usr/bin/chromium-browser")
|
|
|
|
# ============================================================
|
|
# 6. Kiosk launcher script
|
|
# ============================================================
|
|
log "Creating kiosk launcher..."
|
|
cat > "$PI_HOME/screentinker-kiosk.sh" << KIOSKEOF
|
|
#!/bin/bash
|
|
# ScreenTinker Kiosk - launches Chromium in fullscreen player mode
|
|
KIOSK_URL="${KIOSK_URL}"
|
|
|
|
# Wait for display
|
|
sleep 2
|
|
|
|
# Which display server are we actually on? Pi 5 on Bookworm defaults to WAYLAND, where every
|
|
# X11 tool below is a no-op that prints an error into the journal and silently does nothing —
|
|
# so a Wayland Pi got no blanking suppression and no cursor hiding while appearing configured.
|
|
SESSION_TYPE="\${XDG_SESSION_TYPE:-}"
|
|
if [ -z "\$SESSION_TYPE" ]; then
|
|
if [ -n "\${WAYLAND_DISPLAY:-}" ]; then SESSION_TYPE=wayland
|
|
elif [ -n "\${DISPLAY:-}" ]; then SESSION_TYPE=x11
|
|
fi
|
|
fi
|
|
echo "Display server: \${SESSION_TYPE:-unknown}"
|
|
|
|
if [ "\$SESSION_TYPE" = "wayland" ]; then
|
|
# Blanking/DPMS belong to the compositor here, not to us. wlopm is present on Pi OS
|
|
# (wlroots-based wayfire/labwc); if it is not, the compositor's own idle config is the
|
|
# documented fallback and README says so.
|
|
command -v wlopm >/dev/null 2>&1 && wlopm --on '*' 2>/dev/null || true
|
|
# unclutter is X11-only — it exits immediately here, which is why a Wayland Pi kept its cursor
|
|
# on screen while the install looked complete. Hiding it is the COMPOSITOR's job on Wayland;
|
|
# the installer configures wayfire's hide-cursor plugin at install time (section 9b). If this
|
|
# Pi runs labwc instead, there is no equivalent setting and the cursor stays — README says so
|
|
# rather than this pretending otherwise.
|
|
else
|
|
# Disable screen blanking and power management
|
|
xset s off
|
|
xset s noblank
|
|
xset -dpms
|
|
xset s 0 0
|
|
|
|
# Hide cursor after 3 seconds of inactivity (X11 only — no Wayland equivalent)
|
|
unclutter -idle 3 -root &
|
|
fi
|
|
|
|
# Clean Chromium crash flags (prevents restore session dialogs).
|
|
#
|
|
# The white page on every boot after the first is Chromium restoring a session it thinks
|
|
# crashed: a kiosk is killed by the shutdown, never exits cleanly, and comes back with a
|
|
# restore surface on top of the player — which is why ALT+F4 "fixed" it (it closed the
|
|
# surface, not the player). Rewriting the flags is not enough on its own because Chromium
|
|
# also replays the previous window set from Sessions/, so those go too.
|
|
CDIR="\$HOME/.config/chromium/Default"
|
|
mkdir -p "\$CDIR"
|
|
if [ -f "\$CDIR/Preferences" ]; then
|
|
sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' "\$CDIR/Preferences" 2>/dev/null || true
|
|
sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' "\$CDIR/Preferences" 2>/dev/null || true
|
|
fi
|
|
rm -rf "\$CDIR/Sessions" "\$CDIR/Session Storage" 2>/dev/null || true
|
|
|
|
# Wait for local server if running all-in-one
|
|
if echo "\$KIOSK_URL" | grep -q "localhost"; then
|
|
echo "Waiting for ScreenTinker server..."
|
|
for i in \$(seq 1 30); do
|
|
if curl -sf "http://localhost:${SCREENTINKER_PORT}/api/status" >/dev/null 2>&1; then
|
|
echo "Server ready"
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
fi
|
|
|
|
# Detect screen resolution so Chromium fills the display on minimal X11 (no WM)
|
|
SCREEN_RES=\$(xrandr 2>/dev/null | grep ' connected' | grep -oE '[0-9]+x[0-9]+' | head -1)
|
|
SCREEN_W=\${SCREEN_RES%%x*}
|
|
SCREEN_H=\${SCREEN_RES##*x}
|
|
if [ -z "\$SCREEN_W" ] || [ -z "\$SCREEN_H" ]; then
|
|
SCREEN_W=1920
|
|
SCREEN_H=1080
|
|
fi
|
|
|
|
# Wayland needs the ozone backend named explicitly on some Bookworm builds; on X11 the flag
|
|
# is absent so nothing changes there.
|
|
OZONE=""
|
|
[ "\$SESSION_TYPE" = "wayland" ] && OZONE="--ozone-platform=wayland"
|
|
|
|
exec ${CHROMIUM_BIN} \\
|
|
--kiosk \\
|
|
\$OZONE \\
|
|
--password-store=basic \\
|
|
--window-position=0,0 \\
|
|
--window-size=\${SCREEN_W},\${SCREEN_H} \\
|
|
--noerrdialogs \\
|
|
--disable-infobars \\
|
|
--disable-session-crashed-bubble \\
|
|
--disable-features=TranslateUI \\
|
|
--disable-component-update \\
|
|
--check-for-update-interval=31536000 \\
|
|
--autoplay-policy=no-user-gesture-required \\
|
|
--no-first-run \\
|
|
--disable-pinch \\
|
|
--overscroll-history-navigation=0 \\
|
|
--disable-translate \\
|
|
--disable-sync \\
|
|
--disable-background-networking \\
|
|
--disable-default-apps \\
|
|
--disable-extensions \\
|
|
--disable-hang-monitor \\
|
|
--disable-popup-blocking \\
|
|
--disable-prompt-on-repost \\
|
|
--metrics-recording-only \\
|
|
--safebrowsing-disable-auto-update \\
|
|
--ignore-certificate-errors \\
|
|
"\$KIOSK_URL"
|
|
KIOSKEOF
|
|
|
|
chmod +x "$PI_HOME/screentinker-kiosk.sh"
|
|
chown "$PI_USER":"$PI_USER" "$PI_HOME/screentinker-kiosk.sh"
|
|
|
|
# ============================================================
|
|
# 7. Xinitrc (Pi OS Lite - starts kiosk from console)
|
|
# ============================================================
|
|
if [ "$HAS_DESKTOP" = false ]; then
|
|
cat > "$PI_HOME/.xinitrc" << 'EOF'
|
|
#!/bin/bash
|
|
exec ~/screentinker-kiosk.sh
|
|
EOF
|
|
chmod +x "$PI_HOME/.xinitrc"
|
|
chown "$PI_USER":"$PI_USER" "$PI_HOME/.xinitrc"
|
|
fi
|
|
|
|
# ============================================================
|
|
# 8. Kiosk systemd service
|
|
# ============================================================
|
|
log "Creating kiosk service..."
|
|
|
|
if [ "$HAS_DESKTOP" = false ]; then
|
|
# Lite: start X ourselves
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
KIOSK_AFTER="After=screentinker-server.service"
|
|
KIOSK_REQ="Requires=screentinker-server.service"
|
|
else
|
|
KIOSK_AFTER="After=network-online.target"
|
|
KIOSK_REQ="Wants=network-online.target"
|
|
fi
|
|
|
|
cat > /etc/systemd/system/screentinker-kiosk.service << EOF
|
|
[Unit]
|
|
Description=ScreenTinker Kiosk Display
|
|
${KIOSK_AFTER}
|
|
${KIOSK_REQ}
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${PI_USER}
|
|
Environment=DISPLAY=:0
|
|
Environment=XAUTHORITY=${PI_HOME}/.Xauthority
|
|
ExecStartPre=/bin/sleep 3
|
|
ExecStart=/usr/bin/startx ${PI_HOME}/.xinitrc -- :0 -nolisten tcp vt1
|
|
Restart=always
|
|
RestartSec=10
|
|
|
|
TTYPath=/dev/tty1
|
|
StandardInput=tty
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=screentinker-kiosk
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
else
|
|
# Desktop: X already running, just launch Chromium
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
KIOSK_AFTER="After=screentinker-server.service graphical.target"
|
|
KIOSK_REQ="Requires=screentinker-server.service"
|
|
else
|
|
KIOSK_AFTER="After=graphical.target"
|
|
KIOSK_REQ="Wants=graphical.target"
|
|
fi
|
|
|
|
cat > /etc/systemd/system/screentinker-kiosk.service << EOF
|
|
[Unit]
|
|
Description=ScreenTinker Kiosk Display
|
|
${KIOSK_AFTER}
|
|
${KIOSK_REQ}
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${PI_USER}
|
|
Environment=DISPLAY=:0
|
|
ExecStartPre=/bin/sleep 5
|
|
ExecStart=/bin/bash ${PI_HOME}/screentinker-kiosk.sh
|
|
Restart=always
|
|
RestartSec=10
|
|
|
|
StandardOutput=journal
|
|
StandardError=journal
|
|
SyslogIdentifier=screentinker-kiosk
|
|
|
|
[Install]
|
|
WantedBy=graphical.target
|
|
EOF
|
|
fi
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable screentinker-kiosk.service
|
|
log "Kiosk service enabled"
|
|
|
|
# Desktop: autostart entry as fallback
|
|
if [ "$HAS_DESKTOP" = true ]; then
|
|
AUTOSTART_DIR="$PI_HOME/.config/autostart"
|
|
mkdir -p "$AUTOSTART_DIR"
|
|
cat > "$AUTOSTART_DIR/screentinker.desktop" << EOF
|
|
[Desktop Entry]
|
|
Type=Application
|
|
Name=ScreenTinker Player
|
|
Exec=${PI_HOME}/screentinker-kiosk.sh
|
|
X-GNOME-Autostart-enabled=true
|
|
EOF
|
|
chown -R "$PI_USER":"$PI_USER" "$AUTOSTART_DIR"
|
|
fi
|
|
|
|
# ============================================================
|
|
# 9. Auto-login on tty1 (Lite only)
|
|
# ============================================================
|
|
if [ "$HAS_DESKTOP" = false ]; then
|
|
log "Configuring auto-login on tty1..."
|
|
mkdir -p /etc/systemd/system/getty@tty1.service.d
|
|
cat > /etc/systemd/system/getty@tty1.service.d/autologin.conf << EOF
|
|
[Service]
|
|
ExecStart=
|
|
ExecStart=-/sbin/agetty --autologin ${PI_USER} --noclear %I \$TERM
|
|
EOF
|
|
fi
|
|
|
|
# ============================================================
|
|
# 9b. Wayland cursor hiding (wayfire)
|
|
# ============================================================
|
|
# On X11 the launcher runs `unclutter -idle 3`. On Wayland unclutter cannot work at all — there is
|
|
# no root window to track and no client may move or hide another client's cursor — so hiding it is
|
|
# the compositor's decision. Pi OS Bookworm on Pi 4/5 defaults to wayfire, which has a hide-cursor
|
|
# plugin; this configures it. A previous version of this script claimed in a comment to do exactly
|
|
# this and never did, so a Wayland Pi sat there with a mouse pointer on the sign (#245).
|
|
#
|
|
# Written at install time rather than from the launcher because wayfire reads this at session
|
|
# start. It is also idempotent and non-destructive: a Pi whose owner has already tuned wayfire.ini
|
|
# keeps their settings, and the file is backed up before the first edit either way.
|
|
if [ -f "$PI_HOME/.config/wayfire.ini" ]; then
|
|
log "Configuring wayfire to hide the cursor..."
|
|
WF="$PI_HOME/.config/wayfire.ini"
|
|
[ -f "${WF}.screentinker-bak" ] || cp "$WF" "${WF}.screentinker-bak"
|
|
|
|
if grep -q '^\[hide-cursor\]' "$WF"; then
|
|
log " wayfire.ini already has [hide-cursor] — leaving it alone"
|
|
else
|
|
printf '\n[hide-cursor]\nhide_delay = 3000\n' >> "$WF"
|
|
fi
|
|
|
|
# The plugin only loads if it is named in core's plugin list, and that list is space-separated
|
|
# on one line. Appending to it is fiddly enough to be worth doing carefully rather than with a
|
|
# blind sed: only touch the line when it exists and does not already mention us.
|
|
if grep -qE '^\s*plugins\s*=' "$WF"; then
|
|
if ! grep -E '^\s*plugins\s*=' "$WF" | grep -q 'hide-cursor'; then
|
|
sed -i 's/^\(\s*plugins\s*=.*\)$/\1 hide-cursor/' "$WF"
|
|
fi
|
|
else
|
|
warn "wayfire.ini has no [core] plugins line — add 'hide-cursor' to it to hide the pointer"
|
|
fi
|
|
chown "$PI_USER":"$PI_USER" "$WF" 2>/dev/null || true
|
|
elif [ "$HAS_DESKTOP" = true ]; then
|
|
# labwc (the newer Pi OS compositor) has no cursor-hiding option, and neither do we from the
|
|
# outside. Say so plainly instead of leaving the operator to wonder whether it failed.
|
|
if command -v labwc >/dev/null 2>&1; then
|
|
warn "This Pi appears to run labwc, which has no cursor-hide setting — the pointer will stay visible."
|
|
warn "Switch to wayfire (raspi-config > Advanced > Wayland) or to X11 if a hidden cursor matters."
|
|
fi
|
|
fi
|
|
|
|
# ============================================================
|
|
# 10. Pi display and boot optimizations
|
|
# ============================================================
|
|
log "Applying display optimizations..."
|
|
|
|
# Find config.txt (Pi 5 uses /boot/firmware/, older uses /boot/)
|
|
CONFIG_FILE=""
|
|
for p in /boot/firmware/config.txt /boot/config.txt; do
|
|
[ -f "$p" ] && CONFIG_FILE="$p" && break
|
|
done
|
|
|
|
if [ -n "$CONFIG_FILE" ]; then
|
|
# GPU memory for video playback
|
|
if ! grep -q "^gpu_mem=" "$CONFIG_FILE"; then
|
|
echo -e "\n# ScreenTinker: GPU memory for smooth video" >> "$CONFIG_FILE"
|
|
echo "gpu_mem=128" >> "$CONFIG_FILE"
|
|
log "GPU memory: 128MB"
|
|
fi
|
|
|
|
# Disable overscan (removes black borders on TVs)
|
|
if ! grep -q "^disable_overscan=1" "$CONFIG_FILE"; then
|
|
echo "disable_overscan=1" >> "$CONFIG_FILE"
|
|
log "Overscan disabled"
|
|
fi
|
|
fi
|
|
|
|
# Disable console blanking
|
|
for p in /boot/firmware/cmdline.txt /boot/cmdline.txt; do
|
|
if [ -f "$p" ]; then
|
|
if ! grep -q "consoleblank=0" "$p"; then
|
|
sed -i 's/$/ consoleblank=0/' "$p"
|
|
log "Console blanking disabled"
|
|
fi
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Lightdm screen blanking (Desktop only)
|
|
if [ "$HAS_DESKTOP" = true ] && [ -f /etc/lightdm/lightdm.conf ]; then
|
|
sed -i 's/#xserver-command=X/xserver-command=X -s 0 -dpms/' /etc/lightdm/lightdm.conf
|
|
fi
|
|
|
|
# Hardware watchdog for auto-recovery from system hangs
|
|
if grep -q "#RuntimeWatchdogSec=0" /etc/systemd/system.conf 2>/dev/null; then
|
|
sed -i 's/#RuntimeWatchdogSec=0/RuntimeWatchdogSec=10/' /etc/systemd/system.conf
|
|
log "Hardware watchdog enabled (10s)"
|
|
fi
|
|
|
|
# ============================================================
|
|
# 11. Management scripts (all-in-one only)
|
|
# ============================================================
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
log "Creating management scripts..."
|
|
|
|
cat > /usr/local/bin/screentinker-update << 'UPDATEEOF'
|
|
#!/bin/bash
|
|
echo "Stopping services..."
|
|
sudo systemctl stop screentinker-kiosk.service 2>/dev/null || true
|
|
sudo systemctl stop screentinker-server.service 2>/dev/null || true
|
|
|
|
echo "Pulling latest..."
|
|
cd /opt/screentinker && git pull origin main
|
|
|
|
echo "Installing dependencies..."
|
|
cd server && npm install --production
|
|
|
|
echo "Starting services..."
|
|
sudo systemctl start screentinker-server.service
|
|
sleep 3
|
|
sudo systemctl start screentinker-kiosk.service
|
|
|
|
echo ""
|
|
echo "Done! Server: $(systemctl is-active screentinker-server.service)"
|
|
echo " Kiosk: $(systemctl is-active screentinker-kiosk.service)"
|
|
UPDATEEOF
|
|
chmod +x /usr/local/bin/screentinker-update
|
|
|
|
cat > /usr/local/bin/screentinker-status << 'STATUSEOF'
|
|
#!/bin/bash
|
|
echo ""
|
|
echo "=== ScreenTinker Status ==="
|
|
echo ""
|
|
IP=$(hostname -I | awk '{print $1}')
|
|
|
|
if systemctl is-active screentinker-server.service &>/dev/null; then
|
|
echo "Server: RUNNING (PID $(systemctl show screentinker-server.service -p MainPID --value))"
|
|
else
|
|
echo "Server: STOPPED"
|
|
fi
|
|
|
|
if systemctl is-active screentinker-kiosk.service &>/dev/null; then
|
|
echo "Kiosk: RUNNING"
|
|
else
|
|
echo "Kiosk: STOPPED"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Uptime: $(uptime -p)"
|
|
echo "CPU Temp: $(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'n/a')"
|
|
echo "Disk: $(df -h /opt/screentinker 2>/dev/null | tail -1 | awk '{print $3 "/" $2 " (" $5 " used)"}')"
|
|
echo "Memory: $(free -h | awk '/Mem:/ {print $3 " / " $2}')"
|
|
echo ""
|
|
echo "Dashboard: http://${IP}:3001"
|
|
echo "Player: http://${IP}:3001/player"
|
|
echo "mDNS: http://$(hostname).local:3001"
|
|
echo ""
|
|
STATUSEOF
|
|
chmod +x /usr/local/bin/screentinker-status
|
|
|
|
cat > /usr/local/bin/screentinker-logs << 'LOGSEOF'
|
|
#!/bin/bash
|
|
case "${1:-server}" in
|
|
server) journalctl -u screentinker-server.service -f --no-hostname ;;
|
|
kiosk) journalctl -u screentinker-kiosk.service -f --no-hostname ;;
|
|
all) journalctl -u screentinker-server.service -u screentinker-kiosk.service -f --no-hostname ;;
|
|
*) echo "Usage: screentinker-logs [server|kiosk|all]" ;;
|
|
esac
|
|
LOGSEOF
|
|
chmod +x /usr/local/bin/screentinker-logs
|
|
else
|
|
# Player-Only gets its own pair. It used to get NONE, while section 12 below wrote an MOTD
|
|
# advertising all three to every install — so a player Pi greeted its operator at each SSH
|
|
# login with three commands that were never on it (#245). There is no server here to update,
|
|
# so screentinker-update is genuinely not applicable and is not offered; status and logs are,
|
|
# and a player with no way to answer "is it running?" is the harder machine to support.
|
|
log "Creating management scripts (player)..."
|
|
|
|
cat > /usr/local/bin/screentinker-status << PSTATUSEOF
|
|
#!/bin/bash
|
|
echo ""
|
|
echo "=== ScreenTinker Player Status ==="
|
|
echo ""
|
|
if systemctl is-active screentinker-kiosk.service &>/dev/null; then
|
|
echo "Kiosk: RUNNING"
|
|
else
|
|
echo "Kiosk: STOPPED (screentinker-logs to see why)"
|
|
fi
|
|
echo "Server: ${SERVER_URL}"
|
|
# Whether this player can actually reach the server it was pointed at — the first question worth
|
|
# asking on a panel that is showing nothing.
|
|
if curl -sf --max-time 5 "${SERVER_URL}/api/status" >/dev/null 2>&1; then
|
|
echo "Reachable: yes"
|
|
else
|
|
echo "Reachable: NO (network, DNS, or the server is down)"
|
|
fi
|
|
echo ""
|
|
echo "Uptime: \$(uptime -p)"
|
|
echo "CPU Temp: \$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'n/a')"
|
|
echo "Disk: \$(df -h / 2>/dev/null | tail -1 | awk '{print \$3 "/" \$2 " (" \$5 " used)"}')"
|
|
echo "Memory: \$(free -h | awk '/Mem:/ {print \$3 " / " \$2}')"
|
|
echo ""
|
|
PSTATUSEOF
|
|
chmod +x /usr/local/bin/screentinker-status
|
|
|
|
cat > /usr/local/bin/screentinker-logs << 'PLOGSEOF'
|
|
#!/bin/bash
|
|
# Only the kiosk exists on a player, so it is the default AND the only target. Accepting
|
|
# "server" here and following an empty unit would be a worse answer than saying so.
|
|
case "${1:-kiosk}" in
|
|
kiosk|all) journalctl -u screentinker-kiosk.service -f --no-hostname ;;
|
|
server) echo "This is a player-only install — there is no local server. Point at your server's logs instead." ;;
|
|
*) echo "Usage: screentinker-logs [kiosk]" ;;
|
|
esac
|
|
PLOGSEOF
|
|
chmod +x /usr/local/bin/screentinker-logs
|
|
fi
|
|
|
|
# ============================================================
|
|
# 12. MOTD
|
|
# ============================================================
|
|
cat > /etc/motd << 'MOTDEOF'
|
|
|
|
____ _____ _ _
|
|
/ ___| ___ _ __ ___ ___ _ __ |_ _|(_) _ __ | | __ ___ _ __
|
|
\___ \ / __|| '__| / _ \ / _ \| '_ \ | | | || '_ \ | |/ / / _ \| '__|
|
|
___) || (__ | | | __/| __/| | | | | | | || | | || < | __/| |
|
|
|____/ \___||_| \___| \___||_| |_| |_| |_||_| |_||_|\_\ \___||_|
|
|
|
|
Open-Source Digital Signage for Any Screen
|
|
|
|
MOTDEOF
|
|
|
|
# The command list is appended SEPARATELY and per-mode, because section 11 creates
|
|
# screentinker-update on an All-in-One install only. A single hard-coded list here is what made a
|
|
# Player-Only Pi advertise three commands it did not have, at every SSH login (#245). The MOTD is
|
|
# the first thing an operator reads on a machine that is misbehaving, which makes it the worst
|
|
# place in the system to be confidently wrong.
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
cat >> /etc/motd << 'MOTDCMDEOF'
|
|
Commands:
|
|
screentinker-status Show system info and URLs
|
|
screentinker-update Pull latest and restart
|
|
screentinker-logs Follow logs (server|kiosk|all)
|
|
|
|
MOTDCMDEOF
|
|
else
|
|
cat >> /etc/motd << 'MOTDCMDEOF'
|
|
Commands:
|
|
screentinker-status Kiosk state, server URL, and whether it is reachable
|
|
screentinker-logs Follow the kiosk log
|
|
|
|
MOTDCMDEOF
|
|
fi
|
|
|
|
# ============================================================
|
|
# 13. Clean up legacy remotedisplay naming
|
|
# ============================================================
|
|
if [ -f /etc/systemd/system/remotedisplay.service ]; then
|
|
log "Cleaning up legacy remotedisplay service..."
|
|
systemctl stop remotedisplay.service 2>/dev/null || true
|
|
systemctl disable remotedisplay.service 2>/dev/null || true
|
|
rm -f /etc/systemd/system/remotedisplay.service
|
|
rm -f "$PI_HOME/remotedisplay-kiosk.sh"
|
|
rm -f "$PI_HOME/.config/autostart/remotedisplay.desktop"
|
|
systemctl daemon-reload
|
|
fi
|
|
|
|
# ============================================================
|
|
# Done
|
|
# ============================================================
|
|
echo ""
|
|
echo -e "${GREEN}======================================${NC}"
|
|
echo -e "${GREEN} ScreenTinker Setup Complete!${NC}"
|
|
echo -e "${GREEN}======================================${NC}"
|
|
echo ""
|
|
|
|
IP=$(hostname -I | awk '{print $1}')
|
|
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
echo "Mode: All-in-One (server + player)"
|
|
echo ""
|
|
echo "After reboot this Pi will:"
|
|
echo " - Start the ScreenTinker server on port $SCREENTINKER_PORT"
|
|
echo " - Display the player fullscreen on the connected screen"
|
|
echo ""
|
|
echo "First steps:"
|
|
echo " 1. Reboot: sudo reboot"
|
|
echo " 2. From your phone, go to http://${IP}:${SCREENTINKER_PORT}"
|
|
echo " (or http://$(hostname).local:${SCREENTINKER_PORT})"
|
|
echo " 3. Register - first user gets full admin access"
|
|
echo " 4. Add a display and enter the pairing code from the TV"
|
|
echo " 5. Upload content and push it to the screen"
|
|
echo ""
|
|
echo "Management:"
|
|
echo " screentinker-status Check everything is running"
|
|
echo " screentinker-update Update to latest version"
|
|
echo " screentinker-logs Watch server logs"
|
|
else
|
|
echo "Mode: Player Only"
|
|
echo "Server: $SERVER_URL"
|
|
echo ""
|
|
echo "After reboot this Pi will:"
|
|
echo " - Open the player in fullscreen kiosk mode"
|
|
echo " - Auto-reconnect if the server goes down"
|
|
echo ""
|
|
echo "To pair:"
|
|
echo " 1. Reboot: sudo reboot"
|
|
echo " 2. The pairing screen will appear on the TV"
|
|
echo " 3. Enter the code in your ScreenTinker dashboard"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Services:"
|
|
if [ "$PLAYER_ONLY" = false ]; then
|
|
echo " sudo systemctl [start|stop|restart] screentinker-server"
|
|
fi
|
|
echo " sudo systemctl [start|stop|restart] screentinker-kiosk"
|
|
echo ""
|
|
echo -e "${YELLOW}Reboot to start: sudo reboot${NC}"
|
|
echo ""
|