mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 01:00:04 +02:00
595 lines
25 KiB
Bash
Executable File
595 lines
25 KiB
Bash
Executable File
#!/bin/sh
|
|
# FrameYap pinned release installer. No implicit version, downloads, or runtime launch.
|
|
set -eu
|
|
for tool in python3 curl sha256sum tar; do
|
|
command -v "$tool" >/dev/null 2>&1 || { echo "frameyap installer: missing prerequisite: $tool" >&2; exit 1; }
|
|
done
|
|
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 12) else 1)' || {
|
|
echo 'frameyap installer: Python 3.12+ required for bootstrap only (release bundles runtime)' >&2
|
|
exit 1
|
|
}
|
|
exec python3 - "$@" <<'PY'
|
|
"""FrameYap installer implementation. Embedded verbatim in install.sh for piped installs."""
|
|
import argparse
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from urllib.parse import quote
|
|
|
|
KEY = "local.frameyap.overlay"
|
|
MARKER = "# FrameYap managed launcher v1\n"
|
|
ARCHIVE_LIMIT = 12 * 1024**3
|
|
MEMBER_LIMIT = 50000
|
|
VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z")
|
|
DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
|
|
CONFIG_DEFAULTS = {
|
|
"font": "",
|
|
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
|
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
|
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
|
|
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
|
"right_grip": "/user/hand/right/input/grip",
|
|
"ptt": "/user/hand/right/input/x", "cancel": "", "insert": "", "enter": ""},
|
|
}
|
|
COLOR_RE = re.compile(r"#[0-9a-fA-F]{6}\Z")
|
|
BUTTON_RE = re.compile(r"/user/hand/(left|right)/input/[A-Za-z0-9_]+\Z")
|
|
|
|
|
|
def fail(message):
|
|
raise ValueError(message)
|
|
|
|
|
|
def json_atomic(path, value):
|
|
atomic_write(path, (json.dumps(value, indent=2, sort_keys=True) + "\n").encode())
|
|
|
|
|
|
def atomic_write(path, content, mode=0o600):
|
|
fd, name = tempfile.mkstemp(prefix=".frameyap-", dir=path.parent)
|
|
try:
|
|
os.fchmod(fd, mode)
|
|
with os.fdopen(fd, "wb") as stream:
|
|
stream.write(content)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(name, path)
|
|
finally:
|
|
if os.path.exists(name):
|
|
os.unlink(name)
|
|
|
|
|
|
def config_path():
|
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
base = Path(xdg) if xdg and Path(xdg).is_absolute() else Path.home() / ".config"
|
|
return base / "frameyap/config.json"
|
|
|
|
|
|
def unique_pairs(pairs):
|
|
result = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
fail(f"duplicate JSON config key: {key}")
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def normalized_config(data):
|
|
# Invalid individual values reset to defaults. Unknown/retired properties
|
|
# are removed; the exact original bytes are backed up before any rewrite.
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
|
|
for section in ("theme", "buttons"):
|
|
source = data.get(section)
|
|
source = source if isinstance(source, dict) else {}
|
|
fixed[section] = {}
|
|
for name, default in CONFIG_DEFAULTS[section].items():
|
|
value = source.get(name, default)
|
|
valid = (isinstance(value, str) and (COLOR_RE.fullmatch(value) if section == "theme"
|
|
else (not value or BUTTON_RE.fullmatch(value))))
|
|
fixed[section][name] = value if valid else default
|
|
paths = [value for value in fixed["buttons"].values() if value]
|
|
if len(paths) != len(set(paths)):
|
|
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
|
size = lambda: len((json.dumps(fixed, indent=2, sort_keys=True) + "\n").encode())
|
|
if size() > 4096:
|
|
fixed["font"] = ""
|
|
if size() > 4096:
|
|
fixed["buttons"] = CONFIG_DEFAULTS["buttons"].copy()
|
|
return fixed
|
|
|
|
|
|
def repair_user_config():
|
|
path = config_path()
|
|
owned_dir(path.parent.parent)
|
|
owned_dir(path.parent)
|
|
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
fail(f"refusing foreign config path: {path}")
|
|
original = None
|
|
parsed = None
|
|
if path.exists():
|
|
if path.stat().st_size > 65536:
|
|
fail(f"config too large to safely back up: {path}")
|
|
original = path.read_bytes()
|
|
try:
|
|
if len(original) > 4096: # native config reader has the same bound
|
|
fail("config exceeds native 4096-byte limit")
|
|
parsed = json.loads(original.decode("utf-8"), object_pairs_hook=unique_pairs)
|
|
except (ValueError, UnicodeError):
|
|
pass
|
|
fixed = normalized_config(parsed)
|
|
if original is not None and parsed == fixed:
|
|
return # Keep valid user formatting, permissions and custom values.
|
|
if original is not None:
|
|
fd, backup = tempfile.mkstemp(prefix="config.json.backup-", dir=path.parent)
|
|
with os.fdopen(fd, "wb") as stream:
|
|
stream.write(original)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
print(f"Preserved previous FrameYap config at {backup}")
|
|
json_atomic(path, fixed)
|
|
|
|
|
|
def owned_dir(path):
|
|
if path.is_symlink():
|
|
fail(f"refusing symlink directory: {path}")
|
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
if not path.is_dir():
|
|
fail(f"not a directory: {path}")
|
|
|
|
|
|
def check_host():
|
|
if sys.platform != "linux" or platform.machine().lower() not in ("aarch64", "arm64"):
|
|
fail("release requires Linux AArch64 (ARM64); no cross-architecture install")
|
|
if platform.libc_ver()[0] != "glibc":
|
|
fail("release requires glibc Linux; libc compatibility still requires native testing")
|
|
|
|
|
|
def digest_file(path):
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def check_digest(value):
|
|
if not DIGEST_RE.fullmatch(value):
|
|
fail("--sha256 must be a 64-character hex SHA-256")
|
|
return value.lower()
|
|
|
|
|
|
def check_version(value):
|
|
if not VERSION_RE.fullmatch(value) or value in (".", ".."):
|
|
fail("invalid release version/tag")
|
|
return value
|
|
|
|
|
|
def release_name(version):
|
|
return f"frameyap-{version}-linux-aarch64.tar.gz"
|
|
|
|
|
|
def download(url, destination):
|
|
subprocess.run(["curl", "--fail", "--silent", "--show-error", "--location", "--proto", "=https",
|
|
"--proto-redir", "=https", "--tlsv1.2", "--max-filesize",
|
|
str(4096 if url.endswith(".sha256") else ARCHIVE_LIMIT),
|
|
"--output", str(destination), url], check=True)
|
|
|
|
|
|
def verify_members(archive):
|
|
seen = set()
|
|
total = 0
|
|
count = 0
|
|
for member in archive:
|
|
count += 1
|
|
if count > MEMBER_LIMIT:
|
|
fail("too many archive members")
|
|
name = member.name
|
|
parts = name.rstrip("/").split("/")
|
|
if (not name or name.startswith("/") or len(name) > 1024 or len(parts) > 32
|
|
or any(x in ("", ".", "..") for x in parts)
|
|
or "\\" in name or "\x00" in name):
|
|
fail(f"unsafe archive path: {name!r}")
|
|
if name in seen:
|
|
fail(f"duplicate archive member: {name}")
|
|
seen.add(name)
|
|
if not (member.isfile() or member.isdir()):
|
|
fail(f"archive links and special files forbidden: {name}")
|
|
if member.size < 0 or member.size > ARCHIVE_LIMIT:
|
|
fail("oversized archive member")
|
|
if name == "release.json" and member.size > 8192:
|
|
fail("oversized release metadata")
|
|
total += member.size
|
|
if total > ARCHIVE_LIMIT:
|
|
fail("archive uncompressed limit exceeded")
|
|
if (parts[0] not in ("release.json", "bin", "lib", "assets", "python", "runtime", "model", "fonts", "licenses")
|
|
or (parts[0] == "release.json" and (len(parts) != 1 or not member.isfile()))
|
|
or (len(parts) == 1 and parts[0] != "release.json" and not member.isdir())):
|
|
fail(f"unexpected archive path: {name}")
|
|
|
|
|
|
def extract(archive_path, destination):
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
verify_members(archive)
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive:
|
|
target = destination.joinpath(*member.name.rstrip("/").split("/"))
|
|
if member.isdir():
|
|
owned_dir(target)
|
|
else:
|
|
owned_dir(target.parent)
|
|
# Never follow a pre-existing link, including one created by the archive.
|
|
with target.open("xb") as out, archive.extractfile(member) as source:
|
|
shutil.copyfileobj(source, out, 1024 * 1024)
|
|
# Retain executable helpers/shared libraries; discard all other mode bits.
|
|
target.chmod(0o700 if member.mode & 0o111 else 0o600)
|
|
|
|
|
|
def validate_payload(root, version, without_model=False, installed=False):
|
|
meta = json.loads((root / "release.json").read_text())
|
|
if not isinstance(meta, dict):
|
|
fail("invalid release metadata")
|
|
if meta.get("schema") != 1 or meta.get("version") != version or meta.get("arch") != "linux-aarch64":
|
|
fail("release metadata version/architecture/schema mismatch")
|
|
if meta.get("runtime") not in ("bundled-cpu-python", "external-authorized-python") or not isinstance(meta.get("model_revision"), str) or not meta["model_revision"]:
|
|
fail("missing runtime/model revision declaration")
|
|
has_model = meta.get("includes_model")
|
|
if not isinstance(has_model, bool) or (not installed and not has_model and (root / "model").exists()):
|
|
fail("inconsistent model declaration")
|
|
if has_model and not (root / "model").is_dir() and not (installed and without_model and not (root / "model").exists()):
|
|
fail("missing declared model")
|
|
if meta["runtime"] == "external-authorized-python" and (root / "runtime").exists():
|
|
fail("external runtime payload must not include a runtime")
|
|
for name in ("bin/frameyap", "runtime/bin/python3", "python/frameyap/worker.py", "assets/actions.json", "fonts/font.ttf"):
|
|
if name == "runtime/bin/python3" and meta["runtime"] == "external-authorized-python":
|
|
continue
|
|
if not (root / name).is_file():
|
|
fail(f"missing payload file: {name}")
|
|
for name in ("lib", "fonts"):
|
|
if not (root / name).is_dir() or not any((root / name).iterdir()):
|
|
fail(f"missing payload directory: {name}")
|
|
return meta
|
|
|
|
|
|
def selected(root, link):
|
|
path = root / link
|
|
if not path.is_symlink():
|
|
if path.exists():
|
|
fail(f"refusing foreign selection path: {path}")
|
|
return None
|
|
value = os.readlink(path)
|
|
if not re.fullmatch(r"versions/[A-Za-z0-9][A-Za-z0-9._-]{0,95}", value):
|
|
fail(f"invalid {link} selection")
|
|
if not (root / value).is_dir():
|
|
fail(f"broken {link} selection")
|
|
return value
|
|
|
|
|
|
def select(root, link, target):
|
|
path = root / link
|
|
temp = root / ("." + link + "-" + str(os.getpid()))
|
|
try:
|
|
os.symlink(target, temp)
|
|
os.replace(temp, path)
|
|
finally:
|
|
temp.unlink(missing_ok=True)
|
|
|
|
|
|
def desired_launcher(root, legacy=False, pinned_font=False):
|
|
import shlex
|
|
q = lambda path: shlex.quote(str(path))
|
|
base = root / "current"
|
|
flags = ["--assets", base / "assets", "--worker", base / "python/frameyap/worker.py"]
|
|
if pinned_font or legacy:
|
|
flags[2:2] = ["--font", base / "fonts/font.ttf"] # exact previously managed launchers
|
|
args = " ".join(q(item) for item in flags)
|
|
# Parse two literal absolute paths, never source/eval this user-owned file.
|
|
# Environment overrides remain useful for a one-off explicit launch.
|
|
config = ('' if legacy else
|
|
'CONFIG=${XDG_CONFIG_HOME:-$HOME/.config}/frameyap/paths.conf\n'
|
|
'CONFIG_PYTHON= CONFIG_MODEL=\n'
|
|
'if [ -f "$CONFIG" ] && [ ! -L "$CONFIG" ]; then\n'
|
|
' while IFS= read -r line || [ -n "$line" ]; do\n'
|
|
' case "$line" in\n'
|
|
' python=/*) CONFIG_PYTHON=${line#python=};;\n'
|
|
' model=/*) CONFIG_MODEL=${line#model=};;\n'
|
|
' esac\n'
|
|
' done < "$CONFIG"\n'
|
|
'fi\n')
|
|
python_default = (q(base / "runtime/bin/python3") if legacy else
|
|
'${CONFIG_PYTHON:-' + q(base / "runtime/bin/python3") + '}')
|
|
model_default = (q(base / "model") if legacy else
|
|
'${CONFIG_MODEL:-' + q(base / "model") + '}')
|
|
check_font = ' --font ' + q(base / "fonts/font.ttf") if legacy or pinned_font else ''
|
|
return ("#!/bin/sh\n" + MARKER + 'export PYTHONDONTWRITEBYTECODE=1\n'
|
|
+ f'export FRAMEYAP_INSTALL_ROOT={q(root)}\n'
|
|
+ f'export LD_LIBRARY_PATH={q(base / "lib")}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n'
|
|
+ config
|
|
+ f'PYTHON=${{FRAMEYAP_PYTHON:-{python_default}}}\n'
|
|
+ f'MODEL=${{FRAMEYAP_MODEL:-{model_default}}}\n'
|
|
+ 'if [ "$#" -eq 0 ]; then set -- --run; fi\n'
|
|
+ 'case "$1" in\n'
|
|
+ ' --run)\n'
|
|
+ ' if [ -n "${GAMESCOPE_SOCKET:-}" ]; then set -- "$@" --socket "$GAMESCOPE_SOCKET"; fi\n'
|
|
+ f" shift; exec {q(base / 'bin/frameyap')} --run {args} --python \"$PYTHON\" --model \"$MODEL\" \"$@\";;\n"
|
|
+ f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')}{check_font} \"$@\";;\n"
|
|
+ f" *) exec {q(base / 'bin/frameyap')} \"$@\";;\n"
|
|
+ 'esac\n').encode()
|
|
|
|
|
|
def desired_manifest(launcher):
|
|
return {"source": "builtin", "applications": [{"app_key": KEY, "launch_type": "binary",
|
|
"binary_path_linux": str(launcher), "binary_path_linux_arm": str(launcher),
|
|
"is_dashboard_overlay": True,
|
|
"strings": {"en_us": {"name": "FrameYap"}}}]}
|
|
|
|
|
|
def desktop_path(root):
|
|
return root.parent / "applications/frameyap.desktop"
|
|
|
|
|
|
def desired_desktop(launcher):
|
|
# Desktop Entry Exec quotes are not shell quotes; escape reserved characters.
|
|
executable = (str(launcher).replace("\\", "\\\\").replace('"', '\\"')
|
|
.replace("$", "\\$").replace("`", "\\`").replace("%", "%%"))
|
|
return ("[Desktop Entry]\nType=Application\nName=FrameYap\n"
|
|
f'Exec="{executable}"\nTerminal=false\nCategories=Utility;\n').encode()
|
|
|
|
|
|
def check_owned_file(path, expected, alternatives=()):
|
|
if path.is_symlink():
|
|
fail(f"refusing foreign symlink: {path}")
|
|
if path.exists():
|
|
if not path.is_file():
|
|
fail(f"refusing foreign path: {path}")
|
|
data = path.read_bytes()
|
|
if data != expected and data not in alternatives:
|
|
fail(f"refusing to replace modified/foreign file: {path}")
|
|
|
|
|
|
def check_wrappers(root, launcher):
|
|
manifest = root / "frameyap.vrmanifest"
|
|
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
|
|
# Accept only exact earlier managed scripts for migration.
|
|
check_owned_file(launcher, desired_launcher(root),
|
|
(desired_launcher(root, pinned_font=True), desired_launcher(root, legacy=True)))
|
|
check_owned_file(manifest, desired)
|
|
check_owned_file(desktop_path(root), desired_desktop(launcher))
|
|
return manifest, desired
|
|
|
|
|
|
def install_files(root, installed, launcher):
|
|
owned_dir(launcher.parent)
|
|
manifest, desired = check_wrappers(root, launcher)
|
|
desktop = desktop_path(root)
|
|
owned_dir(desktop.parent)
|
|
atomic_write(launcher, desired_launcher(root), 0o755)
|
|
atomic_write(manifest, desired)
|
|
atomic_write(desktop, desired_desktop(launcher))
|
|
|
|
|
|
def receipt(path):
|
|
marker = path / ".archive-sha256"
|
|
if marker.is_symlink() or not marker.is_file() or not DIGEST_RE.fullmatch(marker.read_text().strip()):
|
|
fail(f"refusing unowned installation directory: {path}")
|
|
return marker.read_text().strip()
|
|
|
|
|
|
def inventory(path):
|
|
return sorted(p.relative_to(path).as_posix() for p in path.rglob("*")
|
|
if p.relative_to(path).parts[0] != "model"
|
|
and p.relative_to(path).as_posix() not in (".archive-sha256", ".installed-files.json", ".install-options.json"))
|
|
|
|
|
|
def check_inventory(path):
|
|
index = path / ".installed-files.json"
|
|
if not index.is_file() or index.is_symlink():
|
|
fail(f"missing installation inventory: {path}")
|
|
declared = json.loads(index.read_text())
|
|
if not isinstance(declared, list) or inventory(path) != declared:
|
|
fail(f"refusing to remove untracked files in: {path}")
|
|
for p in path.rglob("*"):
|
|
if p.is_symlink() and p.relative_to(path).parts[0] != "model":
|
|
fail(f"refusing link inside installation: {p}")
|
|
|
|
|
|
def installed_choice(path):
|
|
receipt(path)
|
|
options = path / ".install-options.json"
|
|
if not options.is_file() or options.is_symlink():
|
|
fail(f"missing installation options: {path}")
|
|
choice = json.loads(options.read_text())
|
|
if not isinstance(choice, dict) or set(choice) != {"without_model"} or not isinstance(choice["without_model"], bool):
|
|
fail(f"invalid installation options: {path}")
|
|
return choice["without_model"]
|
|
|
|
|
|
def do_install(args, root, launcher):
|
|
version = check_version(args.version)
|
|
versions = root / "versions"
|
|
owned_dir(versions)
|
|
current = selected(root, "current")
|
|
selected(root, "previous")
|
|
# Refuse foreign wrappers/launcher directories before installing a new version.
|
|
owned_dir(launcher.parent)
|
|
check_wrappers(root, launcher)
|
|
if args.archive:
|
|
archive = Path(args.archive).expanduser().resolve(strict=True)
|
|
expected = check_digest(args.sha256)
|
|
do_download = False
|
|
else:
|
|
do_download = True
|
|
archive = None
|
|
with tempfile.TemporaryDirectory(prefix=".download-", dir=root) as td:
|
|
if do_download:
|
|
name = release_name(version)
|
|
url = f"https://github.com/{args.repo}/releases/download/{quote(version)}/{name}"
|
|
archive = Path(td) / name
|
|
sidecar = Path(td) / (name + ".sha256")
|
|
download(url + ".sha256", sidecar)
|
|
if sidecar.stat().st_size > 4096:
|
|
fail("oversized SHA-256 sidecar")
|
|
line = sidecar.read_text().strip()
|
|
match = re.fullmatch(r"([a-fA-F0-9]{64}) " + re.escape(name), line)
|
|
if not match:
|
|
fail("invalid versioned SHA-256 sidecar")
|
|
expected = match.group(1).lower()
|
|
download(url, archive)
|
|
if archive.stat().st_size > ARCHIVE_LIMIT:
|
|
fail("compressed archive exceeds limit")
|
|
if not do_download:
|
|
local_copy = Path(td) / "local-archive.tar.gz"
|
|
shutil.copyfile(archive, local_copy)
|
|
archive = local_copy
|
|
if digest_file(archive) != expected:
|
|
fail("archive SHA-256 mismatch")
|
|
target = versions / version
|
|
if target.exists() or target.is_symlink():
|
|
if target.is_symlink() or receipt(target) != expected:
|
|
fail("same version already installed with different archive digest")
|
|
if installed_choice(target) != args.without_model:
|
|
fail("same version already installed with different --without-model choice")
|
|
check_inventory(target)
|
|
validate_payload(target, version, args.without_model, installed=True)
|
|
else:
|
|
temp = Path(tempfile.mkdtemp(prefix=".staging-", dir=versions))
|
|
try:
|
|
extract(archive, temp)
|
|
validate_payload(temp, version)
|
|
if args.without_model and (temp / "model").exists():
|
|
shutil.rmtree(temp / "model")
|
|
json_atomic(temp / ".installed-files.json", inventory(temp))
|
|
json_atomic(temp / ".install-options.json", {"without_model": args.without_model})
|
|
(temp / ".archive-sha256").write_text(expected + "\n")
|
|
os.replace(temp, target)
|
|
finally:
|
|
if temp.exists():
|
|
shutil.rmtree(temp)
|
|
repair_user_config()
|
|
install_files(root, target, launcher)
|
|
if current == f"versions/{version}":
|
|
print(f"FrameYap {version}: already installed (same digest); wrappers verified")
|
|
return
|
|
if current and current != f"versions/{version}":
|
|
select(root, "previous", current)
|
|
select(root, "current", f"versions/{version}")
|
|
print(f"FrameYap {version} installed. OpenVR registration is NOT automatic; see docs/packaging.md.")
|
|
if json.loads((target / "release.json").read_text())["runtime"] == "external-authorized-python":
|
|
print("ASR runtime is NOT included. Supply an independently authorized environment with FRAMEYAP_PYTHON or --python; no packages are downloaded.")
|
|
|
|
|
|
def uninstall(root, launcher):
|
|
current = selected(root, "current")
|
|
previous = selected(root, "previous")
|
|
manifest = root / "frameyap.vrmanifest"
|
|
desired = (json.dumps(desired_manifest(launcher), sort_keys=True, indent=2) + "\n").encode()
|
|
check_owned_file(manifest, desired)
|
|
check_owned_file(launcher, desired_launcher(root),
|
|
(desired_launcher(root, pinned_font=True), desired_launcher(root, legacy=True)))
|
|
desktop = desktop_path(root)
|
|
check_owned_file(desktop, desired_desktop(launcher))
|
|
versions = root / "versions"
|
|
if versions.exists():
|
|
if versions.is_symlink():
|
|
fail("refusing symlink versions directory")
|
|
for item in versions.iterdir():
|
|
if not item.is_dir() or item.is_symlink() or item.name.startswith("."):
|
|
fail(f"unexpected versions entry: {item}")
|
|
choice = installed_choice(item)
|
|
check_inventory(item)
|
|
validate_payload(item, item.name, choice, installed=True)
|
|
if (item / "model").exists() or (item / "model").is_symlink():
|
|
saved = root / "saved-models"
|
|
owned_dir(saved)
|
|
target = saved / item.name
|
|
if target.exists() or target.is_symlink():
|
|
fail(f"saved model already exists: {target}")
|
|
for item in versions.iterdir():
|
|
if (item / "model").exists() or (item / "model").is_symlink():
|
|
os.replace(item / "model", root / "saved-models" / item.name)
|
|
shutil.rmtree(item)
|
|
for link in ("current", "previous"):
|
|
(root / link).unlink(missing_ok=True)
|
|
if launcher.exists():
|
|
launcher.unlink()
|
|
if manifest.exists():
|
|
manifest.unlink()
|
|
if desktop.exists():
|
|
desktop.unlink()
|
|
print("FrameYap removed; config and saved models preserved. OpenVR unregister acknowledgement was required.")
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(prog="install.sh", description="User-local FrameYap release installer (no SteamVR actions)")
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument("--archive", help="local release archive (requires --sha256 and --version)")
|
|
mode.add_argument("--rollback", action="store_true")
|
|
mode.add_argument("--uninstall", action="store_true")
|
|
parser.add_argument("--sha256")
|
|
parser.add_argument("--version")
|
|
parser.add_argument("--repo", default="baketnk/frame-yap")
|
|
parser.add_argument("--without-model", action="store_true")
|
|
parser.add_argument("--unregistered", action="store_true", help="acknowledge explicit OpenVR removal before uninstall")
|
|
args = parser.parse_args(argv)
|
|
if args.repo != "baketnk/frame-yap":
|
|
parser.error("only the pinned baketnk/frame-yap release repository is supported")
|
|
if args.archive and (not args.sha256 or not args.version):
|
|
parser.error("--archive requires --sha256 and --version")
|
|
if not args.archive and args.sha256:
|
|
parser.error("--sha256 only applies to --archive")
|
|
if not (args.rollback or args.uninstall or args.archive) and not args.version:
|
|
parser.error("--version TAG is required; no moving/latest release")
|
|
if args.uninstall and not args.unregistered:
|
|
parser.error("uninstall requires --unregistered after explicit OpenVR unregister")
|
|
if args.unregistered and not args.uninstall:
|
|
parser.error("--unregistered only applies to --uninstall")
|
|
if args.version:
|
|
check_version(args.version)
|
|
check_host()
|
|
data = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share").expanduser().absolute()
|
|
root = data / "frameyap"
|
|
launcher = Path.home() / ".local/bin/frameyap"
|
|
owned_dir(root)
|
|
lock = root / ".lock"
|
|
if lock.is_symlink():
|
|
fail("refusing symlink lock")
|
|
with lock.open("a+b") as fd:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
fail("FrameYap installer or application is running (.lock held); try again later")
|
|
if args.uninstall:
|
|
uninstall(root, launcher)
|
|
elif args.rollback:
|
|
owned_dir(root / "versions")
|
|
current, previous = selected(root, "current"), selected(root, "previous")
|
|
if not current or not previous:
|
|
fail("no previous installation to roll back to")
|
|
check_wrappers(root, launcher)
|
|
choice = installed_choice(root / previous)
|
|
check_inventory(root / previous)
|
|
validate_payload(root / previous, Path(previous).name, choice, installed=True)
|
|
select(root, "current", previous)
|
|
select(root, "previous", current)
|
|
print(f"Rolled back to {previous}")
|
|
else:
|
|
do_install(args, root, launcher)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except (ValueError, OSError, subprocess.CalledProcessError, tarfile.TarError, json.JSONDecodeError) as exc:
|
|
print(f"frameyap installer: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
PY
|