mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-17 10:00:19 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46932f466c | ||
|
|
b3e79ebdae |
No files matched your search
+1
-1
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user