mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 00:00:05 +02:00
installer: attended curl|sh flow prompts on the terminal and offers model/runtime steps
- A bare terminal invocation reads answers from the real terminal (/dev/tty when stdin is the curl pipe); piped data is never used as an answer and nothing prompts without a controlling terminal. - After the install choices it asks y/n separately for the speech model download and the pip runtime install, runs the chosen steps in order and stops at the first failure; equivalent flag commands are printed. - Opens with a one-line note that the user can Ctrl+C and read the script. - Drops the OpenVR autolaunch and --without-model questions from attended mode; the KDE desktop entry is the launch path. Flags are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e5b998eb6c
commit
e6cb07e8b8
5 files changed
+206
-50
No files matched your search
@@ -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
|
||||
|
||||
+13
-5
@@ -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
|
||||
|
||||
+57
-20
@@ -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
|
||||
|
||||
+57
-20
@@ -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
|
||||
|
||||
+72
-1
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user