#!/usr/bin/env bash # RewindOS installer — install / --update / --uninstall / --with-paddleocr. # Privacy-first: inspect this script before running it. set -euo pipefail REPO="jaypopat/rewindos" ASSET="rewindos-linux-x86_64.tar.gz" APP_ID="io.github.jaypopat.rewindos" # Semantic search runs on Ollama + a local embedding model. Keep EMBED_MODEL in # sync with SemanticConfig::default().model in crates/rewindos-core/src/config.rs. OLLAMA_HOST="${OLLAMA_HOST:-http://localhost:11434}" EMBED_MODEL="nomic-embed-text" # Optional chat model for non-interactive installs (--chat-model=). CHAT_MODEL="${CHAT_MODEL:-}" BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" ICON_BASE="$HOME/.local/share/icons/hicolor" UNIT_DIR="$HOME/.config/systemd/user" AUTOSTART_DIR="$HOME/.config/autostart" DATA_DIR="$HOME/.rewindos" VERSION_FILE="$DATA_DIR/INSTALLED_VERSION" # Staging dir for downloads; cleaned up on exit (normal OR die) via the EXIT trap. STAGING_DIR="" cleanup_staging() { [[ -n "$STAGING_DIR" ]] && rm -rf "$STAGING_DIR"; true; } trap cleanup_staging EXIT # log goes to stderr: functions like download_and_extract return values via # stdout command substitution, and a stdout log line would be captured into # the result (this once corrupted INSTALLED_VERSION with a "==> Downloading" # line). log() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; } die() { err "$*"; exit 1; } # ---- pure helpers (unit-tested in scripts/install.test.sh) ---- # pkg_mgr_for -> apt|dnf|pacman|unknown pkg_mgr_for() { local id="${1:-}" like="${2:-}" case " $id $like " in *" debian "*|*" ubuntu "*) echo apt ;; *" fedora "*|*" rhel "*|*" centos "*) echo dnf ;; *" arch "*) echo pacman ;; *) echo unknown ;; esac } # detect_pkg_mgr -> reads /etc/os-release detect_pkg_mgr() { local id="" like="" if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 id="$(. /etc/os-release 2>/dev/null; echo "${ID:-}")" # shellcheck disable=SC1091 like="$(. /etc/os-release 2>/dev/null; echo "${ID_LIKE:-}")" fi pkg_mgr_for "$id" "$like" } # portal_backend_pkg -> upstream portal backend package portal_backend_pkg() { local d; d="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" case "$d" in *kde*|*plasma*) echo "xdg-desktop-portal-kde" ;; *gnome*) echo "xdg-desktop-portal-gnome" ;; *hyprland*) echo "xdg-desktop-portal-hyprland" ;; *sway*|*wlroots*|*wlr*) echo "xdg-desktop-portal-wlr" ;; *) echo "xdg-desktop-portal-gnome" ;; esac } # runtime_deps -> space-separated package list runtime_deps() { local pm="$1" desktop="$2" portal portal="$(portal_backend_pkg "$desktop")" case "$pm" in apt) echo "tesseract-ocr tesseract-ocr-eng libwebkit2gtk-4.1-0 libayatana-appindicator3-1 pipewire xdg-desktop-portal $portal" ;; dnf) echo "tesseract tesseract-langpack-eng webkit2gtk4.1 libayatana-appindicator-gtk3 pipewire xdg-desktop-portal $portal" ;; pacman) echo "tesseract tesseract-data-eng webkit2gtk-4.1 libayatana-appindicator pipewire xdg-desktop-portal $portal" ;; *) echo "" ;; esac } # verify_sha256 verify_sha256() { local actual; actual="$(sha256sum "$1" | awk '{print $1}')" [[ "$actual" == "$2" ]] } # version_gt -> true if a > b (leading 'v' stripped, sort -V) version_gt() { local a="${1#v}" b="${2#v}" [[ "$a" != "$b" && "$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -1)" == "$a" ]] } # set_config_engine -> set [ocr].engine in a TOML config set_config_engine() { local cfg="$1" engine="$2" mkdir -p "$(dirname "$cfg")" if [[ -f "$cfg" ]] && grep -qE '^[[:space:]]*engine[[:space:]]*=' "$cfg"; then sed -i -E "s|^[[:space:]]*engine[[:space:]]*=.*|engine = \"$engine\"|" "$cfg" elif [[ -f "$cfg" ]] && grep -qE '^\[ocr\]' "$cfg"; then sed -i -E "/^\[ocr\]/a engine = \"$engine\"" "$cfg" else printf '\n[ocr]\nengine = "%s"\n' "$engine" >> "$cfg" fi } # build_chat_options -> ordered menu options # on stdout, one "TYPE:VALUE" per line: installed:, pull:, custom:, skip:. # Each installed name is expected pre-trimmed (e.g. from `ollama list | awk '{print $1}'`). # Curated models already installed are not repeated as pull options. build_chat_options() { local installed="$1" m while IFS= read -r m; do [[ -n "$m" ]] && printf 'installed:%s\n' "$m" done <<< "$installed" local curated=("llama3.2:3b" "qwen2.5:7b" "qwen2.5:14b") for m in "${curated[@]}"; do [[ $'\n'"$installed"$'\n' == *$'\n'"$m"$'\n'* ]] || printf 'pull:%s\n' "$m" done printf 'custom:\nskip:\n' } # chat_size_hint -> human size/hardware hint for curated models (display). chat_size_hint() { case "$1" in llama3.2:3b) echo "~2.0 GB low RAM / no GPU" ;; qwen2.5:7b) echo "~4.7 GB 8GB+ RAM [default]" ;; qwen2.5:14b) echo "~9.0 GB 16GB+ / GPU" ;; *) echo "" ;; esac } # choose_chat_model -> echoes the chosen model name ("" = skip) on stdout. # Interactive via /dev/tty (prompts/pull progress go to /dev/tty, never stdout). # Non-interactive: uses $CHAT_MODEL if set, else skips. Always returns 0. choose_chat_model() { # Guard: must be able to open /dev/tty for both read and write; stat flags # alone are not sufficient (the device may exist but have no controlling tty). if ! { true > /dev/tty; } 2>/dev/null || ! { true < /dev/tty; } 2>/dev/null; then echo "${CHAT_MODEL:-}" return 0 fi local installed opts=() o n=1 default_n=1 otype val installed="$(ollama list 2>/dev/null | awk 'NR>1{print $1}' || true)" mapfile -t opts < <(build_chat_options "$installed") { printf '\nChoose a chat model for the Ask view:\n\n' for o in "${opts[@]}"; do otype="${o%%:*}"; val="${o#*:}" [[ "$val" == "qwen2.5:7b" ]] && default_n="$n" case "$otype" in installed) printf ' %d) %-14s (installed)\n' "$n" "$val" ;; pull) printf ' %d) %-14s %s\n' "$n" "$val" "$(chat_size_hint "$val")" ;; custom) printf ' %d) custom model name\n' "$n" ;; skip) printf ' %d) skip (set up later in Settings)\n' "$n" ;; esac n=$((n+1)) done printf '\nChoice [%d]: ' "$default_n" } > /dev/tty local choice=""; read -r choice < /dev/tty || choice="" [[ -z "$choice" ]] && choice="$default_n" if ! [[ "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#opts[@]} )); then echo ""; return 0 fi o="${opts[choice-1]}"; otype="${o%%:*}"; val="${o#*:}" case "$otype" in installed|pull) echo "$val" ;; custom) local name=""; printf 'Model name: ' > /dev/tty; read -r name < /dev/tty || name="" echo "$name" ;; *) echo "" ;; # skip / unknown esac return 0 } # do_chat_model -> pick a chat model, pull it, persist config. All failures soft. do_chat_model() { local model; model="$(choose_chat_model)" if [[ -z "$model" ]]; then "$BIN_DIR/rewindos-daemon" configure-ai --disable-chat >/dev/null 2>&1 || true log "No chat model selected — the Ask view stays off." log "Set one later in Settings → AI, or rerun: install.sh --with-semantic --chat-model=" return 0 fi log "Pulling chat model $model (this can be several GB)..." if ! ollama pull "$model"; then warn "Chat model pull failed — Ask stays off. Retry later: ollama pull $model" "$BIN_DIR/rewindos-daemon" configure-ai --disable-chat >/dev/null 2>&1 || true return 0 fi if "$BIN_DIR/rewindos-daemon" configure-ai --chat-model="$model" >/dev/null 2>&1; then log "Ask view enabled with $model." else warn "Pulled $model but could not write chat config — set it in Settings → AI." fi } # ---- side-effecting helpers ---- run_pkg_install() { # run_pkg_install local pm="$1"; shift case "$pm" in apt) sudo apt-get update -y && sudo apt-get install -y "$@" ;; dnf) sudo dnf install -y "$@" ;; pacman) sudo pacman -S --needed --noconfirm "$@" ;; *) return 1 ;; esac } install_deps() { # install_deps local pm="$1" desktop="$2" pkgs pkgs="$(runtime_deps "$pm" "$desktop")" if [[ "$pm" == "unknown" || -z "$pkgs" ]]; then warn "Unsupported distro — install these packages manually, then re-run:" warn " tesseract, webkit2gtk-4.1, libayatana-appindicator, pipewire, xdg-desktop-portal + your desktop's backend" return 0 fi if ! command -v sudo >/dev/null; then warn "sudo not found — install these packages manually: $pkgs" return 0 fi log "Installing system dependencies: $pkgs" # shellcheck disable=SC2086 run_pkg_install "$pm" $pkgs || warn "Some dependencies failed to install; continuing." } # latest_asset_url -> prints " " for the latest release latest_asset_url() { local api="https://api.github.com/repos/$REPO/releases/latest" json json="$(curl -fsSL "$api")" || return 1 local tag tarball sha tag="$(printf '%s' "$json" | grep -oE '"tag_name":\s*"[^"]+"' | head -1 | sed -E 's/.*"([^"]+)"$/\1/')" tarball="$(printf '%s' "$json" | grep -oE "https://[^\"]*$ASSET" | head -1)" sha="$(printf '%s' "$json" | grep -oE "https://[^\"]*$ASSET.sha256" | head -1)" [[ -n "$tag" && -n "$tarball" && -n "$sha" ]] || return 1 printf '%s %s %s\n' "$tag" "$tarball" "$sha" } # download_and_extract -> echoes the resolved tag; extracts tarball into staging download_and_extract() { local stage="$1" tag tarball sha read -r tag tarball sha < <(latest_asset_url) || die "Could not find a release asset. Manual download: https://github.com/$REPO/releases/latest" log "Downloading RewindOS $tag..." curl -fsSL "$tarball" -o "$stage/$ASSET" || die "Download failed." curl -fsSL "$sha" -o "$stage/$ASSET.sha256" || die "Checksum download failed." local expected; expected="$(awk '{print $1}' "$stage/$ASSET.sha256")" verify_sha256 "$stage/$ASSET" "$expected" || die "Checksum mismatch — refusing to install." tar -C "$stage" -xzf "$stage/$ASSET" || die "Extraction failed." echo "$tag" } # place_files place_files() { local src="$1" mkdir -p "$BIN_DIR" "$APP_DIR" "$UNIT_DIR" "$AUTOSTART_DIR" "$DATA_DIR" install -m755 "$src/rewindos" "$BIN_DIR/rewindos" install -m755 "$src/rewindos-daemon" "$BIN_DIR/rewindos-daemon" # Stash the PaddleOCR worker (NOT activated unless the user opts in via --with-paddleocr) mkdir -p "$(dirname "$BIN_DIR")/share/rewindos" install -m644 "$src/paddleocr_worker.py" "$(dirname "$BIN_DIR")/share/rewindos/paddleocr_worker.py" # systemd unit (uses %h, no rewrite needed) install -m644 "$src/rewindos-daemon.service" "$UNIT_DIR/rewindos-daemon.service" # app launcher — rewrite Exec to absolute path sed "s|^Exec=.*|Exec=$BIN_DIR/rewindos --minimized|" "$src/rewindos.desktop" \ > "$APP_DIR/$APP_ID.desktop" # daemon desktop file — REQUIRED for KDE KWin ScreenShot2 authorization sed "s|^Exec=.*|Exec=$BIN_DIR/rewindos-daemon|" "$src/com.rewindos.Daemon.desktop" \ > "$APP_DIR/com.rewindos.Daemon.desktop" # autostart (UI minimized in tray on login) sed "s|^Exec=.*|Exec=$BIN_DIR/rewindos --minimized|" "$src/rewindos.desktop" \ > "$AUTOSTART_DIR/rewindos.desktop" # icons local sizes=(32x32 128x128 256x256 512x512) local srcs=(32x32.png 128x128.png 128x128@2x.png icon.png) local i for i in "${!sizes[@]}"; do mkdir -p "$ICON_BASE/${sizes[$i]}/apps" install -m644 "$src/icons/${srcs[$i]}" "$ICON_BASE/${sizes[$i]}/apps/$APP_ID.png" done update-desktop-database "$APP_DIR" 2>/dev/null || true gtk-update-icon-cache "$ICON_BASE" 2>/dev/null || true } smoke_check() { # The single-binary glibc/webkit floor means a too-old distro fails to exec. if ! "$BIN_DIR/rewindos-daemon" --version >/dev/null 2>&1; then die "The prebuilt binary won't run on this system (likely too old — needs a newer glibc / webkit2gtk-4.1). See https://github.com/$REPO#building-from-source to build locally." fi } enable_service() { systemctl --user daemon-reload if ! systemctl --user enable --now rewindos-daemon.service 2>/dev/null; then warn "Could not enable the systemd user service automatically. Enable it with:" warn " systemctl --user enable --now rewindos-daemon.service" fi } do_install() { # do_install local with_paddle="${1:-0}" with_semantic="${2:-0}" [[ "$(uname -m)" == "x86_64" ]] || die "Unsupported architecture $(uname -m); x86_64 only for now." install_deps "$(detect_pkg_mgr)" "${XDG_CURRENT_DESKTOP:-}" local stage tag; STAGING_DIR="$(mktemp -d)"; stage="$STAGING_DIR" tag="$(download_and_extract "$stage")" place_files "$stage/rewindos-linux-x86_64" smoke_check echo "$tag" > "$VERSION_FILE" enable_service if [[ "$with_paddle" == "1" ]]; then do_paddleocr else maybe_prompt_paddleocr fi if [[ "$with_semantic" == "1" ]]; then do_semantic else maybe_prompt_semantic fi log "RewindOS $tag is installed and capturing." log "Open it from your app launcher, or run: $BIN_DIR/rewindos" log "(If 'rewindos' isn't found, add ~/.local/bin to your PATH.)" log "On GNOME, install the 'Window Calls Extended' extension for app/window tracking." } python_pkgs_for() { # python_pkgs_for case "$1" in apt) echo "python3 python3-pip" ;; dnf) echo "python3 python3-pip" ;; pacman) echo "python python-pip" ;; *) echo "" ;; esac } do_paddleocr() { local pm; pm="$(detect_pkg_mgr)" local pypkgs; pypkgs="$(python_pkgs_for "$pm")" log "Setting up PaddleOCR (downloads several hundred MB of Python deps)..." if [[ -n "$pypkgs" ]]; then # shellcheck disable=SC2086 run_pkg_install "$pm" $pypkgs || { warn "Could not install $pypkgs; staying on Tesseract."; return 0; } fi if ! python3 -m pip install --user paddleocr paddlepaddle; then warn "pip install of paddleocr/paddlepaddle failed — staying on Tesseract (no config change)." return 0 fi # Get the worker into ~/.rewindos (where find_worker_script() looks first). # place_files stashed it under ~/.local/share/rewindos; if that's # missing, fall back to fetching it from the repo. It is only activated here, # on explicit opt-in — a default Tesseract install never places it. if [[ -f "$DATA_DIR/paddleocr_worker.py" ]]; then : # already present elif [[ -f "$(dirname "$BIN_DIR")/share/rewindos/paddleocr_worker.py" ]]; then install -m644 "$(dirname "$BIN_DIR")/share/rewindos/paddleocr_worker.py" "$DATA_DIR/paddleocr_worker.py" else curl -fsSL "https://raw.githubusercontent.com/$REPO/master/scripts/paddleocr_worker.py" \ -o "$DATA_DIR/paddleocr_worker.py" || { warn "Could not fetch the worker script — staying on Tesseract."; return 0; } fi set_config_engine "$DATA_DIR/config.toml" paddleocr systemctl --user restart rewindos-daemon.service 2>/dev/null || true log "PaddleOCR enabled." } maybe_prompt_paddleocr() { if prompt_yes_no "Enable higher-accuracy PaddleOCR? Downloads ~hundreds of MB of Python deps."; then do_paddleocr fi } # ollama_reachable -> 0 if the Ollama API answers at $OLLAMA_HOST ollama_reachable() { curl -fsS --max-time 3 "$OLLAMA_HOST/api/tags" >/dev/null 2>&1 } # Set up semantic search: ensure Ollama is present + running, pull the embedding # model (visible progress), then nudge the daemon to re-probe and enable it. # Every failure is soft — semantic search is optional, so we warn and move on. do_semantic() { log "Setting up semantic search (Ollama + embedding model)..." if ! command -v ollama >/dev/null 2>&1 && ! ollama_reachable; then log "Installing the Ollama runtime via its official installer..." if ! curl -fsSL https://ollama.com/install.sh | sh; then warn "Ollama install failed — semantic search stays off." warn "Install Ollama yourself, then re-run: install.sh --with-semantic" return 0 fi fi # The official installer starts ollama.service; give the API a moment to bind. local tries=0 until ollama_reachable || (( tries++ >= 10 )); do sleep 1; done if ! ollama_reachable; then warn "Ollama is installed but not reachable at $OLLAMA_HOST." warn "Start it (e.g. 'systemctl --user start ollama' or 'ollama serve'), then run: ollama pull $EMBED_MODEL" return 0 fi log "Pulling embedding model $EMBED_MODEL (~274 MB, one time)..." if ! ollama pull "$EMBED_MODEL"; then warn "Model pull failed — semantic search stays off. Retry later: ollama pull $EMBED_MODEL" return 0 fi # Persist semantic=on so the Settings UI reflects it (daemon probes regardless). "$BIN_DIR/rewindos-daemon" configure-ai --enable-semantic >/dev/null 2>&1 || true # Offer a chat model so the Ask view works too. do_chat_model # Daemon re-reads config + auto-detects Ollama on restart. systemctl --user restart rewindos-daemon.service 2>/dev/null || true log "Semantic search enabled — the daemon will embed your history in the background." } maybe_prompt_semantic() { # Already running Ollama? Then semantic search is free to turn on — skip the # prompt, just make sure the model is present (matches the daemon's own # auto-enable-when-reachable behavior). if ollama_reachable; then log "Ollama detected at $OLLAMA_HOST — enabling semantic search." do_semantic return 0 fi if prompt_yes_no "Enable semantic search? Installs Ollama + a ~274 MB model for meaning-based search."; then do_semantic fi } # prompt_yes_no -> 0 if yes. Reads /dev/tty so it works under curl|bash. # Non-interactive (no tty) -> default No. prompt_yes_no() { local q="$1" ans="" if [[ -r /dev/tty ]]; then printf '%s [y/N] ' "$q" > /dev/tty read -r ans < /dev/tty || ans="" else ans="" # non-interactive: safe default (No) fi [[ "$ans" == "y" || "$ans" == "Y" ]] } installed_version() { if [[ -x "$BIN_DIR/rewindos-daemon" ]]; then "$BIN_DIR/rewindos-daemon" --version 2>/dev/null | awk '{print $NF}' elif [[ -f "$VERSION_FILE" ]]; then cat "$VERSION_FILE" fi } do_update() { [[ -x "$BIN_DIR/rewindos-daemon" ]] || die "RewindOS is not installed. Run install.sh first." local cur latest; cur="$(installed_version)" read -r latest _ < <(latest_asset_url) || die "Could not reach the releases API." if [[ -n "$cur" ]] && ! version_gt "$latest" "$cur"; then log "Already up to date ($cur)." return 0 fi log "Updating ${cur:-?} -> $latest..." local stage tag; STAGING_DIR="$(mktemp -d)"; stage="$STAGING_DIR" tag="$(download_and_extract "$stage")" place_files "$stage/rewindos-linux-x86_64" smoke_check echo "$tag" > "$VERSION_FILE" systemctl --user restart rewindos-daemon.service 2>/dev/null || true log "Updated to $tag." } do_uninstall() { systemctl --user disable --now rewindos-daemon.service 2>/dev/null || true # Our install artifacts (always removed; NOT the user's captured data) rm -f "$BIN_DIR/rewindos" "$BIN_DIR/rewindos-daemon" rm -f "$UNIT_DIR/rewindos-daemon.service" rm -f "$APP_DIR/$APP_ID.desktop" "$APP_DIR/com.rewindos.Daemon.desktop" rm -f "$AUTOSTART_DIR/rewindos.desktop" rm -f "$DATA_DIR/paddleocr_worker.py" "$VERSION_FILE" rm -rf "$(dirname "$BIN_DIR")/share/rewindos" local s for s in 32x32 128x128 256x256 512x512; do rm -f "$ICON_BASE/$s/apps/$APP_ID.png" done systemctl --user daemon-reload 2>/dev/null || true update-desktop-database "$APP_DIR" 2>/dev/null || true gtk-update-icon-cache "$ICON_BASE" 2>/dev/null || true log "RewindOS removed." # The user's captured data — never wiped without explicit interactive consent. if [[ -d "$DATA_DIR" ]] && prompt_yes_no "Also delete your captured data in $DATA_DIR (screenshots + database)?"; then rm -rf "$DATA_DIR" log "Captured data deleted." else log "Captured data kept at $DATA_DIR." fi } print_help() { cat < with --with-semantic: select this chat model non-interactively install.sh --update update to the latest release install.sh --uninstall remove RewindOS (prompts before deleting your data) install.sh --help this help EOF } main() { local mode="install" with_paddle=0 with_semantic=0 arg for arg in "$@"; do case "$arg" in --update) mode="update" ;; --uninstall) mode="uninstall" ;; --with-paddleocr) with_paddle=1 ;; --with-semantic) with_semantic=1 ;; --help|-h) mode="help" ;; --chat-model=*) CHAT_MODEL="${arg#*=}" ;; *) die "Unknown option: $arg (try --help)" ;; esac done case "$mode" in install) do_install "$with_paddle" "$with_semantic" ;; update) do_update ;; uninstall) do_uninstall ;; help) print_help ;; esac } # Run when executed as a file (BASH_SOURCE[0] == $0) OR piped via stdin # (curl|bash — BASH_SOURCE is empty), but NOT when sourced by tests (where # BASH_SOURCE[0] is this file's path and differs from $0). The ":-" defaults # keep this safe under `set -u`, which is what broke the naked ${BASH_SOURCE[0]}. if [[ "${BASH_SOURCE[0]:-}" == "${0}" || -z "${BASH_SOURCE[0]:-}" ]]; then main "$@" fi