#!/usr/bin/env bash # setup-openwhispr.sh — One-command installer for OpenWhispr on Linux # https://github.com/outis1one/openwhispr-easy-setup # License: MIT # ── Wrap everything in main() so bash reads the entire script before executing. # This prevents the "line-by-line interpreted as commands" bug when run via: # bash <(curl -fsSL ...) or curl ... | bash # Without this wrapper, bash may lose track of the script source after a read # from /dev/tty and start feeding remaining script lines to the shell prompt. main() { # Do NOT use set -e — we handle errors explicitly readonly SCRIPT_VERSION="1.0.0" readonly BASHRC="$HOME/.bashrc" readonly MARKER="# Added by openwhispr-easy-setup" readonly GITHUB_API="https://api.github.com/repos/OpenWhispr/openwhispr/releases/latest" readonly TMP_DIR="/tmp/openwhispr-install" # ── Colours & formatting ───────────────────────────────────────────────────── if [[ -t 1 ]]; then BOLD='\033[1m' DIM='\033[2m' GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' CYAN='\033[0;36m' RESET='\033[0m' else BOLD='' DIM='' GREEN='' YELLOW='' RED='' CYAN='' RESET='' fi # ── Helper functions ────────────────────────────────────────────────────────── # shellcheck disable=SC2059 info() { printf "${GREEN}[✓]${RESET} %s\n" "$*"; } # shellcheck disable=SC2059 warn() { printf "${YELLOW}[!]${RESET} %s\n" "$*"; } # shellcheck disable=SC2059 err() { printf "${RED}[✗]${RESET} %s\n" "$*" >&2; } # shellcheck disable=SC2059 step() { printf "\n${BOLD}${CYAN}[%s]${RESET} ${BOLD}%s${RESET}\n" "$1" "$2"; } # shellcheck disable=SC2059 divider() { printf "${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"; } prompt_default() { # Usage: prompt_default "prompt text" "default" VARNAME local prompt="$1" default="$2" varname="$3" reply printf "%s [default: %s]: " "$prompt" "$default" > /dev/tty read -r reply < /dev/tty reply="${reply:-$default}" printf -v "$varname" '%s' "$reply" } prompt_hidden() { # Usage: prompt_hidden "prompt text" VARNAME local prompt="$1" varname="$2" reply printf "%s: " "$prompt" > /dev/tty read -rs reply < /dev/tty printf "\n" > /dev/tty printf -v "$varname" '%s' "$reply" } store_api_key() { # Usage: store_api_key VARNAME value local keyname="$1" keyval="$2" if [[ -z "$keyval" ]]; then warn "Empty value for $keyname — skipping." return 1 fi # Check if already present in bashrc if grep -q "^export ${keyname}=" "$BASHRC" 2>/dev/null; then info "$keyname already set in $BASHRC — not overwriting." else printf '\n%s\nexport %s="%s"\n' "$MARKER" "$keyname" "$keyval" >> "$BASHRC" info "$keyname stored in $BASHRC" fi # Also export for current session export "$keyname=$keyval" } command_exists() { command -v "$1" >/dev/null 2>&1; } # ── Variables set during the run ────────────────────────────────────────────── DISTRO="" # debian, ubuntu, mint, fedora, arch, opensuse DISTRO_FAMILY="" # deb, rpm-dnf, rpm-zypper, arch ARCH="" # amd64, arm64 SESSION_TYPE="" # x11, wayland DESKTOP="" # GNOME, KDE, etc. STT_CHOICE="" CLEANUP_CHOICE="" STT_LABEL="" CLEANUP_LABEL="" INSTALLED_VERSION="" LATEST_VERSION="" # ══════════════════════════════════════════════════════════════════════════════ # [1/8] Intro # ══════════════════════════════════════════════════════════════════════════════ step "1/8" "Welcome to OpenWhispr Easy Setup v${SCRIPT_VERSION}" divider cat <<'INTRO' OpenWhispr is an Electron-based voice dictation app for Linux. Press a hotkey, speak, and your words appear wherever the cursor is. Your voice stays on your machine. Always. The only question is whether you want AI to clean up the text after transcription. Speech-to-text (STT) is local Whisper/Parakeet by default. Audio never leaves your machine unless YOU explicitly choose a cloud option. This script will: 1. Detect your distro and architecture 2. Install OpenWhispr (latest release) 3. Install paste/typing dependencies 4. Configure STT provider 5. Configure AI text cleanup 6. Launch OpenWhispr for first-run setup INTRO divider printf "\nPress Enter to continue or Ctrl-C to cancel... " > /dev/tty read -r < /dev/tty # ══════════════════════════════════════════════════════════════════════════════ # [2/8] Detect distro + arch # ══════════════════════════════════════════════════════════════════════════════ step "2/8" "Detecting distribution and architecture" detect_distro() { if [[ -f /etc/os-release ]]; then # shellcheck source=/dev/null . /etc/os-release case "${ID,,}" in ubuntu) DISTRO="ubuntu"; DISTRO_FAMILY="deb" ;; debian) DISTRO="debian"; DISTRO_FAMILY="deb" ;; linuxmint) DISTRO="mint"; DISTRO_FAMILY="deb" ;; fedora) DISTRO="fedora"; DISTRO_FAMILY="rpm-dnf" ;; opensuse*|sles) DISTRO="opensuse"; DISTRO_FAMILY="rpm-zypper" ;; arch|manjaro|endeavouros) DISTRO="arch"; DISTRO_FAMILY="arch" ;; *) err "Unsupported distribution: ${ID} (${PRETTY_NAME:-unknown})" err "Supported: Ubuntu, Debian, Mint, Fedora, openSUSE, Arch" exit 1 ;; esac else err "Cannot detect distribution (/etc/os-release not found)." exit 1 fi } detect_arch() { local machine machine="$(uname -m)" case "$machine" in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; *) err "Unsupported architecture: $machine" err "Supported: x86_64 (amd64), aarch64 (arm64)" exit 1 ;; esac } detect_distro detect_arch info "Distro: ${DISTRO} (${DISTRO_FAMILY}) | Arch: ${ARCH}" # ── Check base dependencies ─────────────────────────────────────────────────── if ! command_exists curl; then err "curl is required but not installed." err "Install it with your package manager and re-run this script." exit 1 fi if ! command_exists jq; then warn "jq is required but not installed." printf "Install jq now? [Y/n]: " > /dev/tty read -r jq_reply < /dev/tty jq_reply="${jq_reply:-Y}" if [[ "${jq_reply,,}" == "y" ]]; then case "$DISTRO_FAMILY" in deb) sudo apt update && sudo apt install -y jq ;; rpm-dnf) sudo dnf install -y jq ;; rpm-zypper) sudo zypper install -y jq ;; arch) sudo pacman -S --noconfirm jq ;; esac if ! command_exists jq; then err "Failed to install jq. Please install it manually and re-run." exit 1 fi info "jq installed." else err "jq is required. Please install it and re-run." exit 1 fi fi # ══════════════════════════════════════════════════════════════════════════════ # [3/8] Install OpenWhispr # ══════════════════════════════════════════════════════════════════════════════ step "3/8" "Installing OpenWhispr" # Check currently installed version get_installed_version() { if command_exists openwhispr; then openwhispr --version 2>/dev/null || true elif command_exists open-whispr; then open-whispr --version 2>/dev/null || true fi } INSTALLED_VERSION="$(get_installed_version)" # Fetch latest release info info "Fetching latest release info..." RELEASE_JSON="$(curl -sf "$GITHUB_API")" || { err "Failed to fetch release info from GitHub." err "Check your internet connection and try again." exit 1 } LATEST_VERSION="$(printf '%s' "$RELEASE_JSON" | jq -r '.tag_name // empty')" if [[ -z "$LATEST_VERSION" ]]; then err "Could not determine latest version from GitHub." exit 1 fi info "Latest version: ${LATEST_VERSION}" # Strip leading 'v' for comparison LATEST_CLEAN="${LATEST_VERSION#v}" INSTALLED_CLEAN="${INSTALLED_VERSION#v}" if [[ -n "$INSTALLED_VERSION" && "$INSTALLED_CLEAN" == "$LATEST_CLEAN" ]]; then info "OpenWhispr ${LATEST_VERSION} is already installed — skipping." else if [[ -n "$INSTALLED_VERSION" ]]; then info "Upgrading from ${INSTALLED_VERSION} to ${LATEST_VERSION}..." fi # Determine asset pattern case "$DISTRO_FAMILY" in deb) ASSET_EXT=".deb" ;; rpm-dnf|rpm-zypper) ASSET_EXT=".rpm" ;; arch) ASSET_EXT=".tar.gz" ;; esac # Map arch names (assets may use different conventions) case "$ARCH" in amd64) ARCH_PATTERNS=("amd64" "x86_64" "x64") ;; arm64) ARCH_PATTERNS=("arm64" "aarch64") ;; esac # Find matching asset URL ASSET_URL="" for arch_pat in "${ARCH_PATTERNS[@]}"; do ASSET_URL="$(printf '%s' "$RELEASE_JSON" | jq -r \ --arg ext "$ASSET_EXT" --arg arch "$arch_pat" \ '.assets[] | select(.name | (endswith($ext) and contains($arch))) | .browser_download_url' \ | head -1)" [[ -n "$ASSET_URL" ]] && break done if [[ -z "$ASSET_URL" ]]; then err "No ${ASSET_EXT} asset found for ${ARCH} in release ${LATEST_VERSION}." err "Check: https://github.com/OpenWhispr/openwhispr/releases/latest" exit 1 fi ASSET_NAME="$(basename "$ASSET_URL")" info "Downloading ${ASSET_NAME}..." mkdir -p "$TMP_DIR" curl --progress-bar -fL -o "${TMP_DIR}/${ASSET_NAME}" "$ASSET_URL" || { err "Download failed." exit 1 } info "Installing..." case "$DISTRO_FAMILY" in deb) sudo apt install -y "${TMP_DIR}/${ASSET_NAME}" || { err "Installation failed. Try: sudo dpkg -i ${TMP_DIR}/${ASSET_NAME}" exit 1 } ;; rpm-dnf) sudo dnf install -y "${TMP_DIR}/${ASSET_NAME}" || { err "Installation failed." exit 1 } ;; rpm-zypper) sudo zypper install -y "${TMP_DIR}/${ASSET_NAME}" || { err "Installation failed." exit 1 } ;; arch) sudo mkdir -p /opt/openwhispr sudo tar -xzf "${TMP_DIR}/${ASSET_NAME}" -C /opt/openwhispr || { err "Extraction failed." exit 1 } # Find the binary and symlink local_bin="$(find /opt/openwhispr -maxdepth 2 -name 'openwhispr' -o -name 'open-whispr' 2>/dev/null | head -1)" if [[ -n "$local_bin" ]]; then sudo ln -sf "$local_bin" /usr/local/bin/openwhispr fi ;; esac # Clean up rm -rf "$TMP_DIR" info "OpenWhispr ${LATEST_VERSION} installed successfully." fi # ══════════════════════════════════════════════════════════════════════════════ # [4/8] Install paste dependencies # ══════════════════════════════════════════════════════════════════════════════ step "4/8" "Installing paste/typing dependencies" SESSION_TYPE="${XDG_SESSION_TYPE:-}" DESKTOP="${XDG_CURRENT_DESKTOP:-}" if [[ -z "$SESSION_TYPE" ]]; then # Try to detect if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then SESSION_TYPE="wayland" elif [[ -n "${DISPLAY:-}" ]]; then SESSION_TYPE="x11" else warn "Cannot detect session type (X11/Wayland). Skipping paste deps." fi fi info "Session: ${SESSION_TYPE:-unknown} | Desktop: ${DESKTOP:-unknown}" install_paste_dep() { local pkg="$1" if command_exists "$pkg"; then info "$pkg is already installed." return 0 fi info "Installing $pkg..." case "$DISTRO_FAMILY" in deb) sudo apt install -y "$pkg" ;; rpm-dnf) sudo dnf install -y "$pkg" ;; rpm-zypper) sudo zypper install -y "$pkg" ;; arch) sudo pacman -S --noconfirm "$pkg" ;; esac } case "$SESSION_TYPE" in x11) install_paste_dep "xdotool" ;; wayland) if [[ "$DESKTOP" == *"GNOME"* ]]; then info "GNOME on Wayland — OpenWhispr uses D-Bus natively. No extra deps." elif [[ "$DESKTOP" == *"KDE"* ]]; then install_paste_dep "kdotool" else install_paste_dep "wtype" fi ;; *) warn "Skipping paste dependency install (unknown session type)." ;; esac # ══════════════════════════════════════════════════════════════════════════════ # [5/8] STT provider # ══════════════════════════════════════════════════════════════════════════════ step "5/8" "Speech-to-text (STT) provider" cat <<'STT_MENU' How should OpenWhispr transcribe your voice? Option Method Streaming Cost Audio sent to ───────────────────────────────────────────────────────────────────────────── 1) Local Whisper/Parakeet (default) no free nobody, ever 2) Groq whisper-large-v3-turbo no $0.04/hr* Groq servers 3) OpenAI Realtime API YES $0.06/min OpenAI servers *Groq free tier: ~2,000 audio seconds/day (~600 dictations/day free). NOT used for training. 30-day retention max. Verify: https://console.groq.com/docs/your-data Streaming (option 3) = text appears word-by-word as you speak. Options 1 and 2 transcribe after you stop speaking. All cloud options send audio to a server (like Siri/Google Assistant) but unlike Siri/Google: NOT tied to your identity, NOT used for training, deleted within 30 days. Best free combo: option 1 (local) + Groq cleanup [step 6] Best free cloud combo: option 2 (Groq STT) + Groq cleanup [same key] Best streaming: option 3 (OpenAI Realtime) + any cleanup STT_MENU prompt_default "Enter 1-3" "1" STT_CHOICE case "$STT_CHOICE" in 1) STT_LABEL="Local Whisper/Parakeet (offline)" info "STT: Local — audio never leaves your machine." ;; 2) STT_LABEL="Groq whisper-large-v3-turbo (cloud)" info "STT: Groq cloud transcription." if [[ -n "${GROQ_API_KEY:-}" ]]; then info "GROQ_API_KEY already set — reusing." else prompt_hidden "Enter your Groq API key (from https://console.groq.com)" GROQ_KEY_INPUT store_api_key "GROQ_API_KEY" "$GROQ_KEY_INPUT" fi ;; 3) STT_LABEL="OpenAI Realtime API (cloud, streaming)" info "STT: OpenAI Realtime (streaming)." if [[ -n "${OPENAI_API_KEY:-}" ]]; then info "OPENAI_API_KEY already set — reusing." else prompt_hidden "Enter your OpenAI API key (from https://platform.openai.com/api-keys)" OPENAI_KEY_INPUT store_api_key "OPENAI_API_KEY" "$OPENAI_KEY_INPUT" fi ;; *) warn "Invalid choice '$STT_CHOICE' — defaulting to local." STT_CHOICE="1" STT_LABEL="Local Whisper/Parakeet (offline)" ;; esac # ══════════════════════════════════════════════════════════════════════════════ # [6/8] AI cleanup provider # ══════════════════════════════════════════════════════════════════════════════ step "6/8" "AI text cleanup provider" cat <<'CLEANUP_MENU' After transcription, AI can fix grammar, spelling, and filler words. Only transcribed TEXT is sent — your voice stays local regardless. None of these providers use your text for training. Provider Model Per use 50/day/mo 200/day/mo ────────────────────────────────────────────────────────────────────────── 1) OpenAI gpt-4o-mini-2024-07-18 $0.000058 $0.09 $0.35 2) Anthropic claude-haiku-4-5 $0.000450 $0.68 $2.70 3) Groq llama-3.3-70b-versatile free* free* free* 4) Local Ollama (fully offline) free free free 5) Skip raw transcription only free free free *Groq free: 1,000 req/day, 100K tokens/day. NOT used for training. Verify: https://console.groq.com/docs/your-data Pricing based on ~50 words per dictation (~125 input + 65 output tokens). Verify current pricing: OpenAI: https://openai.com/api/pricing Anthropic: https://www.anthropic.com/pricing Groq: https://groq.com/pricing CLEANUP_MENU prompt_default "Enter 1-5" "1" CLEANUP_CHOICE case "$CLEANUP_CHOICE" in 1) CLEANUP_LABEL="OpenAI gpt-4o-mini" info "Cleanup: OpenAI gpt-4o-mini." if [[ -n "${OPENAI_API_KEY:-}" ]]; then info "OPENAI_API_KEY already set — reusing." else prompt_hidden "Enter your OpenAI API key (from https://platform.openai.com/api-keys)" OPENAI_KEY_INPUT store_api_key "OPENAI_API_KEY" "$OPENAI_KEY_INPUT" fi ;; 2) CLEANUP_LABEL="Anthropic claude-haiku-4-5" info "Cleanup: Anthropic claude-haiku-4-5." if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then info "ANTHROPIC_API_KEY already set — reusing." else prompt_hidden "Enter your Anthropic API key (from https://console.anthropic.com)" ANTHROPIC_KEY_INPUT store_api_key "ANTHROPIC_API_KEY" "$ANTHROPIC_KEY_INPUT" fi ;; 3) CLEANUP_LABEL="Groq llama-3.3-70b (free)" info "Cleanup: Groq llama-3.3-70b." if [[ -n "${GROQ_API_KEY:-}" ]]; then info "GROQ_API_KEY already set — reusing." else prompt_hidden "Enter your Groq API key (from https://console.groq.com)" GROQ_KEY_INPUT store_api_key "GROQ_API_KEY" "$GROQ_KEY_INPUT" fi ;; 4) CLEANUP_LABEL="Local Ollama (offline)" info "Cleanup: Local Ollama." if command_exists ollama; then info "Ollama is already installed." else warn "Ollama is not installed." cat <<'OLLAMA_INFO' To install Ollama: curl -fsSL https://ollama.ai/install.sh | sh Then pull a model: ollama pull llama3.1 OLLAMA_INFO printf "Install Ollama now? [Y/n]: " > /dev/tty read -r ollama_reply < /dev/tty ollama_reply="${ollama_reply:-Y}" if [[ "${ollama_reply,,}" == "y" ]]; then curl -fsSL https://ollama.ai/install.sh | sh || { warn "Ollama install failed. Install manually later." } else warn "Skipping Ollama install — you can install it later." fi fi ;; 5) CLEANUP_LABEL="None (raw transcription)" info "Cleanup: Skipped — raw transcription only." ;; *) warn "Invalid choice '$CLEANUP_CHOICE' — defaulting to OpenAI." CLEANUP_CHOICE="1" CLEANUP_LABEL="OpenAI gpt-4o-mini" if [[ -n "${OPENAI_API_KEY:-}" ]]; then info "OPENAI_API_KEY already set — reusing." else prompt_hidden "Enter your OpenAI API key (from https://platform.openai.com/api-keys)" OPENAI_KEY_INPUT store_api_key "OPENAI_API_KEY" "$OPENAI_KEY_INPUT" fi ;; esac # ══════════════════════════════════════════════════════════════════════════════ # [7/8] First launch # ══════════════════════════════════════════════════════════════════════════════ step "7/8" "First launch" cat <<'LAUNCH_INFO' OpenWhispr will open now for first-run setup. - It will download speech models (~75-500 MB depending on choice). - Set your hotkey in Settings (default: backtick `). - The app manages its own autostart. LAUNCH_INFO LAUNCHED=false LAUNCH_CMD="" # Check PATH first, then common Electron install locations for cmd in openwhispr open-whispr; do if command_exists "$cmd"; then LAUNCH_CMD="$cmd" break fi done # If not in PATH, search common install directories if [[ -z "$LAUNCH_CMD" ]]; then for path in \ /opt/OpenWhispr/open-whispr \ /opt/open-whispr/open-whispr \ /opt/openwhispr/openwhispr \ /usr/lib/open-whispr/open-whispr \ /usr/lib/openwhispr/openwhispr \ /snap/bin/openwhispr \ /snap/bin/open-whispr; do if [[ -x "$path" ]]; then LAUNCH_CMD="$path" break fi done fi # Last resort: ask dpkg where it installed the binary if [[ -z "$LAUNCH_CMD" ]] && command_exists dpkg; then LAUNCH_CMD="$(dpkg -L open-whispr 2>/dev/null | grep -E '/bin/|/opt/' | head -1 || true)" if [[ -n "$LAUNCH_CMD" && ! -x "$LAUNCH_CMD" ]]; then LAUNCH_CMD="" fi fi if [[ -n "$LAUNCH_CMD" ]]; then "$LAUNCH_CMD" &>/dev/null & LAUNCHED=true fi if [[ "$LAUNCHED" == true ]]; then info "OpenWhispr launched in the background." else warn "Could not auto-detect OpenWhispr binary location." warn "Try launching manually:" warn " open-whispr" warn " openwhispr" if command_exists dpkg; then warn " Find it: dpkg -L open-whispr | grep bin" fi fi # ══════════════════════════════════════════════════════════════════════════════ # [8/8] Summary # ══════════════════════════════════════════════════════════════════════════════ step "8/8" "Done!" printf "\n" divider # shellcheck disable=SC2059 printf "${BOLD} OpenWhispr is ready${RESET}\n" divider DISPLAY_CMD="${LAUNCH_CMD:-open-whispr}" DISPLAY_CMD_BASE="$(basename "$DISPLAY_CMD")" cat <