Add opt-in Settings release check and attended update handoff

This commit is contained in:
baketnk committed 2026-09-28 18:50:38 -04:00
1 parent dd164aacee
commit eb054199e1
13 files changed
+544 -13

No files matched your search

+12 -2
View File
@@ -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}")
+1
View File
@@ -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.
+12
View File
@@ -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
+63
View File
@@ -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())
+47
View File
@@ -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())
+21 -2
View File
@@ -6,6 +6,7 @@
#include "placement.hpp"
#include "panel_surface.hpp"
#include "companion_apps.hpp"
#include "update_check.hpp"
#include <openvr.h>
@@ -95,6 +96,9 @@ struct Overlay::Impl {
Config config;
PanelSurface surface;
std::optional<std::filesystem::path> plan_launcher, keyboard_launcher;
UpdateCheck updates;
std::filesystem::path install_update_script;
std::string update_version;
Panel panel;
std::vector<ModelAction> 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<UiAction> poll() {
std::vector<UiAction> 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;
+34 -5
View File
@@ -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<Button, 34> buttons{{
constexpr std::array<Button, 36> 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<Button, 34> 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<UiAction> 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<Rect> 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<DateFormat>((static_cast<int>(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; }
}
+4
View File
@@ -26,6 +26,8 @@ struct SurfaceEvent {
std::optional<DateFormat> date_format;
bool recenter = false;
bool open_bindings = false;
bool check_updates = false;
bool install_update = false;
std::optional<ModelAction> model_action;
};
// Header indicators. Absent values are hidden, never shown as zero or as a guess.
@@ -34,6 +36,7 @@ struct StatusIndicators {
std::optional<BatteryLevel> 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<Bounds> input_regions() const;
+158
View File
@@ -0,0 +1,158 @@
#include "update_check.hpp"
#include <cerrno>
#include <csignal>
#include <cstdlib>
#include <fcntl.h>
#include <spawn.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <regex>
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<std::filesystem::path> 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<std::string> args{python_, "-I", helper, "--check", "--current", current_};
std::vector<char*> 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<UpdateResult> 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<std::string> args{terminal->string(), "--hold", "-e", "python3", "-I", script, "--version", version};
std::vector<char*> 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
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <chrono>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
#include <sys/types.h>
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<UpdateResult> 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);
}
+18 -4
View File
@@ -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));
+79
View File
@@ -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()
+58
View File
@@ -0,0 +1,58 @@
#include "update_check.hpp"
#include <cassert>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <unistd.h>
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);
}