diff --git a/CMakeLists.txt b/CMakeLists.txt index dff5ad4..f09793e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,7 +253,7 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING) 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) + foreach(test IN ITEMS test_backends.py test_model_files.py test_backend_dispatch.py test_telemetry.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}") set_tests_properties("frameyap.${test_name}" PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 60) diff --git a/README.md b/README.md index 588093a..3f0b1be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FrameYap -On-device voice typing for Steam Frame. Hold a button, speak, check the text, and type it into whatever app has focus. Speech recognition runs on the headset itself; nothing is sent to the cloud. +On-device voice typing for Steam Frame. Hold a button, speak, check the text, and type it into whatever app has focus. Speech recognition runs on the headset itself. [![FrameYap’s review panel on Steam Frame, with recording controls, controller and headset battery levels, and Settings and Bindings tabs.](docs/images/frameyap-ui.png)](https://youtu.be/9dg2KfojYTE) @@ -55,7 +55,15 @@ The header shows battery levels for your controllers and headset. The **Buttons ## Privacy -Recognition runs locally with the Parakeet Redux model. The installer downloads FrameYap from GitHub, the model from Hugging Face and the Python packages (moondream, Kestrel, CPU Torch) from PyPI and PyTorch; after that, FrameYap works offline. By default the microphone stays open while FrameYap is running and idle audio is thrown away; turn on **Settings → Close mic when idle** to close it between recordings (it may clip the first syllable). Recordings and transcripts are not kept, unless you turn on **Advanced debug**, whose logs may contain speech. +Recognition runs locally with the Parakeet Redux model. **FrameYap itself does not collect or send analytics.** Its separately installed Moondream/Kestrel runtime includes upstream telemetry; see [Kestrel's telemetry disclosure](https://pypi.org/project/kestrel/0.8.0/#description). + +**Settings → Moondream analytics is OFF by default**, including for existing settings files without this option. With it OFF, FrameYap suppresses the Kestrel reporter before starting recognition. Turning it ON requires confirming the in-app disclosure and successfully saving the preference. ON leaves the upstream reporter enabled: Kestrel 0.8.0 sends hostname, model/service name, device details, timestamps, random instance/report IDs, and aggregate request/error/token counts to the default endpoint `https://api.moondream.ai/v1/photon/telemetry`. Reports are attempted at model startup, about every 60 seconds (even while idle), and on graceful runtime shutdown. On FrameYap's CPU path, device information identifies CPU and zero GPU memory. The audited reporter does not send audio or transcripts. + +Changing the setting restarts recognition and discards current recording, review text and queued dictation. Turning it OFF stops the old worker without a final graceful-shutdown report; it cannot recall requests already sent. The choice is saved as `moondream_analytics` in `~/.config/frameyap/config.json`. If saving OFF fails, it still applies for this session; the panel warns that the old saved choice may return next launch and offers **Save analytics OFF** to retry. + +Kestrel 0.8.0 has no supported opt-out, so OFF uses a narrow in-process monkey-patch for **moondream 2.4.0 / Kestrel 0.8.0**. It does not edit installed dependency files or affect other programs. With OFF selected, an unknown version or changed reporter interface stops recognition with **Telemetry protection unavailable** rather than silently allowing analytics. Quit FrameYap, then restore the pinned runtime using the `--install-runtime --yes` command in Troubleshooting; upgrades need a fresh compatibility review. ON does not apply that patch or its version guard. This is not a network sandbox for every third-party library. Earlier releases without this setting did not suppress Kestrel telemetry. See [worker implementation and verification](docs/worker.md#moondream-analytics). + +The installer downloads FrameYap from GitHub, the model from Hugging Face and the Python packages (moondream, Kestrel, CPU Torch) from PyPI and PyTorch. **Check for updates** contacts GitHub when pressed. Once installed, recognition can run offline. By default the microphone stays open while FrameYap is running and idle audio is thrown away; turn on **Settings → Close mic when idle** to close it between recordings (it may clip the first syllable). Recordings and transcripts are not kept, unless you turn on **Advanced debug**, whose logs may contain speech. ## Update and uninstall diff --git a/assets/config.example.json b/assets/config.example.json index c1c110d..2474d07 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -2,6 +2,7 @@ "font": "", "input_priority": "normal", "advanced_debug": false, + "moondream_analytics": false, "auto_insert": false, "close_mic_when_idle": false, "backend": "redux", diff --git a/docs/worker.md b/docs/worker.md index ae610a8..4749efe 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -6,7 +6,8 @@ supply the optional `backend`, `manifest_dir`, `root` arguments for the manifest dispatcher. Poll until `ready()`, then `submit(id, pcm)` and poll for one `WorkerReply` (text or privacy-safe per-request error). One request at a time; no queue, no capture and no input injection. `stop()` discards pending audio, -terminates/reaps **only its direct child** (TERM, bounded 500 ms, then KILL), +terminates/reaps its direct child (TERM, bounded 500 ms, then KILL), +and kills remaining processes in its dedicated owned worker group, and is safe to repeat. Destruction stops it. `start()` returns without waiting for model load; `poll()` reports warmup/load/crash/timeout/protocol errors by throwing, then stops. Launch uses `posix_spawn`, safe with the host's OpenVR/SDL @@ -35,7 +36,7 @@ The private pipes use unsigned LE32 payload lengths (1..65536), a one-byte message type and, for requests/replies, unsigned LE64 request ID. `T` + ID requests reading the fixed clip; `Y` means ready; `F` means load failure (`M` for missing/mismatched pinned model or private clip directory, `I` for a -missing Python dependency, `D` for runtime/model load failure); `R` + ID + UTF-8 +missing Python dependency, `P` for unavailable telemetry protection, `D` for runtime/model load failure); `R` + ID + UTF-8 text and `E` + ID + privacy-safe UTF-8 error are replies. Text is at most 4096 bytes. An unexpected or duplicate reply, wrong ID, extra frame, closed pipe or oversized frame stops the worker. A correlated `E` reply or an invalid @@ -86,6 +87,61 @@ Native-only archives do not bundle the runtime; the explicit Limited ARM64 measurements were taken during development; they are not a claim of complete headset acceptance. +## Moondream analytics + +The persistent `moondream_analytics` boolean defaults to false, including when +absent from existing config. Settings requires a rendered disclosure and Confirm +Enable before saving true; failed enabling saves leave it off. Disabling takes +effect in-session even if saving fails, with an explicit next-launch warning and +retry control. The native controller passes the effective setting before its first +warmup and every restart. It overwrites inherited `FRAMEYAP_MOONDREAM_ANALYTICS`; +the manifest dispatcher forwards opt-in only to the known Redux Python launcher. +Direct worker invocation treats only environment value `1` as enabled; missing +or malformed values are off. Manual config edits are read on application restart. + +With analytics off, the worker verifies moondream 2.4.0 and Kestrel 0.8.0 and the +private reporter coroutine interface, then replaces `PhotonReporter._flush_window` +with an async no-op before engine construction. Startup, periodic, shutdown and +nonrotating authentication-retry uploads share that method. It returns no account +standing, disabling cloud finetune authentication; FrameYap only uses the local +base Redux model. Incompatible versions/interfaces emit `F/P` and do not start +inference. Missing packages retain the existing `F/I` error. Enabled analytics +leave upstream behavior untouched, including its allowlisted API-base override +(`https://api-staging.moondream.ai`) and use of an inherited `MOONDREAM_API_KEY` +authentication header if present. FrameYap's local model needs no API key. +No installed package files are changed. + +OFF persistence is deferred until after the old worker is stopped; a slow or +failed config write cannot keep the old enabled reporter running. Enabling +requires successful write, flush, close and rename before becoming effective. + +Changing analytics cancels audio/review/queued delivery and immediately kills the +owned worker process group before closing its pipes, without graceful telemetry +hooks. The direct worker is reaped before replacement starts with the new choice. +Already sent/in-flight network requests cannot be recalled. Normal shutdown still +uses the existing bounded TERM/KILL cleanup. This is worker-scoped protection, +not an OS network sandbox or a guarantee about all native/transitive dependencies. + +Audit source: [Kestrel 0.8.0](https://pypi.org/project/kestrel/0.8.0/) wheel +`kestrel/photon.py`, `kestrel/engine/core.py`, and +[moondream 2.4.0](https://pypi.org/project/moondream/2.4.0/) `moondream/photon_vl.py`. +The Kestrel wheel SHA-256 is +`15c65bf12983d347330082838d4c6a9678a7f00c0f32ae089d9ccad7482e62bd`. +Re-audit before changing the allowed versions: metadata/interface checks do not +detect a locally modified package retaining its original version. + +Default offline tests cover config migration/persistence/failures, rendered opt-in +consent and cancellation, initial worker policy, toggles during pending work, +restart/cleanup, and version/interface failures. An optional exact-wheel test +executes only the upstream reporter with fake HTTP/runtime modules, checking +startup, idle periodic, shutdown and authentication retries in both states. It +never uploads, loads a model, or downloads dependencies: + +```sh +FRAMEYAP_TEST_KESTREL_WHEEL=/path/to/kestrel-0.8.0-py3-none-any.whl \ + python3 -m unittest discover -s tests -p test_telemetry.py -v +``` + ## Advanced debugging Explicitly set `"advanced_debug": true` in config or enable Settings → Advanced diff --git a/python/frameyap/backend_worker.py b/python/frameyap/backend_worker.py index 6835599..1e6dab8 100644 --- a/python/frameyap/backend_worker.py +++ b/python/frameyap/backend_worker.py @@ -55,6 +55,11 @@ def serve(backend, root, python, model_dir, clip_dir, threads, advanced_debug=Fa # launchers need not accept --advanced-debug or know this environment key. env["FRAMEYAP_ADVANCED_DEBUG"] = "1" if (advanced_debug and backend.id == "redux" and backend.launcher["type"] == "python" and backend.launcher["path"] == "python/frameyap/worker.py") else "0" + # Only our known Redux launcher receives the explicit analytics opt-in. + # A caller's inherited value must not enable it for arbitrary backends. + env["FRAMEYAP_MOONDREAM_ANALYTICS"] = "1" if ( + os.environ.get("FRAMEYAP_MOONDREAM_ANALYTICS") == "1" and backend.id == "redux" and + backend.launcher["type"] == "python" and backend.launcher["path"] == "python/frameyap/worker.py") else "0" # A child cannot inherit input-delivery authority. stdout is exclusively frames. try: child = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -74,7 +79,7 @@ def serve(backend, root, python, model_dir, clip_dir, threads, advanced_debug=Fa try: ready = read_frame(child.stdout.fileno()) if ready is None or ready[:1] == b"F": - send_frame(output_fd, b"F", ready[1:2] if ready and ready[1:2] in (b"M", b"I") else b"D") + send_frame(output_fd, b"F", ready[1:2] if ready and ready[1:2] in (b"M", b"I", b"P") else b"D") return 1 if ready != b"Y": send_frame(output_fd, b"F", b"D") diff --git a/python/frameyap/worker.py b/python/frameyap/worker.py index 0ca4d6f..05d430d 100644 --- a/python/frameyap/worker.py +++ b/python/frameyap/worker.py @@ -8,6 +8,8 @@ by default, or captured privately with explicit --advanced-debug. This module ne """ import argparse +import importlib.metadata +import inspect import os from pathlib import Path import stat @@ -25,6 +27,10 @@ except ImportError: # direct executable script from model_files import DEFAULT_MANIFEST_DIR, ManifestError, check_model, load_backends +class TelemetryProtectionError(RuntimeError): + """An unaudited runtime must not start while analytics are disabled.""" + + class LocalModelError(ValueError): """Missing, incomplete or wrong pinned local weights (never a download cue).""" @@ -84,6 +90,34 @@ def local_model(path, manifest_dir=DEFAULT_MANIFEST_DIR): return str(Path(path)) +def disable_kestrel_telemetry(): + """Patch only the audited runtime, before constructing any engine. + + Kestrel 0.8.0 has no supported opt-out. Startup, periodic, shutdown and + authentication-retry uploads share this coroutine. Keep the patch local to + this worker process and re-audit both packages before allowing upgrades. + """ + for package, expected in (("moondream", "2.4.0"), ("kestrel", "0.8.0")): + if importlib.metadata.version(package) != expected: + raise TelemetryProtectionError("unsupported runtime version for telemetry protection") + from kestrel.photon import PhotonReporter + flush = getattr(PhotonReporter, "_flush_window", None) + if not inspect.iscoroutinefunction(flush): + raise TelemetryProtectionError("unsupported telemetry reporter interface") + parameters = inspect.signature(flush).parameters + if (list(parameters) != ["self", "rotate"] or + parameters["rotate"].kind != inspect.Parameter.KEYWORD_ONLY or + parameters["rotate"].default is not True): + raise TelemetryProtectionError("unsupported telemetry reporter interface") + + async def no_telemetry(self, *, rotate=True): + # No account standing also disables cloud finetune authentication. + # FrameYap uses only the local base Redux model, not cloud finetunes. + return None + + PhotonReporter._flush_window = no_telemetry + + def load_model(path, threads, manifest_dir=DEFAULT_MANIFEST_DIR): # Set before importing moondream/torch/huggingface dependencies, even when # invoked directly without the native adapter. @@ -98,6 +132,10 @@ def load_model(path, threads, manifest_dir=DEFAULT_MANIFEST_DIR): import torch torch.set_num_threads(threads) torch.set_num_interop_threads(1) + # Native Settings passes an explicit 0/1 through the dispatcher. Missing + # or malformed values remain OFF; a changed setting starts a fresh worker. + if os.environ.get("FRAMEYAP_MOONDREAM_ANALYTICS") != "1": + disable_kestrel_telemetry() import moondream as md # noqa: explicit lazy import return md.photon("moondream/parakeet-redux", model_path=directory, device="cpu", cpu_threads=threads) @@ -212,6 +250,10 @@ def main(argv=None): if args.advanced_debug: traceback.print_exc(file=sys.stderr) send_frame(protocol_fd, b"F", b"M") return 1 + except TelemetryProtectionError: + if args.advanced_debug: traceback.print_exc(file=sys.stderr) + send_frame(protocol_fd, b"F", b"P") + return 1 except ImportError: if args.advanced_debug: traceback.print_exc(file=sys.stderr) send_frame(protocol_fd, b"F", b"I") diff --git a/src/config.cpp b/src/config.cpp index 1ca52d4..29ec897 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -169,6 +169,10 @@ void write_file(const std::filesystem::path& path, const std::string& content, b std::ofstream out(temporary, std::ios::binary | std::ios::trunc); out << content; if (!out) throw std::runtime_error("Could not write file: " + path.string()); + out.flush(); + if (!out) throw std::runtime_error("Could not flush file: " + path.string()); + out.close(); + if (!out) throw std::runtime_error("Could not close file: " + path.string()); } if (private_file) std::filesystem::permissions(temporary, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, std::filesystem::perm_options::replace); @@ -206,6 +210,9 @@ Config load_config(const std::filesystem::path& path) { } else if (key == "advanced_debug") { if (!value.is_bool) throw std::runtime_error("Config advanced_debug must be a boolean"); config.advanced_debug = value.value == "true"; + } else if (key == "moondream_analytics") { + if (!value.is_bool) throw std::runtime_error("Config moondream_analytics must be a boolean"); + config.moondream_analytics = value.value == "true"; } else if (key == "auto_insert") { if (!value.is_bool) throw std::runtime_error("Config auto_insert must be a boolean"); config.auto_insert = value.value == "true"; @@ -359,6 +366,15 @@ bool save_bool_option(const std::filesystem::path& path, std::string_view key, b bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept { return save_bool_option(path, "advanced_debug", enabled); } +bool save_moondream_analytics(const std::filesystem::path& path, bool enabled) noexcept { + return save_bool_option(path, "moondream_analytics", enabled); +} +bool apply_moondream_analytics(Config& config, const std::filesystem::path& path, bool enabled) noexcept { + if (!enabled) config.moondream_analytics = false; + const bool saved = save_moondream_analytics(path, enabled); + if (saved) config.moondream_analytics = enabled; + return saved; +} bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept { return save_bool_option(path, "auto_insert", enabled); } diff --git a/src/config.hpp b/src/config.hpp index 85c1155..a0cbda7 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -28,6 +28,7 @@ struct Config { // Requests OpenVR's experimental global action priority; SteamVR must allow it too. bool experimental_input_priority = false; bool advanced_debug = false; // opt-in full diagnostic logging; never raw audio recording + bool moondream_analytics = false; // explicit opt-in to the speech runtime's outbound analytics bool auto_insert = false; // opt-in; runtime also requires uninterrupted verified Xwayland focus bool close_mic_when_idle = false; // default keeps the device open, discarding idle audio bool lock_layout = false; // hide the grab and scale handles when enabled @@ -44,6 +45,10 @@ Config load_config(const std::filesystem::path& path); // Update only the selected boolean in an existing valid config; false on invalid/unwritable paths. // Other user customizations and formatting are retained; creates a minimal config if absent. bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept; +bool save_moondream_analytics(const std::filesystem::path& path, bool enabled) noexcept; +// Enabling takes effect only after persistence succeeds; disabling always takes +// effect for this session. Returns whether the requested choice was saved. +bool apply_moondream_analytics(Config& config, const std::filesystem::path& path, bool enabled) noexcept; bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept; bool save_gradient_enabled(const std::filesystem::path& path, bool enabled) noexcept; bool save_close_mic_when_idle(const std::filesystem::path& path, bool enabled) noexcept; diff --git a/src/controller.cpp b/src/controller.cpp index 957693d..6c606dc 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -23,17 +23,18 @@ std::string state_label(State state) { Controller::Controller(ControllerAudio& audio, ControllerWorker& worker, DeliveryFactory delivery, FocusFactory focus, std::vector quick_inputs, bool auto_insert, - bool advanced_debug, bool close_mic_when_idle, PacedDelivery* paced) + bool advanced_debug, bool close_mic_when_idle, PacedDelivery* paced, + bool moondream_analytics) : audio_(audio), worker_(worker), delivery_(std::move(delivery)), paced_(paced), focus_factory_(std::move(focus)), quick_inputs_(std::move(quick_inputs)), auto_insert_(auto_insert), advanced_debug_(advanced_debug), - close_mic_when_idle_(close_mic_when_idle) {} + close_mic_when_idle_(close_mic_when_idle), moondream_analytics_(moondream_analytics) {} -void Controller::warm() { +void Controller::warm(bool immediate) { if (paced_) paced_->cancel(); status_note_.clear(); quick_open_ = false; - armed_focus_.reset(); audio_.close(); worker_.stop(); session_ = Session{}; - worker_.start(advanced_debug_); + armed_focus_.reset(); audio_.close(); worker_.stop(immediate); session_ = Session{}; + worker_.start(advanced_debug_, moondream_analytics_); detail_ = "Loading local model; microphone closed. Record again when Ready."; } void Controller::initialize() { @@ -258,8 +259,12 @@ Panel Controller::panel() const { if (result.recording) result.status += " - " + std::to_string(audio_.seconds()) + " / 20s"; return result; } -void Controller::settings(bool auto_insert, bool advanced_debug, bool close_mic_when_idle) { - const bool delivery_setting_changed = auto_insert_ != auto_insert || advanced_debug_ != advanced_debug; +void Controller::settings(bool auto_insert, bool advanced_debug, bool close_mic_when_idle, + bool moondream_analytics) { + const bool analytics_changed = moondream_analytics_ != moondream_analytics; + const bool worker_setting_changed = advanced_debug_ != advanced_debug || + analytics_changed; + const bool delivery_setting_changed = auto_insert_ != auto_insert || worker_setting_changed; const bool was_partial = delivery_setting_changed && paced_ && paced_->began(); if (auto_insert_ != auto_insert) { const bool partial = paced_ && paced_->began(); @@ -279,13 +284,20 @@ void Controller::settings(bool auto_insert, bool advanced_debug, bool close_mic_ detail_ = close_mic_when_idle_ ? "Mic closes between clips. Opening on PTT may delay or clip speech." : "Mic stays open while idle; samples are discarded. Other apps may still hear you."; } - if (advanced_debug_ != advanced_debug) { + if (worker_setting_changed) { const bool partial = paced_ && paced_->began(); advanced_debug_ = advanced_debug; + moondream_analytics_ = moondream_analytics; + // Revoke the current clip/review/delivery before starting with the new + // policy. warm() synchronously stops and reaps the previous worker. if (backend_available_) { - try { warm(); } + try { warm(analytics_changed); } catch (const std::exception& e) { session_.fail(); detail_ = e.what(); } - } else { if (paced_) paced_->cancel(); armed_focus_.reset(); audio_.close(); worker_.stop(); session_.fail(); } + } else { + if (paced_) paced_->cancel(); + armed_focus_.reset(); audio_.close(); worker_.stop(analytics_changed); session_.fail(); + quick_open_ = false; status_note_.clear(); + } if (partial) { status_note_ = "Input stopped - partial delivery uncertain"; detail_ += " Earlier input may have queued; remainder and Enter dropped."; diff --git a/src/controller.hpp b/src/controller.hpp index ad79236..963e0ae 100644 --- a/src/controller.hpp +++ b/src/controller.hpp @@ -25,11 +25,11 @@ public: class ControllerWorker { public: virtual ~ControllerWorker() = default; - virtual void start(bool advanced_debug) = 0; + virtual void start(bool advanced_debug, bool moondream_analytics) = 0; virtual bool ready() const = 0; virtual void submit(uint64_t id, const std::vector& pcm) = 0; virtual std::optional poll() = 0; - virtual void stop() = 0; + virtual void stop(bool immediate = false) = 0; }; class ControllerFocus { public: @@ -46,7 +46,7 @@ public: DeliveryFactory delivery, FocusFactory focus, std::vector quick_inputs, bool auto_insert = false, bool advanced_debug = false, bool close_mic_when_idle = false, - PacedDelivery* paced = nullptr); + PacedDelivery* paced = nullptr, bool moondream_analytics = false); // Called explicitly by runtime, after setting up the UI. A failure is shown // in Panel and can be retried with Record. void initialize(); @@ -55,13 +55,14 @@ public: void backend_changed(bool verified, std::string detail = {}); void tick(); // nonblocking poll of focus, worker, microphone; no UI or hardware setup Panel panel() const; - void settings(bool auto_insert, bool advanced_debug, bool close_mic_when_idle); + void settings(bool auto_insert, bool advanced_debug, bool close_mic_when_idle, + bool moondream_analytics = false); void action(UiAction action); bool quitting() const { return quit_; } void shutdown(); // releases only injected resources; safe to call repeatedly State state() const { return session_.state(); } private: - void warm(); + void warm(bool immediate = false); void start_record(); void stop_record(); void request_failed(const std::string& error); @@ -81,7 +82,7 @@ private: std::string detail_, status_note_; bool quit_ = false, quick_open_ = false; size_t quick_selected_ = 0; - bool auto_insert_, advanced_debug_, close_mic_when_idle_; + bool auto_insert_, advanced_debug_, close_mic_when_idle_, moondream_analytics_; bool backend_available_ = true; }; } // namespace frameyap diff --git a/src/overlay.cpp b/src/overlay.cpp index 44a46c0..2c75f4b 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -91,6 +91,8 @@ struct Overlay::Impl { struct Persistence { std::filesystem::path settings_path, laser_settings_path; bool save_failed = false, debug_save_failed = false, auto_save_failed = false; + bool analytics_save_failed = false, analytics_disable_unsaved = false; + bool analytics_disable_pending = false; bool layout_save_failed = false, mic_save_failed = false, gradient_save_failed = false, laser_change_failed = false; bool placement_save_failed = false; bool persist_mount = true; @@ -244,6 +246,7 @@ struct Overlay::Impl { surface.set_lasers_anytime(lasers_anytime); surface.set_wrist_world_fallback(config.wrist_world_fallback); surface.set_advanced_debug(config.advanced_debug); + surface.set_moondream_analytics(config.moondream_analytics); surface.set_auto_insert(config.auto_insert); surface.set_gradient_enabled(config.gradient.enabled); surface.set_close_mic_when_idle(config.close_mic_when_idle); @@ -327,7 +330,9 @@ struct Overlay::Impl { const Mount effective = *selected; if (effective == Mount::World && applied_mount && *applied_mount != Mount::World) world_ready = false; // a fresh world fallback near the wearer, not an old room location - std::string note = persistence.placement_save_failed ? "Placement not saved; using it only for this session." : + std::string note = persistence.analytics_disable_unsaved ? "Analytics OFF this session; save failed. Could be ON next launch. Click Save analytics OFF to retry." : + persistence.analytics_save_failed ? "Could not save analytics preference; analytics remain OFF." : + persistence.placement_save_failed ? "Placement not saved; using it only for this session." : persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." : persistence.gradient_save_failed ? "Could not save background setting." : persistence.layout_save_failed ? "Layout lock not saved; using it only for this session." : @@ -335,7 +340,7 @@ struct Overlay::Impl { persistence.debug_save_failed ? "Debug preference not saved; using it only for this session." : persistence.laser_change_failed ? "SteamVR declined the laser mode change." : persistence.save_failed ? "Preference could not be saved; using it for this session." : ""; - if (effective != mount) note = persistence.save_failed ? "Wrist untracked; world fallback. Preference not saved." : + if (effective != mount && !persistence.analytics_save_failed) note = persistence.save_failed ? "Wrist untracked; world fallback. Preference not saved." : "Wrist not tracked - using world space until it returns."; if (effective == Mount::World && !world_ready) { const auto& hmd = poses[vr::k_unTrackedDeviceIndex_Hmd]; @@ -703,7 +708,7 @@ 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.check_updates || event_result.install_update || event_result.advanced_debug || event_result.auto_insert || event_result.gradient_enabled || 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.moondream_analytics || event_result.auto_insert || event_result.gradient_enabled || 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(); @@ -781,6 +786,26 @@ struct Overlay::Impl { // restarts its worker and invalidates pending work/actions. return result; } + if (event_result.moondream_analytics) { + const bool enabled = *event_result.moondream_analytics; + if (!enabled) { + // No filesystem work here: return effective OFF to + // the runtime so it stops the opted-in worker first. + config.moondream_analytics = false; + persistence.analytics_disable_pending = true; + } else { + // Non-persistent overlays cannot opt in. An ON + // worker starts only after a successful config write. + persistence.analytics_save_failed = !apply_moondream_analytics(config, + persistence.persist_mount ? default_config_path() : std::filesystem::path{}, true); + persistence.analytics_disable_unsaved = false; + } + surface.set_moondream_analytics(config.moondream_analytics, persistence.analytics_disable_unsaved); + reset_input(result); + // Runtime observes effective consent before handling this + // batch and replaces the worker, discarding pending work. + return result; + } if (event_result.open_bindings) { // The editor changes input ownership. Invalidate held gestures // and pointer presses; never turn the returning release into input. @@ -872,28 +897,40 @@ struct Overlay::Impl { auto [la, ld] = digital(0); auto [ra, rd] = digital(1); const auto now = DoubleTap::Clock::now(); - if (left.update(la && focus, ld, available(UiAction::Enter), now)) result.push_back(UiAction::Enter); - switch (right.update(ra && focus && !panel.quick_open, rd, now)) { - case GripRecord::Change::Begin: grip_capture = true; result.push_back(UiAction::BeginRecord); break; - case GripRecord::Change::End: grip_capture = false; result.push_back(UiAction::EndRecord); break; - case GripRecord::Change::Cancel: grip_capture = false; result.push_back(UiAction::Cancel); break; + auto bound_action = [&](UiAction action) { + if (!surface.allow_bound_action(action)) return false; + if (action == UiAction::Cancel) { + reset_input(result); // no later release from a cancelled PTT/grip + if (!result.empty() && result.back() == UiAction::Cancel) return true; + } + result.push_back(action); + return true; + }; + if (left.update(la && focus, ld, available(UiAction::Enter), now)) bound_action(UiAction::Enter); + switch (right.update(ra && focus && !panel.quick_open && + (grip_capture || available(UiAction::Record)), rd, now)) { + case GripRecord::Change::Begin: grip_capture = bound_action(UiAction::BeginRecord); break; + case GripRecord::Change::End: grip_capture = false; bound_action(UiAction::EndRecord); break; + case GripRecord::Change::Cancel: grip_capture = false; bound_action(UiAction::Cancel); break; case GripRecord::Change::None: break; } constexpr std::array mapped{{UiAction::Record, UiAction::Cancel, UiAction::Insert, UiAction::Enter, UiAction::QuickChat}}; for (size_t i = 0; i < edges.size(); ++i) { auto [active, down] = digital(i + 2); - if (i == 0 && (!active || !focus || !panel.enabled || panel.quick_open) && edges[i].reset()) { - ptt_capture = false; result.push_back(UiAction::Cancel); + const bool record_allowed = ptt_capture || available(UiAction::Record); + if (i == 0 && (!active || !focus || !panel.enabled || panel.quick_open || !record_allowed) && edges[i].reset()) { + ptt_capture = false; bound_action(UiAction::Cancel); } - auto change = edges[i].update(active && focus && ((panel.enabled && (i != 0 || !panel.quick_open)) || i == 1), down); + auto change = edges[i].update(active && focus && + ((panel.enabled && (i != 0 || (!panel.quick_open && record_allowed))) || i == 1), down); if (i == 0) { if (change == NeutralEdge::Change::Down) { - ptt_capture = true; result.push_back(UiAction::BeginRecord); + ptt_capture = bound_action(UiAction::BeginRecord); } else if (change == NeutralEdge::Change::Up) { - ptt_capture = false; result.push_back(UiAction::EndRecord); + ptt_capture = false; bound_action(UiAction::EndRecord); } } else if (change == NeutralEdge::Change::Down && available(mapped[i])) { - result.push_back(mapped[i]); + bound_action(mapped[i]); } } return result; @@ -911,6 +948,16 @@ std::vector Overlay::take_model_actions() { } void Overlay::draw(const Panel& panel) { impl_->draw(panel); } bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; } +bool Overlay::moondream_analytics() const { return impl_->config.moondream_analytics; } +void Overlay::flush_pending_analytics_save() { + auto& persistence = impl_->persistence; + if (!persistence.analytics_disable_pending) return; + persistence.analytics_disable_pending = false; + persistence.analytics_save_failed = !save_moondream_analytics( + persistence.persist_mount ? default_config_path() : std::filesystem::path{}, false); + persistence.analytics_disable_unsaved = persistence.analytics_save_failed; + impl_->surface.set_moondream_analytics(false, persistence.analytics_disable_unsaved); +} bool Overlay::auto_insert() const { return impl_->config.auto_insert; } bool Overlay::close_mic_when_idle() const { return impl_->config.close_mic_when_idle; } const std::vector& Overlay::quick_inputs() const { return impl_->config.quick_inputs; } diff --git a/src/overlay.hpp b/src/overlay.hpp index e95d289..13138b5 100644 --- a/src/overlay.hpp +++ b/src/overlay.hpp @@ -47,6 +47,10 @@ public: std::vector take_model_actions(); void draw(const Panel& panel); bool advanced_debug() const; + bool moondream_analytics() const; + // Call after the controller applies effective consent and stops the old + // worker. OFF must take effect before potentially slow filesystem writes. + void flush_pending_analytics_save(); bool auto_insert() const; bool close_mic_when_idle() const; const std::vector& quick_inputs() const; diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index 982c4e0..1512171 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -32,10 +32,10 @@ enum class Control { Review, Settings, Bindings, Plan, Keyboard, Record, Cancel, World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert, Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow, ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback, UpdateCheck, UpdateInstall, Draw, - AnimatedBackground }; -enum class Tab { Review, Settings, Models, About }; + AnimatedBackground, MoondreamAnalytics, AnalyticsConfirm, AnalyticsKeepOff }; +enum class Tab { Review, Settings, Models, About, AnalyticsConsent }; struct Button { Rect r; Control id; const char* label; }; -constexpr std::array buttons{{ +constexpr std::array buttons{{ {{32, 138, 180, 46}, Control::Review, "Review"}, {{226, 138, 180, 46}, Control::Settings, "Settings"}, {{420, 138, 180, 46}, Control::Bindings, "Bindings"}, @@ -73,8 +73,18 @@ constexpr std::array buttons{{ {{32, 682, 454, 42}, Control::UpdateCheck, "Check for updates"}, {{514, 682, 454, 42}, Control::UpdateInstall, "Install update..."}, {{32, 496, 454, 44}, Control::Draw, "Open Draw"}, - {{32, 574, 936, 36}, Control::AnimatedBackground, "Animated background"}, + {{32, 574, 454, 36}, Control::AnimatedBackground, "Animated background"}, + {{514, 574, 454, 36}, Control::MoondreamAnalytics, "Moondream analytics"}, + {{514, 490, 454, 44}, Control::AnalyticsConfirm, "Confirm Enable"}, + {{32, 490, 454, 44}, Control::AnalyticsKeepOff, "Keep OFF"}, }}; +const std::vector analytics_review_lines{ + "Enabling sends analytics to Moondream over the network:", + "hostname, device details, model details and usage events.", + "Speech recognition still runs locally.", + "Changing this setting restarts recognition and discards", + "any current recording, transcript or queued dictation.", + "Analytics stay OFF unless this choice saves successfully."}; constexpr Rect settings_view{32, 232, 936, 316}; constexpr Rect review_view{48, 212, 904, 260}; constexpr int review_line_height = 42; @@ -103,7 +113,7 @@ 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::AnimatedBackground || c == Control::WristWorldFallback || + c == Control::CloseMicWhenIdle || c == Control::AnimatedBackground || c == Control::MoondreamAnalytics || c == Control::WristWorldFallback || c == Control::UpdateCheck || c == Control::UpdateInstall; } // Invalid bytes become visible replacement glyphs, never control commands. @@ -140,6 +150,7 @@ struct PanelSurface::Impl { Color background, card, ink, muted, cyan, pink; Tab tab = Tab::Review; bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false; + bool moondream_analytics = false, analytics_unsaved_off = false, analytics_consent_rendered = false; bool close_mic_when_idle = false, wrist_world_fallback = true; bool clock_24h = false, layout_locked = false; float settings_scroll = 0.f; @@ -376,6 +387,7 @@ struct PanelSurface::Impl { return float(lines.size() > 6 ? (lines.size() - 6) * review_line_height : 0); } bool available(UiAction a) const { + if (tab == Tab::AnalyticsConsent && a != UiAction::Cancel && a != UiAction::Quit) return false; if (a == UiAction::Record) return tab != Tab::Models && !panel.quick_open && (panel.recording || panel.record_available); // With nothing to review, Type is an explicit Enter. if (a == UiAction::Insert) return tab != Tab::Models && !panel.quick_open && panel.enabled && !panel.recording; @@ -386,6 +398,7 @@ 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::AnalyticsConfirm || c == Control::AnalyticsKeepOff) return tab == Tab::AnalyticsConsent; 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; @@ -398,11 +411,12 @@ struct PanelSurface::Impl { if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime || c == Control::AdvancedDebug || c == Control::AutoInsert || c == Control::Clock24h || c == Control::Date || c == Control::LockLayout || - c == Control::CloseMicWhenIdle || c == Control::AnimatedBackground) return tab == Tab::Settings; + c == Control::CloseMicWhenIdle || c == Control::AnimatedBackground || c == Control::MoondreamAnalytics) return tab == Tab::Settings; return true; } bool enabled(Control c) const { if (auto a = action(c)) return available(*a); + if (c == Control::AnalyticsConfirm) return analytics_consent_rendered && !moondream_analytics; 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; @@ -455,7 +469,7 @@ struct PanelSurface::Impl { rounded({32, 204, 936, 334}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); text("FrameYap", 48, 244, 30, ink, 952); text("Version " + (version.empty() ? std::string("unknown") : version), 48, 276, 22, cyan, 952); - text("On-device voice typing for Steam Frame. Nothing is sent to the cloud.", 48, 306, 19, muted, 952); + text("On-device voice typing. Optional Moondream analytics in Settings.", 48, 306, 19, muted, 952); const auto it = std::find_if(panel.models.begin(), panel.models.end(), [&](const auto& m) { return m.id == panel.selected_backend; }); const std::array, 5> rows{{ {"Source", "github.com/baketnk/frame-yap"}, @@ -492,6 +506,10 @@ struct PanelSurface::Impl { if (p.quick_open != panel.quick_open || p.models != panel.models || p.selected_backend != panel.selected_backend || p.model_busy != panel.model_busy) { reset(); install_confirm = false; consent_snapshot.reset(); + if (tab == Tab::AnalyticsConsent) { + tab = Tab::Settings; + analytics_consent_rendered = false; + } } panel = p; if (panel.quick_open && tab != Tab::Review) { tab = Tab::Review; dirty = true; } @@ -592,6 +610,13 @@ struct PanelSurface::Impl { } else text("No model selected.", 32, 225, 18, pink, 968); } else if (tab == Tab::About) { about(); + } else if (tab == Tab::AnalyticsConsent) { + rounded({32, 204, 936, 334}, 16, mix(card, muted, .08f), mix(card, cyan, .25f), .18f); + text("Enable Moondream analytics?", 48, 244, 30, pink, 952); + for (size_t i = 0; i < analytics_review_lines.size(); ++i) + text(analytics_review_lines[i], 48, 284 + int(i) * 31, 22, ink, 952); + // Events before this disclosure has been painted cannot opt in. + analytics_consent_rendered = true; } else { text("Hold Quit: hold 0.9s then release. Lasers anytime: system-wide; may affect games.", 32, 204, 18, pink, 968); @@ -616,6 +641,7 @@ struct PanelSurface::Impl { (mounting(b.id) && *mounting(b.id) == mount) || (b.id == Control::LasersAnytime && lasers_anytime) || (b.id == Control::AdvancedDebug && advanced_debug) || + (b.id == Control::MoondreamAnalytics && moondream_analytics) || (b.id == Control::AutoInsert && auto_insert) || (b.id == Control::AnimatedBackground && gradient.enabled) || (b.id == Control::CloseMicWhenIdle && close_mic_when_idle) || @@ -644,24 +670,26 @@ struct PanelSurface::Impl { }() : b.id == Control::ModelInstall && install_confirm ? "Confirm Install" : b.id == Control::Record && panel.recording ? "Stop" : b.id == Control::Insert && panel.transcript.empty() ? "Enter" : + b.id == Control::MoondreamAnalytics && analytics_unsaved_off ? "Save analytics OFF" : b.id == Control::Clock24h ? (clock_24h ? "Clock: 24 hour" : "Clock: 12 hour") : b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" : date_format == DateFormat::MonthDayYear ? "Date: MM/DD/YYYY" : date_format == DateFormat::DayMonthYear ? "Date: DD/MM/YYYY" : "Date: YYYY-MM-DD") : b.label; text(label, r.x + 16, r.y + r.h / 2 + 9, - b.id == Control::Date ? 23 : b.id == Control::CloseMicWhenIdle ? 22 : + b.id == Control::Date ? 23 : b.id == Control::CloseMicWhenIdle || b.id == Control::MoondreamAnalytics ? 22 : b.id == Control::Enter ? 20 : 27, on ? ink : mix(background, muted, .48f), r.x + r.w - 8); if (mounting(b.id) && selected) text("ON", r.x + r.w - 56, r.y + 38, 23, cyan, r.x + r.w - 12); if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug || b.id == Control::AutoInsert || b.id == Control::LockLayout || b.id == Control::CloseMicWhenIdle || b.id == Control::AnimatedBackground || - b.id == Control::WristWorldFallback) { + b.id == Control::WristWorldFallback || b.id == Control::MoondreamAnalytics) { bool active = b.id == Control::LasersAnytime ? lasers_anytime : b.id == Control::AutoInsert ? auto_insert : b.id == Control::LockLayout ? layout_locked : b.id == Control::AnimatedBackground ? gradient.enabled : b.id == Control::CloseMicWhenIdle ? close_mic_when_idle : b.id == Control::WristWorldFallback ? wrist_world_fallback : advanced_debug; + if (b.id == Control::MoondreamAnalytics) active = moondream_analytics; text(active ? "ON" : "OFF", r.x + r.w - 66, r.y + r.h / 2 + 9, 22, active ? cyan : muted, r.x + r.w - 12); } @@ -724,9 +752,18 @@ PanelSurface::~PanelSurface() = default; bool PanelSurface::render(const Panel& p, Clock::time_point now, bool animate) { return impl_->render(p, now, animate); } const std::vector& PanelSurface::pixels() const { return impl_->pixels; } bool PanelSurface::available(UiAction a) const { return impl_->available(a); } +bool PanelSurface::allow_bound_action(UiAction action) { + if (action == UiAction::Cancel) { reset_pointers(); return true; } + if (action == UiAction::EndRecord) return true; + return available(action == UiAction::BeginRecord ? UiAction::Record : action); +} std::vector PanelSurface::visible_model_review_lines() const { return impl_->visible_consent_lines(); } +std::vector PanelSurface::visible_analytics_review_lines() const { + return impl_->tab == Tab::AnalyticsConsent && impl_->analytics_consent_rendered ? + analytics_review_lines : std::vector{}; +} bool PanelSurface::dragging(unsigned cursor) const { return cursor < impl_->pressed.size() && int(cursor) == impl_->drag_cursor; } @@ -778,6 +815,14 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock:: if (index == 10 || (index >= 0 && buttons[index].id == Control::Keyboard)) impl_->dirty = true; if (index < 0 || impl_->hit(x, y) != index) return result; auto c = buttons[index].id; + if (impl_->tab == Tab::AnalyticsConsent) { + // Leaving or cancelling never leaves an opt-in armed behind another view. + impl_->tab = Tab::Settings; + impl_->analytics_consent_rendered = false; + impl_->reset(); impl_->dirty = true; + if (c == Control::AnalyticsConfirm) { result.moondream_analytics = true; return result; } + if (c == Control::AnalyticsKeepOff) return result; + } if (c == Control::Keyboard) { if (!fired) result.launch_companion = now - impl_->press_time[cursor] >= keyboard_hold ? SurfaceEvent::Companion::KeyboardRecenter : SurfaceEvent::Companion::Keyboard; @@ -790,6 +835,14 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock:: else if (c == Control::Recenter) { result.recenter = true; impl_->reset(); } else if (c == Control::LasersAnytime) result.lasers_anytime = !impl_->lasers_anytime; else if (c == Control::AdvancedDebug) result.advanced_debug = !impl_->advanced_debug; + else if (c == Control::MoondreamAnalytics) { + if (impl_->moondream_analytics || impl_->analytics_unsaved_off) result.moondream_analytics = false; + else { + impl_->tab = Tab::AnalyticsConsent; + impl_->analytics_consent_rendered = false; + } + impl_->reset(); impl_->dirty = true; + } else if (c == Control::AutoInsert) result.auto_insert = !impl_->auto_insert; else if (c == Control::AnimatedBackground) result.gradient_enabled = !impl_->gradient.enabled; else if (c == Control::CloseMicWhenIdle) result.close_mic_when_idle = !impl_->close_mic_when_idle; @@ -881,7 +934,14 @@ void PanelSurface::set_indicators(const StatusIndicators& indicators) { void PanelSurface::set_binding_note(std::string note) { if (impl_->binding_note != note) { impl_->binding_note = std::move(note); impl_->dirty = true; } } -void PanelSurface::reset_pointers() { impl_->reset(); } +void PanelSurface::reset_pointers() { + impl_->reset(); + if (impl_->tab == Tab::AnalyticsConsent) { + impl_->tab = Tab::Settings; + impl_->analytics_consent_rendered = false; + impl_->dirty = true; + } +} std::vector PanelSurface::input_regions() const { std::vector result{{10, 10, CW - 20, CH - 20}}; if (!impl_->layout_locked) { result.push_back(grab); result.push_back(scale); } @@ -917,6 +977,17 @@ void PanelSurface::set_advanced_debug(bool enabled) { impl_->dirty = true; } } +void PanelSurface::set_moondream_analytics(bool enabled, bool unsaved_off) { + unsaved_off = unsaved_off && !enabled; + if (impl_->moondream_analytics != enabled || impl_->analytics_unsaved_off != unsaved_off || + impl_->tab == Tab::AnalyticsConsent) { + impl_->moondream_analytics = enabled; + impl_->analytics_unsaved_off = unsaved_off; + if (impl_->tab == Tab::AnalyticsConsent) impl_->tab = Tab::Settings; + impl_->analytics_consent_rendered = false; + impl_->reset(); impl_->dirty = true; + } +} void PanelSurface::set_auto_insert(bool enabled) { if (impl_->auto_insert != enabled) { impl_->auto_insert = enabled; diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index e5f712b..b2f7bbc 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -18,6 +18,7 @@ struct SurfaceEvent { std::optional mount; std::optional lasers_anytime; std::optional advanced_debug; + std::optional moondream_analytics; std::optional auto_insert; std::optional gradient_enabled; std::optional close_mic_when_idle; @@ -79,6 +80,8 @@ public: void set_version(std::string version); void set_lasers_anytime(bool enabled); void set_advanced_debug(bool enabled); + // An unsaved disable remains OFF; clicking its retry control saves OFF again. + void set_moondream_analytics(bool enabled, bool unsaved_off = false); void set_auto_insert(bool enabled); void set_gradient_enabled(bool enabled); void set_close_mic_when_idle(bool enabled); @@ -88,9 +91,13 @@ public: void set_binding_note(std::string note); void set_indicators(const StatusIndicators& indicators); bool available(UiAction action) const; + // Controller bindings obey the same consent gate as pointer controls. + // Cancel dismisses consent and stale pointer approval; EndRecord stays safe. + bool allow_bound_action(UiAction action); // Exact text rows currently shown in the consent viewport (empty outside it). // OpenVR has no screen-reader accessibility channel for this canvas. std::vector visible_model_review_lines() const; + std::vector visible_analytics_review_lines() const; private: struct Impl; std::unique_ptr impl_; diff --git a/src/runtime.cpp b/src/runtime.cpp index cb11813..97c5058 100644 --- a/src/runtime.cpp +++ b/src/runtime.cpp @@ -51,15 +51,15 @@ public: backend_ = std::move(backend); model_ = std::move(model); manifest_ = std::move(manifest); root_ = std::move(root); } - void start(bool advanced_debug) override { + void start(bool advanced_debug, bool moondream_analytics) override { const std::string script = backend_.empty() ? options_.worker : root_ + "/python/frameyap/backend_worker.py"; worker_.start(options_.python, script, backend_.empty() ? options_.model : model_, options_.threads, - advanced_debug, backend_, manifest_, root_); + advanced_debug, backend_, manifest_, root_, moondream_analytics); } bool ready() const override { return worker_.ready(); } void submit(uint64_t id, const std::vector& pcm) override { worker_.submit(id, pcm); } std::optional poll() override { return worker_.poll(); } - void stop() override { worker_.stop(); } + void stop(bool immediate = false) override { worker_.stop(immediate); } private: const Options& options_; Worker worker_; @@ -139,7 +139,7 @@ int run(const Options& options) { paced.set_trace([&trace](const std::string& line) { trace(line); }); Controller controller(audio, worker, acquire, [&trace] { return std::make_unique(trace); }, overlay.quick_inputs(), overlay.auto_insert(), overlay.advanced_debug(), - overlay.close_mic_when_idle(), &paced); + overlay.close_mic_when_idle(), &paced, overlay.moondream_analytics()); namespace fs = std::filesystem; const auto root = asset_root(options.assets); const auto manifest = options.manifest_dir.empty() ? root / "assets/backends" : fs::path(options.manifest_dir); @@ -164,6 +164,7 @@ int run(const Options& options) { controller.backend_changed(false, "Checking installed models offline. Recording unavailable until verified."); } else controller.initialize(); bool previous_debug = overlay.advanced_debug(); + bool previous_analytics = overlay.moondream_analytics(); while (!controller.quitting() && !interrupted) { if (models) { models->poll(); @@ -197,6 +198,16 @@ int run(const Options& options) { overlay.draw(panel); auto actions = overlay.poll(); const auto model_actions = overlay.take_model_actions(); + const bool debug_changed = overlay.advanced_debug() != previous_debug; + const bool analytics_changed = overlay.moondream_analytics() != previous_analytics; + trace.set_enabled(overlay.advanced_debug()); + // Apply privacy changes before any model action can restart a worker, + // including a settings/model click received in the same event batch. + controller.settings(overlay.auto_insert(), overlay.advanced_debug(), overlay.close_mic_when_idle(), + overlay.moondream_analytics()); + // Revocation must stop the old reporter before any potentially slow + // filesystem operation persists OFF. Enabling was saved before opt-in. + overlay.flush_pending_analytics_save(); // A model-row click revokes the current clip/review even when it targets // the selected row, is rejected while busy, or carries stale consent. // Do not dispatch the Cancel/EndRecord generated when the overlay resets @@ -232,11 +243,8 @@ int run(const Options& options) { controller.backend_changed(verified, verified ? "" : "Selected model unavailable or being checked. Recording disabled until offline verification."); } - bool debug_changed = overlay.advanced_debug() != previous_debug; - trace.set_enabled(overlay.advanced_debug()); - controller.settings(overlay.auto_insert(), overlay.advanced_debug(), overlay.close_mic_when_idle()); - // Debug consent and model actions cancel work before any stale input. - if (debug_changed || !model_actions.empty()) { + // Privacy/debug consent and model actions cancel work before stale input. + if (debug_changed || analytics_changed || !model_actions.empty()) { for (auto action : actions) if (action == UiAction::Quit) controller.action(action); } else { for (auto action : actions) { @@ -245,6 +253,7 @@ int run(const Options& options) { } } previous_debug = overlay.advanced_debug(); + previous_analytics = overlay.moondream_analytics(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } controller.shutdown(); diff --git a/src/worker.cpp b/src/worker.cpp index 7624a42..b2cfb4d 100644 --- a/src/worker.cpp +++ b/src/worker.cpp @@ -246,25 +246,30 @@ Worker::Worker(std::chrono::milliseconds warmup, std::chrono::milliseconds reque } Worker::~Worker() { stop(); } -void Worker::stop() { +void Worker::stop(bool immediate) { auto& s = *state_; - close_fd(s.to_child); - close_fd(s.from_child); if (s.pid > 0) { - ::kill(s.pid, SIGTERM); // Dispatcher handles and reaps its owned child. - auto until = Clock::now() + std::chrono::milliseconds(500); int status = 0; - while (::waitpid(s.pid, &status, WNOHANG) == 0 && Clock::now() < until) { - try { s.drain_debug(); } catch (...) {} // stop/destructor are best effort - ::usleep(10000); + if (!immediate) { + // Signal before closing stdin: EOF could otherwise run the enabled + // runtime's graceful-shutdown analytics flush before TERM arrives. + ::kill(s.pid, SIGTERM); // Dispatcher kills/reaps its owned child. + auto until = Clock::now() + std::chrono::milliseconds(500); + while (::waitpid(s.pid, &status, WNOHANG) == 0 && Clock::now() < until) { + try { s.drain_debug(); } catch (...) {} // stop/destructor are best effort + ::usleep(10000); + } } - // Spawned in a dedicated group: if dispatcher was stuck or died before - // reaping, stop its remaining model process too. Never signal a session. + // Analytics revocation skips all graceful hooks and kills every process + // in the dedicated owned group before pipe EOF or a replacement start. + // Normal shutdown also cleans up a stuck dispatcher/model. No sessions + // or unrelated processes are signalled; prior network sends cannot be undone. ::kill(-s.pid, SIGKILL); - if (::waitpid(s.pid, &status, WNOHANG) == 0) - while (::waitpid(s.pid, &status, 0) < 0 && errno == EINTR) {} + while (::waitpid(s.pid, &status, 0) < 0 && errno == EINTR) {} s.pid = -1; } + close_fd(s.to_child); + close_fd(s.from_child); try { s.drain_debug(); } catch (...) {} close_fd(s.debug_pipe); close_fd(s.debug_file); @@ -280,7 +285,7 @@ void Worker::stop() { void Worker::start(const std::string& python, const std::string& script, const std::string& model, int threads, bool advanced_debug, const std::string& backend, const std::string& manifest_dir, - const std::string& root) { + const std::string& root, bool moondream_analytics) { if (state_->pid > 0) throw std::logic_error("worker already started"); if (python.empty() || script.empty() || model.empty() || threads < 1 || threads > 64) throw std::invalid_argument("python, script, local model and 1..64 threads required"); @@ -309,13 +314,17 @@ void Worker::start(const std::string& python, const std::string& script, for (char** e = environ; *e; ++e) { std::string_view entry(*e); if (entry.starts_with("PYTHONDONTWRITEBYTECODE=") || - entry.starts_with("FRAMEYAP_ADVANCED_DEBUG=")) continue; + entry.starts_with("FRAMEYAP_ADVANCED_DEBUG=") || + entry.starts_with("FRAMEYAP_MOONDREAM_ANALYTICS=")) continue; environment.emplace_back(*e); } environment.emplace_back("PYTHONDONTWRITEBYTECODE=1"); // Explicit protocol opt-in; dispatcher forwards it to known Redux without // appending unsupported flags to third-party backend launchers. environment.emplace_back(std::string("FRAMEYAP_ADVANCED_DEBUG=") + (advanced_debug ? "1" : "0")); + // Only the saved/in-panel choice authorizes analytics. An inherited + // launcher environment must never override the default-off setting. + environment.emplace_back(std::string("FRAMEYAP_MOONDREAM_ANALYTICS=") + (moondream_analytics ? "1" : "0")); std::vector envp; for (auto& value : environment) envp.push_back(value.data()); envp.push_back(nullptr); @@ -448,6 +457,8 @@ std::optional Worker::poll() { throw std::runtime_error("Missing/mismatched local model files or private clip directory; check --model"); if (size == 2 && s.input[5] == 'I') throw std::runtime_error("Configured local worker runtime unavailable; check --python and backend launcher"); + if (size == 2 && s.input[5] == 'P') + throw std::runtime_error("Telemetry protection unavailable; reinstall the pinned speech runtime"); throw std::runtime_error("Local backend failed to load; check runtime and verified model files"); } if ((type != 'R' && type != 'E') || size < 9 || !s.loaded || !s.pending || diff --git a/src/worker.hpp b/src/worker.hpp index ad7baeb..7d476fa 100644 --- a/src/worker.hpp +++ b/src/worker.hpp @@ -29,11 +29,13 @@ public: void start(const std::string& python, const std::string& script, const std::string& model, int threads = 2, bool advanced_debug = false, const std::string& backend = {}, const std::string& manifest_dir = {}, - const std::string& root = {}); + const std::string& root = {}, bool moondream_analytics = false); bool ready() const; void submit(uint64_t id, const std::vector& pcm); std::optional poll(); - void stop(); + // Immediate revocation kills the owned process group without running a + // runtime's graceful analytics flush; the direct worker is reaped on return. + void stop(bool immediate = false); private: struct State; diff --git a/tests/config_test.cpp b/tests/config_test.cpp index c09f03d..aad2cd5 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -1,4 +1,7 @@ #include "config.hpp" +#include +#include +#include #include "panel_clock.hpp" #include #include @@ -43,6 +46,7 @@ int main(int argc, char** argv) { assert(load_config(path).backend == "redux"); assert(!load_config(path).experimental_input_priority); assert(!load_config(path).advanced_debug); + assert(!load_config(path).moondream_analytics); assert(!load_config(path).auto_insert); assert(!load_config(path).close_mic_when_idle); assert(!load_config(path).lock_layout); @@ -61,6 +65,7 @@ int main(int argc, char** argv) { assert(example.font.empty()); assert(!example.experimental_input_priority); assert(!example.advanced_debug); + assert(!example.moondream_analytics); assert(!example.auto_insert); assert(!example.close_mic_when_idle); assert(!example.lock_layout); @@ -69,6 +74,71 @@ int main(int argc, char** argv) { assert(example.wrist.y == .18f && example.wrist.z == .089f); assert(!example.gradient.enabled && example.gradient.period_seconds == 30.f && example.gradient.strength == .12f); std::filesystem::create_directories(path.parent_path()); + // Both new files and old configs without an explicit opt-in remain OFF. + put(path, "{}\n"); + assert(!load_config(path).moondream_analytics); + std::filesystem::remove(path); + assert(save_moondream_analytics(path, true)); + assert(load_config(path).moondream_analytics); + assert(get(path) == "{\"moondream_analytics\":true}\n"); + assert(save_moondream_analytics(path, false)); + // A buffered flush can fail after operator<< appeared to succeed. Simulate + // disk exhaustion in a child so the rest of the test keeps its file limit. + const auto before_failed_flush = get(path); + const pid_t flush_child = ::fork(); + assert(flush_child >= 0); + if (flush_child == 0) { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit{}; + assert(::getrlimit(RLIMIT_FSIZE, &limit) == 0); + limit.rlim_cur = 0; + assert(::setrlimit(RLIMIT_FSIZE, &limit) == 0); + Config consent; + assert(!apply_moondream_analytics(consent, path, true)); + assert(!consent.moondream_analytics); + assert(get(path) == before_failed_flush); // original config is never replaced + for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) + assert(!entry.path().filename().string().starts_with("config.json.tmp.")); + ::_exit(0); + } + int flush_status = 0; + assert(::waitpid(flush_child, &flush_status, 0) == flush_child); + assert(WIFEXITED(flush_status) && WEXITSTATUS(flush_status) == 0); + assert(get(path) == before_failed_flush); + assert(!load_config(path).moondream_analytics); + for (const auto* invalid : {R"({"moondream_analytics":"true"})", R"({"moondream_analytics":0})", + R"({"moondream_analytics":1})", R"({"moondream_analytics":null})", + R"({"moondream_analytics":[]})", R"({"moondream_analytics":{}})", + R"({"moondream_analytics":true,"moondream_analytics":false})"}) { + put(path, invalid); + fails([&] { load_config(path); }); + assert(!save_moondream_analytics(path, true)); + assert(!save_moondream_analytics(path, false)); + assert(get(path) == invalid); + } + put(path, R"({"unknown_setting":{"keep":"verbatim"},"moondream_analytics":true})"); + const auto unknown_analytics_config = get(path); + fails([&] { load_config(path); }); + assert(!save_moondream_analytics(path, false)); + assert(get(path) == unknown_analytics_config); // never erase unknown customizations + put(path, "{\n \"font\":\"escaped \\u0061\", \"moondream_analytics\":false, \"theme\":{\"ink\":\"#123ABC\"}\n}\n"); + assert(save_moondream_analytics(path, true)); + assert(get(path) == "{\n \"font\":\"escaped \\u0061\", \"moondream_analytics\":true, \"theme\":{\"ink\":\"#123ABC\"}\n}\n"); + auto analytics_config = load_config(path); + assert(!apply_moondream_analytics(analytics_config, {}, false)); + assert(!analytics_config.moondream_analytics); // failed disable still stops this session + assert(load_config(path).moondream_analytics); // callers must disclose next-launch risk + assert(!apply_moondream_analytics(analytics_config, {}, true)); + assert(!analytics_config.moondream_analytics); // never enable after failed save + assert(!apply_moondream_analytics(analytics_config, path / "not-a-directory", true)); + assert(!analytics_config.moondream_analytics); + assert(apply_moondream_analytics(analytics_config, path, false)); + assert(!analytics_config.moondream_analytics && !load_config(path).moondream_analytics); + assert(apply_moondream_analytics(analytics_config, path, true)); + assert(analytics_config.moondream_analytics && load_config(path).moondream_analytics); + const auto analytics_before = get(path); + assert(save_moondream_analytics(path, true) && get(path) == analytics_before); + assert(save_moondream_analytics(path, false)); assert(save_backend(path, "fixture_cpu")); assert(load_config(path).backend == "fixture_cpu"); assert(!save_backend(path, "../escaped") && !save_backend(path, "UPPER")); @@ -132,6 +202,8 @@ int main(int argc, char** argv) { auto link = dir / "linked-config"; std::filesystem::create_symlink(path, link); assert(!save_advanced_debug(link, false)); + assert(!save_moondream_analytics(link, true)); + assert(!save_moondream_analytics(link, false)); assert(!save_auto_insert(link, true)); assert(!save_close_mic_when_idle(link, true)); assert(!save_lock_layout(link, true)); @@ -140,6 +212,7 @@ int main(int argc, char** argv) { put(path, R"({"font":")" + std::string(4090, 'x') + R"("})"); before = get(path); assert(!save_advanced_debug(path, true) && get(path) == before); + assert(!save_moondream_analytics(path, true) && get(path) == before); put(path, R"({"input_priority":"experimental","advanced_debug":true})"); auto experimental = load_config(path); assert(experimental.experimental_input_priority && experimental.advanced_debug); diff --git a/tests/controller_test.cpp b/tests/controller_test.cpp index 6ec3a71..ca434e2 100644 --- a/tests/controller_test.cpp +++ b/tests/controller_test.cpp @@ -21,21 +21,29 @@ struct AudioFake final : ControllerAudio { int seconds() const override { return 0; } }; struct WorkerFake final : ControllerWorker { - bool loaded = false; - int starts = 0, stops = 0, submissions = 0; - bool debug = false; + bool loaded = false, fail_start = false, fail_poll = false; + int starts = 0, stops = 0, immediate_stops = 0, submissions = 0; + bool debug = false, analytics = false; uint64_t request = 0; std::deque replies; - void start(bool advanced) override { loaded = true; ++starts; debug = advanced; } + void start(bool advanced, bool moondream_analytics) override { + assert(!loaded); // replacement may start only after the old worker stops + ++starts; debug = advanced; analytics = moondream_analytics; + if (fail_start) throw std::runtime_error("worker start failed"); + loaded = true; + } bool ready() const override { return loaded; } void submit(uint64_t id, const std::vector& pcm) override { assert(loaded && pcm.size() == 3200); request = id; ++submissions; } std::optional poll() override { + if (fail_poll) { fail_poll = false; throw std::runtime_error("worker poll failed"); } if (replies.empty()) return std::nullopt; auto result = replies.front(); replies.pop_front(); return result; } - void stop() override { loaded = false; ++stops; replies.clear(); } + void stop(bool immediate = false) override { + loaded = false; ++stops; immediate_stops += immediate; replies.clear(); + } }; struct FocusState { bool armed = true, valid = true; int checks = 0, destroyed = 0; }; struct FocusFake final : ControllerFocus { @@ -73,10 +81,11 @@ struct Fixture { return std::make_unique(input); }; } - Controller make(bool automatic = false, bool close_mic = false, PacedDelivery* paced = nullptr) { + Controller make(bool automatic = false, bool close_mic = false, PacedDelivery* paced = nullptr, + bool analytics = false) { return Controller(audio, worker, acquire(), [this]() -> std::unique_ptr { return std::make_unique(focus); }, - {"Hi", "Next"}, automatic, false, close_mic, paced); + {"Hi", "Next"}, automatic, false, close_mic, paced, analytics); } }; void ready(Controller& c) { c.initialize(); c.tick(); assert(c.state() == State::Ready); } @@ -92,6 +101,118 @@ void result(Controller& c, Fixture& f, std::string text) { } } int main() { + for (bool enabled : {false, true}) { + Fixture f; auto c = f.make(false, false, nullptr, enabled); + assert(f.worker.starts == 0 && f.audio.prepares == 0); + c.initialize(); + assert(c.state() == State::Warming && f.worker.starts == 1 && + f.worker.analytics == enabled && !f.worker.debug); + c.settings(false, false, false, enabled); // every-frame settings, even while warming + assert(f.worker.starts == 1); + c.tick(); + c.settings(false, false, false, enabled); + assert(c.state() == State::Ready && f.worker.starts == 1); + release(c, f); + c.settings(false, false, false, enabled); // no cancellation without a real change + assert(c.state() == State::Transcribing && f.worker.starts == 1); + result(c, f, "unchanged"); assert(c.state() == State::Review); + c.shutdown(); + } + { + Fixture f; auto c = f.make(false, false, nullptr, true); + c.initialize(); // OFF also applies while the previous model is still warming + assert(c.state() == State::Warming && f.worker.analytics); + c.settings(false, false, false, false); + assert(c.state() == State::Warming && !f.worker.analytics && f.worker.starts == 2 && + f.worker.immediate_stops == 1 && !f.audio.opened); + c.action(UiAction::EndRecord); c.action(UiAction::Enter); + assert(f.worker.submissions == 0 && f.input.enter == 0); + c.tick(); assert(c.state() == State::Ready); + c.shutdown(); + } + { + Fixture f; auto c = f.make(true); ready(c); + c.action(UiAction::BeginRecord); + assert(c.state() == State::Recording && f.audio.opened); + c.settings(true, false, false, true); + assert(c.state() == State::Warming && !f.audio.opened && f.focus.destroyed == 1 && + f.worker.starts == 2 && f.worker.immediate_stops == 1 && f.worker.analytics); + c.action(UiAction::EndRecord); c.action(UiAction::Enter); + assert(f.worker.submissions == 0 && f.input.enter == 0); + c.tick(); release(c, f); + c.settings(true, false, false, false); // OFF discards an enabled worker's pending request + assert(c.state() == State::Warming && !f.worker.analytics && f.worker.starts == 3 && + f.worker.immediate_stops == 2); + f.worker.replies.push_back({f.worker.request, "stale", {}}); + c.tick(); + assert(c.state() == State::Ready && c.panel().transcript.empty() && f.input.text == 0); + release(c, f); f.focus.valid = false; result(c, f, "review to discard"); + assert(c.state() == State::Review); + c.settings(true, true, false, true); // combined debug/analytics change warms only once + assert(c.state() == State::Warming && f.worker.starts == 4 && f.worker.debug && + f.worker.analytics && c.panel().transcript.empty()); + c.action(UiAction::Insert); assert(f.input.text == 0); + c.tick(); c.action(UiAction::QuickChat); assert(c.panel().quick_open); + c.settings(true, true, false, false); + assert(!c.panel().quick_open && f.worker.starts == 5 && !f.worker.analytics); + c.tick(); c.shutdown(); + } + { + Fixture f; auto c = f.make(false, false, nullptr, true); ready(c); + f.worker.fail_start = true; + c.settings(false, false, false, false); + assert(c.state() == State::Error && !f.worker.loaded && !f.audio.opened && + !f.worker.analytics && f.worker.starts == 2 && f.worker.immediate_stops == 1); + c.settings(false, false, false, false); // startup failure must not auto-loop every frame + assert(f.worker.starts == 2); + f.worker.fail_start = false; + c.action(UiAction::Record); c.tick(); + assert(c.state() == State::Ready && f.worker.starts == 3 && !f.worker.analytics); + f.worker.fail_poll = true; c.tick(); assert(c.state() == State::Error && !f.worker.loaded); + c.settings(false, false, false, true); c.tick(); + assert(c.state() == State::Ready && f.worker.starts == 4 && f.worker.analytics); + release(c, f); c.action(UiAction::Cancel); + assert(c.state() == State::Error && !f.worker.loaded); + c.settings(false, false, false, false); c.tick(); + assert(c.state() == State::Ready && f.worker.starts == 5 && !f.worker.analytics); + c.shutdown(); + } + { + Fixture f; auto c = f.make(); + c.backend_changed(false, "Model unavailable"); + for (bool enabled : {true, false, true, false}) { + const int stopped = f.worker.immediate_stops; + c.settings(false, false, false, enabled); + assert(c.state() == State::Error && !c.panel().record_available && + !f.worker.loaded && f.worker.starts == 0 && f.worker.immediate_stops == stopped + 1); + c.settings(false, false, false, enabled); + c.action(UiAction::Record); c.action(UiAction::Enter); c.tick(); + assert(f.worker.starts == 0 && f.worker.immediate_stops == stopped + 1 && f.input.enter == 0); + } + c.backend_changed(true); c.tick(); + assert(c.state() == State::Ready && f.worker.starts == 1 && !f.worker.analytics); + c.shutdown(); + } + { + Fixture f; + auto now = std::chrono::steady_clock::time_point{}; + PacedDelivery paced(f.acquire(), [&] { return now; }); + auto c = f.make(false, false, &paced, true); ready(c); + release(c, f); result(c, f, std::string(81, 'a')); + c.action(UiAction::Enter); // queued, before first text batch + c.settings(false, false, false, false); + assert(!paced.active() && c.panel().transcript.empty() && f.input.events.empty()); + c.tick(); release(c, f); result(c, f, std::string(81, 'b')); + c.action(UiAction::Enter); c.tick(); + assert(f.input.text == 1 && paced.active()); + c.settings(false, false, false, true); + assert(!paced.active() && c.panel().transcript.empty() && + c.panel().detail.find("Earlier input may have queued") != std::string::npos); + now += std::chrono::milliseconds(150); c.tick(); + c.settings(false, false, false, true); + assert(f.input.text == 1 && f.input.enter == 0 && f.worker.starts == 3); + c.shutdown(); + } { Fixture f; auto c = f.make(false); ready(c); release(c, f); result(c, f, "pending preview"); diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 50c97dd..d16cecd 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -15,7 +15,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0) return surface.pointer_up(cursor, x, y); } void no_action(const SurfaceEvent& event) { - assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert && + assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.moondream_analytics && !event.auto_insert && !event.close_mic_when_idle && !event.gradient_enabled && !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) { @@ -706,5 +706,102 @@ int main(int argc, char** argv) { assert(click(about, 100, 610).action == UiAction::BeginRecord); no_action(click(about, 290, 160)); about.render(about_panel); // Settings returns assert(about.pixels() == settings_pixels); + // Analytics is OFF until a separately painted disclosure is confirmed and + // the caller successfully persists the choice. Dismissal never opts in. + PanelSurface analytics(argv[1], Mount::World); + Panel analytics_panel{"Ready", "", "", true, false}; + analytics.render(analytics_panel); + assert(analytics.visible_analytics_review_lines().empty()); + no_action(click(analytics, 700, 470)); // switch hidden outside Settings + no_action(click(analytics, 290, 160)); + assert(analytics.scroll(200, 400, -2.5f)); // analytics row: 574 - 120 + analytics.render(analytics_panel); + if (argc >= 3) snapshot(analytics, std::string(argv[2]) + "-analytics-off.ppm"); + no_action(click(analytics, 700, 470)); + assert(analytics.visible_analytics_review_lines().empty()); + no_action(click(analytics, 700, 510)); // no opt-in before disclosure is painted + assert(analytics.render(analytics_panel)); + const auto disclosure = analytics.visible_analytics_review_lines(); + std::string disclosure_text; + for (const auto& line : disclosure) disclosure_text += line + " "; + for (const auto* required : {"Moondream", "hostname", "device", "model", "usage", + "network", "restarts", "discards", "saves successfully"}) + assert(disclosure_text.find(required) != std::string::npos); + if (argc >= 3) snapshot(analytics, std::string(argv[2]) + "-analytics-consent.ppm"); + assert(!analytics.available(UiAction::Record) && !analytics.available(UiAction::Insert)); + assert(!analytics.allow_bound_action(UiAction::BeginRecord)); // both PTT and grip use this gate + assert(!analytics.allow_bound_action(UiAction::Record)); + assert(!analytics.allow_bound_action(UiAction::Insert)); + assert(!analytics.allow_bound_action(UiAction::Enter)); + assert(!analytics.allow_bound_action(UiAction::QuickChat)); + assert(analytics.allow_bound_action(UiAction::EndRecord)); // an existing capture can still finish + assert(!analytics.visible_analytics_review_lines().empty()); + no_action(click(analytics, 180, 510)); // Keep OFF + assert(analytics.visible_analytics_review_lines().empty()); + analytics.render(analytics_panel); + no_action(click(analytics, 700, 470)); analytics.render(analytics_panel); + analytics.pointer_down(0, 700, 510); + no_action(click(analytics, 180, 510, 1)); // other pointer cancels + no_action(analytics.pointer_up(0, 700, 510)); + for (const auto* dismiss : {"settings", "review", "bindings", "reset", "quick", "state", "cancel", "bound_cancel"}) { + no_action(click(analytics, 290, 160)); analytics.render(analytics_panel); + no_action(click(analytics, 700, 470)); analytics.render(analytics_panel); + assert(!analytics.visible_analytics_review_lines().empty()); + analytics.pointer_down(0, 700, 510); + if (std::string(dismiss) == "settings") no_action(click(analytics, 290, 160, 1)); + else if (std::string(dismiss) == "review") no_action(click(analytics, 100, 160, 1)); + else if (std::string(dismiss) == "bindings") assert(click(analytics, 480, 160, 1).open_bindings); + else if (std::string(dismiss) == "reset") analytics.reset_pointers(); + else if (std::string(dismiss) == "quick") { + analytics_panel.quick_open = true; analytics.render(analytics_panel); + analytics_panel.quick_open = false; analytics.render(analytics_panel); + } else if (std::string(dismiss) == "state") analytics.set_moondream_analytics(false); + else if (std::string(dismiss) == "bound_cancel") assert(analytics.allow_bound_action(UiAction::Cancel)); + else assert(click(analytics, 300, 610, 1).action == UiAction::Cancel); + assert(analytics.visible_analytics_review_lines().empty()); + no_action(analytics.pointer_up(0, 700, 510)); // stale confirmation cannot survive + } + no_action(click(analytics, 290, 160)); analytics.render(analytics_panel); + no_action(click(analytics, 700, 470)); analytics.render(analytics_panel); + analytics.pointer_down(1, 700, 510); + auto analytics_event = click(analytics, 700, 510); + assert(analytics_event.moondream_analytics == true && !analytics_event.action); + no_action(analytics.pointer_up(1, 700, 510)); // two pointers cannot repeat consent + assert(analytics.visible_analytics_review_lines().empty()); + Config analytics_config; + assert(!apply_moondream_analytics(analytics_config, {}, *analytics_event.moondream_analytics)); + analytics.set_moondream_analytics(analytics_config.moondream_analytics); + analytics.render(analytics_panel); + no_action(click(analytics, 700, 470)); // failed save still needs fresh confirmation + analytics.render(analytics_panel); + analytics_event = click(analytics, 700, 510); + assert(analytics_event.moondream_analytics == true); + const auto analytics_preference = std::filesystem::temp_directory_path() / + ("frameyap-analytics-ui-" + std::to_string(::getpid()) + ".json"); + assert(apply_moondream_analytics(analytics_config, analytics_preference, true)); + analytics.set_moondream_analytics(analytics_config.moondream_analytics); + analytics.render(analytics_panel); + assert(load_config(analytics_preference).moondream_analytics); + if (argc >= 3) snapshot(analytics, std::string(argv[2]) + "-analytics-on.ppm"); + analytics_event = click(analytics, 700, 470); + assert(analytics_event.moondream_analytics == false); // disable is one click + assert(!apply_moondream_analytics(analytics_config, {}, false)); + analytics.set_moondream_analytics(analytics_config.moondream_analytics, true); + analytics.render(analytics_panel); + if (argc >= 3) snapshot(analytics, std::string(argv[2]) + "-analytics-retry.ppm"); + assert(!analytics_config.moondream_analytics && load_config(analytics_preference).moondream_analytics); + analytics_event = click(analytics, 700, 470); + assert(analytics_event.moondream_analytics == false); // Save analytics OFF retries OFF + assert(analytics.visible_analytics_review_lines().empty()); + assert(apply_moondream_analytics(analytics_config, analytics_preference, false)); + analytics.set_moondream_analytics(analytics_config.moondream_analytics); + analytics.render(analytics_panel); + assert(!load_config(analytics_preference).moondream_analytics); + analytics.pointer_down(0, 700, 470); + analytics.set_moondream_analytics(true); // changed state invalidates stale toggle + no_action(analytics.pointer_up(0, 700, 470)); + analytics.set_moondream_analytics(false); + no_action(click(analytics, 700, 470)); // successful disable restores ordinary opt-in flow + std::filesystem::remove(analytics_preference); std::cout << "panel checks passed (no OpenVR, microphone or input injection)\n"; } diff --git a/tests/test_backend_dispatch.py b/tests/test_backend_dispatch.py index 2c41576..3b6bb83 100644 --- a/tests/test_backend_dispatch.py +++ b/tests/test_backend_dispatch.py @@ -23,6 +23,8 @@ write = sys.stdout.buffer.write assert len(sys.argv) in (3, 4), 'generic backend received unsupported flags' if os.environ.get('FRAMEYAP_ADVANCED_DEBUG') != '0': raise AssertionError('generic launcher must not receive Redux debug opt-in') +if os.environ.get('FRAMEYAP_MOONDREAM_ANALYTICS') != '0': + raise AssertionError('generic launcher must not receive Redux analytics opt-in') if os.environ.get('FAKE_PID_PATH'): import signal signal.signal(signal.SIGTERM, signal.SIG_IGN) @@ -84,7 +86,7 @@ class BackendDispatchTest(unittest.TestCase): '--backend', 'fake', '--root', str(root), '--python', sys.executable, '--manifest-dir', str(root / 'assets/backends'), '--model', str(model), '--clip-dir', str(clip), '--threads', '2'] - env = {**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'} + env = {**os.environ, 'PYTHONDONTWRITEBYTECODE': '1', 'FRAMEYAP_MOONDREAM_ANALYTICS': '1'} with subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) as child: self.assertEqual(read_frame(child.stdout), b'Y') @@ -107,6 +109,45 @@ class BackendDispatchTest(unittest.TestCase): child.stdin.close() self.assertEqual(child.wait(timeout=5), 1) + def test_redux_analytics_choice_and_privacy_failure_forwarding(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + manifests = root / 'assets/backends' + manifests.mkdir(parents=True) + launcher = root / 'python/frameyap/worker.py' + launcher.parent.mkdir(parents=True) + model = root / 'model' + model.mkdir() + (model / 'fixture').write_bytes(b'fixture') + clip = root / 'clip' + clip.mkdir(mode=0o700) + manifest = { + 'schema': 1, 'id': 'redux', 'display_name': 'Fixture Redux', + 'launcher': {'type': 'python', 'path': 'python/frameyap/worker.py', + 'arguments': ['{model_dir}', '{clip_dir}'], 'protocol': 'frameyap-worker-v1'}, + 'model': {'source': 'https://example.org/fixture', 'revision': 'v1', + 'files': [{'path': 'fixture', 'size': 7, 'sha256': hashlib.sha256(b'fixture').hexdigest()}]}, + 'attribution': 'Fixture', 'license': {'id': 'MIT', 'text': 'Fixture'}, + 'requirements': {'cpu': 'None', 'gpu': 'None'}} + (manifests / 'redux.json').write_text(json.dumps(manifest)) + argv = [sys.executable, str(ROOT / 'python/frameyap/backend_worker.py'), + '--backend', 'redux', '--root', str(root), '--python', sys.executable, + '--manifest-dir', str(manifests), '--model', str(model), '--clip-dir', str(clip), '--threads', '2'] + for value in ('0', '1', 'invalid'): + expected = '1' if value == '1' else '0' + launcher.write_text("import os, struct, sys\n" + + f"assert os.environ.get('FRAMEYAP_MOONDREAM_ANALYTICS') == {expected!r}\n" + + "sys.stdout.buffer.write(struct.pack(' +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -34,6 +37,23 @@ int main(int argc, char** argv) { try { std::vector clip(3200, 0.25f); Worker worker; + // Native settings replace (rather than trust) any launcher environment. + ::setenv("FRAMEYAP_MOONDREAM_ANALYTICS", "1", 1); + worker.start(argv[1], argv[2], "analytics-off"); + until([&] { worker.poll(); return worker.ready(); }); + worker.stop(); + ::setenv("FRAMEYAP_MOONDREAM_ANALYTICS", "0", 1); + worker.start(argv[1], argv[2], "analytics-on", 2, false, {}, {}, {}, true); + until([&] { worker.poll(); return worker.ready(); }); + worker.stop(true); + for (bool enabled : {false, true, false}) { + worker.start(argv[1], argv[2], enabled ? "analytics-on" : "analytics-off", + 2, false, {}, {}, {}, enabled); + until([&] { worker.poll(); return worker.ready(); }); + worker.stop(true); + assert(!worker.ready() && std::filesystem::is_empty(runtime)); + } + ::unsetenv("FRAMEYAP_MOONDREAM_ANALYTICS"); worker.start(argv[1], argv[2], "ok", 2); assert(!worker.ready()); until([&] { worker.poll(); return worker.ready(); }); @@ -85,7 +105,8 @@ int main(int argc, char** argv) { }); assert(!worker.ready()); for (const auto& [mode, message] : std::vector>{ - {"missing-model", "check --model"}, {"missing-import", "check --python"}}) { + {"missing-model", "check --model"}, {"missing-import", "check --python"}, + {"analytics-unsupported", "Telemetry protection unavailable"}}) { worker.start(argv[1], argv[2], mode, 2); failed = false; until([&] { @@ -155,6 +176,56 @@ int main(int argc, char** argv) { return failed; }); assert(std::filesystem::is_empty(runtime)); + // OFF must not run an enabled process's termination/atexit hooks. This + // fixture only writes local sentinels; no runtime, network or hardware. + { + char temporary[] = "/tmp/frameyap-privacy-test-XXXXXX"; + assert(::mkdtemp(temporary)); + const std::filesystem::path root(temporary); + const auto script = root / "shutdown-worker.py"; + const auto marker = root / "shutdown"; + { + std::ofstream out(script); + out << R"PY(import atexit, os, signal, struct, sys +from pathlib import Path +marker = Path(sys.argv[sys.argv.index('--model') + 1]) +def terminated(*unused): + marker.with_suffix('.term').write_text('termination hook ran') + sys.exit(0) +signal.signal(signal.SIGTERM, terminated) +atexit.register(lambda: marker.with_suffix('.exit').write_text('exit hook ran')) +marker.with_suffix('.pid').write_text(str(os.getpid())) +os.write(1, struct.pack('> old_pid; } + assert(old_pid > 0); + worker.stop(true); + assert(!std::filesystem::exists(root / "shutdown.term") && + !std::filesystem::exists(root / "shutdown.exit")); + assert(::kill(old_pid, 0) == -1 && errno == ESRCH); + int status = 0; + assert(::waitpid(old_pid, &status, WNOHANG) == -1 && errno == ECHILD); + // Restart only after the previous enabled worker is gone and reaped. + worker.start(argv[1], argv[2], "analytics-off"); + until([&] { worker.poll(); return worker.ready(); }); + worker.stop(true); + std::filesystem::remove_all(root); + assert(std::filesystem::is_empty(runtime)); + } // A separate fake worker proves stderr is not part of the framed pipe, // even when diagnostics fill its pipe before the ready frame. char scratch[] = "/tmp/frameyap-debug-test-XXXXXX";