Files
xbsheng f5e763ccd8 tools: a theme the scripts cannot read sends nothing, so it answers 2
theme.sh's header keeps 1 for a theme that could not be pushed - a run that
reached the push and failed - and 2 for one that stopped before the theme was up.
Four refusals in that second group answered 1 while their own last sentence said
"Nothing was sent to the TV.": a folder with no config.json, a config.json that
does not parse, one the launcher would drop (an array, a number, a string), and
a wallpaper name b64push.py refuses. theme.ps1 answered the same shapes with a
PowerShell error record and 1, and a folder that is not there stopped in
Resolve-Path's own message instead of the script's.

They answer 2 in the scripts' own words now, as the three that were already right
(TV_HOST, a dash-led word, no tools/venv) do, and the headers name them.
2026-11-01 21:30:19 +08:00

196 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Push a theme (a folder with config.json, and optionally a wallpaper.jpg or a wallpapers/ collection) to the
# launcher on the TV and restart Home so it picks it up. The launcher reads the files from its external files
# directory and overlays config.json on its built-in config.xml; see themes/README.md for the keys.
#
# Usage: TV_HOST=192.168.1.50 tools/theme.sh themes/aurora
# TV_HOST=192.168.1.50 tools/theme.sh --reset # remove the overlay, back to config.xml
#
# One argument, and --reset takes none: a second word is refused rather than dropped (see below). Exit status:
# 0 the theme is on the TV, or the reset ran; 1 the theme could not be pushed, or Home did not restart after
# it; 2 the run stopped before the theme was up and nothing was sent - the command line is not one theme folder
# or --reset (a word that starts with a dash is not a folder name, -h and --help included), the folder has no
# config.json or one the launcher would drop, a wallpaper name b64push.py refuses, TV_HOST is unset or empty,
# the checkout has no tools/venv, or the TV was not reached (tv.py's and theme_restart.py's own message says
# which). 1 is a run that reached the push, so a theme this script cannot read before it sends anything is not
# one: it answers 2 with the sentence every one of those refusals ends with.
set -euo pipefail
THEME_ARG="${1:-}"
# A theme folder or --reset, and nothing else. The first word alone decided everything the script did, so
# `theme.sh --reset themes/aurora` matched the reset branch on that word and threw the folder away: the theme
# the reader named was removed from the TV instead of pushed, and the run answered "reset" with exit 0 (the
# undo hint this script prints is what people copy, so appending the folder to it is an easy line to type).
# The same line the other way round, `theme.sh themes/aurora --reset`, pushed the theme and ignored the
# --reset the reader asked for. Both are answered here, before TV_HOST is required and before anything is
# sent: a command line this script cannot read should not reach a TV.
if [ -z "$THEME_ARG" ]; then
echo "usage: theme.sh <theme folder> | --reset" >&2
exit 2
fi
if [ "$#" -gt 1 ]; then
shift
echo "theme.sh takes one argument, a theme folder or --reset: $* would not have been used, and --reset" \
"removes the theme from the TV while a folder pushes it, so the two cannot share a command line." \
"Nothing was sent to the TV." >&2
exit 2
fi
# A word that starts with a dash is an option to the reader who types it, and this script has one: --reset.
# Left to the path below, `--help` (or `--resett`, one letter too many) was resolved against the caller's
# directory and the run named a config.json nobody wrote - "no config.json in <cwd>/--help", exit 1, the status
# the header keeps for a theme that could not be pushed, so a run that sent nothing read as one that had. No
# folder a theme lives in starts with a dash, and this script sends nothing for a command line it cannot read:
# answer 2 with the usage, before TV_HOST is required and before anything is sent.
case "$THEME_ARG" in
-*) if [ "$THEME_ARG" != "--reset" ]; then
echo "theme.sh takes a theme folder or --reset, so $THEME_ARG is not a command line it can read." \
"Nothing was sent to the TV." >&2
echo "usage: theme.sh <theme folder> | --reset" >&2
exit 2
fi ;;
esac
# Resolve the theme folder against the caller's directory before moving into tools/.
# Git Bash's pwd says /c/Users/..., which the Windows Python cannot open; pwd -W gives C:/Users/... (as in disable.sh).
# An absolute argument needs the same: /c/Users/... passed through as typed reached the JSON check below, which
# could not open it and called the file "not valid JSON". A folder that does not exist keeps the path as typed,
# so the "no config.json" message names what the caller gave.
case "$THEME_ARG" in
--reset|"") ;;
/*|[A-Za-z]:[\\/]*) THEME_ARG="$(cd "$THEME_ARG" 2>/dev/null && { pwd -W 2>/dev/null || pwd; } || printf '%s' "$THEME_ARG")" ;;
*) THEME_ARG="$(pwd -W 2>/dev/null || pwd)/$THEME_ARG" ;;
esac
cd "$(dirname "$0")"
# The address is the reader's to give, and a missing one is not a theme that could not be pushed: bash's own
# `${TV_HOST:?}` answered it with "tools/theme.sh: line N: TV_HOST: ..." and exit 1 - the status the header
# keeps for a push that ran and failed - so a run that never started read as one that had. Judged here, before
# the client is chosen, in this script's own words and with the status the header gives a run that stopped
# before the theme was up. An empty TV_HOST is the same mistake as an unset one.
if [ -z "${TV_HOST:-}" ]; then
echo "set TV_HOST to the TV IP address, e.g. TV_HOST=192.168.1.50 $0 themes/aurora." \
"Nothing was sent to the TV." >&2
exit 2
fi
PY=venv/bin/python
[ -x "$PY" ] || PY=venv/Scripts/python.exe
# The venv is on this computer, so a checkout without one is another run that stopped before the theme was up:
# it answered 1, the status for a push that ran and failed, while theme_restart.py's own missing-venv answer is 2.
[ -x "$PY" ] || { echo "run: python3 -m venv tools/venv && tools/venv/bin/pip install -r tools/requirements.txt." \
"Nothing was sent to the TV." >&2; exit 2; }
export MSYS_NO_PATHCONV=1
PKG=com.gathin.tvhome
DIR=/sdcard/Android/data/$PKG/files
# A unique marker lets the launcher clear on-TV layout/motion choices after every theme push,
# including a push of identical config.json bytes and --reset.
REVISION="$("$PY" -I -c 'import uuid; print(uuid.uuid4().hex)')"
tv() { "$PY" -I tv.py "$@"; }
# One shared recovery path verifies the helpers after Home restarts.
# Some firmware (TCL G07, Android 11) locks the shell out of Android/data. Files are staged in
# /data/local/tmp and copied in, or written through the launcher's ThemeProvider when that is refused.
# The provider has to be tried first: on Android 14 the shell can write into Android/data, so cp "succeeds",
# but the file it creates belongs to the shell (uid 2000) and the launcher then gets EACCES opening it, which
# silently ignores the theme whenever the file did not exist yet - a fresh install, or after a reinstall.
# A provider write creates the file as the launcher, and it cannot truncate a file the shell owns either, so
# remove our copy first. cp stays as the fallback for firmware without the provider.
URI=content://$PKG.theme
TMP=/data/local/tmp/cyt-theme
# A file's staging path; wallpapers/a.jpg is staged as $TMP-wallpapers-a.jpg.
stage() { echo "$TMP-${1//\//-}"; }
# Android 8 and 9 have no `content write`: they print the usage and "[ERROR] Unsupported operation: write" but
# exit 0, so the exit status alone would skip cp and leave the file deleted. Treat that message as a failure.
# `content` also reports an exception from the provider as text - no provider in the launcher, or the launcher
# getting EACCES - so "Error while accessing provider" is a failure too, and cp runs instead of the file going.
# A folder is the shell's when the shell makes it (drwxrws--- shell ext_data_rw), and the provider, which runs as the
# launcher, then gets EACCES writing into it. Nothing makes wallpapers/ up front: the provider creates it with the
# first picture, and only the cp fallback, where the shell does the writing, runs mkdir.
put() { echo "rm -f $DIR/$1 2>/dev/null; { o=\$(content write --uri $URI/$1 < $(stage "$1") 2>&1) && case \"\$o\" in *'Unsupported operation'*|*'Error while accessing provider'*) false ;; esac; } || { mkdir -p $(dirname "$DIR/$1") 2>/dev/null; cp $(stage "$1") $DIR/$1 2>/dev/null; }; rm -f $(stage "$1")"; }
del() { echo "rm -f $DIR/$1 2>/dev/null; content delete --uri $URI/$1 2>/dev/null"; }
# The collection a theme can push. A push empties it before writing the theme's own pictures, and --reset has to
# empty it too: the launcher's wallpaper picker (LauncherSettingsActivity.showWallpaperPicker) lists that folder
# itself, and an on-TV pick of one of its pictures is read back from it (WallpaperRotation.chosen), so a reset
# that left the pictures behind still shows the theme it just removed. Both paths use this one string.
CLEAR_WALLPAPERS="rm -f $DIR/wallpapers/*.[jJ][pP][gG] $DIR/wallpapers/*.[jJ][pP][eE][gG] $DIR/wallpapers/*.[pP][nN][gG] $DIR/wallpapers/*.[wW][eE][bB][pP] 2>/dev/null; content delete --uri $URI/wallpapers 2>/dev/null"
if [ "${1:-}" = --reset ]; then
tv "mkdir -p $DIR 2>/dev/null; $(del config.json); $(del wallpaper.jpg); $(del wallpapers/captions.txt); $CLEAR_WALLPAPERS; printf '%s' '$REVISION' > $TMP-theme_revision; $(put theme_revision)" >/dev/null
"$PY" -I theme_restart.py
echo reset
exit 0
fi
THEME="${THEME_ARG:?usage: theme.sh <theme folder> | --reset}"
# A folder that is not a theme, a file the launcher would drop, a wallpaper name the push refuses: each is a
# run that stops before anything is sent, which the header gives 2, not 1 - the status of a push that ran and
# failed. The three answered 1 while their own last sentence said "Nothing was sent to the TV.", so an owner
# (or an assistant following PROMPT.md) read a command line it could fix as the TV refusing the theme.
[ -f "$THEME/config.json" ] || { echo "no config.json in $THEME. Nothing was sent to the TV." >&2; exit 2; }
# What the launcher makes of the file: Config.read() answers "no overlay" for anything that is not a JSON
# object, so an array, a number or a string is a theme the TV drops - and it replaces the theme the set had,
# which puts the built-in config.xml back. Reading the shape here, before anything is pushed, keeps a run
# that ends "is on the TV" from meaning the opposite.
theme_json_kind() {
"$PY" -I -c '
import json, sys
try:
with open(sys.argv[1], encoding="utf-8-sig") as handle:
theme = json.load(handle)
except (OSError, ValueError):
print("invalid")
else:
print("object" if isinstance(theme, dict) else "not an object")
' "$1" 2>/dev/null
}
case "$(theme_json_kind "$THEME/config.json")" in
object) ;;
"not an object") echo "$THEME/config.json is not a JSON object of theme keys, so the launcher would ignore" \
"it and the TV would go back to its built-in config (themes/README.md). Nothing was sent to the TV." >&2
exit 2 ;;
*) echo "$THEME/config.json is not valid JSON, so there is no theme to push (themes/README.md says what the" \
"file holds). Nothing was sent to the TV." >&2; exit 2 ;;
esac
# Every collection name is checked before anything changes on the TV: a name refused halfway through the push
# left the new config.json and part of the collection behind. b64push.py refuses a path with '..' in it, so
# such a name (sunset..jpg) is refused here too. The names that pass have no spaces, so NAMES splits on them.
NAMES=""
if [ -d "$THEME/wallpapers" ]; then
for IMAGE in "$THEME"/wallpapers/*; do
[ -f "$IMAGE" ] || continue
case "$IMAGE" in
*.[jJ][pP][gG]|*.[jJ][pP][eE][gG]|*.[pP][nN][gG]|*.[wW][eE][bB][pP])
NAME=$(basename "$IMAGE")
if [[ ! "$NAME" =~ ^[A-Za-z0-9_.-]+$ || "$NAME" == *..* ]]; then
echo "unsupported wallpaper filename: $NAME (b64push.py takes letters, digits, '.', '_' and '-', with no" \
"'..' in the name). Nothing was sent to the TV." >&2
exit 2
fi
NAMES="$NAMES $NAME" ;;
esac
done
fi
tv "mkdir -p $DIR" >/dev/null 2>&1 || true
tv --push "$THEME/config.json" "$TMP-config.json" >/dev/null
tv "$(put config.json)"
if [ -f "$THEME/wallpaper.jpg" ]; then
"$PY" -I b64push.py "$THEME/wallpaper.jpg" "$TMP-wallpaper.jpg"
tv "$(put wallpaper.jpg)"
else
tv "$(del wallpaper.jpg)" >/dev/null
fi
tv "$CLEAR_WALLPAPERS" >/dev/null
for NAME in $NAMES; do
"$PY" -I b64push.py "$THEME/wallpapers/$NAME" "$(stage "wallpapers/$NAME")"
tv "$(put "wallpapers/$NAME")" >/dev/null
done
# The collection's names (wallpaper_captions); a theme without them leaves no stale names behind.
if [ -f "$THEME/wallpapers/captions.txt" ]; then
"$PY" -I b64push.py "$THEME/wallpapers/captions.txt" "$(stage wallpapers/captions.txt)"
tv "$(put wallpapers/captions.txt)" >/dev/null
else
tv "$(del wallpapers/captions.txt)" >/dev/null
fi
tv "printf '%s' '$REVISION' > $TMP-theme_revision; $(put theme_revision)" >/dev/null
"$PY" -I theme_restart.py
echo applied
echo "Theme $(basename "$THEME") is on the TV. Undo with: tools/theme.sh --reset"