mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 03:00:04 +02:00
Add safe transcription errors and opt-in private debug logging
This commit is contained in:
1 parent
29f3729df7
commit
6592e1af1c
21 files changed
+720
-65
No files matched your search
@@ -39,6 +39,10 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
|
|||||||
panel colors, a font path and Frame controller button mappings; missing fonts
|
panel colors, a font path and Frame controller button mappings; missing fonts
|
||||||
fall back to bundled Inconsolata. The installer creates/checks this file and
|
fall back to bundled Inconsolata. The installer creates/checks this file and
|
||||||
backs it up before repairs. See [overlay configuration](docs/overlay.md#user-theme-and-controller-configuration).
|
backs it up before repairs. See [overlay configuration](docs/overlay.md#user-theme-and-controller-configuration).
|
||||||
|
- **Advanced debugging:** Settings toggle / `"advanced_debug": true` in config.
|
||||||
|
Off by default. Restarts the worker and discards current work; full exceptions,
|
||||||
|
worker output and transcripts go to private, bounded local logs. No raw audio
|
||||||
|
archive. See [diagnostics](docs/worker.md#advanced-debugging).
|
||||||
- **Experimental input priority:** set `"input_priority": "experimental"` in
|
- **Experimental input priority:** set `"input_priority": "experimental"` in
|
||||||
that config and enable SteamVR's Developer option **Enable global input from
|
that config and enable SteamVR's Developer option **Enable global input from
|
||||||
overlays**. FrameYap then requests priority for its bound controller sources.
|
overlays**. FrameYap then requests priority for its bound controller sources.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"font": "",
|
"font": "",
|
||||||
"input_priority": "normal",
|
"input_priority": "normal",
|
||||||
|
"advanced_debug": false,
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {
|
"theme": {
|
||||||
"background": "#0c101b",
|
"background": "#0c101b",
|
||||||
|
|||||||
+14
-1
@@ -98,6 +98,7 @@ installer creates one with defaults on first install. Copy the shipped
|
|||||||
{
|
{
|
||||||
"font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
"font": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
"input_priority": "normal",
|
"input_priority": "normal",
|
||||||
|
"advanced_debug": false,
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {
|
"theme": {
|
||||||
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||||
@@ -117,6 +118,17 @@ installer creates one with defaults on first install. Copy the shipped
|
|||||||
|
|
||||||
Each theme color is `#RRGGBB`; omitted colors keep the default. `font` is a
|
Each theme color is `#RRGGBB`; omitted colors keep the default. `font` is a
|
||||||
TTF/OTF file path (not a family name); a missing file uses the bundled font.
|
TTF/OTF file path (not a family name); a missing file uses the bundled font.
|
||||||
|
`advanced_debug` is a boolean (default `false`, not a string): an opt-in
|
||||||
|
request for full diagnostic logs. Full logs may contain speech/transcribed text
|
||||||
|
and local paths; **raw audio clips are not archived**. The Settings tab shows
|
||||||
|
an Advanced debugging ON/OFF toggle and warns that changing it restarts the
|
||||||
|
worker and cancels current work (including pending review). Changes take effect
|
||||||
|
immediately; failed saves show a warning and keep the selection for this session.
|
||||||
|
Detailed logs are bounded and owner-private; see [worker diagnostics](worker.md#advanced-debugging). The native
|
||||||
|
`save_advanced_debug(path, bool)` helper updates only this value in a valid
|
||||||
|
config, retaining other fields and formatting; invalid/unwritable configs are
|
||||||
|
left untouched and return failure. The installer backs up original bytes before
|
||||||
|
repairing invalid values, while valid `true` and `false` are retained.
|
||||||
`buttons` maps named OpenVR actions (`left_grip`, `right_grip`, `ptt`, `cancel`,
|
`buttons` maps named OpenVR actions (`left_grip`, `right_grip`, `ptt`, `cancel`,
|
||||||
`insert`, `enter`) to Frame physical `/user/hand/{left|right}/input/NAME`
|
`insert`, `enter`) to Frame physical `/user/hand/{left|right}/input/NAME`
|
||||||
button paths. Omitted actions retain their bundled defaults; an empty string
|
button paths. Omitted actions retain their bundled defaults; an empty string
|
||||||
@@ -202,7 +214,8 @@ keeps the selection for the session and displays a warning. `--mount
|
|||||||
world|left-wrist|right-wrist|head` overrides the saved choice for one launch without
|
world|left-wrist|right-wrist|head` overrides the saved choice for one launch without
|
||||||
writing it; `--head` remains an alias for `--mount head`.
|
writing it; `--head` remains an alias for `--mount head`.
|
||||||
|
|
||||||
Settings also has **Lasers anytime** (default off). When enabled, FrameYap sets
|
Settings also has **Lasers anytime** (default off). Open the dashboard to change
|
||||||
|
it when system-wide lasers are disabled. When enabled, FrameYap sets
|
||||||
OpenVR's `VROverlayFlags_MakeOverlaysInteractiveIfVisible` on its panel. OpenVR
|
OpenVR's `VROverlayFlags_MakeOverlaysInteractiveIfVisible` on its panel. OpenVR
|
||||||
requests system-wide laser mouse mode while the panel is visible, including
|
requests system-wide laser mouse mode while the panel is visible, including
|
||||||
with Steam's dashboard closed; it may change interaction with games. Turning
|
with Steam's dashboard closed; it may change interaction with games. Turning
|
||||||
|
|||||||
+35
-6
@@ -1,8 +1,8 @@
|
|||||||
# Offline Redux worker adapter (component, not an installed product)
|
# Offline Redux worker adapter (component, not an installed product)
|
||||||
|
|
||||||
`src/worker.hpp` provides `frameyap::Worker`: call `start(python, script, model,
|
`src/worker.hpp` provides `frameyap::Worker`: call `start(python, script, model,
|
||||||
threads=2)` explicitly, poll until `ready()`, then `submit(id, pcm)` and poll for
|
threads=2, advanced_debug=false)` explicitly, poll until `ready()`, then `submit(id, pcm)` and poll for
|
||||||
one `WorkerReply` (text or generic per-request error). One request at a time;
|
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,
|
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 **only its direct child** (TERM, bounded 500 ms, then KILL),
|
||||||
and is safe to repeat. Destruction stops it. `start()` returns without waiting
|
and is safe to repeat. Destruction stops it. `start()` returns without waiting
|
||||||
@@ -19,16 +19,19 @@ and writes only `clip.raw` with `O_EXCL|O_NOFOLLOW`, mode 0600. Clips are
|
|||||||
float32 (0.2..20 s). Files are unlinked after replies or shutdown, and the
|
float32 (0.2..20 s). Files are unlinked after replies or shutdown, and the
|
||||||
private directory is removed. Private clips are not encrypted against the
|
private directory is removed. Private clips are not encrypted against the
|
||||||
account owner/root; do not use an untrusted runtime directory. The caller
|
account owner/root; do not use an untrusted runtime directory. The caller
|
||||||
should pass a trusted interpreter and script. Neither audio nor transcripts
|
should pass a trusted interpreter and script. By default audio/transcripts are
|
||||||
are logged; child stderr is redirected to `/dev/null`, so worker diagnostics
|
not logged and child stderr is redirected to `/dev/null`. Request errors report
|
||||||
are deliberately generic.
|
only a fixed stage (`audio`, `inference`, `response`) and built-in exception
|
||||||
|
category, never the exception message, arbitrary class name, traceback or text.
|
||||||
|
The native receiver allowlists those labels before displaying/logging them;
|
||||||
|
unknown/legacy errors remain generic.
|
||||||
|
|
||||||
The private pipes use unsigned LE32 payload lengths (1..65536), a one-byte
|
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
|
message type and, for requests/replies, unsigned LE64 request ID. `T` + ID
|
||||||
requests reading the fixed clip; `Y` means ready; `F` means load failure
|
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
|
(`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, `D` for runtime/model load failure); `R` + ID + UTF-8
|
||||||
text and `E` + ID + generic UTF-8 error are replies. Text is at most 4096
|
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
|
bytes. An unexpected or duplicate reply, wrong ID, extra frame, closed pipe
|
||||||
or oversized frame stops the worker. Warmup deadline is 120 s, transcription
|
or oversized frame stops the worker. Warmup deadline is 120 s, transcription
|
||||||
deadline 60 s; `poll()` must be called regularly to enforce deadlines. It
|
deadline 60 s; `poll()` must be called regularly to enforce deadlines. It
|
||||||
@@ -58,6 +61,32 @@ or bundling. See [third-party notes](third-party.md). No public runtime bundle h
|
|||||||
been released. Limited ARM64 measurements are in the [POC record](evidence/poc-cpu-overlay-2026-09-24.md),
|
been released. Limited ARM64 measurements are in the [POC record](evidence/poc-cpu-overlay-2026-09-24.md),
|
||||||
not a claim of complete headset acceptance.
|
not a claim of complete headset acceptance.
|
||||||
|
|
||||||
|
## Advanced debugging
|
||||||
|
|
||||||
|
Explicitly set `"advanced_debug": true` in config or enable Settings → Advanced
|
||||||
|
debugging. It is off by default. A Settings change restarts the owned worker,
|
||||||
|
closes the microphone and discards current work/review; the replacement worker
|
||||||
|
warms normally. Turning it off stops detailed capture but **does not delete
|
||||||
|
previous logs**. Manual config edits take effect on application restart.
|
||||||
|
|
||||||
|
With this opt-in the worker receives `--advanced-debug`: native/model stdout and
|
||||||
|
stderr, sample counts, full exception tracebacks and recognized transcripts are
|
||||||
|
captured. **These logs may contain private speech, transcript text and local
|
||||||
|
paths. Inspect/redact before sharing; keep out of Git.** No raw audio archive is
|
||||||
|
created; normal temporary clips still expire after replies/cancellation.
|
||||||
|
|
||||||
|
Files: `$XDG_STATE_HOME/frameyap/worker-debug.log` (fallback
|
||||||
|
`~/.local/state/frameyap/worker-debug.log`) and `worker-debug.previous.log`.
|
||||||
|
Each debug worker start rotates the current log once; only these two files are
|
||||||
|
retained, each bounded to 4 MiB. At the limit a marker is written and further
|
||||||
|
output is drained/discarded for that worker session, not allowed to block it.
|
||||||
|
The directory is owner-private 0700 and logs are 0600. Unsafe paths, symlinks,
|
||||||
|
hardlinks or preexisting permissive files are refused with a visible error;
|
||||||
|
there is no fallback to public temporary files. The app's single-instance lock
|
||||||
|
is required to avoid competing rotation by multiple workers. Debug output never
|
||||||
|
shares the framed protocol stdout. Direct worker CLI use with `--advanced-debug`
|
||||||
|
writes to its caller's stderr; the native adapter supplies the private bounded sink.
|
||||||
|
|
||||||
Hardware-free tests run through CTest, including fake-child cancellation, short
|
Hardware-free tests run through CTest, including fake-child cancellation, short
|
||||||
injected warmup/request deadlines, duplicate/stale replies, malformed frames,
|
injected warmup/request deadlines, duplicate/stale replies, malformed frames,
|
||||||
missing/hash-mismatched model files and symlink refusal. Default production
|
missing/hash-mismatched model files and symlink refusal. Default production
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
|
|||||||
CONFIG_DEFAULTS = {
|
CONFIG_DEFAULTS = {
|
||||||
"font": "",
|
"font": "",
|
||||||
"input_priority": "normal",
|
"input_priority": "normal",
|
||||||
|
"advanced_debug": False,
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||||
@@ -92,6 +93,8 @@ def normalized_config(data):
|
|||||||
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
|
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
|
||||||
priority = data.get("input_priority", "normal")
|
priority = data.get("input_priority", "normal")
|
||||||
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
|
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
|
||||||
|
debug = data.get("advanced_debug", False)
|
||||||
|
fixed["advanced_debug"] = debug if type(debug) is bool else False
|
||||||
source = data.get("wrist")
|
source = data.get("wrist")
|
||||||
source = source if isinstance(source, dict) else {}
|
source = source if isinstance(source, dict) else {}
|
||||||
fixed["wrist"] = {}
|
fixed["wrist"] = {}
|
||||||
|
|||||||
+49
-12
@@ -4,7 +4,7 @@ Wire protocol: unsigned LE32 payload length (max 65536), type byte, unsigned
|
|||||||
LE64 request id for T/R/E; Y (ready) and F (load failure) have no id. T refers
|
LE64 request id for T/R/E; Y (ready) and F (load failure) have no id. T refers
|
||||||
to the fixed clip.raw in the private directory. R/E carry <=4096 UTF-8 bytes.
|
to the fixed clip.raw in the private directory. R/E carry <=4096 UTF-8 bytes.
|
||||||
Only one T may be outstanding. No stdout other than frames; stderr is suppressed
|
Only one T may be outstanding. No stdout other than frames; stderr is suppressed
|
||||||
by the native launcher. This module never captures audio or delivers input.
|
by default, or captured privately with explicit --advanced-debug. This module never captures audio or delivers input.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
|||||||
import stat
|
import stat
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
MAX_FRAME = 65536
|
MAX_FRAME = 65536
|
||||||
MAX_TEXT = 4096
|
MAX_TEXT = 4096
|
||||||
@@ -131,7 +132,19 @@ def read_clip(directory):
|
|||||||
return pcm
|
return pcm
|
||||||
|
|
||||||
|
|
||||||
def run(model, directory, input_fd=0, output_fd=1):
|
def safe_request_error(stage, error):
|
||||||
|
"""Only fixed labels: never stringify exceptions, paths, audio or transcripts."""
|
||||||
|
if stage not in ("audio", "inference", "response"):
|
||||||
|
stage = "unknown"
|
||||||
|
# Use built-in categories, not an arbitrary exception class name supplied by
|
||||||
|
# a model/runtime. Subclasses are reduced to their safe built-in category.
|
||||||
|
categories = (MemoryError, ImportError, OSError, UnicodeError, TypeError,
|
||||||
|
ValueError, KeyError, IndexError, RuntimeError)
|
||||||
|
category = next((kind.__name__ for kind in categories if isinstance(error, kind)), "Exception")
|
||||||
|
return f"transcription failed [{stage}: {category}]".encode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def run(model, directory, input_fd=0, output_fd=1, advanced_debug=False):
|
||||||
private_dir(directory)
|
private_dir(directory)
|
||||||
send_frame(output_fd, b"Y")
|
send_frame(output_fd, b"Y")
|
||||||
while True:
|
while True:
|
||||||
@@ -141,19 +154,30 @@ def run(model, directory, input_fd=0, output_fd=1):
|
|||||||
if len(frame) != 9 or frame[0:1] != b"T":
|
if len(frame) != 9 or frame[0:1] != b"T":
|
||||||
raise ValueError("invalid request")
|
raise ValueError("invalid request")
|
||||||
request_id = frame[1:9]
|
request_id = frame[1:9]
|
||||||
|
stage = "audio"
|
||||||
try:
|
try:
|
||||||
result = model.transcribe(audio=read_clip(directory), sample_rate=16000)
|
audio = read_clip(directory)
|
||||||
|
if advanced_debug:
|
||||||
|
print(f"request {int.from_bytes(request_id, 'little')}: audio samples={len(audio)} rate=16000", file=sys.stderr, flush=True)
|
||||||
|
stage = "inference"
|
||||||
|
result = model.transcribe(audio=audio, sample_rate=16000)
|
||||||
|
stage = "response"
|
||||||
text = result["text"]
|
text = result["text"]
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise ValueError("invalid model response")
|
raise TypeError("invalid model response")
|
||||||
encoded = text.encode("utf-8", errors="strict")
|
encoded = text.encode("utf-8", errors="strict")
|
||||||
if len(encoded) > MAX_TEXT:
|
if len(encoded) > MAX_TEXT:
|
||||||
raise ValueError("transcript exceeds 4096 bytes")
|
raise ValueError("transcript exceeds 4096 bytes")
|
||||||
|
if advanced_debug:
|
||||||
|
print(f"request {int.from_bytes(request_id, 'little')}: transcript={text!r}", file=sys.stderr, flush=True)
|
||||||
send_frame(output_fd, b"R", request_id + encoded)
|
send_frame(output_fd, b"R", request_id + encoded)
|
||||||
except Exception:
|
except Exception as error:
|
||||||
# Model exceptions may include audio or transcripts. Do not log or
|
if advanced_debug:
|
||||||
# forward them to the overlay; request-local failure only.
|
print(f"request {int.from_bytes(request_id, 'little')}: failure stage={stage}", file=sys.stderr, flush=True)
|
||||||
send_frame(output_fd, b"E", request_id + b"transcription failed")
|
traceback.print_exc(file=sys.stderr)
|
||||||
|
# Only fixed stage/category labels cross IPC. Exception messages and
|
||||||
|
# tracebacks may contain private audio/text and remain suppressed.
|
||||||
|
send_frame(output_fd, b"E", request_id + safe_request_error(stage, error))
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
@@ -161,6 +185,8 @@ def main(argv=None):
|
|||||||
parser.add_argument("--model", required=True)
|
parser.add_argument("--model", required=True)
|
||||||
parser.add_argument("--threads", type=int, default=2)
|
parser.add_argument("--threads", type=int, default=2)
|
||||||
parser.add_argument("--clip-dir", required=True)
|
parser.add_argument("--clip-dir", required=True)
|
||||||
|
parser.add_argument("--advanced-debug", action="store_true",
|
||||||
|
help="log full exceptions/runtime output and transcripts to stderr; may contain private speech")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if not 1 <= args.threads <= 64:
|
if not 1 <= args.threads <= 64:
|
||||||
parser.error("threads must be 1..64")
|
parser.error("threads must be 1..64")
|
||||||
@@ -168,9 +194,15 @@ def main(argv=None):
|
|||||||
# the framed channel, not merely Python's sys.stdout wrapper.
|
# the framed channel, not merely Python's sys.stdout wrapper.
|
||||||
protocol_fd = os.dup(1)
|
protocol_fd = os.dup(1)
|
||||||
os.set_inheritable(protocol_fd, False)
|
os.set_inheritable(protocol_fd, False)
|
||||||
with open(os.devnull, "wb") as null:
|
if args.advanced_debug:
|
||||||
os.dup2(null.fileno(), 1)
|
# Native parent supplies a bounded private diagnostic sink. Model/native
|
||||||
os.dup2(null.fileno(), 2)
|
# stdout must still never corrupt the duplicated framed protocol fd.
|
||||||
|
os.dup2(2, 1)
|
||||||
|
print("FrameYap advanced debugging ON: private speech/text/paths may be logged; no raw clip archive.", file=sys.stderr, flush=True)
|
||||||
|
else:
|
||||||
|
with open(os.devnull, "wb") as null:
|
||||||
|
os.dup2(null.fileno(), 1)
|
||||||
|
os.dup2(null.fileno(), 2)
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
private_dir(args.clip_dir)
|
private_dir(args.clip_dir)
|
||||||
@@ -180,15 +212,20 @@ def main(argv=None):
|
|||||||
try:
|
try:
|
||||||
model = load_model(args.model, args.threads)
|
model = load_model(args.model, args.threads)
|
||||||
except LocalModelError:
|
except LocalModelError:
|
||||||
|
if args.advanced_debug: traceback.print_exc(file=sys.stderr)
|
||||||
send_frame(protocol_fd, b"F", b"M")
|
send_frame(protocol_fd, b"F", b"M")
|
||||||
return 1
|
return 1
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
if args.advanced_debug: traceback.print_exc(file=sys.stderr)
|
||||||
send_frame(protocol_fd, b"F", b"I")
|
send_frame(protocol_fd, b"F", b"I")
|
||||||
return 1
|
return 1
|
||||||
except Exception:
|
except Exception:
|
||||||
|
if args.advanced_debug: traceback.print_exc(file=sys.stderr)
|
||||||
send_frame(protocol_fd, b"F", b"D")
|
send_frame(protocol_fd, b"F", b"D")
|
||||||
return 1
|
return 1
|
||||||
run(model, args.clip_dir, output_fd=protocol_fd)
|
if args.advanced_debug:
|
||||||
|
print("Local model ready", file=sys.stderr, flush=True)
|
||||||
|
run(model, args.clip_dir, output_fd=protocol_fd, advanced_debug=args.advanced_debug)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
os.close(protocol_fd)
|
os.close(protocol_fd)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ DIGEST_RE = re.compile(r"[a-fA-F0-9]{64}\Z")
|
|||||||
CONFIG_DEFAULTS = {
|
CONFIG_DEFAULTS = {
|
||||||
"font": "",
|
"font": "",
|
||||||
"input_priority": "normal",
|
"input_priority": "normal",
|
||||||
|
"advanced_debug": False,
|
||||||
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
"wrist": {"x": 0, "y": 0.18, "z": 0.089, "width": 0.30, "roll_degrees": 0},
|
||||||
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||||
@@ -81,6 +82,8 @@ def normalized_config(data):
|
|||||||
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
|
fixed = {"font": data.get("font") if isinstance(data.get("font"), str) else ""}
|
||||||
priority = data.get("input_priority", "normal")
|
priority = data.get("input_priority", "normal")
|
||||||
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
|
fixed["input_priority"] = priority if priority in ("normal", "experimental") else "normal"
|
||||||
|
debug = data.get("advanced_debug", False)
|
||||||
|
fixed["advanced_debug"] = debug if type(debug) is bool else False
|
||||||
source = data.get("wrist")
|
source = data.get("wrist")
|
||||||
source = source if isinstance(source, dict) else {}
|
source = source if isinstance(source, dict) else {}
|
||||||
fixed["wrist"] = {}
|
fixed["wrist"] = {}
|
||||||
|
|||||||
+52
-9
@@ -13,7 +13,8 @@ namespace {
|
|||||||
struct Json {
|
struct Json {
|
||||||
std::string value;
|
std::string value;
|
||||||
std::map<std::string, Json> object;
|
std::map<std::string, Json> object;
|
||||||
bool is_string = false, is_object = false, is_number = false;
|
bool is_string = false, is_object = false, is_number = false, is_bool = false;
|
||||||
|
size_t start = 0, end = 0; // original value span for non-destructive config updates
|
||||||
};
|
};
|
||||||
struct Parser {
|
struct Parser {
|
||||||
std::string_view s;
|
std::string_view s;
|
||||||
@@ -70,23 +71,25 @@ struct Parser {
|
|||||||
ws();
|
ws();
|
||||||
if (pos == s.size()) fail();
|
if (pos == s.size()) fail();
|
||||||
Json result;
|
Json result;
|
||||||
if (s[pos] == '"') { result.value = str(); result.is_string = true; return result; }
|
result.start = pos;
|
||||||
|
auto done = [&]() { result.end = pos; return result; };
|
||||||
|
if (s[pos] == '"') { result.value = str(); result.is_string = true; return done(); }
|
||||||
if (eat('{')) {
|
if (eat('{')) {
|
||||||
result.is_object = true;
|
result.is_object = true;
|
||||||
if (eat('}')) return result;
|
if (eat('}')) return done();
|
||||||
do {
|
do {
|
||||||
ws(); if (pos == s.size() || s[pos] != '"') fail();
|
ws(); if (pos == s.size() || s[pos] != '"') fail();
|
||||||
auto key = str();
|
auto key = str();
|
||||||
if (!eat(':')) fail();
|
if (!eat(':')) fail();
|
||||||
auto [it, inserted] = result.object.emplace(std::move(key), parse(depth + 1));
|
auto [it, inserted] = result.object.emplace(std::move(key), parse(depth + 1));
|
||||||
if (!inserted) fail();
|
if (!inserted) fail();
|
||||||
if (eat('}')) return result;
|
if (eat('}')) return done();
|
||||||
} while (eat(','));
|
} while (eat(','));
|
||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
if (eat('[')) {
|
if (eat('[')) {
|
||||||
if (eat(']')) return result;
|
if (eat(']')) return done();
|
||||||
do { parse(depth + 1); if (eat(']')) return result; } while (eat(','));
|
do { parse(depth + 1); if (eat(']')) return done(); } while (eat(','));
|
||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
size_t start = pos;
|
size_t start = pos;
|
||||||
@@ -105,11 +108,15 @@ struct Parser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pos == start) fail();
|
if (pos == start) fail();
|
||||||
|
if (s.substr(start, pos - start) == "true" || s.substr(start, pos - start) == "false") {
|
||||||
|
result.is_bool = true;
|
||||||
|
result.value = s.substr(start, pos - start);
|
||||||
|
}
|
||||||
if (s[start] == '-' || (s[start] >= '0' && s[start] <= '9')) {
|
if (s[start] == '-' || (s[start] >= '0' && s[start] <= '9')) {
|
||||||
result.is_number = true;
|
result.is_number = true;
|
||||||
result.value = s.substr(start, pos - start);
|
result.value = s.substr(start, pos - start);
|
||||||
}
|
}
|
||||||
return result;
|
return done();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Rgba color(const Json& json) {
|
Rgba color(const Json& json) {
|
||||||
@@ -153,14 +160,16 @@ std::string read_file(const std::filesystem::path& p) {
|
|||||||
data.assign(buf, size_t(in.gcount()));
|
data.assign(buf, size_t(in.gcount()));
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
void write_file(const std::filesystem::path& path, const std::string& content) {
|
void write_file(const std::filesystem::path& path, const std::string& content, bool private_file = false) {
|
||||||
auto temporary = path.string() + ".tmp." + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
auto temporary = path.string() + ".tmp." + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
|
||||||
try {
|
try {
|
||||||
{
|
{
|
||||||
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
|
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
|
||||||
out << content;
|
out << content;
|
||||||
if (!out) throw std::runtime_error("Could not write generated OpenVR binding: " + path.string());
|
if (!out) throw std::runtime_error("Could not write 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);
|
||||||
std::filesystem::rename(temporary, path);
|
std::filesystem::rename(temporary, path);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
std::error_code ignored;
|
std::error_code ignored;
|
||||||
@@ -185,6 +194,9 @@ Config load_config(const std::filesystem::path& path) {
|
|||||||
if (key == "font") {
|
if (key == "font") {
|
||||||
if (!value.is_string) throw std::runtime_error("Config font must be a path string");
|
if (!value.is_string) throw std::runtime_error("Config font must be a path string");
|
||||||
config.font = value.value;
|
config.font = value.value;
|
||||||
|
} 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 == "input_priority") {
|
} else if (key == "input_priority") {
|
||||||
if (!value.is_string || (value.value != "normal" && value.value != "experimental"))
|
if (!value.is_string || (value.value != "normal" && value.value != "experimental"))
|
||||||
throw std::runtime_error("Config input_priority must be normal or experimental");
|
throw std::runtime_error("Config input_priority must be normal or experimental");
|
||||||
@@ -238,6 +250,37 @@ Config load_config(const std::filesystem::path& path) {
|
|||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept {
|
||||||
|
try {
|
||||||
|
if (path.empty() || !path.is_absolute() || std::filesystem::is_symlink(path)) return false;
|
||||||
|
const bool existing = std::filesystem::exists(path);
|
||||||
|
if (existing && !std::filesystem::is_regular_file(path)) return false;
|
||||||
|
if (existing) load_config(path); // reject invalid/unknown settings rather than erase customizations
|
||||||
|
std::string bytes = existing ? read_file(path) : "{}\n";
|
||||||
|
Parser parser{bytes};
|
||||||
|
auto root = parser.parse(); parser.ws();
|
||||||
|
if (!root.is_object || parser.pos != bytes.size()) return false;
|
||||||
|
const std::string value = enabled ? "true" : "false";
|
||||||
|
auto it = root.object.find("advanced_debug");
|
||||||
|
if (it != root.object.end()) {
|
||||||
|
if (!it->second.is_bool) return false;
|
||||||
|
if (it->second.value == value) return true;
|
||||||
|
bytes.replace(it->second.start, it->second.end - it->second.start, value);
|
||||||
|
} else {
|
||||||
|
bytes.insert(root.end - 1, std::string(root.object.empty() ? "" : ",") +
|
||||||
|
"\"advanced_debug\":" + value);
|
||||||
|
}
|
||||||
|
// The native reader rejects files >=4097 bytes, even if the JSON is valid.
|
||||||
|
if (bytes.size() > 4096) return false;
|
||||||
|
const auto parent = path.parent_path();
|
||||||
|
if (std::filesystem::is_symlink(parent)) return false;
|
||||||
|
std::filesystem::create_directories(parent);
|
||||||
|
write_file(path, bytes, true);
|
||||||
|
return true;
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
std::string resolve_font(const std::string& assets, const std::string& requested) {
|
std::string resolve_font(const std::string& assets, const std::string& requested) {
|
||||||
const auto bundled = std::filesystem::path(assets) / "fonts/Inconsolata-Regular.ttf";
|
const auto bundled = std::filesystem::path(assets) / "fonts/Inconsolata-Regular.ttf";
|
||||||
const std::array<std::filesystem::path, 4> candidates{requested, bundled,
|
const std::array<std::filesystem::path, 4> candidates{requested, bundled,
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ struct Config {
|
|||||||
std::string font; // absolute TTF/OTF path; empty uses the bundled face
|
std::string font; // absolute TTF/OTF path; empty uses the bundled face
|
||||||
// Requests OpenVR's experimental global action priority; SteamVR must allow it too.
|
// Requests OpenVR's experimental global action priority; SteamVR must allow it too.
|
||||||
bool experimental_input_priority = false;
|
bool experimental_input_priority = false;
|
||||||
|
bool advanced_debug = false; // opt-in full diagnostic logging; never raw audio recording
|
||||||
WristPlacement wrist;
|
WristPlacement wrist;
|
||||||
// OpenVR action name -> physical Frame controller input path; empty disables it.
|
// OpenVR action name -> physical Frame controller input path; empty disables it.
|
||||||
std::map<std::string, std::string> buttons;
|
std::map<std::string, std::string> buttons;
|
||||||
};
|
};
|
||||||
std::filesystem::path default_config_path();
|
std::filesystem::path default_config_path();
|
||||||
Config load_config(const std::filesystem::path& path);
|
Config load_config(const std::filesystem::path& path);
|
||||||
|
// Update only advanced_debug 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;
|
||||||
std::string resolve_font(const std::string& assets, const std::string& requested);
|
std::string resolve_font(const std::string& assets, const std::string& requested);
|
||||||
// No writes or OpenVR access when no custom button mappings are specified.
|
// No writes or OpenVR access when no custom button mappings are specified.
|
||||||
// When customized, build a generated manifest and bindings under XDG cache.
|
// When customized, build a generated manifest and bindings under XDG cache.
|
||||||
|
|||||||
+15
-3
@@ -74,7 +74,7 @@ struct Overlay::Impl {
|
|||||||
Config config;
|
Config config;
|
||||||
PanelSurface surface;
|
PanelSurface surface;
|
||||||
Panel panel;
|
Panel panel;
|
||||||
bool save_failed = false;
|
bool save_failed = false, debug_save_failed = false;
|
||||||
bool world_ready = false, placed = false, has_texture = false, shown = false;
|
bool world_ready = false, placed = false, has_texture = false, shown = false;
|
||||||
vr::HmdMatrix34_t world_transform{};
|
vr::HmdMatrix34_t world_transform{};
|
||||||
std::optional<Mount> applied_mount;
|
std::optional<Mount> applied_mount;
|
||||||
@@ -151,6 +151,7 @@ struct Overlay::Impl {
|
|||||||
laser_change_failed = true;
|
laser_change_failed = true;
|
||||||
}
|
}
|
||||||
surface.set_lasers_anytime(lasers_anytime);
|
surface.set_lasers_anytime(lasers_anytime);
|
||||||
|
surface.set_advanced_debug(config.advanced_debug);
|
||||||
overlay_check(overlay->SetOverlayFlag(handle, vr::VROverlayFlags_VisibleInDashboard, true), overlay, "VisibleInDashboard");
|
overlay_check(overlay->SetOverlayFlag(handle, vr::VROverlayFlags_VisibleInDashboard, true), overlay, "VisibleInDashboard");
|
||||||
vr::HmdVector2_t mouse_scale{{float(W), float(H)}};
|
vr::HmdVector2_t mouse_scale{{float(W), float(H)}};
|
||||||
overlay_check(overlay->SetOverlayMouseScale(handle, &mouse_scale), overlay, "SetOverlayMouseScale");
|
overlay_check(overlay->SetOverlayMouseScale(handle, &mouse_scale), overlay, "SetOverlayMouseScale");
|
||||||
@@ -190,7 +191,8 @@ struct Overlay::Impl {
|
|||||||
}
|
}
|
||||||
if (effective == Mount::World && applied_mount && *applied_mount != Mount::World)
|
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
|
world_ready = false; // a fresh world fallback near the wearer, not an old room location
|
||||||
std::string note = laser_change_failed ? "SteamVR declined the laser mode change." :
|
std::string note = debug_save_failed ? "Debug preference not saved; using it only for this session." :
|
||||||
|
laser_change_failed ? "SteamVR declined the laser mode change." :
|
||||||
save_failed ? "Preference could not be saved; using it for this session." : "";
|
save_failed ? "Preference could not be saved; using it for this session." : "";
|
||||||
if (effective != mount) note = save_failed ? "Wrist untracked; world fallback. Preference not saved." :
|
if (effective != mount) note = save_failed ? "Wrist untracked; world fallback. Preference not saved." :
|
||||||
"Wrist not tracked - using world space until it returns.";
|
"Wrist not tracked - using world space until it returns.";
|
||||||
@@ -371,8 +373,17 @@ struct Overlay::Impl {
|
|||||||
last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
|
last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
|
||||||
if (event.data.mouse.button == vr::VRMouseButton_Left) {
|
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);
|
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) ++pointer_actions;
|
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.advanced_debug) ++pointer_actions;
|
||||||
if (event_result.action) result.push_back(*event_result.action);
|
if (event_result.action) result.push_back(*event_result.action);
|
||||||
|
if (event_result.advanced_debug) {
|
||||||
|
config.advanced_debug = *event_result.advanced_debug;
|
||||||
|
debug_save_failed = persist_mount && !save_advanced_debug(default_config_path(), config.advanced_debug);
|
||||||
|
surface.set_advanced_debug(config.advanced_debug);
|
||||||
|
reset_input(result);
|
||||||
|
// Runtime observes the change before handling this batch,
|
||||||
|
// restarts its worker and invalidates pending work/actions.
|
||||||
|
return result;
|
||||||
|
}
|
||||||
if (event_result.open_bindings) {
|
if (event_result.open_bindings) {
|
||||||
// The editor changes input ownership. Invalidate held gestures
|
// The editor changes input ownership. Invalidate held gestures
|
||||||
// and pointer presses; never turn the returning release into input.
|
// and pointer presses; never turn the returning release into input.
|
||||||
@@ -456,6 +467,7 @@ Overlay::Overlay(const std::string& assets, const std::string& font, std::option
|
|||||||
Overlay::~Overlay() = default;
|
Overlay::~Overlay() = default;
|
||||||
std::vector<UiAction> Overlay::poll() { return impl_->poll(); }
|
std::vector<UiAction> Overlay::poll() { return impl_->poll(); }
|
||||||
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
|
void Overlay::draw(const Panel& panel) { impl_->draw(panel); }
|
||||||
|
bool Overlay::advanced_debug() const { return impl_->config.advanced_debug; }
|
||||||
std::string Overlay::controls_status() {
|
std::string Overlay::controls_status() {
|
||||||
// Compare the same actions across modes, before and after our pose/role gate.
|
// Compare the same actions across modes, before and after our pose/role gate.
|
||||||
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
|
// IsInputAvailable and a successful UpdateActionState are not delivery proof.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public:
|
|||||||
Overlay& operator=(const Overlay&) = delete;
|
Overlay& operator=(const Overlay&) = delete;
|
||||||
std::vector<UiAction> poll();
|
std::vector<UiAction> poll();
|
||||||
void draw(const Panel& panel);
|
void draw(const Panel& panel);
|
||||||
|
bool advanced_debug() const;
|
||||||
std::string controls_status(); // diagnostic only, no input delivery
|
std::string controls_status(); // diagnostic only, no input delivery
|
||||||
std::string pointer_status() const; // diagnostic counters, no input delivery
|
std::string pointer_status() const; // diagnostic counters, no input delivery
|
||||||
private:
|
private:
|
||||||
|
|||||||
+24
-12
@@ -26,10 +26,10 @@ struct Rect {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
enum class Control { Review, Settings, Bindings, OpenBindings, Prev, Next, Record, Cancel, Insert, Enter, Quit,
|
enum class Control { Review, Settings, Bindings, OpenBindings, Prev, Next, Record, Cancel, Insert, Enter, Quit,
|
||||||
World, Left, Right, Head, Recenter, LasersAnytime };
|
World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug };
|
||||||
enum class Tab { Review, Settings, Bindings };
|
enum class Tab { Review, Settings, Bindings };
|
||||||
struct Button { Rect r; Control id; const char* label; };
|
struct Button { Rect r; Control id; const char* label; };
|
||||||
constexpr std::array<Button, 17> buttons{{
|
constexpr std::array<Button, 18> buttons{{
|
||||||
{{32, 138, 180, 46}, Control::Review, "Review"},
|
{{32, 138, 180, 46}, Control::Review, "Review"},
|
||||||
{{226, 138, 180, 46}, Control::Settings, "Settings"},
|
{{226, 138, 180, 46}, Control::Settings, "Settings"},
|
||||||
{{420, 138, 180, 46}, Control::Bindings, "Bindings"},
|
{{420, 138, 180, 46}, Control::Bindings, "Bindings"},
|
||||||
@@ -47,6 +47,7 @@ constexpr std::array<Button, 17> buttons{{
|
|||||||
{{514, 308, 454, 58}, Control::Right, "Right wrist"},
|
{{514, 308, 454, 58}, Control::Right, "Right wrist"},
|
||||||
{{32, 394, 300, 50}, Control::Recenter, "Recenter in front"},
|
{{32, 394, 300, 50}, Control::Recenter, "Recenter in front"},
|
||||||
{{514, 394, 454, 50}, Control::LasersAnytime, "Lasers anytime"},
|
{{514, 394, 454, 50}, Control::LasersAnytime, "Lasers anytime"},
|
||||||
|
{{32, 452, 936, 48}, Control::AdvancedDebug, "Advanced debugging (full logs)"},
|
||||||
}};
|
}};
|
||||||
std::optional<UiAction> action(Control c) {
|
std::optional<UiAction> action(Control c) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
@@ -105,7 +106,7 @@ struct PanelSurface::Impl {
|
|||||||
Theme theme;
|
Theme theme;
|
||||||
Color background, card, ink, muted, cyan, pink;
|
Color background, card, ink, muted, cyan, pink;
|
||||||
Tab tab = Tab::Review;
|
Tab tab = Tab::Review;
|
||||||
bool dirty = true, lasers_anytime = false;
|
bool dirty = true, lasers_anytime = false, advanced_debug = false;
|
||||||
std::string placement_note, binding_note;
|
std::string placement_note, binding_note;
|
||||||
std::array<std::string, 6> bindings{};
|
std::array<std::string, 6> bindings{};
|
||||||
std::array<int, 2> pressed{{-1, -1}};
|
std::array<int, 2> pressed{{-1, -1}};
|
||||||
@@ -244,7 +245,8 @@ struct PanelSurface::Impl {
|
|||||||
}
|
}
|
||||||
bool visible(Control c) const {
|
bool visible(Control c) const {
|
||||||
if (c == Control::Prev || c == Control::Next) return tab == Tab::Review;
|
if (c == Control::Prev || c == Control::Next) return tab == Tab::Review;
|
||||||
if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime) return tab == Tab::Settings;
|
if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime ||
|
||||||
|
c == Control::AdvancedDebug) return tab == Tab::Settings;
|
||||||
if (c == Control::OpenBindings) return tab == Tab::Bindings;
|
if (c == Control::OpenBindings) return tab == Tab::Bindings;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -316,10 +318,9 @@ struct PanelSurface::Impl {
|
|||||||
32, 539, 21, muted, 968);
|
32, 539, 21, muted, 968);
|
||||||
} else {
|
} else {
|
||||||
text("MOUNT AND INTERACTION", 32, 224, 22, muted, 968);
|
text("MOUNT AND INTERACTION", 32, 224, 22, muted, 968);
|
||||||
text("Lasers anytime enables system-wide laser mode while this panel is visible.", 32, 472, 20, muted, 968);
|
text("Full logs may contain speech/text/paths. No saved audio clips.", 32, 520, 20, pink, 968);
|
||||||
text(placement_note.empty() ? "May affect games. Default off; changes saved on this device." : placement_note,
|
text(placement_note.empty() ? "Toggle restarts worker; cancels current work. Lasers may affect games." : placement_note,
|
||||||
32, 509, 22, muted, 968);
|
32, 540, 20, muted, 968);
|
||||||
text("If off, open the dashboard to click this setting again.", 32, 538, 20, muted, 968);
|
|
||||||
}
|
}
|
||||||
rect({32, 550, 936, 1}, mix(card, cyan, .17f));
|
rect({32, 550, 936, 1}, mix(card, cyan, .17f));
|
||||||
for (size_t i = 0; i < buttons.size(); ++i) {
|
for (size_t i = 0; i < buttons.size(); ++i) {
|
||||||
@@ -330,7 +331,8 @@ struct PanelSurface::Impl {
|
|||||||
(b.id == Control::Settings && tab == Tab::Settings) ||
|
(b.id == Control::Settings && tab == Tab::Settings) ||
|
||||||
(b.id == Control::Bindings && tab == Tab::Bindings) ||
|
(b.id == Control::Bindings && tab == Tab::Bindings) ||
|
||||||
(mounting(b.id) && *mounting(b.id) == mount) ||
|
(mounting(b.id) && *mounting(b.id) == mount) ||
|
||||||
(b.id == Control::LasersAnytime && lasers_anytime);
|
(b.id == Control::LasersAnytime && lasers_anytime) ||
|
||||||
|
(b.id == Control::AdvancedDebug && advanced_debug);
|
||||||
const Color fill = !on ? mix(background, card, .40f) :
|
const Color fill = !on ? mix(background, card, .40f) :
|
||||||
selected ? mix(card, cyan, .14f) : card;
|
selected ? mix(card, cyan, .14f) : card;
|
||||||
const Color accent = b.id == Control::Record && panel.recording ? pink : cyan;
|
const Color accent = b.id == Control::Record && panel.recording ? pink : cyan;
|
||||||
@@ -342,9 +344,11 @@ struct PanelSurface::Impl {
|
|||||||
const auto label = b.id == Control::Record && panel.recording ? "Stop" : b.label;
|
const auto label = b.id == Control::Record && panel.recording ? "Stop" : b.label;
|
||||||
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, 27, on ? ink : mix(background, muted, .48f), b.r.x + b.r.w - 8);
|
text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, 27, on ? ink : mix(background, muted, .48f), b.r.x + b.r.w - 8);
|
||||||
if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12);
|
if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12);
|
||||||
if (b.id == Control::LasersAnytime)
|
if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug) {
|
||||||
text(lasers_anytime ? "ON" : "OFF", b.r.x + b.r.w - 66, b.r.y + 34, 22,
|
bool active = b.id == Control::LasersAnytime ? lasers_anytime : advanced_debug;
|
||||||
lasers_anytime ? cyan : muted, b.r.x + b.r.w - 12);
|
text(active ? "ON" : "OFF", b.r.x + b.r.w - 66, b.r.y + b.r.h / 2 + 9, 22,
|
||||||
|
active ? cyan : muted, b.r.x + b.r.w - 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dirty = false;
|
dirty = false;
|
||||||
return true;
|
return true;
|
||||||
@@ -375,6 +379,7 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y) {
|
|||||||
else if (auto m = mounting(c)) { impl_->mount = *m; result.mount = *m; impl_->reset(); impl_->dirty = true; }
|
else if (auto m = mounting(c)) { impl_->mount = *m; result.mount = *m; impl_->reset(); impl_->dirty = true; }
|
||||||
else if (c == Control::Recenter) { result.recenter = true; impl_->reset(); }
|
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::LasersAnytime) result.lasers_anytime = !impl_->lasers_anytime;
|
||||||
|
else if (c == Control::AdvancedDebug) result.advanced_debug = !impl_->advanced_debug;
|
||||||
else if (c == Control::OpenBindings) { result.open_bindings = true; impl_->reset(); }
|
else if (c == Control::OpenBindings) { result.open_bindings = true; impl_->reset(); }
|
||||||
else if (c == Control::Review || c == Control::Settings || c == Control::Bindings) {
|
else if (c == Control::Review || c == Control::Settings || c == Control::Bindings) {
|
||||||
impl_->tab = c == Control::Review ? Tab::Review : c == Control::Settings ? Tab::Settings : Tab::Bindings;
|
impl_->tab = c == Control::Review ? Tab::Review : c == Control::Settings ? Tab::Settings : Tab::Bindings;
|
||||||
@@ -402,4 +407,11 @@ void PanelSurface::set_lasers_anytime(bool enabled) {
|
|||||||
impl_->dirty = true;
|
impl_->dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void PanelSurface::set_advanced_debug(bool enabled) {
|
||||||
|
if (impl_->advanced_debug != enabled) {
|
||||||
|
impl_->advanced_debug = enabled;
|
||||||
|
impl_->reset();
|
||||||
|
impl_->dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} // namespace frameyap
|
} // namespace frameyap
|
||||||
@@ -12,6 +12,7 @@ struct SurfaceEvent {
|
|||||||
std::optional<UiAction> action;
|
std::optional<UiAction> action;
|
||||||
std::optional<Mount> mount;
|
std::optional<Mount> mount;
|
||||||
std::optional<bool> lasers_anytime;
|
std::optional<bool> lasers_anytime;
|
||||||
|
std::optional<bool> advanced_debug;
|
||||||
bool recenter = false;
|
bool recenter = false;
|
||||||
bool open_bindings = false;
|
bool open_bindings = false;
|
||||||
};
|
};
|
||||||
@@ -32,6 +33,7 @@ public:
|
|||||||
void reset_pointers();
|
void reset_pointers();
|
||||||
void set_placement_note(std::string note);
|
void set_placement_note(std::string note);
|
||||||
void set_lasers_anytime(bool enabled);
|
void set_lasers_anytime(bool enabled);
|
||||||
|
void set_advanced_debug(bool enabled);
|
||||||
// PTT, Cancel, Insert, Enter, left-grip gesture, right-grip gesture.
|
// PTT, Cancel, Insert, Enter, left-grip gesture, right-grip gesture.
|
||||||
void set_bindings(std::array<std::string, 6> labels);
|
void set_bindings(std::array<std::string, 6> labels);
|
||||||
void set_binding_note(std::string note);
|
void set_binding_note(std::string note);
|
||||||
|
|||||||
+16
-2
@@ -11,6 +11,7 @@
|
|||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
|
#include <iostream>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <sys/file.h>
|
#include <sys/file.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
@@ -54,6 +55,7 @@ int run(const Options& options) {
|
|||||||
Session session;
|
Session session;
|
||||||
std::string detail = "Review mode. Other apps may also hear your mic. Enter is explicit.";
|
std::string detail = "Review mode. Other apps may also hear your mic. Enter is explicit.";
|
||||||
bool quit = false;
|
bool quit = false;
|
||||||
|
bool advanced_debug = overlay.advanced_debug();
|
||||||
const DeliveryFactory acquire = [&]() -> std::unique_ptr<DeliveryLease> {
|
const DeliveryFactory acquire = [&]() -> std::unique_ptr<DeliveryLease> {
|
||||||
auto input = std::make_unique<NativeDeliveryLease>(options.socket);
|
auto input = std::make_unique<NativeDeliveryLease>(options.socket);
|
||||||
if (interrupted) throw std::runtime_error("Input cancelled before delivery");
|
if (interrupted) throw std::runtime_error("Input cancelled before delivery");
|
||||||
@@ -77,7 +79,7 @@ int run(const Options& options) {
|
|||||||
};
|
};
|
||||||
auto warm = [&] {
|
auto warm = [&] {
|
||||||
audio.close(); worker.stop(); session = Session{};
|
audio.close(); worker.stop(); session = Session{};
|
||||||
worker.start(options.python, options.worker, options.model, options.threads);
|
worker.start(options.python, options.worker, options.model, options.threads, advanced_debug);
|
||||||
detail = "Loading local model; microphone closed. Record again when Ready.";
|
detail = "Loading local model; microphone closed. Record again when Ready.";
|
||||||
};
|
};
|
||||||
auto stop_record = [&] {
|
auto stop_record = [&] {
|
||||||
@@ -108,6 +110,9 @@ int run(const Options& options) {
|
|||||||
// E is a request-local error. The child still owns its
|
// E is a request-local error. The child still owns its
|
||||||
// loaded model and can accept the next utterance.
|
// loaded model and can accept the next utterance.
|
||||||
session.fail(); detail = reply->error + "; model ready. Record to retry.";
|
session.fail(); detail = reply->error + "; model ready. Record to retry.";
|
||||||
|
// Worker::poll allows only fixed diagnostic labels here,
|
||||||
|
// never exception messages, audio, paths or recognized text.
|
||||||
|
std::cerr << "FrameYap worker: " << reply->error << '\n';
|
||||||
} else {
|
} else {
|
||||||
session.reply(reply->id, reply->text);
|
session.reply(reply->id, reply->text);
|
||||||
detail = session.text().empty() ? "No speech recognized; try again." : "Focus your destination, then Insert. Cancel discards.";
|
detail = session.text().empty() ? "No speech recognized; try again." : "Focus your destination, then Insert. Cancel discards.";
|
||||||
@@ -139,7 +144,16 @@ int run(const Options& options) {
|
|||||||
session.state() != State::Warming && session.state() != State::Transcribing && session.state() != State::Review};
|
session.state() != State::Warming && session.state() != State::Transcribing && session.state() != State::Review};
|
||||||
if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s";
|
if (panel.recording) panel.status += " - " + std::to_string(audio.seconds()) + " / 20s";
|
||||||
overlay.draw(panel);
|
overlay.draw(panel);
|
||||||
for (auto action : overlay.poll()) {
|
auto actions = overlay.poll();
|
||||||
|
if (advanced_debug != overlay.advanced_debug()) {
|
||||||
|
advanced_debug = overlay.advanced_debug();
|
||||||
|
// Consent changes take effect before any more work or delivery. The
|
||||||
|
// Settings warning makes the worker restart/cancellation explicit.
|
||||||
|
try { warm(); }
|
||||||
|
catch (const std::exception& e) { session.fail(); detail = e.what(); }
|
||||||
|
std::erase_if(actions, [](UiAction action) { return action != UiAction::Quit; });
|
||||||
|
}
|
||||||
|
for (auto action : actions) {
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case UiAction::Quit: quit = true; break;
|
case UiAction::Quit: quit = true; break;
|
||||||
|
|||||||
+162
-13
@@ -1,5 +1,6 @@
|
|||||||
#include "worker.hpp"
|
#include "worker.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
@@ -26,6 +27,8 @@ namespace {
|
|||||||
using Clock = std::chrono::steady_clock;
|
using Clock = std::chrono::steady_clock;
|
||||||
constexpr size_t max_frame = 65536;
|
constexpr size_t max_frame = 65536;
|
||||||
constexpr size_t max_text = 4096;
|
constexpr size_t max_text = 4096;
|
||||||
|
constexpr size_t max_debug_log = 4 * 1024 * 1024;
|
||||||
|
constexpr std::string_view truncation = "\n[worker diagnostics truncated; further output discarded]\n";
|
||||||
|
|
||||||
void close_fd(int& fd) { if (fd >= 0) { ::close(fd); fd = -1; } }
|
void close_fd(int& fd) { if (fd >= 0) { ::close(fd); fd = -1; } }
|
||||||
void put32(unsigned char* p, uint32_t n) {
|
void put32(unsigned char* p, uint32_t n) {
|
||||||
@@ -62,6 +65,102 @@ bool valid_utf8(std::string_view text) {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
std::string safe_request_error(std::string_view error) {
|
||||||
|
// Treat E payloads as untrusted too: older/alternate workers might include
|
||||||
|
// raw exception messages. Only fixed diagnostic labels reach UI or logs.
|
||||||
|
for (const auto* stage : {"audio", "inference", "response", "unknown"}) {
|
||||||
|
for (const auto* category : {"MemoryError", "ImportError", "OSError", "UnicodeError", "TypeError",
|
||||||
|
"ValueError", "KeyError", "IndexError", "RuntimeError", "Exception"}) {
|
||||||
|
auto allowed = std::string("transcription failed [") + stage + ": " + category + "]";
|
||||||
|
if (error == allowed) return allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "transcription failed"; // compatible legacy/unknown error, never raw text
|
||||||
|
}
|
||||||
|
// Walk from / using directory fds: no symlinks, even in intermediate components.
|
||||||
|
// Root-owned system ancestors (including sticky /tmp) are permitted, but a
|
||||||
|
// writable non-sticky ancestor could redirect private diagnostics elsewhere.
|
||||||
|
int state_directory() {
|
||||||
|
const char* base = ::getenv("XDG_STATE_HOME");
|
||||||
|
std::string path;
|
||||||
|
if (base && *base) path = base;
|
||||||
|
else {
|
||||||
|
const char* home = ::getenv("HOME");
|
||||||
|
if (!home || !*home) throw std::runtime_error("advanced debug needs HOME or XDG_STATE_HOME");
|
||||||
|
path = std::string(home) + "/.local/state";
|
||||||
|
}
|
||||||
|
if (path.empty() || path[0] != '/')
|
||||||
|
throw std::runtime_error("advanced debug state path must be absolute");
|
||||||
|
while (path.size() > 1 && path.back() == '/') path.pop_back();
|
||||||
|
path += path == "/" ? "frameyap" : "/frameyap";
|
||||||
|
int dir = ::open("/", O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
|
if (dir < 0) throw std::runtime_error("cannot open debug state root");
|
||||||
|
try {
|
||||||
|
size_t pos = 1;
|
||||||
|
while (pos < path.size()) {
|
||||||
|
auto end = path.find('/', pos);
|
||||||
|
if (end == std::string::npos) end = path.size();
|
||||||
|
auto part = path.substr(pos, end - pos);
|
||||||
|
if (part.empty() || part == "." || part == "..")
|
||||||
|
throw std::runtime_error("unsafe debug state path");
|
||||||
|
if (::mkdirat(dir, part.c_str(), 0700) && errno != EEXIST)
|
||||||
|
throw std::runtime_error("cannot create private debug state directory");
|
||||||
|
int next = ::openat(dir, part.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
|
if (next < 0) throw std::runtime_error("unsafe debug state directory");
|
||||||
|
struct stat st{};
|
||||||
|
bool final = end == path.size();
|
||||||
|
if (::fstat(next, &st) || !S_ISDIR(st.st_mode) ||
|
||||||
|
(final && (st.st_uid != ::geteuid() || (st.st_mode & 07777) != 0700)) ||
|
||||||
|
(!final && ((st.st_uid != ::geteuid() && st.st_uid != 0) ||
|
||||||
|
((st.st_mode & 0022) && !(st.st_uid == 0 && (st.st_mode & S_ISVTX)))))) {
|
||||||
|
::close(next);
|
||||||
|
throw std::runtime_error("unsafe debug state directory permissions");
|
||||||
|
}
|
||||||
|
::close(dir); dir = next;
|
||||||
|
pos = end + 1;
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
} catch (...) { ::close(dir); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open existing files without following links; never rotate someone else's file,
|
||||||
|
// a hardlink or a permissive target. Both names are checked before any mutation.
|
||||||
|
void check_log_target(int dir, const char* name) {
|
||||||
|
int fd = ::openat(dir, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||||
|
if (fd < 0) {
|
||||||
|
if (errno == ENOENT) return;
|
||||||
|
throw std::runtime_error("unsafe existing worker debug log");
|
||||||
|
}
|
||||||
|
struct stat st{};
|
||||||
|
bool valid = ::fstat(fd, &st) == 0 && S_ISREG(st.st_mode) &&
|
||||||
|
st.st_uid == ::geteuid() && (st.st_mode & 07777) == 0600 &&
|
||||||
|
st.st_nlink == 1 && st.st_size <= static_cast<off_t>(max_debug_log);
|
||||||
|
::close(fd);
|
||||||
|
if (!valid) throw std::runtime_error("unsafe existing worker debug log");
|
||||||
|
}
|
||||||
|
int create_debug_log() {
|
||||||
|
int dir = state_directory();
|
||||||
|
try {
|
||||||
|
check_log_target(dir, "worker-debug.log");
|
||||||
|
check_log_target(dir, "worker-debug.previous.log");
|
||||||
|
if (::unlinkat(dir, "worker-debug.previous.log", 0) && errno != ENOENT)
|
||||||
|
throw std::runtime_error("cannot rotate worker debug log");
|
||||||
|
if (::renameat(dir, "worker-debug.log", dir, "worker-debug.previous.log") && errno != ENOENT)
|
||||||
|
throw std::runtime_error("cannot rotate worker debug log");
|
||||||
|
int fd = ::openat(dir, "worker-debug.log", O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
|
||||||
|
if (fd < 0) throw std::runtime_error("cannot create private worker debug log");
|
||||||
|
::close(dir);
|
||||||
|
return fd;
|
||||||
|
} catch (...) { ::close(dir); throw; }
|
||||||
|
}
|
||||||
|
void log_bytes(int fd, const unsigned char* data, size_t size) {
|
||||||
|
while (size) {
|
||||||
|
ssize_t n = ::write(fd, data, size);
|
||||||
|
if (n < 0 && errno == EINTR) continue;
|
||||||
|
if (n <= 0) throw std::runtime_error("worker debug log write failed");
|
||||||
|
data += n; size -= static_cast<size_t>(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
void check_runtime(const char* path) {
|
void check_runtime(const char* path) {
|
||||||
if (!path || path[0] != '/') throw std::runtime_error("XDG_RUNTIME_DIR must be an absolute private directory");
|
if (!path || path[0] != '/') throw std::runtime_error("XDG_RUNTIME_DIR must be an absolute private directory");
|
||||||
struct stat st{};
|
struct stat st{};
|
||||||
@@ -97,13 +196,45 @@ void write_all(int fd, const unsigned char* data, size_t size) {
|
|||||||
|
|
||||||
struct Worker::State {
|
struct Worker::State {
|
||||||
pid_t pid = -1;
|
pid_t pid = -1;
|
||||||
int to_child = -1, from_child = -1;
|
int to_child = -1, from_child = -1, debug_pipe = -1, debug_file = -1;
|
||||||
|
size_t debug_written = 0;
|
||||||
|
bool debug_truncated = false;
|
||||||
std::string dir;
|
std::string dir;
|
||||||
bool loaded = false;
|
bool loaded = false;
|
||||||
std::optional<uint64_t> pending;
|
std::optional<uint64_t> pending;
|
||||||
Clock::time_point deadline{};
|
Clock::time_point deadline{};
|
||||||
std::vector<unsigned char> input;
|
std::vector<unsigned char> input;
|
||||||
std::chrono::milliseconds warmup_timeout{120000}, request_timeout{60000};
|
std::chrono::milliseconds warmup_timeout{120000}, request_timeout{60000};
|
||||||
|
|
||||||
|
void drain_debug() {
|
||||||
|
if (debug_pipe < 0) return;
|
||||||
|
// Limit work per poll: even an endlessly noisy child cannot trap the UI.
|
||||||
|
size_t budget = 256 * 1024;
|
||||||
|
unsigned char block[4096];
|
||||||
|
while (budget) {
|
||||||
|
ssize_t n = ::read(debug_pipe, block, std::min(budget, sizeof block));
|
||||||
|
if (n > 0) {
|
||||||
|
budget -= static_cast<size_t>(n);
|
||||||
|
if (!debug_truncated) {
|
||||||
|
size_t left = max_debug_log - truncation.size() - debug_written;
|
||||||
|
size_t count = std::min(left, static_cast<size_t>(n));
|
||||||
|
log_bytes(debug_file, block, count);
|
||||||
|
debug_written += count;
|
||||||
|
if (count < static_cast<size_t>(n)) {
|
||||||
|
log_bytes(debug_file, reinterpret_cast<const unsigned char*>(truncation.data()), truncation.size());
|
||||||
|
debug_written += truncation.size();
|
||||||
|
debug_truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n == 0) { close_fd(debug_pipe); return; }
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (errno != EAGAIN && errno != EWOULDBLOCK)
|
||||||
|
throw std::runtime_error("worker debug pipe read failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Worker::Worker(std::chrono::milliseconds warmup, std::chrono::milliseconds request)
|
Worker::Worker(std::chrono::milliseconds warmup, std::chrono::milliseconds request)
|
||||||
@@ -122,14 +253,20 @@ void Worker::stop() {
|
|||||||
::kill(s.pid, SIGTERM); // Only our direct child, never a process group.
|
::kill(s.pid, SIGTERM); // Only our direct child, never a process group.
|
||||||
auto until = Clock::now() + std::chrono::milliseconds(500);
|
auto until = Clock::now() + std::chrono::milliseconds(500);
|
||||||
int status = 0;
|
int status = 0;
|
||||||
while (::waitpid(s.pid, &status, WNOHANG) == 0 && Clock::now() < until)
|
while (::waitpid(s.pid, &status, WNOHANG) == 0 && Clock::now() < until) {
|
||||||
|
try { s.drain_debug(); } catch (...) {} // stop/destructor are best effort
|
||||||
::usleep(10000);
|
::usleep(10000);
|
||||||
|
}
|
||||||
if (::waitpid(s.pid, &status, WNOHANG) == 0) {
|
if (::waitpid(s.pid, &status, WNOHANG) == 0) {
|
||||||
::kill(s.pid, SIGKILL);
|
::kill(s.pid, SIGKILL);
|
||||||
while (::waitpid(s.pid, &status, 0) < 0 && errno == EINTR) {}
|
while (::waitpid(s.pid, &status, 0) < 0 && errno == EINTR) {}
|
||||||
}
|
}
|
||||||
s.pid = -1;
|
s.pid = -1;
|
||||||
}
|
}
|
||||||
|
try { s.drain_debug(); } catch (...) {}
|
||||||
|
close_fd(s.debug_pipe);
|
||||||
|
close_fd(s.debug_file);
|
||||||
|
s.debug_written = 0; s.debug_truncated = false;
|
||||||
if (!s.dir.empty()) {
|
if (!s.dir.empty()) {
|
||||||
::unlink((s.dir + "/clip.raw").c_str());
|
::unlink((s.dir + "/clip.raw").c_str());
|
||||||
::rmdir(s.dir.c_str());
|
::rmdir(s.dir.c_str());
|
||||||
@@ -139,7 +276,7 @@ void Worker::stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Worker::start(const std::string& python, const std::string& script,
|
void Worker::start(const std::string& python, const std::string& script,
|
||||||
const std::string& model, int threads) {
|
const std::string& model, int threads, bool advanced_debug) {
|
||||||
if (state_->pid > 0) throw std::logic_error("worker already started");
|
if (state_->pid > 0) throw std::logic_error("worker already started");
|
||||||
if (python.empty() || script.empty() || model.empty() || threads < 1 || threads > 64)
|
if (python.empty() || script.empty() || model.empty() || threads < 1 || threads > 64)
|
||||||
throw std::invalid_argument("python, script, local model and 1..64 threads required");
|
throw std::invalid_argument("python, script, local model and 1..64 threads required");
|
||||||
@@ -151,9 +288,12 @@ void Worker::start(const std::string& python, const std::string& script,
|
|||||||
if (!::mkdtemp(tmp.data())) throw std::runtime_error("cannot create private clip directory");
|
if (!::mkdtemp(tmp.data())) throw std::runtime_error("cannot create private clip directory");
|
||||||
state_->dir = tmp.data();
|
state_->dir = tmp.data();
|
||||||
::chmod(state_->dir.c_str(), 0700);
|
::chmod(state_->dir.c_str(), 0700);
|
||||||
int in[2]{-1,-1}, out[2]{-1,-1};
|
if (advanced_debug) state_->debug_file = create_debug_log();
|
||||||
if (::pipe2(in, O_CLOEXEC) || ::pipe2(out, O_CLOEXEC)) {
|
int in[2]{-1,-1}, out[2]{-1,-1}, debug[2]{-1,-1};
|
||||||
|
if (::pipe2(in, O_CLOEXEC) || ::pipe2(out, O_CLOEXEC) ||
|
||||||
|
(advanced_debug && ::pipe2(debug, O_CLOEXEC))) {
|
||||||
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
||||||
|
close_fd(debug[0]); close_fd(debug[1]);
|
||||||
throw std::runtime_error("cannot create worker pipes");
|
throw std::runtime_error("cannot create worker pipes");
|
||||||
}
|
}
|
||||||
std::string thread_arg = std::to_string(threads);
|
std::string thread_arg = std::to_string(threads);
|
||||||
@@ -170,17 +310,20 @@ void Worker::start(const std::string& python, const std::string& script,
|
|||||||
for (auto& value : environment) envp.push_back(value.data());
|
for (auto& value : environment) envp.push_back(value.data());
|
||||||
envp.push_back(nullptr);
|
envp.push_back(nullptr);
|
||||||
const char* args[] = {python.c_str(), script.c_str(), "--model", model.c_str(),
|
const char* args[] = {python.c_str(), script.c_str(), "--model", model.c_str(),
|
||||||
"--threads", thread_arg.c_str(), "--clip-dir", state_->dir.c_str(), nullptr};
|
"--threads", thread_arg.c_str(), "--clip-dir", state_->dir.c_str(),
|
||||||
|
advanced_debug ? "--advanced-debug" : nullptr, nullptr};
|
||||||
posix_spawn_file_actions_t actions;
|
posix_spawn_file_actions_t actions;
|
||||||
int error = posix_spawn_file_actions_init(&actions);
|
int error = posix_spawn_file_actions_init(&actions);
|
||||||
if (error) {
|
if (error) {
|
||||||
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
||||||
|
close_fd(debug[0]); close_fd(debug[1]);
|
||||||
throw std::runtime_error("cannot initialize worker spawn");
|
throw std::runtime_error("cannot initialize worker spawn");
|
||||||
}
|
}
|
||||||
error = posix_spawn_file_actions_adddup2(&actions, in[0], STDIN_FILENO);
|
error = posix_spawn_file_actions_adddup2(&actions, in[0], STDIN_FILENO);
|
||||||
if (!error) error = posix_spawn_file_actions_adddup2(&actions, out[1], STDOUT_FILENO);
|
if (!error) error = posix_spawn_file_actions_adddup2(&actions, out[1], STDOUT_FILENO);
|
||||||
if (!error) error = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
|
if (!error && advanced_debug) error = posix_spawn_file_actions_adddup2(&actions, debug[1], STDERR_FILENO);
|
||||||
for (int fd : std::array<int, 4>{in[0], in[1], out[0], out[1]})
|
if (!error && !advanced_debug) error = posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
|
||||||
|
for (int fd : std::array<int, 6>{in[0], in[1], out[0], out[1], debug[0], debug[1]})
|
||||||
if (!error && fd > STDERR_FILENO) error = posix_spawn_file_actions_addclose(&actions, fd);
|
if (!error && fd > STDERR_FILENO) error = posix_spawn_file_actions_addclose(&actions, fd);
|
||||||
pid_t pid = -1;
|
pid_t pid = -1;
|
||||||
if (!error) error = posix_spawnp(&pid, python.c_str(), &actions, nullptr,
|
if (!error) error = posix_spawnp(&pid, python.c_str(), &actions, nullptr,
|
||||||
@@ -188,13 +331,18 @@ void Worker::start(const std::string& python, const std::string& script,
|
|||||||
posix_spawn_file_actions_destroy(&actions);
|
posix_spawn_file_actions_destroy(&actions);
|
||||||
if (error) {
|
if (error) {
|
||||||
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
close_fd(in[0]); close_fd(in[1]); close_fd(out[0]); close_fd(out[1]);
|
||||||
|
close_fd(debug[0]); close_fd(debug[1]);
|
||||||
throw std::runtime_error("cannot launch configured Python worker");
|
throw std::runtime_error("cannot launch configured Python worker");
|
||||||
}
|
}
|
||||||
close_fd(in[0]); close_fd(out[1]);
|
close_fd(in[0]); close_fd(out[1]); close_fd(debug[1]);
|
||||||
state_->pid = pid; state_->to_child = in[1]; state_->from_child = out[0];
|
state_->pid = pid; state_->to_child = in[1]; state_->from_child = out[0];
|
||||||
int flags = ::fcntl(state_->from_child, F_GETFL);
|
state_->debug_pipe = debug[0];
|
||||||
if (flags < 0 || ::fcntl(state_->from_child, F_SETFL, flags | O_NONBLOCK))
|
for (int fd : {state_->from_child, state_->debug_pipe}) {
|
||||||
throw std::runtime_error("cannot set nonblocking worker pipe");
|
if (fd < 0) continue;
|
||||||
|
int flags = ::fcntl(fd, F_GETFL);
|
||||||
|
if (flags < 0 || ::fcntl(fd, F_SETFL, flags | O_NONBLOCK))
|
||||||
|
throw std::runtime_error("cannot set nonblocking worker pipe");
|
||||||
|
}
|
||||||
state_->deadline = Clock::now() + state_->warmup_timeout;
|
state_->deadline = Clock::now() + state_->warmup_timeout;
|
||||||
} catch (...) { stop(); throw; }
|
} catch (...) { stop(); throw; }
|
||||||
}
|
}
|
||||||
@@ -240,6 +388,7 @@ std::optional<WorkerReply> Worker::poll() {
|
|||||||
auto& s = *state_;
|
auto& s = *state_;
|
||||||
if (s.pid <= 0) return std::nullopt;
|
if (s.pid <= 0) return std::nullopt;
|
||||||
try {
|
try {
|
||||||
|
s.drain_debug();
|
||||||
if ((!s.loaded || s.pending) && Clock::now() > s.deadline)
|
if ((!s.loaded || s.pending) && Clock::now() > s.deadline)
|
||||||
throw std::runtime_error(s.loaded ? "worker transcription timed out" : "worker warmup timed out");
|
throw std::runtime_error(s.loaded ? "worker transcription timed out" : "worker warmup timed out");
|
||||||
unsigned char block[4096];
|
unsigned char block[4096];
|
||||||
@@ -280,7 +429,7 @@ std::optional<WorkerReply> Worker::poll() {
|
|||||||
std::string text(reinterpret_cast<const char*>(s.input.data() + 13), size - 9);
|
std::string text(reinterpret_cast<const char*>(s.input.data() + 13), size - 9);
|
||||||
if (!valid_utf8(text)) throw std::runtime_error("invalid worker UTF-8 reply");
|
if (!valid_utf8(text)) throw std::runtime_error("invalid worker UTF-8 reply");
|
||||||
if (type == 'R') reply.text = std::move(text);
|
if (type == 'R') reply.text = std::move(text);
|
||||||
else reply.error = std::move(text);
|
else reply.error = safe_request_error(text);
|
||||||
s.pending.reset(); s.input.clear();
|
s.pending.reset(); s.input.clear();
|
||||||
::unlink((s.dir + "/clip.raw").c_str());
|
::unlink((s.dir + "/clip.raw").c_str());
|
||||||
return reply;
|
return reply;
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ public:
|
|||||||
Worker(const Worker&) = delete;
|
Worker(const Worker&) = delete;
|
||||||
Worker& operator=(const Worker&) = delete;
|
Worker& operator=(const Worker&) = delete;
|
||||||
void start(const std::string& python, const std::string& script,
|
void start(const std::string& python, const std::string& script,
|
||||||
const std::string& model, int threads = 2);
|
const std::string& model, int threads = 2, bool advanced_debug = false);
|
||||||
bool ready() const;
|
bool ready() const;
|
||||||
void submit(uint64_t id, const std::vector<float>& pcm);
|
void submit(uint64_t id, const std::vector<float>& pcm);
|
||||||
std::optional<WorkerReply> poll();
|
std::optional<WorkerReply> poll();
|
||||||
|
|||||||
+41
-2
@@ -28,16 +28,55 @@ int main(int argc, char** argv) {
|
|||||||
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
|
::setenv("XDG_CONFIG_HOME", dir.c_str(), 1);
|
||||||
assert(load_config(path).buttons.empty());
|
assert(load_config(path).buttons.empty());
|
||||||
assert(!load_config(path).experimental_input_priority);
|
assert(!load_config(path).experimental_input_priority);
|
||||||
|
assert(!load_config(path).advanced_debug);
|
||||||
assert(load_config(path).wrist.width == .30f);
|
assert(load_config(path).wrist.width == .30f);
|
||||||
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
|
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
|
||||||
assert(example.buttons.at("ptt") == "/user/hand/right/input/x");
|
assert(example.buttons.at("ptt") == "/user/hand/right/input/x");
|
||||||
assert(example.font.empty());
|
assert(example.font.empty());
|
||||||
assert(!example.experimental_input_priority);
|
assert(!example.experimental_input_priority);
|
||||||
|
assert(!example.advanced_debug);
|
||||||
assert(example.wrist.y == .18f && example.wrist.z == .089f);
|
assert(example.wrist.y == .18f && example.wrist.z == .089f);
|
||||||
std::filesystem::create_directories(path.parent_path());
|
std::filesystem::create_directories(path.parent_path());
|
||||||
put(path, R"({"input_priority":"experimental"})");
|
assert(save_advanced_debug(path, true));
|
||||||
|
assert(load_config(path).advanced_debug);
|
||||||
|
assert(get(path).find("\"advanced_debug\":true") != std::string::npos);
|
||||||
|
assert(save_advanced_debug(path, false));
|
||||||
|
assert(!load_config(path).advanced_debug);
|
||||||
|
for (const auto* invalid : {R"({"advanced_debug":"true"})", R"({"advanced_debug":0})",
|
||||||
|
R"({"advanced_debug":null})", R"({"advanced_debug":[]})"}) {
|
||||||
|
put(path, invalid);
|
||||||
|
fails([&] { load_config(path); });
|
||||||
|
assert(!save_advanced_debug(path, true));
|
||||||
|
assert(get(path) == invalid);
|
||||||
|
}
|
||||||
|
put(path, R"({"font":"escaped \u0061 and \"quotes\"","input_priority":"experimental","wrist":{"y":0.21},"theme":{"ink":"#123ABC"},"buttons":{"enter":""}})");
|
||||||
|
const auto customized = get(path);
|
||||||
|
assert(save_advanced_debug(path, true));
|
||||||
|
assert(load_config(path).advanced_debug);
|
||||||
|
assert(get(path).find(customized.substr(1, customized.size() - 2)) != std::string::npos);
|
||||||
|
assert(save_advanced_debug(path, false));
|
||||||
|
assert(!load_config(path).advanced_debug);
|
||||||
|
auto before = get(path);
|
||||||
|
assert(save_advanced_debug(path, false) && get(path) == before);
|
||||||
|
put(path, R"({"advanced_debug":false, "font":"kept"})");
|
||||||
|
assert(save_advanced_debug(path, true));
|
||||||
|
assert(get(path) == R"({"advanced_debug":true, "font":"kept"})");
|
||||||
|
before = get(path);
|
||||||
|
put(path, R"({"font":"bad","font":"duplicate"})");
|
||||||
|
assert(!save_advanced_debug(path, true));
|
||||||
|
assert(get(path) == R"({"font":"bad","font":"duplicate"})");
|
||||||
|
put(path, before);
|
||||||
|
auto link = dir / "linked-config";
|
||||||
|
std::filesystem::create_symlink(path, link);
|
||||||
|
assert(!save_advanced_debug(link, false));
|
||||||
|
assert(get(path) == before);
|
||||||
|
std::filesystem::remove(link);
|
||||||
|
put(path, R"({"font":")" + std::string(4090, 'x') + R"("})");
|
||||||
|
before = get(path);
|
||||||
|
assert(!save_advanced_debug(path, true) && get(path) == before);
|
||||||
|
put(path, R"({"input_priority":"experimental","advanced_debug":true})");
|
||||||
auto experimental = load_config(path);
|
auto experimental = load_config(path);
|
||||||
assert(experimental.experimental_input_priority);
|
assert(experimental.experimental_input_priority && experimental.advanced_debug);
|
||||||
// A priority request must preserve the user's existing action manifest/bindings.
|
// A priority request must preserve the user's existing action manifest/bindings.
|
||||||
assert(action_manifest(argv[1], experimental) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json"));
|
assert(action_manifest(argv[1], experimental) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json"));
|
||||||
put(path, R"({"input_priority":"normal"})");
|
put(path, R"({"input_priority":"normal"})");
|
||||||
|
|||||||
+17
-1
@@ -12,7 +12,8 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0)
|
|||||||
return surface.pointer_up(cursor, x, y);
|
return surface.pointer_up(cursor, x, y);
|
||||||
}
|
}
|
||||||
void no_action(const SurfaceEvent& event) {
|
void no_action(const SurfaceEvent& event) {
|
||||||
assert(!event.action && !event.mount && !event.lasers_anytime && !event.recenter && !event.open_bindings);
|
assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug &&
|
||||||
|
!event.recenter && !event.open_bindings);
|
||||||
}
|
}
|
||||||
void snapshot(PanelSurface& surface, const std::string& path) {
|
void snapshot(PanelSurface& surface, const std::string& path) {
|
||||||
std::ofstream out(path, std::ios::binary);
|
std::ofstream out(path, std::ios::binary);
|
||||||
@@ -112,6 +113,19 @@ int main(int argc, char** argv) {
|
|||||||
laser = click(surface, 680, 420);
|
laser = click(surface, 680, 420);
|
||||||
assert(laser.lasers_anytime == false && !laser.action && !laser.mount);
|
assert(laser.lasers_anytime == false && !laser.action && !laser.mount);
|
||||||
surface.set_lasers_anytime(false); assert(surface.render(p));
|
surface.set_lasers_anytime(false); assert(surface.render(p));
|
||||||
|
auto debug = click(surface, 680, 474);
|
||||||
|
assert(debug.advanced_debug == true && !debug.action && !debug.mount && !debug.lasers_anytime);
|
||||||
|
assert(!surface.render(p)); // an event is only a request; caller sets the accepted value
|
||||||
|
surface.set_advanced_debug(true); assert(surface.render(p)); assert(!surface.render(p));
|
||||||
|
surface.pointer_down(1, 680, 474);
|
||||||
|
surface.set_advanced_debug(false); assert(surface.render(p));
|
||||||
|
no_action(surface.pointer_up(1, 680, 474)); // stale press cannot toggle after state change
|
||||||
|
debug = click(surface, 680, 474);
|
||||||
|
assert(debug.advanced_debug == true);
|
||||||
|
surface.set_advanced_debug(true); assert(surface.render(p));
|
||||||
|
debug = click(surface, 680, 474);
|
||||||
|
assert(debug.advanced_debug == false);
|
||||||
|
surface.set_advanced_debug(false); assert(surface.render(p));
|
||||||
assert(click(surface, 280, 610).action == UiAction::Cancel);
|
assert(click(surface, 280, 610).action == UiAction::Cancel);
|
||||||
surface.set_placement_note("Wrist not tracked - using world space until it returns.");
|
surface.set_placement_note("Wrist not tracked - using world space until it returns.");
|
||||||
assert(surface.render(p)); assert(!surface.render(p));
|
assert(surface.render(p)); assert(!surface.render(p));
|
||||||
@@ -123,6 +137,7 @@ int main(int argc, char** argv) {
|
|||||||
no_action(surface.pointer_up(1, 680, 260));
|
no_action(surface.pointer_up(1, 680, 260));
|
||||||
no_action(click(surface, 680, 260)); // mount controls not active on Review
|
no_action(click(surface, 680, 260)); // mount controls not active on Review
|
||||||
no_action(click(surface, 680, 420)); // laser toggle only exists on Settings
|
no_action(click(surface, 680, 420)); // laser toggle only exists on Settings
|
||||||
|
no_action(click(surface, 680, 474)); // debug toggle only exists on Settings
|
||||||
|
|
||||||
// Binding navigation is not a delivery action; settings and paging are hidden.
|
// Binding navigation is not a delivery action; settings and paging are hidden.
|
||||||
assert(!surface.bindings_visible());
|
assert(!surface.bindings_visible());
|
||||||
@@ -138,6 +153,7 @@ int main(int argc, char** argv) {
|
|||||||
no_action(click(surface, 680, 260));
|
no_action(click(surface, 680, 260));
|
||||||
no_action(click(surface, 900, 430));
|
no_action(click(surface, 900, 430));
|
||||||
no_action(click(surface, 680, 420));
|
no_action(click(surface, 680, 420));
|
||||||
|
no_action(click(surface, 680, 474));
|
||||||
const auto editor = click(surface, 200, 434);
|
const auto editor = click(surface, 200, 434);
|
||||||
assert(editor.open_bindings && !editor.action && !editor.mount && !editor.recenter && !editor.lasers_anytime);
|
assert(editor.open_bindings && !editor.action && !editor.mount && !editor.recenter && !editor.lasers_anytime);
|
||||||
no_action(surface.pointer_up(0, 200, 434)); // one launch per deliberate click
|
no_action(surface.pointer_up(0, 200, 434)); // one launch per deliberate click
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class InstallTests(unittest.TestCase):
|
|||||||
self.assertNotIn("--font", launcher.read_text()) # run/check modes honor config font
|
self.assertNotIn("--font", launcher.read_text()) # run/check modes honor config font
|
||||||
config = self.home / ".config/frameyap/config.json"
|
config = self.home / ".config/frameyap/config.json"
|
||||||
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS)
|
self.assertEqual(json.loads(config.read_text()), installer.CONFIG_DEFAULTS)
|
||||||
|
self.assertIs(json.loads(config.read_text())["advanced_debug"], False)
|
||||||
self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
|
self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
|
||||||
self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text())
|
self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text())
|
||||||
self.assertTrue(os.access(root / "versions/v1/runtime/bin/helper", os.X_OK))
|
self.assertTrue(os.access(root / "versions/v1/runtime/bin/helper", os.X_OK))
|
||||||
@@ -125,17 +126,26 @@ class InstallTests(unittest.TestCase):
|
|||||||
self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"])
|
self.assertEqual(fixed["theme"]["card"], installer.CONFIG_DEFAULTS["theme"]["card"])
|
||||||
self.assertEqual(fixed["buttons"]["cancel"], "/user/hand/right/input/b")
|
self.assertEqual(fixed["buttons"]["cancel"], "/user/hand/right/input/b")
|
||||||
self.assertEqual(fixed["input_priority"], "normal")
|
self.assertEqual(fixed["input_priority"], "normal")
|
||||||
|
self.assertIs(fixed["advanced_debug"], False)
|
||||||
self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"])
|
self.assertEqual(fixed["wrist"], installer.CONFIG_DEFAULTS["wrist"])
|
||||||
backups = list(config.parent.glob("config.json.backup-*"))
|
backups = list(config.parent.glob("config.json.backup-*"))
|
||||||
self.assertEqual(len(backups), 1)
|
self.assertEqual(len(backups), 1)
|
||||||
self.assertEqual(backups[0].read_bytes(), original)
|
self.assertEqual(backups[0].read_bytes(), original)
|
||||||
fixed["input_priority"] = "experimental"
|
fixed["input_priority"] = "experimental"
|
||||||
|
fixed["advanced_debug"] = True
|
||||||
fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades
|
fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades
|
||||||
fixed["wrist"]["y"] = 0.2
|
fixed["wrist"]["y"] = 0.2
|
||||||
compact = json.dumps(fixed, separators=(",", ":")).encode()
|
compact = json.dumps(fixed, separators=(",", ":")).encode()
|
||||||
config.write_bytes(compact)
|
config.write_bytes(compact)
|
||||||
self.install("v1", archive, digest)
|
self.install("v1", archive, digest)
|
||||||
self.assertEqual(config.read_bytes(), compact)
|
self.assertEqual(config.read_bytes(), compact)
|
||||||
|
self.assertIs(json.loads(config.read_text())["advanced_debug"], True)
|
||||||
|
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
|
||||||
|
fixed["advanced_debug"] = False
|
||||||
|
compact_off = json.dumps(fixed, separators=(",", ":")).encode()
|
||||||
|
config.write_bytes(compact_off)
|
||||||
|
self.install("v1", archive, digest)
|
||||||
|
self.assertEqual(config.read_bytes(), compact_off)
|
||||||
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
|
self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1)
|
||||||
original = b'{"font":"/system/face.ttf","input_priority":"highest","wrist":{"x":0.04,"y":true,"width":100,"obsolete":4},"theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}'
|
original = b'{"font":"/system/face.ttf","input_priority":"highest","wrist":{"x":0.04,"y":true,"width":100,"obsolete":4},"theme":{"ink":"bad","retired":"#123456"},"buttons":{"ptt":"/user/hand/left/input/grip"},"old_option":4}'
|
||||||
config.write_bytes(original)
|
config.write_bytes(original)
|
||||||
@@ -145,6 +155,7 @@ class InstallTests(unittest.TestCase):
|
|||||||
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
|
self.assertEqual(fixed["theme"]["ink"], installer.CONFIG_DEFAULTS["theme"]["ink"])
|
||||||
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
|
self.assertEqual(fixed["buttons"], installer.CONFIG_DEFAULTS["buttons"]) # colliding paths reset
|
||||||
self.assertEqual(fixed["input_priority"], "normal")
|
self.assertEqual(fixed["input_priority"], "normal")
|
||||||
|
self.assertIs(fixed["advanced_debug"], False)
|
||||||
self.assertEqual(fixed["wrist"]["x"], 0.04)
|
self.assertEqual(fixed["wrist"]["x"], 0.04)
|
||||||
self.assertEqual(fixed["wrist"]["y"], 0.18)
|
self.assertEqual(fixed["wrist"]["y"], 0.18)
|
||||||
self.assertEqual(fixed["wrist"]["width"], 0.30)
|
self.assertEqual(fixed["wrist"]["width"], 0.30)
|
||||||
@@ -170,6 +181,18 @@ class InstallTests(unittest.TestCase):
|
|||||||
self.install("v1", archive, digest)
|
self.install("v1", archive, digest)
|
||||||
self.assertTrue(config.is_symlink())
|
self.assertTrue(config.is_symlink())
|
||||||
|
|
||||||
|
def test_debug_boolean_repair_backs_up_invalid_values(self):
|
||||||
|
archive, digest = self.package("v1")
|
||||||
|
config = self.home / ".config/frameyap/config.json"
|
||||||
|
config.parent.mkdir(parents=True)
|
||||||
|
for invalid in ("true", 1, None, [], {}):
|
||||||
|
original = json.dumps({"advanced_debug": invalid, "font": "/custom/font.ttf"}).encode()
|
||||||
|
config.write_bytes(original)
|
||||||
|
self.install("v1", archive, digest)
|
||||||
|
self.assertIs(json.loads(config.read_text())["advanced_debug"], False)
|
||||||
|
self.assertEqual(json.loads(config.read_text())["font"], "/custom/font.ttf")
|
||||||
|
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
|
||||||
|
|
||||||
def test_digest_and_same_version_mismatch_leave_previous(self):
|
def test_digest_and_same_version_mismatch_leave_previous(self):
|
||||||
a, h = self.package("v1")
|
a, h = self.package("v1")
|
||||||
self.install("v1", a, h)
|
self.install("v1", a, h)
|
||||||
|
|||||||
+120
-3
@@ -4,8 +4,11 @@ Run: python3 -m unittest discover -s tests -p test_worker.py
|
|||||||
Native fixture: python3 tests/test_worker.py --model ok --threads 2 --clip-dir DIR
|
Native fixture: python3 tests/test_worker.py --model ok --threads 2 --clip-dir DIR
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import io
|
||||||
|
import contextlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -31,6 +34,7 @@ def fake_child():
|
|||||||
parser.add_argument("--model", required=True)
|
parser.add_argument("--model", required=True)
|
||||||
parser.add_argument("--threads", required=True)
|
parser.add_argument("--threads", required=True)
|
||||||
parser.add_argument("--clip-dir", required=True)
|
parser.add_argument("--clip-dir", required=True)
|
||||||
|
parser.add_argument("--advanced-debug", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.model in ("fail", "missing-model", "missing-import"):
|
if args.model in ("fail", "missing-model", "missing-import"):
|
||||||
worker.send_frame(1, b"F", {"fail": b"", "missing-model": b"M",
|
worker.send_frame(1, b"F", {"fail": b"", "missing-model": b"M",
|
||||||
@@ -54,6 +58,11 @@ def fake_child():
|
|||||||
continue
|
continue
|
||||||
assert len(msg) == 9 and msg[:1] == b"T"
|
assert len(msg) == 9 and msg[:1] == b"T"
|
||||||
requests += 1
|
requests += 1
|
||||||
|
if args.advanced_debug and args.model in ("debug-output", "debug-spam"):
|
||||||
|
os.write(2, b"private debug fixture\n")
|
||||||
|
if args.model == "debug-spam":
|
||||||
|
for _ in range(1100):
|
||||||
|
os.write(2, b"x" * 4096)
|
||||||
clip = Path(args.clip_dir) / "clip.raw"
|
clip = Path(args.clip_dir) / "clip.raw"
|
||||||
assert clip.stat().st_size == 3200 * 4
|
assert clip.stat().st_size == 3200 * 4
|
||||||
ident = msg[1:9]
|
ident = msg[1:9]
|
||||||
@@ -62,8 +71,11 @@ def fake_child():
|
|||||||
if args.model == "oversized-frame":
|
if args.model == "oversized-frame":
|
||||||
os.write(1, struct.pack("<I", 65537))
|
os.write(1, struct.pack("<I", 65537))
|
||||||
continue
|
continue
|
||||||
if args.model == "request-error" and requests == 1:
|
errors = {"request-error": b"transcription failed",
|
||||||
worker.send_frame(1, b"E", ident + b"transcription failed")
|
"safe-error": b"transcription failed [inference: RuntimeError]",
|
||||||
|
"unsafe-error": b"transcription failed [inference: RuntimeError] PRIVATE SPEECH\n"}
|
||||||
|
if args.model in errors and requests == 1:
|
||||||
|
worker.send_frame(1, b"E", ident + errors[args.model])
|
||||||
continue
|
continue
|
||||||
text = b"a" * 4097 if args.model == "long" else "héllo 世界".encode()
|
text = b"a" * 4097 if args.model == "long" else "héllo 世界".encode()
|
||||||
worker.send_frame(1, b"R", ident + text)
|
worker.send_frame(1, b"R", ident + text)
|
||||||
@@ -110,6 +122,111 @@ class WorkerTests(unittest.TestCase):
|
|||||||
os.close(out_r)
|
os.close(out_r)
|
||||||
os.close(out_w)
|
os.close(out_w)
|
||||||
|
|
||||||
|
def test_safe_error_labels_and_retry(self):
|
||||||
|
class PrivateError(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
raise AssertionError("exception messages must not be accessed")
|
||||||
|
self.assertEqual(worker.safe_request_error("PRIVATE PATH", PrivateError()),
|
||||||
|
b"transcription failed [unknown: Exception]")
|
||||||
|
cases = [
|
||||||
|
(OSError("PRIVATE AUDIO"), {"text": "ignored"}, b"audio: OSError"),
|
||||||
|
(None, RuntimeError("PRIVATE TRANSCRIPT"), b"inference: RuntimeError"),
|
||||||
|
(None, PrivateError(), b"inference: Exception"),
|
||||||
|
(None, {}, b"response: KeyError"),
|
||||||
|
(None, {"text": 4}, b"response: TypeError"),
|
||||||
|
(None, {"text": "\ud800"}, b"response: UnicodeError"),
|
||||||
|
]
|
||||||
|
for audio_error, response, label in cases:
|
||||||
|
with self.subTest(label=label), tempfile.TemporaryDirectory() as path:
|
||||||
|
class Model:
|
||||||
|
count = 0
|
||||||
|
def transcribe(self, **kwargs):
|
||||||
|
self.count += 1
|
||||||
|
if self.count > 1:
|
||||||
|
return {"text": "retry"}
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return response
|
||||||
|
model = Model()
|
||||||
|
in_r, in_w = os.pipe()
|
||||||
|
out_r, out_w = os.pipe()
|
||||||
|
try:
|
||||||
|
worker.send_frame(in_w, b"T", struct.pack("<Q", 1))
|
||||||
|
worker.send_frame(in_w, b"T", struct.pack("<Q", 2))
|
||||||
|
os.close(in_w); in_w = -1
|
||||||
|
# A clip-read failure never calls the model; retry can succeed.
|
||||||
|
reads = [audio_error, []] if audio_error else [[], []]
|
||||||
|
if audio_error:
|
||||||
|
model.count = 1
|
||||||
|
with patch.object(worker, "read_clip", side_effect=reads):
|
||||||
|
worker.run(model, path, in_r, out_w)
|
||||||
|
self.assertEqual(worker.read_frame(out_r), b"Y")
|
||||||
|
self.assertEqual(worker.read_frame(out_r), b"E" + struct.pack("<Q", 1) +
|
||||||
|
b"transcription failed [" + label + b"]")
|
||||||
|
self.assertEqual(worker.read_frame(out_r), b"R" + struct.pack("<Q", 2) + b"retry")
|
||||||
|
finally:
|
||||||
|
os.close(in_r)
|
||||||
|
if in_w >= 0: os.close(in_w)
|
||||||
|
os.close(out_r); os.close(out_w)
|
||||||
|
|
||||||
|
def test_debug_is_explicit_and_protocol_stays_safe(self):
|
||||||
|
for enabled in (False, True):
|
||||||
|
with self.subTest(enabled=enabled), tempfile.TemporaryDirectory() as path:
|
||||||
|
class Failing:
|
||||||
|
def transcribe(self, **kwargs):
|
||||||
|
raise RuntimeError("PRIVATE EXCEPTION DETAIL")
|
||||||
|
in_r, in_w = os.pipe(); out_r, out_w = os.pipe()
|
||||||
|
captured = io.StringIO()
|
||||||
|
try:
|
||||||
|
worker.send_frame(in_w, b"T", struct.pack("<Q", 8))
|
||||||
|
os.close(in_w); in_w = -1
|
||||||
|
with patch.object(worker, "read_clip", return_value=[]), contextlib.redirect_stderr(captured):
|
||||||
|
worker.run(Failing(), path, in_r, out_w, advanced_debug=enabled)
|
||||||
|
self.assertEqual(worker.read_frame(out_r), b"Y")
|
||||||
|
self.assertEqual(worker.read_frame(out_r), b"E" + struct.pack("<Q", 8) +
|
||||||
|
b"transcription failed [inference: RuntimeError]")
|
||||||
|
if enabled:
|
||||||
|
self.assertIn("Traceback", captured.getvalue())
|
||||||
|
self.assertIn("PRIVATE EXCEPTION DETAIL", captured.getvalue())
|
||||||
|
else:
|
||||||
|
self.assertEqual(captured.getvalue(), "")
|
||||||
|
finally:
|
||||||
|
os.close(in_r)
|
||||||
|
if in_w >= 0: os.close(in_w)
|
||||||
|
os.close(out_r); os.close(out_w)
|
||||||
|
|
||||||
|
def test_debug_fd_output_never_corrupts_protocol(self):
|
||||||
|
program = '''
|
||||||
|
import os, sys
|
||||||
|
from frameyap import worker
|
||||||
|
class Model:
|
||||||
|
def transcribe(self, **kwargs):
|
||||||
|
os.write(1, b"native stdout fixture\\n")
|
||||||
|
os.write(2, b"native stderr fixture\\n")
|
||||||
|
return {"text": "private fixture transcript"}
|
||||||
|
worker.load_model = lambda *args: Model()
|
||||||
|
worker.read_clip = lambda *args: []
|
||||||
|
sys.exit(worker.main(sys.argv[1:]))
|
||||||
|
'''
|
||||||
|
request = b"T" + struct.pack("<Q", 12)
|
||||||
|
response = b"R" + struct.pack("<Q", 12) + b"private fixture transcript"
|
||||||
|
expected = struct.pack("<I", 1) + b"Y" + struct.pack("<I", len(response)) + response
|
||||||
|
with tempfile.TemporaryDirectory() as path:
|
||||||
|
for enabled in (False, True):
|
||||||
|
args = [sys.executable, "-c", program, "--model", "unused", "--clip-dir", path]
|
||||||
|
if enabled: args.append("--advanced-debug")
|
||||||
|
env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[1] / "python"))
|
||||||
|
result = subprocess.run(args, input=struct.pack("<I", len(request)) + request,
|
||||||
|
capture_output=True, env=env, timeout=10)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout, expected)
|
||||||
|
if enabled:
|
||||||
|
self.assertIn(b"native stdout fixture", result.stderr)
|
||||||
|
self.assertIn(b"native stderr fixture", result.stderr)
|
||||||
|
self.assertIn(b"private fixture transcript", result.stderr)
|
||||||
|
else:
|
||||||
|
self.assertEqual(result.stderr, b"")
|
||||||
|
|
||||||
def test_missing_weights_no_dependency_import(self):
|
def test_missing_weights_no_dependency_import(self):
|
||||||
with tempfile.TemporaryDirectory() as path:
|
with tempfile.TemporaryDirectory() as path:
|
||||||
with self.assertRaisesRegex(worker.LocalModelError, "weights missing"):
|
with self.assertRaisesRegex(worker.LocalModelError, "weights missing"):
|
||||||
@@ -157,7 +274,7 @@ class WorkerTests(unittest.TestCase):
|
|||||||
in_w = -1
|
in_w = -1
|
||||||
worker.run(Large(), path, in_r, out_w)
|
worker.run(Large(), path, in_r, out_w)
|
||||||
self.assertEqual(worker.read_frame(out_r), b"Y")
|
self.assertEqual(worker.read_frame(out_r), b"Y")
|
||||||
self.assertEqual(worker.read_frame(out_r), b"E" + struct.pack("<Q", 7) + b"transcription failed")
|
self.assertEqual(worker.read_frame(out_r), b"E" + struct.pack("<Q", 7) + b"transcription failed [response: ValueError]")
|
||||||
finally:
|
finally:
|
||||||
os.close(in_r)
|
os.close(in_r)
|
||||||
if in_w >= 0:
|
if in_w >= 0:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sys/stat.h>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
@@ -143,6 +145,137 @@ int main(int argc, char** argv) {
|
|||||||
return failed;
|
return failed;
|
||||||
});
|
});
|
||||||
assert(std::filesystem::is_empty(runtime));
|
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";
|
||||||
|
if (!::mkdtemp(scratch)) throw std::runtime_error("debug fixture setup failed");
|
||||||
|
const auto root = std::filesystem::path(scratch);
|
||||||
|
try {
|
||||||
|
const auto script = root / "fake.py";
|
||||||
|
{
|
||||||
|
std::ofstream out(script);
|
||||||
|
out << R"PY(import argparse, os, struct, sys
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument('--model')
|
||||||
|
p.add_argument('--threads')
|
||||||
|
p.add_argument('--clip-dir')
|
||||||
|
p.add_argument('--advanced-debug', action='store_true')
|
||||||
|
a = p.parse_args()
|
||||||
|
protocol = os.dup(1)
|
||||||
|
if a.advanced_debug:
|
||||||
|
os.dup2(2, 1) # Match the production Python protocol/stdout split.
|
||||||
|
os.write(2, b'PRIVATE TRANSCRIPT /private/model/path\n')
|
||||||
|
os.write(1, b'PRIVATE STDOUT from model\n')
|
||||||
|
if a.model == 'spam':
|
||||||
|
for _ in range(1300): os.write(2, b'x' * 4096)
|
||||||
|
else:
|
||||||
|
null = os.open(os.devnull, os.O_WRONLY)
|
||||||
|
os.dup2(null, 1)
|
||||||
|
os.close(null)
|
||||||
|
os.write(protocol, struct.pack('<I', 1) + b'Y')
|
||||||
|
while True:
|
||||||
|
request = os.read(0, 13)
|
||||||
|
if not request: break
|
||||||
|
assert len(request) == 13 and request[4:5] == b'T'
|
||||||
|
if a.model == 'unsafe':
|
||||||
|
value = b'E' + request[5:] + b'transcription failed [inference: RuntimeError] PRIVATE SPEECH'
|
||||||
|
elif a.model == 'safe':
|
||||||
|
value = b'E' + request[5:] + b'transcription failed [inference: RuntimeError]'
|
||||||
|
else:
|
||||||
|
value = b'R' + request[5:] + b'ok'
|
||||||
|
os.write(protocol, struct.pack('<I', len(value)) + value)
|
||||||
|
)PY";
|
||||||
|
}
|
||||||
|
const auto state = root / "state";
|
||||||
|
::setenv("XDG_STATE_HOME", state.c_str(), 1);
|
||||||
|
const auto logs = state / "frameyap";
|
||||||
|
const auto current = logs / "worker-debug.log";
|
||||||
|
const auto previous = logs / "worker-debug.previous.log";
|
||||||
|
worker.start(argv[1], script, "safe", 2); // OFF must not create files.
|
||||||
|
until([&] { worker.poll(); return worker.ready(); });
|
||||||
|
worker.submit(300, clip);
|
||||||
|
until([&] { reply = worker.poll(); return reply.has_value(); });
|
||||||
|
assert(reply->error == "transcription failed [inference: RuntimeError]");
|
||||||
|
worker.stop();
|
||||||
|
assert(!std::filesystem::exists(logs));
|
||||||
|
|
||||||
|
worker.start(argv[1], script, "spam", 2, true);
|
||||||
|
until([&] { worker.poll(); return worker.ready(); });
|
||||||
|
worker.submit(301, clip);
|
||||||
|
until([&] { reply = worker.poll(); return reply.has_value(); });
|
||||||
|
assert(reply->text == "ok");
|
||||||
|
worker.stop();
|
||||||
|
assert(std::filesystem::status(logs).permissions() == std::filesystem::perms::owner_all);
|
||||||
|
assert(std::filesystem::status(current).permissions() ==
|
||||||
|
(std::filesystem::perms::owner_read | std::filesystem::perms::owner_write));
|
||||||
|
assert(std::filesystem::file_size(current) <= 4 * 1024 * 1024);
|
||||||
|
std::ifstream input(current, std::ios::binary);
|
||||||
|
std::string body((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
|
||||||
|
assert(body.find("PRIVATE TRANSCRIPT /private/model/path") != std::string::npos);
|
||||||
|
assert(body.find("PRIVATE STDOUT from model") != std::string::npos);
|
||||||
|
assert(body.find("[worker diagnostics truncated; further output discarded]") != std::string::npos);
|
||||||
|
worker.start(argv[1], script, "unsafe", 2, true);
|
||||||
|
until([&] { worker.poll(); return worker.ready(); });
|
||||||
|
worker.submit(302, clip);
|
||||||
|
until([&] { reply = worker.poll(); return reply.has_value(); });
|
||||||
|
assert(reply->error == "transcription failed" && worker.ready());
|
||||||
|
worker.stop();
|
||||||
|
assert(std::filesystem::file_size(previous) <= 4 * 1024 * 1024);
|
||||||
|
assert(std::filesystem::file_size(current) < 4096);
|
||||||
|
auto size = std::filesystem::file_size(current);
|
||||||
|
worker.start(argv[1], script, "ok", 2); // OFF retains earlier logs.
|
||||||
|
until([&] { worker.poll(); return worker.ready(); });
|
||||||
|
worker.stop();
|
||||||
|
assert(std::filesystem::file_size(current) == size && std::filesystem::exists(previous));
|
||||||
|
std::filesystem::remove(current);
|
||||||
|
std::filesystem::create_symlink(script, current);
|
||||||
|
bool refused = false;
|
||||||
|
try { worker.start(argv[1], script, "ok", 2, true); }
|
||||||
|
catch (const std::runtime_error&) { refused = true; }
|
||||||
|
assert(refused && !worker.ready());
|
||||||
|
std::filesystem::remove(current);
|
||||||
|
{ std::ofstream out(current); out << "existing"; }
|
||||||
|
::chmod(current.c_str(), 0644);
|
||||||
|
refused = false;
|
||||||
|
try { worker.start(argv[1], script, "ok", 2, true); }
|
||||||
|
catch (const std::runtime_error&) { refused = true; }
|
||||||
|
assert(refused);
|
||||||
|
std::filesystem::remove(current);
|
||||||
|
{ std::ofstream out(current); out << "linked"; }
|
||||||
|
::chmod(current.c_str(), 0600);
|
||||||
|
std::filesystem::create_hard_link(current, root / "outside-link");
|
||||||
|
refused = false;
|
||||||
|
try { worker.start(argv[1], script, "ok", 2, true); }
|
||||||
|
catch (const std::runtime_error&) { refused = true; }
|
||||||
|
assert(refused);
|
||||||
|
std::filesystem::remove(root / "outside-link");
|
||||||
|
std::filesystem::remove(current);
|
||||||
|
std::filesystem::remove_all(logs);
|
||||||
|
std::filesystem::create_directory_symlink(root, logs);
|
||||||
|
refused = false;
|
||||||
|
try { worker.start(argv[1], script, "ok", 2, true); }
|
||||||
|
catch (const std::runtime_error&) { refused = true; }
|
||||||
|
assert(refused);
|
||||||
|
std::filesystem::remove(logs);
|
||||||
|
std::filesystem::create_directory(logs);
|
||||||
|
::chmod(logs.c_str(), 0755);
|
||||||
|
refused = false;
|
||||||
|
try { worker.start(argv[1], script, "ok", 2, true); }
|
||||||
|
catch (const std::runtime_error&) { refused = true; }
|
||||||
|
assert(refused);
|
||||||
|
::unsetenv("XDG_STATE_HOME");
|
||||||
|
::setenv("HOME", root.c_str(), 1);
|
||||||
|
worker.start(argv[1], script, "ok", 2, true);
|
||||||
|
until([&] { worker.poll(); return worker.ready(); });
|
||||||
|
worker.stop();
|
||||||
|
assert(std::filesystem::exists(root / ".local/state/frameyap/worker-debug.log"));
|
||||||
|
::setenv("XDG_STATE_HOME", "relative/state", 1);
|
||||||
|
refused = false;
|
||||||
|
try { worker.start(argv[1], script, "ok", 2, true); }
|
||||||
|
catch (const std::runtime_error&) { refused = true; }
|
||||||
|
assert(refused);
|
||||||
|
std::filesystem::remove_all(root);
|
||||||
|
} catch (...) { std::filesystem::remove_all(root); throw; }
|
||||||
std::filesystem::remove(runtime);
|
std::filesystem::remove(runtime);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
std::filesystem::remove(runtime);
|
std::filesystem::remove(runtime);
|
||||||
|
|||||||
Reference in new issue
Block a user