From 69f5de5a0964f657042b2ab66ec2d28d779e9326 Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 14:31:00 -0400 Subject: [PATCH] Add opt-in experimental overlay input priority --- CMakeLists.txt | 1 + README.md | 5 ++ assets/config.example.json | 1 + docs/design.md | 8 ++- docs/evidence/vulkan-overlay-2026-09-24.md | 8 +++ docs/overlay.md | 44 ++++++++++-- install.sh | 3 + scripts/install_payload.py | 3 + src/config.cpp | 4 ++ src/config.hpp | 2 + src/main.cpp | 2 + src/overlay.cpp | 80 +++++++++++----------- tests/config_test.cpp | 14 ++++ tests/test_installer.py | 5 +- 14 files changed, 131 insertions(+), 49 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a8581a6..090a2e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -117,6 +117,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_executable(frameyap_config_test tests/config_test.cpp) target_link_libraries(frameyap_config_test PRIVATE frameyap_config) + target_compile_options(frameyap_config_test PRIVATE -UNDEBUG) add_test(NAME frameyap.config COMMAND frameyap_config_test "${CMAKE_CURRENT_SOURCE_DIR}/assets") add_executable(frameyap_mount_test tests/mount_test.cpp) target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount) diff --git a/README.md b/README.md index d890644..c798b6c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet panel colors, a font path and Frame controller button mappings; missing fonts fall back to bundled Inconsolata. The installer creates/checks this file and backs it up before repairs. See [overlay configuration](docs/overlay.md#user-theme-and-controller-configuration). +- **Experimental input priority:** set `"input_priority": "experimental"` in + that config and enable SteamVR's Developer option **Enable global input from + overlays**. FrameYap then requests priority for its bound controller sources. + This may consume controls used by games or the dashboard; coexistence on Frame + is under test. The default is `"normal"`; restart FrameYap after changing it. - **Review-first:** focus your destination, then press Insert. No automatic insertion or submission. Maximum clip 20 seconds; accidental taps under 200 ms are discarded. - While the native app is Ready, it keeps the mic device open and discards idle diff --git a/assets/config.example.json b/assets/config.example.json index 97b63c3..3f81063 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -1,5 +1,6 @@ { "font": "", + "input_priority": "normal", "theme": { "background": "#0c101b", "card": "#141c2b", diff --git a/docs/design.md b/docs/design.md index 540ea8b..d39bd65 100644 --- a/docs/design.md +++ b/docs/design.md @@ -112,9 +112,11 @@ silently takes a game's button away. Check action activity and neutral rearm. OpenVR 2.15.6 documents experimental overlay action-set priorities `0x01000000..0x01FFFFFF`, gated by SteamVR's **Experimental overlay input overrides** -setting. This can selectively override scene input, but is not guaranteed enabled -or usable on this Frame. Do not toggle it automatically. Begin with overlay mic -controls; validate a global PTT binding separately with a scene active, dashboard +setting. FrameYap now requests the minimum experimental priority when its config +has `"input_priority": "experimental"`; the default is `"normal"` (priority zero). +This can selectively override scene input, but delivered input and dashboard +coexistence still need testing on Frame. FrameYap only reads the SteamVR permission +setting and never toggles it automatically. Validate bindings with a scene active, dashboard open/closed, lost tracking, and reconnection. Overlay interactivity/input ownership is distinct from OS keyboard focus. diff --git a/docs/evidence/vulkan-overlay-2026-09-24.md b/docs/evidence/vulkan-overlay-2026-09-24.md index d37ddae..29a86d3 100644 --- a/docs/evidence/vulkan-overlay-2026-09-24.md +++ b/docs/evidence/vulkan-overlay-2026-09-24.md @@ -51,3 +51,11 @@ not establish SteamVR texture acceptance, orientation, click behavior or a flicker fix. No microphone, inference or text/Enter delivery was exercised. The next human headset check should compare both static action clicks and content-changing tabs using the installed Vulkan build's `--check-controls`. + +## Subsequent wearer report — 2026-09-24 + +After testing the installed Vulkan build, the user confirmed that the flicker +is fixed. They separately reported controller shortcuts becoming unavailable +in system laser/dashboard interaction states while pointer clicks work. This +is wearer confirmation of the visual fix and a distinct input-routing issue; +it does not establish microphone, transcription or text-delivery acceptance. diff --git a/docs/overlay.md b/docs/overlay.md index 2f12878..9d6f95a 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -73,6 +73,7 @@ installer creates one with defaults on first install. Copy the shipped ```json { "font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "input_priority": "normal", "theme": { "background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", @@ -101,8 +102,36 @@ the exact prior bytes under `config.json.backup-*` before a repair and refuses symlink/oversized config paths; valid customizations remain intact. The installed launcher no longer pins `--font`, so this selection takes effect. Direct native launches with bad JSON, colors or button mappings fail startup rather than -silently changing input behavior. This does not enable experimental SteamVR -action overrides or prove delivery in games. +silently changing input behavior. + +### Experimental controller input priority + +There are two independent gates: + +1. SteamVR's Developer setting **Enable global input from overlays** (called + **Experimental overlay input overrides** in the SDK documentation) permits + global action priority. FrameYap reads it and never changes it. +2. FrameYap config `"input_priority": "experimental"` requests + `k_nActionSetOverlayGlobalPriorityMin` (`0x01000000`) for its existing action + set. `"normal"` or an omitted field requests priority zero. Restart FrameYap + after changing the config. Invalid values fail native startup; the installer + repairs them to normal and backs up the prior bytes. + +This applies to all controller sources bound to FrameYap actions, including +custom SteamVR bindings. It does not replace the action manifest or change the +physical button mappings. Bound sources can take input away from games or the +dashboard. The request stays the same with the dashboard open/closed and Lasers +anytime on/off so the experiment can compare those modes. Successful API calls +do not prove that controller actions arrive or that dashboard interaction works. + +Native startup logs the requested priority and SteamVR permission separately. +The no-audio/no-delivery `--check-controls` probe also logs dashboard state, the +Lasers anytime flag, `IsInputAvailable`, panel visibility/focus, and activity, +press state and pose/role acceptance for all six actions. The laser flag is our +request, not a detector for every system laser. Compare the same bindings in +each dashboard/laser state; check pointer clicks and press/release through mode +transitions too. Return `input_priority` to `normal` and relaunch to end the +FrameYap experiment. ### Placement settings @@ -139,7 +168,10 @@ missing, symlinked or invalid files mean off. A failed save keeps the new choice only for the running session and shows a warning. To turn it back on after disabling it with the dashboard closed, open the dashboard to use its laser on the Settings button. Controls-only checks can toggle it temporarily -but do not save the preference. This has not yet been accepted in a headset. +but do not save the preference. The wearer reports that pointer clicks work, +while normal-priority controller actions become unavailable in system laser +mode, including when this preference is enabled. Experimental-priority +coexistence remains unverified. ### Hardware-free UI checks @@ -199,9 +231,9 @@ action explicitly begins on down and ends on up. On tracking-pose invalidity, action inactivity or overlay focus loss, a held capture emits Cancel, and reconnection requires a neutral observation before any new press. PTT and left Enter require an enabled panel; clickable Record remains available for retry -after an error and Cancel is always available. The action set has normal priority: global input -while a scene is active is not guaranteed, and experimental overlay overrides -are not switched on automatically. +after an error and Cancel is always available. The action set defaults to normal +priority; the experimental config request is described above. Neither priority +guarantees delivery while a game or dashboard owns input. The UI cannot itself guarantee a capture started when a BeginRecord action arrives: the owning runtime checks worker readiness. `Cancel` invalidates diff --git a/install.sh b/install.sh index c6db559..2cb04ba 100755 --- a/install.sh +++ b/install.sh @@ -33,6 +33,7 @@ VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z") DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z") CONFIG_DEFAULTS = { "font": "", + "input_priority": "normal", "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff"}, @@ -87,6 +88,8 @@ def normalized_config(data): if not isinstance(data, dict): data = {} fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""} + priority = data.get("input_priority", "normal") + fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal" for section in ("theme", "buttons"): source = data.get(section) source = source if isinstance(source, dict) else {} diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 4748fdd..09c94c4 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -22,6 +22,7 @@ VERSION_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z") DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z") CONFIG_DEFAULTS = { "font": "", + "input_priority": "normal", "theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9", "muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87", "frame_start": "#1fff91", "frame_end": "#1f70ff"}, @@ -76,6 +77,8 @@ def normalized_config(data): if not isinstance(data, dict): data = {} fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""} + priority = data.get("input_priority", "normal") + fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal" for section in ("theme", "buttons"): source = data.get(section) source = source if isinstance(source, dict) else {} diff --git a/src/config.cpp b/src/config.cpp index f0f1c9b..96cc5d6 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -173,6 +173,10 @@ Config load_config(const std::filesystem::path& path) { if (key == "font") { if (!value.is_string) throw std::runtime_error("Config font must be a path string"); config.font = value.value; + } else if (key == "input_priority") { + if (!value.is_string || (value.value != "normal" && value.value != "experimental")) + throw std::runtime_error("Config input_priority must be normal or experimental"); + config.experimental_input_priority = value.value == "experimental"; } else if (key == "theme") { if (!value.is_object) throw std::runtime_error("Config theme must be an object"); for (const auto& [name, v] : value.object) { diff --git a/src/config.hpp b/src/config.hpp index a758946..57fd437 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -15,6 +15,8 @@ struct Theme { struct Config { Theme theme; std::string font; // absolute TTF/OTF path; empty uses the bundled face + // Requests OpenVR's experimental global action priority; SteamVR must allow it too. + bool experimental_input_priority = false; // OpenVR action name -> physical Frame controller input path; empty disables it. std::map buttons; }; diff --git a/src/main.cpp b/src/main.cpp index 3218f9b..9c1aa16 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,8 @@ void help() { "Theme, font and Frame button mappings: $XDG_CONFIG_HOME/frameyap/config.json\n" "(or ~/.config/frameyap/config.json). CLI --font overrides config; missing fonts\n" "fall back to bundled Inconsolata, then a system DejaVu face.\n" + "Config input_priority: normal (default) or experimental; also requires\n" + "SteamVR Developer setting Enable global input from overlays.\n" "Right X: hold to speak, release to review (default Frame binding).\n" "Grip gestures are remappable but may be unavailable in the dashboard.\n" "Left grip: double-tap for explicit Enter when active.\n" diff --git a/src/overlay.cpp b/src/overlay.cpp index d29dbef..fa8f3b0 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -34,6 +34,7 @@ void configure_registry() { ::setenv("VR_PATHREG_OVERRIDE", registry.c_str(), 0); } constexpr int W = PanelSurface::width, H = PanelSurface::height; +constexpr std::array action_names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}}; void overlay_check(vr::EVROverlayError err, vr::IVROverlay* api, const char* op) { if (err != vr::VROverlayError_None) throw std::runtime_error(std::string(op) + ": " + api->GetOverlayErrorNameFromEnum(err)); @@ -136,15 +137,13 @@ struct Overlay::Impl { throw std::runtime_error("Could not set OpenVR action manifest path"); if (input->GetActionSetHandle("/actions/frameyap", &action_set) != vr::VRInputError_None) throw std::runtime_error("Could not find FrameYap action set"); - constexpr std::array names{{"left_grip", "right_grip", "ptt", "cancel", "insert", "enter"}}; - for (size_t i = 0; i < names.size(); ++i) - if (input->GetActionHandle((std::string("/actions/frameyap/in/") + names[i]).c_str(), &actions[i]) != vr::VRInputError_None) - throw std::runtime_error(std::string("Could not find action ") + names[i]); + for (size_t i = 0; i < action_names.size(); ++i) + if (input->GetActionHandle((std::string("/actions/frameyap/in/") + action_names[i]).c_str(), &actions[i]) != vr::VRInputError_None) + throw std::runtime_error(std::string("Could not find action ") + action_names[i]); overlay_check(overlay->CreateOverlay("local.frameyap.overlay.panel", "FrameYap", &handle), overlay, "CreateOverlay"); overlay_check(overlay->SetOverlayWidthInMeters(handle, 0.85f), overlay, "SetOverlayWidthInMeters"); overlay_check(overlay->SetOverlayInputMethod(handle, vr::VROverlayInputMethod_Mouse), overlay, "SetOverlayInputMethod"); - // Normal priority actions stay unchanged. This separate, explicit - // preference requests system-wide laser mouse mode only while the + // This separate preference requests system-wide laser mouse mode only while the // panel is visible; it may affect interaction with a running game. if (lasers_anytime && overlay->SetOverlayFlag(handle, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, true) != vr::VROverlayError_None) { lasers_anytime = false; @@ -157,6 +156,7 @@ struct Overlay::Impl { system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size())); place(); draw(Panel{"Disabled", "", "Record to start local worker", false, false}); + std::cout << input_mode_status() << std::endl; } catch (...) { cleanup(); throw; } } ~Impl() { cleanup(); } @@ -250,6 +250,28 @@ struct Overlay::Impl { for (size_t i = 1; i < edges.size(); ++i) edges[i].reset(); surface.reset_pointers(); } + int32_t action_priority() const { + return config.experimental_input_priority ? vr::k_nActionSetOverlayGlobalPriorityMin : 0; + } + std::string input_mode_status() const { + // Read the runtime permission separately: requesting a priority does not + // enable SteamVR's global setting or prove delivery of controller input. + vr::EVRSettingsError settings_error = vr::VRSettingsError_None; + auto* settings = vr::VRSettings(); + const bool allowed = settings && settings->GetBool(vr::k_pch_SteamVR_Section, + vr::k_pch_SteamVR_AllowGlobalActionSetPriority, &settings_error); + const char* permission = !settings || settings_error != vr::VRSettingsError_None ? "unavailable" : + allowed ? "enabled" : "disabled"; + bool laser_flag = false; + const auto laser_error = overlay->GetOverlayFlag(handle, + vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, &laser_flag); + return std::string("Input priority-request=") + (config.experimental_input_priority ? "experimental" : "normal") + + " (" + std::to_string(action_priority()) + ") SteamVR-global-input=" + permission + + "\nMode dashboard=" + (overlay->IsDashboardVisible() ? "Y" : "N") + + " lasers-anytime=" + (laser_error == vr::VROverlayError_None ? (laser_flag ? "Y" : "N") : "n/a") + + " system-input-available=" + (system->IsInputAvailable() ? "Y" : "N") + + " panel-shown=" + (shown ? "Y" : "N") + " focus-gate=" + (focus ? "Y" : "N"); + } // Bound actions are accepted only with a connected tracked source. A held input // following loss of activity must return to neutral before generating an edge. std::pair digital(size_t index) { @@ -350,7 +372,10 @@ struct Overlay::Impl { vr::VRActiveActionSet_t set{}; set.ulActionSet = action_set; set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle; - set.nPriority = 0; // normal priority; no experimental scene-input overrides + // Explicit opt-in affects only sources bound to our existing action set. + // Keep requesting it across dashboard/laser states: those are what the + // experiment compares. SteamVR's separate permission gate is never changed here. + set.nPriority = action_priority(); action_update_error = input->UpdateActionState(&set, sizeof(set), 1); if (action_update_error != vr::VRInputError_None) { reset_input(result); return result; @@ -392,42 +417,19 @@ Overlay::~Overlay() = default; std::vector Overlay::poll() { return impl_->poll(); } void Overlay::draw(const Panel& panel) { impl_->draw(panel); } std::string Overlay::controls_status() { - // Diagnostic only: distinguish SteamVR binding/activity from our stricter - // pose/role gate. Raw legacy state is read-only and may be unavailable. - bool laser_flag = false; - const auto laser_error = impl_->overlay->GetOverlayFlag(impl_->handle, - vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, &laser_flag); - std::string result = "SteamVR update=" + std::to_string(int(impl_->action_update_error)) + - " dashboard=" + (impl_->overlay->IsDashboardVisible() ? "Y" : "N") + - " lasers-anytime=" + (laser_error == vr::VROverlayError_None ? (laser_flag ? "Y" : "N") : "n/a"); - for (size_t i = 0; i < 2; ++i) { - const auto role = i == 0 ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand; - const auto device = impl_->system->GetTrackedDeviceIndexForControllerRole(role); - const bool tracked = device < impl_->poses.size() && impl_->poses[device].bPoseIsValid && - impl_->system->IsTrackedDeviceConnected(device); + // Compare the same actions across modes, before and after our pose/role gate. + // IsInputAvailable and a successful UpdateActionState are not delivery proof. + std::string result = impl_->input_mode_status() + + " update=" + std::to_string(int(impl_->action_update_error)); + for (size_t i = 0; i < action_names.size(); ++i) { vr::InputDigitalActionData_t data{}; const auto error = impl_->input->GetDigitalActionData(impl_->actions[i], &data, sizeof(data), vr::k_ulInvalidInputValueHandle); - vr::InputOriginInfo_t origin{}; - const bool origin_ok = error == vr::VRInputError_None && data.activeOrigin != vr::k_ulInvalidInputValueHandle && - impl_->input->GetOriginTrackedDeviceInfo(data.activeOrigin, &origin, sizeof(origin)) == vr::VRInputError_None && - origin.trackedDeviceIndex == device; - vr::VRControllerState_t raw{}; - const bool raw_ok = device < impl_->poses.size() && impl_->system->GetControllerState(device, &raw, sizeof(raw)); - result += std::string(i == 0 ? "\nLeft:" : "\nRight:") + + const auto [accepted, down] = impl_->digital(i); + result += std::string("\nAction ") + action_names[i] + " err=" + std::to_string(int(error)) + " active=" + (data.bActive ? "Y" : "N") + - " down=" + (data.bState ? "Y" : "N") + " pose=" + (tracked ? "Y" : "N") + - " origin=" + (origin_ok ? "Y" : "N") + - " raw=" + (raw_ok ? ((raw.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_Grip)) ? "down" : "up") : "n/a"); + " down=" + (data.bState ? "Y" : "N") + + " pose-role-accepted=" + (accepted ? "Y" : "N") + " gated-down=" + (down ? "Y" : "N"); } - // The default Frame binding maps right X to the named hold-to-talk action. - // Report the action's own activity separately from the stricter pose gate; - // remapped bindings may intentionally have a different origin. - vr::InputDigitalActionData_t ptt{}; - const auto ptt_error = impl_->input->GetDigitalActionData(impl_->actions[2], &ptt, sizeof(ptt), vr::k_ulInvalidInputValueHandle); - const auto [accepted, down] = impl_->digital(2); - result += "\nPTT (default right X): err=" + std::to_string(int(ptt_error)) + - " active=" + (ptt.bActive ? "Y" : "N") + " down=" + (ptt.bState ? "Y" : "N") + - " accepted=" + (accepted ? "Y" : "N") + " gated-down=" + (down ? "Y" : "N"); return result; } std::string Overlay::pointer_status() const { diff --git a/tests/config_test.cpp b/tests/config_test.cpp index d9a4b1f..2841ce0 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -27,10 +27,24 @@ int main(int argc, char** argv) { assert(default_config_path().empty()); ::setenv("XDG_CONFIG_HOME", dir.c_str(), 1); assert(load_config(path).buttons.empty()); + assert(!load_config(path).experimental_input_priority); auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json"); assert(example.buttons.at("ptt") == "/user/hand/right/input/x"); assert(example.font.empty()); + assert(!example.experimental_input_priority); std::filesystem::create_directories(path.parent_path()); + put(path, R"({"input_priority":"experimental"})"); + auto experimental = load_config(path); + assert(experimental.experimental_input_priority); + // A priority request must preserve the user's existing action manifest/bindings. + assert(action_manifest(argv[1], experimental) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json")); + put(path, R"({"input_priority":"normal"})"); + assert(!load_config(path).experimental_input_priority); + for (const auto* invalid : {R"({"input_priority":true})", R"({"input_priority":16777216})", + R"({"input_priority":"highest"})", R"({"input_priority":null})"}) { + put(path, invalid); + fails([&] { load_config(path); }); + } put(path, R"({"theme":{"background":"#123ABC","accent":"#abcdef","frame_end":"#010203"},"font":"/nonexistent/face.ttf","buttons":{"ptt":"/user/hand/left/input/y","cancel":"/user/hand/right/input/b","right_grip":""}})"); auto config = load_config(path); assert((config.theme.background == Rgba{0x12, 0x3a, 0xbc, 255})); diff --git a/tests/test_installer.py b/tests/test_installer.py index c2ce0f8..83ad235 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -124,21 +124,24 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["buttons"]["ptt"], "/user/hand/left/input/y") self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"]) self.assertEqual(fixed["buttons"]["cancel"], "") + self.assertEqual(fixed["input_priority"], "normal") backups = list(config.parent.glob("config.json.backup-*")) self.assertEqual(len(backups), 1) self.assertEqual(backups[0].read_bytes(), original) + fixed["input_priority"] = "experimental" compact = json.dumps(fixed, separators=(",", ":")).encode() config.write_bytes(compact) self.install("v1", archive, digest) self.assertEqual(config.read_bytes(), compact) self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1) - original = b'{"font":"/system/face.ttf","theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}' + original = b'{"font":"/system/face.ttf","input_priority":"highest","theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}' config.write_bytes(original) self.install("v1", archive, digest) fixed = json.loads(config.read_text()) self.assertEqual(fixed["font"], "/system/face.ttf") self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"]) self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset + self.assertEqual(fixed["input_priority"], "normal") self.assertNotIn("old_option", fixed) self.assertEqual(sorted(p.read_bytes() for p in config.parent.glob("config.json.backup-*")), sorted([backups[0].read_bytes(), original])) original = b'{"font":"one","font":"two"'