#!/usr/bin/env bash # Detect quiet mode early (for screen reader users) quiet_mode=false for arg in "$@"; do case "$arg" in --enable-screen-reader) enable_screen_reader_arg=true ;; --disable-shadow-ui) disable_shadow_ui_arg=true ;; esac done if [ "${enable_screen_reader_arg:-false}" = true ] && \ [ "${disable_shadow_ui_arg:-false}" = true ]; then quiet_mode=true fi # Skip ASCII art in quiet mode (screen reader friendly) if [ "$quiet_mode" = false ]; then cat << 'EOM' ____ _ / ___| ___| |____ ___ _ _ __ __ _ \___ \ / __| '_ \ \ /\ / / | | | '_ \ / _` | ___) | (__| | | \ V V /| |_| | | | | (_| | |____/ \___|_| |_|\_/\_/ \__,_|_| |_|\__, | |___/ EOM else echo "Schwung installer (screen reader mode)" fi # uncomment to debug # set -x set -euo pipefail fail() { echo echo "Error: $*" exit 1 } # Echo only if not in quiet mode (for screen reader friendly output) qecho() { if [ "$quiet_mode" = false ]; then echo "$@" fi } # Echo always (for important messages even in quiet mode) iecho() { echo "$@" } # ═══════════════════════════════════════════════════════════════════════════════ # Retry wrapper for scp (network operations can be flaky) # ═══════════════════════════════════════════════════════════════════════════════ scp_with_retry() { local src="$1" local dest="$2" local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do if $scp_ableton "$src" "$dest" 2>/dev/null; then return 0 fi retry=$((retry + 1)) if [ $retry -lt $max_retries ]; then qecho " Retry $retry/$max_retries..." sleep 2 fi done qecho " Failed to copy after $max_retries attempts" return 1 } # ═══════════════════════════════════════════════════════════════════════════════ # Retry wrapper for SSH commands (Windows mDNS can be flaky) # ═══════════════════════════════════════════════════════════════════════════════ ssh_root_with_retry() { local cmd="$1" local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do if $ssh_root "$cmd" 2>/dev/null; then return 0 fi retry=$((retry + 1)) if [ $retry -lt $max_retries ]; then qecho " Connection retry $retry/$max_retries..." sleep 2 fi done qecho " SSH command failed after $max_retries attempts" return 1 } ssh_ableton_with_retry() { local cmd="$1" local max_retries=3 local retry=0 while [ $retry -lt $max_retries ]; do if $ssh_ableton "$cmd" 2>/dev/null; then return 0 fi retry=$((retry + 1)) if [ $retry -lt $max_retries ]; then qecho " Connection retry $retry/$max_retries..." sleep 2 fi done qecho " SSH command failed after $max_retries attempts" return 1 } # ═══════════════════════════════════════════════════════════════════════════════ # SSH Setup Wizard # ═══════════════════════════════════════════════════════════════════════════════ ssh_test_ableton() { ssh -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -n ableton@move.local true 2>&1 } ssh_test_root() { ssh -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -n root@move.local true 2>&1 } ssh_get_configured_key() { # Check if SSH config specifies an IdentityFile for move.local if [ -f "$HOME/.ssh/config" ]; then # Extract IdentityFile from move.local config block awk 'BEGIN{found=0} /^Host move\.local/{found=1; next} found && /^Host /{found=0} found && /IdentityFile/{gsub(/.*IdentityFile[ \t]+/,""); gsub(/[ \t]*$/,""); print; exit}' "$HOME/.ssh/config" | sed "s|~|$HOME|g" fi } ssh_find_public_key() { # First check if SSH config specifies a key for move.local configured_key=$(ssh_get_configured_key) if [ -n "$configured_key" ]; then # Check that BOTH private and public key exist if [ -f "$configured_key" ] && [ -f "${configured_key}.pub" ]; then echo "${configured_key}.pub" return 0 fi # Config specifies a key but it doesn't exist - return empty to trigger generation return 1 fi # No config entry - check for default keys (check private key exists too) for keyfile in "$HOME/.ssh/id_ed25519" "$HOME/.ssh/id_rsa" "$HOME/.ssh/id_ecdsa"; do if [ -f "$keyfile" ] && [ -f "${keyfile}.pub" ]; then echo "${keyfile}.pub" return 0 fi done return 1 } ssh_generate_key() { # Check if SSH config specifies a key path for move.local configured_key=$(ssh_get_configured_key) if [ -n "$configured_key" ]; then keypath="$configured_key" echo "Generating SSH key at configured path: $keypath" else keypath="$HOME/.ssh/id_ed25519" echo "No SSH key found. Generating one now..." fi echo ssh-keygen -t ed25519 -N "" -f "$keypath" -C "$(whoami)@$(hostname)" echo echo "SSH key generated successfully." } ssh_copy_to_clipboard() { pubkey="$1" # Try macOS clipboard if command -v pbcopy >/dev/null 2>&1; then cat "$pubkey" | pbcopy return 0 fi # Try Windows clipboard (Git Bash) if command -v clip >/dev/null 2>&1; then cat "$pubkey" | clip return 0 fi # Try Linux clipboard (xclip) if command -v xclip >/dev/null 2>&1; then cat "$pubkey" | xclip -selection clipboard return 0 fi # Try Linux clipboard (xsel) if command -v xsel >/dev/null 2>&1; then cat "$pubkey" | xsel --clipboard return 0 fi return 1 } ssh_remove_known_host() { echo "Removing old entry for move.local..." ssh-keygen -R move.local 2>/dev/null || true # Also remove by IP if we can resolve it (getent not available on Windows) if command -v getent >/dev/null 2>&1; then move_ip=$(getent hosts move.local 2>/dev/null | awk '{print $1}') if [ -n "$move_ip" ]; then ssh-keygen -R "$move_ip" 2>/dev/null || true fi fi } ssh_fix_permissions() { echo "Updating /data/authorized_keys permissions..." ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -n root@move.local "chmod 644 /data/authorized_keys" } ssh_wizard() { echo echo "═══════════════════════════════════════════════════════════════════════════════" echo " SSH Setup Wizard for Ableton Move" echo "═══════════════════════════════════════════════════════════════════════════════" echo # Step 1: Find or generate SSH key echo "Checking for existing SSH keys..." echo if pubkey=$(ssh_find_public_key); then echo "Found: $pubkey" echo "Using your existing SSH key." else ssh_generate_key # Use configured key path if set, otherwise default configured_key=$(ssh_get_configured_key) if [ -n "$configured_key" ]; then pubkey="${configured_key}.pub" else pubkey="$HOME/.ssh/id_ed25519.pub" fi fi echo # Step 2: Display and copy the key echo "═══════════════════════════════════════════════════════════════════════════════" echo " Step 1: Copy your public key" echo "═══════════════════════════════════════════════════════════════════════════════" echo echo "Your public SSH key is:" echo cat "$pubkey" echo if ssh_copy_to_clipboard "$pubkey"; then echo "(The key has been copied to your clipboard)" else echo "(Copy the key above - clipboard copy not available)" fi echo # Step 3: Guide them to add it echo "═══════════════════════════════════════════════════════════════════════════════" echo " Step 2: Add the key to your Move" echo "═══════════════════════════════════════════════════════════════════════════════" echo echo "1. Open your web browser to: http://move.local/development/ssh" echo "2. Paste the key into the text area" echo "3. Click 'Save'" echo printf "Press ENTER when you've added the key..." read -r dummy /dev/null | awk '{print \$1}'); test -n \"\$pid\" && tr '\\0' '\\n' < /proc/\$pid/environ | grep -q 'LD_PRELOAD=schwung-shim.so' && grep -q 'schwung-shim.so' /proc/\$pid/maps" 2>/dev/null; then return 0 fi done return 1 } direct_start_move_with_shim() { qecho "Init service did not relaunch Move; trying direct launch fallback..." ssh_root_with_retry "for name in MoveOriginal Move MoveLauncher MoveMessageDisplay shadow_ui schwung link-subscriber display-server schwung-manager; do pids=\$(pidof \$name 2>/dev/null || true); if [ -n \"\$pids\" ]; then kill -9 \$pids 2>/dev/null || true; fi; done" || true ssh_root_with_retry "rm -f /dev/shm/move-shadow-* /dev/shm/move-display-*" || true ssh_root_with_retry "pids=\$(fuser /dev/ablspi0.0 2>/dev/null || true); if [ -n \"\$pids\" ]; then kill -9 \$pids || true; fi" || true ssh_root_with_retry "su -s /bin/sh ableton -c 'nohup /opt/move/Move >/tmp/move-shim.log 2>&1 &'" || return 1 return 0 } restart_move_with_fallback() { local fail_msg="$1" local init_attempts="${2:-15}" local fallback_attempts="${3:-30}" ssh_root_with_retry "/etc/init.d/move start >/dev/null 2>&1" || fail "Failed to restart Move service" if wait_for_move_shim_mapping "$init_attempts"; then return 0 fi direct_start_move_with_shim || fail "$fail_msg" wait_for_move_shim_mapping "$fallback_attempts" || fail "$fail_msg" } # Parse arguments use_local=false skip_modules=false skip_confirmation=false use_reenable=false enable_screen_reader=false disable_shadow_ui=false screen_reader_runtime_available=true for arg in "$@"; do case "$arg" in local) use_local=true ;; reenable) use_reenable=true ;; -skip-modules|--skip-modules) skip_modules=true ;; -skip-confirmation|--skip-confirmation) skip_confirmation=true ;; --enable-screen-reader) enable_screen_reader=true ;; --disable-shadow-ui) disable_shadow_ui=true ;; uninstall-module) module_action="uninstall" ;; install-module) module_action="install-local" ;; install-module-github) module_action="install-github" ;; -h|--help) echo "Usage: install.sh [options]" echo "" echo "Options:" echo " local Use local build instead of GitHub release" echo " reenable Re-enable after firmware update (root partition only)" echo " --skip-modules Skip module installation prompt" echo " --skip-confirmation Skip unsupported/liability confirmation prompt" echo " --enable-screen-reader Enable screen reader (TTS) by default" echo " --disable-shadow-ui Disable shadow UI (slot configuration interface)" echo "" echo "Module management:" echo " uninstall-module Remove an installed module" echo " install-module Install a module from a local .tar.gz" echo " install-module-github " echo " Install a module from a GitHub repo" echo "" echo "Examples:" echo " install.sh # Install from GitHub, all features enabled" echo " install.sh local --enable-screen-reader # Install local build with screen reader on" echo " install.sh uninstall-module dexed # Remove the Dexed module" echo " install.sh install-module ./dexed-module.tar.gz # Install from local tarball" echo " install.sh install-module-github charlesvestal/move-anything-dx7" echo " # Install from GitHub repo" echo " install.sh install-module-github charlesvestal/move-anything-dx7/dev" echo " # Install from specific branch" echo "" exit 0 ;; esac done # Collect positional arguments for module subcommands module_action="${module_action:-}" module_arg="" if [ -n "$module_action" ]; then # Find the argument after the subcommand found_cmd=false for arg in "$@"; do if [ "$found_cmd" = true ]; then module_arg="$arg" break fi case "$arg" in uninstall-module|install-module|install-module-github) found_cmd=true ;; esac done if [ -z "$module_arg" ]; then case "$module_action" in uninstall) fail "Usage: install.sh uninstall-module " ;; install-local) fail "Usage: install.sh install-module " ;; install-github) fail "Usage: install.sh install-module-github " ;; esac fi fi if [ -n "$module_action" ]; then # Module management subcommands skip the host install confirmation and download skip_confirmation=true fi if [ "$skip_confirmation" = false ]; then echo echo "**************************************************************" echo "* *" echo "* WARNING: *" echo "* *" echo "* Are you sure you want to install Schwung on your *" echo "* Move? This is UNSUPPORTED by Ableton. *" echo "* *" echo "* The authors of this project accept no liability for *" echo "* any damage you incur by proceeding. *" echo "* *" echo "**************************************************************" echo echo "Type 'yes' to proceed: " read -r response /dev/null 2>&1; then echo "Build MD5: $(md5sum "$local_file")" elif command -v md5 >/dev/null 2>&1; then echo "Build MD5: $(md5 -q "$local_file")" fi fi fi # Check SSH connection, run setup wizard if needed qecho "Checking SSH connection to $hostname..." ssh_result=$(ssh_test_ableton) || true if [ -n "$ssh_result" ]; then # SSH failed - check if it's a network issue first if echo "$ssh_result" | grep -qi "Could not resolve\|No route to host\|Connection timed out\|Network is unreachable"; then echo echo "Cannot reach move.local on the network." echo echo "Please check that:" echo " - Your Move is powered on" echo " - Your Move is connected to the same WiFi network as this computer" echo " - You can access http://move.local in your browser" echo fail "Network connection to Move failed" fi # SSH failed for auth/key reasons - offer wizard (interactive only) echo echo "SSH connection failed." if [ "$skip_confirmation" = true ]; then # Non-interactive mode (GUI installer) - fail immediately fail "SSH connection to $hostname failed (non-interactive mode)" fi printf "Would you like help setting up SSH access? (y/N): " read -r run_wizard /dev/null) if [ -z "$mod_path" ]; then # Also check root-level modules (legacy location) if $ssh_ableton "test -d /data/UserData/schwung/modules/$mod_id" 2>/dev/null; then mod_path="modules/$mod_id" else fail "Module '$mod_id' not found on device" fi fi echo "Found: $mod_path" if [ "$skip_confirmation" = false ]; then printf "Remove module '$mod_id'? [y/N] " read -r confirm /dev/null | grep '/module\.json$' | head -1) if [ -z "$mod_json_path" ]; then fail "No module.json found in tarball" fi mod_json=$(tar -xzf "$tarball" -O "$mod_json_path" 2>/dev/null) || fail "Could not read module.json from tarball" mod_id=$(echo "$mod_json" | grep '"id"' | head -1 | sed 's/.*"id": *"//;s/".*//') ctype=$(echo "$mod_json" | grep '"component_type"' | head -1 | sed 's/.*"component_type": *"//;s/".*//') if [ -z "$mod_id" ]; then fail "Could not determine module ID from module.json in tarball" fi subdir=$(component_type_to_subdir "$ctype") dest="modules/$subdir" echo "Module: $mod_id (type: ${ctype:-unknown})" echo "Install to: $dest/$mod_id/" # Copy tarball to device and extract (use root for mkdir/extract since parent dirs may not be ableton-owned) tarball_name=$(basename "$tarball") scp_with_retry "$tarball" "$username@$hostname:./schwung/$tarball_name" || fail "Failed to copy tarball to device" ssh_root_with_retry "cd /data/UserData/schwung && mkdir -p $dest && tar -xzf $tarball_name -C $dest/ && rm $tarball_name" || fail "Failed to extract module on device" # Fix ownership ssh_root_with_retry "chown -R ableton:users /data/UserData/schwung/$dest/$mod_id" || true echo "Module '$mod_id' installed to $dest/$mod_id/" echo "Restart Move or reload modules for the change to take effect." exit 0 fi if [ "$module_action" = "install-github" ]; then github_input="$module_arg" # Validate format: owner/repo or owner/repo/branch if ! echo "$github_input" | grep -q '/'; then fail "Expected format: owner/repo[/branch] (e.g., charlesvestal/move-anything-dx7)" fi # Parse owner/repo and optional branch # Count slashes: 1 = owner/repo, 2+ = owner/repo/branch slash_count=$(echo "$github_input" | tr -cd '/' | wc -c | tr -d ' ') if [ "$slash_count" -ge 2 ]; then # Extract owner/repo (first two segments) and branch (rest) github_repo=$(echo "$github_input" | cut -d'/' -f1-2) github_branch=$(echo "$github_input" | cut -d'/' -f3-) echo "Fetching release.json from $github_repo (branch: $github_branch)..." release_json=$(curl -fsSL "https://raw.githubusercontent.com/${github_repo}/${github_branch}/release.json" 2>/dev/null) || \ fail "Could not fetch release.json from $github_repo branch $github_branch" else github_repo="$github_input" echo "Fetching release.json from $github_repo..." release_json=$(curl -fsSL "https://raw.githubusercontent.com/${github_repo}/main/release.json" 2>/dev/null) || \ release_json=$(curl -fsSL "https://raw.githubusercontent.com/${github_repo}/master/release.json" 2>/dev/null) || \ fail "Could not fetch release.json from $github_repo (tried main and master branches)" fi version=$(echo "$release_json" | grep '"version"' | head -1 | sed 's/.*"version": *"//;s/".*//') download_url=$(echo "$release_json" | grep '"download_url"' | head -1 | sed 's/.*"download_url": *"//;s/".*//') if [ -z "$download_url" ]; then fail "No download_url found in release.json" fi echo "Version: ${version:-unknown}" echo "Download URL: $download_url" # Download the tarball tarball_name=$(basename "$download_url") echo "Downloading $tarball_name..." curl -fsSLO "$download_url" || fail "Failed to download release tarball" # Extract module.json to determine install location # Find module.json path inside tarball (cross-platform: works on macOS and Linux) mod_json_path=$(tar -tzf "$tarball_name" 2>/dev/null | grep '/module\.json$' | head -1) if [ -z "$mod_json_path" ]; then rm -f "$tarball_name" fail "No module.json found in tarball" fi mod_json=$(tar -xzf "$tarball_name" -O "$mod_json_path" 2>/dev/null) || { rm -f "$tarball_name"; fail "Could not read module.json from tarball"; } mod_id=$(echo "$mod_json" | grep '"id"' | head -1 | sed 's/.*"id": *"//;s/".*//') ctype=$(echo "$mod_json" | grep '"component_type"' | head -1 | sed 's/.*"component_type": *"//;s/".*//') if [ -z "$mod_id" ]; then fail "Could not determine module ID from module.json in tarball" fi subdir=$(component_type_to_subdir "$ctype") dest="modules/$subdir" echo "Module: $mod_id (type: ${ctype:-unknown})" echo "Install to: $dest/$mod_id/" # Copy tarball to device and extract (use root for mkdir/extract since parent dirs may not be ableton-owned) scp_with_retry "$tarball_name" "$username@$hostname:./schwung/$tarball_name" || fail "Failed to copy tarball to device" ssh_root_with_retry "cd /data/UserData/schwung && mkdir -p $dest && tar -xzf $tarball_name -C $dest/ && rm $tarball_name" || fail "Failed to extract module on device" # Clean up local tarball rm -f "$tarball_name" # Fix ownership ssh_root_with_retry "chown -R ableton:users /data/UserData/schwung/$dest/$mod_id" || true echo "Module '$mod_id' (v${version:-unknown}) installed to $dest/$mod_id/" echo "Restart Move or reload modules for the change to take effect." exit 0 fi # ═══════════════════════════════════════════════════════════════════════════════ # Re-enable mode: root partition operations only (after firmware update) # ═══════════════════════════════════════════════════════════════════════════════ if [ "$use_reenable" = true ]; then echo echo "Re-enable mode: restoring root partition hooks..." echo # Verify data partition payload is intact if ! $ssh_ableton "test -f /data/UserData/schwung/schwung-shim.so" 2>/dev/null; then fail "Shim not found on data partition. Run a full install instead." fi if ! $ssh_ableton "test -f /data/UserData/schwung/shim-entrypoint.sh" 2>/dev/null; then fail "Entrypoint not found on data partition. Run a full install instead." fi # Clean stale ld.so.preload entries ssh_root_with_retry "if [ -f /etc/ld.so.preload ] && grep -q 'schwung-shim.so' /etc/ld.so.preload; then ts=\$(date +%Y%m%d-%H%M%S); cp /etc/ld.so.preload /etc/ld.so.preload.bak-schwung-\$ts; grep -v 'schwung-shim.so' /etc/ld.so.preload > /tmp/ld.so.preload.new || true; if [ -s /tmp/ld.so.preload.new ]; then cat /tmp/ld.so.preload.new > /etc/ld.so.preload; else rm -f /etc/ld.so.preload; fi; rm -f /tmp/ld.so.preload.new; fi" || true # Symlink shim to /usr/lib/ + setuid ssh_root_with_retry "rm -f /usr/lib/schwung-shim.so && ln -s /data/UserData/schwung/schwung-shim.so /usr/lib/schwung-shim.so" || fail "Failed to install shim" ssh_root_with_retry "chmod u+s /data/UserData/schwung/schwung-shim.so" || fail "Failed to set shim permissions" ssh_root_with_retry "test -u /data/UserData/schwung/schwung-shim.so" || fail "Shim setuid bit missing" # Remove web shim symlink (no longer used as of 0.9.2) ssh_root_with_retry "rm -f /usr/lib/schwung-web-shim.so" || true # TTS library symlinks if present if $ssh_ableton "test -d /data/UserData/schwung/lib" 2>/dev/null; then qecho "Restoring TTS library symlinks..." ssh_root_with_retry "cd /data/UserData/schwung/lib && for lib in *.so.*; do rm -f /usr/lib/\$lib && ln -s /data/UserData/schwung/lib/\$lib /usr/lib/\$lib; done" || echo "Warning: Failed to restore TTS libraries" fi # Ensure entrypoint is executable ssh_root_with_retry "chmod +x /data/UserData/schwung/shim-entrypoint.sh" || fail "Failed to set entrypoint permissions" # Backup original Move binary if MoveOriginal doesn't exist yet if $ssh_root "test ! -f /opt/move/MoveOriginal" 2>/dev/null; then ssh_root_with_retry "test -f /opt/move/Move" || fail "Missing /opt/move/Move" ssh_root_with_retry "mv /opt/move/Move /opt/move/MoveOriginal" || fail "Failed to backup original Move" ssh_ableton_with_retry "cp /opt/move/MoveOriginal ~/" || true fi # Install shimmed entrypoint ssh_root_with_retry "cp /data/UserData/schwung/shim-entrypoint.sh /opt/move/Move" || fail "Failed to install shim entrypoint" # Restore stock MoveWebService if previously wrapped web_svc_path=$($ssh_root "grep 'service_path=' /etc/init.d/move-web-service 2>/dev/null | head -n 1 | sed 's/.*service_path=//' | tr -d '[:space:]'" 2>/dev/null || echo "") if [ -n "$web_svc_path" ] && $ssh_root "test -f ${web_svc_path}Original" 2>/dev/null; then qecho "Restoring stock MoveWebService..." ssh_root_with_retry "killall MoveWebService MoveWebServiceOriginal 2>/dev/null; sleep 1; cp ${web_svc_path}Original $web_svc_path && chmod +x $web_svc_path && /etc/init.d/move-web-service start >/dev/null 2>&1" || echo "Warning: Failed to restore MoveWebService" fi # Stop and restart Move service iecho "Restarting Move..." ssh_root_with_retry "/etc/init.d/move stop >/dev/null 2>&1 || true" || true ssh_root_with_retry "for name in MoveOriginal Move MoveLauncher MoveMessageDisplay shadow_ui schwung link-subscriber display-server schwung-manager; do pids=\$(pidof \$name 2>/dev/null || true); if [ -n \"\$pids\" ]; then kill -9 \$pids 2>/dev/null || true; fi; done" || true ssh_root_with_retry "rm -f /dev/shm/move-shadow-* /dev/shm/move-display-*" || true ssh_root_with_retry "pids=\$(fuser /dev/ablspi0.0 2>/dev/null || true); if [ -n \"\$pids\" ]; then kill -9 \$pids || true; fi" || true ssh_ableton_with_retry "sleep 2" || true restart_move_with_fallback "Move started without active shim (LD_PRELOAD check failed)" iecho "" iecho "Schwung has been re-enabled!" iecho "All your modules, patches, and settings are intact." exit 0 fi # Migrate from move-anything if needed (skip if already a symlink from prior migration) if ssh_ableton_with_retry "test -d /data/UserData/move-anything && ! test -L /data/UserData/move-anything" 2>/dev/null; then if ssh_ableton_with_retry "test -d /data/UserData/schwung" 2>/dev/null; then iecho "Warning: Both /data/UserData/move-anything and /data/UserData/schwung exist." iecho " Keeping schwung, old data left at move-anything/." else iecho "Migrating data from move-anything → schwung..." ssh_root_with_retry "mv /data/UserData/move-anything /data/UserData/schwung" || fail "Failed to migrate data directory" iecho " Done. Patches, slot state, and settings preserved." fi fi # Migrate UserLibrary folders from old "Move Everything" name if ssh_ableton_with_retry "test -d '/data/UserData/UserLibrary/Samples/Move Everything'" 2>/dev/null; then if ! ssh_ableton_with_retry "test -d '/data/UserData/UserLibrary/Samples/Schwung'" 2>/dev/null; then ssh_ableton_with_retry "mv '/data/UserData/UserLibrary/Samples/Move Everything' '/data/UserData/UserLibrary/Samples/Schwung'" || true iecho "Migrated samples folder → Schwung" fi fi if ssh_ableton_with_retry "test -d '/data/UserData/UserLibrary/Track Presets/Move Everything'" 2>/dev/null; then if ! ssh_ableton_with_retry "test -d '/data/UserData/UserLibrary/Track Presets/Schwung'" 2>/dev/null; then ssh_ableton_with_retry "mv '/data/UserData/UserLibrary/Track Presets/Move Everything' '/data/UserData/UserLibrary/Track Presets/Schwung'" || true iecho "Migrated track presets folder → Schwung" fi fi # Create backwards-compat symlink so modules with old import paths still work # (e.g., import from '/data/UserData/move-anything/shared/constants.mjs') ssh_root_with_retry "if [ ! -L /data/UserData/move-anything ] && [ ! -d /data/UserData/move-anything ]; then ln -s /data/UserData/schwung /data/UserData/move-anything; fi" || true # Copy and extract main tarball with retry (Windows mDNS can be flaky) scp_with_retry "$local_file" "$username@$hostname:./$remote_filename" || fail "Failed to copy tarball to device" # Validate tar payload layout before extraction. # Some host-side tar variants can encode large files under GNUSparseFile.0 paths # that BusyBox tar on Move does not restore correctly. ssh_ableton_with_retry "tar -tzf ./$remote_filename | grep -qx 'schwung/schwung-shim.so'" || \ fail "Invalid tar payload: missing schwung/schwung-shim.so entry" # Use verbose tar only in non-quiet mode (screen reader friendly) if [ "$quiet_mode" = true ]; then ssh_ableton_with_retry "tar -xzof ./$remote_filename" || fail "Failed to extract tarball" else ssh_ableton_with_retry "tar -xzvof ./$remote_filename" || fail "Failed to extract tarball" fi # Verify expected payload exists before making system changes ssh_ableton_with_retry "test -f /data/UserData/schwung/schwung-shim.so" || fail "Payload missing: schwung-shim.so" ssh_ableton_with_retry "test -f /data/UserData/schwung/shim-entrypoint.sh" || fail "Payload missing: shim-entrypoint.sh" # Verify modules directory exists if ssh_ableton_with_retry "test -d /data/UserData/schwung/modules"; then qecho "Modules directory found" if [ "$quiet_mode" = false ]; then ssh_ableton_with_retry "ls /data/UserData/schwung/modules/" || true fi else echo "Warning: No modules directory found" fi # Legacy v0.3.0 migration removed — directory restructuring is long complete. # All installs since v0.3.0 already use the modules/// layout. deleted_modules="" # Preflight: clean stale debug/tmp artifacts that can fill root on dev-heavy setups. # Keep runtime sockets and only remove known one-off files/directories. ssh_root_with_retry "rm -rf /var/volatile/tmp/_MEI* 2>/dev/null || true; rm -f /var/volatile/tmp/*.pcm /var/volatile/tmp/*.out /var/volatile/tmp/*.err /var/volatile/tmp/yt* /var/volatile/tmp/ytdlp* /var/volatile/tmp/ytmod* /var/volatile/tmp/ytsearch* /var/volatile/tmp/clap_* /var/volatile/tmp/chain_* /var/volatile/tmp/lddebug_* /var/volatile/tmp/preload_* /var/volatile/tmp/verify-* /var/volatile/tmp/auxv_* /var/volatile/tmp/test_shadow.js /var/volatile/tmp/trigger /var/volatile/tmp/surge_debug.log /var/volatile/tmp/multipart-* 2>/dev/null || true; rm -rf /data/UserData/schwung/.tmp 2>/dev/null || true" || true # Safety: check root partition has enough free space (< 10MB free = danger zone) root_avail=$($ssh_root "df / | tail -1 | awk '{print \$4}'" 2>/dev/null || echo "0") if [ "$root_avail" -lt 10240 ] 2>/dev/null; then echo echo "Warning: Root partition has less than 10MB free (${root_avail}KB available)" echo "Cleaning up any stale backup files..." $ssh_root "rm -f /opt/move/Move.bak /opt/move/Move.shim /opt/move/Move.orig 2>/dev/null || true" root_avail=$($ssh_root "df / | tail -1 | awk '{print \$4}'" 2>/dev/null || echo "0") if [ "$root_avail" -lt 1024 ] 2>/dev/null; then fail "Root partition critically low (${root_avail}KB free). Cannot safely proceed." fi echo "Root partition now has ${root_avail}KB free" fi # Ensure shim isn't globally preloaded (breaks XMOS firmware check and causes communication error) ssh_root_with_retry "if [ -f /etc/ld.so.preload ] && grep -q 'schwung-shim.so' /etc/ld.so.preload; then ts=\$(date +%Y%m%d-%H%M%S); cp /etc/ld.so.preload /etc/ld.so.preload.bak-schwung-\$ts; grep -v 'schwung-shim.so' /etc/ld.so.preload > /tmp/ld.so.preload.new || true; if [ -s /tmp/ld.so.preload.new ]; then cat /tmp/ld.so.preload.new > /etc/ld.so.preload; else rm -f /etc/ld.so.preload; fi; rm -f /tmp/ld.so.preload.new; fi" || true # Symlink shim to /usr/lib/ (root partition has no free space for copies) ssh_root_with_retry "rm -f /usr/lib/schwung-shim.so && ln -s /data/UserData/schwung/schwung-shim.so /usr/lib/schwung-shim.so" || fail "Failed to install shim after retries" ssh_root_with_retry "chmod u+s /data/UserData/schwung/schwung-shim.so" || fail "Failed to set shim permissions" ssh_root_with_retry "test -u /data/UserData/schwung/schwung-shim.so" || fail "Shim setuid bit missing after install" # Remove web shim symlink (no longer used as of 0.9.2) ssh_root_with_retry "rm -f /usr/lib/schwung-web-shim.so" || true # Deploy TTS libraries (eSpeak-NG + Flite) from /data to /usr/lib via symlink. # Root partition is nearly full, so symlink libraries instead of copying. # Use direct predicate checks so expected test failures don't print misleading # "Connection retry" messages from the retry wrapper. if $ssh_ableton "test ! -d /data/UserData/schwung/lib" 2>/dev/null; then screen_reader_runtime_available=false iecho "Screen reader runtime not bundled; skipping TTS library deployment." elif $ssh_ableton "test -d /data/UserData/schwung/lib" 2>/dev/null; then qecho "Deploying TTS libraries (eSpeak-NG + Flite)..." # Symlink all bundled TTS libraries to /usr/lib ssh_root_with_retry "cd /data/UserData/schwung/lib && for lib in *.so.*; do rm -f /usr/lib/\$lib && ln -s /data/UserData/schwung/lib/\$lib /usr/lib/\$lib; done" || fail "Failed to install TTS libraries" # eSpeak-NG data directory if $ssh_ableton "test -d /data/UserData/schwung/espeak-ng-data" 2>/dev/null; then qecho "eSpeak-NG data directory present" else qecho "Warning: eSpeak-NG data directory not found (eSpeak engine may not work)" fi else fail "Failed to check TTS runtime payload on device (SSH error)" fi # Ensure the replacement Move script exists and is executable ssh_root_with_retry "chmod +x /data/UserData/schwung/shim-entrypoint.sh" || fail "Failed to set entrypoint permissions" # Backup original only once, and only if current Move exists # IMPORTANT: Use mv (not cp) on root partition — it's nearly full (~460MB, <25MB free). # Never create extra copies of large files under /opt/move/ or anywhere on /. if $ssh_root "test ! -f /opt/move/MoveOriginal" 2>/dev/null; then ssh_root_with_retry "test -f /opt/move/Move" || fail "Missing /opt/move/Move; refusing to proceed" ssh_root_with_retry "mv /opt/move/Move /opt/move/MoveOriginal" || fail "Failed to backup original Move" ssh_ableton_with_retry "cp /opt/move/MoveOriginal ~/" || true elif ! $ssh_root "test -f /opt/move/MoveOriginal" 2>/dev/null; then fail "Failed to verify /opt/move/MoveOriginal on device (SSH error)" fi # Install the shimmed Move entrypoint ssh_root_with_retry "cp /data/UserData/schwung/shim-entrypoint.sh /opt/move/Move" || fail "Failed to install shim entrypoint" # Restore stock MoveWebService if previously wrapped (pre-0.9.2 cleanup) web_svc_path=$($ssh_root "grep 'service_path=' /etc/init.d/move-web-service 2>/dev/null | head -n 1 | sed 's/.*service_path=//' | tr -d '[:space:]'" 2>/dev/null || echo "") if [ -n "$web_svc_path" ] && $ssh_root "test -f ${web_svc_path}Original" 2>/dev/null; then qecho "Restoring stock MoveWebService..." ssh_root_with_retry "killall MoveWebService MoveWebServiceOriginal 2>/dev/null; sleep 1; cp ${web_svc_path}Original $web_svc_path && chmod +x $web_svc_path" || echo "Warning: Failed to restore MoveWebService" fi # Remove web shim symlink (no longer used) ssh_root_with_retry "rm -f /usr/lib/schwung-web-shim.so" || true # Create feature configuration file qecho "" qecho "Configuring features..." ssh_ableton_with_retry "mkdir -p /data/UserData/schwung/config" || true # Link Audio enabled by default (harmless on 1.x, activates on 2.0+ with Link) link_audio_val="true" # Read existing features.json from device (if any) to preserve user settings existing_features=$(ssh_ableton_with_retry "cat /data/UserData/schwung/config/features.json 2>/dev/null" || echo "") # Helper: extract a JSON bool value from existing config, with fallback get_existing_feature() { local key="$1" local fallback="$2" if [ -n "$existing_features" ]; then local val=$(echo "$existing_features" | grep "\"$key\"" | grep -o 'true\|false' | head -1) if [ -n "$val" ]; then echo "$val" return fi fi echo "$fallback" } # Determine feature values: CLI flags override, otherwise preserve existing, otherwise default if [ "$disable_shadow_ui" = true ]; then shadow_ui_val="false" else shadow_ui_val=$(get_existing_feature "shadow_ui_enabled" "true") fi existing_link_audio=$(get_existing_feature "link_audio_enabled" "$link_audio_val") existing_display_mirror=$(get_existing_feature "display_mirror_enabled" "false") existing_long_press=$(get_existing_feature "long_press_shadow" "false") # Build features.json content features_json="{ \"shadow_ui_enabled\": $shadow_ui_val, \"link_audio_enabled\": $existing_link_audio, \"display_mirror_enabled\": $existing_display_mirror, \"long_press_shadow\": $existing_long_press }" # Write features.json ssh_ableton_with_retry "cat > /data/UserData/schwung/config/features.json << 'EOF' $features_json EOF" || echo "Warning: Failed to create features.json" # Create screen reader state file if --enable-screen-reader was passed if [ "$enable_screen_reader" = true ]; then if [ "$screen_reader_runtime_available" = true ]; then qecho "Enabling screen reader..." ssh_ableton_with_retry "echo '1' > /data/UserData/schwung/config/screen_reader_state.txt" || true else iecho "Screen reader requested, but this build does not include TTS runtime support." enable_screen_reader=false fi fi if [ "$quiet_mode" = false ]; then echo "Features configured:" echo " Shadow UI: $([ "$shadow_ui_val" = "true" ] && echo "enabled" || echo "disabled")" echo " Screen Reader: $([ "$enable_screen_reader" = true ] && echo "enabled" || echo "disabled (toggle with shift+vol+menu)")" fi # Optional: Install modules from the Module Store (before restart so they're available immediately) echo install_mode="" deleted_modules=$(echo "$deleted_modules" | xargs) # trim whitespace if [ "$disable_shadow_ui" = true ]; then echo "Skipping module installation (shadow UI disabled)" skip_modules=true elif [ "$skip_modules" = true ]; then echo "Skipping module installation (--skip-modules)" elif [ -n "$deleted_modules" ]; then # Migration happened - offer three choices echo "Module installation options:" echo " (a) Install ALL available modules" echo " (m) Install only MIGRATED modules: $deleted_modules" echo " (n) Install NONE (use Module Store later)" echo printf "Choice [a/m/N]: " read -r install_choice 0 && length(repo) > 0 && length(asset) > 0) { if (ctype == "sound_generator") subdir = "sound_generators" else if (ctype == "audio_fx") subdir = "audio_fx" else if (ctype == "midi_fx") subdir = "midi_fx" else if (ctype == "utility") subdir = "utilities" else if (ctype == "overtake") subdir = "overtake" else if (ctype == "tool") subdir = "tools" else subdir = "other" print id "|" repo "|" asset "|" name "|" subdir } id=""; name=""; repo=""; asset=""; ctype="" } ' | while IFS='|' read -r id repo asset name subdir; do # If mode is "missing", only install modules that were deleted if [ "$install_mode" = "missing" ]; then case " $deleted_modules " in *" $id "*) ;; # Module was deleted, continue to install *) continue ;; # Module wasn't deleted, skip esac fi echo if [ -n "$subdir" ]; then dest="modules/$subdir" echo "Installing $name ($id) to $dest/..." else dest="modules" echo "Installing $name ($id)..." fi url="https://github.com/${repo}/releases/latest/download/${asset}" if curl -fsSLO "$url"; then # Use retry for scp/ssh because Windows mDNS can be flaky if scp_with_retry "$asset" "$username@$hostname:./schwung/"; then ssh_root_with_retry "cd /data/UserData/schwung && mkdir -p $dest && tar -xzf $asset -C $dest/ && rm $asset && chown -R ableton:users $dest/$id" || echo " Warning: Failed to extract $name" else echo " Warning: Failed to copy $name to device" fi rm -f "$asset" echo " Installed: $name" else echo " Failed to download $name (may not have a release yet)" fi done echo echo "========================================" echo "Module Installation Complete" echo "========================================" fi # Offer to copy assets for modules that need them (skip if --skip-modules was used) if [ "$skip_modules" = false ]; then echo echo "Some modules require or benefit from additional assets:" echo " - Mini-JV: ROM files + optional SR-JV80 expansions" echo " - SF2: SoundFont files (.sf2)" echo " - Dexed: Additional .syx patch banks (optional - defaults included)" echo " - NAM: .nam model files (free models at tonehunt.org and tone3000.com)" echo " - REX Player: .rx2/.rex loop files (created with Propellerhead ReCycle)" echo " - HUSH ONE: .vstpreset or .bassline presets (TAL-BassLine-101 format)" echo " - CLAP: .clap audio effect plugins (ARM64 Linux)" echo " - Osirus: Virus ROM files (.mid or .BIN)" echo printf "Would you like to copy assets to your Move now? (y/N): " read -r copy_assets /dev/null" || true # Clean up old underscore-named presets from root Track Presets folder ssh_ableton_with_retry "rm -f '/data/UserData/UserLibrary/Track Presets/ME_Slot_'*.json" || true # Fetch fresh Move Manual on the installing computer and deploy to device cache qecho "Fetching Move Manual..." if [ -f "scripts/fetch_move_manual.sh" ] && scripts/fetch_move_manual.sh 2>/dev/null && [ -f ".cache/move_manual.json" ]; then ssh_ableton_with_retry "mkdir -p /data/UserData/schwung/cache" || true scp_with_retry ".cache/move_manual.json" "$username@$hostname:./schwung/cache/move_manual.json" || true qecho "Move Manual deployed ($(wc -c < .cache/move_manual.json | tr -d ' ') bytes)" else qecho "Manual fetch skipped (requires node + curl)" fi # Install JACK shadow driver to RNBO lib path (where jackd looks for drivers) echo " Setting up JACK shadow driver symlinks..." ssh_ableton_with_retry "mkdir -p /data/UserData/rnbo/lib/jack && \ ln -sf /data/UserData/schwung/lib/jack/jack_shadow.so /data/UserData/rnbo/lib/jack/jack_shadow.so" || true # Install display_ctl to RNBO scripts path (used by RNBO Runner module) ssh_ableton_with_retry "mkdir -p /data/UserData/rnbo/scripts && \ ln -sf /data/UserData/schwung/bin/display_ctl /data/UserData/rnbo/scripts/display_ctl" || true # Install RNBO shadow config (used by RNBO Runner to launch without its own JACK) ssh_ableton_with_retry "mkdir -p /data/UserData/rnbo/config && \ ln -sf /data/UserData/schwung/modules/overtake/rnbo-runner/control-startup-shadow.json \ /data/UserData/rnbo/config/control-startup-shadow.json" || true # Enable RNBO MIDI auto-connect (required for pad MIDI to reach patcher instances) ssh_ableton_with_retry "python3 -c \" import json, os p='/data/UserData/.config/rnbo/runner.json' if os.path.exists(p): with open(p) as f: cfg=json.load(f) if not cfg.get('instance_auto_connect_midi'): cfg['instance_auto_connect_midi']=True with open(p,'w') as f: json.dump(cfg,f,indent=4) \"" || true # Fix ownership of all files under UserData. # The shim runs as root (setuid), so any files it creates (recordings, config, # skipback, sets, etc.) end up root-owned. Move's UI runs as ableton and can't # see root-owned files. Fix everything we touch. qecho "Fixing file ownership..." ssh_root_with_retry "chown -R ableton:users /data/UserData/schwung" || true ssh_root_with_retry "chown -R ableton:users '/data/UserData/UserLibrary/Samples/Schwung' 2>/dev/null" || true ssh_root_with_retry "chown -R ableton:users '/data/UserData/UserLibrary/Track Presets/Schwung' 2>/dev/null" || true # Restore setuid on shim (chown clears it) ssh_root_with_retry "chmod u+s /data/UserData/schwung/schwung-shim.so" || true qecho "" iecho "Restarting Move..." # Stop Move via init service (kills MoveLauncher + Move + all children cleanly) # Use retry wrappers because Windows mDNS resolution can be flaky. ssh_root_with_retry "/etc/init.d/move stop >/dev/null 2>&1 || true" || true ssh_root_with_retry "for name in MoveOriginal Move MoveLauncher MoveMessageDisplay shadow_ui schwung link-subscriber display-server schwung-manager; do pids=\$(pidof \$name 2>/dev/null || true); if [ -n \"\$pids\" ]; then kill -9 \$pids 2>/dev/null || true; fi; done" || true # Clean up stale shared memory so it's recreated with correct permissions ssh_root_with_retry "rm -f /dev/shm/move-shadow-* /dev/shm/move-display-*" || true # Free the SPI device if anything still holds it (prevents "communication error" on restart) ssh_root_with_retry "pids=\$(fuser /dev/ablspi0.0 2>/dev/null || true); if [ -n \"\$pids\" ]; then kill -9 \$pids || true; fi" || true ssh_ableton_with_retry "sleep 2" || true ssh_ableton_with_retry "test -x /opt/move/Move" || fail "Missing /opt/move/Move" # Restart via init service (starts MoveLauncher which starts Move with proper lifecycle) restart_move_with_fallback "Move started without active shim mapping (LD_PRELOAD env/maps check failed)" # Start or restart schwung-manager web UI. # Graceful restart: SIGTERM allows clean shutdown, then start the new binary. # Brief downtime (~1s) is acceptable; mDNS re-advertises on startup. if $ssh_ableton "test -x /data/UserData/schwung/schwung-manager" 2>/dev/null; then if ssh_root_with_retry "pidof schwung-manager >/dev/null 2>&1" 2>/dev/null; then qecho "Restarting schwung-manager with new binary..." ssh_root_with_retry "killall schwung-manager 2>/dev/null; sleep 1; killall -9 schwung-manager 2>/dev/null" || true else qecho "Starting schwung-manager web UI..." fi # Truncate oversized logs (cleanup for pre-0.9.6 installs with runaway logging) ssh_root_with_retry "for f in /data/UserData/schwung/schwung-manager.log /data/UserData/schwung/debug.log; do if [ -f \"\$f\" ]; then sz=\$(wc -c < \"\$f\" 2>/dev/null || echo 0); if [ \"\$sz\" -gt 102400 ]; then tail -c 102400 \"\$f\" > \"\$f.tmp\" && mv \"\$f.tmp\" \"\$f\"; fi; fi; done" || true ssh_root_with_retry "start-stop-daemon --start --background --make-pidfile --pidfile /data/UserData/schwung/schwung-manager.pid --startas /bin/sh -- -c 'exec /data/UserData/schwung/schwung-manager -port 7700 -roots /data/UserData/ >> /data/UserData/schwung/schwung-manager.log 2>&1'" || true qecho " Web UI available at http://move.local:7700" fi iecho "" iecho "Installation complete!" # Concise output in quiet mode (screen reader friendly) if [ "$quiet_mode" = true ]; then iecho "" iecho "Screen reader enabled. Press Shift+Menu on Move to toggle on/off." iecho "Visit http://move.local for web interface." else # Verbose output for visual users echo echo "Schwung is now installed with the modular plugin system." echo "Modules are located in: /data/UserData/schwung/modules/" echo # Show active features if [ "$disable_shadow_ui" = false ]; then echo "Active features:" echo " Shift+Vol+Track or Shift+Menu: Access slot configurations and Master FX" echo fi # Show screen reader shortcut based on shadow UI state if [ "$disable_shadow_ui" = true ]; then echo "Screen Reader:" echo " Shift+Menu: Toggle screen reader on/off" echo else echo "Screen Reader:" echo " Toggle via Shadow UI settings menu" echo fi echo "Web Manager:" echo " http://move.local:7700" echo echo "Logging commands:" echo " Enable: ssh ableton@move.local 'touch /data/UserData/schwung/debug_log_on'" echo " Disable: ssh ableton@move.local 'rm -f /data/UserData/schwung/debug_log_on'" echo " View: ssh ableton@move.local 'tail -f /data/UserData/schwung/debug.log'" echo fi