diff --git a/CLAUDE.md b/CLAUDE.md index 47ed74e..55f3316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,8 @@ Utility scripts for selfhosted infrastructure management. - `.bash_aliases` — Shell aliases for common operations - `docker_aliases.sh` — Docker Compose wrapper with 1Password secret resolution, fzf integration, Dozzle group management, and tab completion - `tmux_session_picker.sh` — Auto-creates tmux sessions on SSH login with process detection and last-access display +- `projectivy-deploy.sh` — Installs and configures Projectivy Launcher on Android TV over ADB +- `androidtv-app-sync.sh` — Applies checksum-pinned APK updates to reachable Android TV devices ## Conventions - Bash scripts: POSIX-compatible where possible diff --git a/README.md b/README.md index 98beec4..728f4dd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,28 @@ Utility scripts for selfhosted infrastructure management. - `.bash_aliases` — Shell aliases for common operations - `docker_aliases.sh` — Docker Compose wrapper with 1Password secret resolution, fzf integration, Dozzle group management, and tab completion - `tmux_session_picker.sh` — Auto-creates tmux sessions on SSH login with a table showing running processes and last access time +- `projectivy-deploy.sh` — Installs Projectivy Launcher on Android TV over ADB, selects it as home, and supports configuration backup/restore +- `androidtv-app-sync.sh` — Applies checksum-pinned APK updates to reachable Android TV devices without downgrading packages + +`androidtv-app-sync.sh` can share one manifest between a timer and manual runs. Keep Play-managed +apps in audit-only mode and pin every sideloaded APK by version and checksum: + +```text +# packageminimum-versionCodesourceSHA-256 +com.spocky.projengmenu 1 managed - +com.plexapp.android 1 managed - +com.google.android.youtube.tvunplugged 1 managed - +ca.devmesh.seerrtv 1 managed - +smarttube.package.from.approved.apk 123 https://example.invalid/smarttube.apk <64-character-sha256> +ca.devmesh.seerrtv 123 https://example.invalid/seerrtv.apk <64-character-sha256> +``` + +Use the managed SeerrTV row on Google TV/Shield manifests and the pinned APK row on Fire TV +manifests. SmartTube is always a pinned sideload row. Obtain the package and version from the exact +approved APK (`aapt dump badging app.apk`) rather than assuming stable, beta, and F-Droid IDs match. + +The ADB client key must be stored outside an ephemeral container. If a device reports +`unauthorized`, approve that persistent key once on the TV; never rotate keys as a retry strategy. ## Contributors diff --git a/androidtv-app-sync.sh b/androidtv-app-sync.sh new file mode 100755 index 0000000..2e2ee54 --- /dev/null +++ b/androidtv-app-sync.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Update pinned Android TV APKs when a device is reachable over network ADB. + +set -euo pipefail + +readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv-adb}" + +usage() { + cat >&2 < + +Manifest columns (tab-separated): package, minimum versionCode, source, SHA-256. +Use source "managed" and checksum "-" to audit a Play-managed package without +installing it. Otherwise source must be an APK URL with its pinned checksum. +Blank lines and lines beginning with # are ignored. Updates and downgrades must +be represented by a new pinned version and checksum; downgrades are refused. +EOF + exit 2 +} + +[[ $# -eq 2 ]] || usage +readonly DEVICE_TARGET="$1:5555" +readonly MANIFEST="$2" +[[ -r "$MANIFEST" ]] || { + printf 'ERROR: manifest is not readable: %s\n' "$MANIFEST" >&2 + exit 1 +} + +declare -a adb_cmd +using_container=false +if command -v docker >/dev/null 2>&1 \ + && docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$ADB_CONTAINER"; then + adb_cmd=(docker exec "$ADB_CONTAINER" adb) + using_container=true +elif command -v adb >/dev/null 2>&1; then + adb_cmd=(adb) +else + printf 'ERROR: no running ADB container and no host adb executable\n' >&2 + exit 1 +fi + +run_adb() { + "${adb_cmd[@]}" "$@" +} + +tmp_dir="$(mktemp -d)" +declare -a container_apks=() +cleanup() { + run_adb disconnect "$DEVICE_TARGET" >/dev/null 2>&1 || true + if [[ "$using_container" == true && ${#container_apks[@]} -gt 0 ]]; then + docker exec "$ADB_CONTAINER" rm -f "${container_apks[@]}" >/dev/null 2>&1 || true + fi + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +if ! timeout 15s "${adb_cmd[@]}" connect "$DEVICE_TARGET" >/dev/null; then + printf 'Device is offline or ADB is unavailable: %s\n' "$DEVICE_TARGET" >&2 + exit 75 +fi +device_state="$(run_adb -s "$DEVICE_TARGET" get-state 2>&1 || true)" +if [[ "$device_state" == *unauthorized* ]]; then + printf 'ERROR: ADB authorization is missing; approve the persistent client key on the TV\n' >&2 + exit 77 +fi +if ! timeout 15s "${adb_cmd[@]}" -s "$DEVICE_TARGET" wait-for-device; then + printf 'ERROR: timed out waiting for authorized ADB device %s\n' "$DEVICE_TARGET" >&2 + exit 75 +fi +device_state="$(run_adb -s "$DEVICE_TARGET" get-state 2>&1 || true)" +[[ "$device_state" == device ]] || { + printf 'ERROR: unexpected ADB state for %s: %s\n' "$DEVICE_TARGET" "$device_state" >&2 + exit 1 +} + +updates=0 +audit_failures=0 +while IFS=$'\t' read -r package target_version source expected_sha extra; do + [[ -z "$package" || "$package" == \#* ]] && continue + [[ -z "${extra:-}" && "$target_version" =~ ^[0-9]+$ ]] || { + printf 'ERROR: invalid manifest row for %s\n' "$package" >&2 + exit 1 + } + + installed_version="$(run_adb -s "$DEVICE_TARGET" shell dumpsys package "$package" 2>/dev/null \ + | sed -n 's/.*versionCode=\([0-9][0-9]*\).*/\1/p' | head -n 1)" + installed_version="${installed_version:-0}" + if [[ "$source" == managed ]]; then + [[ "$expected_sha" == - ]] || { + printf 'ERROR: managed package %s must use checksum "-"\n' "$package" >&2 + exit 1 + } + if ((installed_version >= target_version && installed_version > 0)); then + printf 'Managed/current: %s (%s)\n' "$package" "$installed_version" + else + printf 'MANAGED ACTION NEEDED: %s is missing or below %s (installed: %s)\n' \ + "$package" "$target_version" "$installed_version" >&2 + audit_failures=$((audit_failures + 1)) + fi + continue + fi + [[ "$expected_sha" =~ ^[[:xdigit:]]{64}$ ]] || { + printf 'ERROR: invalid SHA-256 for %s\n' "$package" >&2 + exit 1 + } + if ((installed_version == target_version)); then + printf 'Current: %s (%s)\n' "$package" "$target_version" + continue + fi + if ((installed_version > target_version)); then + printf 'ERROR: refusing downgrade of %s from %s to %s\n' \ + "$package" "$installed_version" "$target_version" >&2 + exit 1 + fi + + apk_path="$tmp_dir/${package}.apk" + curl --fail --location --silent --show-error "$source" --output "$apk_path" + printf '%s %s\n' "${expected_sha,,}" "$apk_path" | sha256sum --check --status || { + printf 'ERROR: checksum mismatch for %s\n' "$package" >&2 + exit 1 + } + printf 'Updating: %s (%s -> %s)\n' "$package" "$installed_version" "$target_version" + install_path="$apk_path" + if [[ "$using_container" == true ]]; then + install_path="/tmp/${package}-$$.apk" + docker cp "$apk_path" "$ADB_CONTAINER:$install_path" + container_apks+=("$install_path") + fi + run_adb -s "$DEVICE_TARGET" install -r -g "$install_path" + updates=$((updates + 1)) +done <"$MANIFEST" + +printf 'Sync complete: %s update(s) installed\n' "$updates" +if ((audit_failures > 0)); then + printf 'Managed package audit: %s action(s) needed\n' "$audit_failures" >&2 + exit 3 +fi diff --git a/projectivy-deploy.sh b/projectivy-deploy.sh new file mode 100755 index 0000000..20c7404 --- /dev/null +++ b/projectivy-deploy.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Install Projectivy Launcher on an Android TV device and select it as home. +# Uses ADB_CONTAINER (default: androidtv-adb) when running, otherwise adb. + +set -euo pipefail + +readonly PROJECTIVY_PACKAGE="com.spocky.projengmenu" +readonly PROJECTIVY_HOME="${PROJECTIVY_PACKAGE}/.ui.home.MainActivity" +readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv-adb}" + +usage() { + cat >&2 < [|--launcher-only|--backup |--restore ] + +The device must expose ADB over TCP and trust the selected ADB client key. +Set ADB_CONTAINER to use a running container other than androidtv-adb. +EOF + exit 2 +} + +[[ $# -ge 1 ]] || usage +readonly DEVICE_HOST="$1" +readonly DEVICE_TARGET="${DEVICE_HOST}:5555" +APK_PATH="${2:-$(dirname "$0")/projectivy.apk}" +LAUNCHER_ONLY=false +RESTORE_PATH="" +BACKUP_PATH="" + +case "${2:-}" in + "") ;; + --launcher-only) LAUNCHER_ONLY=true ;; + --restore) + LAUNCHER_ONLY=true + RESTORE_PATH="${3:?--restore needs a path}" + ;; + --backup) + LAUNCHER_ONLY=true + BACKUP_PATH="${3:?--backup needs a path}" + ;; + --*) + printf 'ERROR: unknown option: %s\n' "$2" >&2 + usage + ;; +esac + +declare -a adb_cmd +using_container=false +container_tmp="" +if command -v docker >/dev/null 2>&1 \ + && docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$ADB_CONTAINER"; then + adb_cmd=(docker exec "$ADB_CONTAINER" adb) + using_container=true + container_tmp="/tmp/projectivy-deploy-$$" + docker exec "$ADB_CONTAINER" mkdir -p "$container_tmp" + printf 'Using ADB from container %s\n' "$ADB_CONTAINER" +elif command -v adb >/dev/null 2>&1; then + adb_cmd=(adb) + printf 'Using host ADB\n' +else + printf 'ERROR: no running ADB container and no host adb executable\n' >&2 + exit 1 +fi + +run_adb() { + "${adb_cmd[@]}" "$@" +} + +cleanup() { + run_adb disconnect "$DEVICE_TARGET" >/dev/null 2>&1 || true + if [[ "$using_container" == true ]]; then + docker exec "$ADB_CONTAINER" rm -rf "$container_tmp" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +printf 'Connecting to %s\n' "$DEVICE_TARGET" +run_adb connect "$DEVICE_TARGET" >/dev/null +run_adb -s "$DEVICE_TARGET" wait-for-device + +device_name="$(run_adb -s "$DEVICE_TARGET" shell getprop ro.product.model 2>/dev/null | tr -d '\r' || true)" +printf 'Connected: %s\n' "${device_name:-unknown}" + +if [[ "$LAUNCHER_ONLY" == false ]]; then + [[ -f "$APK_PATH" ]] || { + printf 'ERROR: APK not found at %s; use --launcher-only after store installation\n' "$APK_PATH" >&2 + exit 1 + } + install_path="$APK_PATH" + if [[ "$using_container" == true ]]; then + install_path="$container_tmp/projectivy.apk" + docker cp "$APK_PATH" "$ADB_CONTAINER:$install_path" + fi + run_adb -s "$DEVICE_TARGET" install -r -g "$install_path" +fi + +if ! run_adb -s "$DEVICE_TARGET" shell pm list packages 2>/dev/null \ + | grep -Fq "package:${PROJECTIVY_PACKAGE}"; then + printf 'ERROR: Projectivy is not installed; install it first or provide an APK\n' >&2 + exit 1 +fi + +if [[ -n "$RESTORE_PATH" ]]; then + [[ -f "$RESTORE_PATH" ]] || { + printf 'ERROR: restore file not found: %s\n' "$RESTORE_PATH" >&2 + exit 1 + } + printf 'Restoring Projectivy configuration; confirm the prompt on the TV\n' + restore_path="$RESTORE_PATH" + if [[ "$using_container" == true ]]; then + restore_path="$container_tmp/projectivy.ab" + docker cp "$RESTORE_PATH" "$ADB_CONTAINER:$restore_path" + fi + run_adb -s "$DEVICE_TARGET" restore "$restore_path" +fi + +if [[ -n "$BACKUP_PATH" ]]; then + printf 'Backing up Projectivy configuration; confirm the prompt on the TV\n' + backup_path="$BACKUP_PATH" + if [[ "$using_container" == true ]]; then + backup_path="$container_tmp/projectivy.ab" + fi + run_adb -s "$DEVICE_TARGET" backup -f "$backup_path" -noapk "$PROJECTIVY_PACKAGE" + if [[ "$using_container" == true ]]; then + docker cp "$ADB_CONTAINER:$backup_path" "$BACKUP_PATH" + fi + if [[ -r "$BACKUP_PATH" ]]; then + printf 'Backup written: %s bytes\n' "$(wc -c <"$BACKUP_PATH" | tr -d '[:space:]')" + else + printf 'Backup written: missing\n' + fi + exit 0 +fi + +printf 'Setting Projectivy as the default launcher\n' +run_adb -s "$DEVICE_TARGET" shell cmd package set-home-activity "$PROJECTIVY_HOME" +printf 'Done. Press Home on the TV to verify Projectivy launches.\n'