From 8c809816748f3070cc41641d985e7dbf2fae7322 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 17 Oct 2026 15:39:47 -0400 Subject: [PATCH 01/11] feat(engine): add an opt-in shadow mode service ShadowModeService runs openadapt-capture's shadow observer inside the engine. It turns on only with acknowledged=true and the hash of the current notice, resumes after a restart only while that acknowledgement stands, and "purge and disable" deletes the buffer, the key, the ignore list, and saved drafts, then records the revocation. The recorder gets the service's live tap when shadow mode is on, so Desktop recordings feed it. Review draft saves an owner-only draft file; nothing runs. The shadow commands are on the Tauri bridge only, not the local socket, and shadow mode is not part of EngineConfig, so no config, environment, or policy can turn it on. Signed-off-by: Richard Abrich --- openadapt_desktop/controller.py | 45 +++- openadapt_desktop/dispatch.py | 70 +++++ openadapt_desktop/shadow_mode.py | 362 ++++++++++++++++++++++++++ tests/test_engine/test_dispatch.py | 7 + tests/test_engine/test_shadow_mode.py | 307 ++++++++++++++++++++++ 5 files changed, 789 insertions(+), 2 deletions(-) create mode 100644 openadapt_desktop/shadow_mode.py create mode 100644 tests/test_engine/test_shadow_mode.py diff --git a/openadapt_desktop/controller.py b/openadapt_desktop/controller.py index d632105..5b76d87 100644 --- a/openadapt_desktop/controller.py +++ b/openadapt_desktop/controller.py @@ -24,12 +24,13 @@ from __future__ import annotations import enum +import inspect import json import sys import uuid from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable from loguru import logger @@ -99,6 +100,7 @@ def __init__( db: IndexDB | None = None, bundles_dir: Path | None = None, auto_compile: bool = False, + live_tap_provider: Callable[[], object | None] | None = None, ) -> None: self.captures_dir = captures_dir self.quality = quality @@ -114,6 +116,10 @@ def __init__( self._db = db self._bundles_dir = bundles_dir self._auto_compile = auto_compile + # Shadow mode (off by default) hands a live tap for each recording + # while the person has it on; None otherwise. + self._live_tap_provider = live_tap_provider + self._live_tap: object | None = None @property def is_recording(self) -> bool: @@ -182,7 +188,8 @@ def start( recorder = None try: Recorder = _load_capture_recorder() - recorder = Recorder(str(capture_dir), task_description=task_description) + extra = self._live_tap_kwargs(Recorder) + recorder = Recorder(str(capture_dir), task_description=task_description, **extra) recorder.__enter__() if not recorder.wait_for_ready(timeout=60): raise RuntimeError("OpenAdapt Capture did not become ready within 60 seconds") @@ -200,6 +207,7 @@ def start( if self._storage_manager: self._storage_manager.register_capture(capture_id, capture_dir) except Exception as exc: + self._close_live_tap() if recorder is not None: try: recorder.stop() @@ -223,6 +231,37 @@ def start( return capture_id + def _live_tap_kwargs(self, recorder_cls: object) -> dict[str, object]: + """``{"live_event_tap": tap}`` when shadow mode is on and the installed + recorder accepts a tap; ``{}`` otherwise.""" + + self._live_tap = None + if self._live_tap_provider is None: + return {} + try: + accepts = "live_event_tap" in inspect.signature(recorder_cls).parameters + except (TypeError, ValueError): + accepts = False + if not accepts: + return {} + try: + tap = self._live_tap_provider() + except Exception: + logger.exception("Shadow mode could not provide a live tap; recording without it") + return {} + if tap is None: + return {} + self._live_tap = tap + return {"live_event_tap": tap} + + def _close_live_tap(self) -> None: + tap, self._live_tap = self._live_tap, None + if tap is not None: + try: + tap.close() # type: ignore[attr-defined] + except Exception: + logger.exception("Shadow live tap did not close cleanly") + def stop(self) -> dict: """Stop the current recording session. @@ -246,7 +285,9 @@ def stop(self) -> dict: recorder.stop() recorder.__exit__(None, None, None) event_count = getattr(recorder, "event_count", 0) or 0 + self._close_live_tap() except Exception as exc: + self._close_live_tap() try: recorder.__exit__(type(exc), exc, exc.__traceback__) except Exception: diff --git a/openadapt_desktop/dispatch.py b/openadapt_desktop/dispatch.py index 51a4bcf..7a773ca 100644 --- a/openadapt_desktop/dispatch.py +++ b/openadapt_desktop/dispatch.py @@ -140,6 +140,9 @@ def __init__( # engine never binds a socket or spawns a console it was not asked for. self.portal = portal self.authoring = authoring + # Set by the dispatcher: returns a shadow-mode live tap for the next + # recording, or None while shadow mode is off (the default). + self.live_tap_provider: Callable[[], Any] | None = None @property def db(self) -> Any: @@ -191,6 +194,9 @@ def controller(self) -> Any: flow_bridge=self.flow_bridge, db=self.db, bundles_dir=self.config.data_dir / "bundles", + live_tap_provider=lambda: ( + self.live_tap_provider() if self.live_tap_provider is not None else None + ), ) return self._controller @@ -239,6 +245,11 @@ def __init__( self._approval_watches: dict[str, dict[str, Any]] = {} self._approval_lock = threading.Lock() self._coach: dict[str, Any] = {} + # Shadow mode: built on first use, never from config or a remote + # policy. Its commands are served on the Tauri window wire only. + self._shadow: Any = None + self._shadow_resumed = False + self.services.live_tap_provider = self._shadow_live_tap self._handlers: dict[str, Callable[..., dict | None]] = {} self._register() from openadapt_desktop.coach import empty_coach @@ -363,8 +374,67 @@ def _register(self) -> None: "portal_devices": self.portal_devices, "portal_revoke_device": self.portal_revoke_device, "portal_notification": self.portal_notification, + # Shadow mode (local only, off by default). Turned on only from + # the Desktop window after the person acknowledges the notice. + "shadow_status": self.shadow_status, + "shadow_notice": self.shadow_notice, + "shadow_enable": self.shadow_enable, + "shadow_purge_and_disable": self.shadow_purge_and_disable, + "shadow_set_work_domains": self.shadow_set_work_domains, + "shadow_decide": self.shadow_decide, + "shadow_scan": self.shadow_scan, } + # ------------------------------------------------------- shadow mode + + @property + def shadow(self) -> Any: + if self._shadow is None: + from openadapt_desktop.shadow_mode import ShadowModeService + + self._shadow = ShadowModeService( + self.config.data_dir, emit=lambda event, data: self.emit(event, data) + ) + if not self._shadow_resumed: + self._shadow_resumed = True + self._shadow.resume() + return self._shadow + + def _shadow_live_tap(self) -> Any: + if self._shadow is None: + return None + return self._shadow.live_tap() + + def shadow_status(self, **params: Any) -> dict: + """Whether shadow mode is on, its acknowledgement time, and pending prompts.""" + return self.shadow.status() + + def shadow_notice(self, **params: Any) -> dict: + """The notice the person must acknowledge before shadow mode turns on.""" + return self.shadow.notice() + + def shadow_enable(self, **params: Any) -> dict: + """Turn shadow mode on. Needs ``acknowledged=True`` and the notice hash.""" + return self.shadow.enable( + str(params.get("notice_sha256") or ""), params.get("acknowledged") is True + ) + + def shadow_purge_and_disable(self, **params: Any) -> dict: + """Delete everything shadow mode kept and turn it off.""" + return self.shadow.purge_and_disable() + + def shadow_set_work_domains(self, **params: Any) -> dict: + """Replace the local work-domain allowlist.""" + return self.shadow.set_work_domains(list(params.get("domains") or [])) + + def shadow_decide(self, **params: Any) -> dict: + """Answer a prompt with ``review_draft`` or ``ignore``. Runs nothing.""" + return self.shadow.decide(str(params.get("motif_id") or ""), str(params.get("action"))) + + def shadow_scan(self, **params: Any) -> dict: + """Look for repeated tasks now instead of waiting for the next scan.""" + return {"prompts": self.shadow.scan()} + @property def commands(self) -> list[str]: """The registered command names (for discovery / tests).""" diff --git a/openadapt_desktop/shadow_mode.py b/openadapt_desktop/shadow_mode.py new file mode 100644 index 0000000..4809d0e --- /dev/null +++ b/openadapt_desktop/shadow_mode.py @@ -0,0 +1,362 @@ +"""Shadow mode in Desktop: the opt-in notice, the status, and the prompt. + +Shadow mode is OpenAdapt Capture's shadow miner +(``openadapt_capture.shadow``): it notices a task a person repeats across +apps and offers "You did this N times today." with **Review draft** and +**Ignore**. This module is Desktop's side of it. + +* **Off by default, on only by the person.** The only way to turn it on is + :meth:`ShadowModeService.enable`, called from Desktop's own window with the + hash of the notice the person was shown. Nothing in ``EngineConfig``, the + environment, the hosted policy, the tray's loopback socket, or the phone + portal can turn it on. There is no admin or remote mode. +* **The notice is Capture's.** :meth:`notice` returns it as Capture defines + it, and :meth:`enable` records the acknowledgement time locally through + Capture's consent ledger. +* **One off switch.** :meth:`purge_and_disable` deletes the buffer, the key, + the ignore list, and any saved drafts, records the revocation time, and + stays off until the person opts in again. +* **Local only.** Everything lives under ``/shadow`` with owner-only + permissions. Nothing here opens a network connection, and nothing here is + uploaded or synced. + +Events reach the miner through the recorder's live tap while Desktop records +(see :meth:`live_tap`). The miner is scanned every ``scan_interval_s`` +seconds while shadow mode is on. +""" + +from __future__ import annotations + +import json +import shutil +import threading +import time +from pathlib import Path +from typing import Any, Callable + +from loguru import logger + +from openadapt_desktop.private_fs import ensure_private_dir, make_private_file + +EmitFn = Callable[[str, dict], None] + +STATE_EVENT = "shadow_state" +PROMPT_EVENT = "shadow_prompt" +BUFFER_HOURS = 24 + + +class ShadowModeUnavailable(RuntimeError): + """The installed OpenAdapt Capture has no shadow mode.""" + + +def _capture_shadow() -> Any: + try: + from openadapt_capture import shadow + from openadapt_capture.shadow import consent # noqa: F401 + except Exception as exc: # ImportError, or a broken optional dependency + raise ShadowModeUnavailable( + "this OpenAdapt Capture has no shadow mode; update OpenAdapt Desktop" + ) from exc + if not hasattr(shadow, "ConsentLedger"): + raise ShadowModeUnavailable("this OpenAdapt Capture's shadow mode has no notice") + return shadow + + +class _Notifier: + def __init__(self, service: ShadowModeService) -> None: + self._service = service + + def offer(self, prompt: Any) -> None: + self._service._offer(prompt.payload()) + + +class ShadowModeService: + """Desktop's owner of one person's shadow mode.""" + + def __init__( + self, + data_dir: Path, + emit: EmitFn | None = None, + *, + key_store: Any = None, + clock: Callable[[], float] = time.time, + scan_interval_s: float = 60.0, + autostart_scanner: bool = True, + ) -> None: + self.data_dir = Path(data_dir) + self.dir = self.data_dir / "shadow" + self._emit = emit or (lambda event, data: None) + self._key_store = key_store + self._clock = clock + self._scan_interval_s = float(scan_interval_s) + self._autostart = autostart_scanner + self._lock = threading.RLock() + self._observer: Any = None + self._pending: dict[str, dict[str, Any]] = {} + self._stop = threading.Event() + self._scanner: threading.Thread | None = None + + # ------------------------------------------------------------- paths + + @property + def _state_path(self) -> Path: + return self.dir / "state.json" + + @property + def _domains_path(self) -> Path: + return self.dir / "work_domains.json" + + @property + def _drafts_dir(self) -> Path: + return self.dir / "drafts" + + def _ledger(self) -> Any: + return _capture_shadow().ConsentLedger(self.dir / "consent.json") + + def _write_json(self, path: Path, data: Any) -> None: + ensure_private_dir(self.dir, root=self.data_dir) + path.write_text(json.dumps(data, indent=2, sort_keys=True)) + make_private_file(path) + + def _read_json(self, path: Path, default: Any) -> Any: + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return default + + # ------------------------------------------------------------- queries + + def available(self) -> bool: + try: + _capture_shadow() + except ShadowModeUnavailable: + return False + return True + + def work_domains(self) -> list[str]: + domains = self._read_json(self._domains_path, []) + return sorted(d for d in domains if isinstance(d, str)) if isinstance(domains, list) else [] + + def status(self) -> dict[str, Any]: + with self._lock: + active = self._observer is not None and self._observer.enabled + ack = None + if self.available(): + current = self._ledger().current() + ack = current.acknowledged_at if current is not None else None + return { + "available": self.available(), + "active": active, + "acknowledged_at": ack, + "buffer_hours": BUFFER_HOURS, + "work_domains": self.work_domains(), + "keys_in_keychain": bool(active and self._observer.keys_persisted), + "epoch_ends_at": self._observer.epoch_ends_at if active else None, + "watches": "recordings" if active else None, + "pending": list(self._pending.values()), + } + + def notice(self) -> dict[str, Any]: + return _capture_shadow().notice() + + # ------------------------------------------------------------- on / off + + def enable(self, notice_sha256: str, acknowledged: bool) -> dict[str, Any]: + """Turn shadow mode on after the person acknowledged the notice. + + ``notice_sha256`` must be the hash of the notice the window showed; + ``acknowledged`` must be True (the person clicked the button). + """ + + shadow = _capture_shadow() + if acknowledged is not True: + raise PermissionError("shadow mode turns on only after the notice is acknowledged") + with self._lock: + ensure_private_dir(self.dir, root=self.data_dir) + ack = self._ledger().acknowledge(str(notice_sha256), now=self._clock()) + self._start(shadow, ack) + self._write_json(self._state_path, {"active": True}) + self._emit_state() + return self.status() + + def resume(self) -> bool: + """Turn shadow mode back on at engine start if the person left it on. + + Needs a standing acknowledgement of the current notice; a changed + notice must be acknowledged again. + """ + + if not self._read_json(self._state_path, {}).get("active"): + return False + try: + shadow = _capture_shadow() + ack = self._ledger().current() + except ShadowModeUnavailable: + return False + if ack is None: + return False + with self._lock: + self._start(shadow, ack) + self._emit_state() + return True + + def _start(self, shadow: Any, ack: Any) -> None: + if self._observer is not None and self._observer.enabled: + return + config = shadow.ShadowConfig( + enabled=True, + store_path=self.dir / "buffer.db", + work_domains=frozenset(self.work_domains()), + ) + self._observer = shadow.ShadowObserver( + config, + acknowledgement=ack, + notifier=_Notifier(self), + suppression=shadow.SuppressionList(self.dir / "ignored.json"), + key_store=self._key_store, + clock=self._clock, + ) + if self._autostart: + self._start_scanner() + + def purge_and_disable(self) -> dict[str, Any]: + """Delete everything shadow mode kept, record the revocation, turn off.""" + + self._stop_scanner() + with self._lock: + observer, self._observer = self._observer, None + if observer is not None: + observer.purge_and_disable() + else: + self._purge_without_observer() + self._pending.clear() + shutil.rmtree(self._drafts_dir, ignore_errors=True) + for name in ("buffer.db", "buffer.db-journal", "ignored.json"): + try: + (self.dir / name).unlink() + except FileNotFoundError: + pass + try: + self._ledger().revoke(now=self._clock()) + except ShadowModeUnavailable: + pass + self._write_json(self._state_path, {"active": False}) + self._emit_state() + return self.status() + + def _purge_without_observer(self) -> None: + try: + from openadapt_capture.shadow.keys import destroy_keys + except Exception: + return + destroy_keys(self._key_store) + + # ------------------------------------------------------------- settings + + def set_work_domains(self, domains: list[str]) -> dict[str, Any]: + """Replace the local work-domain allowlist. Applies from now on.""" + + shadow = _capture_shadow() + from openadapt_capture.shadow.config import normalize_domain + + if not isinstance(domains, list): + raise ValueError("domains must be a list of domain names") + cleaned = sorted({normalize_domain(d) for d in domains if str(d).strip()}) + with self._lock: + self._write_json(self._domains_path, cleaned) + if self._observer is not None and self._observer.enabled: + ack = self._observer.acknowledgement + self._observer.close() + self._observer = None + self._start(shadow, ack) + self._emit_state() + return self.status() + + # ------------------------------------------------------------- prompts + + def _offer(self, payload: dict[str, Any]) -> None: + self._pending[payload["motif_id"]] = payload + self._emit(PROMPT_EVENT, dict(payload)) + + def scan(self) -> list[dict[str, Any]]: + with self._lock: + if self._observer is None or not self._observer.enabled: + return [] + prompts = self._observer.scan() + return [p.payload() for p in prompts] + + def decide(self, motif_id: str, action: str) -> dict[str, Any]: + """Answer one prompt. Review draft saves the export locally; Ignore + suppresses the task. Neither runs anything.""" + + with self._lock: + if self._observer is None or not self._observer.enabled: + raise RuntimeError("shadow mode is off") + export = self._observer.decide(str(motif_id), str(action)) + self._pending.pop(str(motif_id), None) + result: dict[str, Any] = {"motif_id": motif_id, "action": action} + if export is not None: + ensure_private_dir(self._drafts_dir, root=self.data_dir) + path = self._drafts_dir / f"{export['motif_id']}.episodes.json" + path.write_text(json.dumps(export, indent=1, sort_keys=True)) + make_private_file(path) + result.update( + { + "draft_path": str(path), + "episodes": len(export["episodes"]), + "next": ( + "This is a draft, not an automation. Record the task once " + "to start; a person reviews and admits it before it can run." + ), + } + ) + self._emit_state() + return result + + # ------------------------------------------------------------- source + + def live_tap(self) -> Any: + """A live tap for the next recording, or None while shadow mode is off.""" + + with self._lock: + if self._observer is None or not self._observer.enabled: + return None + from openadapt_capture.live import LiveEventTap + + shadow = _capture_shadow() + return LiveEventTap(shadow.RecorderShadowBridge(self._observer)).start() + + # ------------------------------------------------------------- scanner + + def _start_scanner(self) -> None: + if self._scanner is not None and self._scanner.is_alive(): + return + self._stop.clear() + + def loop() -> None: + while not self._stop.wait(self._scan_interval_s): + try: + self.scan() + except Exception as exc: + logger.warning("shadow scan failed: {}", type(exc).__name__) + + self._scanner = threading.Thread(target=loop, name="shadow-scan", daemon=True) + self._scanner.start() + + def _stop_scanner(self) -> None: + self._stop.set() + scanner, self._scanner = self._scanner, None + if scanner is not None: + scanner.join(timeout=2.0) + + def _emit_state(self) -> None: + try: + self._emit(STATE_EVENT, self.status()) + except Exception as exc: + logger.warning("shadow state event failed: {}", type(exc).__name__) + + def close(self) -> None: + self._stop_scanner() + with self._lock: + if self._observer is not None: + self._observer.close() diff --git a/tests/test_engine/test_dispatch.py b/tests/test_engine/test_dispatch.py index b08405f..afa54b6 100644 --- a/tests/test_engine/test_dispatch.py +++ b/tests/test_engine/test_dispatch.py @@ -2846,6 +2846,13 @@ def test_all_frontend_commands_registered(self, deps) -> None: "approve_review", "dismiss_review", "get_pending_reviews", + "shadow_status", + "shadow_notice", + "shadow_enable", + "shadow_purge_and_disable", + "shadow_set_work_domains", + "shadow_decide", + "shadow_scan", } assert expected.issubset(set(disp.commands)) diff --git a/tests/test_engine/test_shadow_mode.py b/tests/test_engine/test_shadow_mode.py new file mode 100644 index 0000000..aab91cf --- /dev/null +++ b/tests/test_engine/test_shadow_mode.py @@ -0,0 +1,307 @@ +"""Shadow mode in Desktop: opt-in only, local only, one off switch.""" + +from __future__ import annotations + +import json +import socket +from pathlib import Path + +import pytest + +shadow = pytest.importorskip("openadapt_capture.shadow") +consent = pytest.importorskip("openadapt_capture.shadow.consent") +pytest.importorskip("openadapt_capture.shadow.keys") + +from openadapt_capture.shadow.keys import ACTIVE_ACCOUNT, MemoryKeyStore # noqa: E402 + +from openadapt_desktop.shadow_mode import ( # noqa: E402 + PROMPT_EVENT, + STATE_EVENT, + ShadowModeService, +) + + +class Clock: + def __init__(self, now: float) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + +def _invoice_events(start: float): + """Four copies of a spreadsheet-to-ERP task, with noise in between.""" + + S = shadow.ShadowEvent + events = [] + t = start + for r in range(4): + for i in range(6): + t += 7 + events.append( + S(t, "click", app=("Slack", "Finder")[i % 2], role="button", label=f"N{r}{i}") + ) + t += 30 + events += [ + S(t, "focus", app="Microsoft Excel"), + S(t + 1, "click", app="Microsoft Excel", role="AXCell", label=f"INV-{1000 + r}"), + S(t + 2, "copy", app="Microsoft Excel", key="c", modifiers=("meta",)), + S(t + 3, "focus", app="Google Chrome"), + S(t + 4, "navigate", app="Google Chrome", url="https://erp.example.com/invoices/new"), + S(t + 5, "click", app="Google Chrome", role="textbox", label="Invoice number"), + S(t + 6, "paste", app="Google Chrome", key="v", modifiers=("meta",)), + S(t + 7, "click", app="Google Chrome", role="AXButton", label="Submit"), + ] + t += 40 + return events, t + + +@pytest.fixture() +def svc(tmp_path: Path): + events: list[tuple[str, dict]] = [] + store = MemoryKeyStore() + service = ShadowModeService( + tmp_path, + emit=lambda e, d: events.append((e, d)), + key_store=store, + clock=Clock(1_000_000.0), + autostart_scanner=False, + ) + yield service, events, store, tmp_path + service.close() + + +def test_off_by_default_and_nothing_written(svc): + service, events, _store, root = svc + status = service.status() + assert status["available"] is True + assert status["active"] is False and status["acknowledged_at"] is None + assert service.live_tap() is None + assert service.scan() == [] + assert not (root / "shadow" / "buffer.db").exists() + assert service.resume() is False + + +def test_enable_needs_the_acknowledged_current_notice(svc): + service, _events, _store, root = svc + with pytest.raises(PermissionError): + service.enable(service.notice()["sha256"], acknowledged=False) + with pytest.raises(consent.ShadowConsentRequired): + service.enable("0" * 64, acknowledged=True) + assert service.status()["active"] is False + assert not (root / "shadow" / "consent.json").exists() + + +def test_enable_records_the_acknowledgement_time_locally(svc): + service, events, _store, root = svc + status = service.enable(service.notice()["sha256"], acknowledged=True) + assert status["active"] is True + assert status["acknowledged_at"] == 1_000_000.0 + assert status["buffer_hours"] == 24 + ledger = json.loads((root / "shadow" / "consent.json").read_text()) + assert ledger["events"][-1]["event"] == "acknowledged" + assert oct((root / "shadow" / "consent.json").stat().st_mode & 0o777) == "0o600" + assert oct((root / "shadow").stat().st_mode & 0o777) == "0o700" + assert events[-1][0] == STATE_EVENT and events[-1][1]["active"] is True + + +def test_prompt_review_draft_and_ignore(svc): + service, events, _store, root = svc + service.set_work_domains(["erp.example.com"]) + service.enable(service.notice()["sha256"], acknowledged=True) + stream, end = _invoice_events(1_000_000.0 - 3000) + for event in stream: + service._observer.observe(event) + prompts = service.scan() + assert len(prompts) == 1 + prompt = prompts[0] + assert prompt["body"] == "You did this 4 times today." + assert set(prompt) == {"motif_id", "count", "title", "body", "actions"} + assert any(e == PROMPT_EVENT for e, _ in events) + assert service.status()["pending"] == [prompt] + result = service.decide(prompt["motif_id"], "review_draft") + draft = Path(result["draft_path"]) + assert draft.parent == root / "shadow" / "drafts" + export = json.loads(draft.read_text()) + assert export["schema"] == "openadapt.shadow.episodes/v2" + for raw in ("INV-1000", "INV-1003", "Invoice number"): + assert raw not in draft.read_text() + assert oct(draft.stat().st_mode & 0o777) == "0o600" + assert service.status()["pending"] == [] + + +def test_non_work_sites_are_not_recorded_until_allowlisted(svc): + service, _events, _store, _root = svc + service.enable(service.notice()["sha256"], acknowledged=True) + S = shadow.ShadowEvent + service._observer.observe( + S(999_990.0, "navigate", app="Safari", url="https://erp.example.com/x") + ) + (sym,) = service._observer.symbols() + assert sym.token == "focus|non-work-browser" + with pytest.raises(ValueError): + service.set_work_domains(["https://erp.example.com/path"]) + status = service.set_work_domains(["ERP.example.com"]) + assert status["work_domains"] == ["erp.example.com"] and status["active"] is True + + +def test_purge_and_disable_deletes_everything_and_records_revocation(svc): + service, events, store, root = svc + service.set_work_domains(["erp.example.com"]) + service.enable(service.notice()["sha256"], acknowledged=True) + stream, _ = _invoice_events(1_000_000.0 - 3000) + for event in stream: + service._observer.observe(event) + (prompt,) = service.scan() + service.decide(prompt["motif_id"], "review_draft") + assert (root / "shadow" / "buffer.db").exists() + assert store.get(ACTIVE_ACCOUNT) is not None + status = service.purge_and_disable() + assert status["active"] is False and status["acknowledged_at"] is None + shadow_dir = root / "shadow" + assert not (shadow_dir / "buffer.db").exists() + assert not (shadow_dir / "drafts").exists() + assert not (shadow_dir / "ignored.json").exists() + assert store.get(ACTIVE_ACCOUNT) is None + ledger = json.loads((shadow_dir / "consent.json").read_text()) + assert [e["event"] for e in ledger["events"]] == ["acknowledged", "revoked"] + # It stays off across a restart. + again = ShadowModeService(root, key_store=store, autostart_scanner=False) + assert again.resume() is False + + +def test_resume_after_restart_only_with_a_standing_acknowledgement(svc): + service, _events, store, root = svc + service.enable(service.notice()["sha256"], acknowledged=True) + service.close() + again = ShadowModeService( + root, key_store=store, clock=Clock(1_000_100.0), autostart_scanner=False + ) + assert again.resume() is True and again.status()["active"] is True + again.close() + # A changed notice needs a new acknowledgement. + data = json.loads((root / "shadow" / "consent.json").read_text()) + data["events"][-1]["notice_sha256"] = "f" * 64 + (root / "shadow" / "consent.json").write_text(json.dumps(data)) + third = ShadowModeService(root, key_store=store, autostart_scanner=False) + assert third.resume() is False + + +def test_live_tap_feeds_the_miner_while_on(svc): + service, _events, _store, _root = svc + service.enable(service.notice()["sha256"], acknowledged=True) + tap = service.live_tap() + assert tap is not None + ax = { + "element": {"role": "AXTextField", "name": "Amount"}, + "process": {"process_name": "Numbers"}, + } + for i, ch in enumerate("12"): + tap.publish( + 999_990.0 + i * 0.1, + "action", + {"name": "press", "key_char": ch, "structural_observation": ax}, + ) + tap.close() + (sym,) = service._observer.symbols() + assert sym.kind == "type" and sym.value_shape == "digits" + + +def test_no_network_access(svc, monkeypatch): + service, _events, _store, _root = svc + + def refuse(*_a, **_k): + raise AssertionError("shadow mode attempted network access") + + for name in ("connect", "connect_ex", "sendto", "bind"): + monkeypatch.setattr(socket.socket, name, refuse) + monkeypatch.setattr(socket, "create_connection", refuse) + monkeypatch.setattr(socket, "getaddrinfo", refuse) + service.set_work_domains(["erp.example.com"]) + service.enable(service.notice()["sha256"], acknowledged=True) + stream, _ = _invoice_events(1_000_000.0 - 3000) + for event in stream: + service._observer.observe(event) + (prompt,) = service.scan() + service.decide(prompt["motif_id"], "ignore") + service.purge_and_disable() + + +# --------------------------------------------------------------- dispatcher + + +def test_shadow_commands_are_on_the_window_wire_only(): + from openadapt_desktop import socket_server + + for cmd in ( + "shadow_status", + "shadow_notice", + "shadow_enable", + "shadow_purge_and_disable", + "shadow_set_work_domains", + "shadow_decide", + "shadow_scan", + ): + assert cmd not in socket_server._SOCKET_COMMANDS + + +def test_engine_config_has_no_shadow_switch(): + from openadapt_desktop.config import EngineConfig + + assert not [name for name in EngineConfig.model_fields if "shadow" in name.lower()] + + +def test_dispatcher_serves_shadow_commands(tmp_path): + from openadapt_desktop.config import EngineConfig + from openadapt_desktop.dispatch import EngineDispatcher + + emitted: list[str] = [] + disp = EngineDispatcher(EngineConfig(data_dir=tmp_path), emit=lambda e, d: emitted.append(e)) + disp._shadow = ShadowModeService( + tmp_path, + emit=lambda e, d: disp.emit(e, d), + key_store=MemoryKeyStore(), + autostart_scanner=False, + ) + assert disp.dispatch("shadow_status", {})["active"] is False + assert disp.services.live_tap_provider() is None + sha = disp.dispatch("shadow_notice", {})["sha256"] + with pytest.raises(PermissionError): + disp.dispatch("shadow_enable", {"notice_sha256": sha}) + on = disp.dispatch("shadow_enable", {"notice_sha256": sha, "acknowledged": True}) + assert on["active"] is True and STATE_EVENT in emitted + tap = disp.services.live_tap_provider() + assert tap is not None + tap.close() + assert disp.dispatch("shadow_scan", {}) == {"prompts": []} + off = disp.dispatch("shadow_purge_and_disable", {}) + assert off["active"] is False + assert disp.services.live_tap_provider() is None + disp._shadow.close() + + +def test_controller_passes_a_tap_only_to_a_recorder_that_accepts_one(tmp_path): + from openadapt_desktop.controller import RecordingController + + class Tap: + closed = False + + def close(self): + Tap.closed = True + + class NewRecorder: + def __init__(self, path, task_description="", live_event_tap=None): + pass + + class OldRecorder: + def __init__(self, path, task_description=""): + pass + + with_tap = RecordingController(tmp_path, live_tap_provider=lambda: Tap()) + assert "live_event_tap" in with_tap._live_tap_kwargs(NewRecorder) + with_tap._close_live_tap() + assert Tap.closed + assert with_tap._live_tap_kwargs(OldRecorder) == {} + off = RecordingController(tmp_path, live_tap_provider=lambda: None) + assert off._live_tap_kwargs(NewRecorder) == {} + assert RecordingController(tmp_path)._live_tap_kwargs(NewRecorder) == {} From 75ebab5830a4f1462d90589ef7cb12b8216be041 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 17 Oct 2026 15:39:47 -0400 Subject: [PATCH 02/11] feat(ui): show the shadow mode notice, status, and repeated-task offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gets a Shadow mode card: the notice with Turn on shadow mode and Not now, the status (when it was turned on, the 24-hour buffer, where the key is), the work-site list, and a one-click "Delete everything and turn off". The header shows "Shadow mode on" while it is on, and offers appear as "You did this N times today." with Review draft and Ignore. The tray menu gains a "Shadow mode: on/off" line, a matching tooltip, a "Shadow" title on macOS, and a "Shadow mode settings…" item. The window sets them through the new set_shadow_tray_status command. Signed-off-by: Richard Abrich --- src-tauri/src/main.rs | 1 + src-tauri/src/tray.rs | 101 ++++++++++-- src/App.tsx | 15 ++ src/lib/engine.ts | 32 ++++ src/lib/shadowMode.ts | 106 +++++++++++++ src/screens/Settings.tsx | 3 + src/styles/app.css | 26 ++++ src/ui/ShadowMode.test.tsx | 189 ++++++++++++++++++++++ src/ui/ShadowMode.tsx | 312 +++++++++++++++++++++++++++++++++++++ 9 files changed, 775 insertions(+), 10 deletions(-) create mode 100644 src/lib/shadowMode.ts create mode 100644 src/ui/ShadowMode.test.tsx create mode 100644 src/ui/ShadowMode.tsx diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8af59cb..eb18707 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -104,6 +104,7 @@ fn main() { commands::set_control_overlay_interactive, commands::ensure_control_overlay_capture_excluded, commands::set_control_overlay_layout, + tray::set_shadow_tray_status, ffmpeg::ffmpeg_status, ffmpeg::retry_ffmpeg_provisioning, // typed convenience commands (forward to the sidecar) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 2b6f442..e1c4390 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -11,21 +11,60 @@ // // The recording indicator remains a compliance requirement wherever a tray is // shown: the tooltip reflects whether recording is active. +// +// Shadow mode (off by default) has its own indicator here, because a person +// must be able to see at a glance that it is on: a menu line that reads +// "Shadow mode: on" or "Shadow mode: off", the tooltip, and on macOS a short +// title next to the icon. The window sets it through `set_shadow_tray_status` +// from the engine's shadow state. The menu line is not clickable: turning +// shadow mode on needs the notice in the window, and "Shadow mode settings" +// opens that window. + +use std::sync::Mutex; use tauri::{ menu::{Menu, MenuItem}, tray::TrayIconBuilder, - App, Manager, + App, AppHandle, Manager, Wry, }; -/// Build the minimal tray icon + context menu (Show window / Quit). +const TRAY_ID: &str = "openadapt-desktop-tray"; +const TOOLTIP: &str = "OpenAdapt Desktop"; + +/// The tray's shadow-mode menu line, kept so its text can change. +pub struct ShadowTrayItem(pub Mutex>>); + +/// Menu text, tooltip, and macOS title for one shadow-mode state. +pub fn shadow_labels(active: bool) -> (&'static str, &'static str, Option<&'static str>) { + if active { + ( + "Shadow mode: on (local only)", + "OpenAdapt Desktop: shadow mode is on", + Some("Shadow"), + ) + } else { + ("Shadow mode: off", TOOLTIP, None) + } +} + +/// Build the minimal tray icon + context menu. pub fn setup_tray(app: &App) -> Result<(), Box> { let show = MenuItem::with_id(app, "show", "Open OpenAdapt", true, None::<&str>)?; + let (label, _, _) = shadow_labels(false); + let shadow = MenuItem::with_id(app, "shadow-status", label, false, None::<&str>)?; + let shadow_settings = MenuItem::with_id( + app, + "shadow-settings", + "Shadow mode settings…", + true, + None::<&str>, + )?; let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show, &quit])?; + let menu = Menu::with_items(app, &[&show, &shadow, &shadow_settings, &quit])?; + app.manage(ShadowTrayItem(Mutex::new(Some(shadow)))); - TrayIconBuilder::with_id("openadapt-desktop-tray") - .tooltip("OpenAdapt Desktop") + TrayIconBuilder::with_id(TRAY_ID) + .tooltip(TOOLTIP) .icon( app.default_window_icon() .cloned() @@ -34,11 +73,11 @@ pub fn setup_tray(app: &App) -> Result<(), Box> { .menu(&menu) .show_menu_on_left_click(true) .on_menu_event(|app, event| match event.id.as_ref() { - "show" => { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); - } + "show" => show_main(app), + "shadow-settings" => { + show_main(app); + use tauri::Emitter; + let _ = app.emit("tray://open-shadow-settings", ()); } "quit" => { app.exit(0); @@ -49,3 +88,45 @@ pub fn setup_tray(app: &App) -> Result<(), Box> { Ok(()) } + +fn show_main(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } +} + +/// Reflect shadow mode's on/off state in the tray. Called by the window +/// whenever the engine reports a shadow state. +#[tauri::command] +pub fn set_shadow_tray_status(app: AppHandle, active: bool) -> Result<(), String> { + let (label, tooltip, title) = shadow_labels(active); + if let Some(state) = app.try_state::() { + if let Some(item) = state.0.lock().map_err(|e| e.to_string())?.as_ref() { + item.set_text(label).map_err(|e| e.to_string())?; + } + } + if let Some(tray) = app.tray_by_id(TRAY_ID) { + tray.set_tooltip(Some(tooltip)).map_err(|e| e.to_string())?; + #[cfg(target_os = "macos")] + tray.set_title(title).map_err(|e| e.to_string())?; + #[cfg(not(target_os = "macos"))] + let _ = title; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::shadow_labels; + + #[test] + fn shadow_indicator_says_on_or_off() { + let (on, on_tip, on_title) = shadow_labels(true); + assert!(on.contains("on") && on_tip.contains("shadow mode is on")); + assert_eq!(on_title, Some("Shadow")); + let (off, _, off_title) = shadow_labels(false); + assert!(off.ends_with("off")); + assert_eq!(off_title, None); + } +} diff --git a/src/App.tsx b/src/App.tsx index 2cbb80b..167be67 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { EVT, engineTry, onEngineEvent, + onShadowSettingsRequested, sidecarRunning, } from "./lib/engine"; import type { @@ -44,6 +45,11 @@ import { deliverAttentionNotification } from "./lib/attentionNotification"; import { Qualification } from "./screens/Qualification"; import { clearLocalSession, rememberLocalSession } from "./localSession"; import { ItemApprovalDialog } from "./ui/ItemApproval"; +import { + ShadowModeIndicator, + ShadowPromptBanner, + useShadowMode, +} from "./ui/ShadowMode"; type Route = | { name: "tasks" } @@ -220,6 +226,13 @@ export default function App() { const [entryRetrying, setEntryRetrying] = useState(false); const [showLogin, setShowLogin] = useState(false); const [route, setRoute] = useState({ name: "tasks" }); + // Shadow mode is off unless the person turned it on from Settings. While on, + // the header and the tray both say so. + const { status: shadow } = useShadowMode({ syncTray: true }); + useEffect(() => { + const unlisten = onShadowSettingsRequested(() => setRoute({ name: "settings" })); + return () => void unlisten.then((fn) => fn()).catch(() => {}); + }, []); const [screenBusy, setScreenBusy] = useState(false); const [engineUp, setEngineUp] = useState(false); @@ -850,6 +863,7 @@ export default function App() { Recording ) : null} + {cloudConnected && sync && sync.state !== "synced" && ( @@ -879,6 +893,7 @@ export default function App() {
+ {route.name === "tasks" && ( `). */ @@ -156,6 +165,10 @@ export const EVT = { // {workflow_id, state: "watching" | "closed"}: a run that may stop before a // save for a person's approval started or ended. Never carries a value. ITEM_APPROVAL: "item_approval", + // Shadow mode status (see src/lib/shadowMode.ts). Never carries an event. + SHADOW_STATE: "shadow_state", + // {motif_id, count, title, body, actions}: generic text only. + SHADOW_PROMPT: "shadow_prompt", } as const; export type EngineEvent = (typeof EVT)[keyof typeof EVT]; @@ -287,6 +300,25 @@ export async function openExternal(url: string): Promise { await invoke("open_external", { url }); } +/** + * Show whether shadow mode is on in the menu-bar tray. A no-op outside Tauri + * or in a shell without the command. + */ +export async function setShadowTrayStatus(active: boolean): Promise { + if (!inTauri()) return; + try { + await invoke("set_shadow_tray_status", { active }); + } catch { + // An older shell has no shadow tray line. The window still shows it. + } +} + +/** The tray's "Shadow mode settings…" item. Returns an unlisten fn. */ +export function onShadowSettingsRequested(handler: () => void): Promise { + if (!inTauri()) return Promise.resolve(() => {}); + return listen("tray://open-shadow-settings", () => handler()); +} + /** Subscribe to an engine event. Returns an unlisten fn. */ export function onEngineEvent( event: EngineEvent, diff --git a/src/lib/shadowMode.ts b/src/lib/shadowMode.ts new file mode 100644 index 0000000..8f1760d --- /dev/null +++ b/src/lib/shadowMode.ts @@ -0,0 +1,106 @@ +// Shadow mode: the engine's local, off-by-default watcher that notices a task +// done many times and offers to turn it into a draft. These types mirror +// openadapt_desktop/shadow_mode.py. Nothing here leaves the computer. + +export interface ShadowStatus { + available: boolean; + active: boolean; + acknowledged_at: number | null; + buffer_hours: number; + work_domains: string[]; + keys_in_keychain: boolean; + epoch_ends_at: number | null; + watches: "recordings" | null; + pending: ShadowPrompt[]; +} + +export interface ShadowPromptAction { + id: "review_draft" | "ignore"; + label: string; +} + +/** Generic text only: never an app, a page, a field, or a value. */ +export interface ShadowPrompt { + motif_id: string; + count: number; + title: string; + body: string; + actions: ShadowPromptAction[]; +} + +export interface ShadowNotice { + version: string; + sha256: string; + title: string; + intro: string; + sections: { heading: string; items: string[] }[]; + acknowledge_label: string; + decline_label: string; + purge_and_disable_label: string; + text: string; +} + +export interface ShadowDecision { + motif_id: string; + action: string; + draft_path?: string; + episodes?: number; + next?: string; +} + +export const SHADOW_OFF: ShadowStatus = { + available: false, + active: false, + acknowledged_at: null, + buffer_hours: 24, + work_domains: [], + keys_in_keychain: false, + epoch_ends_at: null, + watches: null, + pending: [], +}; + +/** Read a status payload defensively: anything unclear reads as off. */ +export function readShadowStatus(raw: unknown): ShadowStatus { + if (!raw || typeof raw !== "object") return SHADOW_OFF; + const r = raw as Partial; + return { + available: r.available === true, + active: r.available === true && r.active === true, + acknowledged_at: typeof r.acknowledged_at === "number" ? r.acknowledged_at : null, + buffer_hours: typeof r.buffer_hours === "number" ? r.buffer_hours : 24, + work_domains: Array.isArray(r.work_domains) ? r.work_domains.map(String) : [], + keys_in_keychain: r.keys_in_keychain === true, + epoch_ends_at: typeof r.epoch_ends_at === "number" ? r.epoch_ends_at : null, + watches: r.watches === "recordings" ? "recordings" : null, + pending: Array.isArray(r.pending) ? r.pending.filter(isPrompt) : [], + }; +} + +export function isPrompt(raw: unknown): raw is ShadowPrompt { + if (!raw || typeof raw !== "object") return false; + const p = raw as Partial; + return ( + typeof p.motif_id === "string" && + typeof p.count === "number" && + typeof p.title === "string" && + typeof p.body === "string" && + Array.isArray(p.actions) + ); +} + +/** Split what a person typed into domain names: commas, spaces, or lines. */ +export function parseDomains(text: string): string[] { + const seen = new Set(); + for (const part of text.split(/[\s,]+/)) { + const d = part.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + if (d) seen.add(d); + } + return [...seen]; +} + +/** A local date and time for an epoch-seconds value. */ +export function formatWhen(epochSeconds: number | null): string { + if (epochSeconds == null) return "not recorded"; + return new Date(epochSeconds * 1000).toLocaleString(); +} diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 59783d9..f46271d 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -24,6 +24,7 @@ import { SegControl, } from "../ui/primitives"; import { OsWarning } from "../ui/OsWarning"; +import { ShadowModeCard } from "../ui/ShadowMode"; import { overlayPresentationEnabled, saveOverlayPresentation, @@ -245,6 +246,8 @@ export function Settings({ onConnectCloud={onConnectCloud} /> + + span.recording { color: var(--attention); } +/* Shadow mode is on: never subtle. */ +.desktop-status > span.shadow-mode-on { + color: var(--text); + border-color: currentColor; +} .desktop-status strong { color: inherit; font-size: 10px; @@ -3086,3 +3091,24 @@ select.input:focus { color: var(--text-muted); font-size: var(--fs-caption); } + +/* Shadow mode notice and repeated-task offers. */ +.shadow-notice { + margin: var(--space-2) 0; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-md, 8px); + background: var(--panel-sunk); +} +.shadow-notice h4 { + margin: var(--space-2) 0 var(--space-1); +} +.shadow-notice ul { + margin: 0; + padding-left: 1.2em; +} +.shadow-prompts { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-3); +} diff --git a/src/ui/ShadowMode.test.tsx b/src/ui/ShadowMode.test.tsx new file mode 100644 index 0000000..fece135 --- /dev/null +++ b/src/ui/ShadowMode.test.tsx @@ -0,0 +1,189 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const events = vi.hoisted(() => ({ handlers: new Map void>() })); + +vi.mock("../lib/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + engineTry: vi.fn(), + engineInvoke: vi.fn(), + setShadowTrayStatus: vi.fn(async () => {}), + onEngineEvent: vi.fn((event: string, handler: (payload: unknown) => void) => { + events.handlers.set(event, handler); + return Promise.resolve(() => events.handlers.delete(event)); + }), + }; +}); + +import { CMD, EVT, engineInvoke, engineTry, setShadowTrayStatus } from "../lib/engine"; +import { parseDomains, readShadowStatus } from "../lib/shadowMode"; +import { + ShadowModeCard, + ShadowModeIndicator, + ShadowPromptBanner, + useShadowMode, +} from "./ShadowMode"; + +const NOTICE = { + version: "2026-10-17", + sha256: "a".repeat(64), + title: "Turn on shadow mode?", + intro: "Shadow mode watches for a task you repeat.", + sections: [ + { heading: "What stays on this computer", items: ["Nothing leaves this computer."] }, + ], + acknowledge_label: "Turn on shadow mode", + decline_label: "Not now", + purge_and_disable_label: "Delete everything and turn off", + text: "", +}; + +const OFF = { ...readShadowStatus({ available: true }), available: true }; +const ON = { + ...OFF, + active: true, + acknowledged_at: 1_000_000, + epoch_ends_at: 1_086_400, + keys_in_keychain: true, + watches: "recordings" as const, +}; +const PROMPT = { + motif_id: "motif-abc", + count: 6, + title: "OpenAdapt noticed a repeated task", + body: "You did this 6 times today.", + actions: [ + { id: "review_draft" as const, label: "Review draft" }, + { id: "ignore" as const, label: "Ignore" }, + ], +}; + +let status: unknown = OFF; + +beforeEach(() => { + status = OFF; + events.handlers.clear(); + vi.mocked(engineTry).mockImplementation(async (command, _params, fallback) => + command === CMD.SHADOW_STATUS ? status : fallback, + ); + vi.mocked(engineInvoke).mockReset(); + vi.mocked(setShadowTrayStatus).mockClear(); +}); + +afterEach(cleanup); + +describe("ShadowModeCard", () => { + it("turns on only from the notice's acknowledge button, with the notice hash", async () => { + vi.mocked(engineInvoke).mockImplementation(async (command) => { + if (command === CMD.SHADOW_NOTICE) return NOTICE; + if (command === CMD.SHADOW_ENABLE) return ON; + throw new Error(`unexpected ${command}`); + }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Read the notice to turn on" })); + expect((await screen.findByRole("dialog")).textContent).toContain("Nothing leaves this computer."); + expect(engineInvoke).not.toHaveBeenCalledWith(CMD.SHADOW_ENABLE, expect.anything()); + + fireEvent.click(screen.getByRole("button", { name: "Turn on shadow mode" })); + await waitFor(() => + expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_ENABLE, { + notice_sha256: NOTICE.sha256, + acknowledged: true, + }), + ); + expect(await screen.findByText("On")).toBeTruthy(); + expect(screen.getByText(/in your system keychain/)).toBeTruthy(); + }); + + it("declining the notice changes nothing", async () => { + vi.mocked(engineInvoke).mockResolvedValue(NOTICE); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Read the notice to turn on" })); + fireEvent.click(await screen.findByRole("button", { name: "Not now" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(engineInvoke).toHaveBeenCalledTimes(1); + }); + + it("deletes everything and turns off in one click", async () => { + status = ON; + vi.mocked(engineInvoke).mockResolvedValue(OFF); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete everything and turn off" })); + await waitFor(() => expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_PURGE_AND_DISABLE)); + expect(await screen.findByText(/Everything it kept on this computer is deleted/)).toBeTruthy(); + }); + + it("saves the work sites as a cleaned list", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ ...OFF, work_domains: ["erp.example.com"] }); + render(); + const box = await screen.findByLabelText("Work sites"); + fireEvent.change(box, { target: { value: "https://ERP.example.com/x, mail.example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Save work sites" })); + await waitFor(() => + expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_SET_WORK_DOMAINS, { + domains: ["erp.example.com", "mail.example.com"], + }), + ); + }); + + it("says so when the engine has no shadow mode", async () => { + status = null; + render(); + expect(await screen.findByText(/doesn't include shadow mode/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /turn on/i })).toBeNull(); + }); +}); + +describe("indicator and prompts", () => { + it("shows the indicator only while on", () => { + const { container, rerender } = render(); + expect(container.textContent).toBe(""); + rerender(); + expect(screen.getByRole("status").textContent).toContain("Shadow mode on"); + }); + + it("offers Review draft and Ignore with generic text, and runs nothing", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ + motif_id: PROMPT.motif_id, + action: "review_draft", + next: "This is a draft, not an automation.", + }); + render(); + expect(screen.getByText("You did this 6 times today.")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Review draft" })); + await waitFor(() => + expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_DECIDE, { + motif_id: PROMPT.motif_id, + action: "review_draft", + }), + ); + expect(await screen.findByText("This is a draft, not an automation.")).toBeTruthy(); + }); + + it("shows no prompt while shadow mode is off", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("follows engine events and keeps the tray in step", async () => { + function Probe() { + const { status: s } = useShadowMode({ syncTray: true }); + return {`${s.active}:${s.pending.length}`}; + } + render(); + await waitFor(() => expect(events.handlers.has(EVT.SHADOW_STATE)).toBe(true)); + act(() => events.handlers.get(EVT.SHADOW_STATE)?.(ON)); + act(() => events.handlers.get(EVT.SHADOW_PROMPT)?.(PROMPT)); + act(() => events.handlers.get(EVT.SHADOW_PROMPT)?.({ motif_id: "x", leaked: "Doe, John" })); + expect(screen.getByTestId("probe").textContent).toContain("true:1"); + await waitFor(() => expect(setShadowTrayStatus).toHaveBeenLastCalledWith(true)); + }); +}); + +describe("parseDomains", () => { + it("splits, lowercases, and strips schemes and paths", () => { + expect(parseDomains(" A.com\nhttps://b.com/path, a.com ")).toEqual(["a.com", "b.com"]); + }); +}); diff --git a/src/ui/ShadowMode.tsx b/src/ui/ShadowMode.tsx new file mode 100644 index 0000000..1a9b5c8 --- /dev/null +++ b/src/ui/ShadowMode.tsx @@ -0,0 +1,312 @@ +// Shadow mode UI: the notice a person must acknowledge, the Settings card, the +// header indicator, and the prompt banner. Shadow mode is off by default and +// runs only on this computer. It offers drafts; it never runs anything. +import { useCallback, useEffect, useState } from "react"; +import { + CMD, + EVT, + engineInvoke, + engineTry, + onEngineEvent, + setShadowTrayStatus, +} from "../lib/engine"; +import { + SHADOW_OFF, + formatWhen, + isPrompt, + parseDomains, + readShadowStatus, + type ShadowDecision, + type ShadowNotice, + type ShadowPrompt, + type ShadowStatus, +} from "../lib/shadowMode"; +import { Button, Callout, Card, CardHead, Field, Pill, StatusDot } from "./primitives"; + +/** Shadow mode's status, kept current from engine events. */ +export function useShadowMode({ syncTray = false }: { syncTray?: boolean } = {}) { + const [status, setStatus] = useState(SHADOW_OFF); + + const refresh = useCallback(async () => { + setStatus(readShadowStatus(await engineTry(CMD.SHADOW_STATUS, {}, null))); + }, []); + + useEffect(() => { + void refresh(); + // Outside the desktop shell there are no events; the status read above + // still answers, so a failed subscription is not an error. + const quiet = (p: Promise<() => void>) => p.catch(() => () => {}); + const unlisteners = [ + quiet(onEngineEvent(EVT.SHADOW_STATE, (payload) => setStatus(readShadowStatus(payload)))), + quiet( + onEngineEvent(EVT.SHADOW_PROMPT, (payload) => { + if (!isPrompt(payload)) return; + setStatus((s) => ({ + ...s, + pending: [...s.pending.filter((p) => p.motif_id !== payload.motif_id), payload], + })); + }), + ), + ]; + return () => { + for (const u of unlisteners) void u.then((fn) => fn()); + }; + }, [refresh]); + + useEffect(() => { + if (syncTray) void setShadowTrayStatus(status.active); + }, [syncTray, status.active]); + + return { status, setStatus, refresh }; +} + +/** The header indicator. Shows only while shadow mode is on. */ +export function ShadowModeIndicator({ status }: { status: ShadowStatus }) { + if (!status.active) return null; + return ( + + + Shadow mode on + + ); +} + +/** One offer per repeated task: Review draft or Ignore. Generic text only. */ +export function ShadowPromptBanner({ + status, + onDecided, +}: { + status: ShadowStatus; + onDecided?: (decision: ShadowDecision) => void; +}) { + const [busy, setBusy] = useState(null); + const [note, setNote] = useState(null); + const [error, setError] = useState(null); + if (!status.active || (status.pending.length === 0 && !note && !error)) return null; + + async function decide(prompt: ShadowPrompt, action: string) { + setBusy(prompt.motif_id); + setError(null); + try { + const result = await engineInvoke(CMD.SHADOW_DECIDE, { + motif_id: prompt.motif_id, + action, + }); + setNote(action === "review_draft" ? (result.next ?? "The draft is saved.") : null); + onDecided?.(result); + } catch (e) { + setError(String(e)); + } finally { + setBusy(null); + } + } + + return ( +
+ {status.pending.map((prompt) => ( + +

{prompt.body}

+
+ {prompt.actions.map((a) => ( + + ))} +
+
+ ))} + {note && ( + +

{note}

+
+ )} + {error && ( + + {error} + + )} +
+ ); +} + +/** The notice. Shadow mode turns on only from its acknowledge button. */ +export function ShadowNoticeDialog({ + notice, + busy, + onAcknowledge, + onDecline, +}: { + notice: ShadowNotice; + busy?: boolean; + onAcknowledge: () => void; + onDecline: () => void; +}) { + return ( +
+

{notice.title}

+

{notice.intro}

+ {notice.sections.map((section) => ( +
+

{section.heading}

+
    + {section.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +

Notice version {notice.version}

+
+ + +
+
+ ); +} + +/** The Settings card: status, the notice, work domains, and delete-and-off. */ +export function ShadowModeCard() { + const { status, setStatus } = useShadowMode(); + const [notice, setNotice] = useState(null); + const [domains, setDomains] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const savedDomains = status.work_domains.join("\n"); + useEffect(() => { + setDomains(savedDomains); + }, [savedDomains]); + + async function run(action: () => Promise, done?: string) { + setBusy(true); + setError(null); + setMessage(null); + try { + const result = await action(); + if (result && typeof result === "object" && "available" in result) { + setStatus(readShadowStatus(result)); + } + if (done) setMessage(done); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + const openNotice = () => + run(async () => setNotice(await engineInvoke(CMD.SHADOW_NOTICE))); + + const acknowledge = () => + run(async () => { + if (!notice) return null; + const result = await engineInvoke(CMD.SHADOW_ENABLE, { + notice_sha256: notice.sha256, + acknowledged: true, + }); + setNotice(null); + return result; + }); + + const purge = () => + run( + () => engineInvoke(CMD.SHADOW_PURGE_AND_DISABLE), + "Shadow mode is off. Everything it kept on this computer is deleted.", + ); + + const saveDomains = () => + run( + () => engineInvoke(CMD.SHADOW_SET_WORK_DOMAINS, { domains: parseDomains(domains) }), + "Work sites saved.", + ); + + return ( + + + {!status.available ? ( + + This version of the OpenAdapt engine doesn't include shadow mode. + + ) : ( + <> +
+ + {status.active ? "On" : "Off"} + + {status.active && ( + + Turned on {formatWhen(status.acknowledged_at)}. Watches your recordings in + Desktop. Keeps the last {status.buffer_hours} hours, encrypted. The key is{" "} + {status.keys_in_keychain ? "in your system keychain" : "in memory only"} and is + replaced, with everything recorded, by {formatWhen(status.epoch_ends_at)}. + + )} +
+ + {notice ? ( + void acknowledge()} + onDecline={() => setNotice(null)} + /> + ) : status.active ? ( +
+ +
+ ) : ( +
+ +
+ )} + + +