diff --git a/README.md b/README.md index f895851..1457521 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,21 @@ stays paused. [docs/DECISION_PORTAL.md](https://github.com/OpenAdaptAI/openadapt-desktop/blob/main/docs/DECISION_PORTAL.md) covers pairing, what the phone can see, and how a decision is applied. +## Shadow mode + +Shadow mode is off unless you turn it on in **Settings**, after reading a +notice. While it's on, the header and the tray say so. It notices a task you +repeat in your recordings and offers to make a draft from it ("You did this 6 +times today."). Its record is an encrypted buffer in the app-data folder, +which isn't synced, cleared with its key at least every 24 hours and whenever +you turn shadow mode off or quit. **Pause** stops it recording, and **Delete +everything and turn off** erases everything it kept in one click. OpenAdapt +never sends shadow mode data off your computer, though backup, sync, and +security software on the computer can still copy local files. +A draft never runs; a person still records the task and admits the automation. +[docs/SHADOW_MODE.md](https://github.com/OpenAdaptAI/openadapt-desktop/blob/main/docs/SHADOW_MODE.md) +has the details. + ## Engine command line The engine also runs without the app. In a source checkout, put `uv run` in @@ -196,8 +211,9 @@ setting. | `storage`, `health`, `cleanup`, `config` | Inspect and maintain local storage and settings | | `upload`, `backends` | Older upload commands. `upload --backend hosted_ingest` runs `push`; upload to your own S3 storage is turned off in this release. | -Recordings stay on your computer unless you choose to send one to OpenAdapt -Cloud with `push`. +OpenAdapt doesn't send a recording anywhere unless you choose to send it to +OpenAdapt Cloud with `push`. Backup, sync, and endpoint security software on +the computer can still copy local files. ## Develop diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index 9b39788..5151902 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -101,6 +101,32 @@ deployment HIPAA-compliant. `PLAYWRIGHT_BROWSERS_PATH` at a version-matched prebundle. Native, RDP, and Citrix workflows never use that path. +## Shadow mode + +Shadow mode is off by default and has no remote, admin, policy, or +configuration-file switch. A person turns it on in the Desktop window after +accepting a versioned notice; the engine checks the notice hash and writes the +acceptance time to an owner-only local file. The header and the tray show +when it's on. Its record is an encrypted buffer in the platform's app-data +folder (Application Support on macOS, LocalAppData on Windows), and shadow +mode refuses to start if that folder resolves inside a known sync root such as +Documents, Desktop, iCloud, OneDrive, or Dropbox. The key is in the OS keychain +(or in memory) and is destroyed, with the buffer, at least every 24 hours, +when shadow mode is turned off, and when the app quits; a buffer left by a +crash is erased at the next start. Shadow mode makes no network connections, +and its commands aren't accepted on the local socket. OpenAdapt never sends +shadow mode data off the computer. These controls stop OpenAdapt, an +employer, and other apps from turning it on or reading it through OpenAdapt; +they don't stop an OS administrator, software running as the user, or on a +managed computer IT administrators and endpoint security or backup tools, from +inspecting or copying local files or memory while shadow mode is on. The +notice says so, says the record may include personal, confidential, or health +information, and advises pausing before viewing sensitive material. **Pause** +is in the header, the tray, and Settings. **Turn off** erases the buffer, the +key, and drafts; **Delete everything and turn off** also removes the ignore +list and withdraws the +acceptance. [SHADOW_MODE.md](SHADOW_MODE.md) has the details. + ## Phone decisions A paired phone receives a request for a decision. A tap doesn't approve the diff --git a/docs/DECISION_PORTAL.md b/docs/DECISION_PORTAL.md index 5748815..33626ae 100644 --- a/docs/DECISION_PORTAL.md +++ b/docs/DECISION_PORTAL.md @@ -223,8 +223,8 @@ copy and the receipt copy that it derives from the closed result enums. It does not call a model to write a question or interpret an answer. The hosted outbound lane has a narrower remote-safe projection and does not -carry the local evidence image. A custom entity class stays local unless it is -in the reviewed remote-safe vocabulary; otherwise the remote task uses the +carry the local evidence image. The remote task names a custom entity class +only if it is in the reviewed remote-safe vocabulary; otherwise it uses the signed neutral `record` or `item` fallback. The primary screen is intentionally short: one question, the delivery and risk diff --git a/docs/SHADOW_MODE.md b/docs/SHADOW_MODE.md new file mode 100644 index 0000000..3f32b57 --- /dev/null +++ b/docs/SHADOW_MODE.md @@ -0,0 +1,183 @@ +# Shadow mode + +Shadow mode notices a task you repeat and offers to turn it into a draft. It is +off by default. Only the person at the computer can turn it on, from +**Settings → Shadow mode**, after reading a notice. OpenAdapt never sends +shadow mode data off this computer, and shadow mode never runs a task. + +The watcher itself lives in openadapt-capture (`openadapt_capture.shadow`). Its +design, the buffer format, and the privacy rules are in that repository's +`docs/SHADOW_MINING.md`. This page covers what Desktop adds: the notice, the +controls, where the record is kept, the indicators, and the commands. + +## Turning it on + +1. Open **Settings** and find the **Shadow mode** card. +2. Select **Read the notice to turn on**. The notice covers what shadow mode + captures (and that it may include personal, confidential, or health + information shown on screen), why, how long it is kept, how to erase it, + and who can access it. +3. Select **Turn on shadow mode** to accept, or **Not now** to leave it off. + +Desktop sends the notice's SHA-256 hash with the acceptance. The engine turns +shadow mode on only if that hash matches the notice it has now, and it writes +the time of the acceptance to a local file that only your user account can +read. If the notice text changes in a later version, shadow mode needs a new +acceptance. + +After a restart, shadow mode comes back on if it was on when Desktop quit and +the accepted notice is still the current one. It starts with an empty buffer +and a new key: the old buffer was erased when Desktop quit. If it was paused, +it stays paused. + +## Where the record is kept + +The buffer, the notice acceptance, the work-site list, drafts, and the ignore +list live in the platform's local app-data folder, not in the Desktop data +folder, which you may have moved to a synced place: + +| Platform | Folder | +| --- | --- | +| macOS | `~/Library/Application Support/ai.openadapt.desktop/shadow` | +| Windows | `%LOCALAPPDATA%\ai.openadapt.desktop\shadow` | +| Linux | `$XDG_DATA_HOME/ai.openadapt.desktop/shadow`, or `~/.local/share/...` | + +Shadow mode refuses to start if that folder, after following symlinks, +resolves inside a folder a sync service copies: Documents, Desktop, iCloud +Drive, `~/Library/CloudStorage` (OneDrive, Dropbox, Google Drive, and Box on +macOS), the folders the Windows `OneDrive` variables or Dropbox's own settings +name, and folders named after common sync clients. The Settings card shows +the folder in use. + +The buffer and its key are erased: + +- at least every 24 hours; +- when you select **Turn off** or **Delete everything and turn off**; +- when Desktop quits. The window asks the engine to erase them, and waits up + to 2 seconds before it stops the engine; +- after a crash, at the next start, before a new buffer begins. + +The check can't see every backup or sync tool. Time Machine, File History, a +sync client pointed at an unusual folder, or endpoint security software can +still copy local files. The notice says so. + +## Knowing it's on + +While shadow mode is on: + +- The window header shows **Shadow mode on**, or **Shadow mode paused**, with + a **Pause** or **Resume** button. +- The menu-bar tray menu reads **Shadow mode: on** or **Shadow mode: paused**, + and the tray tooltip says so. On macOS **Shadow** (or **Shadow paused**) + also appears next to the tray icon. Until the engine answers, the tray reads + **Shadow mode: checking…**, never "off". +- The Settings card shows when it was turned on, whether the key is in the + system keychain or only in memory, when the buffer and key will be erased, + and the folder that holds them. + +The tray status line can't be clicked to turn shadow mode on. **Shadow mode +settings…** in the tray menu opens the Settings screen instead. + +## Pausing it + +**Pause** stops shadow mode recording until you select **Resume**. It's in +the window header, the tray menu (**Pause shadow mode**), and the Settings +card. Pause before you view sensitive material. While paused, steps are +dropped as they arrive, and typing in progress when you paused is dropped +too. What was already in the buffer stays until it's erased as usual. A pause +lasts across a restart. + +## What it watches + +In this version, shadow mode sees only the steps of recordings you make in +Desktop. While a recording runs, the recorder hands each step to shadow mode +as well as saving it. Shadow mode doesn't run a separate listener when no +recording is running. + +Browser steps count only on the sites in **Work sites**. On any other site, +shadow mode notes only that a browser was in use. The list is empty until you +add a site, and OpenAdapt doesn't send it anywhere. + +## The offer + +About once a minute, shadow mode looks for a task you did several times +today. When it finds one, the window shows: + +> OpenAdapt noticed a repeated task +> You did this 6 times today. + +with **Review draft** and **Ignore**. The text never names an app, a site, a +field, or a value. + +- **Review draft** saves a draft file in `drafts` in the shadow folder, + readable only by your user account. It isn't encrypted and is kept until + you turn shadow mode off. The draft is not an automation. + To automate the task, record it once; a person reviews and admits the + automation before it can run. +- **Ignore** stops the offer for that task. + +## Turning it off + +- **Turn off** erases the encrypted buffer, the key, and any saved drafts. It + keeps the ignore list, so an ignored task isn't offered again. +- **Delete everything and turn off** also erases the ignore list, and records + that the acceptance was withdrawn. + +Either way, turning shadow mode on again goes through the notice. If the +system keychain refuses to delete the key, the card says so, and the engine +tries again at the next start. + +## What others can and can't do + +OpenAdapt, your employer, and other apps can't turn shadow mode on or read its +record through OpenAdapt: + +- There is no configuration file setting, environment variable, Cloud policy, + or admin setting that turns shadow mode on. It is not part of + `EngineConfig`. +- The shadow commands are available only to the Desktop window. The local + socket that other OpenAdapt apps use doesn't accept them. +- Shadow mode makes no network connections. OpenAdapt never sends shadow mode + data off this computer, and doesn't keep it in a folder that is synced by + default. + +That doesn't protect the record from the computer itself. The record is +encrypted with a key that is destroyed at least every 24 hours, but the key +sits in your keychain (or in memory) while shadow mode is on. Someone with +administrator access to this computer, or software running under your +account, could still inspect the record while it's on. On a work-managed +computer, that includes IT administrators, and endpoint security or backup +tools, which could inspect or copy local files or memory. The notice says +so. + +## Engine commands + +| Command | What it does | +| --- | --- | +| `shadow_status` | On, paused, or off, the acceptance time, work sites, key location, the folder in use, and pending offers | +| `shadow_notice` | The notice, with its version and SHA-256 hash | +| `shadow_enable` | Turns shadow mode on. Needs `notice_sha256` and `acknowledged: true` | +| `shadow_pause` | Stops recording until `shadow_unpause` | +| `shadow_unpause` | Resumes recording after a pause | +| `shadow_turn_off` | Erases the buffer, the key, and drafts, and turns shadow mode off | +| `shadow_purge_and_disable` | Deletes everything shadow mode kept, including the ignore list, and turns it off | +| `shadow_set_work_domains` | Replaces the work-site list (`domains`) | +| `shadow_decide` | Answers an offer: `motif_id` and `action` (`review_draft` or `ignore`) | +| `shadow_scan` | Looks for repeated tasks now | + +Events: `shadow_state` carries the same fields as `shadow_status`, and +`shadow_prompt` carries one offer (`motif_id`, `count`, `title`, `body`, +`actions`). The quit-time `shutdown` command, sent by the window's shell, +erases the buffer and key before the engine stops. + +## Requirements and limits + +- Shadow mode needs an openadapt-capture release that includes + `openadapt_capture.shadow`, with its `shadow` extra (`cryptography` and + `keyring`). With an older release, the Settings card says shadow mode isn't + available. +- Shadow mode sees only Desktop recordings. An always-on listener is not built. +- If the system keychain can't be reached, the key is kept in memory only. +- Every restart starts a new buffer, so a task is counted from the start of + the current session. +- A buffer left by a crash stays on disk, encrypted, until the next start. diff --git a/openadapt_desktop/cli.py b/openadapt_desktop/cli.py index 42e3e9a..40fb66a 100644 --- a/openadapt_desktop/cli.py +++ b/openadapt_desktop/cli.py @@ -1013,7 +1013,7 @@ def main(argv: list[str] | None = None) -> None: config = EngineConfig() logger.remove() - logger.add(sys.stderr, level=config.log_level) + logger.add(sys.stderr, level=config.log_level, diagnose=False) engine = _init_engine(config) 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 23b34f1..03e8b32 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 @@ -364,8 +375,87 @@ 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_turn_off": self.shadow_turn_off, + "shadow_pause": self.shadow_pause, + "shadow_unpause": self.shadow_unpause, + "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_turn_off(self, **params: Any) -> dict: + """Turn shadow mode off, erasing its buffer, key, and drafts.""" + return self.shadow.turn_off() + + def shadow_pause(self, **params: Any) -> dict: + """Stop shadow mode recording until it's resumed.""" + return self.shadow.pause() + + def shadow_unpause(self, **params: Any) -> dict: + """Resume shadow mode recording after a pause.""" + return self.shadow.unpause() + + def close_shadow(self) -> None: + """Clean exit: erase the shadow buffer and its key, if one is open.""" + if self._shadow is not None: + self._shadow.close() + + 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/ipc.py b/openadapt_desktop/ipc.py index 7c3b607..c4631fc 100644 --- a/openadapt_desktop/ipc.py +++ b/openadapt_desktop/ipc.py @@ -35,6 +35,10 @@ #: dispatcher's approval watches and Flow's approval files. CONCURRENT_COMMANDS = frozenset({"get_item_approval", "answer_item_approval"}) +#: Sent by the Tauri shell when the app quits. Handled on the reader thread, +#: ahead of queued commands, because the shell kills the engine shortly after. +SHUTDOWN_COMMAND = "shutdown" + class IPCHandler: """Handles JSON line protocol communication with the Tauri shell. @@ -114,6 +118,11 @@ def work() -> None: except json.JSONDecodeError: logger.error("Invalid JSON received: {line}", line=line) continue + if isinstance(message, dict) and message.get("cmd") == SHUTDOWN_COMMAND: + # The shell is quitting and kills the engine soon after. + # Erase the shadow buffer first, then answer and stop. + self._shutdown(message.get("id")) + break try: if isinstance(message, dict) and message.get("cmd") in CONCURRENT_COMMANDS: self._dispatch(message) @@ -125,6 +134,13 @@ def work() -> None: pending.put(None) worker.join() + def _shutdown(self, msg_id: str | None) -> None: + try: + self.dispatcher.close_shadow() + except Exception: + logger.exception("Shadow mode didn't close cleanly") + self.send_response(msg_id, {"stopping": True}) + def _dispatch(self, message: dict) -> None: """Dispatch a command message to the appropriate handler. diff --git a/openadapt_desktop/main.py b/openadapt_desktop/main.py index a9bfddf..9157ee4 100644 --- a/openadapt_desktop/main.py +++ b/openadapt_desktop/main.py @@ -160,6 +160,21 @@ def _run_embedded_playwright() -> None: playwright_main() +ENGINE_LOG_FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level:<8} | {name}:{function}:{line} - {message}" + + +def configure_engine_logging(level: str) -> None: + """Send engine logs to stderr, without variable values in tracebacks. + + loguru's ``diagnose`` mode prints the value of every variable on each + traceback line. A failure while handling a recording or shadow-mode event + would then copy typed text, labels, or URLs into the log, so it is off. + """ + + logger.remove() + logger.add(sys.stderr, level=level, format=ENGINE_LOG_FORMAT, diagnose=False) + + def main() -> None: """Initialize the engine and start the appropriate mode.""" from openadapt_desktop.private_fs import apply_private_umask @@ -188,12 +203,7 @@ def main() -> None: # IPC mode: piped stdin from Tauri shell config = EngineConfig() - logger.remove() - logger.add( - sys.stderr, - level=config.log_level, - format="{time:YYYY-MM-DD HH:mm:ss} | {level:<8} | {name}:{function}:{line} - {message}", - ) + configure_engine_logging(config.log_level) logger.info("OpenAdapt Desktop Engine starting (v{version})", version=ENGINE_VERSION) logger.info("Storage mode: {mode}", mode=config.storage_mode) @@ -245,6 +255,13 @@ def main() -> None: except Exception: logger.exception("Runner loop failed to start (lane stays off)") + # Start shadow mode now, not on the window's first status request, so a + # buffer left by a crash is erased at once and a standing opt-in resumes. + try: + _ = handler.dispatcher.shadow + except Exception as exc: + logger.warning("Shadow mode didn't start: {}", type(exc).__name__) + try: handler.run() except KeyboardInterrupt: @@ -257,6 +274,10 @@ def main() -> None: services.runner.stop() socket_server.stop() monitor.stop() + try: + handler.dispatcher.close_shadow() + except Exception as exc: + logger.warning("Shadow mode didn't close cleanly: {}", type(exc).__name__) db.close() logger.info("Engine stopped") diff --git a/openadapt_desktop/portal/flow_client.py b/openadapt_desktop/portal/flow_client.py index 80c9057..898454c 100644 --- a/openadapt_desktop/portal/flow_client.py +++ b/openadapt_desktop/portal/flow_client.py @@ -117,9 +117,9 @@ class FlowConsoleClient: Args: port: The loopback port the console bound. - access_token: The console's bearer capability. It stays on this - machine: a paired phone receives a portal session token instead and - never learns this value. + access_token: The console's bearer capability. The portal never + sends it to a paired phone, which receives a portal session token + instead and never learns this value. csrf_token: The console's CSRF token, required for mutations. client: Injected HTTP client (tests supply a transport-backed one). """ diff --git a/openadapt_desktop/shadow_mode.py b/openadapt_desktop/shadow_mode.py new file mode 100644 index 0000000..1e9a717 --- /dev/null +++ b/openadapt_desktop/shadow_mode.py @@ -0,0 +1,544 @@ +"""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. +* **Off erases it.** :meth:`turn_off` erases the buffer, its key, and any + saved drafts. :meth:`purge_and_disable` also erases the ignore list and + records the revocation time. :meth:`pause` stops recording until + :meth:`unpause`, and stays paused across a restart. +* **Cleared on exit.** :meth:`close` (a clean engine exit, or ``atexit``) + erases the buffer and its key. A buffer left by a crash is erased at the + next start, before a new one begins. +* **Unsynced app data.** Everything lives in the platform's app-data folder + (``~/Library/Application Support/ai.openadapt.desktop/shadow`` on macOS, + ``%LOCALAPPDATA%\\ai.openadapt.desktop\\shadow`` on Windows), not under + ``data_dir``, which a person may point at a synced folder. Shadow mode + refuses to start if that folder resolves inside a known sync root. + OpenAdapt never sends shadow mode data off this computer, and nothing here + opens a network connection. OS backup, sync, or endpoint security tools + can still copy local files; the notice says so. + +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 atexit +import json +import os +import shutil +import sys +import threading +import time +import weakref +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 +APP_ID = "ai.openadapt.desktop" +_BUFFER_FILES = ("buffer.db", "buffer.db-journal", "buffer.db-wal", "buffer.db-shm") + + +def default_shadow_dir() -> Path: + """Desktop's shadow folder in the platform's local, unsynced app data.""" + + try: + from openadapt_capture.shadow.storage import app_data_dir + except Exception: # an older Capture; same rule, computed here + home = Path.home() + if sys.platform == "darwin": + base = home / "Library" / "Application Support" + elif sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local") + else: + base = Path(os.environ.get("XDG_DATA_HOME") or home / ".local" / "share") + return base / APP_ID / "shadow" + return app_data_dir(APP_ID) / "shadow" + + +def _write_private(path: Path, text: str) -> None: + """Write ``text`` to a file that is owner-only from the moment it exists.""" + + tmp = path.with_name(f".{path.name}.tmp") + fd = os.open(tmp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as handle: + handle.write(text) + make_private_file(tmp) + os.replace(tmp, path) + + +def _close_at_exit(ref: weakref.ref) -> None: + service = ref() + if service is not None: + service.close() + + +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 _CurrentObserver: + """What a recording's live tap feeds: whichever observer is current. + + A tap lives for one recording, but the observer can be replaced while it + runs (a work-site change) or shut (Delete everything and turn off). Each + event goes to the observer that is current when it arrives, or nowhere. + """ + + def __init__(self, service: ShadowModeService) -> None: + self._service = service + + def observe(self, event: Any) -> None: + with self._service._lock: + observer = self._service._observer + if observer is not None and observer.enabled: + observer.observe(event) + + +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, + *, + shadow_dir: Path | 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 = Path(shadow_dir) if shadow_dir is not None else default_shadow_dir() + # Earlier builds of this branch kept shadow files under data_dir. + self._legacy_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 + self._atexit_registered = False + + # ------------------------------------------------------------- paths + + def _check_location(self) -> None: + """Refuse to keep anything in a folder a sync client may copy.""" + + try: + from openadapt_capture.shadow.storage import require_unsynced + except Exception: + return # an older Capture has no shadow mode to start anyway + require_unsynced(self.dir) + + @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(path.parent, root=self.dir) + _write_private(path, json.dumps(data, indent=2, sort_keys=True)) + + 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, + "paused": bool(active and self._observer.paused), + "location": str(self.dir), + "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: + self._check_location() + ensure_private_dir(self.dir) + ack = self._ledger().acknowledge(str(notice_sha256), now=self._clock()) + self._start(shadow, ack) + self._write_json(self._state_path, {"active": True, "paused": False}) + self._emit_state() + return self.status() + + def resume(self) -> bool: + """Turn shadow mode back on at engine start if the person left it on. + + Whatever an earlier run left (after a crash, or from an older build) + is erased first, so each run starts with an empty buffer and a new + key. It needs a standing acknowledgement of the current notice; a + changed notice must be acknowledged again. A pause carries over. + """ + + self._discard_leftovers() + state = self._read_json(self._state_path, {}) + if not state.get("active"): + return False + try: + shadow = _capture_shadow() + ack = self._ledger().current() + self._check_location() + except ShadowModeUnavailable: + ack = None + except ValueError as exc: # ShadowStorageRefused + logger.warning("shadow mode can't resume here: {}", type(exc).__name__) + ack = None + if ack is None: + # It was left on, but can't come back on: the notice changed, this + # Capture has no shadow mode, or the folder is synced. Don't leave + # anything behind waiting for a resume that will never happen. + logger.info("shadow mode can't resume; deleting what it kept") + self.purge_and_disable() + return False + with self._lock: + self._start(shadow, ack) + if state.get("paused"): + self._observer.pause() + self._emit_state() + return True + + def _discard_leftovers(self) -> None: + """Erase a buffer and key that no running observer owns.""" + + with self._lock: + if self._observer is not None: + return + for folder in {self.dir, self._legacy_dir}: + for name in _BUFFER_FILES: + try: + (folder / name).unlink() + except FileNotFoundError: + pass + if self._legacy_dir != self.dir and self._legacy_dir.is_dir(): + shutil.rmtree(self._legacy_dir, ignore_errors=True) + self._purge_without_observer() + + 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() + if not self._atexit_registered: + self._atexit_registered = True + atexit.register(_close_at_exit, weakref.ref(self)) + + def turn_off(self) -> dict[str, Any]: + """Turn shadow mode off: erase the buffer, its key, and any drafts. + + The acknowledgement and the ignore list stay. Turning it back on + still goes through the notice. + """ + + self._stop_scanner() + with self._lock: + observer, self._observer = self._observer, None + key_deleted = True + if observer is not None: + key_deleted = observer.shutdown() is not False + self._pending.clear() + shutil.rmtree(self._drafts_dir, ignore_errors=True) + for name in _BUFFER_FILES: + try: + (self.dir / name).unlink() + except FileNotFoundError: + pass + if not key_deleted or observer is None: + key_deleted = self._purge_without_observer() + self._write_json(self._state_path, {"active": False}) + self._emit_state() + return {**self.status(), "key_deleted": key_deleted} + + def pause(self) -> dict[str, Any]: + """Stop recording until :meth:`unpause`. Typing in progress is dropped.""" + + with self._lock: + if self._observer is None or not self._observer.enabled: + raise RuntimeError("shadow mode is off") + self._observer.pause() + self._write_json(self._state_path, {"active": True, "paused": True}) + self._emit_state() + return self.status() + + def unpause(self) -> dict[str, Any]: + with self._lock: + if self._observer is None or not self._observer.enabled: + raise RuntimeError("shadow mode is off") + self._observer.unpause() + self._write_json(self._state_path, {"active": True, "paused": False}) + self._emit_state() + return self.status() + + 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: + key_deleted = observer.purge_and_disable() is not False + else: + key_deleted = self._purge_without_observer() + self._pending.clear() + shutil.rmtree(self._drafts_dir, ignore_errors=True) + for name in (*_BUFFER_FILES, "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() + # The buffer is gone either way. If the keychain refused to delete the + # key, say so: it's retried on the next start, but it isn't gone yet. + return {**self.status(), "key_deleted": key_deleted} + + def _purge_without_observer(self) -> bool: + try: + from openadapt_capture.shadow.keys import destroy_keys + except Exception: + return True # no shadow mode, so no key was ever made here + return bool(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 + paused = self._observer.paused + # The new allowlist starts a new buffer; the old one is erased. + self._observer.shutdown() + self._observer = None + self._start(shadow, ack) + if paused: + self._observer.pause() + 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.dir) + path = self._drafts_dir / f"{export['motif_id']}.episodes.json" + _write_private(path, json.dumps(export, indent=1, sort_keys=True)) + 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(_CurrentObserver(self))).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: + """Clean exit: erase the buffer and its key. Safe to call twice. + + Shadow mode stays on in ``state.json``, so the next start begins a + new, empty buffer. Drafts and the ignore list stay. + """ + + self._stop_scanner() + with self._lock: + observer, self._observer = self._observer, None + if observer is not None: + try: + observer.shutdown() + except Exception as exc: + logger.warning("shadow shutdown failed: {}", type(exc).__name__) + for name in _BUFFER_FILES: + try: + (self.dir / name).unlink() + except OSError: + pass diff --git a/pyproject.toml b/pyproject.toml index 74aaa60..d212de1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,13 @@ dependencies = [ # 1.2.1 rather than 1.2.0 because 1.2.0 and every earlier release resolve # ``capture transcribe``'s default ``auto`` backend to a hosted recognizer # and upload the raw microphone waveform; 1.2.1 removed that path. + # 1.3.0 because shadow mode (``openadapt_desktop/shadow_mode.py``) needs + # ``openadapt_capture.shadow``: the recorder live tap, the consent notice, + # the encrypted bounded buffer, and on-device motif mining. Below 1.3.0 + # the package is absent and shadow mode cannot start. # ``tests/test_capture_runtime_contract.py`` asserts this floor against the # bundled Flow distribution's own declared extra, so it cannot drift back. - "openadapt-capture>=1.2.1", + "openadapt-capture>=1.3.0", "openadapt-privacy>=1.0.0", # Cross-surface status/target contract used by the presentation exporter # and by the generated TypeScript projection checked into src/. 0.17.0 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/sidecar.rs b/src-tauri/src/sidecar.rs index 89f5a95..2d14d54 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -68,6 +68,9 @@ pub struct SidecarInner { running: AtomicBool, seq: AtomicU64, runtime_paths: Mutex>, + /// Set when the engine answers the quit-time `shutdown` command, after + /// it has erased the shadow-mode buffer. + shutdown_acked: AtomicBool, } /// Managed-state wrapper so Tauri commands can reach the sidecar. @@ -266,18 +269,43 @@ fn route_line(app: &AppHandle, inner: &Arc, line: &str) { } if let Some(id) = resp.id.as_deref() { + if id == SHUTDOWN_ID { + inner.shutdown_acked.store(true, Ordering::SeqCst); + return; + } if let Some(tx) = inner.pending.lock().unwrap().remove(id) { let _ = tx.send(resp); } } } +const SHUTDOWN_ID: &str = "shutdown"; + +/// How long quitting waits for the engine to erase the shadow-mode buffer. +const SHUTDOWN_GRACE: Duration = Duration::from_secs(2); + /// Gracefully shut down the Python sidecar (best-effort on app quit). +/// +/// The engine erases the shadow-mode buffer and its key when it gets +/// `shutdown`, then answers. Wait for that answer, up to [`SHUTDOWN_GRACE`], +/// before killing it, so quitting clears the buffer instead of leaving it +/// for the next start. pub fn shutdown(inner: &Arc) { inner.running.store(false, Ordering::SeqCst); - if let Some(mut child) = inner.child.lock().unwrap().take() { - // Ask the engine to exit cleanly, then drop the child (kills on drop). - let _ = child.write(b"{\"id\":\"shutdown\",\"cmd\":\"shutdown\",\"params\":{}}\n"); + let child = inner.child.lock().unwrap().take(); + if let Some(mut child) = child { + inner.shutdown_acked.store(false, Ordering::SeqCst); + let sent = child + .write(b"{\"id\":\"shutdown\",\"cmd\":\"shutdown\",\"params\":{}}\n") + .is_ok(); + if sent { + let deadline = std::time::Instant::now() + SHUTDOWN_GRACE; + while !inner.shutdown_acked.load(Ordering::SeqCst) + && std::time::Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(50)); + } + } let _ = child.kill(); } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 2b6f442..291cc37 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -11,21 +11,91 @@ // // 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", "paused", "off", or "checking" until the engine answers, +// 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 +// status line is not clickable: turning shadow mode on needs the notice in +// the window, and "Shadow mode settings" opens that window. "Pause shadow +// mode" (or "Resume shadow mode") asks the window to pause or resume, so a +// person can pause it from the menu bar before viewing sensitive material. + +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 status line and pause item, kept so they can change. +pub struct ShadowTrayItem(pub Mutex, MenuItem)>>); + +/// Menu text, tooltip, and macOS title for one shadow-mode state. `active` +/// is None until the engine has reported a state. +pub fn shadow_labels( + active: Option, + paused: bool, +) -> (&'static str, &'static str, Option<&'static str>) { + match (active, paused) { + (None, _) => ("Shadow mode: checking…", TOOLTIP, None), + (Some(true), true) => ( + "Shadow mode: paused", + "OpenAdapt Desktop: shadow mode is paused", + Some("Shadow paused"), + ), + (Some(true), false) => ( + "Shadow mode: on", + "OpenAdapt Desktop: shadow mode is on", + Some("Shadow"), + ), + (Some(false), _) => ("Shadow mode: off", TOOLTIP, None), + } +} + +/// Text of the pause item, and whether it can be clicked. +pub fn shadow_pause_label(active: Option, paused: bool) -> (&'static str, bool) { + match (active, paused) { + (Some(true), true) => ("Resume shadow mode", true), + (Some(true), false) => ("Pause shadow mode", true), + _ => ("Pause shadow mode", false), + } +} + +/// 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(None, false); + let shadow = MenuItem::with_id(app, "shadow-status", label, false, None::<&str>)?; + let (pause_label, pause_enabled) = shadow_pause_label(None, false); + let shadow_pause = MenuItem::with_id( + app, + "shadow-pause", + pause_label, + pause_enabled, + 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_pause, &shadow_settings, &quit], + )?; + app.manage(ShadowTrayItem(Mutex::new(Some((shadow, shadow_pause))))); - TrayIconBuilder::with_id("openadapt-desktop-tray") - .tooltip("OpenAdapt Desktop") + TrayIconBuilder::with_id(TRAY_ID) + .tooltip(TOOLTIP) .icon( app.default_window_icon() .cloned() @@ -34,11 +104,15 @@ 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-pause" => { + use tauri::Emitter; + let _ = app.emit("tray://toggle-shadow-pause", ()); + } + "shadow-settings" => { + show_main(app); + use tauri::Emitter; + let _ = app.emit("tray://open-shadow-settings", ()); } "quit" => { app.exit(0); @@ -49,3 +123,73 @@ 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: Option, + paused: Option, +) -> Result<(), String> { + let paused = paused.unwrap_or(false); + let (label, tooltip, title) = shadow_labels(active, paused); + let (pause_label, pause_enabled) = shadow_pause_label(active, paused); + if let Some(state) = app.try_state::() { + if let Some((item, pause)) = state.0.lock().map_err(|e| e.to_string())?.as_ref() { + item.set_text(label).map_err(|e| e.to_string())?; + pause.set_text(pause_label).map_err(|e| e.to_string())?; + pause + .set_enabled(pause_enabled) + .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, shadow_pause_label}; + + #[test] + fn shadow_indicator_says_on_paused_off_or_checking() { + let (on, on_tip, on_title) = shadow_labels(Some(true), false); + assert!(on.ends_with("on") && on_tip.contains("shadow mode is on")); + assert_eq!(on_title, Some("Shadow")); + let (paused, paused_tip, _) = shadow_labels(Some(true), true); + assert!(paused.ends_with("paused") && paused_tip.contains("paused")); + let (off, _, off_title) = shadow_labels(Some(false), false); + assert!(off.ends_with("off")); + assert_eq!(off_title, None); + let (unknown, _, _) = shadow_labels(None, false); + assert!(unknown.contains("checking")); + } + + #[test] + fn pause_item_is_clickable_only_while_on() { + assert_eq!( + shadow_pause_label(Some(true), false), + ("Pause shadow mode", true) + ); + assert_eq!( + shadow_pause_label(Some(true), true), + ("Resume shadow mode", true) + ); + assert!(!shadow_pause_label(Some(false), false).1); + assert!(!shadow_pause_label(None, false).1); + } +} diff --git a/src/App.tsx b/src/App.tsx index 2cbb80b..063d728 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import { EVT, engineTry, onEngineEvent, + onShadowPauseToggleRequested, + onShadowSettingsRequested, sidecarRunning, } from "./lib/engine"; import type { @@ -44,6 +46,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 +227,17 @@ 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, togglePause: toggleShadowPause } = useShadowMode({ syncTray: true }); + useEffect(() => { + const unlisten = onShadowPauseToggleRequested(() => void toggleShadowPause()); + return () => void unlisten.then((fn) => fn()).catch(() => {}); + }, [toggleShadowPause]); + useEffect(() => { + const unlisten = onShadowSettingsRequested(() => setRoute({ name: "settings" })); + return () => void unlisten.then((fn) => fn()).catch(() => {}); + }, []); const [screenBusy, setScreenBusy] = useState(false); const [engineUp, setEngineUp] = useState(false); @@ -486,7 +504,7 @@ export default function App() { ? authoring.coach_only ? `This job is coach-only on this window. ${authoring.client_display || "Your assistant"} can suggest; you click.` : authoring.allowed - ? "Pin the browser URL or use this window. Titles stay on this computer." + ? "Pin the browser URL or use this window. OpenAdapt doesn't send window titles to your assistant." : `This computer is bound. Pin the window, then Allow ${authoring.client_display || "your assistant"} to drive this job.` : authoring.prompt || (authoring.status === "replace_allow" @@ -850,6 +868,10 @@ export default function App() { Recording ) : null} + void toggleShadowPause()} + /> {cloudConnected && sync && sync.state !== "synced" && ( @@ -879,6 +901,7 @@ export default function App() {
+ {route.name === "tasks" && ( `). */ @@ -157,6 +169,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]; @@ -288,6 +304,35 @@ 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. + */ +/** Update the tray's shadow line. `active` is null until the engine answers. */ +export async function setShadowTrayStatus( + active: boolean | null, + paused = false, +): Promise { + if (!inTauri()) return; + try { + await invoke("set_shadow_tray_status", { active, paused }); + } 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()); +} + +/** The tray's "Pause shadow mode" / "Resume shadow mode" item. */ +export function onShadowPauseToggleRequested(handler: () => void): Promise { + if (!inTauri()) return Promise.resolve(() => {}); + return listen("tray://toggle-shadow-pause", () => 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..b81e7d0 --- /dev/null +++ b/src/lib/shadowMode.ts @@ -0,0 +1,122 @@ +// 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. OpenAdapt never sends shadow mode data off +// this computer. + +export interface ShadowStatus { + /** False until the engine has answered, so the UI never guesses "off". */ + known: boolean; + available: boolean; + active: boolean; + paused: boolean; + /** The app-data folder that holds the buffer. */ + location: string | null; + 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 = { + known: true, + available: false, + active: false, + paused: false, + location: null, + acknowledged_at: null, + buffer_hours: 24, + work_domains: [], + keys_in_keychain: false, + epoch_ends_at: null, + watches: null, + pending: [], +}; + +/** Before the engine answers. Shown as "checking", never as off. */ +export const SHADOW_UNKNOWN: ShadowStatus = { ...SHADOW_OFF, known: false }; + +/** Read a status payload defensively. No payload means not known yet. */ +export function readShadowStatus(raw: unknown): ShadowStatus { + if (!raw || typeof raw !== "object") return SHADOW_UNKNOWN; + const r = raw as Partial; + const active = r.available === true && r.active === true; + return { + known: true, + available: r.available === true, + active, + paused: active && r.paused === true, + location: typeof r.location === "string" ? r.location : null, + 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/NeedsYou.test.tsx b/src/screens/NeedsYou.test.tsx index 912b1d2..6c07505 100644 --- a/src/screens/NeedsYou.test.tsx +++ b/src/screens/NeedsYou.test.tsx @@ -280,7 +280,7 @@ it("shows what was typed and what the record has on a Check the record stop", as expect(box.textContent).toContain("DOB."); expect(box.textContent).toContain("Typed: 1949-12-11."); expect(box.textContent).toContain("Record on file: 1960-01-01"); - expect(box.textContent).toContain("Shown on this computer only."); + expect(box.textContent).toContain("Shown only in this window. OpenAdapt doesn't send it anywhere."); }); it("shows no field diff when the record check recorded none", async () => { diff --git a/src/screens/NeedsYou.tsx b/src/screens/NeedsYou.tsx index 6ec9080..13c3bda 100644 --- a/src/screens/NeedsYou.tsx +++ b/src/screens/NeedsYou.tsx @@ -170,7 +170,7 @@ function AttentionCard({ ))} -

Shown on this computer only.

+

Shown only in this window. OpenAdapt doesn't send it anywhere.

)} @@ -181,7 +181,7 @@ function AttentionCard({ height={screen.height ?? 0} focus={screen.focus} caption="The screen when OpenAdapt stopped" - provenance="Saved on this computer only" + provenance="Saved on this computer. OpenAdapt doesn't upload it." /> )} diff --git a/src/screens/RecordReview.tsx b/src/screens/RecordReview.tsx index 77208d3..ebfc0fd 100644 --- a/src/screens/RecordReview.tsx +++ b/src/screens/RecordReview.tsx @@ -367,7 +367,8 @@ export function RecordReview({ Do the task on a test record, the way you normally would. A good task takes a minute or two and ends with a save you can see. - Everything stays on this computer. + OpenAdapt doesn't send this recording anywhere unless you choose to + share or upload it. )} @@ -406,7 +407,7 @@ export function RecordReview({ hint={ firstWorkflow ? "For example: Add a claim status note. This name shows on the task's card." - : "Optional. Stays on this computer with the recording." + : "Optional. Saved with the recording. OpenAdapt doesn't send it anywhere unless you choose to upload the recording." } htmlFor={`${fieldPrefix}-record-task`} hintId={`${fieldPrefix}-record-task-hint`} @@ -447,8 +448,8 @@ export function RecordReview({ )}

- What you enter here stays on this computer and out of OpenAdapt's - logs. + OpenAdapt keeps what you enter here out of its logs, and doesn't + send it anywhere unless you choose to deploy the task.

{target.backend === "web" && runtime && runtime.state !== "ready" && ( )} - - Before any upload, OpenAdapt removes sensitive data, and it blocks - the upload if it can't. In a self-hosted setup nothing is uploaded - at all. + + OpenAdapt doesn't upload this recording on its own. Before any + upload you choose, it removes sensitive data, and it blocks the + upload if it can't. In a self-hosted setup nothing goes to + OpenAdapt Cloud. Backup, sync, and security software on this + computer can still copy its files.

- Everything stays on this computer unless you choose to send it.{" "} + OpenAdapt doesn't send your recordings or tasks anywhere unless you + choose to share or upload them. Backup, sync, and security software on + this computer can still copy its files.{" "} diff --git a/src/styles/app.css b/src/styles/app.css index f8ffba7..c288324 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -238,6 +238,11 @@ a:hover { .desktop-status > 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/JudgmentCaseCapture.tsx b/src/ui/JudgmentCaseCapture.tsx index d9ae472..3c3439e 100644 --- a/src/ui/JudgmentCaseCapture.tsx +++ b/src/ui/JudgmentCaseCapture.tsx @@ -256,7 +256,7 @@ export function JudgmentCaseCapture({ onChange={(event) => setSourceRefSha256(event.target.value)} placeholder="Digest of the local demo, shadow run, or counterfactual source" /> - The source stays local. Flow records only this reference. + Flow records only this digest, not the source.

diff --git a/src/ui/ShadowMode.test.tsx b/src/ui/ShadowMode.test.tsx new file mode 100644 index 0000000..e778703 --- /dev/null +++ b/src/ui/ShadowMode.test.tsx @@ -0,0 +1,253 @@ +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.4", + sha256: "a".repeat(64), + title: "Turn on shadow mode?", + intro: "Shadow mode watches for a task you repeat.", + sections: [ + { + heading: "Who can access it", + items: ["OpenAdapt never sends shadow mode data off 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( + "OpenAdapt never sends shadow mode data off 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 erased/)).toBeTruthy(); + }); + + it("says so when the keychain kept the key", async () => { + status = ON; + vi.mocked(engineInvoke).mockResolvedValue({ ...OFF, key_deleted: false }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Delete everything and turn off" })); + expect(await screen.findByText(/didn't delete its key/)).toBeTruthy(); + }); + + it("turns off, erasing the buffer, without deleting the ignore list", async () => { + status = ON; + vi.mocked(engineInvoke).mockResolvedValue({ ...OFF, key_deleted: true }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Turn off" })); + await waitFor(() => expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_TURN_OFF)); + expect(await screen.findByText(/buffer, key, and drafts are erased/)).toBeTruthy(); + }); + + it("pauses and resumes, and advises pausing before sensitive material", async () => { + status = ON; + vi.mocked(engineInvoke).mockImplementation(async (command) => + command === CMD.SHADOW_PAUSE ? { ...ON, paused: true } : ON, + ); + render(); + expect(await screen.findByText(/Pause shadow mode before you view sensitive material/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Pause" })); + await waitFor(() => expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_PAUSE)); + expect(await screen.findByText("Paused")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Resume" })); + await waitFor(() => expect(engineInvoke).toHaveBeenCalledWith(CMD.SHADOW_UNPAUSE)); + }); + + 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 it's checking until the engine answers, never off", async () => { + status = null; + render(); + expect(await screen.findByText(/Checking shadow mode/)).toBeTruthy(); + expect(screen.queryByText("Off")).toBeNull(); + }); + + it("says so when the engine has no shadow mode", async () => { + status = { available: false }; + 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("shows paused, and its button pauses or resumes", () => { + const toggle = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Pause" })); + expect(toggle).toHaveBeenCalledTimes(1); + rerender(); + expect(screen.getByRole("status").textContent).toContain("Shadow mode paused"); + expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy(); + }); + + 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, false)); + }); + + it("tells the tray it doesn't know yet instead of saying off", async () => { + status = null; + function Probe() { + useShadowMode({ syncTray: true }); + return null; + } + render(); + await waitFor(() => expect(setShadowTrayStatus).toHaveBeenCalledWith(null, false)); + expect(setShadowTrayStatus).not.toHaveBeenCalledWith(false, false); + }); +}); + +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..e820e18 --- /dev/null +++ b/src/ui/ShadowMode.tsx @@ -0,0 +1,380 @@ +// Shadow mode UI: the notice a person must acknowledge, the Settings card, the +// header indicator with its Pause control, and the prompt banner. Shadow mode +// is off by default, and OpenAdapt never sends its data off 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_UNKNOWN, + 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"; + +/** How often the status is re-read, in case an event was missed. */ +const REFRESH_MS = 30_000; + +/** Shadow mode's status, kept current from engine events. */ +export function useShadowMode({ syncTray = false }: { syncTray?: boolean } = {}) { + const [status, setStatus] = useState(SHADOW_UNKNOWN); + + const refresh = useCallback(async () => { + const raw = await engineTry(CMD.SHADOW_STATUS, {}, null); + // A failed read keeps what we knew; it doesn't flip the indicator to off. + if (raw) setStatus(readShadowStatus(raw)); + }, []); + + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), REFRESH_MS); + // 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], + })); + }), + ), + // The engine restarted: what we knew may be stale. + quiet(onEngineEvent(EVT.SIDECAR_STATE, () => void refresh())), + ]; + return () => { + clearInterval(timer); + for (const u of unlisteners) void u.then((fn) => fn()); + }; + }, [refresh]); + + useEffect(() => { + if (syncTray) void setShadowTrayStatus(status.known ? status.active : null, status.paused); + }, [syncTray, status.known, status.active, status.paused]); + + /** Pause or resume. Returns the error text, or null. */ + const togglePause = useCallback(async (): Promise => { + try { + const result = await engineInvoke(status.paused ? CMD.SHADOW_UNPAUSE : CMD.SHADOW_PAUSE); + setStatus(readShadowStatus(result)); + return null; + } catch (e) { + return String(e); + } + }, [status.paused]); + + return { status, setStatus, refresh, togglePause }; +} + +/** The header indicator and its Pause control. Shows only while shadow mode is on. */ +export function ShadowModeIndicator({ + status, + onTogglePause, +}: { + status: ShadowStatus; + onTogglePause?: () => void; +}) { + if (!status.active) return null; + return ( + + + {status.paused ? "Shadow mode paused" : "Shadow mode on"} + {onTogglePause && ( + + )} + + ); +} + +/** 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, togglePause } = 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 (result && typeof result === "object" && (result as { key_deleted?: unknown }).key_deleted === false) { + setError( + "The buffer is deleted, but the system keychain didn't delete its key. OpenAdapt tries again the next time it starts.", + ); + } + 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 turnOff = () => + run( + () => engineInvoke(CMD.SHADOW_TURN_OFF), + "Shadow mode is off. Its buffer, key, and drafts are erased.", + ); + + const purge = () => + run( + () => engineInvoke(CMD.SHADOW_PURGE_AND_DISABLE), + "Shadow mode is off. Everything it kept on this computer is erased.", + ); + + const pause = () => + run(async () => { + const failed = await togglePause(); + if (failed) throw new Error(failed); + return null; + }); + + const saveDomains = () => + run( + () => engineInvoke(CMD.SHADOW_SET_WORK_DOMAINS, { domains: parseDomains(domains) }), + "Work sites saved.", + ); + + return ( + + + {!status.known ? ( +

Checking shadow mode…

+ ) : !status.available ? ( + + This version of the OpenAdapt engine doesn't include shadow mode. + + ) : ( + <> +
+ + {status.active ? (status.paused ? "Paused" : "On") : "Off"} + + {status.active && ( + + Turned on {formatWhen(status.acknowledged_at)}. Watches your recordings in + Desktop{status.paused ? ", but is paused and records nothing" : ""}. The buffer is + encrypted with a key{" "} + {status.keys_in_keychain ? "in your system keychain" : "held in memory only"}. Both + are erased by {formatWhen(status.epoch_ends_at)}, or sooner when you turn shadow + mode off or quit OpenAdapt. + {status.location ? ` Kept in ${status.location}.` : ""} + + )} +
+ + {notice ? ( + void acknowledge()} + onDecline={() => setNotice(null)} + /> + ) : status.active ? ( + <> +

Pause shadow mode before you view sensitive material.

+
+ + + +
+ + ) : ( +
+ +
+ )} + + +