Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
# package<TAB>minimum-versionCode<TAB>source<TAB>SHA-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

Expand Down
137 changes: 137 additions & 0 deletions androidtv-app-sync.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Usage: $0 <device-host> <manifest.tsv>

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
136 changes: 136 additions & 0 deletions projectivy-deploy.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Usage: $0 <device-host> [<apk-path>|--launcher-only|--backup <file>|--restore <file>]

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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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'
Loading