#!/bin/sh
set -eu
die() { printf 'VoiceStudio: %s\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<'HELP'
VoiceStudio Electron installer (macOS / Linux)
curl -fsSL https://voicestudio.sh/install | sh
curl -fsSL https://voicestudio.sh/install | sh -s -- --version X.Y.Z
curl -fsSL https://voicestudio.sh/install | sh -s -- --main
--version VERSION Install a published Electron version (optional v prefix)
--main, --source Clone main, build Electron, and install the desktop app
--binary Install a release (default)
--uninstall Remove the installed app, preserving all user data
--help Show this help
Release installs require curl and a SHA-256 tool. Main builds also require
Git, Node.js 22+, Bun, Rust/Cargo, and native build dependencies (see README).
The installer preserves settings, models, and projects. Quit VoiceStudio first.
Versions without Electron artifacts are not supported; Tauri is archived.
HELP
}
MODE=binary
VERSION=
UNINSTALL=0
INSTALL_OPTION=0
while [ "$#" -gt 0 ]; do
case "$1" in
--main|--source) MODE=main; INSTALL_OPTION=1 ;;
--binary) MODE=binary; INSTALL_OPTION=1 ;;
--uninstall) UNINSTALL=1 ;;
--version)
[ "$#" -ge 2 ] || die '--version requires a value'
VERSION=${2#v}; INSTALL_OPTION=1; shift ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown option: $1 (see --help)" ;;
esac
shift
done
[ "$UNINSTALL-$INSTALL_OPTION" != 1-1 ] || die '--uninstall cannot be combined with installation options'
[ "$MODE" != main ] || [ -z "$VERSION" ] || die '--main cannot be combined with --version'
valid_version() {
printf '%s\n' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$'
}
[ -z "$VERSION" ] || valid_version "$VERSION" || die 'Invalid version; use X.Y.Z or X.Y.Z-prerelease'
case "$(uname -s)" in
Darwin) OS=mac ;;
Linux) OS=linux ;;
*) die 'This shell installer supports macOS and Linux. On Windows, use the Electron .exe from GitHub Releases.' ;;
esac
if [ "$OS" = mac ]; then
PATH="$PATH:/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support"
export PATH
fi
if [ -n "${VOICESTUDIO_INSTALL_DIR:-}" ]; then
case "$VOICESTUDIO_INSTALL_DIR" in /*) ;; *) die 'Install directory must be absolute';; esac
[ -d "$VOICESTUDIO_INSTALL_DIR" ] && [ -w "$VOICESTUDIO_INSTALL_DIR" ] || die 'Custom install directory must exist and be writable.'
fi
require_closed() {
if have pgrep && { pgrep -f '/VoiceStudio[.]app/Contents/MacOS/VoiceStudio([[:space:]]|$)' >/dev/null 2>&1 || pgrep -x VoiceStudio >/dev/null 2>&1 || pgrep -f '(^|/)voicestudio-electron([[:space:]]|$)' >/dev/null 2>&1; }; then
die 'Quit VoiceStudio completely (macOS: Command-Q), then run this command again. No app files were changed.'
fi
}
require_closed
if [ "$UNINSTALL" = 1 ]; then
remove_app() {
target=$1
[ -e "$target" ] || [ -L "$target" ] || return 0
[ ! -L "$target" ] || die "Refusing symlink: $target"
if [ "$OS" = mac ]; then
[ -f "$target/Contents/Resources/app.asar" ] || die "Not an Electron app: $target"
else
[ -f "$target" ] || die "Not an AppImage file: $target"
fi
if [ "$OS" = mac ]; then
mkdir -p "$HOME/.Trash"
recovery=$(mktemp -d "$HOME/.Trash/VoiceStudio-uninstalled.XXXXXXXX")
lsregister -u "$target" || printf 'Warning: macOS could not refresh the old registration; moving the app to Trash.\n' >&2
else
recovery=$(mktemp -d "$(dirname "$target")/.voicestudio-uninstalled.XXXXXXXX")
fi
mv "$target" "$recovery/" || die "Could not remove $target; check permissions."
printf 'Uninstalled: %s\nRecoverable copy: %s\n' "$target" "$recovery"
}
printf 'Quit VoiceStudio before uninstalling.\n'
if [ -n "${VOICESTUDIO_INSTALL_DIR:-}" ]; then
case "$VOICESTUDIO_INSTALL_DIR" in /*) ;; *) die 'Install directory must be absolute';; esac
if [ "$OS" = mac ]; then remove_app "$VOICESTUDIO_INSTALL_DIR/VoiceStudio.app";
else remove_app "$VOICESTUDIO_INSTALL_DIR/VoiceStudio"; fi
elif [ "$OS" = mac ]; then
remove_app /Applications/VoiceStudio.app
remove_app "$HOME/Applications/VoiceStudio.app"
else
remove_app "$HOME/.local/bin/VoiceStudio"
fi
printf 'Uninstall complete. Settings, models and projects are preserved.\n'
exit 0
fi
have curl || die 'Install curl, then retry.'
case "$(uname -m)" in
arm64|aarch64) ARCH=arm64 ;;
x86_64|amd64) ARCH=x64 ;;
*) die 'Unsupported processor architecture.' ;;
esac
if [ "$OS" = mac ] && [ "$ARCH" = x64 ] && [ "$(sysctl -in hw.optional.arm64 2>/dev/null || true)" = 1 ]; then
ARCH=arm64
[ "$MODE" != main ] || die 'Run --main from a native Apple Silicon terminal, not Rosetta.'
fi
[ "$OS-$ARCH" != linux-arm64 ] || die 'Linux arm64 Electron packages are not supported yet.'
WORK=$(mktemp -d "${TMPDIR:-/tmp}/voicestudio-install.XXXXXXXX")
MOUNT=
STAGED=
BACKUP=
DEST=
cleanup() {
if [ -n "$MOUNT" ]; then hdiutil detach "$MOUNT" -quiet >/dev/null 2>&1 || true; fi
if [ -n "$BACKUP" ] && [ -e "$BACKUP" ] && [ ! -e "$DEST" ]; then mv "$BACKUP" "$DEST"; fi
if [ -n "$STAGED" ] && [ -d "$STAGED" ]; then rm -rf "$STAGED"; fi
rm -rf "$WORK"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
download() { curl --proto '=https' --proto-redir '=https' --connect-timeout 30 --max-time 1800 -fLsS --retry 3 "$1" -o "$2"; }
install_app() {
require_closed
if [ "$OS" = mac ]; then
DEST_DIR=${VOICESTUDIO_INSTALL_DIR:-/Applications}
if [ ! -w "$DEST_DIR" ]; then DEST_DIR="$HOME/Applications"; mkdir -p "$DEST_DIR"; fi
DEST="$DEST_DIR/VoiceStudio.app"
STAGED=$(mktemp -d "$DEST_DIR/.voicestudio-install.XXXXXXXX")
ditto "$1" "$STAGED/VoiceStudio.app"
[ -f "$STAGED/VoiceStudio.app/Contents/Info.plist" ] || die 'Invalid application bundle.'
if [ -e "$DEST" ]; then
BACKUP="$STAGED/previous.app"
mv "$DEST" "$BACKUP"
fi
mv "$STAGED/VoiceStudio.app" "$DEST"
lsregister -f "$DEST" || printf 'Warning: macOS registration failed; open the installed app directly.\n' >&2
else
DEST_DIR=${VOICESTUDIO_INSTALL_DIR:-"$HOME/.local/bin"}
mkdir -p "$DEST_DIR"
DEST="$DEST_DIR/VoiceStudio"
STAGED=$(mktemp -d "$DEST_DIR/.voicestudio-install.XXXXXXXX")
cp "$1" "$STAGED/VoiceStudio"
chmod +x "$STAGED/VoiceStudio"
mv -f "$STAGED/VoiceStudio" "$DEST"
fi
printf 'Installed Electron: %s\n' "$DEST"
printf 'Open VoiceStudio to configure its local backend and choose models. Existing data is preserved.\n'
if [ "$OS" = mac ]; then printf 'Launch the installed app: open "%s"\n' "$DEST"; fi
}
if [ "$MODE" = main ]; then
for tool in git node bun cargo; do have "$tool" || die "Main builds require $tool; see the source-build prerequisites in README."; done
if [ "$OS" = linux ]; then
for tool in readelf zsyncmake; do have "$tool" || die "Linux main builds require $tool; install binutils and zsync (see docs/install/script.md)."; done
fi
node -e 'if (Number(process.versions.node.split(".")[0]) < 22) process.exit(1)' || die 'Node.js 22 or newer is required.'
printf 'Building and installing Electron from main (this may take several minutes).\n'
git clone --depth 1 --branch main --single-branch https://github.com/debpalash/VoiceStudio.git "$WORK/source"
(
cd "$WORK/source"
printf 'Source commit: '; git rev-parse HEAD
bun install --frozen-lockfile
export CSC_IDENTITY_AUTO_DISCOVERY=false
cd electron
bun run build
node tests/packaging-contract.mjs
bun run electron-builder --config electron-builder.config.mjs --publish never --"$OS" --"$ARCH"
if [ "$OS" = linux ]; then node scripts/embed-appimage-update.mjs; fi
node tests/update-package-contract.mjs
)
VERSION=$(node -p 'require(process.argv[1]).version' "$WORK/source/frontend/package.json")
valid_version "$VERSION" || die 'Invalid version in source checkout.'
PACKAGE_DIR="$WORK/source/electron/release"
else
RELEASES=https://github.com/debpalash/VoiceStudio/releases
if [ -z "$VERSION" ]; then
URL=$(curl --proto '=https' --proto-redir '=https' --connect-timeout 30 --max-time 60 -fLsS --retry 3 -o /dev/null -w '%{url_effective}' "$RELEASES/latest")
case "$URL" in "$RELEASES/tag/v"*) VERSION=${URL##*/v} ;; *) die 'Could not resolve the latest release tag.' ;; esac
valid_version "$VERSION" || die 'Invalid latest release version.'
fi
PACKAGE_DIR="$WORK"
fi
case "$OS" in
mac) ASSET="VoiceStudio-Electron-$VERSION-mac-$ARCH.dmg" ;;
linux) ASSET="VoiceStudio-Electron-$VERSION-linux-x64.AppImage" ;;
esac
PACKAGE="$PACKAGE_DIR/$ASSET"
if [ "$MODE" = binary ]; then
BASE="$RELEASES/download/v$VERSION"
printf 'Downloading %s\n' "$ASSET"
download "$BASE/$ASSET" "$PACKAGE" || die "No downloadable Electron package for v$VERSION ($OS/$ARCH). Check the release; legacy Tauri versions are not installed."
download "$BASE/SHA256SUMS.txt" "$WORK/SHA256SUMS.txt" || die 'Published checksums are unavailable; refusing to install.'
EXPECTED=$(awk -v asset="$ASSET" '$2 == asset {print $1}' "$WORK/SHA256SUMS.txt")
printf '%s\n' "$EXPECTED" | grep -Eq '^[a-fA-F0-9]{64}$' || die 'Missing, duplicate, or invalid checksum entry.'
if have shasum; then ACTUAL=$(shasum -a 256 "$PACKAGE" | awk '{print $1}');
elif have sha256sum; then ACTUAL=$(sha256sum "$PACKAGE" | awk '{print $1}');
else die 'A SHA-256 tool (shasum or sha256sum) is required; refusing to install.'; fi
[ "$(printf '%s' "$EXPECTED" | tr 'A-F' 'a-f')" = "$ACTUAL" ] || die 'Checksum mismatch; refusing to install.'
printf 'Checksum verified.\n'
fi
[ -f "$PACKAGE" ] || die "Expected Electron package not found: $PACKAGE"
if [ "$OS" = mac ]; then
MOUNT="$WORK/mount"
mkdir "$MOUNT"
hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT" "$PACKAGE"
[ -d "$MOUNT/VoiceStudio.app" ] || die 'VoiceStudio.app is missing from the disk image.'
install_app "$MOUNT/VoiceStudio.app"
else
install_app "$PACKAGE"
fi