diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c893f3d..3028207 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,12 @@ jobs: cp tizen/ScreenTinker.wgt ScreenTinker.wgt ls -la ScreenTinker.wgt + - name: Build BrightSign autorun.zip (single-file player installer) + run: | + chmod +x scripts/build-autorun-zip.sh + ./scripts/build-autorun-zip.sh -o autorun.zip + ls -la autorun.zip + - name: Build source tarball (bundles the .wgt; the signed apk is added by scripts/finalize-release.sh) run: | OUT="screentinker-${{ steps.ver.outputs.version }}.tar.gz" @@ -87,7 +93,7 @@ jobs: --exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \ --exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \ --exclude='*.apk' \ - server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt + server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt brightsign echo "TARBALL=$OUT" >> "$GITHUB_ENV" ls -la "$OUT" @@ -111,6 +117,10 @@ jobs: echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes" echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher" echo " at \`https:///player\` (no signing needed)." + echo "- \`autorun.zip\` - BrightSign player installer. Drop it on the root of a player's" + echo " storage (microSD, USB, or internal flash) and power-cycle: it unpacks itself and" + echo " reboots into the player. Edit \`screentinker.json\` inside the archive first to" + echo " point it at your own server." if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)." else diff --git a/brightsign/README.md b/brightsign/README.md index c41eecf..44a64eb 100644 --- a/brightsign/README.md +++ b/brightsign/README.md @@ -75,6 +75,33 @@ the script loaded and then could not find its own `index.html`. them. A stale copy on a card is precisely the version skew that would leave a panel unable to restart itself. +## autorun.zip — one file instead of four + +`scripts/build-autorun-zip.sh` packages the host, the fallback page and the config into a single +`autorun.zip`, attached to every GitHub release: + +```bash +scripts/build-autorun-zip.sh --server https://your-server +``` + +Drop it on the root of a player's storage and power-cycle. `autozip.brs` unpacks it in place, +renames it `autorun.zip.done` so it never re-extracts, and reboots into the player. + +Two rules the format imposes, both of which fail silently if broken: + +- **The archive must expand to files at its ROOT**, with no wrapper directory — a player extracts + to the storage root, so a nested folder puts `autorun.brs` somewhere the player never looks and + the card appears to do nothing. The build script zips from *inside* the staging directory and + then asserts the layout rather than trusting it. +- **`autorun.brs` must NOT sit next to `autorun.zip`** on the storage root; its presence stops the + zip being processed at all. It belongs inside the archive. + +The rename is what makes it idempotent. Without it the player extracts, reboots, extracts, reboots +— a loop that looks exactly like a hardware fault. An extraction *failure* deliberately does not +rename, so a truncated copy is retried after someone replaces it rather than skipped forever. + +Requires BrightSignOS 7.0.60+ (`roUnzip`). + ## Provisioning Config resolves `screentinker.json` on the card **>** registry **>** built-in default. The JSON diff --git a/brightsign/autozip.brs b/brightsign/autozip.brs new file mode 100644 index 0000000..5bc3921 --- /dev/null +++ b/brightsign/autozip.brs @@ -0,0 +1,83 @@ +' ScreenTinker — autorun.zip unpacker. +' +' Ships INSIDE autorun.zip, at its root. The card (or internal flash) carries a single file — +' autorun.zip — and this script unpacks it in place, marks it done so it never re-extracts, and +' reboots into the real host. +' +' That is the whole point of the zip: one file to hand someone, or to drop on a hundred cards, +' instead of four files that must all arrive intact and in the right place. A partially-copied +' set of loose files boots into something broken; a partially-copied zip simply fails to extract +' and leaves the player where it was. +' +' ⚠️ autorun.brs must NOT sit next to autorun.zip on the storage root — its presence stops the zip +' being processed at all. autorun.brs belongs INSIDE the zip, which is where the build script puts +' it (scripts/build-autorun-zip.sh). +' +' Requires BrightSignOS 7.0.60+ (roUnzip). + +Function StorageRoot() As String + ' Same reasoning as autorun.brs: a player may be booting from internal flash rather than a + ' card — the only path on a unit whose card interface has failed. Extracting to "SD:/" on such + ' a player writes to a volume that does not exist. + if DoesFileExist("FLASH:/autorun.zip") then return "FLASH:" + return "SD:" +End Function + +Sub Main() + root$ = StorageRoot() + zipPath$ = root$ + "/autorun.zip" + extractPath$ = root$ + "/" + donePath$ = root$ + "/autorun.zip.done" + + print "[st-autozip] volume "; root$ + + if not DoesFileExist(zipPath$) then + print "[st-autozip] no autorun.zip at "; zipPath$; " — nothing to do" + return + end if + + ' Idempotence. Without this the player extracts, reboots, extracts again, reboots again — + ' a boot loop that looks like a hardware fault. + if DoesFileExist(donePath$) then + print "[st-autozip] already unpacked (autorun.zip.done present) — leaving it alone" + return + end if + + print "[st-autozip] unpacking "; zipPath$ + + unzip = CreateObject("roUnzip", zipPath$) + if unzip = invalid then + print "[st-autozip] ERROR: could not open the archive" + return + end if + + result = unzip.DecompressAllFiles(extractPath$) + if result <> 0 then + print "[st-autozip] ERROR: extract failed, code "; result + ' Deliberately NOT marking it done: a corrupt or truncated copy should be retried after + ' someone replaces the file, not silently skipped forever. + return + end if + + print "[st-autozip] extracted" + + fs = CreateObject("roFileSystem") + if fs = invalid then + print "[st-autozip] ERROR: no roFileSystem — cannot mark the archive done" + return + end if + + if not fs.Rename(zipPath$, donePath$) then + print "[st-autozip] ERROR: could not rename the archive; refusing to reboot into a loop" + return + end if + + print "[st-autozip] rebooting into the unpacked player" + sleep(2000) + RebootSystem() +End Sub + +Function DoesFileExist(filePath$ As String) As Boolean + files = MatchFiles(filePath$, filePath$) + return files.Count() > 0 +End Function diff --git a/scripts/build-autorun-zip.sh b/scripts/build-autorun-zip.sh new file mode 100755 index 0000000..986f306 --- /dev/null +++ b/scripts/build-autorun-zip.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Build brightsign/autorun.zip — the single-file installer for a BrightSign player. +# +# scripts/build-autorun-zip.sh [--server https://your-server] [-o path/to/autorun.zip] +# +# Drop the resulting autorun.zip on the root of a player's storage (microSD, USB, or internal +# flash over SFTP) and power-cycle. autozip.brs unpacks it in place, marks it done, and reboots +# into the player. One file to distribute instead of four that must all land intact. +# +# ⚠️ The zip must expand to files AT ITS ROOT — no wrapper directory. A player extracts to the +# storage root, so a nested folder puts autorun.brs somewhere the player never looks and the +# card silently does nothing. That is why this zips from *inside* the staging directory. +# +# ⚠️ autorun.brs must NOT sit next to autorun.zip on the storage root: its presence stops the zip +# being processed at all. It belongs inside, which is where this puts it. +set -euo pipefail +cd "$(dirname "$0")/.." + +SERVER="" +OUT="brightsign/autorun.zip" +while [ $# -gt 0 ]; do + case "$1" in + --server) SERVER="${2:-}"; shift 2 ;; + -o|--out) OUT="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,12p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +command -v zip >/dev/null || { echo "ERROR: 'zip' is not installed." >&2; exit 1; } + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +# The payload. autozip.brs must be here too: it is what the NEXT player to receive this archive +# runs, and it has to survive being extracted alongside everything else. +cp brightsign/autozip.brs "$STAGE/" +cp brightsign/autorun.brs "$STAGE/" +cp brightsign/offline.html "$STAGE/" +cp brightsign/screentinker.json "$STAGE/" + +# Point a batch at a specific server without hand-editing each card. +if [ -n "$SERVER" ]; then + python3 - "$STAGE/screentinker.json" "$SERVER" <<'PY' +import json, sys +path, server = sys.argv[1], sys.argv[2] +cfg = json.load(open(path)) +cfg['server_url'] = server +json.dump(cfg, open(path, 'w'), indent=2) +PY + echo " server_url set to $SERVER" +fi + +mkdir -p "$(dirname "$OUT")" +rm -f "$OUT" +ABS_OUT="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")" + +# -j would flatten any directories we add later; instead cd in and zip '.' so the archive root IS +# the staging root, and future subdirectories keep their structure. +( cd "$STAGE" && zip -q -r -X "$ABS_OUT" . ) + +echo " built $OUT" +unzip -l "$OUT" | sed 's/^/ /' + +# Prove the root-level invariant rather than trusting it: this is the one mistake that makes a +# card look blank to the player, and it is invisible until hardware refuses to boot. +if unzip -l "$OUT" | awk 'NR>3 && $4 ~ /\// && $4 !~ /^[^\/]+$/ {print $4}' | grep -qE '^[^/]+/'; then + echo " NOTE: archive contains directories — verify they are intended subdirectories, not a wrapper." +fi +if ! unzip -l "$OUT" | grep -qE ' autorun\.brs$'; then + echo "ERROR: autorun.brs is not at the archive root — the player would never find it." >&2 + exit 1 +fi +if ! unzip -l "$OUT" | grep -qE ' autozip\.brs$'; then + echo "ERROR: autozip.brs is missing — nothing would unpack this archive." >&2 + exit 1 +fi +echo " root-level layout verified"