From 1dccf15ccde92d76f6e12a33e71eaf74ace38727 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:19:53 -0500 Subject: [PATCH 1/4] feat(androidtv): add Projectivy deployment helper --- CLAUDE.md | 1 + README.md | 1 + projectivy-deploy.sh | 102 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100755 projectivy-deploy.sh diff --git a/CLAUDE.md b/CLAUDE.md index 47ed74e..4348785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ 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 ## Conventions - Bash scripts: POSIX-compatible where possible diff --git a/README.md b/README.md index 98beec4..14fb758 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ 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 ## Contributors diff --git a/projectivy-deploy.sh b/projectivy-deploy.sh new file mode 100755 index 0000000..9f4921e --- /dev/null +++ b/projectivy-deploy.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Install Projectivy Launcher on an Android TV device and select it as home. +# Uses ADB_CONTAINER (default: androidtv.internal) when running, otherwise adb. + +set -euo pipefail + +readonly PROJECTIVY_PACKAGE="com.spocky.projectivylauncher" +readonly PROJECTIVY_HOME="${PROJECTIVY_PACKAGE}/com.spocky.projectivylauncher.MainActivity" +readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv.internal}" + +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.internal. +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}" + ;; +esac + +declare -a adb_cmd +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) + 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 +} +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 + } + run_adb -s "$DEVICE_TARGET" install -r -g "$APK_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' + 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' + run_adb -s "$DEVICE_TARGET" backup -f "$BACKUP_PATH" -noapk "$PROJECTIVY_PACKAGE" + printf 'Backup written: %s bytes\n' "$(stat -c %s "$BACKUP_PATH" 2>/dev/null || echo missing)" + 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' From b3c16681580541a389a96ebbaa358829fd880e22 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:21:28 -0500 Subject: [PATCH 2/4] fix(androidtv): use verified Projectivy component --- projectivy-deploy.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/projectivy-deploy.sh b/projectivy-deploy.sh index 9f4921e..06e8b3e 100755 --- a/projectivy-deploy.sh +++ b/projectivy-deploy.sh @@ -4,8 +4,8 @@ set -euo pipefail -readonly PROJECTIVY_PACKAGE="com.spocky.projectivylauncher" -readonly PROJECTIVY_HOME="${PROJECTIVY_PACKAGE}/com.spocky.projectivylauncher.MainActivity" +readonly PROJECTIVY_PACKAGE="com.spocky.projengmenu" +readonly PROJECTIVY_HOME="${PROJECTIVY_PACKAGE}/.ui.home.MainActivity" readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv.internal}" usage() { @@ -27,6 +27,7 @@ RESTORE_PATH="" BACKUP_PATH="" case "${2:-}" in + "") ;; --launcher-only) LAUNCHER_ONLY=true ;; --restore) LAUNCHER_ONLY=true @@ -36,6 +37,10 @@ case "${2:-}" in LAUNCHER_ONLY=true BACKUP_PATH="${3:?--backup needs a path}" ;; + --*) + printf 'ERROR: unknown option: %s\n' "$2" >&2 + usage + ;; esac declare -a adb_cmd From 79dc5e2b97b6d4f37281021cb56bd03c92654913 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:24:25 -0500 Subject: [PATCH 3/4] feat(androidtv): add pinned app fleet sync --- CLAUDE.md | 1 + README.md | 16 ++++++ androidtv-app-sync.sh | 125 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100755 androidtv-app-sync.sh diff --git a/CLAUDE.md b/CLAUDE.md index 4348785..55f3316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ Utility scripts for selfhosted infrastructure management. - `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 14fb758..fe25413 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,22 @@ Utility scripts for selfhosted infrastructure management. - `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 - +example.sideloaded.app 123 https://example.invalid/app.apk <64-character-sha256> +``` + +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..3b5f420 --- /dev/null +++ b/androidtv-app-sync.sh @@ -0,0 +1,125 @@ +#!/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.internal}" + +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 +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) +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)" +cleanup() { + run_adb disconnect "$DEVICE_TARGET" >/dev/null 2>&1 || true + 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" + run_adb -s "$DEVICE_TARGET" install -r -g "$apk_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 From 60fdfffb76bc9f41a8e20ca57a5b85f7cae77771 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:25:45 -0500 Subject: [PATCH 4/4] fix(androidtv): stage container ADB files safely --- README.md | 7 ++++++- androidtv-app-sync.sh | 16 ++++++++++++++-- projectivy-deploy.sh | 43 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fe25413..728f4dd 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,14 @@ com.spocky.projengmenu 1 managed - com.plexapp.android 1 managed - com.google.android.youtube.tvunplugged 1 managed - ca.devmesh.seerrtv 1 managed - -example.sideloaded.app 123 https://example.invalid/app.apk <64-character-sha256> +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. diff --git a/androidtv-app-sync.sh b/androidtv-app-sync.sh index 3b5f420..2e2ee54 100755 --- a/androidtv-app-sync.sh +++ b/androidtv-app-sync.sh @@ -3,7 +3,7 @@ set -euo pipefail -readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv.internal}" +readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv-adb}" usage() { cat >&2 </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 @@ -42,8 +44,12 @@ run_adb() { } 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 @@ -114,7 +120,13 @@ while IFS=$'\t' read -r package target_version source expected_sha extra; do exit 1 } printf 'Updating: %s (%s -> %s)\n' "$package" "$installed_version" "$target_version" - run_adb -s "$DEVICE_TARGET" install -r -g "$apk_path" + 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" diff --git a/projectivy-deploy.sh b/projectivy-deploy.sh index 06e8b3e..20c7404 100755 --- a/projectivy-deploy.sh +++ b/projectivy-deploy.sh @@ -1,19 +1,19 @@ #!/usr/bin/env bash # Install Projectivy Launcher on an Android TV device and select it as home. -# Uses ADB_CONTAINER (default: androidtv.internal) when running, otherwise adb. +# 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.internal}" +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.internal. +Set ADB_CONTAINER to use a running container other than androidtv-adb. EOF exit 2 } @@ -44,9 +44,14 @@ case "${2:-}" in 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) @@ -62,6 +67,9 @@ run_adb() { 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 @@ -77,7 +85,12 @@ if [[ "$LAUNCHER_ONLY" == false ]]; then printf 'ERROR: APK not found at %s; use --launcher-only after store installation\n' "$APK_PATH" >&2 exit 1 } - run_adb -s "$DEVICE_TARGET" install -r -g "$APK_PATH" + 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 \ @@ -92,13 +105,29 @@ if [[ -n "$RESTORE_PATH" ]]; then exit 1 } printf 'Restoring Projectivy configuration; confirm the prompt on the TV\n' - run_adb -s "$DEVICE_TARGET" restore "$RESTORE_PATH" + 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' - run_adb -s "$DEVICE_TARGET" backup -f "$BACKUP_PATH" -noapk "$PROJECTIVY_PACKAGE" - printf 'Backup written: %s bytes\n' "$(stat -c %s "$BACKUP_PATH" 2>/dev/null || echo missing)" + 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