Files
voculinux/snap/local/check_runtime.py
T
soubarnak ea7be5f111 feat(identity): rename user-visible identity to voculinux with one-time legacy import
The fork takes its own name everywhere a user can see it: binaries
(voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus
namespace (com.voculinux.Voculinux, no alias for the old name),
config/data/cache dirs, single-instance lock, IBus engine identity,
tray icons, resource prefixes, VOCULINUX_* environment names, and the
update-checker user agent. The internal Python module stays vocalinux
and the distribution metadata keeps the upstream URLs as provenance.

On first run a previous Vocalinux install's config.json, custom
dictionary, and portal restore token are copied once into the new
locations (new config always wins, no merge, no write-back); models
re-resolve through the pinned-checksum download path. An offline
naming-sweep test proves no old-name strings remain on user-visible
surfaces outside the allowlist.

Refs #18
2026-11-06 01:29:39 +05:30

149 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Record locked Python versions at build time and audit the installed Snap.
Only recording uses packaging, from the locked build toolchain. The packaged
audit needs only the standard library and the runtime it is checking.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib
import importlib.metadata
import json
import re
import runpy
import sys
from pathlib import Path
MANIFEST = Path("usr/share/voculinux/python-inputs.json")
def normalize(name: str) -> str:
"""Normalize a Python distribution name."""
return re.sub(r"[-_.]+", "-", name).lower()
def locked_versions(export: Path) -> dict[str, str]:
"""Evaluate the complete export for the build interpreter, rejecting GI."""
from packaging.requirements import Requirement
expected = {}
blocks = re.split(r"(?=^[A-Za-z0-9_.-]+==)", export.read_text(), flags=re.MULTILINE)[1:]
if not blocks:
raise RuntimeError(f"No pinned requirements in {export}")
for block in blocks:
requirement = Requirement(re.split(r"\s+--hash=|\\", block, maxsplit=1)[0].strip())
if not re.search(r"--hash=sha256:[0-9a-f]{64}(?:\s|$)", block):
raise RuntimeError(f"Missing SHA-256 for {requirement.name}")
name = normalize(requirement.name)
if name == "pygobject":
raise RuntimeError("GI must come from the GNOME extension, not pip")
if requirement.marker and not requirement.marker.evaluate():
continue
pins = list(requirement.specifier)
if len(pins) != 1 or pins[0].operator != "==" or "*" in pins[0].version:
raise RuntimeError(f"Not an exact pin: {requirement}")
if name in expected:
raise RuntimeError(f"Multiple active pins for {name}")
expected[name] = pins[0].version
if not expected:
raise RuntimeError(f"No active requirements in {export}")
return expected
def audit(root: Path, expected: dict[str, str]) -> Path:
"""Check every bundled distribution, including missing/extra/build packages."""
site = root / f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages"
actual = {}
for distribution in importlib.metadata.distributions(path=[str(site)]):
name = normalize(distribution.metadata["Name"])
if name in actual:
raise RuntimeError(f"Duplicate installed distribution: {name}")
actual[name] = distribution.version
if actual != expected:
differences = {
name: {"expected": expected.get(name), "installed": actual.get(name)}
for name in sorted(expected.keys() | actual.keys())
if expected.get(name) != actual.get(name)
}
raise RuntimeError(f"Snap Python versions differ from the lock: {differences}")
return site
def record(root: Path, source: Path) -> None:
"""Retain the expected versions and original export digests in the payload."""
for name, expected_version in locked_versions(
source / "requirements/installer-build.txt"
).items():
if importlib.metadata.version(name) != expected_version:
raise RuntimeError(f"Build tool {name} differs from its locked version")
expected = locked_versions(source / "requirements/runtime.txt")
expected["voculinux"] = runpy.run_path(str(source / "src/vocalinux/version.py"))["__version__"]
audit(root, expected)
manifest = {
"python": list(sys.version_info[:2]),
"packages": expected,
"exports": {
name: hashlib.sha256((source / "requirements" / name).read_bytes()).hexdigest()
for name in ("runtime.txt", "installer-build.txt")
},
}
destination = root / MANIFEST
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def smoke(root: Path) -> None:
"""Audit a packaged runtime and import native/UI modules without a model."""
manifest = json.loads((root / MANIFEST).read_text())
if manifest["python"] != list(sys.version_info[:2]):
raise RuntimeError("The Snap is running with a different Python ABI")
site = audit(root, manifest["packages"])
sys.path.insert(0, str(site))
for name in ("numpy", "requests", "psutil", "pyaudio", "cairo", "evdev", "vocalinux"):
module = importlib.import_module(name)
if not module.__file__ or not Path(module.__file__).resolve().is_relative_to(
site.resolve()
):
raise RuntimeError(f"{name} is shadowed by a platform/host package")
from vocalinux.utils.pywhispercpp_loader import preload_shared_libraries
preload_shared_libraries()
for name in (
"pywhispercpp.model",
"vocalinux.main",
"vocalinux.ui.tray_indicator",
"vocalinux.speech_recognition.recognition_manager",
"vocalinux.text_injection.text_injector",
):
importlib.import_module(name)
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa: F401
if Path(gi.__file__).resolve().is_relative_to(site.resolve()):
raise RuntimeError("The Snap bundled GI instead of using the GNOME platform")
print("PASS: locked Snap Python versions, native modules and GNOME GTK import")
def main() -> None:
"""Record build inputs or check the installed, confined artifact."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--record", type=Path)
args = parser.parse_args()
try:
if args.record:
record(args.root, args.record)
else:
smoke(args.root)
except (OSError, ValueError, KeyError, RuntimeError, ImportError) as error:
parser.exit(1, f"Snap runtime verification failed: {error}\n")
if __name__ == "__main__":
main()