Merge branch 'fix/pi-installer-245'

This commit is contained in:
ScreenTinker 2026-08-10 15:38:56 -05:00
commit 9b6f0856e0
3 changed files with 179 additions and 4 deletions

View file

@ -81,7 +81,13 @@
<h2>Step 2: Run the ScreenTinker installer</h2> <h2>Step 2: Run the ScreenTinker installer</h2>
<p>Open a terminal on the Pi and run:</p> <p>Open a terminal on the Pi and run:</p>
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | bash</code></pre> <pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash</code></pre>
<p>The <code>sudo</code> 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.</p>
<p>That gives you <strong>All-in-One</strong>: 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:</p>
<pre><code>curl -sL https://screentinker.com/scripts/raspberry-pi-setup.sh | sudo bash -s -- --player-only https://your-server</code></pre>
<p>The script will:</p> <p>The script will:</p>
<ul> <ul>
<li>Install Chromium (the kiosk browser used as the player)</li> <li>Install Chromium (the kiosk browser used as the player)</li>

View file

@ -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 # (wlroots-based wayfire/labwc); if it is not, the compositor's own idle config is the
# documented fallback and README says so. # documented fallback and README says so.
command -v wlopm >/dev/null 2>&1 && wlopm --on '*' 2>/dev/null || true 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 # unclutter is X11-only — it exits immediately here, which is why a Wayland Pi kept its cursor
# (hide_cursor / idle plugin), which the installer writes below when wayfire.ini exists. # 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 else
# Disable screen blanking and power management # Disable screen blanking and power management
xset s off xset s off
@ -532,6 +535,49 @@ ExecStart=-/sbin/agetty --autologin ${PI_USER} --noclear %I \$TERM
EOF EOF
fi 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 # 10. Pi display and boot optimizations
# ============================================================ # ============================================================
@ -651,6 +697,52 @@ case "${1:-server}" in
esac esac
LOGSEOF LOGSEOF
chmod +x /usr/local/bin/screentinker-logs 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 fi
# ============================================================ # ============================================================
@ -666,12 +758,29 @@ cat > /etc/motd << 'MOTDEOF'
Open-Source Digital Signage for Any Screen 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: Commands:
screentinker-status Show system info and URLs screentinker-status Show system info and URLs
screentinker-update Pull latest and restart screentinker-update Pull latest and restart
screentinker-logs Follow logs (server|kiosk|all) 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 # 13. Clean up legacy remotedisplay naming

View file

@ -110,3 +110,63 @@ test('#245: the login banner spells the product name', () => {
const banner = lines.join('\n'); const banner = lines.join('\n');
assert.ok(banner.includes('| | | |'), 'the n glyph is missing from the banner — it reads "Scree Tinker"'); 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');
});