diff --git a/frontend/guides/raspberry-pi-digital-signage.html b/frontend/guides/raspberry-pi-digital-signage.html
index 13206d1..0da6109 100644
--- a/frontend/guides/raspberry-pi-digital-signage.html
+++ b/frontend/guides/raspberry-pi-digital-signage.html
@@ -81,7 +81,13 @@
Step 2: Run the ScreenTinker installer
Open a terminal on the Pi and run:
- curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | bash
+ curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash
+ The sudo is required — the installer writes systemd units, installs packages and
+ configures autostart. Without it the script stops on its first line and prints the correct
+ command, so nothing is half-installed.
+ That gives you All-in-One: the server and the player on the same Pi. To use
+ the Pi as a player only, pointing at a ScreenTinker server you already run:
+ curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://your-server
The script will:
- Install Chromium (the kiosk browser used as the player)
diff --git a/scripts/raspberry-pi-setup.sh b/scripts/raspberry-pi-setup.sh
index 4463f6f..1815ec8 100644
--- a/scripts/raspberry-pi-setup.sh
+++ b/scripts/raspberry-pi-setup.sh
@@ -328,8 +328,11 @@ if [ "\$SESSION_TYPE" = "wayland" ]; then
# (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. Under wayfire the cursor is hidden by the compositor
- # (hide_cursor / idle plugin), which the installer writes below when wayfire.ini exists.
+ # 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
@@ -532,6 +535,49 @@ 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
# ============================================================
@@ -651,6 +697,52 @@ case "${1:-server}" in
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
# ============================================================
@@ -666,12 +758,29 @@ 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)
-MOTDEOF
+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
diff --git a/server/test/raspberry-pi-setup.test.js b/server/test/raspberry-pi-setup.test.js
index 3ea0525..5c47316 100644
--- a/server/test/raspberry-pi-setup.test.js
+++ b/server/test/raspberry-pi-setup.test.js
@@ -110,3 +110,63 @@ test('#245: the login banner spells the product name', () => {
const banner = lines.join('\n');
assert.ok(banner.includes('| | | |'), 'the n glyph is missing from the banner — it reads "Scree Tinker"');
});
+
+// ---------------------------------------------------------------------------------------------
+// #245 round two: the installer advertised what it had not installed.
+//
+// The MOTD is written unconditionally and listed three commands, but section 11 creates them only
+// on an All-in-One install. So a Player-Only Pi greeted its operator at every SSH login with three
+// commands that were not on it. Same failure shape as the first round — the script describing a
+// state it never reached — and the same reporter found both.
+
+// The generated MOTD for a given mode: the base heredoc plus whichever command block that mode
+// appends. Extracted rather than re-typed, so a future edit to either half shows up here.
+function motdFor(playerOnly) {
+ const base = SRC.match(/cat > \/etc\/motd << 'MOTDEOF'\n([\s\S]*?)\nMOTDEOF/);
+ assert.ok(base, 'MOTD heredoc not found — did the installer restructure?');
+ const blocks = [...SRC.matchAll(/cat >> \/etc\/motd << 'MOTDCMDEOF'\n([\s\S]*?)\nMOTDCMDEOF/g)];
+ assert.equal(blocks.length, 2, 'expected exactly two per-mode MOTD command blocks');
+ // The all-in-one block is written in the `if [ "$PLAYER_ONLY" = false ]` arm, which comes first.
+ return base[1] + (playerOnly ? blocks[1][1] : blocks[0][1]);
+}
+
+// Which management commands the installer actually creates in a given mode.
+function commandsCreated(playerOnly) {
+ const all = [...SRC.matchAll(/cat > \/usr\/local\/bin\/(screentinker-[a-z]+)/g)].map((m) => m[1]);
+ // Section 11 is an if/else: the all-in-one arm creates update, the player arm does not.
+ return playerOnly ? all.filter((c) => c !== 'screentinker-update') : all;
+}
+
+test('#245: the MOTD never advertises a command that mode did not install', () => {
+ for (const playerOnly of [false, true]) {
+ const motd = motdFor(playerOnly);
+ const created = commandsCreated(playerOnly);
+ const advertised = [...motd.matchAll(/(screentinker-[a-z]+)/g)].map((m) => m[1]);
+ assert.ok(advertised.length > 0, `${playerOnly ? 'player' : 'all-in-one'} MOTD lists no commands at all`);
+ for (const cmd of advertised) {
+ assert.ok(created.includes(cmd),
+ `the ${playerOnly ? 'Player-Only' : 'All-in-One'} MOTD advertises ${cmd}, which that mode does not install`);
+ }
+ }
+});
+
+test('#245: a Player-Only Pi is not left with no diagnostics at all', () => {
+ // The cheap fix would have been to print nothing on a player. That trades a wrong banner for a
+ // machine an operator cannot inspect over SSH, which is the harder support call.
+ const motd = motdFor(true);
+ assert.match(motd, /screentinker-status/, 'a player still needs to answer "is it running?"');
+ assert.match(motd, /screentinker-logs/, 'and "why did it stop?"');
+ assert.doesNotMatch(motd, /screentinker-update/,
+ 'there is no local server to update on a player-only install, so it must not be offered');
+});
+
+test('#245: the Wayland cursor claim is backed by something that runs', () => {
+ // The launcher used to state that the installer wrote the compositor cursor config "below". It
+ // did not: wayfire.ini and hide_cursor each appeared exactly once, both inside that comment.
+ const code = SRC.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
+ assert.match(code, /wayfire\.ini/, 'no wayfire.ini handling outside comments');
+ assert.match(code, /\[hide-cursor\]/, 'the hide-cursor section is never written');
+ assert.match(code, /hide_delay/, 'the plugin is configured without a delay');
+ // Non-destructive: a Pi whose owner already tuned wayfire must not silently lose it.
+ assert.match(code, /screentinker-bak/, 'wayfire.ini is edited without a backup');
+});