#!/usr/bin/env bash set -euo pipefail MODEL_REVISION="541d1f99c6b0c3cd0b11a95167540bb8edefd82b" MODEL_SHA256="3cbdc85877e668ca7b82d0d56770eb1fac76691f55d6b97545e8d61ca588d10d" MODEL_FILENAME="parakeet-tdt-0.6b-v3.nemo" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPOSITORY_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" # Keep the install roots deliberately fixed below $HOME. In particular, do not # let an environment variable turn an uninstall into rm -rf /. INSTALL_ROOT="$HOME/.local/share/pi-dictate" CONFIG_ROOT="$HOME/.config/pi-dictate" CREDENTIAL_DIR="$CONFIG_ROOT/credentials" CONFIG_PATH="$CONFIG_ROOT/config.json" PROFILE_MARKER="$INSTALL_ROOT/.dependency-profile" UNIT_DIR="$HOME/.config/systemd/user" DROPIN_DIR="$UNIT_DIR/pi-dictate.service.d" DROPIN_PATH="$DROPIN_DIR/10-pi-dictate-network.conf" MODEL_DIR="$INSTALL_ROOT/models/parakeet-tdt-0.6b-v3" MODEL_PATH="$MODEL_DIR/$MODEL_FILENAME" DICTIONARY_SOURCE="$SCRIPT_DIR/coding-terms.txt" DICTIONARY_PATH="$INSTALL_ROOT/config/coding-terms.txt" DRY_RUN=false MODEL_SOURCE="" MODEL_SHA256_OVERRIDE="" API_KEY_FILE="" BACKEND="" PURGE_CONFIG=false ACTION=install usage() { cat <<'EOF' Usage: install.sh install [--backend local|meta|openai] [--model-source PATH] [--model-sha256 SHA256] [--api-key-file PATH] [--dry-run] install.sh configure --backend meta|openai --api-key-file PATH [--dry-run] install.sh configure --backend local [--model-source PATH --model-sha256 SHA256] [--dry-run] install.sh check install.sh uninstall [--purge-config] [--dry-run] The default install backend is local. A cloud key must be supplied by path; the key contents are never accepted as an argument. Cloud installs do not provision the local model or NeMo/CUDA dependencies. EOF } run() { printf '+ ' printf '%q ' "$@" printf '\n' if ! $DRY_RUN; then "$@" fi } is_legacy_service_unit() { local path="$1" [[ -f "$path" && ! -L "$path" ]] || return 1 LEGACY_UNIT_PATH="$path" python3 - <<'PY' import hashlib import os import re from pathlib import Path raw = Path(os.environ["LEGACY_UNIT_PATH"]).read_bytes() normalized, count = re.subn( rb"^Environment=PI_DICTATE_IDLE_SECONDS=[0-9]+$", b"Environment=PI_DICTATE_IDLE_SECONDS=900", raw, count=1, flags=re.MULTILINE, ) if count != 1: raise SystemExit(1) idle_match = re.search(rb"^Environment=PI_DICTATE_IDLE_SECONDS=([0-9]+)$", raw, re.MULTILINE) if idle_match is None or not 60 <= int(idle_match.group(1)) <= 86400: raise SystemExit(1) # Exact previous checked-in unit, allowing only its documented idle override. expected = "eca5297563ae5e337b0b28a036218cee6289e5bbaaec89ddda0c762bd05dfd62" raise SystemExit(0 if hashlib.sha256(normalized).hexdigest() == expected else 1) PY } read_legacy_idle_seconds() { local path="$1" is_legacy_service_unit "$path" || return 1 grep '^Environment=PI_DICTATE_IDLE_SECONDS=' "$path" | cut -d= -f3 } is_owned_unit() { local destination="$1" local source="$2" if [[ -L "$destination" ]]; then [[ "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]] else # Recognize the exact current unit or the exact previous service template. # A copied marker alone is never ownership proof. [[ -f "$destination" ]] && { cmp -s "$destination" "$source" || { [[ "$(basename -- "$source")" == pi-dictate.service ]] && is_legacy_service_unit "$destination"; } } fi } install_owned_unit() { local source="$1" local destination="$2" if [[ -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]; then run rm "$destination" elif [[ -L "$destination" || -e "$destination" ]]; then if ! is_owned_unit "$destination" "$source"; then printf 'Refusing to replace unmanaged or modified systemd unit: %s\n' "$destination" >&2 return 1 fi fi run install -m 0644 "$source" "$destination" } remove_owned_unit() { local destination="$1" local source="${2:-}" if [[ -n "$source" && -L "$destination" && "$(readlink -f -- "$destination")" == "$(readlink -f -- "$source")" ]]; then run rm "$destination" elif [[ -n "$source" && ! -L "$destination" && -f "$destination" ]] && is_owned_unit "$destination" "$source"; then run rm "$destination" elif [[ -e "$destination" || -L "$destination" ]]; then printf 'Leaving unmanaged or modified unit in place: %s\n' "$destination" >&2 fi } fail() { printf '%s\n' "$*" >&2 exit 1 } valid_backend() { case "$1" in local|meta|openai) return 0 ;; *) return 1 ;; esac } while (($#)); do case "$1" in install|configure|check|uninstall) ACTION="$1" ;; --dry-run) DRY_RUN=true ;; --purge-config) PURGE_CONFIG=true ;; --backend) shift (($#)) || fail '--backend requires local, meta, or openai' BACKEND="$1" valid_backend "$BACKEND" || fail "Unsupported backend: $BACKEND" ;; --model-source) shift (($#)) || fail '--model-source requires a path' MODEL_SOURCE="$1" ;; --model-sha256) shift (($#)) || fail '--model-sha256 requires a SHA-256 digest' MODEL_SHA256_OVERRIDE="$1" ;; --api-key-file) shift (($#)) || fail '--api-key-file requires a path' API_KEY_FILE="$1" ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;; esac shift done if [[ "$ACTION" == check ]]; then [[ -z "$BACKEND" ]] || fail '--backend is not valid for check' [[ -z "$MODEL_SOURCE" ]] || fail '--model-source is only valid for install' [[ -z "$MODEL_SHA256_OVERRIDE" ]] || fail '--model-sha256 is only valid for install' [[ -z "$API_KEY_FILE" ]] || fail '--api-key-file is only valid for install or configure' [[ "$PURGE_CONFIG" == false ]] || fail '--purge-config is only valid for uninstall' elif [[ "$ACTION" == uninstall ]]; then [[ -z "$BACKEND" ]] || fail '--backend is not valid for uninstall' [[ -z "$API_KEY_FILE" ]] || fail '--api-key-file is not valid for uninstall' [[ -z "$MODEL_SHA256_OVERRIDE" ]] || fail '--model-sha256 is not valid for uninstall' elif [[ "$ACTION" == configure ]]; then [[ "$PURGE_CONFIG" == false ]] || fail '--purge-config is only valid for uninstall' else [[ "$PURGE_CONFIG" == false ]] || fail '--purge-config is only valid for uninstall' fi if [[ -n "$MODEL_SHA256_OVERRIDE" && ! "$MODEL_SHA256_OVERRIDE" =~ ^[[:xdigit:]]{64}$ ]]; then fail '--model-sha256 must be exactly 64 hexadecimal characters' fi if [[ -n "$MODEL_SOURCE" && -z "$MODEL_SHA256_OVERRIDE" ]]; then fail '--model-source requires --model-sha256 (custom archives must be explicitly verified)' fi # This parser is intentionally duplicated in the installer rather than relying # on jq or the service virtualenv. It makes check useful before provisioning a # virtualenv and means malformed configuration fails closed. validate_config() { local path="$1" CONFIG_TO_VALIDATE="$path" python3 - <<'PY' import json import math import os import re import stat from pathlib import Path path = Path(os.environ["CONFIG_TO_VALIDATE"]) fd = -1 try: fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: raise ValueError("configuration ownership, permissions, or file type is insecure") if info.st_size > 65536: raise ValueError("configuration is too large") raw = bytearray() while len(raw) <= 65536: chunk = os.read(fd, min(4096, 65537 - len(raw))) if not chunk: break raw.extend(chunk) if len(raw) > 65536: raise ValueError("configuration is too large") data = json.loads(bytes(raw).decode("utf-8")) except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: raise SystemExit(f"Invalid pi-dictate configuration: {exc}") finally: if fd >= 0: os.close(fd) if not isinstance(data, dict): raise SystemExit("Invalid pi-dictate configuration: root must be an object") if set(data) - {"version", "backend", "idleSeconds", "local", "meta", "openai"}: raise SystemExit("Invalid pi-dictate configuration: unknown root key") version = data.get("version") if isinstance(version, bool) or version != 1: raise SystemExit("Invalid pi-dictate configuration: unsupported version") backend = data.get("backend") if not isinstance(backend, str) or backend not in {"local", "meta", "openai"}: raise SystemExit("Invalid pi-dictate configuration: unsupported backend") idle = data.get("idleSeconds", 900) if isinstance(idle, bool) or not isinstance(idle, int) or not 60 <= idle <= 86400: raise SystemExit("Invalid pi-dictate configuration: idleSeconds must be an integer in bounds") schemas = { "local": {"adapter", "modelPath", "modelSha256", "termsPath", "boostAlpha"}, "meta": {"model", "apiKeyFile", "termsPath", "languageBias"}, "openai": {"model", "apiKeyFile", "prompt", "termsPath", "languages", "delay"}, } section = data.get(backend) if not isinstance(section, dict): raise SystemExit("Invalid pi-dictate configuration: selected backend section is required") if set(section) - schemas[backend]: raise SystemExit("Invalid pi-dictate configuration: unknown selected-backend key") if backend == "local": if section.get("adapter") != "nemo-parakeet": raise SystemExit("Invalid pi-dictate configuration: unsupported local adapter") if not isinstance(section.get("modelPath"), str) or not section["modelPath"] or not (section["modelPath"].startswith("/") or section["modelPath"].startswith("~/")) or "\x00" in section["modelPath"] or "\n" in section["modelPath"] or "\r" in section["modelPath"] or not section["modelPath"].endswith(".nemo"): raise SystemExit("Invalid pi-dictate configuration: modelPath must name an absolute .nemo archive") if not isinstance(section.get("modelSha256"), str) or not re.fullmatch(r"[0-9a-fA-F]{64}", section["modelSha256"]): raise SystemExit("Invalid pi-dictate configuration: modelSha256 is required") if not isinstance(section.get("termsPath"), str) or not section["termsPath"] or not (section["termsPath"].startswith("/") or section["termsPath"].startswith("~/")) or "\x00" in section["termsPath"] or "\n" in section["termsPath"] or "\r" in section["termsPath"]: raise SystemExit("Invalid pi-dictate configuration: termsPath must be absolute") boost = section.get("boostAlpha") if isinstance(boost, bool) or not isinstance(boost, (int, float)) or not 0 < boost <= 10: raise SystemExit("Invalid pi-dictate configuration: boostAlpha out of bounds") else: expected_model = "muse-voice-transcribe-1.0" if backend == "meta" else None if backend == "meta" and section.get("model") != expected_model: raise SystemExit("Invalid pi-dictate configuration: unsupported Meta model") if backend == "openai" and (not isinstance(section.get("model"), str) or not section["model"] or any(control in section["model"] for control in ("\x00", "\n", "\r")) or len(section["model"].encode("utf-8")) > 128 or section["model"] == "whisper-1"): raise SystemExit("Invalid pi-dictate configuration: unsupported OpenAI model") expected_key = f"~/.config/pi-dictate/credentials/{backend}-api-key" if section.get("apiKeyFile") != expected_key: raise SystemExit("Invalid pi-dictate configuration: credential path must use the managed directory") if not isinstance(section.get("termsPath"), str) or not section["termsPath"] or not (section["termsPath"].startswith("/") or section["termsPath"].startswith("~/")) or "\x00" in section["termsPath"] or "\n" in section["termsPath"] or "\r" in section["termsPath"]: raise SystemExit("Invalid pi-dictate configuration: termsPath must be absolute") if backend == "meta": languages = section.get("languageBias", []) if not isinstance(languages, list) or len(languages) > 16 or any(not isinstance(item, str) or not item or len(item.encode("utf-8")) > 32 or any(control in item for control in ("\x00", "\n", "\r")) for item in languages): raise SystemExit("Invalid pi-dictate configuration: invalid Meta languageBias") if backend == "openai": delay = section.get("delay") if delay is not None and (not isinstance(delay, str) or delay not in {"minimal", "low", "medium", "high", "xhigh"}): raise SystemExit("Invalid pi-dictate configuration: invalid OpenAI delay") prompt = section.get("prompt") if prompt is not None and (not isinstance(prompt, str) or not prompt or any(control in prompt for control in ("\x00", "\n", "\r")) or len(prompt.encode("utf-8")) > 4096): raise SystemExit("Invalid pi-dictate configuration: invalid OpenAI prompt") languages = section.get("languages", []) if not isinstance(languages, list) or ("languages" in section and not languages) or len(languages) > 16 or any(not isinstance(item, str) or not item or len(item.encode("utf-8")) > 32 or any(control in item for control in ("\x00", "\n", "\r")) for item in languages): raise SystemExit("Invalid pi-dictate configuration: invalid OpenAI languages") PY } read_config_backend() { CONFIG_TO_READ="$CONFIG_PATH" python3 - <<'PY' import json import os import stat from pathlib import Path path = Path(os.environ["CONFIG_TO_READ"]) fd = -1 try: fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 65536: raise ValueError raw = os.read(fd, 65537) if len(raw) > 65536: raise ValueError data = json.loads(raw.decode("utf-8")) backend = data["backend"] except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError, ValueError): raise SystemExit("Cannot determine backend from invalid pi-dictate configuration") finally: if fd >= 0: os.close(fd) if backend not in {"local", "meta", "openai"}: raise SystemExit("Cannot determine backend from invalid pi-dictate configuration") print(backend) PY } read_config_model_sha() { CONFIG_TO_READ="$CONFIG_PATH" python3 - <<'PY' import json import os import stat from pathlib import Path fd = -1 try: path = Path(os.environ["CONFIG_TO_READ"]) fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 65536: raise ValueError raw = os.read(fd, 65537) if len(raw) > 65536: raise ValueError data = json.loads(raw.decode("utf-8")) value = data.get("local", {}).get("modelSha256", "") except (OSError, UnicodeError, json.JSONDecodeError, AttributeError, ValueError): value = "" finally: if fd >= 0: os.close(fd) print(value if isinstance(value, str) else "") PY } read_config_model_path() { CONFIG_TO_READ="$CONFIG_PATH" python3 - <<'PY' import json import os import stat from pathlib import Path fd = -1 try: path = Path(os.environ["CONFIG_TO_READ"]) fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 or info.st_size > 65536: raise ValueError raw = os.read(fd, 65537) if len(raw) > 65536: raise ValueError data = json.loads(raw.decode("utf-8")) value = data.get("local", {}).get("modelPath", "") except (OSError, UnicodeError, json.JSONDecodeError, AttributeError, ValueError): value = "" finally: if fd >= 0: os.close(fd) if not value: # A cloud configuration has no local section. Switching explicitly to # local should then use the pinned default rather than a stale value. print("") elif not isinstance(value, str) or "\n" in value or "\r" in value: raise SystemExit("Invalid local model path") else: print(Path(value).expanduser()) PY } validate_credential() { local path="$1" CREDENTIAL_TO_VALIDATE="$path" python3 - <<'PY' import os import stat from pathlib import Path path = Path(os.environ["CREDENTIAL_TO_VALIDATE"]) fd = -1 try: fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077: raise ValueError("credential ownership, permissions, or file type is insecure") if stat.S_IMODE(info.st_mode) != 0o600: raise ValueError("credential ownership or permissions are insecure") if info.st_size > 4096: raise ValueError("credential is too large") raw = bytearray() while len(raw) <= 4096: chunk = os.read(fd, min(4096, 4097 - len(raw))) if not chunk: break raw.extend(chunk) if len(raw) > 4096: raise ValueError("credential is too large") value = bytes(raw).decode("utf-8") except (OSError, UnicodeError, ValueError) as exc: raise SystemExit(f"Invalid pi-dictate credential: {exc}") finally: if fd >= 0: os.close(fd) if value.endswith("\n"): value = value[:-1] if not value or "\n" in value or "\r" in value or "\x00" in value: raise SystemExit("Invalid pi-dictate credential: empty or multi-line value") PY } copy_credential() { local source="$1" local destination="$2" [[ -f "$source" && ! -L "$source" ]] || fail "API key source is not a regular file: $source" if ! $DRY_RUN; then # Normalize one trailing newline while copying. No key value is passed in # argv, inherited environment, or output. CREDENTIAL_SOURCE="$source" CREDENTIAL_DESTINATION="$destination" python3 - <<'PY' import os import stat import tempfile from pathlib import Path source = Path(os.environ["CREDENTIAL_SOURCE"]) destination = Path(os.environ["CREDENTIAL_DESTINATION"]) fd = -1 try: flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0) fd = os.open(source, flags) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode): raise ValueError("source is not a regular file") if info.st_size > 4096: raise ValueError("source is too large") with os.fdopen(fd, "rb") as stream: fd = -1 raw = stream.read(4097) if len(raw) > 4096: raise ValueError("source is too large") value = raw.decode("utf-8") except (OSError, UnicodeError, ValueError) as exc: if fd >= 0: os.close(fd) raise SystemExit(f"Invalid API key source: {exc}") if value.endswith("\n"): value = value[:-1] if not value or "\n" in value or "\r" in value or "\x00" in value: raise SystemExit("Invalid API key source: empty or multi-line value") fd, temporary_name = tempfile.mkstemp(prefix=f".{destination.name}.install.", dir=destination.parent) tmp = Path(temporary_name) try: with os.fdopen(fd, "w", encoding="utf-8") as stream: stream.write(value) stream.flush() os.fsync(stream.fileno()) os.chmod(tmp, 0o600) os.replace(tmp, destination) except BaseException: tmp.unlink(missing_ok=True) raise PY else printf '+ copy API key from %q to %q (mode 0600; value redacted)\n' "$source" "$destination" fi } write_config() { if $DRY_RUN; then printf '+ atomically write %q for backend %q (mode 0600; credentials redacted)\n' "$CONFIG_PATH" "$BACKEND" return fi CONFIG_TO_WRITE="$CONFIG_PATH" CONFIG_BACKEND="$BACKEND" CONFIG_MODEL_SHA256="$EFFECTIVE_MODEL_SHA256" \ CONFIG_MODEL_PATH="$MODEL_PATH" python3 - <<'PY' import json import os import tempfile from pathlib import Path path = Path(os.environ["CONFIG_TO_WRITE"]) backend = os.environ["CONFIG_BACKEND"] model_sha = os.environ["CONFIG_MODEL_SHA256"] old = {} if path.exists(): try: old = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise SystemExit(f"Refusing to replace invalid pi-dictate configuration: {exc}") if not isinstance(old, dict): raise SystemExit("Refusing to replace invalid pi-dictate configuration") section = old.get(backend, {}) if not isinstance(section, dict): section = {} legacy_idle = os.environ.get("CONFIG_LEGACY_IDLE_SECONDS", "") config = { "version": 1, "backend": backend, "idleSeconds": old.get("idleSeconds", int(legacy_idle) if legacy_idle else 900), } # Keep inactive provider settings so switching back does not discard a # deliberately configured model, prompt, or terms path. Only the selected # section is validated and consumed by the service. for inactive in ("local", "meta", "openai"): if inactive != backend and isinstance(old.get(inactive), dict): config[inactive] = old[inactive] if backend == "local": section = { "adapter": section.get("adapter", "nemo-parakeet"), "modelPath": section.get("modelPath", "~/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo"), "modelSha256": section.get("modelSha256", model_sha), "termsPath": section.get("termsPath", "~/.local/share/pi-dictate/config/coding-terms.txt"), "boostAlpha": section.get("boostAlpha", 1.0), } # A replaced archive stays at the selected destination. Preserve custom # paths rather than rewriting them to the managed default. if os.environ.get("CONFIG_MODEL_REPLACED") == "1": selected_model = Path(os.environ["CONFIG_MODEL_PATH"]) managed_model = Path("~/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo").expanduser() section["modelPath"] = ( "~/.local/share/pi-dictate/models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3.nemo" if selected_model == managed_model else str(selected_model) ) section["modelSha256"] = model_sha else: if backend == "meta": section = { "model": section.get("model", "muse-voice-transcribe-1.0"), "apiKeyFile": "~/.config/pi-dictate/credentials/meta-api-key", "termsPath": section.get("termsPath", "~/.local/share/pi-dictate/config/coding-terms.txt"), "languageBias": section.get("languageBias", []), } else: delay = section.get("delay") section = { "model": section.get("model", "gpt-live-transcribe"), "apiKeyFile": "~/.config/pi-dictate/credentials/openai-api-key", "prompt": section.get("prompt", "Coding dictation"), "termsPath": section.get("termsPath", "~/.local/share/pi-dictate/config/coding-terms.txt"), "languages": section.get("languages", ["en"]), } if delay is not None: section["delay"] = delay config[backend] = section path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.install.", dir=path.parent) tmp = Path(temporary_name) try: with os.fdopen(fd, "w", encoding="utf-8") as stream: stream.write(json.dumps(config, indent=2, sort_keys=True) + "\n") stream.flush() os.fsync(stream.fileno()) os.chmod(tmp, 0o600) os.replace(tmp, path) except BaseException: tmp.unlink(missing_ok=True) raise PY } write_profile_marker() { if $DRY_RUN; then printf '+ atomically write dependency profile %q (%q; mode 0600)\n' "$PROFILE_MARKER" "$DEPENDENCY_PROFILE" return fi PROFILE_MARKER_VALUE="$DEPENDENCY_PROFILE" PROFILE_MARKER_PATH="$PROFILE_MARKER" python3 - <<'PY' import os import tempfile from pathlib import Path path = Path(os.environ["PROFILE_MARKER_PATH"]) path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.install.", dir=path.parent) tmp = Path(temporary_name) try: with os.fdopen(fd, "w", encoding="ascii") as stream: stream.write(os.environ["PROFILE_MARKER_VALUE"] + "\n") stream.flush() os.fsync(stream.fileno()) os.chmod(tmp, 0o600) os.replace(tmp, path) except BaseException: tmp.unlink(missing_ok=True) raise PY } reject_symlink_ancestry() { local path="$1" current="$1" info while [[ "$current" != "/" ]]; do if [[ -L "$current" ]]; then fail "Refusing symlinked path ancestry: $path" fi current="$(dirname -- "$current")" done } render_config_bind_paths() { local backend="$1" NETWORK_DROPIN_BACKEND="$backend" NETWORK_DROPIN_CONFIG_PATH="$CONFIG_PATH" python3 - <<'PY' import json import os from pathlib import Path data = json.loads(Path(os.environ["NETWORK_DROPIN_CONFIG_PATH"]).read_text(encoding="utf-8")) backend = os.environ["NETWORK_DROPIN_BACKEND"] section = data[backend] paths = [] if backend == "local": paths.append(section["modelPath"]) else: paths.append(section["apiKeyFile"]) paths.append(section["termsPath"]) for value in paths: path = str(Path(value).expanduser()).replace("%", "%%") print("BindReadOnlyPaths=" + json.dumps(path, ensure_ascii=False)) PY } write_network_dropin() { local backend="$1" local network_mode if [[ "$backend" == local ]]; then network_mode=offline else network_mode=cloud fi if $DRY_RUN; then printf '+ atomically generate %q (%s network hardening)\n' "$DROPIN_PATH" "$network_mode" return fi reject_symlink_ancestry "$DROPIN_DIR" mkdir -p "$DROPIN_DIR" chmod 0700 "$DROPIN_DIR" if [[ -e "$DROPIN_PATH" || -L "$DROPIN_PATH" ]]; then if [[ -L "$DROPIN_PATH" || ! -f "$DROPIN_PATH" ]] || ! grep -qxF '# Managed by pi-dictate install.sh' "$DROPIN_PATH"; then fail "Refusing to replace unmanaged systemd drop-in: $DROPIN_PATH" fi fi local temporary temporary="$(mktemp "$DROPIN_DIR/.10-pi-dictate-network.conf.install.XXXXXX")" { printf '%s\n' '# Managed by pi-dictate install.sh' printf '%s\n' '[Service]' printf 'Environment=PI_DICTATE_NETWORK_MODE=%s\n' "$network_mode" if [[ "$backend" == local ]]; then cat <<'EOF' Environment=HF_HUB_OFFLINE=1 Environment=TRANSFORMERS_OFFLINE=1 Environment=NEMO_HOME=/tmp/pi-dictate-nemo Environment=HF_HOME=/tmp/pi-dictate-huggingface Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba RestrictAddressFamilies=AF_UNIX IPAddressDeny=any EOF else cat <<'EOF' # Cloud backends need outbound DNS and TLS, but never a listening IP socket. Environment=HF_HUB_OFFLINE= Environment=TRANSFORMERS_OFFLINE= Environment=NEMO_HOME= Environment=HF_HOME= Environment=TRITON_CACHE_DIR= Environment=NUMBA_CACHE_DIR= Environment=PI_DICTATE_MODEL_PATH= Environment=PI_DICTATE_BOOST_ALPHA= RestrictAddressFamilies= RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 IPAddressDeny= EOF fi render_config_bind_paths "$backend" } > "$temporary" chmod 0644 "$temporary" mv -f "$temporary" "$DROPIN_PATH" } validate_network_dropin() { local backend="$1" reject_symlink_ancestry "$DROPIN_DIR" [[ -f "$DROPIN_PATH" && ! -L "$DROPIN_PATH" ]] || fail "Systemd network drop-in is not a regular managed file" NETWORK_DROPIN_BACKEND="$backend" NETWORK_DROPIN_PATH="$DROPIN_PATH" NETWORK_DROPIN_CONFIG_PATH="$CONFIG_PATH" python3 - <<'PY' import json import os from pathlib import Path backend = os.environ["NETWORK_DROPIN_BACKEND"] path = Path(os.environ["NETWORK_DROPIN_PATH"]) config = json.loads(Path(os.environ["NETWORK_DROPIN_CONFIG_PATH"]).read_text(encoding="utf-8")) section = config[backend] def bind_line(value: str) -> str: expanded = str(Path(value).expanduser()).replace("%", "%%") return "BindReadOnlyPaths=" + json.dumps(expanded, ensure_ascii=False) expected = [ "# Managed by pi-dictate install.sh", "[Service]", f"Environment=PI_DICTATE_NETWORK_MODE={'offline' if backend == 'local' else 'cloud'}", ] if backend == "local": expected.extend([ "Environment=HF_HUB_OFFLINE=1", "Environment=TRANSFORMERS_OFFLINE=1", "Environment=NEMO_HOME=/tmp/pi-dictate-nemo", "Environment=HF_HOME=/tmp/pi-dictate-huggingface", "Environment=TRITON_CACHE_DIR=/tmp/pi-dictate-triton", "Environment=NUMBA_CACHE_DIR=/tmp/pi-dictate-numba", "RestrictAddressFamilies=AF_UNIX", "IPAddressDeny=any", bind_line(section["modelPath"]), bind_line(section["termsPath"]), ]) else: expected.extend([ "# Cloud backends need outbound DNS and TLS, but never a listening IP socket.", "Environment=HF_HUB_OFFLINE=", "Environment=TRANSFORMERS_OFFLINE=", "Environment=NEMO_HOME=", "Environment=HF_HOME=", "Environment=TRITON_CACHE_DIR=", "Environment=NUMBA_CACHE_DIR=", "Environment=PI_DICTATE_MODEL_PATH=", "Environment=PI_DICTATE_BOOST_ALPHA=", "RestrictAddressFamilies=", "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6", "IPAddressDeny=", bind_line(section["apiKeyFile"]), bind_line(section["termsPath"]), ]) try: if path.is_symlink() or path.read_text(encoding="utf-8") != "\n".join(expected) + "\n": raise ValueError except (OSError, UnicodeError, ValueError): raise SystemExit("Systemd network drop-in is not the expected managed policy") PY } network_dropin_is_owned() { # The marker is not ownership proof. Accept only one of the exact policies # this installer writes, so a same-named tampered drop-in is never removed. validate_network_dropin local >/dev/null 2>&1 || validate_network_dropin meta >/dev/null 2>&1 || validate_network_dropin openai >/dev/null 2>&1 } remove_network_dropin() { if [[ -f "$DROPIN_PATH" ]] && network_dropin_is_owned; then run rm "$DROPIN_PATH" elif [[ -e "$DROPIN_PATH" || -L "$DROPIN_PATH" ]]; then fail "Refusing to remove unmanaged or modified systemd drop-in: $DROPIN_PATH" fi } check_credential_destination() { local backend="$1" local path="$CREDENTIAL_DIR/${backend}-api-key" [[ -e "$path" || -L "$path" ]] || fail "Missing credential for $backend: $path" validate_credential "$path" } check_installation() { reject_symlink_ancestry "$DROPIN_DIR" for pair in "$socket_link:$socket_source" "$service_link:$service_source"; do local destination="${pair%%:*}" local source="${pair#*:}" [[ -f "$destination" ]] || fail "Missing installed systemd unit: $destination" is_owned_unit "$destination" "$source" || fail "Unmanaged installed systemd unit: $destination" cmp -s "$destination" "$source" || fail "Modified installed systemd unit: $destination" done [[ -f "$CONFIG_PATH" && ! -L "$CONFIG_PATH" ]] || fail "Missing pi-dictate configuration: $CONFIG_PATH" [[ ! -L "$CONFIG_ROOT" && ! -L "$CREDENTIAL_DIR" ]] || fail "Configuration path contains a symlinked directory" CONFIG_TO_VALIDATE="$CONFIG_PATH" python3 - <<'PY' import os from pathlib import Path path = Path(os.environ["CONFIG_TO_VALIDATE"]) info = path.lstat() if info.st_uid != os.getuid() or info.st_mode & 0o777 != 0o600: raise SystemExit("Invalid pi-dictate configuration: ownership or permissions are insecure") PY validate_config "$CONFIG_PATH" local selected selected="$(read_config_backend)" if [[ "$selected" == local ]]; then MODEL_PATH="$(read_config_model_path)" fi case "$selected" in meta|openai) check_credential_destination "$selected" ;; esac [[ -f "$PROFILE_MARKER" && ! -L "$PROFILE_MARKER" ]] || fail "Missing dependency profile marker: $PROFILE_MARKER" PROFILE_MARKER_TO_VALIDATE="$PROFILE_MARKER" PROFILE_MARKER_BACKEND="$selected" python3 - <<'PY' import os from pathlib import Path path = Path(os.environ["PROFILE_MARKER_TO_VALIDATE"]) info = path.lstat() if info.st_uid != os.getuid() or info.st_mode & 0o777 != 0o600: raise SystemExit("Invalid dependency profile marker: ownership or permissions are insecure") expected = b"local\n" if os.environ["PROFILE_MARKER_BACKEND"] == "local" else b"cloud\n" if path.read_bytes() != expected: raise SystemExit("Installed dependency profile does not match configured backend") PY [[ -f "$DROPIN_PATH" && ! -L "$DROPIN_PATH" ]] || fail "Missing generated systemd network hardening: $DROPIN_PATH" local expected_mode expected_mode=cloud [[ "$selected" == local ]] && expected_mode=offline grep -q "^Environment=PI_DICTATE_NETWORK_MODE=$expected_mode$" "$DROPIN_PATH" || fail "Systemd network mode does not match configured backend" validate_network_dropin "$selected" [[ -f "$DICTIONARY_PATH" && ! -L "$DICTIONARY_PATH" ]] || fail "Missing coding dictionary: $DICTIONARY_PATH" if [[ "$selected" == local ]]; then local model_path model_sha actual_sha model_path="$MODEL_PATH" [[ -f "$model_path" && ! -L "$model_path" ]] || fail "Missing local model archive: $model_path" model_sha="$(read_config_model_sha)" model_sha="${model_sha,,}" actual_sha="$(sha256sum "$model_path" | cut -d' ' -f1)" [[ "$actual_sha" == "$model_sha" ]] || fail "Local model checksum mismatch" fi printf 'pi-dictate check: OK (%s backend, %s network)\n' "$selected" "$expected_mode" } socket_source="$REPOSITORY_ROOT/systemd/user/pi-dictate.socket" service_source="$REPOSITORY_ROOT/systemd/user/pi-dictate.service" socket_link="$UNIT_DIR/pi-dictate.socket" service_link="$UNIT_DIR/pi-dictate.service" # Recursive writes and removals are safe only when every existing ancestor is # a real directory. Checking the final path alone does not prevent redirection # through ~/.local, ~/.config, or a custom XDG config root. reject_symlink_ancestry "$INSTALL_ROOT" reject_symlink_ancestry "$CONFIG_ROOT" reject_symlink_ancestry "$UNIT_DIR" if [[ "$ACTION" == uninstall ]]; then for pair in "$socket_link:$socket_source" "$service_link:$service_source"; do destination="${pair%%:*}" source="${pair#*:}" if [[ ( -e "$destination" || -L "$destination" ) ]] && ! is_owned_unit "$destination" "$source"; then printf 'Refusing to disable or remove unmanaged systemd unit: %s\n' "$destination" >&2 exit 1 fi done if [[ -e "$DROPIN_PATH" || -L "$DROPIN_PATH" ]]; then [[ -f "$DROPIN_PATH" && ! -L "$DROPIN_PATH" ]] && network_dropin_is_owned || fail "Refusing to remove unmanaged or modified systemd drop-in: $DROPIN_PATH" fi command -v systemctl >/dev/null || fail 'systemctl is required' run systemctl --user disable --now pi-dictate.socket remove_owned_unit "$socket_link" "$socket_source" remove_owned_unit "$service_link" "$service_source" remove_network_dropin run systemctl --user daemon-reload run rm -rf "$INSTALL_ROOT" if $PURGE_CONFIG; then [[ ! -L "$CONFIG_ROOT" ]] || fail "Refusing to purge symlinked configuration directory: $CONFIG_ROOT" run rm -rf "$CONFIG_ROOT" printf 'Purging host configuration and credentials: %s\n' "$CONFIG_ROOT" else printf 'Preserving host configuration and credentials: %s\n' "$CONFIG_ROOT" fi exit 0 fi if [[ "$ACTION" == check ]]; then check_installation exit 0 fi [[ -n "$BACKEND" ]] || { if [[ -f "$CONFIG_PATH" ]]; then BACKEND="$(read_config_backend)" else BACKEND=local fi } valid_backend "$BACKEND" || fail "Unsupported backend: $BACKEND" if [[ "$BACKEND" == local && -f "$CONFIG_PATH" ]]; then configured_model_path="$(read_config_model_path)" if [[ -n "$configured_model_path" ]]; then MODEL_PATH="$configured_model_path" MODEL_DIR="$(dirname -- "$MODEL_PATH")" fi fi if [[ "$BACKEND" == local ]]; then reject_symlink_ancestry "$MODEL_DIR" fi if [[ "$BACKEND" == local ]]; then [[ -z "$API_KEY_FILE" ]] || fail '--api-key-file is only valid for meta or openai' [[ "$ACTION" == configure || "$ACTION" == install ]] || fail "Unsupported operation: $ACTION" else [[ -z "$MODEL_SOURCE" ]] || fail '--model-source is only valid for local installs' [[ -z "$MODEL_SHA256_OVERRIDE" ]] || fail '--model-sha256 is only valid for local installs' [[ "$ACTION" == configure || "$ACTION" == install ]] || fail "Unsupported operation: $ACTION" [[ -n "$API_KEY_FILE" || -f "$CREDENTIAL_DIR/$BACKEND-api-key" ]] || fail "Cloud backend requires --api-key-file or an existing managed credential" fi command -v python3 >/dev/null || fail 'python3 is required' command -v uv >/dev/null || fail 'uv is required' command -v systemctl >/dev/null || fail 'systemctl is required' [[ ! -L "$INSTALL_ROOT" ]] || fail "Refusing to use symlinked install directory: $INSTALL_ROOT" [[ ! -L "$CONFIG_ROOT" ]] || fail "Refusing to use symlinked configuration directory: $CONFIG_ROOT" [[ ! -L "$CREDENTIAL_DIR" ]] || fail "Refusing to use symlinked credential directory: $CREDENTIAL_DIR" if [[ -f "$CONFIG_PATH" || -L "$CONFIG_PATH" ]]; then [[ ! -L "$CONFIG_PATH" ]] || fail "Refusing to use symlinked configuration file: $CONFIG_PATH" validate_config "$CONFIG_PATH" fi CONFIG_LEGACY_IDLE_SECONDS="" if [[ ! -f "$CONFIG_PATH" && -f "$service_link" && ! -L "$service_link" ]]; then CONFIG_LEGACY_IDLE_SECONDS="$(read_legacy_idle_seconds "$service_link" || true)" fi export CONFIG_LEGACY_IDLE_SECONDS # Keep all installer-owned files restorable until systemd has accepted the new # configuration. This makes a failed backend switch leave the previous # configuration, credentials, hardening, and units usable. TRANSACTION_DIR="" MODEL_TEMP="" STAGED_VENV="" OLD_VENV_BACKUP="" VENV_SWAPPED=false SOCKET_WAS_ACTIVE=false SOCKET_WAS_ENABLED=false SERVICE_WAS_ACTIVE=false declare -a SNAPSHOT_PATHS=() declare -a SNAPSHOT_BACKUPS=() declare -a SNAPSHOT_EXISTS=() declare -a SNAPSHOT_HARDLINKS=() snapshot_file() { local path="$1" name name="$(printf '%s' "$path" | sha256sum | cut -c1-16)" SNAPSHOT_PATHS+=("$path") SNAPSHOT_BACKUPS+=("$TRANSACTION_DIR/$name") SNAPSHOT_HARDLINKS+=(0) if [[ -e "$path" || -L "$path" ]]; then SNAPSHOT_EXISTS+=(1) cp -a -- "$path" "$TRANSACTION_DIR/$name" else SNAPSHOT_EXISTS+=(0) fi } snapshot_replace_only_file() { local path="$1" backup backup="${path}.pi-dictate-rollback.$$" [[ ! -e "$backup" && ! -L "$backup" ]] || fail "Refusing existing replace-only snapshot: $backup" SNAPSHOT_PATHS+=("$path") SNAPSHOT_BACKUPS+=("$backup") SNAPSHOT_HARDLINKS+=(1) if [[ -e "$path" || -L "$path" ]]; then [[ -f "$path" && ! -L "$path" ]] || fail "Refusing non-regular replace-only snapshot: $path" SNAPSHOT_EXISTS+=(1) # Keep the backup beside the archive so hard-link snapshots also work # when a custom model lives on a filesystem other than $HOME. ln -- "$path" "$backup" else SNAPSHOT_EXISTS+=(0) fi } rollback_transaction() { set +e local index path backup for index in "${!SNAPSHOT_PATHS[@]}"; do path="${SNAPSHOT_PATHS[$index]}" backup="${SNAPSHOT_BACKUPS[$index]}" rm -f -- "$path" if [[ "${SNAPSHOT_EXISTS[$index]}" == 1 ]]; then mkdir -p -- "$(dirname -- "$path")" if [[ "${SNAPSHOT_HARDLINKS[$index]}" == 1 ]]; then mv -- "$backup" "$path" else cp -a -- "$backup" "$path" fi fi done [[ -z "$MODEL_TEMP" ]] || rm -f -- "$MODEL_TEMP" if [[ -n "$OLD_VENV_BACKUP" && -e "$OLD_VENV_BACKUP" ]]; then rm -rf -- "$INSTALL_ROOT/venv" mv -- "$OLD_VENV_BACKUP" "$INSTALL_ROOT/venv" elif $VENV_SWAPPED; then rm -rf -- "$INSTALL_ROOT/venv" else [[ -z "$STAGED_VENV" ]] || rm -rf -- "$STAGED_VENV" fi systemctl --user daemon-reload >/dev/null 2>&1 if $SOCKET_WAS_ENABLED; then systemctl --user enable pi-dictate.socket >/dev/null 2>&1 else systemctl --user disable pi-dictate.socket >/dev/null 2>&1 fi if $SOCKET_WAS_ACTIVE; then systemctl --user start pi-dictate.socket >/dev/null 2>&1 else systemctl --user stop pi-dictate.socket >/dev/null 2>&1 fi if $SERVICE_WAS_ACTIVE; then systemctl --user start pi-dictate.service >/dev/null 2>&1 else systemctl --user stop pi-dictate.service >/dev/null 2>&1 fi [[ -z "$TRANSACTION_DIR" ]] || rm -rf -- "$TRANSACTION_DIR" } on_exit() { local status=$? index trap - EXIT if (( status != 0 )) && [[ -n "$TRANSACTION_DIR" ]]; then rollback_transaction elif [[ -n "$TRANSACTION_DIR" ]]; then [[ -z "$MODEL_TEMP" ]] || rm -f -- "$MODEL_TEMP" for index in "${!SNAPSHOT_PATHS[@]}"; do if [[ "${SNAPSHOT_HARDLINKS[$index]}" == 1 ]]; then rm -f -- "${SNAPSHOT_BACKUPS[$index]}" fi done rm -rf -- "$TRANSACTION_DIR" fi exit "$status" } if ! $DRY_RUN; then systemctl --user is-active --quiet pi-dictate.socket && SOCKET_WAS_ACTIVE=true systemctl --user is-enabled --quiet pi-dictate.socket && SOCKET_WAS_ENABLED=true systemctl --user is-active --quiet pi-dictate.service && SERVICE_WAS_ACTIVE=true TRANSACTION_DIR="$(mktemp -d "$HOME/.pi-dictate-install.XXXXXX")" chmod 0700 "$TRANSACTION_DIR" snapshot_file "$CONFIG_PATH" snapshot_file "$PROFILE_MARKER" snapshot_file "$DROPIN_PATH" snapshot_file "$socket_link" snapshot_file "$service_link" snapshot_file "$DICTIONARY_PATH" if [[ "$BACKEND" == meta || "$BACKEND" == openai ]]; then snapshot_file "$CREDENTIAL_DIR/$BACKEND-api-key" fi if [[ "$BACKEND" == local ]]; then snapshot_replace_only_file "$MODEL_PATH" fi trap on_exit EXIT fi run mkdir -p "$INSTALL_ROOT" "$CONFIG_ROOT" "$CREDENTIAL_DIR" "$UNIT_DIR" run chmod 0700 "$INSTALL_ROOT" "$CONFIG_ROOT" "$CREDENTIAL_DIR" DEPENDENCY_PROFILE=local [[ "$BACKEND" == local ]] || DEPENDENCY_PROFILE=cloud if $DRY_RUN; then STAGED_VENV="$INSTALL_ROOT/venv" else STAGED_VENV="$INSTALL_ROOT/.venv.install.$$" OLD_VENV_BACKUP="$TRANSACTION_DIR/venv.previous" fi run_uv_sync() { if $DRY_RUN; then printf '+ uv venv --relocatable %q\n' "$STAGED_VENV" printf '+ UV_PROJECT_ENVIRONMENT=%q uv sync --frozen --no-dev --no-editable --reinstall-package pi-dictate-service --extra %q --project %q\n' \ "$STAGED_VENV" "$DEPENDENCY_PROFILE" "$SCRIPT_DIR" else uv venv --relocatable "$STAGED_VENV" UV_PROJECT_ENVIRONMENT="$STAGED_VENV" uv sync --frozen --no-dev --no-editable \ --reinstall-package pi-dictate-service --extra "$DEPENDENCY_PROFILE" --project "$SCRIPT_DIR" fi } run_uv_sync EFFECTIVE_MODEL_SHA256="$MODEL_SHA256" MODEL_REPLACED=false if [[ -n "$MODEL_SHA256_OVERRIDE" ]]; then EFFECTIVE_MODEL_SHA256="${MODEL_SHA256_OVERRIDE,,}" fi if [[ "$BACKEND" == local && -z "$MODEL_SOURCE" && -f "$CONFIG_PATH" ]]; then existing_model_sha="$(read_config_model_sha)" [[ "$existing_model_sha" =~ ^[[:xdigit:]]{64}$ ]] && EFFECTIVE_MODEL_SHA256="${existing_model_sha,,}" fi if [[ "$BACKEND" == local ]]; then run mkdir -p "$MODEL_DIR" "$INSTALL_ROOT/config" run chmod 0700 "$MODEL_DIR" "$INSTALL_ROOT/config" model_is_reusable=false if [[ -z "$MODEL_SOURCE" && -f "$MODEL_PATH" && ! -L "$MODEL_PATH" && "$DRY_RUN" == false ]]; then [[ "$(sha256sum "$MODEL_PATH" | cut -d' ' -f1)" == "$EFFECTIVE_MODEL_SHA256" ]] && model_is_reusable=true fi if $model_is_reusable; then printf 'Keeping existing verified model archive: %s\n' "$MODEL_PATH" else if $DRY_RUN; then MODEL_TEMP="$MODEL_DIR/.${MODEL_FILENAME}.install.$$" else MODEL_TEMP="$(mktemp "$MODEL_DIR/.${MODEL_FILENAME}.install.XXXXXX")" fi if [[ -n "$MODEL_SOURCE" ]]; then [[ -f "$MODEL_SOURCE" && ! -L "$MODEL_SOURCE" ]] || fail "Model source not found or is a symlink: $MODEL_SOURCE" run cp --reflink=auto "$MODEL_SOURCE" "$MODEL_TEMP" elif $DRY_RUN; then printf '+ download nvidia/parakeet-tdt-0.6b-v3@%s to %q\n' "$MODEL_REVISION" "$MODEL_TEMP" else MODEL_REVISION="$MODEL_REVISION" MODEL_PATH="$MODEL_TEMP" \ "$STAGED_VENV/bin/python" - <<'PY' import os import shutil from pathlib import Path from huggingface_hub import hf_hub_download source = hf_hub_download( repo_id="nvidia/parakeet-tdt-0.6b-v3", filename="parakeet-tdt-0.6b-v3.nemo", revision=os.environ["MODEL_REVISION"], ) destination = Path(os.environ["MODEL_PATH"]) destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, destination) PY fi if ! $DRY_RUN; then actual_sha256="$(sha256sum "$MODEL_TEMP" | cut -d' ' -f1)" if [[ "$actual_sha256" != "$EFFECTIVE_MODEL_SHA256" ]]; then rm -f "$MODEL_TEMP" printf 'Model checksum mismatch: expected %s, got %s\n' "$EFFECTIVE_MODEL_SHA256" "$actual_sha256" >&2 exit 1 fi fi run chmod 0400 "$MODEL_TEMP" run mv -f "$MODEL_TEMP" "$MODEL_PATH" MODEL_REPLACED=true fi fi # The coding dictionary is small and is useful to every backend. It is never # copied into Pi's sandbox; only the host service reads it. run mkdir -p "$INSTALL_ROOT/config" run chmod 0700 "$INSTALL_ROOT/config" if [[ -L "$DICTIONARY_PATH" || ( -e "$DICTIONARY_PATH" && ! -f "$DICTIONARY_PATH" ) ]]; then fail "Refusing to use non-regular coding dictionary: $DICTIONARY_PATH" elif [[ ! -e "$DICTIONARY_PATH" ]]; then run cp "$DICTIONARY_SOURCE" "$DICTIONARY_PATH" run chmod 0600 "$DICTIONARY_PATH" else printf 'Keeping existing coding dictionary: %s\n' "$DICTIONARY_PATH" fi if [[ "$BACKEND" == meta || "$BACKEND" == openai ]]; then key_destination="$CREDENTIAL_DIR/$BACKEND-api-key" if [[ -n "$API_KEY_FILE" ]]; then copy_credential "$API_KEY_FILE" "$key_destination" elif ! $DRY_RUN; then validate_credential "$key_destination" fi fi if [[ "$BACKEND" == local ]]; then export CONFIG_MODEL_REPLACED=0 if $MODEL_REPLACED; then export CONFIG_MODEL_REPLACED=1 fi fi write_config if ! $DRY_RUN; then validate_config "$CONFIG_PATH" fi install_owned_unit "$socket_source" "$socket_link" install_owned_unit "$service_source" "$service_link" write_network_dropin "$BACKEND" if ! $DRY_RUN; then validate_network_dropin "$BACKEND" fi # Commit the profile marker only after dependencies, credentials, config, and # hardening have all been staged and validated. A failed switch stays fail-closed # against the previous marker instead of poisoning an otherwise working install. # Make the newly resolved dependency environment visible only after every # generated file has been validated. Stop activation during the swap; rollback # restores both the files and the previous systemd activation state. run systemctl --user stop pi-dictate.socket run systemctl --user stop pi-dictate.service if ! $DRY_RUN; then if [[ -e "$INSTALL_ROOT/venv" || -L "$INSTALL_ROOT/venv" ]]; then [[ ! -L "$INSTALL_ROOT/venv" ]] || fail "Refusing to replace symlinked dependency environment" mv -- "$INSTALL_ROOT/venv" "$OLD_VENV_BACKUP" fi mv -- "$STAGED_VENV" "$INSTALL_ROOT/venv" VENV_SWAPPED=true STAGED_VENV="" fi write_profile_marker run systemctl --user daemon-reload run systemctl --user enable --now pi-dictate.socket # Starting the socket alone defers ExecStartPre until first use. Start the # service once so this transaction commits only after startup validation passes. run systemctl --user start pi-dictate.service if $DRY_RUN; then printf 'Would install pi-dictate with %s backend (%s dependency profile, %s network).\n' \ "$BACKEND" "$DEPENDENCY_PROFILE" "$([[ "$BACKEND" == local ]] && printf offline || printf cloud)" else printf 'Installed pi-dictate with %s backend (%s dependency profile).\n' "$BACKEND" "$DEPENDENCY_PROFILE" printf 'Check it with: %s check\n' "$0" fi