diff --git a/install.sh b/install.sh index 00002ff..ee12b36 100755 --- a/install.sh +++ b/install.sh @@ -348,7 +348,7 @@ def select(root, link, target): temp.unlink(missing_ok=True) -def desired_launcher(root, legacy=False, pinned_font=False): +def desired_launcher(root, legacy=False, pinned_font=False, prior_launcher=False): import shlex q = lambda path: shlex.quote(str(path)) base = root / "current" @@ -374,18 +374,48 @@ def desired_launcher(root, legacy=False, pinned_font=False): 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' + preamble = ("#!/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') + # These are only for byte-for-byte migration of earlier managed wrappers. + if legacy or pinned_font or prior_launcher: + return (preamble + + ' --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() + # Scan the original argv without shifting or evaluating it. Prepend each + # absent default in reverse order, preserving explicit options (including + # duplicates) for the binary's strict parser to handle. + return (preamble + ' --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" + + ' shift\n' + + ' has_assets= has_worker= has_python= has_model= has_socket=\n' + + ' for arg in "$@"; do\n' + + ' case "$arg" in\n' + + ' --assets) has_assets=1;;\n' + + ' --worker) has_worker=1;;\n' + + ' --python) has_python=1;;\n' + + ' --model) has_model=1;;\n' + + ' --socket) has_socket=1;;\n' + + ' esac\n' + + ' done\n' + + ' if [ -z "$has_socket" ] && [ -n "${GAMESCOPE_SOCKET:-}" ]; then\n' + + ' set -- --socket "$GAMESCOPE_SOCKET" "$@"\n' + + ' fi\n' + + ' if [ -z "$has_model" ]; then set -- --model "$MODEL" "$@"; fi\n' + + ' if [ -z "$has_python" ]; then set -- --python "$PYTHON" "$@"; fi\n' + + f' if [ -z "$has_worker" ]; then set -- --worker {q(base / "python/frameyap/worker.py")} "$@"; fi\n' + + f' if [ -z "$has_assets" ]; then set -- --assets {q(base / "assets")} "$@"; fi\n' + + f" exec {q(base / 'bin/frameyap')} --run \"$@\";;\n" + + f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')} \"$@\";;\n" + f" *) exec {q(base / 'bin/frameyap')} \"$@\";;\n" + 'esac\n').encode() @@ -425,7 +455,8 @@ def check_wrappers(root, launcher): 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))) + (desired_launcher(root, prior_launcher=True), 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 @@ -764,7 +795,8 @@ def uninstall(root, launcher): 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))) + (desired_launcher(root, prior_launcher=True), 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" diff --git a/scripts/install_payload.py b/scripts/install_payload.py index d47de03..fb9941b 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -328,7 +328,7 @@ def select(root, link, target): temp.unlink(missing_ok=True) -def desired_launcher(root, legacy=False, pinned_font=False): +def desired_launcher(root, legacy=False, pinned_font=False, prior_launcher=False): import shlex q = lambda path: shlex.quote(str(path)) base = root / "current" @@ -354,18 +354,48 @@ def desired_launcher(root, legacy=False, pinned_font=False): 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' + preamble = ("#!/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') + # These are only for byte-for-byte migration of earlier managed wrappers. + if legacy or pinned_font or prior_launcher: + return (preamble + + ' --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() + # Scan the original argv without shifting or evaluating it. Prepend each + # absent default in reverse order, preserving explicit options (including + # duplicates) for the binary's strict parser to handle. + return (preamble + ' --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" + + ' shift\n' + + ' has_assets= has_worker= has_python= has_model= has_socket=\n' + + ' for arg in "$@"; do\n' + + ' case "$arg" in\n' + + ' --assets) has_assets=1;;\n' + + ' --worker) has_worker=1;;\n' + + ' --python) has_python=1;;\n' + + ' --model) has_model=1;;\n' + + ' --socket) has_socket=1;;\n' + + ' esac\n' + + ' done\n' + + ' if [ -z "$has_socket" ] && [ -n "${GAMESCOPE_SOCKET:-}" ]; then\n' + + ' set -- --socket "$GAMESCOPE_SOCKET" "$@"\n' + + ' fi\n' + + ' if [ -z "$has_model" ]; then set -- --model "$MODEL" "$@"; fi\n' + + ' if [ -z "$has_python" ]; then set -- --python "$PYTHON" "$@"; fi\n' + + f' if [ -z "$has_worker" ]; then set -- --worker {q(base / "python/frameyap/worker.py")} "$@"; fi\n' + + f' if [ -z "$has_assets" ]; then set -- --assets {q(base / "assets")} "$@"; fi\n' + + f" exec {q(base / 'bin/frameyap')} --run \"$@\";;\n" + + f" --check-overlay|--check-controls) mode=$1; shift; exec {q(base / 'bin/frameyap')} \"$mode\" --assets {q(base / 'assets')} \"$@\";;\n" + f" *) exec {q(base / 'bin/frameyap')} \"$@\";;\n" + 'esac\n').encode() @@ -405,7 +435,8 @@ def check_wrappers(root, launcher): 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))) + (desired_launcher(root, prior_launcher=True), 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 @@ -744,7 +775,8 @@ def uninstall(root, launcher): 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))) + (desired_launcher(root, prior_launcher=True), 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" diff --git a/tests/test_installer.py b/tests/test_installer.py index 1867674..511a891 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -438,6 +438,84 @@ class InstallTests(unittest.TestCase): self.install("0.1.202609241530", a, h, "--without-model") self.assertTrue((self.data / "frameyap/versions/0.1.202609241530/model/weights.bin").exists()) + def test_launcher_run_defaults_and_explicit_overrides_preserve_argv(self): + native = self.stage / "bin/frameyap" + native.write_text('#!/usr/bin/env python3\nimport json, sys\nprint(json.dumps(sys.argv[1:]))\n') + native.chmod(0o755) + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) + root = self.data / "frameyap/current" + launcher = self.home / ".local/bin/frameyap" + base_env = {**os.environ, "GAMESCOPE_SOCKET": "", "FRAMEYAP_PYTHON": "", "FRAMEYAP_MODEL": ""} + + def run(*args, env=base_env): + result = subprocess.run([str(launcher), *args], env=env, capture_output=True, text=True, check=True) + return json.loads(result.stdout) + + defaults = ["--assets", str(root / "assets"), "--worker", str(root / "python/frameyap/worker.py"), + "--python", str(root / "runtime/bin/python3"), "--model", str(root / "model")] + self.assertEqual(run(), ["--run", *defaults]) + marker = self.base / "not-executed" + literal = f"$(touch {marker})" + config = self.home / ".config/frameyap/paths.conf" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(f"python=/opt/approved python/{literal}\n" + "model=/opt/local model; touch not-executed\n") + config_defaults = [*defaults[:4], "--python", f"/opt/approved python/{literal}", + "--model", "/opt/local model; touch not-executed"] + self.assertEqual(run("--run"), ["--run", *config_defaults]) + self.assertFalse(marker.exists()) + env_defaults = [*defaults[:4], "--python", f"/env/python $HOME; {literal}", + "--model", "/env/model * 'quoted'"] + env = {**base_env, "FRAMEYAP_PYTHON": env_defaults[5], "FRAMEYAP_MODEL": env_defaults[7], + "GAMESCOPE_SOCKET": "env-socket $HOME"} + self.assertEqual(run(env=env), ["--run", *env_defaults, "--socket", "env-socket $HOME"]) + self.assertFalse(marker.exists()) + + # Each explicit flag wins independently, even when environment/config supplies defaults. + for flag, value in (("--assets", f"/other assets/{literal}"), + ("--worker", "/other worker; touch not-executed"), + ("--python", "/other python $HOME"), + ("--model", "/other model *"), + ("--socket", f"explicit socket; {literal}")): + with self.subTest(flag=flag): + argv = run("--run", flag, value, env=env) + self.assertEqual(argv.count(flag), 1) + self.assertEqual(argv[argv.index(flag) + 1], value) + if flag == "--socket": + self.assertNotIn("env-socket $HOME", argv) + else: + self.assertIn("env-socket $HOME", argv) + explicit = ["--model", "/my model", "--worker", "/my worker", "--assets", "/my assets", + "--python", "/my python", "--socket", "my socket", "--head"] + self.assertEqual(run("--run", *explicit, env=env), ["--run", *explicit]) + self.assertEqual(run("--run", "--python", "/one", "--python", "/two"), + ["--run", *defaults[:4], "--model", "/opt/local model; touch not-executed", + "--python", "/one", "--python", "/two"]) + self.assertFalse(marker.exists()) + + def test_prior_managed_launcher_migration_and_modified_refusal(self): + a, h = self.package("0.1.202609241530") + self.install("0.1.202609241530", a, h) + root = self.data / "frameyap" + launcher = self.home / ".local/bin/frameyap" + old = installer.desired_launcher(root, prior_launcher=True) + self.assertIn(b'shift; exec ', old) + self.assertIn(b'--python "$PYTHON" --model "$MODEL" "$@"', old) + launcher.write_bytes(old) + self.install("0.1.202609241530", a, h) + self.assertEqual(launcher.read_bytes(), installer.desired_launcher(root)) + launcher.write_bytes(old + b'# user change\n') + with self.assertRaisesRegex(ValueError, "foreign file"): + self.install("0.1.202609241530", a, h) + with self.assertRaisesRegex(ValueError, "foreign file"): + installer.main(["--uninstall", "--unregistered"]) + self.assertEqual(launcher.read_bytes(), old + b'# user change\n') + launcher.write_bytes(old) + with contextlib.redirect_stdout(io.StringIO()): + installer.main(["--uninstall", "--unregistered"]) + self.assertFalse(launcher.exists()) + def test_no_model_reinstall_preserves_provisioned_model_and_uninstall(self): a, h = self.package("0.1.202609241530") self.install("0.1.202609241530", a, h, "--without-model")