macOS invalidates Accessibility permission whenever the app binary changes (code signature shifts on each build). New --fix-accessibility flag runs tccutil reset + a sudo sqlite3 TCC grant via SSH after the .app is synced. Falls back gracefully if sqlite3 grant fails (SIP or missing sudoers), logging a warning with a pointer to the manual steps. README documents the symptom, manual fix, sudoers one-time setup, and bundle ID (com.electron.aetherlauncher). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
247 lines
9.8 KiB
Bash
Executable File
247 lines
9.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# deploy.sh — Deploy Aether Native Launcher to onsite Mac laptops
|
|
#
|
|
# USAGE:
|
|
# ./deploy.sh <num> [num ...] Deploy to one or more laptops (e.g. 03 04 05)
|
|
# ./deploy.sh all Deploy to all laptops in devices.conf
|
|
# ./deploy.sh --seed-only <num> Update seed.json only — skip .app copy
|
|
# ./deploy.sh --seed-only all
|
|
# ./deploy.sh --build <num> [num ...] Build first (npm run package:mac), then deploy
|
|
# ./deploy.sh --build all
|
|
# ./deploy.sh --fix-accessibility <num> [num ...] Re-grant macOS Accessibility permission after .app update
|
|
# ./deploy.sh --fix-accessibility all (requires NOPASSWD sudo for sqlite3 — see README)
|
|
#
|
|
# REQUIRES:
|
|
# event.env — copy from event.env.example and fill in AETHER_API_KEY
|
|
# builds/ — pre-built, or use --build to build before deploying
|
|
#
|
|
# SSH keys must already be installed on each target (run ssh-copy-id once per laptop).
|
|
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
DEVICES_FILE="$SCRIPT_DIR/devices.conf"
|
|
EVENT_ENV="$SCRIPT_DIR/event.env"
|
|
SSH_USER="speaker ready"
|
|
BUILD_DIR="$SCRIPT_DIR/../builds"
|
|
|
|
# ── Argument parsing ──────────────────────────────────────────────────────────
|
|
|
|
SEED_ONLY=false
|
|
BUILD_FIRST=false
|
|
FIX_ACCESSIBILITY=false
|
|
TARGETS=()
|
|
# Bundle ID as embedded in Info.plist by electron-packager (no --app-bundle-id override)
|
|
BUNDLE_ID="com.electron.aetherlauncher"
|
|
|
|
usage() {
|
|
grep '^#' "$0" | grep -v '^#!/' | sed 's/^# \{0,1\}//'
|
|
exit 1
|
|
}
|
|
|
|
if [[ $# -eq 0 ]]; then usage; fi
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--seed-only) SEED_ONLY=true ;;
|
|
--build) BUILD_FIRST=true ;;
|
|
--fix-accessibility) FIX_ACCESSIBILITY=true ;;
|
|
--help|-h) usage ;;
|
|
all) TARGETS+=("all") ;;
|
|
*) TARGETS+=("$1") ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
|
echo "ERROR: No targets specified."
|
|
usage
|
|
fi
|
|
|
|
# ── Load event config ─────────────────────────────────────────────────────────
|
|
|
|
if [[ ! -f "$EVENT_ENV" ]]; then
|
|
echo "ERROR: $EVENT_ENV not found."
|
|
echo " Copy event.env.example → event.env and fill in AETHER_API_KEY."
|
|
exit 1
|
|
fi
|
|
|
|
# shellcheck source=/dev/null
|
|
source "$EVENT_ENV"
|
|
|
|
: "${AETHER_API_KEY:?event.env must set AETHER_API_KEY}"
|
|
: "${PRIMARY_API_BASE_URL:?event.env must set PRIMARY_API_BASE_URL}"
|
|
: "${BACKUP_API_BASE_URL:?event.env must set BACKUP_API_BASE_URL}"
|
|
|
|
# Render onsite URL as JSON null or quoted string
|
|
if [[ -n "${ONSITE_API_BASE_URL:-}" ]]; then
|
|
ONSITE_JSON="\"$ONSITE_API_BASE_URL\""
|
|
else
|
|
ONSITE_JSON="null"
|
|
fi
|
|
|
|
# ── Device list helpers ───────────────────────────────────────────────────────
|
|
|
|
# Returns "ip device_id" for a laptop number, or empty if not found
|
|
lookup_device() {
|
|
local num="$1"
|
|
grep -v '^[[:space:]]*#' "$DEVICES_FILE" \
|
|
| grep -E "^[[:space:]]*${num}[[:space:]]" \
|
|
| awk '{print $2, $3}' \
|
|
| head -1
|
|
}
|
|
|
|
# Returns all laptop numbers from devices.conf (first column, non-comment lines)
|
|
all_device_nums() {
|
|
grep -v '^[[:space:]]*#' "$DEVICES_FILE" \
|
|
| grep -v '^[[:space:]]*$' \
|
|
| awk '{print $1}'
|
|
}
|
|
|
|
# ── Deploy one laptop ─────────────────────────────────────────────────────────
|
|
|
|
deploy_laptop() {
|
|
local num="$1"
|
|
|
|
local info
|
|
info=$(lookup_device "$num")
|
|
if [[ -z "$info" ]]; then
|
|
echo " ERROR: Laptop $num not found in devices.conf"
|
|
return 1
|
|
fi
|
|
|
|
local ip device_id
|
|
ip=$(echo "$info" | awk '{print $1}')
|
|
device_id=$(echo "$info" | awk '{print $2}')
|
|
|
|
echo ""
|
|
echo "══════════════════════════════════════════════"
|
|
echo " Laptop $num · $ip · $device_id"
|
|
echo "══════════════════════════════════════════════"
|
|
|
|
# ── Copy .app ──────────────────────────────────────────────────────────
|
|
if [[ "$SEED_ONLY" != "true" ]]; then
|
|
echo " Detecting architecture..."
|
|
local arch
|
|
arch=$(ssh "$SSH_USER@$ip" "uname -m" 2>/dev/null) || {
|
|
echo " ERROR: SSH failed for $ip — is the laptop on the network?"
|
|
return 1
|
|
}
|
|
|
|
local bundle
|
|
case "$arch" in
|
|
x86_64) bundle="$BUILD_DIR/aether_launcher-darwin-x64/aether_launcher.app" ;;
|
|
arm64) bundle="$BUILD_DIR/aether_launcher-darwin-arm64/aether_launcher.app" ;;
|
|
*)
|
|
echo " ERROR: Unknown arch '$arch' on $ip"
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
if [[ ! -d "$bundle" ]]; then
|
|
echo " ERROR: Build not found: $bundle"
|
|
echo " Run: npm run package:mac"
|
|
return 1
|
|
fi
|
|
|
|
echo " Arch: $arch → syncing $(basename "$bundle")..."
|
|
# rsync --delete syncs contents in-place without removing the top-level .app dir.
|
|
# This preserves the inode so macOS Aliases and Desktop shortcuts keep working.
|
|
rsync -a --delete -e ssh "$bundle/" "$SSH_USER@$ip:/Applications/aether_launcher.app/" || {
|
|
echo " ERROR: rsync failed."
|
|
return 1
|
|
}
|
|
echo " .app synced."
|
|
else
|
|
echo " (--seed-only: skipping .app copy)"
|
|
fi
|
|
|
|
# ── Write seed.json ────────────────────────────────────────────────────
|
|
echo " Writing seed.json..."
|
|
ssh "$SSH_USER@$ip" "cat > ~/seed.json" <<EOF
|
|
{
|
|
"event_device_id": "$device_id",
|
|
"aether_api_key": "$AETHER_API_KEY",
|
|
"primary_api_base_url": "$PRIMARY_API_BASE_URL",
|
|
"backup_api_base_url": "$BACKUP_API_BASE_URL",
|
|
"onsite_api_base_url": $ONSITE_JSON
|
|
}
|
|
EOF
|
|
|
|
# ── Accessibility permission ───────────────────────────────────────────
|
|
if [[ "$FIX_ACCESSIBILITY" == "true" ]]; then
|
|
echo " Resetting Accessibility permission (tccutil)..."
|
|
if ssh "$SSH_USER@$ip" "tccutil reset Accessibility $BUNDLE_ID" 2>/dev/null; then
|
|
echo " tccutil reset OK."
|
|
else
|
|
echo " WARNING: tccutil reset failed (non-fatal)."
|
|
fi
|
|
|
|
echo " Granting Accessibility via TCC database (requires NOPASSWD sudo)..."
|
|
# shellcheck disable=SC2016
|
|
TCC_SQL="INSERT OR REPLACE INTO access(service,client,client_type,auth_value,auth_reason,auth_version) VALUES('kTCCServiceAccessibility','$BUNDLE_ID',0,2,4,1);"
|
|
if ssh "$SSH_USER@$ip" "sudo sqlite3 '/Library/Application Support/com.apple.TCC/TCC.db' \"$TCC_SQL\"" 2>/dev/null; then
|
|
echo " ✓ Accessibility granted."
|
|
else
|
|
echo " WARNING: TCC grant failed — manual re-authorization required."
|
|
echo " See README: macOS Accessibility Permission."
|
|
fi
|
|
fi
|
|
|
|
# ── Verify ─────────────────────────────────────────────────────────────
|
|
echo " Verifying..."
|
|
ssh "$SSH_USER@$ip" "cat ~/seed.json"
|
|
if [[ "$SEED_ONLY" != "true" ]]; then
|
|
ssh "$SSH_USER@$ip" \
|
|
"test -d /Applications/aether_launcher.app && echo ' ✓ .app present' || echo ' ✗ .app NOT found'"
|
|
fi
|
|
|
|
echo " ✓ Laptop $num done."
|
|
return 0
|
|
}
|
|
|
|
# ── Build if requested ───────────────────────────────────────────────────────
|
|
|
|
if [[ "$BUILD_FIRST" == "true" ]]; then
|
|
echo "══════════════════════════════════════════════"
|
|
echo " Building: npm run package:mac"
|
|
echo "══════════════════════════════════════════════"
|
|
(cd "$SCRIPT_DIR/.." && npm run package:mac) || {
|
|
echo "ERROR: Build failed. Aborting deploy."
|
|
exit 1
|
|
}
|
|
echo ""
|
|
fi
|
|
|
|
# ── Expand "all" target ───────────────────────────────────────────────────────
|
|
|
|
EXPANDED_TARGETS=()
|
|
for t in "${TARGETS[@]}"; do
|
|
if [[ "$t" == "all" ]]; then
|
|
while IFS= read -r num; do
|
|
EXPANDED_TARGETS+=("$num")
|
|
done < <(all_device_nums)
|
|
else
|
|
EXPANDED_TARGETS+=("$t")
|
|
fi
|
|
done
|
|
|
|
# ── Run deploys ───────────────────────────────────────────────────────────────
|
|
|
|
FAILED=()
|
|
for num in "${EXPANDED_TARGETS[@]}"; do
|
|
if ! deploy_laptop "$num"; then
|
|
FAILED+=("$num")
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo "══════════════════════════════════════════════"
|
|
if [[ ${#FAILED[@]} -eq 0 ]]; then
|
|
echo " All done. ✓"
|
|
else
|
|
echo " FAILED: ${FAILED[*]}"
|
|
echo " Re-run with just those numbers to retry."
|
|
exit 1
|
|
fi
|