diff --git a/docs/install-design.md b/docs/install-design.md index de8ff84..5f392e5 100644 --- a/docs/install-design.md +++ b/docs/install-design.md @@ -20,8 +20,9 @@ a separately provisioned compatible CPU Python environment and pinned weights. ## OpenVR identity `local.frameyap.overlay` is a string OpenVR application key, **not** a Steam -store AppID. The installer creates a manifest/desktop launcher user-locally but -does not register/launch the app by default. Explicit `frameyap --register +store AppID. The installer creates a manifest and a desktop entry user-locally; the +desktop entry (read by KDE's menu) is the intended launch path, and the app +is not registered with SteamVR or launched by default. Explicit `frameyap --register /absolute/manifest/path` uses the OpenVR registration API; registration alone did not reveal a launcher in the first checked dashboard menu. On one Frame the user opened the panel from the **Non-Steam** section and quit; shortcut discovery @@ -57,8 +58,10 @@ already be available for registration; never start/restart it for installation. authorized runtime/model can be set in `paths.conf`. - **Noninteractive**: supply flags and `--yes` for network/model consent. `--print-plan` is read-only; `--json` provides structured results/errors and - progress events for a model download. No prompt reads stdin in a pipe. An - empty TTY invocation offers a local menu and prints equivalent flags. + progress events for a model download. No prompt reads answers from a pipe. + A bare terminal invocation, including `curl … | sh`, prompts on the real + terminal (`/dev/tty`), asks y/n separately for the model and runtime + downloads, and prints the equivalent flag commands. Installation is user-local under XDG data/config paths with a managed launcher, retained rollback, SHA-256/path validation, foreign-file refusal and a lock diff --git a/docs/packaging.md b/docs/packaging.md index 8067fa7..db37b4d 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -86,7 +86,7 @@ sh install.sh --archive /path/to/frameyap-VERSION-linux-aarch64.tar.gz \ After an actual vetted release exists, use a real numeric version (for example, `sh install.sh --mode binary --version 0.1.202609241627 --yes`); the installer will retrieve tag `v0.1.202609241627` and its versioned checksum; no `latest` or moving-branch -lookup. A pipe invocation is supported, never prompts on stdin, and must also +lookup. A pipe invocation is supported, never reads answers from the pipe, and must also pin a real published tag. **There is no functional public download command yet.** `--without-model` omits any model files in the selected archive, never deletes @@ -139,9 +139,17 @@ compiler, CMake, SDK, SDL3, Wayland/scanner, libxcb, FreeType, Vulkan developmen files and producer-supplied licenses; it still does not install Python ASR packages. `--print-plan` performs a read-only plan, `--json` gives machine-readable results/errors (model installs -also stream file events), and `--yes` authorizes network downloads. Bare TTY -invocation can guide choices and prints equivalent flags; non-TTY runs require -explicit arguments and never prompt. `--autolaunch`/`--no-autolaunch` are explicit +also stream file events), and `--yes` authorizes network downloads. +A bare invocation with a terminal on stdout (including `curl … | sh`) runs +the attended flow: it first notes that the user can press Ctrl+C and read the +script, asks for the install choices, then separately asks y/n before the +speech-model download (about 180 MB) and the pip runtime install, and runs the +chosen steps in order, stopping at the first failure. Answers come from the real +terminal (stdin if it is one, else `/dev/tty`); piped data is never read as an +answer, and with no controlling terminal nothing prompts. It prints the +equivalent flag commands for automation. Attended mode does not offer OpenVR +registration: the desktop entry is the launch path. +Runs with flags, or without a terminal, never prompt. `--autolaunch`/`--no-autolaunch` are explicit OpenVR registration choices, off by default; do not pass either during an inert install if a running SteamVR session must remain untouched. @@ -188,7 +196,7 @@ Install root: `$XDG_DATA_HOME/frameyap` (default `~/.local/share/frameyap`); launcher: `~/.local/bin/frameyap`; desktop entry: `$XDG_DATA_HOME/applications/frameyap.desktop` (default `~/.local/share/applications/frameyap.desktop`). The desktop entry points to the -user-local launcher; the installer never edits Steam's library or registers a +user-local launcher and is the intended launch path (KDE's menu reads it); the installer never edits Steam's library or registers a Steam shortcut. Install/upgrade creates or checks `$XDG_CONFIG_HOME/frameyap/config.json` (default `~/.config/frameyap/config.json`), filling missing properties or repairing invalid JSON/values while preserving valid customization. Before each repair it diff --git a/install.sh b/install.sh index e1ee418..2afec55 100755 --- a/install.sh +++ b/install.sh @@ -1105,8 +1105,21 @@ def main(argv=None): print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request") +def ask_yes_no(question): + while True: + answer = input(question + " [y/n]: ").strip().lower() + if answer in ("y", "yes"): + return True + if answer in ("n", "no"): + return False + print("Please answer y or n.") + + def interactive_options(): - """Only an empty, actual terminal invocation offers a guided local choice.""" + """Only an empty, actual terminal invocation offers a guided local choice. + + Returns one argv per step; each step is an ordinary flag-driven operation.""" + print("If you piped this script without reading it, you can press Ctrl+C now and read it first.") print("FrameYap installer: choose binary (verified archive) or source (local build).") mode = input("Mode [binary/source]: ").strip().lower() if mode not in ("binary", "source"): @@ -1119,44 +1132,68 @@ def interactive_options(): if archive: chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()] else: - if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes": + if not ask_yes_no("Download verified release v" + version + "?"): raise UsageError("download not approved; no installation started") chosen.append("--yes") else: for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"): chosen += ["--" + flag, input(flag + " local path: ").strip()] - if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes": - chosen.append("--without-model") - if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes": - chosen.append("--autolaunch") - print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen)) - return chosen + steps = [chosen] + # Each download is described and separately approved; nothing is fetched on a default. + if ask_yes_no("Download the Parakeet Redux speech model (about 180 MB, CC-BY-4.0) from Hugging Face?"): + steps.append(["--install-model", "--backend", "redux", "--yes"]) + if ask_yes_no("Install the CPU Python runtime with pip (" + RUNTIME_DOWNLOAD + ")?"): + steps.append(["--install-runtime", "--yes"]) + print("Equivalent commands:") + for step in steps: + print(" sh install.sh " + " ".join(shlex.quote(item) for item in step)) + return steps + + +def attended_input(): + """The real terminal for prompts, or None. Under `curl | sh` stdin (and the + saved fd 3) is the pipe, so the controlling terminal is opened directly; + piped data is never read as answers.""" + if sys.stdin.isatty(): + return sys.stdin + try: + if os.isatty(3): + return os.fdopen(3, "r", closefd=False) + except OSError: + pass # Direct Python entry point, no saved shell descriptor. + try: + return open("/dev/tty", "r") + except OSError: + return None # no controlling terminal: never prompt def cli(argv=None): argv = sys.argv[1:] if argv is None else argv - structured = "--json" in argv + steps = [argv] if not argv and sys.stdout.isatty(): # With `sh install.sh`, fd 0 is the embedded Python code, not the # invoking terminal. fd 3 retains original stdin (including a pipe). - attended = sys.stdin - if not attended.isatty(): - try: - if os.isatty(3): - attended = os.fdopen(3, "r", closefd=False) - except OSError: - pass # Direct Python entry point, no saved shell descriptor. - if attended.isatty(): + attended = attended_input() + if attended is not None: try: original_stdin = sys.stdin try: sys.stdin = attended - argv = interactive_options() + steps = interactive_options() finally: sys.stdin = original_stdin - except (ValueError, EOFError) as exc: - print(f"frameyap installer: {exc}", file=sys.stderr) + except (ValueError, EOFError, KeyboardInterrupt) as exc: + print(f"frameyap installer: {exc or 'cancelled'}", file=sys.stderr) return 2 + for step in steps: + code = run_step(step) + if code: + return code + return 0 + + +def run_step(argv): + structured = "--json" in argv try: if structured and "--print-plan" in argv: main(argv) # already emits one JSON plan diff --git a/scripts/install_payload.py b/scripts/install_payload.py index fc7cbe4..8561e58 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -1085,8 +1085,21 @@ def main(argv=None): print("OpenVR autolaunch " + ("enabled" if args.autolaunch else "disabled") + " by explicit request") +def ask_yes_no(question): + while True: + answer = input(question + " [y/n]: ").strip().lower() + if answer in ("y", "yes"): + return True + if answer in ("n", "no"): + return False + print("Please answer y or n.") + + def interactive_options(): - """Only an empty, actual terminal invocation offers a guided local choice.""" + """Only an empty, actual terminal invocation offers a guided local choice. + + Returns one argv per step; each step is an ordinary flag-driven operation.""" + print("If you piped this script without reading it, you can press Ctrl+C now and read it first.") print("FrameYap installer: choose binary (verified archive) or source (local build).") mode = input("Mode [binary/source]: ").strip().lower() if mode not in ("binary", "source"): @@ -1099,44 +1112,68 @@ def interactive_options(): if archive: chosen += ["--archive", archive, "--sha256", input("SHA-256 (64 hex digits): ").strip()] else: - if input("Download verified release v" + version + "? [yes/no]: ").strip().lower() != "yes": + if not ask_yes_no("Download verified release v" + version + "?"): raise UsageError("download not approved; no installation started") chosen.append("--yes") else: for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"): chosen += ["--" + flag, input(flag + " local path: ").strip()] - if input("Omit archive model files? [yes/no]: ").strip().lower() == "yes": - chosen.append("--without-model") - if input("Enable OpenVR autolaunch? [yes/no]: ").strip().lower() == "yes": - chosen.append("--autolaunch") - print("Equivalent flags: " + " ".join(shlex.quote(item) for item in chosen)) - return chosen + steps = [chosen] + # Each download is described and separately approved; nothing is fetched on a default. + if ask_yes_no("Download the Parakeet Redux speech model (about 180 MB, CC-BY-4.0) from Hugging Face?"): + steps.append(["--install-model", "--backend", "redux", "--yes"]) + if ask_yes_no("Install the CPU Python runtime with pip (" + RUNTIME_DOWNLOAD + ")?"): + steps.append(["--install-runtime", "--yes"]) + print("Equivalent commands:") + for step in steps: + print(" sh install.sh " + " ".join(shlex.quote(item) for item in step)) + return steps + + +def attended_input(): + """The real terminal for prompts, or None. Under `curl | sh` stdin (and the + saved fd 3) is the pipe, so the controlling terminal is opened directly; + piped data is never read as answers.""" + if sys.stdin.isatty(): + return sys.stdin + try: + if os.isatty(3): + return os.fdopen(3, "r", closefd=False) + except OSError: + pass # Direct Python entry point, no saved shell descriptor. + try: + return open("/dev/tty", "r") + except OSError: + return None # no controlling terminal: never prompt def cli(argv=None): argv = sys.argv[1:] if argv is None else argv - structured = "--json" in argv + steps = [argv] if not argv and sys.stdout.isatty(): # With `sh install.sh`, fd 0 is the embedded Python code, not the # invoking terminal. fd 3 retains original stdin (including a pipe). - attended = sys.stdin - if not attended.isatty(): - try: - if os.isatty(3): - attended = os.fdopen(3, "r", closefd=False) - except OSError: - pass # Direct Python entry point, no saved shell descriptor. - if attended.isatty(): + attended = attended_input() + if attended is not None: try: original_stdin = sys.stdin try: sys.stdin = attended - argv = interactive_options() + steps = interactive_options() finally: sys.stdin = original_stdin - except (ValueError, EOFError) as exc: - print(f"frameyap installer: {exc}", file=sys.stderr) + except (ValueError, EOFError, KeyboardInterrupt) as exc: + print(f"frameyap installer: {exc or 'cancelled'}", file=sys.stderr) return 2 + for step in steps: + code = run_step(step) + if code: + return code + return 0 + + +def run_step(argv): + structured = "--json" in argv try: if structured and "--print-plan" in argv: main(argv) # already emits one JSON plan diff --git a/tests/test_installer.py b/tests/test_installer.py index cd880ee..2a22407 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -745,8 +745,9 @@ class InstallTests(unittest.TestCase): def test_piped_input_with_tty_output_does_not_prompt(self): master, slave = pty.openpty() try: + # A new session has no controlling terminal: nothing to prompt on. child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE, - stdout=slave, stderr=slave, + stdout=slave, stderr=slave, start_new_session=True, env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) os.close(slave) slave = -1 @@ -773,6 +774,76 @@ class InstallTests(unittest.TestCase): if slave >= 0: os.close(slave) + def test_curl_pipe_prompts_on_controlling_terminal_not_the_pipe(self): + import fcntl as fcntl_module + import termios + master, slave = pty.openpty() + try: + def controlling_terminal(): + fcntl_module.ioctl(1, termios.TIOCSCTTY, 0) + # `curl | sh`: stdin is a pipe; the pty is stdout and the session's terminal. + child = subprocess.Popen(["sh", str(REPO / "install.sh")], stdin=subprocess.PIPE, + stdout=slave, stderr=slave, start_new_session=True, + preexec_fn=controlling_terminal, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) + os.close(slave) + slave = -1 + output = bytearray() + try: + child.stdin.write(b"source\n") # piped data must never answer a prompt + child.stdin.close() + wrote = False + while child.poll() is None: + ready, _, _ = select.select([master], [], [], 8) + self.assertTrue(ready, "installer did not prompt on the terminal") + try: + output.extend(os.read(master, 8192)) + except OSError: + break + if not wrote and b"Mode [binary/source]:" in output: + os.write(master, b"not-a-mode\n") + wrote = True + self.assertEqual(child.wait(timeout=8), 2) + self.assertIn(b"read it first", output) + self.assertIn(b"choose binary or source", output) + self.assertFalse((self.data / "frameyap").exists()) + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=8) + finally: + os.close(master) + if slave >= 0: + os.close(slave) + + def test_attended_steps_ask_for_model_and_runtime_separately(self): + answers = iter(["binary", "0.1.202609241530", "/tmp/a.tar.gz", "a" * 64, "maybe", "y", "n"]) + with patch("builtins.input", lambda prompt="": next(answers)), \ + contextlib.redirect_stdout(io.StringIO()) as shown: + steps = installer.interactive_options() + self.assertEqual(steps, [["--mode", "binary", "--version", "0.1.202609241530", + "--archive", "/tmp/a.tar.gz", "--sha256", "a" * 64], + ["--install-model", "--backend", "redux", "--yes"]]) + self.assertIn("Please answer y or n.", shown.getvalue()) + self.assertIn("sh install.sh --install-model --backend redux --yes", shown.getvalue()) + answers = iter(["binary", "0.1.202609241530", "", "n"]) + with patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(ValueError, "download not approved"): + installer.interactive_options() + answers = iter(["binary", "0.1.202609241530", "", "yes", "n", "y"]) + with patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()): + steps = installer.interactive_options() + self.assertEqual(steps[0][-1], "--yes") + self.assertEqual(steps[1:], [["--install-runtime", "--yes"]]) + # Steps run in order and stop at the first failure. + calls = [] + with patch.object(installer, "attended_input", return_value=sys.stdin), \ + patch.object(installer, "interactive_options", return_value=[["a"], ["b"], ["c"]]), \ + patch.object(installer, "run_step", side_effect=lambda argv: calls.append(argv) or (1 if argv == ["b"] else 0)), \ + patch.object(installer.sys.stdout, "isatty", return_value=True): + self.assertEqual(installer.cli([]), 1) + self.assertEqual(calls, [["a"], ["b"]]) + def test_attended_shell_wrapper_reads_the_actual_tty(self): master, slave = pty.openpty() try: