From eb054199e192ff3bff26e92855f9bdba7f588c31 Mon Sep 17 00:00:00 2001 From: baketnk Date: Mon, 28 Sep 2026 18:50:38 -0400 Subject: [PATCH] Add opt-in Settings release check and attended update handoff --- CMakeLists.txt | 14 +++- README.md | 1 + docs/overlay.md | 12 +++ scripts/check-update.py | 63 ++++++++++++++ scripts/install-update.py | 47 +++++++++++ src/overlay.cpp | 23 +++++- src/panel_surface.cpp | 39 +++++++-- src/panel_surface.hpp | 4 + src/update_check.cpp | 158 ++++++++++++++++++++++++++++++++++++ src/update_check.hpp | 37 +++++++++ tests/panel_test.cpp | 22 ++++- tests/test_update.py | 79 ++++++++++++++++++ tests/update_check_test.cpp | 58 +++++++++++++ 13 files changed, 544 insertions(+), 13 deletions(-) create mode 100644 scripts/check-update.py create mode 100644 scripts/install-update.py create mode 100644 src/update_check.cpp create mode 100644 src/update_check.hpp create mode 100644 tests/test_update.py create mode 100644 tests/update_check_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c2101cc..1ddbdca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,8 @@ target_compile_definitions(frameyap PRIVATE FRAMEYAP_VERSION="${FRAMEYAP_VERSION FRAMEYAP_BUILD_ROOT="${CMAKE_CURRENT_BINARY_DIR}" FRAMEYAP_SOURCE_ROOT="${CMAKE_CURRENT_SOURCE_DIR}") if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_library(frameyap_update src/update_check.cpp) + target_include_directories(frameyap_update PUBLIC src) add_library(frameyap_companions src/companion_apps.cpp) target_include_directories(frameyap_companions PUBLIC src) add_library(frameyap_mount src/mount.cpp src/laser_setting.cpp src/placement.cpp) @@ -104,7 +106,7 @@ if(FRAMEYAP_NATIVE) target_sources(frameyap PRIVATE src/runtime.cpp src/controller.cpp src/backend_manager.cpp src/overlay.cpp src/overlay_texture.cpp src/audio.cpp src/text_input.cpp src/focus_guard.cpp "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method.c" "${CMAKE_CURRENT_BINARY_DIR}/gamescope-input-method-client.h") target_include_directories(frameyap PRIVATE "${OPENVR_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") - target_link_libraries(frameyap PRIVATE frameyap_paced frameyap_worker frameyap_mount frameyap_battery frameyap_panel frameyap_companions PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}") + target_link_libraries(frameyap PRIVATE frameyap_paced frameyap_worker frameyap_mount frameyap_battery frameyap_panel frameyap_companions frameyap_update PkgConfig::SDL3 PkgConfig::WAYLAND PkgConfig::XCB Vulkan::Vulkan "${OPENVR_LIBRARY}") target_compile_definitions(frameyap PRIVATE FRAMEYAP_NATIVE=1) set_target_properties(frameyap PROPERTIES INSTALL_RPATH "$ORIGIN/../lib") endif() @@ -115,7 +117,7 @@ install(TARGETS frameyap RUNTIME DESTINATION bin) install(PROGRAMS install.sh DESTINATION bin) install(DIRECTORY assets/ DESTINATION assets) install(DIRECTORY python/frameyap DESTINATION python FILES_MATCHING PATTERN "*.py" PATTERN "__pycache__" EXCLUDE) -install(FILES scripts/model-status.py scripts/fetch-model.py scripts/backend-service.py DESTINATION scripts) +install(FILES scripts/model-status.py scripts/fetch-model.py scripts/backend-service.py scripts/check-update.py scripts/install-update.py DESTINATION scripts) include(CTest) if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) @@ -151,6 +153,12 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) add_test(NAME frameyap.panel COMMAND frameyap_panel_test "${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/Inconsolata-Regular.ttf") endif() if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + if(Python3_FOUND) + add_executable(frameyap_update_test tests/update_check_test.cpp) + target_link_libraries(frameyap_update_test PRIVATE frameyap_update) + target_compile_options(frameyap_update_test PRIVATE -UNDEBUG) + add_test(NAME frameyap.update_check COMMAND frameyap_update_test "${Python3_EXECUTABLE}") + endif() add_executable(frameyap_companions_test tests/companion_apps_test.cpp) target_link_libraries(frameyap_companions_test PRIVATE frameyap_companions) target_compile_options(frameyap_companions_test PRIVATE -UNDEBUG) @@ -239,6 +247,8 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) endif() endif() if(Python3_FOUND) + add_test(NAME frameyap.update_helpers COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p test_update.py) + set_tests_properties(frameyap.update_helpers PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") foreach(test IN ITEMS test_backends.py test_model_files.py test_backend_dispatch.py cli_git_tag.py) string(REPLACE ".py" "" test_name "${test}") add_test(NAME "frameyap.${test_name}" COMMAND "${Python3_EXECUTABLE}" -m unittest discover -s tests -p "${test}") diff --git a/README.md b/README.md index 9fe2431..c5baa75 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Recognition runs locally with the Parakeet Redux model. The installer downloads ## Update and uninstall - **Update:** run the install command again and answer **y** to FrameYap. Answer **n** to the model and runtime questions unless the release notes say they changed. +- **Check for updates:** Settings → scroll to **Check for updates**. FrameYap contacts GitHub only when you press it. If a newer published release is available, **Install update...** opens a desktop terminal; quit FrameYap and press Enter there to install. Nothing is downloaded by the check itself. - **Settings:** `~/.config/frameyap/config.json` (Quick phrases, theme, placement). Restart FrameYap after editing. - **Uninstall:** `sh ~/.local/share/frameyap/current/bin/install.sh --uninstall --unregistered`. Your settings and downloaded model are kept. Delete `~/.local/share/frameyap` and `~/.config/frameyap` to remove everything. diff --git a/docs/overlay.md b/docs/overlay.md index bb7a8ef..20cc357 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -87,6 +87,18 @@ source URL, MIT license, the selected model's name and license from its manifest the bundled font's license, and a pointer to `docs/third-party.md`. It performs no checks and no network access; Settings returns. Recording controls stay live. +At the bottom of Settings, **Check for updates** performs an explicit, asynchronous +request to GitHub's latest published release metadata. It never runs at launch, +while idle or as part of offline tests. The bounded, timeout-limited helper +compares the release's numeric tag with this build's version; repository commits +and prereleases are not update candidates. No archive or installer is downloaded +by a check. If a newer release is found, **Install update...** opens Konsole +with the installed handoff script. The terminal waits for the wearer to close +FrameYap and press Enter before the existing installer downloads a pinned +version and checksum and installs it. Until that confirmation, the terminal +does not fetch anything. An unavailable check or terminal reports an error in +Settings; no fallback auto-install is attempted. + The transcript wraps by glyph width and scrolls in its review viewport with the right-stick laser wheel; a new transcript resets the scroll position. The former paging row is retained for **Open Plan** and **Open Keyboard** when diff --git a/scripts/check-update.py b/scripts/check-update.py new file mode 100644 index 0000000..1fd82aa --- /dev/null +++ b/scripts/check-update.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Explicit, bounded GitHub release check. No network access unless --check is passed.""" +import argparse +from datetime import datetime +import json +import re +import sys +from urllib import request + +RELEASE = re.compile(r"v?((?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.([1-9][0-9]{11}))\Z") +URL = "https://api.github.com/repos/baketnk/frame-yap/releases/latest" + + +def version(value): + match = RELEASE.fullmatch(value) + if not match: + raise ValueError("invalid release version") + datetime.strptime(match[2], "%Y%m%d%H%M") + return tuple(map(int, match[1].split("."))) + + +def outcome(payload, current): + data = json.loads(payload) + tag = data["tag_name"] + if not isinstance(tag, str) or not tag.startswith("v"): + raise ValueError("invalid release tag") + candidate = version(tag) + return "AVAILABLE " + tag[1:] if candidate > version(current) else "CURRENT" + + +class NoRedirect(request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def check(current): + version(current) # Reject malformed local metadata before making any request. + opener = request.build_opener(request.ProxyHandler({}), NoRedirect()) + req = request.Request(URL, headers={"Accept": "application/vnd.github+json", "User-Agent": "FrameYap-update-check"}) + with opener.open(req, timeout=6) as response: + payload = response.read(65537) + if len(payload) > 65536: + raise ValueError("oversized response") + return outcome(payload, current) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="explicit network check") + parser.add_argument("--current", required=True) + args = parser.parse_args(argv) + if not args.check: + parser.error("--check required for network access") + try: + print(check(args.current), flush=True) + except (ValueError, KeyError, TypeError, OSError): + print("ERROR", flush=True) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install-update.py b/scripts/install-update.py new file mode 100644 index 0000000..4b5d4b2 --- /dev/null +++ b/scripts/install-update.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Attended terminal handoff for a user-requested FrameYap update.""" +import argparse +from pathlib import Path +import subprocess +import sys + +# The same validation as check-update.py, without importing the network helper. +import re +VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.[1-9][0-9]{11}\Z") + + +def installer_command(script, version): + if not VERSION.fullmatch(version): + raise ValueError("invalid version") + return ["sh", str(script), "--mode", "binary", "--version", version, "--yes"] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + args = parser.parse_args(argv) + script = Path(__file__).resolve().parent.parent / "bin/install.sh" + try: + command = installer_command(script, args.version) + except ValueError: + parser.error("invalid release version") + if not script.is_file(): + print("Installed FrameYap installer not found.") + return 1 + print(f"FrameYap update: {args.version}") + print("Close FrameYap, then press Enter here to download and install this version.") + if not sys.stdin.isatty(): + print("A terminal is required; no download started.") + return 1 + try: + input() + except (KeyboardInterrupt, EOFError): + print("Update cancelled.") + return 1 + result = subprocess.run(command, check=False) + print("Update installed." if result.returncode == 0 else "Update failed; check the installer message above.") + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/overlay.cpp b/src/overlay.cpp index 3c3369d..c38dc56 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -6,6 +6,7 @@ #include "placement.hpp" #include "panel_surface.hpp" #include "companion_apps.hpp" +#include "update_check.hpp" #include @@ -95,6 +96,9 @@ struct Overlay::Impl { Config config; PanelSurface surface; std::optional plan_launcher, keyboard_launcher; + UpdateCheck updates; + std::filesystem::path install_update_script; + std::string update_version; Panel panel; std::vector model_actions; StatusIndicators indicators; @@ -139,7 +143,9 @@ struct Overlay::Impl { mount(requested ? *requested : load_mount(persistence.settings_path)), lasers_anytime(load_lasers_anytime(persistence.laser_settings_path)), config(load_config(default_config_path())), - surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient) { + surface(resolve_font(assets, font.empty() ? config.font : font), mount, config.theme, config.gradient), + updates((std::filesystem::absolute(assets) / "../scripts/check-update.py").lexically_normal(), FRAMEYAP_VERSION), + install_update_script((std::filesystem::absolute(assets) / "../scripts/install-update.py").lexically_normal()) { persistence.persist_mount = persist; plan_launcher = find_companion("tnkplan"); keyboard_launcher = find_companion("tnkboard"); @@ -518,6 +524,12 @@ struct Overlay::Impl { } std::vector poll() { std::vector result; + if (auto update = updates.poll()) { + update_version = std::move(update->version); + surface.set_update_status(update->state == UpdateResult::State::Available ? UpdateStatus::Available : + update->state == UpdateResult::State::Current ? UpdateStatus::Current : UpdateStatus::Failed, + update_version); + } system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size())); place(); vr::VREvent_t event{}; @@ -584,8 +596,15 @@ struct Overlay::Impl { diagnostics.last_pointer_event = "up button=" + std::to_string(event.data.mouse.button); if (event.data.mouse.button == vr::VRMouseButton_Left) { auto event_result = surface.pointer_up(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y); - if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.launch_companion || event_result.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || event_result.wrist_world_fallback || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions; + if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.launch_companion || event_result.check_updates || event_result.install_update || event_result.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || event_result.wrist_world_fallback || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions; if (event_result.action) result.push_back(*event_result.action); + if (event_result.check_updates) { + update_version.clear(); + surface.set_update_status(updates.start() ? UpdateStatus::Checking : UpdateStatus::Failed); + } + if (event_result.install_update && !update_version.empty() && + !open_update_terminal(install_update_script, update_version)) + surface.set_update_status(UpdateStatus::TerminalFailed); if (event_result.launch_companion) { const auto& launcher = *event_result.launch_companion == SurfaceEvent::Companion::Plan ? plan_launcher : keyboard_launcher; diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index af4cf55..c11090f 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -31,10 +31,10 @@ struct Rect { enum class Control { Review, Settings, Bindings, Plan, Keyboard, Record, Cancel, Insert, Enter, Quit, World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert, Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow, - ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback }; + ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback, UpdateCheck, UpdateInstall }; enum class Tab { Review, Settings, Models, About }; struct Button { Rect r; Control id; const char* label; }; -constexpr std::array buttons{{ +constexpr std::array buttons{{ {{32, 138, 180, 46}, Control::Review, "Review"}, {{226, 138, 180, 46}, Control::Settings, "Settings"}, {{420, 138, 180, 46}, Control::Bindings, "Bindings"}, @@ -69,11 +69,14 @@ constexpr std::array buttons{{ {{260, 530, 214, 36}, Control::ModelNext, "Next"}, {{346, 378, 154, 44}, Control::About, "About"}, {{32, 584, 936, 50}, Control::WristWorldFallback, "Wrist world fallback"}, + {{32, 640, 454, 42}, Control::UpdateCheck, "Check for updates"}, + {{514, 640, 454, 42}, Control::UpdateInstall, "Install update..."}, }}; constexpr Rect settings_view{32, 232, 936, 316}; constexpr Rect review_view{48, 212, 904, 260}; constexpr int review_line_height = 42; -constexpr int settings_max_scroll = 634 - (settings_view.y + settings_view.h); +// Leave a status row after the optional update controls, within the viewport. +constexpr int settings_max_scroll = 722 - (settings_view.y + settings_view.h); std::optional action(Control c) { switch (c) { case Control::Record: return UiAction::Record; @@ -97,7 +100,8 @@ bool settings_control(Control c) { return c == Control::Models || c == Control::About || mounting(c) || c == Control::Recenter || c == Control::LasersAnytime || c == Control::AdvancedDebug || c == Control::AutoInsert || c == Control::Clock24h || c == Control::Date || - c == Control::CloseMicWhenIdle || c == Control::WristWorldFallback; + c == Control::CloseMicWhenIdle || c == Control::WristWorldFallback || + c == Control::UpdateCheck || c == Control::UpdateInstall; } // Invalid bytes become visible replacement glyphs, never control commands. uint32_t next_codepoint(std::string_view s, size_t& i) { @@ -138,6 +142,8 @@ struct PanelSurface::Impl { float settings_scroll = 0.f; std::optional clip_view; bool plan_present = false, keyboard_present = false; + UpdateStatus update_status = UpdateStatus::Idle; + std::string update_version; float review_scroll = 0.f; DateFormat date_format = DateFormat::MonthDayYear; std::time_t clock_time = std::time(nullptr); @@ -376,7 +382,9 @@ struct PanelSurface::Impl { } bool visible(Control c) const { if (panel.quick_open && c != Control::Cancel && c != Control::Enter && c != Control::Quit) return false; - if (c == Control::Models || c == Control::About || c == Control::WristWorldFallback) return tab == Tab::Settings; + if (c == Control::Models || c == Control::About || c == Control::WristWorldFallback || + c == Control::UpdateCheck) return tab == Tab::Settings; + if (c == Control::UpdateInstall) return tab == Tab::Settings && update_status == UpdateStatus::Available; if (c == Control::ModelRow) return tab == Tab::Models && !install_confirm; if (c == Control::ModelPrev || c == Control::ModelNext) return tab == Tab::Models; if (c == Control::ModelInstall) return tab == Tab::Models; @@ -390,6 +398,7 @@ struct PanelSurface::Impl { } bool enabled(Control c) const { if (auto a = action(c)) return available(*a); + if (c == Control::UpdateCheck) return update_status != UpdateStatus::Checking; if (c == Control::Recenter) return mount == Mount::World; if (c == Control::ModelRow) return !panel.model_busy; if (c == Control::ModelInstall) { @@ -632,6 +641,18 @@ struct PanelSurface::Impl { 5, thumb}, cyan); text("Right stick: scroll", 32, 565, 16, muted, 320); text("OFF: discard idle audio; ON: spike / start latency.", 390, 565, 16, muted, 887); + const std::string update_note = update_status == UpdateStatus::Idle ? "" : + update_status == UpdateStatus::Checking ? "Checking for updates..." : + update_status == UpdateStatus::Current ? "FrameYap is up to date." : + update_status == UpdateStatus::Available ? "Update available: " + update_version : + update_status == UpdateStatus::TerminalFailed ? "Could not open update terminal." : + "Could not check for updates."; + if (!update_note.empty()) { + clip_view = settings_view; + text(update_note, 32, 716 - int(std::lround(settings_scroll)), 18, + (update_status == UpdateStatus::Failed || update_status == UpdateStatus::TerminalFailed) ? pink : cyan, 968); + clip_view.reset(); + } } if (tab == Tab::Review && !binding_note.empty()) text(binding_note, 32, 665, 18, pink, 968); @@ -715,6 +736,8 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock:: else if (c == Control::Clock24h) result.clock_24h = !impl_->clock_24h; else if (c == Control::Date) result.date_format = static_cast((static_cast(impl_->date_format) + 1) % 4); else if (c == Control::Bindings) { result.open_bindings = true; impl_->reset(); } + else if (c == Control::UpdateCheck) { result.check_updates = true; impl_->reset(); } + else if (c == Control::UpdateInstall) { result.install_update = true; impl_->reset(); } else if (c == Control::Models) { impl_->tab = Tab::Models; impl_->model_page = 0; impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true; @@ -783,6 +806,12 @@ void PanelSurface::set_companions(bool plan, bool keyboard) { impl_->reset(); impl_->dirty = true; } } +void PanelSurface::set_update_status(UpdateStatus status, std::string version) { + if (impl_->update_status != status || impl_->update_version != version) { + impl_->update_status = status; impl_->update_version = std::move(version); + impl_->reset(); impl_->dirty = true; + } +} void PanelSurface::set_indicators(const StatusIndicators& indicators) { if (impl_->indicators != indicators) { impl_->indicators = indicators; impl_->dirty = true; } } diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index 32d53c9..d4121d0 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -26,6 +26,8 @@ struct SurfaceEvent { std::optional date_format; bool recenter = false; bool open_bindings = false; + bool check_updates = false; + bool install_update = false; std::optional model_action; }; // Header indicators. Absent values are hidden, never shown as zero or as a guess. @@ -34,6 +36,7 @@ struct StatusIndicators { std::optional left, head, right; bool operator==(const StatusIndicators&) const = default; }; +enum class UpdateStatus { Idle, Checking, Current, Available, Failed, TerminalFailed }; // One CPU RGBA canvas, independent of OpenVR. Settings replace the review area; // status and safety controls remain on the same surface. class PanelSurface { @@ -59,6 +62,7 @@ public: // OpenVR laser wheel input at the last hovered panel coordinate. bool scroll(float x, float y, float vertical_delta); void set_companions(bool plan, bool keyboard); + void set_update_status(UpdateStatus status, std::string version = {}); bool dragging(unsigned cursor) const; // OpenVR intersection masks use top-left coordinates, unlike mouse events. std::vector input_regions() const; diff --git a/src/update_check.cpp b/src/update_check.cpp new file mode 100644 index 0000000..717df1b --- /dev/null +++ b/src/update_check.cpp @@ -0,0 +1,158 @@ +#include "update_check.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char** environ; +namespace frameyap { +namespace { +bool release_version(const std::string& version) { + static const std::regex pattern(R"((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.[1-9][0-9]{11})"); + return std::regex_match(version, pattern); +} +std::optional terminal_executable() { + const char* path = std::getenv("PATH"); + if (!path) return {}; + const std::string dirs(path); + for (size_t start = 0; start <= dirs.size();) { + const size_t end = dirs.find(':', start); + const auto candidate = std::filesystem::path(dirs.substr(start, end - start)) / "konsole"; + struct stat info{}; + if (candidate.is_absolute() && ::stat(candidate.c_str(), &info) == 0 && + S_ISREG(info.st_mode) && ::access(candidate.c_str(), X_OK) == 0) return candidate; + if (end == std::string::npos) break; + start = end + 1; + } + return {}; +} +} +UpdateCheck::UpdateCheck(std::filesystem::path helper, std::string current, std::string python) + : helper_(std::move(helper)), current_(std::move(current)), python_(std::move(python)) {} +UpdateCheck::~UpdateCheck() { stop(); } +void UpdateCheck::stop() noexcept { + if (pid_ > 0) { + ::kill(pid_, SIGTERM); // exact owned checker, never SteamVR or the terminal + int status = 0; + bool reaped = false; + for (int i = 0; i < 20; ++i) { + auto result = ::waitpid(pid_, &status, WNOHANG); + if (result == pid_ || (result < 0 && errno == ECHILD)) { reaped = true; break; } + if (result < 0 && errno != EINTR) break; + ::usleep(5000); + } + if (!reaped) { + ::kill(pid_, SIGKILL); + while (::waitpid(pid_, &status, 0) < 0 && errno == EINTR) {} + } + pid_ = -1; + } + if (fd_ >= 0) { ::close(fd_); fd_ = -1; } + output_.clear(); eof_ = false; +} +bool UpdateCheck::start() { + if (busy() || !helper_.is_absolute() || !std::filesystem::is_regular_file(helper_) || + !release_version(current_)) return false; + int pipe[2]; + if (::pipe2(pipe, O_CLOEXEC) < 0) return false; + if (::fcntl(pipe[0], F_SETFL, O_NONBLOCK) < 0) { + ::close(pipe[0]); ::close(pipe[1]); return false; + } + posix_spawn_file_actions_t actions; + int error = posix_spawn_file_actions_init(&actions); + if (error) { ::close(pipe[0]); ::close(pipe[1]); return false; } + error = posix_spawn_file_actions_adddup2(&actions, pipe[1], STDOUT_FILENO); + if (!error) error = posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + if (!error) error = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0); + if (!error) error = posix_spawn_file_actions_addclose(&actions, pipe[0]); + if (!error) error = posix_spawn_file_actions_addclose(&actions, pipe[1]); + const auto helper = helper_.string(); + std::vector args{python_, "-I", helper, "--check", "--current", current_}; + std::vector argv; + for (auto& arg : args) argv.push_back(arg.data()); + argv.push_back(nullptr); + pid_t child = -1; + if (!error) error = ::posix_spawnp(&child, python_.c_str(), &actions, nullptr, argv.data(), environ); + posix_spawn_file_actions_destroy(&actions); + ::close(pipe[1]); + if (error) { ::close(pipe[0]); return false; } + pid_ = child; fd_ = pipe[0]; eof_ = false; output_.clear(); + deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(10); + return true; +} +std::optional UpdateCheck::poll() { + if (!busy()) return {}; + if (std::chrono::steady_clock::now() > deadline_) { stop(); return UpdateResult{UpdateResult::State::Failed, {}}; } + if (!eof_) { + char bytes[128]; + for (;;) { + const auto n = ::read(fd_, bytes, sizeof bytes); + if (n > 0) { + output_.append(bytes, size_t(n)); + if (output_.size() > 128) { stop(); return UpdateResult{UpdateResult::State::Failed, {}}; } + } else if (n == 0) { ::close(fd_); fd_ = -1; eof_ = true; break; } + else if (errno == EINTR) continue; + else if (errno == EAGAIN || errno == EWOULDBLOCK) break; + else { stop(); return UpdateResult{UpdateResult::State::Failed, {}}; } + } + } + if (!eof_) return {}; + int status = 0; + const auto reaped = ::waitpid(pid_, &status, WNOHANG); + if (reaped == 0) return {}; + pid_ = -1; + if (reaped < 0 || !WIFEXITED(status) || WEXITSTATUS(status)) + return UpdateResult{UpdateResult::State::Failed, {}}; + if (output_ == "CURRENT\n") return UpdateResult{UpdateResult::State::Current, {}}; + constexpr std::string_view prefix = "AVAILABLE "; + if (output_.starts_with(prefix) && output_.ends_with('\n')) { + auto version = output_.substr(prefix.size(), output_.size() - prefix.size() - 1); + if (release_version(version) && version != current_) + return UpdateResult{UpdateResult::State::Available, std::move(version)}; + } + return UpdateResult{UpdateResult::State::Failed, {}}; +} +bool open_update_terminal(const std::filesystem::path& helper, const std::string& version) { + if (!helper.is_absolute() || !std::filesystem::is_regular_file(helper) || !release_version(version)) return false; + const auto terminal = terminal_executable(); + if (!terminal) return false; + const auto script = helper.string(); + std::vector args{terminal->string(), "--hold", "-e", "python3", "-I", script, "--version", version}; + std::vector argv; + for (auto& arg : args) argv.push_back(arg.data()); + argv.push_back(nullptr); + // Konsole is an independent desktop window and must survive FrameYap quitting. + // Double fork avoids a zombie; the close-on-exec pipe detects launch errors. + int pipe[2]; + if (::pipe2(pipe, O_CLOEXEC)) return false; + const pid_t intermediate = ::fork(); + if (intermediate < 0) { ::close(pipe[0]); ::close(pipe[1]); return false; } + if (intermediate == 0) { + ::close(pipe[0]); + const pid_t child = ::fork(); + if (child == 0) { + ::execv(args[0].c_str(), argv.data()); + const int error = errno; + (void)::write(pipe[1], &error, sizeof(error)); + } else if (child < 0) { + const int error = errno; + (void)::write(pipe[1], &error, sizeof(error)); + } + ::_exit(child < 0 ? 1 : child == 0 ? 127 : 0); + } + ::close(pipe[1]); + int error = 0; + ssize_t count; + do { count = ::read(pipe[0], &error, sizeof(error)); } while (count < 0 && errno == EINTR); + ::close(pipe[0]); + int status = 0; + pid_t reaped; + do { reaped = ::waitpid(intermediate, &status, 0); } while (reaped < 0 && errno == EINTR); + return count == 0 && reaped == intermediate && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} +} // namespace frameyap diff --git a/src/update_check.hpp b/src/update_check.hpp new file mode 100644 index 0000000..211bcfc --- /dev/null +++ b/src/update_check.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace frameyap { +struct UpdateResult { + enum class State { Current, Available, Failed } state; + std::string version; // only populated for a newer, validated release +}; +// The only network path is an explicit start() call from Settings. The helper +// emits a tiny protocol; polling never blocks the OpenVR interaction loop. +class UpdateCheck { +public: + UpdateCheck(std::filesystem::path helper, std::string current, std::string python = "python3"); + ~UpdateCheck(); + UpdateCheck(const UpdateCheck&) = delete; + UpdateCheck& operator=(const UpdateCheck&) = delete; + bool start(); + std::optional poll(); + bool busy() const { return pid_ > 0; } +private: + void stop() noexcept; + std::filesystem::path helper_; + std::string current_, python_, output_; + pid_t pid_ = -1; + int fd_ = -1; + bool eof_ = false; + std::chrono::steady_clock::time_point deadline_{}; +}; +// A separate explicit click opens a desktop terminal. The terminal waits for +// the user to quit FrameYap and press Enter; it survives FrameYap's own exit. +bool open_update_terminal(const std::filesystem::path& helper, const std::string& version); +} diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 2e5738c..259df54 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -14,7 +14,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0) } void no_action(const SurfaceEvent& event) { assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert && - !event.close_mic_when_idle && !event.lock_layout && !event.wrist_world_fallback && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action && !event.launch_companion); + !event.close_mic_when_idle && !event.lock_layout && !event.wrist_world_fallback && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action && !event.launch_companion && !event.check_updates && !event.install_update); } void snapshot(PanelSurface& surface, const std::string& path) { std::ofstream out(path, std::ios::binary); @@ -337,15 +337,29 @@ int main(int argc, char** argv) { assert(!surface.scroll(200, 610, -1.f)); // fixed footer assert(surface.scroll(200, 400, -2.f)); assert(surface.render(p)); - assert(!surface.scroll(200, 400, -1.f)); // bottom clamp no_action(click(surface, 180, 231)); // scrolled settings cannot be clicked above the viewport - auto fallback = click(surface, 200, 520); + auto fallback = click(surface, 200, 485); assert(fallback.wrist_world_fallback == false && !fallback.action); assert(!surface.render(p)); surface.set_wrist_world_fallback(false); assert(surface.render(p)); - fallback = click(surface, 200, 520); + fallback = click(surface, 200, 485); assert(fallback.wrist_world_fallback == true); surface.set_wrist_world_fallback(true); assert(surface.render(p)); + assert(surface.scroll(200, 400, -2.f)); // reveal bottom update row and status + assert(surface.render(p)); + assert(!surface.scroll(200, 400, -1.f)); // bottom clamp + no_action(click(surface, 700, 486)); // no install button until a newer release is checked + assert(click(surface, 180, 486).check_updates); + surface.set_update_status(UpdateStatus::Checking); assert(surface.render(p)); + no_action(click(surface, 180, 486)); no_action(click(surface, 700, 486)); + surface.set_update_status(UpdateStatus::Available, "0.1.202609292232"); assert(surface.render(p)); + if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-updates.ppm"); + assert(click(surface, 700, 486).install_update); + surface.set_update_status(UpdateStatus::Current); assert(surface.render(p)); + no_action(click(surface, 700, 486)); + assert(click(surface, 180, 486).check_updates); + surface.set_update_status(UpdateStatus::Failed); assert(surface.render(p)); + no_action(click(surface, 700, 486)); assert(click(surface, 280, 610).action == UiAction::Cancel); // footer stays fixed assert(surface.scroll(200, 400, 4.f)); assert(surface.render(p)); diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..689ce1f --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,79 @@ +"""Offline checks for the manual release lookup and attended installer handoff.""" +import importlib.util +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / 'scripts' / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class UpdateTests(unittest.TestCase): + def test_tags(self): + check = load('frameyap_update', 'check-update.py') + current = '0.1.202609282232' + self.assertEqual(check.outcome('{"tag_name":"v0.1.202609292232"}', current), + 'AVAILABLE 0.1.202609292232') + for tag in ('v0.1.202609282232', 'v0.1.202609272232'): + self.assertEqual(check.outcome('{"tag_name":"' + tag + '"}', current), 'CURRENT') + for tag in ('v0.1.202613292232', '0.1.202609292232', 'v0.1.202609292232;sh', + 'v0.1.20260929', '../v0.1.202609292232'): + with self.assertRaises(ValueError, msg=tag): + check.outcome('{"tag_name":"' + tag + '"}', current) + with self.assertRaises(ValueError): + check.outcome('{"tag_name":"v0.1.202609292232"}', '0.1.invalid') + + def test_network_only_on_explicit_check(self): + check = load('frameyap_update_2', 'check-update.py') + with patch.object(check.request, 'build_opener') as build: + with self.assertRaises(SystemExit): + check.main(['--current', '0.1.202609282232']) + build.assert_not_called() + class Response: + def __enter__(self): return self + def __exit__(self, *args): return False + def read(self, limit): + self.limit = limit + return b'{"tag_name":"v0.1.202609292232"}' + class Opener: + def open(self, req, timeout): + self.url, self.timeout = req.full_url, timeout + return Response() + opener = Opener() + with patch.object(check.request, 'build_opener', return_value=opener): + self.assertEqual(check.check('0.1.202609282232'), 'AVAILABLE 0.1.202609292232') + self.assertEqual(opener.url, 'https://api.github.com/repos/baketnk/frame-yap/releases/latest') + self.assertEqual(opener.timeout, 6) + + def test_terminal_command_is_fixed_and_version_checked(self): + helper = load('frameyap_installer_handoff', 'install-update.py') + cmd = helper.installer_command(Path('/owned/bin/install.sh'), '0.1.202609292232') + self.assertEqual(cmd, ['sh', '/owned/bin/install.sh', '--mode', 'binary', + '--version', '0.1.202609292232', '--yes']) + for bad in ('0.1.202609292232;rm', 'v0.1.202609292232', 'latest'): + with self.assertRaises(ValueError): + helper.installer_command(Path('/owned/bin/install.sh'), bad) + + def test_terminal_handoff_requires_tty_before_install(self): + helper = load('frameyap_installer_handoff_2', 'install-update.py') + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / 'scripts').mkdir() + (root / 'bin').mkdir() + (root / 'bin/install.sh').write_text('#!/bin/sh\nexit 0\n') + helper.__file__ = str(root / 'scripts/install-update.py') + with patch.object(helper.sys, 'stdin', io.StringIO('\n')), patch.object(helper.subprocess, 'run') as run: + self.assertEqual(helper.main(['--version', '0.1.202609292232']), 1) + run.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/update_check_test.cpp b/tests/update_check_test.cpp new file mode 100644 index 0000000..4f05bc3 --- /dev/null +++ b/tests/update_check_test.cpp @@ -0,0 +1,58 @@ +#include "update_check.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace frameyap; +namespace { +UpdateResult finish(UpdateCheck& checker) { + for (int i = 0; i < 300; ++i) { + if (auto result = checker.poll()) return *result; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + assert(false && "update helper did not complete"); + return {UpdateResult::State::Failed, {}}; +} +} +int main(int argc, char** argv) { + assert(argc == 2); + char pattern[] = "/tmp/frameyap-update-XXXXXX"; + const auto root = fs::path(::mkdtemp(pattern)); + const auto helper = root / "fixture.py"; + UpdateCheck check(helper, "0.1.202609282232", argv[1]); + assert(!check.busy() && !check.poll()); + assert(!check.start()); // missing file; no child, no network + std::ofstream(helper) << "import time\ntime.sleep(.2)\nprint('AVAILABLE 0.1.202609292232')\n"; + assert(check.start() && check.busy()); + assert(!check.start()); // one explicit check at a time + assert(!check.poll()); // interaction loop does not wait for network/child + auto result = finish(check); + assert(result.state == UpdateResult::State::Available && result.version == "0.1.202609292232"); + assert(!check.busy()); + std::ofstream(helper) << "print('CURRENT')\n"; + assert(check.start()); + assert(finish(check).state == UpdateResult::State::Current); + std::ofstream(helper) << "print('AVAILABLE 0.1.20260929;echo surprise')\n"; + assert(check.start()); + assert(finish(check).state == UpdateResult::State::Failed); + std::ofstream(helper) << "print('x'*400)\n"; + assert(check.start()); + assert(finish(check).state == UpdateResult::State::Failed); + std::ofstream(helper) << "raise SystemExit(1)\n"; + assert(check.start()); + assert(finish(check).state == UpdateResult::State::Failed); + assert(!open_update_terminal(root / "missing.py", "0.1.202609292232")); + assert(!open_update_terminal(helper, "0.1.20260929;echo surprise")); + const char* path = ::getenv("PATH"); + const std::string old_path = path ? path : ""; + ::setenv("PATH", root.c_str(), 1); + assert(!open_update_terminal(helper, "0.1.202609292232")); // no Konsole + ::setenv("PATH", old_path.c_str(), 1); + fs::remove_all(root); +}