Files

1222 lines
46 KiB
Bash
Executable File

#!/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