mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 02:00:06 +02:00
Add opt-in Settings release check and attended update handoff
This commit is contained in:
1 parent
dd164aacee
commit
eb054199e1
13 files changed
+544
-13
No files matched your search
+12
-2
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user