#!/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 [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 ""