Author SHA1 Message Date
baketnk 46932f466c Merge pull request #4 from baketnk/fix/moondream-analytics-settings
Add default-off Moondream analytics setting
2026-10-12 09:53:12 -04:00
baketnk b3e79ebdae Add default-off Moondream analytics setting and guarded telemetry opt-out 2026-10-12 09:49:12 -04:00
24 changed files with 964 additions and 83 deletions

No files matched your search

+1 -1
View File
@@ -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)
+10 -2
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@
"font": "",
"input_priority": "normal",
"advanced_debug": false,
"moondream_analytics": false,
"auto_insert": false,
"close_mic_when_idle": false,
"backend": "redux",
+58 -2
View File
@@ -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
+6 -1
View File
@@ -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")
+42
View File
@@ -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")
+16
View File
@@ -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);
}
+5
View File
@@ -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;
+22 -10
View File
@@ -23,17 +23,18 @@ std::string state_label(State state) {
Controller::Controller(ControllerAudio& audio, ControllerWorker& worker,
DeliveryFactory delivery, FocusFactory focus,
std::vector<std::string> 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.";
+7 -6
View File
@@ -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<float>& pcm) = 0;
virtual std::optional<WorkerReply> 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<std::string> 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
+61 -14
View File
@@ -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<UiAction, 5> 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<ModelAction> 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<std::string>& Overlay::quick_inputs() const { return impl_->config.quick_inputs; }
+4
View File
@@ -47,6 +47,10 @@ public:
std::vector<ModelAction> 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<std::string>& quick_inputs() const;
+81 -10
View File
@@ -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<Button, 38> buttons{{
constexpr std::array<Button, 41> 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<Button, 38> 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<std::string> 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<std::pair<const char*, std::string>, 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<unsigned char>& 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<std::string> PanelSurface::visible_model_review_lines() const {
return impl_->visible_consent_lines();
}
std::vector<std::string> PanelSurface::visible_analytics_review_lines() const {
return impl_->tab == Tab::AnalyticsConsent && impl_->analytics_consent_rendered ?
analytics_review_lines : std::vector<std::string>{};
}
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::Bounds> PanelSurface::input_regions() const {
std::vector<Bounds> 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;
+7
View File
@@ -18,6 +18,7 @@ struct SurfaceEvent {
std::optional<Mount> mount;
std::optional<bool> lasers_anytime;
std::optional<bool> advanced_debug;
std::optional<bool> moondream_analytics;
std::optional<bool> auto_insert;
std::optional<bool> gradient_enabled;
std::optional<bool> 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<std::string> visible_model_review_lines() const;
std::vector<std::string> visible_analytics_review_lines() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
+18 -9
View File
@@ -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<float>& pcm) override { worker_.submit(id, pcm); }
std::optional<WorkerReply> 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<NativeFocus>(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();
+25 -14
View File
@@ -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<char*> envp;
for (auto& value : environment) envp.push_back(value.data());
envp.push_back(nullptr);
@@ -448,6 +457,8 @@ std::optional<WorkerReply> 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 ||
+4 -2
View File
@@ -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<float>& pcm);
std::optional<WorkerReply> 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;
+73
View File
@@ -1,4 +1,7 @@
#include "config.hpp"
#include <csignal>
#include <sys/resource.h>
#include <sys/wait.h>
#include "panel_clock.hpp"
#include <cassert>
#include <cstdlib>
@@ -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);
+128 -7
View File
@@ -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<WorkerReply> 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<float>& pcm) override {
assert(loaded && pcm.size() == 3200); request = id; ++submissions;
}
std::optional<WorkerReply> 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<Lease>(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<ControllerFocus> { return std::make_unique<FocusFake>(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");
+98 -1
View File
@@ -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";
}
+42 -1
View File
@@ -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('<I', 1) + b'Y'); sys.stdout.buffer.flush()\n"
+ "sys.stdin.buffer.read()\n")
result = subprocess.run(argv, input=b'', capture_output=True, timeout=5,
env={**os.environ, 'FRAMEYAP_MOONDREAM_ANALYTICS': value})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, frame(b'Y'))
launcher.write_text("import struct, sys\nsys.stdout.buffer.write(struct.pack('<I', 2) + b'FP')\n")
result = subprocess.run(argv, input=b'', capture_output=True, timeout=5)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(result.stdout, frame(b'FP'))
def test_status_consent_digest_rejected_before_installer_on_same_id_change(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
+179
View File
@@ -0,0 +1,179 @@
"""Offline analytics choice tests; optional exact-wheel reporter audit uses fake HTTP.
Set FRAMEYAP_TEST_KESTREL_WHEEL to a previously downloaded Kestrel 0.8.0 wheel
for the upstream lifecycle test. Tests never download, infer or upload telemetry.
"""
import asyncio
import hashlib
import os
from pathlib import Path
import struct
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import Mock, patch
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "python"))
from frameyap import worker
VERSIONS = {"kestrel": "0.8.0", "moondream": "2.4.0"}
WHEEL_SHA256 = "15c65bf12983d347330082838d4c6a9678a7f00c0f32ae089d9ccad7482e62bd"
class TelemetryTests(unittest.TestCase):
def reporter_modules(self, reporter):
return {"kestrel": types.ModuleType("kestrel"),
"kestrel.photon": types.SimpleNamespace(PhotonReporter=reporter)}
def test_flush_disabled_including_retry_and_repeated_setup(self):
class Reporter:
async def _flush_window(self, *, rotate=True):
raise AssertionError("upload attempted")
with patch.dict(sys.modules, self.reporter_modules(Reporter)), \
patch.object(worker.importlib.metadata, "version", side_effect=VERSIONS.__getitem__):
for _ in range(2):
worker.disable_kestrel_telemetry()
self.assertIsNone(asyncio.run(Reporter()._flush_window()))
self.assertIsNone(asyncio.run(Reporter()._flush_window(rotate=False)))
def test_unknown_versions_fail_before_importing_kestrel(self):
for package in VERSIONS:
versions = {**VERSIONS, package: "999.0"}
with self.subTest(package=package), \
patch.object(worker.importlib.metadata, "version", side_effect=versions.__getitem__), \
patch.dict(sys.modules, {"kestrel": None, "kestrel.photon": None}):
with self.assertRaisesRegex(worker.TelemetryProtectionError, "unsupported runtime version"):
worker.disable_kestrel_telemetry()
def test_unknown_interfaces_fail_closed(self):
async def positional(self, rotate=True): pass
async def wrong_default(self, *, rotate=False): pass
for flush in (None, lambda self: None, positional, wrong_default):
reporter = type("Reporter", (), {"_flush_window": flush})
with self.subTest(flush=flush), patch.dict(sys.modules, self.reporter_modules(reporter)), \
patch.object(worker.importlib.metadata, "version", side_effect=VERSIONS.__getitem__):
with self.assertRaisesRegex(worker.TelemetryProtectionError, "unsupported telemetry reporter interface"):
worker.disable_kestrel_telemetry()
def test_choice_applied_before_model_creation_default_and_malformed_off(self):
torch = types.SimpleNamespace(set_num_threads=Mock(), set_num_interop_threads=Mock())
for value in (None, "0", "", "true", "garbage", "1"):
calls = []
md = types.SimpleNamespace(photon=lambda *a, **kw: calls.append("model"))
with self.subTest(value=value), patch.dict(os.environ, clear=True), \
patch.dict(sys.modules, {"torch": torch, "moondream": md}), \
patch.object(worker, "local_model", return_value="/fixture"), \
patch.object(worker, "disable_kestrel_telemetry", side_effect=lambda: calls.append("guard")):
if value is not None:
os.environ["FRAMEYAP_MOONDREAM_ANALYTICS"] = value
worker.load_model("/fixture", 2)
self.assertEqual(calls, ["model"] if value == "1" else ["guard", "model"])
def test_failed_guard_prevents_engine_start(self):
torch = types.SimpleNamespace(set_num_threads=Mock(), set_num_interop_threads=Mock())
photon = Mock()
with patch.dict(os.environ, {"FRAMEYAP_MOONDREAM_ANALYTICS": "0"}), \
patch.dict(sys.modules, {"torch": torch, "moondream": types.SimpleNamespace(photon=photon)}), \
patch.object(worker, "local_model", return_value="/fixture"), \
patch.object(worker, "disable_kestrel_telemetry", side_effect=worker.TelemetryProtectionError("blocked")):
with self.assertRaises(worker.TelemetryProtectionError):
worker.load_model("/fixture", 2)
photon.assert_not_called()
def test_privacy_failure_has_specific_protocol_code_without_private_details(self):
program = '''from frameyap import worker
def blocked(*args):
raise worker.TelemetryProtectionError("PRIVATE DETAILS")
worker.load_model = blocked
raise SystemExit(worker.main())
'''
with tempfile.TemporaryDirectory() as clip:
os.chmod(clip, 0o700)
result = subprocess.run([sys.executable, "-c", program, "--model", "unused", "--clip-dir", clip],
capture_output=True, timeout=5,
env={**os.environ, "PYTHONPATH": str(ROOT / "python"), "FRAMEYAP_ADVANCED_DEBUG": "0"})
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, struct.pack("<I", 2) + b"FP")
self.assertEqual(result.stderr, b"")
@unittest.skipUnless(os.environ.get("FRAMEYAP_TEST_KESTREL_WHEEL"), "optional audited upstream wheel not supplied")
class PinnedReporterTests(unittest.TestCase):
def test_on_and_off_startup_periodic_shutdown_and_auth_retry(self):
wheel = Path(os.environ["FRAMEYAP_TEST_KESTREL_WHEEL"])
self.assertEqual(hashlib.sha256(wheel.read_bytes()).hexdigest(), WHEEL_SHA256)
with zipfile.ZipFile(wheel) as archive:
source = archive.read("kestrel/photon.py")
class Client:
def __init__(self, *args, **kwargs):
self.headers = kwargs.get("headers") or {}
self.posts = []
self.closed = False
async def post(self, url, **kwargs):
self.posts.append((url, kwargs))
# Auth failure drives upstream's actual nonrotating retry path.
code = 401 if len(self.posts) == 1 and self.headers else 200
return types.SimpleNamespace(status_code=code, json=lambda: {"standing": "anonymous"})
async def aclose(self):
self.closed = True
for enabled in (False, True, False): # each setting reload uses a fresh process/class
for key in (None, "non-secret-fixture-key", "invalid fixture key"):
module = types.ModuleType("kestrel.photon")
modules = {"kestrel": types.ModuleType("kestrel"), "kestrel.photon": module,
"kestrel.config": types.SimpleNamespace(RuntimeConfig=object),
"httpx": types.SimpleNamespace(AsyncClient=Client, HTTPError=OSError),
"torch": types.SimpleNamespace()}
with self.subTest(enabled=enabled, key=key), patch.dict(sys.modules, modules), \
patch.object(worker.importlib.metadata, "version", side_effect=VERSIONS.__getitem__), \
patch("socket.gethostname", return_value="fixture-host"):
exec(compile(source, "audited-kestrel-0.8.0/photon.py", "exec"), module.__dict__)
module._TELEMETRY_FLUSH_INTERVAL_SECONDS = 0.001
if not enabled:
worker.disable_kestrel_telemetry()
async def exercise():
reporter = module.PhotonReporter(
types.SimpleNamespace(service_name="local", model="moondream/parakeet-redux"),
types.SimpleNamespace(type="cpu"), api_key=key,
api_base_url="https://api.moondream.ai")
client = reporter._client
await reporter.validate_api_key()
startup = len(client.posts)
reporter.record_success(finetune=None, input_tokens=3, output_tokens=4)
reporter.record_error(finetune=None)
flushed = asyncio.Event()
original = reporter._flush_window
async def observed(*, rotate=True):
result = await original(rotate=rotate)
flushed.set()
return result
reporter._flush_window = observed
reporter.start()
await asyncio.wait_for(flushed.wait(), timeout=1)
periodic = len(client.posts)
await reporter.shutdown()
shutdown = len(client.posts)
await reporter._flush_window(rotate=False)
self.assertTrue(client.closed)
if not enabled:
self.assertEqual(client.posts, [])
else:
self.assertGreater(startup, 0)
self.assertGreater(periodic, startup)
self.assertGreater(shutdown, periodic)
report = client.posts[0][1]["json"]["reports"][0]
self.assertEqual(client.posts[0][0], "https://api.moondream.ai/v1/photon/telemetry")
self.assertEqual(report["hostname"], "fixture-host")
self.assertNotIn("audio", report)
self.assertNotIn("text", report)
asyncio.run(exercise())
if __name__ == "__main__":
unittest.main()
+4 -2
View File
@@ -36,9 +36,11 @@ def fake_child():
parser.add_argument("--clip-dir", required=True)
parser.add_argument("--advanced-debug", action="store_true")
args = parser.parse_args()
if args.model in ("fail", "missing-model", "missing-import"):
if args.model in ("analytics-off", "analytics-on"):
assert os.environ.get("FRAMEYAP_MOONDREAM_ANALYTICS") == ("1" if args.model == "analytics-on" else "0")
if args.model in ("fail", "missing-model", "missing-import", "analytics-unsupported"):
worker.send_frame(1, b"F", {"fail": b"", "missing-model": b"M",
"missing-import": b"I"}[args.model])
"missing-import": b"I", "analytics-unsupported": b"P"}[args.model])
return
if args.model == "crash":
return
+72 -1
View File
@@ -1,13 +1,16 @@
#include "worker.hpp"
#include <cassert>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <functional>
#include <fstream>
#include <limits>
#include <signal.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <stdexcept>
#include <string>
#include <thread>
@@ -34,6 +37,23 @@ int main(int argc, char** argv) {
try {
std::vector<float> 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<std::pair<const char*, const char*>>{
{"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('<I', 1) + b'Y')
while os.read(0, 1):
pass
)PY";
}
// Prove the fixture would expose a graceful shutdown flush.
worker.start(argv[1], script.string(), marker.string());
until([&] { worker.poll(); return worker.ready(); });
worker.stop();
assert(std::filesystem::exists(root / "shutdown.term") &&
std::filesystem::exists(root / "shutdown.exit"));
std::filesystem::remove(root / "shutdown.term");
std::filesystem::remove(root / "shutdown.exit");
worker.start(argv[1], script.string(), marker.string(), 2, false, {}, {}, {}, true);
until([&] { worker.poll(); return worker.ready(); });
pid_t old_pid = -1;
{ std::ifstream pid(root / "shutdown.pid"); pid >> 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";