diff --git a/install.sh b/install.sh index 2afec55..c59cd1d 100755 --- a/install.sh +++ b/install.sh @@ -43,6 +43,9 @@ from urllib.parse import quote import urllib.request KEY = "local.frameyap.overlay" +# The pinned release this installer belongs to (set in the release commit that +# is tagged vRELEASE_VERSION); empty in a development tree. +RELEASE_VERSION = "" MARKER = "# FrameYap managed launcher v1\n" ARCHIVE_LIMIT = 12 * 1024**3 MEMBER_LIMIT = 50000 @@ -980,6 +983,9 @@ def resolve_args(argv): if args.install_runtime and (args.version or args.without_model or args.autolaunch is not None or args.backend != "redux" or args.model_dir or args.expected_manifest_sha256): parser.error("--install-runtime is independent of release/model flags") + if not (args.rollback or args.uninstall or args.install_model or args.install_runtime or args.version) \ + and RELEASE_VERSION and args.mode == "binary" and not args.archive: + args.version = RELEASE_VERSION # this installer's own pinned release, not a moving tag if not (args.rollback or args.uninstall or args.install_model or args.install_runtime) and not args.version: parser.error("--version VERSION is required; no moving/latest release") if args.uninstall != args.unregistered: @@ -1120,6 +1126,11 @@ def interactive_options(): 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.") + if RELEASE_VERSION: + print(f"FrameYap installer for release v{RELEASE_VERSION} (github.com/baketnk/frame-yap).") + if not ask_yes_no(f"Download and install FrameYap v{RELEASE_VERSION} from GitHub (checksum verified)?"): + raise UsageError("download not approved; no installation started") + return optional_steps([["--mode", "binary", "--version", RELEASE_VERSION, "--yes"]]) print("FrameYap installer: choose binary (verified archive) or source (local build).") mode = input("Mode [binary/source]: ").strip().lower() if mode not in ("binary", "source"): @@ -1138,7 +1149,10 @@ def interactive_options(): else: for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"): chosen += ["--" + flag, input(flag + " local path: ").strip()] - steps = [chosen] + return optional_steps([chosen]) + + +def optional_steps(steps): # 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"]) diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..5f10a02 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Build a FrameYap native-only release archive on Linux ARM64. +# +# sh scripts/build-release.sh WORKDIR VERSION +# +# Downloads the pinned SDL3 and OpenVR SDK sources into WORKDIR (SHA-256 +# checked), builds audio-only SDL3 and FrameYap, runs the tests, then stages +# and packages WORKDIR/out/frameyap-VERSION-linux-aarch64.tar.gz(.sha256). +# No ASR runtime, model, Kestrel or Torch is downloaded or bundled. +set -eu + +SDL_VERSION=3.2.16 +SDL_URL="https://github.com/libsdl-org/SDL/releases/download/release-$SDL_VERSION/SDL3-$SDL_VERSION.tar.gz" +SDL_SHA256=6340e58879b2d15830c8460d2f589a385c444d1faa2a4828a9626c7322562be8 +OPENVR_VERSION=2.15.6 +OPENVR_URL="https://github.com/ValveSoftware/openvr/archive/refs/tags/v$OPENVR_VERSION.tar.gz" +OPENVR_SHA256=e184cb625010fab7043a9d5e1e000fdeb3067a152bb3169ef53f64dfac37164c + +if [ "$#" -ne 2 ]; then + echo "usage: sh scripts/build-release.sh WORKDIR VERSION" >&2 + exit 2 +fi +case "$1" in /*) work=$1 ;; *) work=$(pwd)/$1 ;; esac +version=$2 +source=$(cd "$(dirname "$0")/.." && pwd) +[ "$(uname -s)-$(uname -m)" = Linux-aarch64 ] || { echo "release archives are built on Linux aarch64" >&2; exit 1; } +if [ -e "$work/out/frameyap-$version-linux-aarch64.tar.gz" ]; then + echo "release already exists in $work/out" >&2 + exit 1 +fi +mkdir -p "$work/downloads" + +fetch() { # url sha256 destination + if [ ! -f "$3" ]; then + curl --fail --silent --show-error --location --proto =https --output "$3.part" "$1" + mv "$3.part" "$3" + fi + echo "$2 $3" | sha256sum -c - >/dev/null || { echo "SHA-256 mismatch: $3" >&2; exit 1; } +} +fetch "$SDL_URL" "$SDL_SHA256" "$work/downloads/SDL3-$SDL_VERSION.tar.gz" +fetch "$OPENVR_URL" "$OPENVR_SHA256" "$work/downloads/openvr-$OPENVR_VERSION.tar.gz" + +rm -rf "$work/src" "$work/sdl-build" "$work/sdl" "$work/build" "$work/stage" +mkdir -p "$work/src" "$work/out" +tar -xzf "$work/downloads/SDL3-$SDL_VERSION.tar.gz" -C "$work/src" +tar -xzf "$work/downloads/openvr-$OPENVR_VERSION.tar.gz" -C "$work/src" +openvr=$work/src/openvr-$OPENVR_VERSION + +# Audio only; PipeWire/Pulse/ALSA backends are loaded at runtime when present. +cmake -S "$work/src/SDL3-$SDL_VERSION" -B "$work/sdl-build" -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$work/sdl" -DCMAKE_INSTALL_LIBDIR=lib \ + -DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF -DSDL_VIDEO=OFF \ + -DSDL_AUDIO=ON -DSDL_PIPEWIRE=ON -DSDL_PULSEAUDIO=ON -DSDL_ALSA=ON +cmake --build "$work/sdl-build" --parallel +cmake --install "$work/sdl-build" + +PKG_CONFIG_PATH="$work/sdl/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" \ +cmake -S "$source" -B "$work/build" -DCMAKE_BUILD_TYPE=Release -DFRAMEYAP_VERSION="$version" \ + -DFRAMEYAP_NATIVE=ON -DFRAMEYAP_UI_TESTS=ON -DOPENVR_ROOT="$openvr" \ + -DOPENVR_LIBRARY="$openvr/lib/linuxarm64/libopenvr_api.so" +cmake --build "$work/build" --parallel +ctest --test-dir "$work/build" --output-on-failure + +python3 "$source/scripts/stage-native.py" --build "$work/build" --destination "$work/stage" \ + --openvr-library "$openvr/lib/linuxarm64/libopenvr_api.so" --openvr-license "$openvr/LICENSE" \ + --sdl-library "$work/sdl/lib/libSDL3.so.0" --sdl-license "$work/src/SDL3-$SDL_VERSION/LICENSE.txt" +revision=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["model"]["revision"])' \ + "$source/assets/backends/redux.json") +python3 "$source/scripts/package-release.py" --stage "$work/stage" --output "$work/out" \ + --version "$version" --arch linux-aarch64 --model-revision "$revision" --external-runtime diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 8561e58..53eca22 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -23,6 +23,9 @@ from urllib.parse import quote import urllib.request KEY = "local.frameyap.overlay" +# The pinned release this installer belongs to (set in the release commit that +# is tagged vRELEASE_VERSION); empty in a development tree. +RELEASE_VERSION = "" MARKER = "# FrameYap managed launcher v1\n" ARCHIVE_LIMIT = 12 * 1024**3 MEMBER_LIMIT = 50000 @@ -960,6 +963,9 @@ def resolve_args(argv): if args.install_runtime and (args.version or args.without_model or args.autolaunch is not None or args.backend != "redux" or args.model_dir or args.expected_manifest_sha256): parser.error("--install-runtime is independent of release/model flags") + if not (args.rollback or args.uninstall or args.install_model or args.install_runtime or args.version) \ + and RELEASE_VERSION and args.mode == "binary" and not args.archive: + args.version = RELEASE_VERSION # this installer's own pinned release, not a moving tag if not (args.rollback or args.uninstall or args.install_model or args.install_runtime) and not args.version: parser.error("--version VERSION is required; no moving/latest release") if args.uninstall != args.unregistered: @@ -1100,6 +1106,11 @@ def interactive_options(): 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.") + if RELEASE_VERSION: + print(f"FrameYap installer for release v{RELEASE_VERSION} (github.com/baketnk/frame-yap).") + if not ask_yes_no(f"Download and install FrameYap v{RELEASE_VERSION} from GitHub (checksum verified)?"): + raise UsageError("download not approved; no installation started") + return optional_steps([["--mode", "binary", "--version", RELEASE_VERSION, "--yes"]]) print("FrameYap installer: choose binary (verified archive) or source (local build).") mode = input("Mode [binary/source]: ").strip().lower() if mode not in ("binary", "source"): @@ -1118,7 +1129,10 @@ def interactive_options(): else: for flag in ("source", "openvr-root", "openvr-library", "openvr-license", "sdl-library", "sdl-license"): chosen += ["--" + flag, input(flag + " local path: ").strip()] - steps = [chosen] + return optional_steps([chosen]) + + +def optional_steps(steps): # 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"]) diff --git a/tests/test_installer.py b/tests/test_installer.py index 2a22407..2bf4b65 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -800,12 +800,12 @@ class InstallTests(unittest.TestCase): 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") + if not wrote and (b"Mode [binary/source]:" in output or b"[y/n]:" in output): + os.write(master, b"not-a-mode\n" if b"Mode" in output else b"n\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.assertTrue(b"choose binary or source" in output or b"download not approved" in output) self.assertFalse((self.data / "frameyap").exists()) finally: if child.poll() is None: @@ -816,7 +816,22 @@ class InstallTests(unittest.TestCase): if slave >= 0: os.close(slave) + def test_release_installer_asks_only_yes_no_questions(self): + answers = iter(["y", "y", "y"]) + with patch.object(installer, "RELEASE_VERSION", "0.1.202609251200"), \ + patch("builtins.input", lambda prompt="": next(answers)), contextlib.redirect_stdout(io.StringIO()): + steps = installer.interactive_options() + self.assertEqual(steps, [["--mode", "binary", "--version", "0.1.202609251200", "--yes"], + ["--install-model", "--backend", "redux", "--yes"], + ["--install-runtime", "--yes"]]) + with patch.object(installer, "RELEASE_VERSION", "0.1.202609251200"): + self.assertEqual(installer.resolve_args(["--yes"]).version, "0.1.202609251200") + self.assertIsNone(installer.resolve_args(["--install-runtime", "--yes"]).version) + with patch.object(installer, "RELEASE_VERSION", ""), self.assertRaises(installer.UsageError): + installer.resolve_args(["--yes"]) + def test_attended_steps_ask_for_model_and_runtime_separately(self): + self.enterContext(patch.object(installer, "RELEASE_VERSION", "")) 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: @@ -854,7 +869,8 @@ class InstallTests(unittest.TestCase): slave = -1 output = bytearray() try: - os.write(master, b"not-a-mode\n") + # A development tree asks for a mode; a release asks y/n first. + os.write(master, b"n\n" if installer.RELEASE_VERSION else b"not-a-mode\n") while child.poll() is None: ready, _, _ = select.select([master], [], [], 8) self.assertTrue(ready, "installer did not return from terminal input") @@ -863,8 +879,12 @@ class InstallTests(unittest.TestCase): except OSError: break self.assertEqual(child.wait(timeout=8), 2) - self.assertIn(b"Mode [binary/source]:", output) - self.assertIn(b"choose binary or source", output) + if installer.RELEASE_VERSION: + self.assertIn(b"[y/n]:", output) + self.assertIn(b"download not approved", output) + else: + self.assertIn(b"Mode [binary/source]:", output) + self.assertIn(b"choose binary or source", output) self.assertFalse((self.data / "frameyap").exists()) finally: if child.poll() is None: