diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9597976445..b7ffb9e33a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,6 +30,14 @@ updates: open-pull-requests-limit: 5 labels: - "dependencies" + # packages/tray keeps its own uv.lock, like packages/capture. + - package-ecosystem: "uv" + directory: "/packages/tray" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" # packages/desktop: its uv.lock, the frontend's package-lock.json and the # Tauri shell's Cargo.lock. Minor and patch bumps are grouped, as they were # in openadapt-desktop. diff --git a/.github/workflows/desktop-test.yml b/.github/workflows/desktop-test.yml index cf27d9f7aa..59d7cfc48a 100644 --- a/.github/workflows/desktop-test.yml +++ b/.github/workflows/desktop-test.yml @@ -74,17 +74,12 @@ jobs: - name: Install Desktop and the exact bundled Flow runtime run: uv sync --locked --extra dev --extra build - - name: Checkout the exact Tray protocol peer - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: OpenAdaptAI/openadapt-tray - ref: 3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c - path: packages/desktop/.integration/openadapt-tray - persist-credentials: false - + # The Tray client is in this tree (packages/tray), so the test runs + # against the client from the same commit. packages.yml runs this test + # too when only the tray changes. - name: Exercise the Desktop and Tray IPC contract env: - OPENADAPT_TRAY_SOURCE: .integration/openadapt-tray/src + OPENADAPT_TRAY_SOURCE: ../tray/src run: uv run pytest tests/test_tray_integration.py -v - name: Exercise qualification safety contracts diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 5018e7046c..423be4dc03 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -42,6 +42,14 @@ # on main, and the bundled-Flow qualification contract) and desktop-build.yml # (frontend, Python distribution, frozen sidecar, and Tauri installers). Their # jobs are in packages-gate like every other package's. +# +# openadapt-tray (packages/tray) keeps its own uv.lock and runs `uv sync +# --locked` against it: its suite on Linux with Python 3.10 and 3.12 for a PR, +# plus 3.11, macOS and Windows on main; ruff, the version and changelog +# contract, `uv build` and the archive boundary; and Desktop's socket test +# against this tray client, so a tray change cannot break the protocol +# without the desktop suite running. A desktop change runs that same test in +# desktop-test.yml. name: Packages on: @@ -74,6 +82,7 @@ jobs: capture: ${{ steps.classify.outputs.capture }} privacy: ${{ steps.classify.outputs.privacy }} desktop: ${{ steps.classify.outputs.desktop }} + tray: ${{ steps.classify.outputs.tray }} program_graph: ${{ steps.classify.outputs.program_graph }} weight: ${{ steps.classify.outputs.weight }} steps: @@ -89,7 +98,7 @@ jobs: run: | set -euo pipefail all() { - for pkg in flow types agent capture privacy desktop program_graph weight; do echo "$pkg=true" >> "$GITHUB_OUTPUT"; done + for pkg in flow types agent capture privacy desktop tray program_graph weight; do echo "$pkg=true" >> "$GITHUB_OUTPUT"; done exit 0 } case "$EVENT_NAME" in @@ -119,6 +128,7 @@ jobs: capture="$(hit "^packages/capture/|$wf")" privacy="$(hit "^packages/privacy/|$wf")" desktop="$(hit "^packages/desktop/|^\.github/workflows/desktop-[a-z-]+\.yml$|$wf")" + tray="$(hit "^packages/tray/|$wf")" # The program map checks its schema against Flow's copy. program_graph="$(hit "^packages/program-graph/|^packages/flow/schemas/program-graph-v2\.json$|^\.github/workflows/release-program-graph\.yml$|$wf")" # Any package's metadata, or the launcher's, can change what an @@ -131,6 +141,7 @@ jobs: echo "capture=$capture" echo "privacy=$privacy" echo "desktop=$desktop" + echo "tray=$tray" echo "program_graph=$program_graph" echo "weight=$weight" } | tee -a "$GITHUB_OUTPUT" @@ -681,6 +692,90 @@ jobs: if: needs.scope.outputs.desktop == 'true' uses: ./.github/workflows/desktop-build.yml + tray: + name: tray (${{ matrix.os }}, py${{ matrix.python-version }}) + needs: scope + if: needs.scope.outputs.tray == 'true' + strategy: + fail-fast: false + matrix: + # A pull request runs Linux on the oldest and newest Python; main adds + # 3.11, macOS and Windows, where the tray's platform handlers differ. + include: ${{ fromJSON(github.event_name == 'pull_request' && '[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.12"}]' || '[{"os":"ubuntu-latest","python-version":"3.10"},{"os":"ubuntu-latest","python-version":"3.11"},{"os":"ubuntu-latest","python-version":"3.12"},{"os":"macos-latest","python-version":"3.12"},{"os":"windows-latest","python-version":"3.12"}]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + working-directory: packages/tray + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Set up Python + run: uv python install ${{ matrix.python-version }} + - name: Install + run: uv sync --locked --python ${{ matrix.python-version }} --extra dev + - name: Test + env: + # No display or input device on the Linux runners. + PYNPUT_BACKEND: ${{ runner.os == 'Linux' && 'dummy' || '' }} + PYSTRAY_BACKEND: ${{ runner.os == 'Linux' && 'dummy' || '' }} + run: uv run --no-sync pytest tests -q + + tray-contract: + name: tray lint and release contract + needs: scope + if: needs.scope.outputs.tray == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: packages/tray + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Set up Python + run: uv python install 3.12 + - name: Install + run: uv sync --locked --python 3.12 --extra dev + # The whole package, including examples/ and the root helper scripts. + - name: Ruff + run: uv run --no-sync ruff check . + - name: Build wheel and source distribution + run: uv build + - name: Versions agree and both distributions exist + run: uv run --no-sync python scripts/check_release_consistency.py --require-dist + - name: Verify release archive boundaries + run: uv run --no-sync python scripts/check_source_boundary.py --require-dist + + tray-desktop-ipc: + # Desktop's socket server against this tray's IPC client, both from this + # commit. desktop-test.yml runs the same test when Desktop changes. + name: tray and desktop IPC contract + needs: scope + if: needs.scope.outputs.tray == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: packages/desktop + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Set up Python + run: uv python install 3.12 + - name: Install Desktop + run: uv sync --locked --python 3.12 --extra dev + - name: Exercise the Desktop and Tray IPC contract + env: + OPENADAPT_TRAY_SOURCE: ../tray/src + run: uv run --no-sync pytest tests/test_tray_integration.py -v + program-graph: name: program-graph (test, build, vendor) needs: scope @@ -806,6 +901,9 @@ jobs: - privacy-contract - desktop-test - desktop-build + - tray + - tray-contract + - tray-desktop-ipc - program-graph - install-weight - capture-core-record diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 3e3ea6a758..82ed995062 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -10,6 +10,7 @@ # agent openadapt-agent pypi-agent # capture openadapt-capture pypi-capture # privacy openadapt-privacy pypi-privacy +# tray openadapt-tray pypi-tray # # Manual dispatch only: there is no tag or push trigger, so nothing here runs # unless someone starts it. openadapt-desktop is not here: desktop-release.yml @@ -36,7 +37,7 @@ on: description: "Package directory under packages/" required: true type: choice - options: [flow, types, agent, capture, privacy] + options: [flow, types, agent, capture, privacy, tray] version: description: "Version to publish; must equal packages//pyproject.toml" required: true @@ -96,6 +97,7 @@ jobs: "agent": "openadapt-agent", "capture": "openadapt-capture", "privacy": "openadapt-privacy", + "tray": "openadapt-tray", }[pkg] if project["name"] != expected: sys.exit(f"packages/{pkg} is {project['name']!r}, expected {expected!r}") diff --git a/CAPABILITIES.md b/CAPABILITIES.md index abe725d137..004acd793f 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -96,7 +96,7 @@ shipping in the next release. | Binds each approval to the exact screenshot and form state, and re-reads the form before saving. | [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md) | | Has a written threat model for prompt injection against the agent that teaches or repairs a program, with tests. | [THREAT_MODEL.md](packages/flow/docs/THREAT_MODEL.md) | | Fills inputs from a referral PDF, fax image or email, and holds back anything unclear for a person. | `intake`, [INTAKE.md](packages/flow/docs/INTAKE.md) | -| Ships as one monorepo with separately installable packages (flow, agent, types, capture, privacy and desktop), each released from the monorepo. | [README](README.md) | +| Ships as one monorepo with separately installable packages (flow, agent, types, capture, privacy, desktop and tray), each released from the monorepo. | [README](README.md) | | Makes no model calls on healthy runs. Model training (openadapt-ml) is separate research. | [README](README.md) | ## The full map @@ -245,6 +245,7 @@ improvising or retrying when something is off. | Record mouse, keyboard, screen video and accessibility trees on one clock (Windows, macOS, Linux X11) | Released (capture 1.3.0) | Real OS | [packages/capture](packages/capture/README.md); Wayland is not supported | | Find and redact personal and health data in text, screenshots and structure | Released (privacy 1.1.3) | Unit tests | [packages/privacy](packages/privacy/README.md), [PRIVACY.md](packages/flow/docs/PRIVACY.md) | | Scrub by default before anything leaves the machine | On main ([#1262](https://github.com/OpenAdaptAI/OpenAdapt/pull/1262)) | Unit tests | `openadapt-privacy` | +| Start and stop Desktop recordings from the menu bar or system tray, and see Desktop's state and how many cloud runs need a person | Released (tray 0.3.7) | Unit tests; CI runs the tray client against Desktop's socket server. The two haven't been run together from packaged installs | [packages/tray](packages/tray/README.md) | | Each package installs on its own within a size budget, checked in CI (privacy without desktop, capture without plotting) | On main ([#1270](https://github.com/OpenAdaptAI/OpenAdapt/pull/1270), [#1271](https://github.com/OpenAdaptAI/OpenAdapt/pull/1271), [#1279](https://github.com/OpenAdaptAI/OpenAdapt/pull/1279)) | CI | `.github/workflows/packages.yml` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume and undo read them in memory and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md) | diff --git a/CLAUDE.md b/CLAUDE.md index bb864c7103..a1bbd8d190 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ retrieval, and general computer-use agents are separate research surfaces. - `openadapt/`: launcher package and unified CLI - `packages/`: the product packages, each folded in with its history - (`flow`, `agent`, `types`, `capture`, `privacy`, `desktop`; see + (`flow`, `agent`, `types`, `capture`, `privacy`, `desktop`, `tray`; see `docs/monorepo/`), and `program-graph`, the program map Desktop, Cloud and the website share - `tests/`: launcher, integration-seam, and release-artifact tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7a73c34e9..5b2e4498d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,8 +10,9 @@ Participation in this project is governed by the `OpenAdaptAI/OpenAdapt` is a monorepo. The root holds the `openadapt` launcher, and `packages/` holds the engine (`packages/flow`), the MCP server (`packages/agent`), the shared contracts (`packages/types`), the recorder -(`packages/capture`), privacy scrubbing (`packages/privacy`) and OpenAdapt -Desktop (`packages/desktop`). New engine, replay, repair, policy, and backend +(`packages/capture`), privacy scrubbing (`packages/privacy`), OpenAdapt +Desktop (`packages/desktop`) and its menu-bar and tray controller +(`packages/tray`). New engine, replay, repair, policy, and backend work belongs in `packages/flow`. Open new issues and pull requests here. The [signed Production record](https://docs.openadapt.ai/production-lifecycle.json) @@ -29,9 +30,12 @@ package availability, repository text, or a sibling repository. Flow depends on it and re-exports it as `openadapt_flow.contracts`. - **`packages/agent`**: the `openadapt-agent` package, which keeps the old imports and commands working on top of `openadapt_flow.agent`. -- **Other repositories** (capture, privacy, desktop and the rest): open issues - only when their stated lifecycle and contribution guide match the proposed - work. +- **`packages/capture`, `packages/privacy`, `packages/desktop`, + `packages/tray`**: each keeps its own README, tests and lockfile. Open issues + and pull requests here. +- **Other repositories** (research such as openadapt-ml and the rest): open + issues only when their stated lifecycle and contribution guide match the + proposed work. ## Getting started diff --git a/README.md b/README.md index 94c1cc8cc0..d01debd93b 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,7 @@ MCP server. The product packages live in this repository: | [`openadapt-capture`](https://pypi.org/project/openadapt-capture/) | The desktop recorder: `openadapt[capture]` | [`packages/capture/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/capture) | | [`openadapt-privacy`](https://pypi.org/project/openadapt-privacy/) | Finds and redacts personal and health data: `openadapt[privacy]` | [`packages/privacy/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/privacy) | | [`openadapt-desktop`](https://pypi.org/project/openadapt-desktop/) | OpenAdapt Desktop ([download](https://openadapt.ai/download)) | [`packages/desktop/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/desktop) | +| [`openadapt-tray`](https://pypi.org/project/openadapt-tray/) | Menu-bar and system-tray controls and status for OpenAdapt Desktop | [`packages/tray/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/tray) | Each package installs only what it imports. For example, `pip install openadapt-privacy` doesn't install the engine or the desktop app. CI checks each package's install size and dependency list ([install weight](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/docs/monorepo/INSTALL_WEIGHT.md)). diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 0f5a4b516c..41faacdbed 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -7,6 +7,6 @@ with the [current documentation](https://docs.openadapt.ai) or the canonical ## Documents -- [System Tray App](openadapt-tray.md) - Design specification for the OpenAdapt system tray application +- [System Tray App](openadapt-tray.md) - Design specification for the OpenAdapt system tray application (it now ships from [`packages/tray`](../../packages/tray)) - [Tray Logging](tray-logging.md) - Logging architecture for the tray application - [Telemetry](telemetry-design.md) - Telemetry and analytics design diff --git a/docs/design/openadapt-tray.md b/docs/design/openadapt-tray.md index 6e347814d0..f74ff4410a 100644 --- a/docs/design/openadapt-tray.md +++ b/docs/design/openadapt-tray.md @@ -1,5 +1,15 @@ # openadapt-tray Package Design +> **Status:** historical. This is the design written before the tray was +> built. The tray ships as `openadapt-tray` from +> [`packages/tray`](../../packages/tray) in this repository, and its +> [README](../../packages/tray/README.md) and +> [PROTOCOL.md](../../packages/tray/docs/PROTOCOL.md) describe what it does +> today. The shipped tray differs from this design: it controls recording in +> OpenAdapt Desktop over Desktop's loopback socket instead of running the CLI, +> shows how many cloud runs need a person, has no training or replay +> controls, and doesn't start at login. + ## Overview `openadapt-tray` is a cross-platform system tray application that provides a graphical interface for the OpenAdapt ecosystem. It serves as a thin orchestration layer, allowing users to control recording, monitor training, view captures, and access settings without using the command line. @@ -59,7 +69,7 @@ The legacy implementation uses **PySide6/Qt** for cross-platform system tray fun ### Package Structure ``` -openadapt-tray/ +packages/tray/ ├── src/openadapt_tray/ │ ├── __init__.py # Package exports, version │ ├── __main__.py # Entry point: python -m openadapt_tray @@ -1060,7 +1070,7 @@ openadapt-tray-gui = "openadapt_tray.app:main" [project.urls] Homepage = "https://openadapt.ai" Documentation = "https://docs.openadapt.ai" -Repository = "https://github.com/OpenAdaptAI/openadapt-tray" +Repository = "https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/tray" [build-system] requires = ["hatchling"] diff --git a/docs/monorepo/README.md b/docs/monorepo/README.md index b97b519e40..55de16344e 100644 --- a/docs/monorepo/README.md +++ b/docs/monorepo/README.md @@ -12,6 +12,7 @@ reach back past the fold. | `packages/capture` | OpenAdaptAI/openadapt-capture | `612412c4` | #1205 | [openadapt-capture-commit-map.tsv](openadapt-capture-commit-map.tsv) | | `packages/privacy` | OpenAdaptAI/openadapt-privacy | `4938a8e7` | #1266 | [openadapt-privacy-commit-map.tsv](openadapt-privacy-commit-map.tsv) | | `packages/desktop` | OpenAdaptAI/openadapt-desktop | `9447a0a1` | #1267 | [openadapt-desktop-commit-map.tsv](openadapt-desktop-commit-map.tsv) | +| `packages/tray` | OpenAdaptAI/openadapt-tray | `e979dbbf` | #1356 | [openadapt-tray-commit-map.tsv](openadapt-tray-commit-map.tsv) | ## How a fold is done diff --git a/docs/monorepo/openadapt-tray-commit-map.tsv b/docs/monorepo/openadapt-tray-commit-map.tsv new file mode 100644 index 0000000000..72a38cb9f0 --- /dev/null +++ b/docs/monorepo/openadapt-tray-commit-map.tsv @@ -0,0 +1,87 @@ +# openadapt-tray commit map: OpenAdaptAI/openadapt-tray main -> OpenAdaptAI/OpenAdapt packages/tray +# Source: openadapt-tray main at freeze commit e979dbbf77439240daac7df0d33d708e7d0cf2c6 +# Every commit listed here exists unchanged in OpenAdaptAI/openadapt-tray, which must never be deleted. +# Commit messages keep the old hashes they cite; look them up here. +# old new +0a3b35968697fa53e702cce3a637c266a075213c c009853ed2946da79dd9bc9776ba38429f1ca194 +0f0c8412694052e88f94c77e5a873a78cd3b85cf 9da53042467b70d0b2c7c7da51ed8080c07d85d9 +102e007f0397347e69496cd1d28609bd40416d5b 7b934659ed4bae8d2e38a6551e32076a5429eda0 +12af13199b75de049a792dd495ed5c7178ba3ec5 8de5c99396a3caa21edd85bec0b49cd0b0db16db +12c951011d322da77aef3bcab58609edb7a89e3a 0ff9a9cea25290758cf5b0fb44204c41c4881a65 +190fc1cc473b6147b4f83c3e644e8e2f7dd4aaf5 0f7255594427f8c3a98e4980e68bf2e5f35175cc +19a760d5a38e244d0b8cc642e1894c37572b303e 61413f7436c38fc0f50f8f40bcddba8d3869e88d +1df6b928f4c37abaf08978bcdfe1efbf9e274c20 0d6ebfe4c5b1041c5930833f256afcdfeb934936 +211c039a4ec096145d43a3a8f606ff4fee79538a 05017275930b43d67ba7d6a4440d7a03877fcb58 +246bf93b8ee328c7616ca506cc2b4a105b8b6698 ddea45152826ecd91c67819f6568c382b27cbb1c +2571f8ff8d7837f87ac35e9a6e4cd1f6a43b4d87 3d62ab7029d70e49a764e3685544549a12e5689d +260a9a2a1fa716dc50cd10434a776138c333f509 ef998436375e998850ef04e8b0b35f922d618e3f +265b7c992e8ed36d4d73c98d3f92d07a665c1f51 cb494e797ffa3aebcfa7334c5ef03c6bc85b1f2f +297a4131bcc8d56e301def3c2d578a07e2fb12a9 5acbda5332caa67fd7215f29494a91668ea657e5 +2b5306251cc3fc20b36442b6fdc6b5729b61c58e 8850e58c99dee1614384344bf217d9fec228d68e +2c818f11e98f785022e40c98b1d8ef2a39a2eb65 83471e4fe858e9ee71f0c326907c0759dfbe97dd +2e422c77d0b76ec0f59cbd93380cb96839974672 560e672f4798915f9a1a68cb061072587292e36f +2e4813a0ad89014a4cdaae77a67ee0a332ce0340 026bc6cb3d274bfa458d9d6fcd35c73a1497f10c +32b8ba87958659e688e1a01019b5e30959e7715f 6afc0a40a669c76c9f8d01b28a88c62cdb9d5ce4 +3660da0a9a22d91bac396832a1e51f07a2e5dabb b6a048b835e7abf9015a23dbfa708df4b1532987 +373abe9b5b6efdf22bfd4eb1e1739c9625e1bffb b213fe80b16c3c7b32075806e8f7b2df3241efde +37c0560db6f78a3d335270cb655c47b40588e999 e193d5b2ce789609fcffc49aa6854095b5826cb4 +38bee1fc55a72a4fbf6a409a6e85af61f37b23ff d9df567fb736fa468c6cd2c8c66509db6433cb4f +3975bfcb6fa6de6009d6ddf37c64e99e83e53d0e 44503c52656dd6f9f0126dadcfd8081faca35237 +3aa1514871bf56f7f493531246ec3b029e616a5c 99f8d435e7e438c12a402c0b4766459caf628b14 +3b5ea9543d6438f5d4fdbe2627c1ec8b886dafb3 e7b2c24f20cdd922ce12969b3b80b5e601cd64b6 +3d28d4a91dc0ae042bef556318b8456c3f6fa4bf cdbf5070b6472deb971982a40e3e5412b38b2815 +3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c 084e2327ea445395042d2f5684f44d80641c0085 +3dc385c2955f093660fddda76ca77dd53a24cad6 fd44df6af3585d47e816542c48274d847849dca2 +3e8c842b8e7f633ef4b31fe9eecfa386b17b970a 4e136353f9eed0c4ff7f08920e165de8fbf26f80 +3ebdc490df355175e2968ceb6d37240465ef9f4b 99b731487330e022d43ab67a6cddc18081571c2c +3f7847555008fe87516d60c2956ce460542bf248 ad84c967234cb333f9a94c7885f181f9bd3e3ea8 +43ef343579311674451dfe81d5864dc55e3d5097 f423ad5fb1a20ecd4c43866baeb8e7f97cb1492a +4af2b995e3466e10f2ba7a4273ad8e3a36d14092 b75c430be525d098a4b42fe8414d1c1a94dd0509 +4c5664172ce71dda1e8779303b8fd1b9e03bfb0c e263c2286ad966d8a407e9a7b36022465223c4e3 +51c42dd2023273a39f1bce60cb14209147a51836 9d89a55ad703486bbea1cfa396b7d2ed8223e464 +528ab0bc64d9a4ef95449e6f25947e8d96515b0b 65eb1b11d78d597b8b38d819bb1600122c9f5ef5 +52fee2573e56d37286110e80292b0f28736a64b6 8bb765919e9084eff1932307b4cf70b2bda1b810 +53319353bd3fb5ec900cba7466e5780202d243a5 48360eca199af0e5b2d30de56105ddead53d56f2 +53776dbcea6f225005cfb3d739906171fcaebdb1 3f70a44279b852d85887fc7a0ceee6d5a217b8f0 +57258c6920317632f5e9a262319670e3f25cd8e9 da200d0205cc2673c48b848a27060f2fee117a89 +6ac30cdab5f536606391d3c6cdd479bb457d9ac4 ba65868f1c3ba9bbb6e343af414550bb061643a8 +6c8a5505f275753cc8cb51563dcdd52c12aac99b ed5e4d57180165771f0534cf652214af400266ce +6ecd0189bb3494e1f68cc51d94ca96accb7704a9 30eeb04ec5bf05f689469c103ddb7b5c6fec8985 +743830ac4acf1075a573f890f485b316de6ca15f a6a49dbd0d1131413de90a555b0661c45312a7de +79e6ec96e1994bbe0054ce546d66b96de87b684c 37122fe10893e779b034e7b0434f65eff0922297 +7b4e143335bf355dce9e42e956d3acfb4422408f 0195c30c9c9f5fc3f9dafa0bb9bf5700bd966fe7 +823da35886e9625a6eae57097e8047a224e05f78 996dc676a06db10f1868abb06096869c0b0fdca6 +87503fa4b2b4b613a4cf50d34fec74a30cad19be 18d231c1d448cd467770f360252a5230c035040a +8f9426dcda2d0cb7e34f6520bc43a496e9994a09 67f0648d3633a7c3b910c4e2c307a40ed388778c +91471e83163ba697ff1c3ab7f1c80f2310fb8523 c623942c8bbc2f71061bcec8b95aa17c39c4ddd9 +922e4f0b4c350762818919d0d0a071bc38e28b66 04f17be533de2d1f958d76cc2f1ac7cce9d8f077 +9461ce91bd5fe972d2cc64285895dc101d8f7b5c 6b752c8190546abe452e4ddc27f13331780074af +992fa640903fdee6dc9a2a035e9196a6a5be9d56 c9b96d31597c186d93164b8e9c1db892a37df190 +a1797151653f0275ef583ea06c3fe111899c9bbd cc5d8cb7c315dba7ede7ef0044364611431c55a8 +a31366e1b13f2975539b30411f7cc41357100e6a 163a97d0baaa48b89a8ced6d36a8e916cb9386a7 +aac794dcd64a5c71ae17c8aa1f5c4c7d5ea4e481 fa17cd1b7ce9d1a2e48077f041f312cb17ddf98b +af5c4066a0deaef12d0d013e282216272a7ad42e 4e4fc4a774711e0d6acceb09bd7a0b27032cdf06 +b3caac71a56c4a03f8abddb4a42c20ca06b5f50b a955627866bd2bfc4348a06428fc2ace2adcc021 +b6bad114b12389666de9af75067df69841cc08b0 807a00f12f78bd0731ba6dcbb11a59ea6eb29df7 +b7a81c53086dff01c8b5ebf7c7e31e862101dce4 d31d3d93e35b75ab7c86c42aa82572da0722c234 +c0bc2cc37ac3ed9791a03b2a256ca3e1acb9c125 11a03e2eabcb282ac2712fd01552fff5066571b3 +c102006d91f65742e146102510db1b6dfe6071e4 d4682e3c7e4cb96cea10c79b7c9159842b8e9b7b +c22dab39037f32b60497808b0e2f733f0b7e8247 baa2c8a267ab8eefdc0be962576ca254b5469f82 +c2a8a48bd34d1fd48bc721895bef9b2096be473d 179c5b66d488101887d38991ca14a81e68c6fc13 +c6b2941af842fbdd2dd655a1bc3c68500efbeb2b d260bfcca277e2cbd76a5199e00363f228a6acbe +cc9859441be58227be4f3b6edda3829182e287f8 a1ee7e5b47ddcd5773298d083c9fbcd90177ebf8 +ceea090f9b0dc1fc39835ad29d6af3a8a547c309 d8d6da8ed8fe363ce9ef513c5c200629bc60050d +d152c113a993f4eb69ea83e983ac3676e1faa156 af9038f501f6429819d41f8cf55cecab85998fed +d2cc03fb8e9fda710f04dfd30ee65416653ab3cb 54063f6f081e9ada2eb6acf6379372d1083b6773 +d498c902f31058e39fd74952fe71cc07ab1eaeeb 077e208cc611f1228181f4e311d46c5dff8f475f +d72449441bbd45cf1106901c86e19a3b15507a6a 0dacb5fbf467874a890806b7fa2422099663229c +d7e723f4df1ea6080629c40970560a330b2b3eec 467393ad19365427b1b572be6177bab619bfdacd +da2a5015f03bf2f95e6c58fef4adca00070c6114 61972aa34f863eef719fc9f95f2c153b5b58e43b +daaf4e2613df1e1ea2eee8b1514ce25fd8b241d3 2e21eb2c14090dfa7467f39ded072a50b6491859 +dbc8ece55399fbf3f86ac9d3ab0fe6118b8d92b8 55269e00fed77ffea0423bc5fd020520d8198720 +dd0a3395cb42f1312bc6aa03650e2e1f3e4cc7dd 553dddfab3f38d7f47dbb87c63a69e2471c09ed9 +e30fa6f87ba30298d98405180017668211c7a174 7a7043f334f0339b315af101a35d192648f92842 +e979dbbf77439240daac7df0d33d708e7d0cf2c6 24ef62d428d6f654c72a26d2e08352475380050d +f8c57793fcf31f0cce2513e467be4d55ba32ade3 670d56e6ae6ab48f27145f736fa5b369af8da26e +fefbd9ca3ee53c8be975a0773c1576cd0b7d93a3 00e534f508e16bbdf4e25da5a7d622da0f74ff52 +ffcf0b7254c60abf52a46052452983cab7f35e59 690ca73e8c352f861e09ae46ddbd5c1eee01978f diff --git a/docs/packages/index.md b/docs/packages/index.md index 573b4c5be8..3f52c57cb0 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -10,6 +10,7 @@ cockpit, privacy tools, and optional managed control plane. | `openadapt-capture` | Native screen, input, timing, window-scope, and media capture | `pip install 'openadapt[capture]'` | | `openadapt-privacy` | Local privacy and sanitization support | `pip install 'openadapt[privacy]'` | | `openadapt-desktop` | Visual authoring and operator application | [Download an installer](https://openadapt.ai/download) | +| `openadapt-tray` | Menu-bar and system-tray controls and status for OpenAdapt Desktop | `pip install openadapt-tray` | Package roles do not assign a lifecycle state. Use the [signed Production record](https://docs.openadapt.ai/production-lifecycle.json) diff --git a/llms-full.txt b/llms-full.txt index 9aa1b5778e..dd99a6c739 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -334,6 +334,7 @@ MCP server. The product packages live in this repository: | [`openadapt-capture`](https://pypi.org/project/openadapt-capture/) | The desktop recorder: `openadapt[capture]` | [`packages/capture/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/capture) | | [`openadapt-privacy`](https://pypi.org/project/openadapt-privacy/) | Finds and redacts personal and health data: `openadapt[privacy]` | [`packages/privacy/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/privacy) | | [`openadapt-desktop`](https://pypi.org/project/openadapt-desktop/) | OpenAdapt Desktop ([download](https://openadapt.ai/download)) | [`packages/desktop/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/desktop) | +| [`openadapt-tray`](https://pypi.org/project/openadapt-tray/) | Menu-bar and system-tray controls and status for OpenAdapt Desktop | [`packages/tray/`](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/tray) | Each package installs only what it imports. For example, `pip install openadapt-privacy` doesn't install the engine or the desktop app. CI checks each package's install size and dependency list ([install weight](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/docs/monorepo/INSTALL_WEIGHT.md)). @@ -492,7 +493,7 @@ shipping in the next release. | Binds each approval to the exact screenshot and form state, and re-reads the form before saving. | [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md) | | Has a written threat model for prompt injection against the agent that teaches or repairs a program, with tests. | [THREAT_MODEL.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THREAT_MODEL.md) | | Fills inputs from a referral PDF, fax image or email, and holds back anything unclear for a person. | `intake`, [INTAKE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/INTAKE.md) | -| Ships as one monorepo with separately installable packages (flow, agent, types, capture, privacy and desktop), each released from the monorepo. | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | +| Ships as one monorepo with separately installable packages (flow, agent, types, capture, privacy, desktop and tray), each released from the monorepo. | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | | Makes no model calls on healthy runs. Model training (openadapt-ml) is separate research. | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | ## The full map @@ -641,6 +642,7 @@ improvising or retrying when something is off. | Record mouse, keyboard, screen video and accessibility trees on one clock (Windows, macOS, Linux X11) | Released (capture 1.3.0) | Real OS | [packages/capture](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/capture/README.md); Wayland is not supported | | Find and redact personal and health data in text, screenshots and structure | Released (privacy 1.1.3) | Unit tests | [packages/privacy](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/privacy/README.md), [PRIVACY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/PRIVACY.md) | | Scrub by default before anything leaves the machine | On main ([#1262](https://github.com/OpenAdaptAI/OpenAdapt/pull/1262)) | Unit tests | `openadapt-privacy` | +| Start and stop Desktop recordings from the menu bar or system tray, and see Desktop's state and how many cloud runs need a person | Released (tray 0.3.7) | Unit tests; CI runs the tray client against Desktop's socket server. The two haven't been run together from packaged installs | [packages/tray](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/tray/README.md) | | Each package installs on its own within a size budget, checked in CI (privacy without desktop, capture without plotting) | On main ([#1270](https://github.com/OpenAdaptAI/OpenAdapt/pull/1270), [#1271](https://github.com/OpenAdaptAI/OpenAdapt/pull/1271), [#1279](https://github.com/OpenAdaptAI/OpenAdapt/pull/1279)) | CI | `.github/workflows/packages.yml` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume and undo read them in memory and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md) | diff --git a/packages/desktop/docs/COMPLIANCE.md b/packages/desktop/docs/COMPLIANCE.md index bcdad78515..eea06aa760 100644 --- a/packages/desktop/docs/COMPLIANCE.md +++ b/packages/desktop/docs/COMPLIANCE.md @@ -64,8 +64,8 @@ and uninstalls every package on clean runners. That is packaging evidence. It isn't workflow qualification, which stays specific to each workflow and each environment. -CI also runs the exact `openadapt-tray` IPC client against this engine's -loopback socket server. The two applications haven't been run together end to +CI also runs the `openadapt-tray` IPC client from the same commit +(`packages/tray`) against this engine's loopback socket server. The two applications haven't been run together end to end. Area-by-area evidence boundaries are in diff --git a/packages/desktop/tests/test_native_release.py b/packages/desktop/tests/test_native_release.py index 7e59dd5d88..6005a6f101 100644 --- a/packages/desktop/tests/test_native_release.py +++ b/packages/desktop/tests/test_native_release.py @@ -194,24 +194,18 @@ def test_native_workflows_are_pinned_and_preserve_candidate_boundary() -> None: assert retired not in text -def test_qualification_pins_and_runs_the_exact_tray_protocol_peer() -> None: +def test_qualification_runs_the_in_tree_tray_protocol_peer() -> None: + # The Tray client is packages/tray in the same monorepo commit, so the + # contract needs no checkout of another repository. workflow = _workflow("test.yml") steps = workflow["jobs"]["qualification-contract"]["steps"] - checkout = next( - step for step in steps if step.get("name") == "Checkout the exact Tray protocol peer" - ) - assert checkout["uses"] == ("actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1") - assert checkout["with"] == { - "repository": "OpenAdaptAI/openadapt-tray", - "ref": "3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c", - "path": "packages/desktop/.integration/openadapt-tray", - "persist-credentials": False, - } + assert not any("repository" in (step.get("with") or {}) for step in steps) contract = next( step for step in steps if step.get("name") == "Exercise the Desktop and Tray IPC contract" ) - assert contract["env"] == {"OPENADAPT_TRAY_SOURCE": ".integration/openadapt-tray/src"} + assert contract["env"] == {"OPENADAPT_TRAY_SOURCE": "../tray/src"} assert contract["run"] == "uv run pytest tests/test_tray_integration.py -v" + assert (ROOT / contract["env"]["OPENADAPT_TRAY_SOURCE"] / "openadapt_tray" / "ipc.py").is_file() def test_linux_sidecar_installs_browser_libraries_before_the_frozen_smoke() -> None: diff --git a/packages/tray/.gitignore b/packages/tray/.gitignore new file mode 100644 index 0000000000..5a54d272a8 --- /dev/null +++ b/packages/tray/.gitignore @@ -0,0 +1,138 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytest_cache/ + +# Translations +*.mo +*.pot + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +*.log + +# Never-publish workspace material (strategy, prospect research, counsel packs, +# personal contact details). Created ad hoc; must never reach a remote. +.private/ + +# Cover every .env variant, not one at a time. A .env.bak holding live +# API keys sat in a stash for six weeks and was one `git add -A` from being +# committed. Negate the committed template so it stays tracked. +.env.* +!.env.example diff --git a/packages/tray/CHANGELOG.md b/packages/tray/CHANGELOG.md new file mode 100644 index 0000000000..580bff4f0d --- /dev/null +++ b/packages/tray/CHANGELOG.md @@ -0,0 +1,369 @@ +# CHANGELOG + + +## v0.3.7 (2026-10-14) + +### Bug Fixes + +- **deps**: Patch Dependabot security alerts ([#75](https://github.com/OpenAdaptAI/openadapt-tray/pull/75), [`373abe9`](https://github.com/OpenAdaptAI/openadapt-tray/commit/373abe9b5b6efdf22bfd4eb1e1739c9625e1bffb)) + +### Testing + +- Check every label in the default tray menu ([#74](https://github.com/OpenAdaptAI/openadapt-tray/pull/74), [`8f9426d`](https://github.com/OpenAdaptAI/openadapt-tray/commit/8f9426dcda2d0cb7e34f6520bc43a496e9994a09)) + + +## v0.3.6 (2026-10-14) + +### Bug Fixes + +- Make each tray menu action reach something real ([#73](https://github.com/OpenAdaptAI/openadapt-tray/pull/73), [`c102006`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c102006d91f65742e146102510db1b6dfe6071e4)) + +- **macos**: Keep the hotkey listeners from aborting the tray at startup ([#73](https://github.com/OpenAdaptAI/openadapt-tray/pull/73), [`c102006`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c102006d91f65742e146102510db1b6dfe6071e4)) + +- **macos**: Stop printing an import error on every start ([#73](https://github.com/OpenAdaptAI/openadapt-tray/pull/73), [`c102006`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c102006d91f65742e146102510db1b6dfe6071e4)) + +### Chores + +- Sync the published source policy ([#72](https://github.com/OpenAdaptAI/openadapt-tray/pull/72), [`1df6b92`](https://github.com/OpenAdaptAI/openadapt-tray/commit/1df6b928f4c37abaf08978bcdfe1efbf9e274c20)) + +### Documentation + +- Lead the README with what the tray does and a checked quick start ([#73](https://github.com/OpenAdaptAI/openadapt-tray/pull/73), [`c102006`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c102006d91f65742e146102510db1b6dfe6071e4)) + +- State what the quick start can reach today ([#73](https://github.com/OpenAdaptAI/openadapt-tray/pull/73), [`c102006`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c102006d91f65742e146102510db1b6dfe6071e4)) + + +## v0.3.5 (2026-10-14) + +### Bug Fixes + +- Keep the tray menu building when the hotkey setting is not a string ([#69](https://github.com/OpenAdaptAI/openadapt-tray/pull/69), [`19a760d`](https://github.com/OpenAdaptAI/openadapt-tray/commit/19a760d5a38e244d0b8cc642e1894c37572b303e)) + +- Show the recording hotkey in the platform's form in the menu ([#69](https://github.com/OpenAdaptAI/openadapt-tray/pull/69), [`19a760d`](https://github.com/OpenAdaptAI/openadapt-tray/commit/19a760d5a38e244d0b8cc642e1894c37572b303e)) + +- Show the recording hotkey in the platform's form in the tray menu ([#69](https://github.com/OpenAdaptAI/openadapt-tray/pull/69), [`19a760d`](https://github.com/OpenAdaptAI/openadapt-tray/commit/19a760d5a38e244d0b8cc642e1894c37572b303e)) + +### Chores + +- Align compatible release tools and bound Click updates ([#58](https://github.com/OpenAdaptAI/openadapt-tray/pull/58), [`922e4f0`](https://github.com/OpenAdaptAI/openadapt-tray/commit/922e4f0b4c350762818919d0d0a071bc38e28b66)) + +- Isolate and update the locked release tools ([#54](https://github.com/OpenAdaptAI/openadapt-tray/pull/54), [`ceea090`](https://github.com/OpenAdaptAI/openadapt-tray/commit/ceea090f9b0dc1fc39835ad29d6af3a8a547c309)) + +- Synchronize uv 0.12.13 release tools ([`37c0560`](https://github.com/OpenAdaptAI/openadapt-tray/commit/37c0560db6f78a3d335270cb655c47b40588e999)) + +- Update CodeQL action to 4.38.0 ([`87503fa`](https://github.com/OpenAdaptAI/openadapt-tray/commit/87503fa4b2b4b613a4cf50d34fec74a30cad19be)) + +- Update setup-uv action to 10.1.0 ([`d152c11`](https://github.com/OpenAdaptAI/openadapt-tray/commit/d152c113a993f4eb69ea83e983ac3676e1faa156)) + +- **deps**: Align the reviewed UV release tool pins ([#51](https://github.com/OpenAdaptAI/openadapt-tray/pull/51), [`211c039`](https://github.com/OpenAdaptAI/openadapt-tray/commit/211c039a4ec096145d43a3a8f606ff4fee79538a)) + +- **deps**: Bump the codeql-action group with 2 updates ([#53](https://github.com/OpenAdaptAI/openadapt-tray/pull/53), [`2571f8f`](https://github.com/OpenAdaptAI/openadapt-tray/commit/2571f8ff8d7837f87ac35e9a6e4cd1f6a43b4d87)) + +- **deps**: Update the GitHub release publishing action ([#55](https://github.com/OpenAdaptAI/openadapt-tray/pull/55), [`3660da0`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3660da0a9a22d91bac396832a1e51f07a2e5dabb)) + +- **deps**: Update the reviewed GitPython release dependency ([#50](https://github.com/OpenAdaptAI/openadapt-tray/pull/50), [`ffcf0b7`](https://github.com/OpenAdaptAI/openadapt-tray/commit/ffcf0b7254c60abf52a46052452983cab7f35e59)) + +- **deps**: Validate the Hatchling 1.32 release contract ([#52](https://github.com/OpenAdaptAI/openadapt-tray/pull/52), [`102e007`](https://github.com/OpenAdaptAI/openadapt-tray/commit/102e007f0397347e69496cd1d28609bd40416d5b)) + + +## v0.3.4 (2026-08-28) + +### Bug Fixes + +- Align tray with desktop protocol ([#49](https://github.com/OpenAdaptAI/openadapt-tray/pull/49), [`3d6ffbe`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c)) + +- Align Tray with the native Desktop protocol ([#49](https://github.com/OpenAdaptAI/openadapt-tray/pull/49), [`3d6ffbe`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c)) + +- Update cryptography to patched release ([#49](https://github.com/OpenAdaptAI/openadapt-tray/pull/49), [`3d6ffbe`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c)) + +- Use desktop Linux application identity ([#49](https://github.com/OpenAdaptAI/openadapt-tray/pull/49), [`3d6ffbe`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3d6ffbeda50af2fc4769ae0f1ce55e3d5d49435c)) + +### Documentation + +- Rewrite the README around what the tray actually is ([#48](https://github.com/OpenAdaptAI/openadapt-tray/pull/48), [`3dc385c`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3dc385c2955f093660fddda76ca77dd53a24cad6)) + +- Say precisely what openadapt-tray-gui is ([#48](https://github.com/OpenAdaptAI/openadapt-tray/pull/48), [`3dc385c`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3dc385c2955f093660fddda76ca77dd53a24cad6)) + +### Testing + +- Assert the README's claims, not its headings ([#48](https://github.com/OpenAdaptAI/openadapt-tray/pull/48), [`3dc385c`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3dc385c2955f093660fddda76ca77dd53a24cad6)) + + +## v0.3.3 (2026-08-27) + +### Bug Fixes + +- Lock the semantic release runtime ([#46](https://github.com/OpenAdaptAI/openadapt-tray/pull/46), [`af5c406`](https://github.com/OpenAdaptAI/openadapt-tray/commit/af5c4066a0deaef12d0d013e282216272a7ad42e)) + +### Chores + +- Ignore every .env variant, not one at a time ([#45](https://github.com/OpenAdaptAI/openadapt-tray/pull/45), [`c22dab3`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c22dab39037f32b60497808b0e2f733f0b7e8247)) + +- **deps**: Bump the codeql-action group with 2 updates ([#44](https://github.com/OpenAdaptAI/openadapt-tray/pull/44), [`57258c6`](https://github.com/OpenAdaptAI/openadapt-tray/commit/57258c6920317632f5e9a262319670e3f25cd8e9)) + +- **deps**: Refresh CodeQL and uv toolchain ([#41](https://github.com/OpenAdaptAI/openadapt-tray/pull/41), [`f8c5779`](https://github.com/OpenAdaptAI/openadapt-tray/commit/f8c57793fcf31f0cce2513e467be4d55ba32ade3)) + +- **policy**: Sync generated source boundary ([#47](https://github.com/OpenAdaptAI/openadapt-tray/pull/47), [`743830a`](https://github.com/OpenAdaptAI/openadapt-tray/commit/743830ac4acf1075a573f890f485b316de6ca15f)) + +### Continuous Integration + +- Correct the stale version comment on the pypi-publish pin ([#43](https://github.com/OpenAdaptAI/openadapt-tray/pull/43), [`a179715`](https://github.com/OpenAdaptAI/openadapt-tray/commit/a1797151653f0275ef583ea06c3fe111899c9bbd)) + +### Documentation + +- Use canonical launcher quickstart ([#42](https://github.com/OpenAdaptAI/openadapt-tray/pull/42), [`823da35`](https://github.com/OpenAdaptAI/openadapt-tray/commit/823da35886e9625a6eae57097e8047a224e05f78)) + +### Testing + +- Make the release wrapper fixture portable ([#46](https://github.com/OpenAdaptAI/openadapt-tray/pull/46), [`af5c406`](https://github.com/OpenAdaptAI/openadapt-tray/commit/af5c4066a0deaef12d0d013e282216272a7ad42e)) + + +## v0.3.2 (2026-08-18) + +### Bug Fixes + +- **deps**: Synchronize the uv release pin ([#39](https://github.com/OpenAdaptAI/openadapt-tray/pull/39), [`246bf93`](https://github.com/OpenAdaptAI/openadapt-tray/commit/246bf93b8ee328c7616ca506cc2b4a105b8b6698)) + +### Chores + +- Confirm tray notification delivery ([`6c8a550`](https://github.com/OpenAdaptAI/openadapt-tray/commit/6c8a5505f275753cc8cb51563dcdd52c12aac99b)) + +- **deps**: Bump astral-sh/setup-uv from 9.0.0 to 10.0.1 ([#40](https://github.com/OpenAdaptAI/openadapt-tray/pull/40), [`12c9510`](https://github.com/OpenAdaptAI/openadapt-tray/commit/12c951011d322da77aef3bcab58609edb7a89e3a)) + +- **deps**: Bump pypa/gh-action-pypi-publish from 1.14.1 to 1.14.2 ([#37](https://github.com/OpenAdaptAI/openadapt-tray/pull/37), [`3e8c842`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3e8c842b8e7f633ef4b31fe9eecfa386b17b970a)) + +- **deps**: Bump the codeql-action group with 2 updates ([#36](https://github.com/OpenAdaptAI/openadapt-tray/pull/36), [`a31366e`](https://github.com/OpenAdaptAI/openadapt-tray/commit/a31366e1b13f2975539b30411f7cc41357100e6a)) + +- **deps-dev**: Bump uv from 0.11.32 to 0.12.4 ([#39](https://github.com/OpenAdaptAI/openadapt-tray/pull/39), [`246bf93`](https://github.com/OpenAdaptAI/openadapt-tray/commit/246bf93b8ee328c7616ca506cc2b4a105b8b6698)) + + +## v0.3.1 (2026-07-28) + +### Bug Fixes + +- **ci**: Satisfy tray source guard lint ([#33](https://github.com/OpenAdaptAI/openadapt-tray/pull/33), [`52fee25`](https://github.com/OpenAdaptAI/openadapt-tray/commit/52fee2573e56d37286110e80292b0f28736a64b6)) + +### Chores + +- **release**: Enforce source policy on archives ([#33](https://github.com/OpenAdaptAI/openadapt-tray/pull/33), [`52fee25`](https://github.com/OpenAdaptAI/openadapt-tray/commit/52fee2573e56d37286110e80292b0f28736a64b6)) + +- **release**: Enforce the source-policy boundary ([#33](https://github.com/OpenAdaptAI/openadapt-tray/pull/33), [`52fee25`](https://github.com/OpenAdaptAI/openadapt-tray/commit/52fee2573e56d37286110e80292b0f28736a64b6)) + + +## v0.3.0 (2026-07-28) + +### Bug Fixes + +- Derive credential identity with HMAC ([#32](https://github.com/OpenAdaptAI/openadapt-tray/pull/32), [`fefbd9c`](https://github.com/OpenAdaptAI/openadapt-tray/commit/fefbd9ca3ee53c8be975a0773c1576cd0b7d93a3)) + +### Features + +- Warn before hosted credentials expire ([#32](https://github.com/OpenAdaptAI/openadapt-tray/pull/32), [`fefbd9c`](https://github.com/OpenAdaptAI/openadapt-tray/commit/fefbd9ca3ee53c8be975a0773c1576cd0b7d93a3)) + + +## v0.2.4 (2026-07-28) + +### Bug Fixes + +- Keep tray command failures visible ([#31](https://github.com/OpenAdaptAI/openadapt-tray/pull/31), [`dbc8ece`](https://github.com/OpenAdaptAI/openadapt-tray/commit/dbc8ece55399fbf3f86ac9d3ab0fe6118b8d92b8)) + +- Reject unreadable tray outcomes ([#31](https://github.com/OpenAdaptAI/openadapt-tray/pull/31), [`dbc8ece`](https://github.com/OpenAdaptAI/openadapt-tray/commit/dbc8ece55399fbf3f86ac9d3ab0fe6118b8d92b8)) + + +## v0.2.3 (2026-07-28) + +### Bug Fixes + +- Make unknown outcomes representable instead of success-shaped ([#30](https://github.com/OpenAdaptAI/openadapt-tray/pull/30), [`3f78475`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3f7847555008fe87516d60c2956ce460542bf248)) + + +## v0.2.2 (2026-07-28) + +### Bug Fixes + +- Report keychain, toast and IPC failures honestly; pin ruff to 0.16.x ([#29](https://github.com/OpenAdaptAI/openadapt-tray/pull/29), [`5331935`](https://github.com/OpenAdaptAI/openadapt-tray/commit/53319353bd3fb5ec900cba7466e5780202d243a5)) + +### Chores + +- Clean up repository root ([#20](https://github.com/OpenAdaptAI/openadapt-tray/pull/20), [`2e422c7`](https://github.com/OpenAdaptAI/openadapt-tray/commit/2e422c77d0b76ec0f59cbd93380cb96839974672)) + +- Gitignore .private/ ([#28](https://github.com/OpenAdaptAI/openadapt-tray/pull/28), [`b3caac7`](https://github.com/OpenAdaptAI/openadapt-tray/commit/b3caac71a56c4a03f8abddb4a42c20ca06b5f50b)) + +- **deps**: Bump astral-sh/setup-uv from 8.3.2 to 9.0.0 ([#24](https://github.com/OpenAdaptAI/openadapt-tray/pull/24), [`9461ce9`](https://github.com/OpenAdaptAI/openadapt-tray/commit/9461ce91bd5fe972d2cc64285895dc101d8f7b5c)) + +- **deps**: Bump github/codeql-action init+analyze to v4.37.3 together ([#26](https://github.com/OpenAdaptAI/openadapt-tray/pull/26), [`3d28d4a`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3d28d4a91dc0ae042bef556318b8456c3f6fa4bf)) + +- **deps**: Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 ([#25](https://github.com/OpenAdaptAI/openadapt-tray/pull/25), [`297a413`](https://github.com/OpenAdaptAI/openadapt-tray/commit/297a4131bcc8d56e301def3c2d578a07e2fb12a9)) + +- **deps**: Bump uv from 0.11.29 to 0.11.32 across every release input ([#27](https://github.com/OpenAdaptAI/openadapt-tray/pull/27), [`4af2b99`](https://github.com/OpenAdaptAI/openadapt-tray/commit/4af2b995e3466e10f2ba7a4273ad8e3a36d14092)) + + +## v0.2.1 (2026-07-21) + +### Bug Fixes + +- **deps**: Raise vulnerable tray dependency floors ([#19](https://github.com/OpenAdaptAI/openadapt-tray/pull/19), [`91471e8`](https://github.com/OpenAdaptAI/openadapt-tray/commit/91471e83163ba697ff1c3ab7f1c80f2310fb8523)) + +### Chores + +- **deps**: Bump actions/checkout from 7.0.0 to 7.0.1 ([#12](https://github.com/OpenAdaptAI/openadapt-tray/pull/12), [`0a3b359`](https://github.com/OpenAdaptAI/openadapt-tray/commit/0a3b35968697fa53e702cce3a637c266a075213c)) + +- **deps**: Bump actions/dependency-review-action from 4.9.0 to 5.0.0 ([#10](https://github.com/OpenAdaptAI/openadapt-tray/pull/10), [`79e6ec9`](https://github.com/OpenAdaptAI/openadapt-tray/commit/79e6ec96e1994bbe0054ce546d66b96de87b684c)) + +- **deps**: Bump actions/setup-python from 6.3.0 to 7.0.0 ([#13](https://github.com/OpenAdaptAI/openadapt-tray/pull/13), [`aac794d`](https://github.com/OpenAdaptAI/openadapt-tray/commit/aac794dcd64a5c71ae17c8aa1f5c4c7d5ea4e481)) + +- **deps**: Bump python-semantic-release/publish-action ([#11](https://github.com/OpenAdaptAI/openadapt-tray/pull/11), [`c0bc2cc`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c0bc2cc37ac3ed9791a03b2a256ca3e1acb9c125)) + +- **deps**: Bump python-semantic-release/python-semantic-release ([#14](https://github.com/OpenAdaptAI/openadapt-tray/pull/14), [`3aa1514`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3aa1514871bf56f7f493531246ec3b029e616a5c)) + +- **deps-dev**: Bump uv from 0.11.26 to 0.11.29 ([#15](https://github.com/OpenAdaptAI/openadapt-tray/pull/15), [`2b53062`](https://github.com/OpenAdaptAI/openadapt-tray/commit/2b5306251cc3fc20b36442b6fdc6b5729b61c58e)) + +- **deps-dev**: Synchronize uv 0.11.29 lock ([#15](https://github.com/OpenAdaptAI/openadapt-tray/pull/15), [`2b53062`](https://github.com/OpenAdaptAI/openadapt-tray/commit/2b5306251cc3fc20b36442b6fdc6b5729b61c58e)) + +### Documentation + +- Refresh README for the tray companion and reconciled desktop contract ([#18](https://github.com/OpenAdaptAI/openadapt-tray/pull/18), [`6ac30cd`](https://github.com/OpenAdaptAI/openadapt-tray/commit/6ac30cdab5f536606391d3c6cdd479bb457d9ac4)) + + +## v0.2.0 (2026-07-20) + +### Chores + +- Add security CI (CodeQL, gitleaks, dependency-review, Dependabot) + ([#9](https://github.com/OpenAdaptAI/openadapt-tray/pull/9), + [`43ef343`](https://github.com/OpenAdaptAI/openadapt-tray/commit/43ef343579311674451dfe81d5864dc55e3d5097)) + +Adds CodeQL SAST, gitleaks secret scanning, dependency review, Dependabot, and a + vulnerability-disclosure policy as part of SOC 2 readiness. Additive and non-breaking. + +Co-authored-by: Claude Fable 5 + +### Documentation + +- Refresh README release boundary to the shipped 0.1.1 reality + ([#6](https://github.com/OpenAdaptAI/openadapt-tray/pull/6), + [`12af131`](https://github.com/OpenAdaptAI/openadapt-tray/commit/12af13199b75de049a792dd495ed5c7178ba3ec5)) + +Supersedes the original whole-file rewrite: main's README (from #5) already covers the loop + status/launcher role, so this PR now only fixes the claims that went stale when the hosted + lifecycle merged and 0.1.1 shipped to PyPI: + +- hosted behavior is on main and released, not 'feat/hosted-rewire branch code' - version is 0.1.1 + (pre-alpha classifier), not 0.0.1 - add the PyPI badge and a real pip install quickstart - keep + the honest boundary: no released openadapt-desktop build provides the companion IPC service yet + +Co-authored-by: Claude Fable 5 + +- Use canonical 'retained evidence' wording in README + ([#8](https://github.com/OpenAdaptAI/openadapt-tray/pull/8), + [`b7a81c5`](https://github.com/OpenAdaptAI/openadapt-tray/commit/b7a81c53086dff01c8b5ebf7c7e31e862101dce4)) + +Match the AGENTS.md / org-profile / LIMITS.md canonical one-liner ('retained evidence'); the tray + README said 'recorded evidence'. + +Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM + +Co-authored-by: Claude Fable 5 + +### Features + +- Use OpenAdapt mark, tinted per state, for the tray icon + ([#16](https://github.com/OpenAdaptAI/openadapt-tray/pull/16), + [`c2a8a48`](https://github.com/OpenAdaptAI/openadapt-tray/commit/c2a8a48bd34d1fd48bc721895bef9b2096be473d)) + +Replace the plain solid colored circle with the OpenAdapt brand mark (the chat/robot face from the + openadapt.ai favicon), color-stylized per recording-lifecycle state so the state stays readable at + a glance while the brand is finally shown in the menu bar / system tray. + +- Add the vector mark (assets/mark.svg) and a high-res transparent raster master + (assets/mark-master.png), plus a 512px copy packaged inside the wheel + (src/openadapt_tray/assets/mark-master.png) so the installed app can render the mark. The wheel + previously shipped no assets, so it always fell back to a generated circle. - IconManager now + tints the mark's alpha silhouette per state (idle=brand blue, recording=red, + starting/stopping=amber, compiling=purple, error=red). One tint implementation is shared by the + runtime and the generator, so committed PNGs and runtime icons never diverge. The needs-attention + badge is preserved. - Give every TrayState its own icon file. This fixes a latent bug where + COMPILING referenced a nonexistent compiling.png (fell back to a circle) and starting/stopping + showed red instead of amber. - Rewrite scripts/generate_icons.py to re-render the master from the + SVG (rsvg-convert/cairosvg/inkscape) and tint per-state 1x + @2x PNGs and a multi-size logo.ico. + Remove the stale training.png icons. + +Icons verified valid at menu-bar sizes (22px + 44px, light and dark); 137 tests pass, ruff clean. + +Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM + +Co-authored-by: Claude Opus 4.8 + + +## v0.1.1 (2026-07-15) + +### Bug Fixes + +- Recover tray 0.1.1 PyPI release + ([`53776db`](https://github.com/OpenAdaptAI/openadapt-tray/commit/53776dbcea6f225005cfb3d739906171fcaebdb1)) + +Replace the undefined release lock variable with an offline exact-root synchronizer and preserve + v0.1.0 as the failed-attempt audit record. + + +## v0.1.0 (2026-07-15) + +### Bug Fixes + +- **ci**: Use v9 branch config for python-semantic-release + ([#4](https://github.com/OpenAdaptAI/openadapt-tray/pull/4), + [`265b7c9`](https://github.com/OpenAdaptAI/openadapt-tray/commit/265b7c992e8ed36d4d73c98d3f92d07a665c1f51)) + +Replace `branch = "main"` (v7/v8 key) with `[tool.semantic_release.branches.main]` table (v9 key). + The old key is silently ignored by v9, causing releases to never trigger on the main branch. + +Co-authored-by: Claude Opus 4.6 + +### Features + +- Align tray with the hosted workflow lifecycle + ([`3b5ea95`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3b5ea9543d6438f5d4fdbe2627c1ec8b886dafb3)) + +Reposition the Experimental tray around record, compile, replay, and governed hosted repair, with + protected-main and pre-1.0 release safeguards. + + +## v0.0.1 (2026-01-29) + +### Bug Fixes + +- Add README badges for license and Python version + ([#1](https://github.com/OpenAdaptAI/openadapt-tray/pull/1), + [`d7e723f`](https://github.com/OpenAdaptAI/openadapt-tray/commit/d7e723f4df1ea6080629c40970560a330b2b3eec)) + +Add standard badges for license and Python version. PyPI badges are commented out until the package + is published. + +Co-authored-by: Claude Sonnet 4.5 + +- **ci**: Remove build_command from semantic-release config + ([`d2cc03f`](https://github.com/OpenAdaptAI/openadapt-tray/commit/d2cc03fb8e9fda710f04dfd30ee65416653ab3cb)) + +The python-semantic-release action runs in a Docker container where uv is not available. Let the + workflow handle building instead. + +Co-Authored-By: Claude Opus 4.5 + +### Continuous Integration + +- Add PyPI publish and auto-release workflows + ([`992fa64`](https://github.com/OpenAdaptAI/openadapt-tray/commit/992fa640903fdee6dc9a2a035e9196a6a5be9d56)) + +- publish.yml: Triggered on tags, publishes to PyPI - release.yml: Auto-bumps version on PR merge, + creates tags + +Co-Authored-By: Claude Opus 4.5 + +- Switch to python-semantic-release for automated versioning + ([`3975bfc`](https://github.com/OpenAdaptAI/openadapt-tray/commit/3975bfcb6fa6de6009d6ddf37c64e99e83e53d0e)) + +Replaces manual commit parsing with python-semantic-release: - Automatic version bumping based on + conventional commits - feat: -> minor, fix:/perf: -> patch - Creates GitHub releases automatically + - Publishes to PyPI on release + +Co-Authored-By: Claude Opus 4.5 diff --git a/packages/tray/LICENSE b/packages/tray/LICENSE new file mode 100644 index 0000000000..f9273392b6 --- /dev/null +++ b/packages/tray/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenAdapt AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/tray/NOTIFICATIONS.md b/packages/tray/NOTIFICATIONS.md new file mode 100644 index 0000000000..eb345f97d4 --- /dev/null +++ b/packages/tray/NOTIFICATIONS.md @@ -0,0 +1,359 @@ +# Notification System + +OpenAdapt Tray uses [desktop-notifier](https://github.com/samschott/desktop-notifier) for modern, native notifications across all platforms. This provides a rich notification experience with support for callbacks, action buttons, and platform-specific features. + +## Overview + +The notification system is implemented in `src/openadapt_tray/notifications.py` and provides: + +- **Cross-platform support**: Works on macOS, Windows, and Linux +- **Native notifications**: Uses platform-native notification centers +- **Click callbacks**: Respond to user interactions +- **Action buttons**: Add interactive buttons (platform-dependent) +- **Reply fields**: Support text replies on macOS +- **Urgency levels**: Normal, low, and critical priorities +- **Graceful fallback**: Falls back to AppleScript/PowerShell if desktop-notifier unavailable + +## Basic Usage + +```python +from openadapt_tray.notifications import NotificationManager + +# Initialize +notifications = NotificationManager() + +# Show a simple notification +notifications.show( + title="OpenAdapt", + body="Recording started successfully!" +) + +# Cleanup when done +notifications.cleanup() +``` + +## Advanced Features + +### Click Callbacks + +Respond when users click on notifications: + +```python +def on_notification_clicked(): + print("User clicked the notification!") + # Open dashboard, show details, etc. + +notifications.show( + title="Recording Complete", + body="Click to view details", + on_clicked=on_notification_clicked +) +``` + +### Urgency Levels + +Control notification priority: + +```python +# Low priority +notifications.show( + title="Info", + body="Background task complete", + urgency="low" +) + +# Critical priority (stays visible longer, may bypass Do Not Disturb) +notifications.show( + title="Error", + body="Recording failed!", + urgency="critical" +) +``` + +### Action Buttons (Platform-Dependent) + +Add interactive buttons to notifications: + +```python +def on_click(): + print("User interacted with notification") + +notifications.show( + title="Training Complete", + body="Choose what to do next", + buttons=["View Results", "Start New Training", "Dismiss"], + on_clicked=on_click +) +``` + +**Note**: Action button support varies by platform: +- **macOS**: Full support via Notification Center +- **Windows**: Limited support +- **Linux**: Depends on desktop environment + +### Reply Fields (macOS Only) + +Add text input to notifications on macOS: + +```python +notifications.show( + title="Name this recording", + body="Enter a name for your capture", + reply_field="Recording name", + on_clicked=lambda: print("User submitted reply") +) +``` + +### Icon Support + +Display custom icons with notifications: + +```python +notifications.show( + title="OpenAdapt", + body="Custom icon notification", + icon_path="/path/to/icon.png" +) +``` + +## Async API + +For async contexts, use the async interface: + +```python +import asyncio + +async def send_notification(): + await notifications.show_async( + title="Async Notification", + body="Sent from async context" + ) + +asyncio.run(send_notification()) +``` + +## Architecture + +### Backend Detection + +The NotificationManager automatically selects the best backend: + +1. **desktop-notifier** (preferred): If available, uses native notification APIs + - macOS: Notification Center framework + - Windows: WinRT Python bridge + - Linux: DBus (org.freedesktop.Notifications) + +2. **Fallback implementations**: If desktop-notifier unavailable + - macOS: AppleScript `display notification` + - Windows: PowerShell toast notifications or pystray + - Linux: `notify-send` command + +### Event Loop Management + +desktop-notifier requires asyncio. The NotificationManager: +- Creates/reuses an event loop automatically +- Handles both sync and async contexts +- Cleans up resources on shutdown + +```python +# The event loop is managed internally +notifications = NotificationManager() + +# Works in sync contexts +notifications.show("Title", "Body") + +# Also works in async contexts +await notifications.show_async("Title", "Body") + +# Clean up when done +notifications.cleanup() +``` + +## Integration with TrayApplication + +The notification system integrates seamlessly with the tray app: + +```python +# In app.py +class TrayApplication: + def __init__(self): + self.notifications = NotificationManager() + # ... other setup + + def _on_state_change(self, state: AppState): + if self.config.show_notifications: + self.notifications.show( + title="State Changed", + body=f"Now {state.state}" + ) + + def quit(self): + self.notifications.cleanup() + # ... other cleanup +``` + +## Configuration + +Notifications can be configured via `tray.json`: + +```json +{ + "show_notifications": true, + "notification_duration_ms": 5000 +} +``` + +- `show_notifications`: Enable/disable notifications (default: true) +- `notification_duration_ms`: Advisory duration in milliseconds (default: 5000) + +**Note**: The actual display duration is controlled by the OS and may differ. + +## Platform-Specific Notes + +### macOS + +- **Signing requirement**: Python must be properly signed for notifications on macOS 10.14+ + - Official python.org installer: Works out of the box + - Homebrew Python: May not show notifications (unsigned) + +- **Notification Center**: Notifications appear in Notification Center +- **Do Not Disturb**: Respects system DND settings (except critical urgency) +- **Grouping**: Notifications group by app name ("OpenAdapt") + +### Windows + +- **Windows 10+**: Full support via WinRT +- **Older Windows**: Falls back to PowerShell or pystray +- **Action Center**: Notifications appear in Action Center +- **Focus Assist**: Respects Focus Assist settings + +### Linux + +- **Requirements**: Requires `notify-send` or compatible notification daemon +- **Desktop environments**: Works with GNOME, KDE, XFCE, etc. +- **Fallback**: Uses `notify-send` command if desktop-notifier unavailable + +## Testing + +Test notifications manually: + +```bash +# Run the test script +cd /Users/abrichr/oa/src/openadapt-tray +python test_notification_simple.py +``` + +Test script shows: +1. Basic notification +2. Critical notification +3. Notification with callback + +Check your system's Notification Center to verify notifications appear correctly. + +## Troubleshooting + +### Notifications not appearing on macOS + +1. Check Python signing: + ```bash + codesign -dv /path/to/python + ``` + +2. Use official python.org installer instead of Homebrew + +3. Check Notification Center settings: + - System Preferences → Notifications + - Ensure Python/Terminal is allowed + +### Notifications not appearing on Windows + +1. Check Focus Assist settings +2. Ensure Windows 10+ or fallback dependencies available +3. Check Action Center settings + +### Notifications not appearing on Linux + +1. Install notification daemon: + ```bash + # Ubuntu/Debian + sudo apt install libnotify-bin + + # Fedora + sudo dnf install libnotify + ``` + +2. Check desktop environment supports notifications + +## API Reference + +### NotificationManager + +#### `__init__()` +Initialize the notification manager. Automatically detects the best backend. + +#### `show(title, body, icon_path=None, duration_ms=5000, on_clicked=None, urgency="normal", buttons=None, reply_field=None) -> bool` +Show a notification. + +**Parameters:** +- `title` (str): Notification title +- `body` (str): Notification body text +- `icon_path` (str, optional): Path to icon image +- `duration_ms` (int): Advisory duration in milliseconds +- `on_clicked` (callable, optional): Callback when notification is clicked +- `urgency` (str): "low", "normal", or "critical" +- `buttons` (list, optional): List of button labels +- `reply_field` (str, optional): Reply field placeholder (macOS only) + +**Returns:** `bool` - True if successful + +#### `show_async(...) -> bool` +Async version of `show()`. Use in async contexts. + +#### `set_tray_icon(icon)` +Set pystray icon for Windows fallback notifications. + +#### `cleanup()` +Clean up resources. Call when shutting down the application. + +## Migration from Old Implementation + +If migrating from the old AppleScript/pystray implementation: + +### Old API (Still Supported) +```python +# Basic usage still works +notifications.show("Title", "Body") +notifications.show("Title", "Body", duration_ms=3000) +``` + +### New Capabilities +```python +# Now you can also use: +notifications.show( + "Title", + "Body", + on_clicked=callback, + urgency="critical", + buttons=["Action 1", "Action 2"] +) +``` + +The API is backward compatible - existing code continues to work without changes. + +## Future Enhancements + +Potential improvements: + +1. **Notification history**: Track and replay recent notifications +2. **Custom sounds**: Per-notification sound support +3. **Rich media**: Images and videos in notification body +4. **Progress notifications**: Show progress bars in notifications +5. **Scheduled notifications**: Send notifications at specific times + +## References + +- [desktop-notifier GitHub](https://github.com/samschott/desktop-notifier) +- [desktop-notifier Documentation](https://desktop-notifier.readthedocs.io/) +- [macOS Notification Center](https://developer.apple.com/documentation/usernotifications) +- [Windows Notifications](https://docs.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/) +- [Linux Desktop Notifications](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html) diff --git a/packages/tray/README.md b/packages/tray/README.md new file mode 100644 index 0000000000..64a808ab66 --- /dev/null +++ b/packages/tray/README.md @@ -0,0 +1,173 @@ +# openadapt-tray + +[![PyPI version](https://img.shields.io/pypi/v/openadapt-tray.svg)](https://pypi.org/project/openadapt-tray/) +[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/tray/LICENSE) + +openadapt-tray puts OpenAdapt in the macOS menu bar or the Windows and Linux +system tray. You can start and stop recordings from its menu or with a hotkey, +see whether OpenAdapt Desktop is recording or compiling a recording into a +workflow, and see how many of your automations in OpenAdapt Cloud stopped and +need a person. + +It's for people who use +[OpenAdapt Desktop](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/desktop) and want +those controls without opening the app window. The tray doesn't record or run +anything itself. OpenAdapt Desktop records, and +[openadapt-flow](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/flow) compiles and +replays workflows. The tray sends them commands and shows what they report. + +## Quick start + +```bash +pip install openadapt-tray +openadapt-tray +``` + +The OpenAdapt icon appears in the menu bar or system tray. When you select +**Start Recording**, the tray asks for a capture name, starts the installed +OpenAdapt Desktop app if it isn't running, and asks it to record. + +Without the desktop app, the tray runs, but recording can't start. OpenAdapt +Desktop has no trusted signed build to download yet +([its README](https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/desktop#quick-start) explains), +and the `openadapt-desktop` command that pip installs doesn't open the socket +the tray connects to. + +On macOS, the global hotkeys need permission to read the keyboard. Allow the +app that starts the tray, such as Terminal, in **System Settings** > +**Privacy & Security** under **Accessibility** and **Input Monitoring**. The +menu works without them. + +Once OpenAdapt Desktop is signed in to OpenAdapt Cloud, the tray checks for +automations that need attention. It reads the same token from the OS keychain, +or from the `OPENADAPT_INGEST_TOKEN` environment variable. + +## The menu + +```text +Start Recording (⌃⇧R) Ctrl+Shift+R on Windows and Linux +Recent Captures View opens a capture's folder +⚠ 2 automations need attention shown only when the count is above zero +Open Desktop App +Open Cloud Dashboard +Account: connected · 30 days left or "Account: sign in" when signed out +Pause Sync "Resume Sync" while paused, + "Sync (offline)" when signed out or offline +Settings... opens tray.json +Quit +``` + +While OpenAdapt Desktop works, the recording item changes to **Starting...**, +**Stop Recording**, **Stopping...**, or **Compiling...**, and the icon changes +color: + +| Idle | Starting or stopping | Recording, or an error | Compiling | +| :---: | :---: | :---: | :---: | +| Blue OpenAdapt icon | Amber OpenAdapt icon | Red OpenAdapt icon | Purple OpenAdapt icon | + +A red dot on the icon means automations need attention. Selecting the +needs-attention item opens the cloud dashboard. If `deployment_lane` is `byoc`, +the setting for keeping recordings on this machine, the item opens Teach in +OpenAdapt Desktop instead and never opens the cloud dashboard. Either +`tray.json` or OpenAdapt Desktop can set it. + +[PROTOCOL.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/tray/docs/PROTOCOL.md) +describes the local socket, the one cloud request, and how the tray handles the +token. + +## Configuration + +**Settings...** opens `tray.json` in a text editor and creates it with the +defaults if it doesn't exist. Changes take effect the next time the tray starts. +The file is at: + +- macOS: `~/Library/Application Support/openadapt/tray.json` +- Windows: `%APPDATA%\openadapt\tray.json` +- Linux: `${XDG_CONFIG_HOME:-~/.config}/openadapt/tray.json` + +The settings you're most likely to change: + +```json +{ + "hotkeys": { + "toggle_recording": "++r", + "open_dashboard": "++d" + }, + "stop_on_triple_ctrl": true, + "hosted_url": "https://app.openadapt.ai", + "deployment_lane": "cloud", + "poll_interval_s": 60, + "show_notifications": true +} +``` + +- Hotkeys use [pynput](https://pynput.readthedocs.io/) syntax. + `open_dashboard` opens the workflow library in OpenAdapt Desktop. +- `stop_on_triple_ctrl` stops a recording when you press Ctrl three times, + each within half a second of the last. +- `deployment_lane` is `cloud` or `byoc`. +- `captures_directory` is the folder that **Recent Captures** lists. It + defaults to `~/openadapt/captures`. +- `poll_interval_s` sets how often, in seconds, the tray checks OpenAdapt + Cloud. The minimum is 30. + +The token never goes in this file. The file that **Settings...** creates also +has keys the tray doesn't use: `dashboard_port`, `auto_launch_dashboard`, +`auto_start_on_login`, `minimize_to_tray`, `default_record_audio`, +`default_transcribe`, and `hotkeys.stop_recording`. + +## Development + +```bash +git clone https://github.com/OpenAdaptAI/OpenAdapt.git +cd OpenAdapt/packages/tray +uv sync --extra dev +uv run pytest tests -q +uv run ruff check . +uv run openadapt-tray +``` + +```text +src/openadapt_tray/ + app.py tray lifecycle, desktop delegation, and click routing + ipc.py client for OpenAdapt Desktop's loopback socket + hosted.py needs-attention poller and lane routing + state.py recording, sync, and badge state + menu.py the menu + icons.py per-state icon tinting + shortcuts.py global hotkeys + keychain.py ingest-token lookup + config.py tray.json + desktop.py finds and starts the installed OpenAdapt Desktop app + notifications.py desktop notifications + platform/ per-OS dialogs and opening tray.json +``` + +To try OpenAdapt itself from the command line, start with the +[OpenAdapt launcher](https://github.com/OpenAdaptAI/OpenAdapt). + +## Limits + +- The tray doesn't record, compile, replay, or check workflow results. It + shows what OpenAdapt Desktop and OpenAdapt Cloud report. +- OpenAdapt Desktop's CI tests this tray's socket client against the desktop's + server. The two apps haven't been run together end to end from packaged + installs. +- The quick start was checked by hand on macOS 15: the tray starts and shows + this menu. Recording through OpenAdapt Desktop and the global hotkeys weren't + tried. On Windows and Linux, only the unit tests run in CI. +- On macOS, a tray installed with pip sends notifications through AppleScript, + so clicking a needs-attention notification doesn't open the dashboard. + Notification clicks haven't been tried on Windows or Linux. +- The cloud poller is tested against mocked responses, not a live account. +- There's no installer, and the tray doesn't start at login. +- OpenAdapt Desktop saves its captures in `~/.openadapt/captures` and manages + them itself, so they don't appear under **Recent Captures** by default. Open + them from the desktop app. +- **Account: sign in** opens the token page of the cloud dashboard. The tray + has no sign-in flow of its own. + +## License + +[MIT](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/tray/LICENSE) diff --git a/packages/tray/SECURITY.md b/packages/tray/SECURITY.md new file mode 100644 index 0000000000..69b270fbaf --- /dev/null +++ b/packages/tray/SECURITY.md @@ -0,0 +1,35 @@ +# Security Policy + +## Reporting a Vulnerability + +We take the security of OpenAdapt seriously. If you believe you have found a +security vulnerability in this repository, please report it **privately** — do +**not** open a public issue, discussion, or pull request that describes it. + +**Preferred:** use GitHub's private vulnerability reporting. Go to the +repository's **Security** tab and click **Report a vulnerability**. This opens a +private advisory visible only to the maintainers. + +**Alternative:** email **hello@openadapt.ai** with the details. + +Please include, where possible: + +- A description of the vulnerability and its potential impact +- Steps to reproduce (a proof-of-concept if you have one) +- Affected version(s), commit, endpoint, or configuration +- Any suggested remediation + +## Our commitment + +- We will **acknowledge** your report within **5 business days**. +- We will provide an estimated timeline for a fix and keep you updated on + progress. +- We will credit reporters who wish to be acknowledged once a fix has shipped. +- Please give us a reasonable opportunity to remediate before any public + disclosure. + +## Supported versions + +Security fixes are applied to the latest released version on the `main` branch. + +Thank you for helping keep OpenAdapt and its users safe. diff --git a/packages/tray/assets/icons/compiling.png b/packages/tray/assets/icons/compiling.png new file mode 100644 index 0000000000..a96c95e07f Binary files /dev/null and b/packages/tray/assets/icons/compiling.png differ diff --git a/packages/tray/assets/icons/compiling@2x.png b/packages/tray/assets/icons/compiling@2x.png new file mode 100644 index 0000000000..6d8e6ce891 Binary files /dev/null and b/packages/tray/assets/icons/compiling@2x.png differ diff --git a/packages/tray/assets/icons/error.png b/packages/tray/assets/icons/error.png new file mode 100644 index 0000000000..88cbb366c6 Binary files /dev/null and b/packages/tray/assets/icons/error.png differ diff --git a/packages/tray/assets/icons/error@2x.png b/packages/tray/assets/icons/error@2x.png new file mode 100644 index 0000000000..010b368533 Binary files /dev/null and b/packages/tray/assets/icons/error@2x.png differ diff --git a/packages/tray/assets/icons/idle.png b/packages/tray/assets/icons/idle.png new file mode 100644 index 0000000000..031e31a3ee Binary files /dev/null and b/packages/tray/assets/icons/idle.png differ diff --git a/packages/tray/assets/icons/idle@2x.png b/packages/tray/assets/icons/idle@2x.png new file mode 100644 index 0000000000..d0942bf7fe Binary files /dev/null and b/packages/tray/assets/icons/idle@2x.png differ diff --git a/packages/tray/assets/icons/recording.png b/packages/tray/assets/icons/recording.png new file mode 100644 index 0000000000..88cbb366c6 Binary files /dev/null and b/packages/tray/assets/icons/recording.png differ diff --git a/packages/tray/assets/icons/recording@2x.png b/packages/tray/assets/icons/recording@2x.png new file mode 100644 index 0000000000..010b368533 Binary files /dev/null and b/packages/tray/assets/icons/recording@2x.png differ diff --git a/packages/tray/assets/icons/recording_starting.png b/packages/tray/assets/icons/recording_starting.png new file mode 100644 index 0000000000..3e7a4e9dbb Binary files /dev/null and b/packages/tray/assets/icons/recording_starting.png differ diff --git a/packages/tray/assets/icons/recording_starting@2x.png b/packages/tray/assets/icons/recording_starting@2x.png new file mode 100644 index 0000000000..92ed691401 Binary files /dev/null and b/packages/tray/assets/icons/recording_starting@2x.png differ diff --git a/packages/tray/assets/icons/recording_stopping.png b/packages/tray/assets/icons/recording_stopping.png new file mode 100644 index 0000000000..3e7a4e9dbb Binary files /dev/null and b/packages/tray/assets/icons/recording_stopping.png differ diff --git a/packages/tray/assets/icons/recording_stopping@2x.png b/packages/tray/assets/icons/recording_stopping@2x.png new file mode 100644 index 0000000000..92ed691401 Binary files /dev/null and b/packages/tray/assets/icons/recording_stopping@2x.png differ diff --git a/packages/tray/assets/logo.ico b/packages/tray/assets/logo.ico new file mode 100644 index 0000000000..d35a1fbe7a Binary files /dev/null and b/packages/tray/assets/logo.ico differ diff --git a/packages/tray/assets/mark-master.png b/packages/tray/assets/mark-master.png new file mode 100644 index 0000000000..2e8c78a81f Binary files /dev/null and b/packages/tray/assets/mark-master.png differ diff --git a/packages/tray/assets/mark.svg b/packages/tray/assets/mark.svg new file mode 100644 index 0000000000..29c23d5cfa --- /dev/null +++ b/packages/tray/assets/mark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/tray/docs/PROTOCOL.md b/packages/tray/docs/PROTOCOL.md new file mode 100644 index 0000000000..97f9f24325 --- /dev/null +++ b/packages/tray/docs/PROTOCOL.md @@ -0,0 +1,98 @@ +# How openadapt-tray talks to OpenAdapt + +The tray has two connections: a local socket to OpenAdapt Desktop, and one +authenticated read from OpenAdapt Cloud. It doesn't record, compile, or replay +anything itself. + +```text +openadapt-tray + | \ + | loopback socket, per-session token \ HTTPS: GET needs-attention count + v v +OpenAdapt Desktop OpenAdapt Cloud (app.openadapt.ai) + | + v +openadapt-flow (compile, replay, repair) +``` + +## OpenAdapt Desktop + +When its socket server starts, OpenAdapt Desktop writes a discovery file to +`~/.openadapt/desktop_ipc.json`: + +```json +{"protocol_version": 1, "host": "127.0.0.1", "port": 50123, "token": ""} +``` + +The tray reads that file and opens a TCP connection to the loopback address. +Messages are newline-delimited JSON, and every command carries the token so the +desktop app can reject other local processes. The tray refuses a discovery file +whose `protocol_version` isn't `1`. When it connects, the tray asks the +desktop app for a status snapshot. + +| Direction | Messages | +| --- | --- | +| Tray to desktop | `start_recording`, `stop_recording`, `get_status`, `open_workflow_library`, `open_teach`, `pause_sync`, `resume_sync` | +| Desktop to tray | `recording_started`, `recording_stopped`, `recording_error`, `status_update`, `compile_progress`, `sync_state`, `break_count` | + +If there is no discovery file when you select an action that needs the desktop +app, the tray starts the installed app and waits up to about 10 seconds for the +file to appear: + +- macOS: `open -b ai.openadapt.desktop` +- Windows: `OpenAdapt Desktop\openadapt-desktop.exe` under `%LOCALAPPDATA%`, + `%ProgramFiles%`, or `%ProgramFiles(x86)%` +- Linux: `gtk-launch "OpenAdapt Desktop"`, then `/usr/bin/openadapt-desktop`, + then the AppImage named by `OPENADAPT_DESKTOP_APPIMAGE` + +The tray never runs a Python `openadapt-desktop` console script. + +OpenAdapt Desktop's CI runs this package's IPC client, from the same +monorepo commit, against the desktop's socket server +([`packages/desktop/tests/test_tray_integration.py`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/desktop/tests/test_tray_integration.py)). + +## OpenAdapt Cloud + +The tray makes one request: + +```text +GET /api/needs-attention/count +Authorization: Bearer +``` + +```json +{ + "count": 0, + "credential": { + "expires_at": "2026-08-05T12:00:00Z", + "expires_in_days": 8, + "expiring_soon": true, + "legacy_non_expiring": false, + "warning_days": 14 + } +} +``` + +- The token comes from `OPENADAPT_INGEST_TOKEN` or from the OS keychain entry + that OpenAdapt Desktop writes (service `ai.openadapt.desktop`, account + ``). It is never read from or written to `tray.json`, and never + logged. +- The tray polls every 60 seconds by default, never more often than every 30 + seconds, and every 5 minutes while offline. +- A response without a readable integer `count` is treated as a failed poll. + The badge keeps its last value instead of dropping to zero. +- OpenAdapt Cloud decides when a credential is expiring soon. The tray shows + one notification per credential and expiry date, and remembers which ones it + showed in `credential-warning-state.json` next to `tray.json`. That file holds + only hashes, never the token. +- No screenshots, recordings, or workflow data go through this request. + +## Needs-attention clicks + +| Lane | What a click opens | +| --- | --- | +| `cloud` | `/dashboard` in the default browser | +| `byoc` | Teach in OpenAdapt Desktop, through `open_teach`. If the desktop app isn't running, the tray starts it first. If that fails, the tray says so and opens nothing else. | + +The lane is `byoc` when either `tray.json` or OpenAdapt Desktop's status report +says so. On `byoc`, the tray never opens the cloud dashboard. diff --git a/packages/tray/examples/README.md b/packages/tray/examples/README.md new file mode 100644 index 0000000000..0e9c765b05 --- /dev/null +++ b/packages/tray/examples/README.md @@ -0,0 +1,100 @@ +# OpenAdapt Tray Examples + +This directory contains example scripts demonstrating various features of openadapt-tray. + +## Notification Examples + +### notification_examples.py + +Comprehensive examples showing all notification features: + +```bash +python examples/notification_examples.py +``` + +Examples include: +1. Basic notification +2. Recording started notification +3. Critical error notification +4. Notification with click callback +5. Notification with custom icon +6. Notification with action buttons +7. Notification with reply field (macOS) +8. Sequence of notifications +9. Conditional notifications based on config +10. State-based notifications + +### Quick Test Scripts + +Located in the root directory for easy access: + +**test_notification_simple.py** - Quick test of basic notification features: +```bash +python test_notification_simple.py +``` + +Shows: +- Backend detection +- Basic notification +- Critical notification +- Callback notification + +**test_notification.py** - Interactive tests with user interaction: +```bash +python test_notification.py +``` + +Includes wait times for user to click notifications and test callbacks. + +## Running Examples + +All examples can be run directly: + +```bash +# From the openadapt-tray root directory +cd /path/to/openadapt-tray + +# Run the comprehensive examples +python examples/notification_examples.py + +# Run quick tests +python test_notification_simple.py +``` + +## Adding New Examples + +To add a new example: + +1. Create a new Python file in this directory +2. Import from `openadapt_tray` modules +3. Add clear comments and docstrings +4. Update this README with a description + +Example template: + +```python +#!/usr/bin/env python3 +"""Description of what this example demonstrates.""" + +import sys +from pathlib import Path + +# Add parent src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from openadapt_tray.notifications import NotificationManager + +def main(): + """Run the example.""" + # Your example code here + pass + +if __name__ == "__main__": + main() +``` + +## Documentation + +For detailed notification system documentation, see: +- [NOTIFICATIONS.md](../NOTIFICATIONS.md) - Complete notification system guide +- [README.md](../README.md) - Main project documentation diff --git a/packages/tray/examples/notification_examples.py b/packages/tray/examples/notification_examples.py new file mode 100755 index 0000000000..6dd6c23f9b --- /dev/null +++ b/packages/tray/examples/notification_examples.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +OpenAdapt Tray Notification Examples + +This file demonstrates various ways to use the notification system +in your OpenAdapt Tray application. +""" + +import sys +import time +from pathlib import Path + +# Add parent src to path for examples +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from openadapt_tray.notifications import NotificationManager + + +def example_basic(): + """Example 1: Basic notification.""" + print("Example 1: Basic Notification") + print("-" * 60) + + notifications = NotificationManager() + + # Simplest form - just title and body + notifications.show( + title="OpenAdapt", + body="This is a basic notification" + ) + + print("✓ Sent basic notification") + notifications.cleanup() + + +def example_recording_started(): + """Example 2: Recording started notification.""" + print("\nExample 2: Recording Started") + print("-" * 60) + + notifications = NotificationManager() + + # Notify user that recording has started + notifications.show( + title="Recording Started", + body="Capturing: my-awesome-workflow", + urgency="normal" + ) + + print("✓ Sent recording notification") + notifications.cleanup() + + +def example_critical_error(): + """Example 3: Critical error notification.""" + print("\nExample 3: Critical Error") + print("-" * 60) + + notifications = NotificationManager() + + # Show critical error - high priority + notifications.show( + title="Recording Failed", + body="Could not access screen capture permissions", + urgency="critical" + ) + + print("✓ Sent critical error notification") + notifications.cleanup() + + +def example_with_callback(): + """Example 4: Notification with click callback.""" + print("\nExample 4: Notification with Callback") + print("-" * 60) + + notifications = NotificationManager() + + # Track if notification was clicked + clicked = {"value": False} + + def on_notification_clicked(): + """Called when user clicks the notification.""" + print(" [User clicked the notification!]") + clicked["value"] = True + # In a real app, you might: + # - Open a dashboard + # - Show details window + # - Bring app to foreground + + notifications.show( + title="Training Complete", + body="Click to view results", + on_clicked=on_notification_clicked + ) + + print("✓ Sent notification with callback") + print(" (Click the notification to test the callback)") + + # Note: In a real app, the callback happens asynchronously + # Here we just demonstrate the API + notifications.cleanup() + + +def example_with_icon(): + """Example 5: Notification with custom icon.""" + print("\nExample 5: Notification with Icon") + print("-" * 60) + + notifications = NotificationManager() + + # Assume we have an icon file + icon_path = Path(__file__).parent.parent / "assets" / "icons" / "recording.png" + + if icon_path.exists(): + notifications.show( + title="OpenAdapt", + body="Notification with custom icon", + icon_path=str(icon_path) + ) + print(f"✓ Sent notification with icon: {icon_path}") + else: + # Send without icon if not found + notifications.show( + title="OpenAdapt", + body="Custom icons require icon files in assets/icons/" + ) + print("✓ Sent notification (icon file not found)") + + notifications.cleanup() + + +def example_action_buttons(): + """Example 6: Notification with action buttons (platform-dependent).""" + print("\nExample 6: Notification with Action Buttons") + print("-" * 60) + + notifications = NotificationManager() + + def on_button_clicked(): + """Called when user interacts with notification.""" + print(" [User clicked an action button!]") + # In desktop-notifier, you can determine which button + # was clicked via the callback parameter + + notifications.show( + title="Recording Ready", + body="What would you like to do?", + buttons=["Play", "Edit", "Delete"], + on_clicked=on_button_clicked + ) + + print("✓ Sent notification with action buttons") + print(" (Note: Button support varies by platform)") + notifications.cleanup() + + +def example_reply_field(): + """Example 7: Notification with reply field (macOS only).""" + print("\nExample 7: Notification with Reply Field") + print("-" * 60) + + notifications = NotificationManager() + + def on_reply_submitted(): + """Called when user submits reply.""" + print(" [User submitted a reply!]") + # In desktop-notifier, the reply text is passed to the callback + + notifications.show( + title="Name Your Recording", + body="Enter a name for this capture", + reply_field="Recording name", + on_clicked=on_reply_submitted + ) + + print("✓ Sent notification with reply field") + print(" (Note: Reply fields only work on macOS)") + notifications.cleanup() + + +def example_notification_sequence(): + """Example 8: Sequence of notifications (simulating workflow).""" + print("\nExample 8: Notification Sequence") + print("-" * 60) + + notifications = NotificationManager() + + # Step 1: Starting + notifications.show( + title="Processing Started", + body="Analyzing recorded actions...", + urgency="low" + ) + print("✓ Step 1: Started processing") + + # Simulate some work + time.sleep(1) + + # Step 2: Progress + notifications.show( + title="Processing", + body="50% complete - extracting patterns...", + urgency="normal" + ) + print("✓ Step 2: Progress update") + + # Simulate more work + time.sleep(1) + + # Step 3: Complete + notifications.show( + title="Processing Complete", + body="Successfully analyzed 127 actions", + urgency="normal" + ) + print("✓ Step 3: Complete") + + notifications.cleanup() + + +def example_conditional_notifications(): + """Example 9: Conditional notifications based on settings.""" + print("\nExample 9: Conditional Notifications") + print("-" * 60) + + notifications = NotificationManager() + + # Simulate config + config = { + "show_notifications": True, + "notify_on_start": True, + "notify_on_complete": True, + "notify_on_error": True, + } + + # Recording started + if config["show_notifications"] and config["notify_on_start"]: + notifications.show( + title="Recording Started", + body="Capture in progress..." + ) + print("✓ Sent start notification (enabled in config)") + + # Recording complete + if config["show_notifications"] and config["notify_on_complete"]: + notifications.show( + title="Recording Complete", + body="Saved successfully" + ) + print("✓ Sent completion notification (enabled in config)") + + # Simulating disabled notification + config["notify_on_error"] = False + if config["show_notifications"] and config["notify_on_error"]: + notifications.show( + title="Error", + body="This won't show" + ) + else: + print("✓ Skipped error notification (disabled in config)") + + notifications.cleanup() + + +def example_state_based_notifications(): + """Example 10: State-based notifications (from TrayApplication pattern).""" + print("\nExample 10: State-Based Notifications") + print("-" * 60) + + notifications = NotificationManager() + + # Simulate different application states + states = { + "IDLE": {"title": "Ready", "body": "OpenAdapt is ready"}, + "RECORDING": {"title": "Recording", "body": "Capture in progress"}, + "TRAINING": {"title": "Training", "body": "Model training started"}, + "ERROR": {"title": "Error", "body": "Something went wrong", "urgency": "critical"}, + } + + # Show notification for each state + for state_name, notification in states.items(): + notifications.show( + title=notification["title"], + body=notification["body"], + urgency=notification.get("urgency", "normal") + ) + print(f"✓ {state_name}: {notification['title']}") + time.sleep(0.5) + + notifications.cleanup() + + +def main(): + """Run all examples.""" + print("=" * 60) + print("OpenAdapt Tray - Notification Examples") + print("=" * 60) + print() + + examples = [ + example_basic, + example_recording_started, + example_critical_error, + example_with_callback, + example_with_icon, + example_action_buttons, + example_reply_field, + example_notification_sequence, + example_conditional_notifications, + example_state_based_notifications, + ] + + try: + for i, example_func in enumerate(examples, 1): + example_func() + if i < len(examples): + time.sleep(2) # Brief pause between examples + + print("\n" + "=" * 60) + print("All examples completed!") + print("Check your system's Notification Center to see the results.") + print("=" * 60) + + except KeyboardInterrupt: + print("\n\nExamples interrupted by user") + except Exception as e: + print(f"\n\nError running examples: {e}") + + +if __name__ == "__main__": + main() diff --git a/packages/tray/generate_screenshots.py b/packages/tray/generate_screenshots.py new file mode 100755 index 0000000000..6f987865f7 --- /dev/null +++ b/packages/tray/generate_screenshots.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Screenshot generation helper for OpenAdapt Tray documentation. + +This script helps capture screenshots by triggering notifications and UI states +in a controlled sequence, giving you time to capture screenshots manually. + +Usage: + python generate_screenshots.py + +Requirements: + - openadapt-tray installed and working + - Manual screenshot tool ready (Cmd+Shift+4 on macOS) + - Notification Center visible + +Screenshots to capture: + 1. Tray icon (idle state) + 2. Tray icon (recording state) + 3. Tray icon (compiling state) + 4. Tray icon (break badge — automations need attention) + 5. Tray icon (error state) + 6. Basic notification popup + 7. Needs-attention notification popup + 8. Menu structure + 9. Notification Center with grouped notifications + 10. Settings/configuration (if available) +""" + +import sys +import time +from pathlib import Path + +# Try to import the notification system +try: + from openadapt_tray.notifications import NotificationManager + NOTIFICATIONS_AVAILABLE = True +except ImportError: + NOTIFICATIONS_AVAILABLE = False + print("WARNING: openadapt_tray not available. Install with: pip install -e .") + +# Screenshot save location +SCREENSHOT_DIR = Path(__file__).parent / "docs" / "screenshots" +SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) + +def countdown(seconds, message=""): + """Display countdown timer.""" + for i in range(seconds, 0, -1): + print(f"\r{message} in {i} seconds... ", end="", flush=True) + time.sleep(1) + print("\r" + " " * 80, end="\r") # Clear line + + +def main(): + """Run the screenshot generation sequence.""" + print("=" * 80) + print("OpenAdapt Tray Screenshot Generation Helper") + print("=" * 80) + print() + print("This script will trigger various notifications and UI states.") + print("You should manually capture screenshots using:") + print(" • macOS: Cmd+Shift+4 (selection) or Cmd+Shift+5 (screen recording)") + print(" • Windows: Win+Shift+S or Snipping Tool") + print(" • Linux: gnome-screenshot or flameshot") + print() + print(f"Save screenshots to: {SCREENSHOT_DIR}") + print() + + if not NOTIFICATIONS_AVAILABLE: + print("ERROR: openadapt_tray not available!") + print("Install with: pip install -e .") + sys.exit(1) + + input("Press Enter when ready to start...") + print() + + # Initialize notification manager + notifications = NotificationManager() + + print(f"Backend detected: {notifications._backend}") + print() + + # Sequence of screenshots + screenshots = [ + { + "name": "01-idle-icon", + "description": "Tray icon in IDLE state", + "action": lambda: print(" → Check menu bar for idle icon (blue/gray)"), + "wait": 3, + "instructions": "Capture: Tray icon in idle state" + }, + { + "name": "02-basic-notification", + "description": "Basic notification popup", + "action": lambda: notifications.show( + "Recording Started", + "Capturing: my-workflow" + ), + "wait": 2, + "instructions": "Capture: Notification popup (Recording Started)" + }, + { + "name": "03-needs-attention-notification", + "description": "Needs-attention notification (break badge source)", + "action": lambda: notifications.show( + "Automations need attention", + "2 automations need attention", + urgency="critical", + on_clicked=lambda: print(" → Click routes to needs-attention"), + ), + "wait": 2, + "instructions": "Capture: Needs-attention notification (critical urgency)" + }, + { + "name": "04-compiling-notification", + "description": "Compiling notification (recording → workflow)", + "action": lambda: notifications.show( + "Compiling", + "Building a workflow from: my-workflow", + ), + "wait": 2, + "instructions": "Capture: Compiling notification" + }, + { + "name": "05-multiple-notifications", + "description": "Multiple notifications (grouping)", + "action": lambda: [ + notifications.show("Recording Started", "Capture session 1"), + time.sleep(0.5), + notifications.show("Recording Started", "Capture session 2"), + time.sleep(0.5), + notifications.show("Recording Started", "Capture session 3"), + ], + "wait": 2, + "instructions": "Capture: Notification Center showing grouped notifications" + }, + { + "name": "06-recording-icon", + "description": "Tray icon in RECORDING state", + "action": lambda: print(" → NOTE: Recording icon requires running tray app"), + "wait": 2, + "instructions": "Capture: Recording icon (red - requires running app)" + }, + { + "name": "07-compiling-icon", + "description": "Tray icon in COMPILING state", + "action": lambda: print(" → NOTE: Compiling icon requires running tray app"), + "wait": 2, + "instructions": "Capture: Compiling icon (purple - requires running app)" + }, + { + "name": "07b-break-badge-icon", + "description": "Tray icon with break badge (needs attention)", + "action": lambda: print(" → NOTE: Badge icon requires running tray app with break_count > 0"), + "wait": 2, + "instructions": "Capture: Idle/recording icon with red break badge dot" + }, + { + "name": "08-error-icon", + "description": "Tray icon in ERROR state", + "action": lambda: print(" → NOTE: Error icon requires running tray app"), + "wait": 2, + "instructions": "Capture: Error icon (red exclamation - requires running app)" + }, + { + "name": "09-menu-structure", + "description": "Tray menu structure", + "action": lambda: print(" → NOTE: Menu requires running tray app - right-click icon"), + "wait": 2, + "instructions": "Capture: Full menu structure (requires running app)" + }, + { + "name": "10-notification-settings", + "description": "System notification settings", + "action": lambda: print(" → Open System Preferences → Notifications"), + "wait": 2, + "instructions": "Capture: System Preferences showing OpenAdapt notification settings" + }, + ] + + print("Starting screenshot sequence...") + print("=" * 80) + print() + + for i, shot in enumerate(screenshots, 1): + print(f"Screenshot {i}/{len(screenshots)}: {shot['description']}") + print(f" File: {shot['name']}.png") + print(f" Instructions: {shot['instructions']}") + print() + + # Countdown before action + countdown(3, " Triggering") + + # Execute action. The notification helpers return whether the + # notification was actually delivered; printing a tick regardless told + # the operator to photograph a notification that was never shown. + shown = True + if callable(shot['action']): + shown = shot['action']() is not False + + # Wait for notification to appear and give time to screenshot + if shown: + print(" ✓ Triggered! Capture screenshot now...") + else: + print(" ✗ NOT delivered — there is nothing on screen to capture.") + time.sleep(shot['wait']) + + # Give extra time to capture + countdown(shot['wait'] + 3, " Time remaining") + + print() + print("-" * 80) + print() + time.sleep(1) + + # Cleanup + notifications.cleanup() + + print("=" * 80) + print("Screenshot sequence complete!") + print("=" * 80) + print() + print("Next steps:") + print(f" 1. Review screenshots in: {SCREENSHOT_DIR}") + print(" 2. Crop and annotate as needed") + print(" 3. Add to documentation (README.md, NOTIFICATIONS.md)") + print(" 4. Create GIF animations (optional)") + print() + print("Recommended screenshots still needed:") + print(" • Tray icon states (recording, compiling, break-badge, error) - requires running app") + print(" • Menu structure - requires running app") + print(" • Settings UI - if implemented") + print() + print("To capture these, run: python -m openadapt_tray") + print("Then manually capture screenshots while interacting with the app.") + print() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nInterrupted by user. Exiting...") + sys.exit(0) + except Exception as e: + print(f"\n\nERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/packages/tray/pyproject.toml b/packages/tray/pyproject.toml new file mode 100644 index 0000000000..9c49e8c7a6 --- /dev/null +++ b/packages/tray/pyproject.toml @@ -0,0 +1,114 @@ +[project] +name = "openadapt-tray" +version = "0.3.7" +description = "Menu-bar and system-tray controls and status for OpenAdapt Desktop" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + {name = "MLDSAI Inc.", email = "richard@mldsai.com"} +] +keywords = ["gui", "system-tray", "menu-bar", "workflow-automation", "openadapt"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "pystray>=0.19.0", + "Pillow>=12.3.0", + "pynput>=1.7.0", + "click>=8.3.3", + "desktop-notifier>=6.2.0", + "httpx>=0.27", + "keyring>=24.0", +] + +[project.optional-dependencies] +macos-native = [ + "rumps>=0.4.0", + "pyobjc-framework-Cocoa>=9.0", +] +dev = [ + "pytest>=9.0.3", + "pytest-mock>=3.10.0", + "Pygments>=2.20.0", + # Bounded on purpose. `ruff>=0.1.0` unpinned is a live hazard, not a + # theoretical one: ruff 0.16.0 grew its DEFAULT rule set from 59 rules to + # 413, this project selects no rules explicitly, and CI runs a blocking + # `ruff check src tests scripts`. Only the 0.14.x pin in `uv.lock` was + # keeping `main` green -- the next dependabot bump would have turned it red + # with 216 findings and no code change. Raise the ceiling deliberately, + # with the findings fixed in the same change. + "ruff>=0.16,<0.18", +] +all = [ + "openadapt-tray[macos-native]", +] + +[project.scripts] +openadapt-tray = "openadapt_tray.app:main" + +[project.gui-scripts] +openadapt-tray-gui = "openadapt_tray.app:main" + +[project.urls] +Homepage = "https://openadapt.ai" +Documentation = "https://docs.openadapt.ai" +Repository = "https://github.com/OpenAdaptAI/OpenAdapt/tree/main/packages/tray" +Changelog = "https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/tray/CHANGELOG.md" + + + +[build-system] +requires = ["hatchling==1.32.4"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/openadapt_tray"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +# No `select` on purpose: this project keeps ruff's FULL default rule set. +# The residual after safe autofix was 91 findings in a 3.9k-line tree, which is +# small enough to read and judge one by one, so the coverage is worth keeping +# rather than trading away for a narrow `["E","F","I","W"]`. The ruff version +# is bounded above in `[project.optional-dependencies].dev`, so the default set +# can only change when a person raises that ceiling. +[tool.ruff.lint] +# BLE001 (blind `except Exception`) is the one rule in ruff 0.16's expanded +# defaults that is wrong for this codebase, so it is disabled deliberately +# rather than suppressed at 50-odd individual sites. +# +# The tray is almost nothing BUT boundaries to flaky, optional, platform-owned +# subsystems: pystray and pynput backends, Pillow decoders, the OS keychain via +# `keyring`, `desktop-notifier`/Cocoa, tkinter, zenity/kdialog, AppleScript via +# `osascript`, PowerShell, `winreg` and `ctypes.windll`. Every one of those +# handlers logs and degrades -- returns False, falls through to the next +# fallback, or keeps a daemon thread alive -- because a menu-bar app that dies +# when a notification daemon is missing is a worse product than one that keeps +# running without notifications. +# +# There is also nothing to narrow to. Those subsystems share no exception +# hierarchy, and several raise backend-specific errors that are not part of any +# published API. Enumerating them would silently reintroduce +# crash-on-first-surprise the next time a dependency added an exception type, +# which is a behaviour change, not a style change. +# +# Where a blind catch was hiding a real defect rather than guarding a boundary, +# the code was fixed instead (see `keychain.clear_ingest_token`, +# `NotificationManager._show_windows`, `IPCServer._accept_loop`). +ignore = ["BLE001"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/packages/tray/rename_screenshots.py b/packages/tray/rename_screenshots.py new file mode 100755 index 0000000000..5e13279282 --- /dev/null +++ b/packages/tray/rename_screenshots.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Automatically find and rename screenshots from Desktop to docs/screenshots/. + +Usage: Just take screenshots with Cmd+Shift+4, then run this script. +""" + +import shutil +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def find_recent_screenshots(minutes=30): + """Find screenshots from Desktop taken in last N minutes.""" + desktop = Path.home() / "Desktop" + cutoff = datetime.now(tz=timezone.utc) - timedelta(minutes=minutes) + + screenshots = [] + for file in desktop.glob("Screen Shot *.png"): + mtime = datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc) + if mtime > cutoff: + screenshots.append(file) + + # Also check for Screenshot (macOS Ventura+) + for file in desktop.glob("Screenshot *.png"): + mtime = datetime.fromtimestamp(file.stat().st_mtime, tz=timezone.utc) + if mtime > cutoff: + screenshots.append(file) + + return sorted(screenshots, key=lambda f: f.stat().st_mtime) + + +def rename_and_move_screenshots(screenshots, target_dir): + """Interactively rename and move screenshots.""" + target_dir = Path(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + + # Predefined names for tray application screenshots + names = [ + "tray-icon-idle.png", + "menu-idle.png", + "notification-basic.png", + "notification-critical.png", + "notification-center.png", + "tray-icon-recording.png", + "menu-recording.png", + "notification-recording-started.png", + ] + + print(f"\nFound {len(screenshots)} recent screenshots:") + for i, screenshot in enumerate(screenshots, 1): + mtime = datetime.fromtimestamp( + screenshot.stat().st_mtime, tz=timezone.utc + ).astimezone() + time_str = mtime.strftime("%H:%M:%S") + print(f" {i}. {screenshot.name} (taken at {time_str})") + + print("\nWill rename them to:") + for i, name in enumerate(names[:len(screenshots)], 1): + print(f" {i}. {name}") + + response = input("\nProceed? (y/n): ") + if response.lower() != 'y': + print("Cancelled.") + return False + + for screenshot, new_name in zip(screenshots, names): + dest = target_dir / new_name + # Backup existing file if it exists + if dest.exists(): + backup = target_dir / f"{dest.stem}.backup{dest.suffix}" + shutil.move(str(dest), str(backup)) + print(f" Backed up existing {dest.name} -> {backup.name}") + shutil.move(str(screenshot), str(dest)) + print(f" Moved {screenshot.name} -> {dest}") + + print(f"\nDone! {len(screenshots)} screenshots in {target_dir}") + return True + + +def main(): + """Main entry point.""" + print("OpenAdapt Screenshot Rename Tool") + print("=" * 60) + + # Find screenshots from last 30 minutes + screenshots = find_recent_screenshots(minutes=30) + + if not screenshots: + print("\nNo recent screenshots found on Desktop.") + print("Tips:") + print(" - Take screenshots with Cmd+Shift+4 (selection)") + print(" - Or Cmd+Shift+5 (screenshot tool)") + print(" - Screenshots save to Desktop by default") + print("\nThen run this script again.") + return 1 + + # Target directory + target_dir = Path(__file__).parent / "docs" / "screenshots" + + # Rename and move + success = rename_and_move_screenshots(screenshots, target_dir) + + if success: + print("\nNext steps:") + print(" 1. Check that screenshots look good:") + print(f" open {target_dir}") + print(" 2. Commit the screenshots:") + print(" git add docs/screenshots/") + print(" git commit -m 'Add tray notification screenshots'") + print(" 3. Push to remote:") + print(" git push origin fix/add-readme-badges") + + return 0 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/packages/tray/scripts/check_release_consistency.py b/packages/tray/scripts/check_release_consistency.py new file mode 100755 index 0000000000..dbb6f357c5 --- /dev/null +++ b/packages/tray/scripts/check_release_consistency.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Check and synchronize the versioned inputs used by releases.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _match(pattern: str, text: str, source: str) -> str: + match = re.search(pattern, text, flags=re.MULTILINE) + if not match: + raise ValueError(f"could not read version from {source}") + return match.group(1) + + +def _project_identity(root: Path) -> tuple[str, str]: + pyproject = (root / "pyproject.toml").read_text(encoding="utf-8") + project_table = _match( + r"(?ms)^\[project\]\s*$\n(.*?)(?=^\[|\Z)", + pyproject, + "pyproject.toml [project] table", + ) + return ( + _match( + r'(?m)^name\s*=\s*"([^"]+)"\s*$', + project_table, + "pyproject.toml", + ), + _match( + r'(?m)^version\s*=\s*"([^"]+)"\s*$', + project_table, + "pyproject.toml", + ), + ) + + +def _editable_lock_version_span( + lock_text: str, package_name: str +) -> tuple[int, int, str]: + package_starts = list(re.finditer(r"(?m)^\[\[package\]\]\s*$", lock_text)) + matches: list[tuple[int, int, str]] = [] + + for index, package_start in enumerate(package_starts): + package_end = ( + package_starts[index + 1].start() + if index + 1 < len(package_starts) + else len(lock_text) + ) + block = lock_text[package_start.start() : package_end] + if not re.search( + rf'(?m)^name\s*=\s*"{re.escape(package_name)}"\s*$', block + ): + continue + if not re.search( + r'(?m)^source\s*=\s*\{\s*editable\s*=\s*"\."\s*\}\s*$', block + ): + continue + + version = re.search(r'(?m)^version\s*=\s*"([^"]+)"\s*$', block) + if not version: + raise ValueError( + f"editable {package_name!r} lock entry has no version" + ) + matches.append( + ( + package_start.start() + version.start(1), + package_start.start() + version.end(1), + version.group(1), + ) + ) + + if len(matches) != 1: + raise ValueError( + f"expected one editable {package_name!r} lock entry, " + f"found {len(matches)}" + ) + return matches[0] + + +def release_versions(root: Path = ROOT) -> dict[str, str]: + package_name, project_version = _project_identity(root) + if package_name != "openadapt-tray": + raise ValueError(f"unexpected project name: {package_name!r}") + + package_init = (root / "src/openadapt_tray/__init__.py").read_text( + encoding="utf-8" + ) + lock = (root / "uv.lock").read_text(encoding="utf-8") + _, _, lock_version = _editable_lock_version_span(lock, package_name) + + return { + "pyproject.toml": project_version, + "src/openadapt_tray/__init__.py": _match( + r'^__version__ = "([^"]+)"$', package_init, "package __init__" + ), + "uv.lock": lock_version, + } + + +def synchronize_release_lock(root: Path = ROOT) -> bool: + """Stamp only the editable root version, preserving reviewed resolution.""" + package_name, project_version = _project_identity(root) + if package_name != "openadapt-tray": + raise ValueError(f"unexpected project name: {package_name!r}") + + lock_path = root / "uv.lock" + lock_text = lock_path.read_text(encoding="utf-8") + version_start, version_end, lock_version = _editable_lock_version_span( + lock_text, package_name + ) + if lock_version == project_version: + return False + + lock_path.write_text( + lock_text[:version_start] + project_version + lock_text[version_end:], + encoding="utf-8", + ) + if release_versions(root)["uv.lock"] != project_version: + raise ValueError("failed to synchronize the editable lock version") + return True + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--write-lock", + action="store_true", + help="stamp the project version into the editable root lock entry", + ) + parser.add_argument("--require-dist", action="store_true") + args = parser.parse_args() + + if args.write_lock: + synchronize_release_lock() + + versions = release_versions() + unique_versions = set(versions.values()) + if len(unique_versions) != 1: + parser.error(f"release versions differ: {versions}") + version = unique_versions.pop() + + if args.require_dist: + distributions = list((ROOT / "dist").glob(f"openadapt_tray-{version}*")) + names = [path.name for path in distributions] + if not any(name.endswith(".whl") for name in names) or not any( + name.endswith(".tar.gz") for name in names + ): + parser.error( + f"missing wheel or source distribution for {version}: {distributions}" + ) + + print(f"Release version {version} is synchronized across project, module, and lock.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/tray/scripts/check_source_boundary.py b/packages/tray/scripts/check_source_boundary.py new file mode 100755 index 0000000000..a2a9baa45d --- /dev/null +++ b/packages/tray/scripts/check_source_boundary.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Reject private crown-jewel material from a public tree and its archives. + +The rules come from ``source-policy.public.json``. The private canonical +manifest renders that file. This guard fails closed when the rendered policy is +missing, malformed, incomplete, or does not classify this repository as public. +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import subprocess +import sys +import tarfile +import zipfile +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import BinaryIO + +ROOT = Path(__file__).resolve().parents[1] +POLICY_PATH = ROOT / "source-policy.public.json" +SCHEMA_VERSION = 1 +CONTENT_SCAN_LIMIT = 5 * 1024 * 1024 +EXEMPT_TREE_PATHS = { + "scripts/check_source_boundary.py", + "source-policy.public.json", +} + + +class BoundaryError(RuntimeError): + """The policy or the inspected public artifact is invalid.""" + + +def _strings(container: dict, key: str, *, where: str) -> tuple[str, ...]: + value = container.get(key) + if not isinstance(value, list) or not value: + raise BoundaryError(f"{where}.{key} must be a non-empty list") + if any(not isinstance(item, str) or not item.strip() for item in value): + raise BoundaryError(f"{where}.{key} must contain non-empty strings") + return tuple(item.lower() for item in value) + + +@dataclass(frozen=True) +class Policy: + path_tokens: tuple[str, ...] + private_segments: frozenset[str] + path_prefixes: tuple[str, ...] + content_patterns: re.Pattern[str] + signatures: tuple[bytes, ...] + repository: str + digest: str + + +def _repository_name(root: Path) -> str: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip().rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") + + +def load_policy(path: Path, repository: str) -> Policy: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise BoundaryError(f"cannot load rendered source policy {path}: {exc}") from exc + if not isinstance(document, dict) or document.get("schema_version") != SCHEMA_VERSION: + raise BoundaryError("the rendered source policy has an unknown schema") + + enforcement = document.get("enforcement") + repositories = document.get("public_repositories") + categories = document.get("crown_jewel_categories") + if not isinstance(enforcement, dict) or not isinstance(repositories, dict): + raise BoundaryError("the rendered source policy is incomplete") + if not isinstance(categories, list) or not categories: + raise BoundaryError("the rendered source policy has no crown-jewel categories") + entry = repositories.get(repository) + if not isinstance(entry, dict) or entry.get("classification") != "public": + raise BoundaryError(f"{repository!r} is not classified as public") + forbidden = entry.get("must_not_contain") + if not isinstance(forbidden, list) or not set(categories).issubset(forbidden): + raise BoundaryError(f"{repository!r} does not forbid every crown-jewel category") + + tree = enforcement.get("repository_tree") + archives = enforcement.get("built_artifacts") + if not isinstance(tree, dict) or not isinstance(archives, dict): + raise BoundaryError("the rendered source policy has no tree or archive rules") + patterns = _strings(tree, "content_patterns", where="repository_tree") + try: + content_patterns = re.compile( + "|".join(f"(?:{pattern})" for pattern in patterns), re.IGNORECASE + ) + except re.error as exc: + raise BoundaryError(f"the source-policy content patterns are invalid: {exc}") from exc + + parts = enforcement.get("content_signature_parts") + if not isinstance(parts, list) or not parts: + raise BoundaryError("the rendered source policy has no content signatures") + signatures: list[bytes] = [] + for value in parts: + if not isinstance(value, list) or not value or any(not str(part) for part in value): + raise BoundaryError("a source-policy content signature is invalid") + signatures.append("".join(str(part) for part in value).encode()) + + policy = Policy( + path_tokens=_strings(enforcement, "path_tokens", where="enforcement"), + private_segments=frozenset( + _strings(enforcement, "private_path_segments", where="enforcement") + ), + path_prefixes=_strings(archives, "path_prefixes", where="built_artifacts"), + content_patterns=content_patterns, + signatures=tuple(signatures), + repository=repository, + digest=str(document.get("policy_digest", "")), + ) + if not policy.digest.startswith("sha256:"): + raise BoundaryError("the rendered source policy has no digest") + _self_test(policy) + return policy + + +def _path_violation(name: str, policy: Policy) -> str | None: + normalized = PurePosixPath(name.replace("\\", "/")).as_posix().lower().lstrip("./") + token = next((value for value in policy.path_tokens if value in normalized), None) + if token: + return f"path contains forbidden token {token!r}" + segment = next( + (part for part in PurePosixPath(normalized).parts if part in policy.private_segments), + None, + ) + if segment: + return f"path contains private segment {segment!r}" + candidates = (normalized, "/".join(PurePosixPath(normalized).parts[1:])) + prefix = next( + ( + value + for value in policy.path_prefixes + if any(item == value or item.startswith(value.rstrip("/") + "/") for item in candidates) + ), + None, + ) + return f"path uses forbidden archive prefix {prefix!r}" if prefix else None + + +def _contains_signature(stream: BinaryIO, signatures: tuple[bytes, ...]) -> bool: + overlap = max(len(value) for value in signatures) - 1 + tail = b"" + while chunk := stream.read(1024 * 1024): + payload = tail + chunk + if any(value in payload for value in signatures): + return True + tail = payload[-overlap:] if overlap else b"" + return False + + +def _self_test(policy: Policy) -> None: + if _path_violation(f"fixture/{policy.path_tokens[0]}/item.json", policy) is None: + raise BoundaryError("the path-token guard failed its self-test") + if _path_violation(f"fixture/{next(iter(policy.private_segments))}/item.json", policy) is None: + raise BoundaryError("the private-segment guard failed its self-test") + if not _contains_signature(io.BytesIO(policy.signatures[0]), policy.signatures): + raise BoundaryError("the content-signature guard failed its self-test") + + +def scan_tree(root: Path, policy: Policy) -> list[str]: + result = subprocess.run(["git", "ls-files", "-z"], cwd=root, check=True, capture_output=True) + violations: list[str] = [] + for name in (value for value in result.stdout.decode().split("\0") if value): + if name in EXEMPT_TREE_PATHS: + continue + if problem := _path_violation(name, policy): + violations.append(f"{name}: {problem}") + full_path = root / name + if not full_path.is_file(): + continue + with full_path.open("rb") as stream: + if _contains_signature(stream, policy.signatures): + violations.append(f"{name}: content carries a private-artifact signature") + continue + if full_path.stat().st_size <= CONTENT_SCAN_LIMIT: + text = full_path.read_text(encoding="utf-8", errors="ignore") + if match := policy.content_patterns.search(text): + line = text.count("\n", 0, match.start()) + 1 + violations.append(f"{name}:{line}: content matches a forbidden pattern") + return violations + + +def _archive_members(path: Path) -> Iterator[tuple[str, BinaryIO]]: + if path.suffix in {".whl", ".zip"}: + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + if not info.is_dir(): + with archive.open(info) as stream: + yield info.filename, stream + return + if path.name.endswith(".tar.gz"): + with tarfile.open(path, "r:gz") as archive: + for member in archive.getmembers(): + if member.isfile() and (stream := archive.extractfile(member)) is not None: + with stream: + yield member.name, stream + return + raise BoundaryError(f"unsupported distribution archive: {path}") + + +def scan_archive(path: Path, policy: Policy) -> list[str]: + violations: list[str] = [] + for name, stream in _archive_members(path): + if problem := _path_violation(name, policy): + violations.append(f"{path.name}:{name}: {problem}") + if _contains_signature(stream, policy.signatures): + violations.append(f"{path.name}:{name}: content carries a private-artifact signature") + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=ROOT) + parser.add_argument("--repo") + parser.add_argument("--policy", type=Path, default=POLICY_PATH) + parser.add_argument("--require-dist", action="store_true") + args = parser.parse_args() + root = args.root.resolve() + try: + repository = args.repo or _repository_name(root) + policy = load_policy(args.policy, repository) + violations = scan_tree(root, policy) + if args.require_dist: + archives = sorted((root / "dist").glob("*.whl")) + sorted( + (root / "dist").glob("*.tar.gz") + ) + if not any(path.suffix == ".whl" for path in archives) or not any( + path.name.endswith(".tar.gz") for path in archives + ): + raise BoundaryError("dist must contain a wheel and a source distribution") + for archive in archives: + violations.extend(scan_archive(archive, policy)) + except (BoundaryError, OSError, subprocess.CalledProcessError) as exc: + print(f"FATAL: {exc}", file=sys.stderr) + return 2 + if violations: + for violation in violations: + print(f"ERROR: {violation}", file=sys.stderr) + return 1 + scope = "source tree and distributions" if args.require_dist else "source tree" + print(f"OK: {policy.repository} {scope} satisfy {policy.digest}.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/tray/scripts/generate_icons.py b/packages/tray/scripts/generate_icons.py new file mode 100755 index 0000000000..b26d346df2 --- /dev/null +++ b/packages/tray/scripts/generate_icons.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Generate OpenAdapt Tray state icons from the OpenAdapt mark. + +Every tray icon is the OpenAdapt mark (the chat/robot face) tinted with a +per-state colour, so the state stays readable at a glance while the brand mark +replaces the old plain coloured circle. + +Source of truth +--------------- +* ``assets/mark.svg`` -- the vector mark (from openadapt.ai favicon). +* ``assets/mark-master.png`` -- a 1024px transparent raster master rendered from + the SVG. This is the canonical silhouette used for tinting. +* ``src/openadapt_tray/assets/mark-master.png`` -- a 512px copy packaged inside + the wheel so the installed app can tint the mark at runtime. + +This script re-renders ``mark-master.png`` from ``mark.svg`` when a rasterizer +(``rsvg-convert``, ``cairosvg`` or ``inkscape``) is available, then tints it +into per-state PNGs (1x + @2x) and a Windows ``logo.ico``. Tinting itself is +pure Pillow and uses the exact same implementation as the runtime +(``IconManager.tint_mark``), so committed and runtime icons never diverge. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from PIL import Image + +# Make the package importable so we reuse the runtime tinting implementation. +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "src")) + +from openadapt_tray.icons import IconManager +from openadapt_tray.state import TrayState + +ASSETS = REPO_ROOT / "assets" +ICONS_DIR = ASSETS / "icons" +MARK_SVG = ASSETS / "mark.svg" +MARK_MASTER = ASSETS / "mark-master.png" +PACKAGED_MASTER = REPO_ROOT / "src" / "openadapt_tray" / "assets" / "mark-master.png" + +MASTER_SIZE = 1024 +PACKAGED_MASTER_SIZE = 512 +BASE_SIZE = 64 # 1x tray icon; @2x is doubled. +ICO_SIZES = [16, 32, 48, 64, 128, 256] + +# Filename stem per state (matches IconManager.STATE_ICONS). +STATE_STEMS = { + TrayState.IDLE: "idle", + TrayState.RECORDING_STARTING: "recording_starting", + TrayState.RECORDING: "recording", + TrayState.RECORDING_STOPPING: "recording_stopping", + TrayState.COMPILING: "compiling", + TrayState.ERROR: "error", +} + + +def _render_svg_to_png(svg: Path, out: Path, size: int) -> bool: + """Render ``svg`` to a transparent ``size`` x ``size`` PNG. + + Tries rsvg-convert, then cairosvg, then inkscape. Returns True on success. + """ + if shutil.which("rsvg-convert"): + subprocess.run( + ["rsvg-convert", "-w", str(size), "-h", str(size), + str(svg), "-o", str(out)], + check=True, + ) + return True + try: + import cairosvg # type: ignore + + cairosvg.svg2png( + url=str(svg), write_to=str(out), output_width=size, output_height=size + ) + return True + except Exception as e: + # Deliberate fall-through to inkscape, but say why: swallowing this made + # a broken cairosvg install look identical to "no rasterizer found". + print(f" cairosvg unavailable or failed ({e}); trying inkscape.") + if shutil.which("inkscape"): + subprocess.run( + ["inkscape", str(svg), "-w", str(size), "-h", str(size), + "--export-filename", str(out)], + check=True, + ) + return True + return False + + +def _trim_square(img: Image.Image, margin_frac: float = 0.08) -> Image.Image: + """Trim to the mark bbox and pad to a centred square with a small margin.""" + img = img.convert("RGBA") + bbox = img.split()[3].getbbox() + if bbox: + img = img.crop(bbox) + w, h = img.size + side = max(w, h) + margin = int(side * margin_frac) + canvas = side + margin * 2 + out = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + out.paste(img, ((canvas - w) // 2, (canvas - h) // 2), img) + return out + + +def regenerate_master() -> None: + """Re-render the mark master from the SVG when a rasterizer is available.""" + if not MARK_SVG.exists(): + print(f" mark.svg not found at {MARK_SVG}; keeping committed master.") + return + tmp = MARK_MASTER.with_suffix(".raw.png") + if not _render_svg_to_png(MARK_SVG, tmp, MASTER_SIZE): + print(" No SVG rasterizer found; keeping committed mark-master.png.") + return + master = _trim_square(Image.open(tmp)).resize( + (MASTER_SIZE, MASTER_SIZE), Image.LANCZOS + ) + master.save(MARK_MASTER) + tmp.unlink(missing_ok=True) + print(f" Re-rendered {MARK_MASTER.name} from {MARK_SVG.name}") + + +def sync_packaged_master() -> None: + """Copy a downscaled master into the package so it ships in the wheel.""" + PACKAGED_MASTER.parent.mkdir(parents=True, exist_ok=True) + master = Image.open(MARK_MASTER).convert("RGBA") + master.resize( + (PACKAGED_MASTER_SIZE, PACKAGED_MASTER_SIZE), Image.LANCZOS + ).save(PACKAGED_MASTER) + print(f" Synced packaged master -> {PACKAGED_MASTER.relative_to(REPO_ROOT)}") + + +def generate_state_icons() -> None: + """Tint the mark per state into 1x + @2x PNGs under assets/icons.""" + ICONS_DIR.mkdir(parents=True, exist_ok=True) + manager = IconManager() + for state, stem in STATE_STEMS.items(): + manager.render_state_icon(state, BASE_SIZE).save(ICONS_DIR / f"{stem}.png") + manager.render_state_icon(state, BASE_SIZE * 2).save( + ICONS_DIR / f"{stem}@2x.png" + ) + print(f" Created {stem}.png + {stem}@2x.png") + + +def generate_ico() -> None: + """Write a multi-size Windows .ico using the brand-blue idle mark. + + Pillow resamples the single base image down to every size in ``sizes``, so + the base must be rendered at the largest size for a crisp multi-res .ico. + """ + manager = IconManager() + base = manager.render_state_icon(TrayState.IDLE, max(ICO_SIZES)) + ico_path = ASSETS / "logo.ico" + base.save( + ico_path, + format="ICO", + sizes=[(s, s) for s in ICO_SIZES], + ) + print(f" Created {ico_path.name}") + + +def main() -> None: + """Regenerate the master, packaged master, state icons and .ico.""" + print(f"Generating OpenAdapt mark icons in {ICONS_DIR}") + regenerate_master() + sync_packaged_master() + generate_state_icons() + generate_ico() + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/packages/tray/source-policy.public.json b/packages/tray/source-policy.public.json new file mode 100644 index 0000000000..b327c8515e --- /dev/null +++ b/packages/tray/source-policy.public.json @@ -0,0 +1,434 @@ +{ + "_comment": "GENERATED FILE -- DO NOT EDIT BY HAND. Rendered by OpenAdaptAI/openadapt-internal:scripts/render_public_policy.py from the canonical private manifest OpenAdaptAI/openadapt-internal:source-policy.yaml. Edit the rule there and re-render; a CI job in that private repository compares this file against the canonical manifest daily. The CI guards that read this file FAIL CLOSED when it is missing, unparseable, or incomplete.", + "crown_jewel_categories": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "enforcement": { + "built_artifacts": { + "content_patterns": [ + "deployment[_-]derived\\s+threshold\\s*=", + "oracle[_-]recipe[_-]id\\s*[:=]" + ], + "path_prefixes": [ + "docs/validation/adversary_corpus", + "openadapt_flow/validation/adversary_corpus" + ] + }, + "content_signature_parts": [ + [ + "OPENADAPT-CORPUS", + "-PRIVATE-DO-NOT-PACKAGE" + ] + ], + "disclosed_private_repositories": [ + "openadapt-cloud", + "openadapt-corpus", + "openadapt-internal" + ], + "path_tokens": [ + "adversary_corpus", + "cerner", + "client_fixture", + "control_plane", + "customer_fixture", + "customer_recipe", + "deployment_corpus", + "deployment_thresholds", + "effect_oracle_recipe", + "enterprise_productionized", + "epic_hyperspace", + "grown_corpus", + "held_out_corpus", + "identity_roc", + "meditech", + "millennium_recipe", + "openadapt-cloud", + "openadapt-corpus", + "openadapt-internal", + "oracle_recipe", + "paid_agent_evidence", + "pixel_verify_cert", + "powerchart", + "real_emr", + "tuned_adversary" + ], + "private_path_segments": [ + ".private", + "private" + ], + "repository_tree": { + "content_patterns": [ + "powerchart", + "cerner\\s+millennium", + "epic\\s+hyperspace", + "openadapt[_-]corpus[/:]", + "deployment[_-]derived\\s+threshold\\s*=", + "oracle[_-]recipe[_-]id\\s*[:=]" + ] + } + }, + "generated_from": "OpenAdaptAI/openadapt-internal:source-policy.yaml", + "policy_digest": "sha256:183f653281a0f9792f7c54c3831f1a2ea8338a5a8a0af812ba47526d542c4843", + "policy_last_updated": "2026-09-03", + "policy_version": 1, + "public_repositories": { + ".github": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/.github" + }, + "OmniMCP": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/OmniMCP" + }, + "OpenAdapt": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/OpenAdapt" + }, + "OpenSanitizer": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/OpenSanitizer" + }, + "PydanticPrompt": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/PydanticPrompt" + }, + "ehr-integration-directory": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/ehr-integration-directory" + }, + "openadapt-agent": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-agent" + }, + "openadapt-blog": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-blog" + }, + "openadapt-capture": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-capture" + }, + "openadapt-consilium": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-consilium" + }, + "openadapt-console": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-console" + }, + "openadapt-crier": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-crier" + }, + "openadapt-desktop": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-desktop" + }, + "openadapt-evals": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-evals" + }, + "openadapt-flow": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-flow" + }, + "openadapt-grounding": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-grounding" + }, + "openadapt-herald": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-herald" + }, + "openadapt-ml": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-ml" + }, + "openadapt-ops": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-ops" + }, + "openadapt-privacy": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-privacy" + }, + "openadapt-retrieval": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-retrieval" + }, + "openadapt-telemetry": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-telemetry" + }, + "openadapt-tray": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-tray" + }, + "openadapt-types": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-types" + }, + "openadapt-viewer": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-viewer" + }, + "openadapt-web": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-web" + }, + "openadapt-wright": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/openadapt-wright" + } + }, + "schema_version": 1 +} diff --git a/packages/tray/src/openadapt_tray/__init__.py b/packages/tray/src/openadapt_tray/__init__.py new file mode 100644 index 0000000000..564fb6a432 --- /dev/null +++ b/packages/tray/src/openadapt_tray/__init__.py @@ -0,0 +1,29 @@ +"""OpenAdapt Tray - System tray application for OpenAdapt.""" + +__version__ = "0.3.7" + +from openadapt_tray.app import TrayApplication, main +from openadapt_tray.config import TrayConfig +from openadapt_tray.hosted import CountResult, HostedPoller +from openadapt_tray.state import ( + AppState, + CredentialState, + CredentialStatus, + StateManager, + SyncState, + TrayState, +) + +__all__ = [ + "AppState", + "CountResult", + "CredentialState", + "CredentialStatus", + "HostedPoller", + "StateManager", + "SyncState", + "TrayApplication", + "TrayConfig", + "TrayState", + "main", +] diff --git a/packages/tray/src/openadapt_tray/__main__.py b/packages/tray/src/openadapt_tray/__main__.py new file mode 100644 index 0000000000..613e71bfa3 --- /dev/null +++ b/packages/tray/src/openadapt_tray/__main__.py @@ -0,0 +1,10 @@ +"""Entry point for running openadapt_tray as a module. + +Usage: + python -m openadapt_tray +""" + +from openadapt_tray.app import main + +if __name__ == "__main__": + main() diff --git a/packages/tray/src/openadapt_tray/app.py b/packages/tray/src/openadapt_tray/app.py new file mode 100644 index 0000000000..b2446b3df6 --- /dev/null +++ b/packages/tray/src/openadapt_tray/app.py @@ -0,0 +1,698 @@ +"""Main system tray application for OpenAdapt. + +The tray is a lightweight status mirror + launcher. It owns no business logic: + +* recording/compile actions are commands sent to the **desktop app** over a + loopback socket (discovered via ``~/.openadapt/desktop_ipc.json``); +* the **break badge** is polled from the hosted control plane's + needs-attention count endpoint; +* the desktop app is the source of truth for all state — the tray renders it. +""" + +import sys +import threading +import webbrowser + +import pystray + +from openadapt_tray.config import ConfigLoadError, TrayConfig +from openadapt_tray.desktop import DesktopLaunchError, launch_native_desktop +from openadapt_tray.hosted import ( + CountResult, + HostedPoller, + InvalidCountPayload, + route_break_click, +) +from openadapt_tray.icons import IconManager +from openadapt_tray.ipc import IPCClient, IPCMessageType +from openadapt_tray.menu import MenuBuilder +from openadapt_tray.notifications import NotificationManager +from openadapt_tray.platform import get_platform_handler +from openadapt_tray.platform.base import DialogUnavailableError +from openadapt_tray.shortcuts import HotkeyManager +from openadapt_tray.state import ( + LANE_BYOC, + LANE_CLOUD, + AppState, + CredentialStatus, + StateManager, + SyncState, + TrayState, +) + + +class TrayApplication: + """Main system tray application.""" + + def __init__(self, config: TrayConfig | None = None): + """Initialize the tray application. + + Args: + config: Optional configuration. If None, loads from file or defaults. + """ + # An unreadable tray.json must not quietly become "default settings" -- + # the default lane is "cloud", and a byoc install silently moved onto + # the hosted route is exactly the kind of wrong-but-confident state the + # tray exists to expose. Keep the error and tell the user in run(). + self._config_error: ConfigLoadError | None = None + if config is not None: + self.config = config + else: + self.config, self._config_error = TrayConfig.load_or_defaults() + self.state = StateManager() + self.platform = get_platform_handler() + + # Seed the deployment lane from config so break-click routing is correct + # before the desktop first reports it. + self.state.set_deployment_lane(self.config.deployment_lane) + + # Initialize components + self.icons = IconManager() + self.notifications = NotificationManager() + self.menu_builder = MenuBuilder(self) + self.hotkeys = HotkeyManager(self.config.hotkeys) + + # IPC client — configured from the desktop discovery file when present. + self.ipc = IPCClient.from_discovery() or IPCClient( + port=self.config.desktop_ipc_port or IPCClient.DEFAULT_PORT, + ) + + # Cloud needs-attention poller (started in run()). + self.hosted = HostedPoller( + config=self.config, + on_count=self._on_hosted_count, + notifier=self.notifications, + on_break_clicked=self.open_needs_attention, + on_credential=self._on_hosted_credential, + on_credential_clicked=self.login, + set_offline=self._on_hosted_offline, + ) + + # Create tray icon + self.icon = pystray.Icon( + name="openadapt", + icon=self.icons.get(TrayState.IDLE), + title="OpenAdapt", + menu=self.menu_builder.build(), + ) + + # Set tray icon reference for Windows notifications + self.notifications.set_tray_icon(self.icon) + + # Register state change handler + self._last_notified_tray_state = self.state.current.state + self.state.add_listener(self._on_state_change) + + # Register IPC handlers + self._setup_ipc_handlers() + + # Register hotkey handlers + self._setup_hotkeys() + + def _setup_hotkeys(self) -> None: + """Configure global hotkeys.""" + self.hotkeys.register( + self.config.hotkeys.toggle_recording, + self._toggle_recording, + ) + self.hotkeys.register( + self.config.hotkeys.open_dashboard, + self.open_desktop_app, + ) + + # Register triple-ctrl for stop recording (legacy compatibility) + if self.config.stop_on_triple_ctrl: + self.hotkeys.register( + "++", + self._stop_recording_if_active, + ) + + def _setup_ipc_handlers(self) -> None: + """Configure IPC message handlers (desktop → tray events).""" + self.ipc.register_handler( + IPCMessageType.RECORDING_STARTED, + self._on_ipc_recording_started, + ) + self.ipc.register_handler( + IPCMessageType.RECORDING_STOPPED, + self._on_ipc_recording_stopped, + ) + self.ipc.register_handler( + IPCMessageType.RECORDING_ERROR, + self._on_ipc_recording_error, + ) + self.ipc.register_handler( + IPCMessageType.STATUS_UPDATE, + self._on_ipc_status_update, + ) + self.ipc.register_handler( + IPCMessageType.COMPILE_PROGRESS, + self._on_ipc_compile_progress, + ) + self.ipc.register_handler( + IPCMessageType.SYNC_STATE, + self._on_ipc_sync_state, + ) + self.ipc.register_handler( + IPCMessageType.BREAK_COUNT, + self._on_ipc_break_count, + ) + + def _on_state_change(self, state: AppState) -> None: + """Handle state changes. + + Args: + state: New application state. + """ + # Update icon (with break badge when automations need attention). + self.icon.icon = self.icons.get(state.state, break_count=state.break_count) + + # Update menu + self.icon.menu = self.menu_builder.build() + + # Show notification if appropriate + if self.config.show_notifications: + self._show_state_notification(state) + + def _show_state_notification(self, state: AppState) -> None: + """Show notification for recording-lifecycle transitions. + + Args: + state: Current application state. + """ + if state.state == self._last_notified_tray_state: + return + + messages = { + TrayState.RECORDING: ( + "Recording Started", + f"Capturing: {state.current_capture or 'session'}", + ), + TrayState.COMPILING: ( + "Compiling", + f"Building a workflow from: {state.current_capture or 'recording'}", + ), + TrayState.IDLE: ("Recording Stopped", "Capture saved"), + TrayState.ERROR: ("Error", state.error_message or "An error occurred"), + } + + if state.state not in messages: + self._last_notified_tray_state = state.state + return + + title, body = messages[state.state] + delivered = self.notifications.show( + title, + body, + duration_ms=self.config.notification_duration_ms, + ) + if delivered: + self._last_notified_tray_state = state.state + + def _toggle_recording(self) -> None: + """Toggle recording state.""" + if self.state.current.can_start_recording(): + self.start_recording() + elif self.state.current.can_stop_recording(): + self.stop_recording() + + def _stop_recording_if_active(self) -> None: + """Stop recording if currently active (for triple-ctrl).""" + if self.state.current.can_stop_recording(): + self.stop_recording() + + # --- desktop connection ------------------------------------------------- + + def ensure_desktop_connection(self) -> bool: + """Ensure the tray is connected to the desktop loopback socket. + + If the desktop app is not running (no discovery file / unreachable), + launch it, wait briefly for the discovery file, then connect. + + Returns: + True if connected. + """ + if self.ipc.is_connected(): + return True + + # Try discovery + connect first (desktop may already be up). + if self.ipc.refresh_from_discovery() and self.ipc.connect(): + return True + + # Desktop not running — launch it, then poll for the discovery file. + if not self._launch_desktop_app(): + return False + for _ in range(20): # ~10s + if self.ipc.refresh_from_discovery() and self.ipc.connect(): + return True + threading.Event().wait(0.5) + + return False + + def _launch_desktop_app(self) -> bool: + """Launch the installed native desktop application.""" + try: + launch_native_desktop() + except DesktopLaunchError as e: + self.notifications.show( + "Desktop app not found", + "Install the OpenAdapt desktop app to record and manage workflows.", + ) + print(f"Failed to launch desktop app: {e}") + return False + except Exception as e: + print(f"Failed to launch desktop app: {e}") + return False + return True + + # --- recording actions (delegated to the desktop over IPC) -------------- + + def start_recording(self, name: str | None = None) -> None: + """Start a new capture session via the desktop app. + + Args: + name: Optional name for the capture. If None, prompts the user. + """ + if not self.state.current.can_start_recording(): + return + + # Prompt for name if not provided + if name is None and self.config.use_native_dialogs: + try: + name = self.platform.prompt_input( + "New Recording", + "Enter a name for this capture:", + ) + except DialogUnavailableError as e: + # The user asked to record and the prompt never appeared. This + # used to look identical to "the user cancelled", so the click + # did nothing and said nothing. Honour the click with a default + # name and say why there was no prompt. + print(f"Could not show the capture-name dialog: {e}") + self.notifications.show( + "No naming dialog available", + "Recording started with a default name.", + ) + else: + if not name: + return # User cancelled + + # Use default name if still not set + if not name: + from datetime import datetime, timezone + + # The default name is shown to the user next to their other + # captures, so it stays in LOCAL time -- made explicit rather than + # leaning on a naive `datetime.now()`. + local_now = datetime.now(tz=timezone.utc).astimezone() + name = local_now.strftime("capture_%Y%m%d_%H%M%S") + + self.state.transition(TrayState.RECORDING_STARTING, current_capture=name) + + # Connect + dispatch on a background thread (launching the desktop app + # may take a few seconds). + threading.Thread( + target=self._dispatch_start_recording, + args=(name,), + daemon=True, + ).start() + + def _dispatch_start_recording(self, name: str) -> None: + """Ensure the desktop is up and send the start-recording command.""" + if not self.ensure_desktop_connection(): + self.state.transition( + TrayState.ERROR, + error_message="Could not reach the desktop app to start recording.", + ) + return + if not self.ipc.send_start_recording(name): + self.state.transition( + TrayState.ERROR, + error_message="Failed to send start-recording command.", + ) + # The desktop confirms via a RECORDING_STARTED event. + + def stop_recording(self) -> None: + """Stop the current capture session via the desktop app.""" + if not self.state.current.can_stop_recording(): + return + + self.state.transition(TrayState.RECORDING_STOPPING) + if not self.ipc.send_stop_recording(): + self.state.transition( + TrayState.ERROR, + error_message="Failed to send stop-recording command.", + ) + # The desktop confirms via RECORDING_STOPPED (then COMPILE_PROGRESS). + + # --- quick actions ------------------------------------------------------ + + def open_desktop_app(self) -> None: + """Open (or focus) the desktop app's workflow library.""" + if self.ipc.is_connected(): + if not self.ipc.send_open_workflow_library(): + self._report_desktop_open_failure() + return + # Not connected — launch/connect, then ask it to open the library. + threading.Thread( + target=self._open_desktop_app_async, + daemon=True, + ).start() + + def _open_desktop_app_async(self) -> None: + """Background helper for :meth:`open_desktop_app`.""" + if self.ensure_desktop_connection(): + if not self.ipc.send_open_workflow_library(): + self._report_desktop_open_failure() + else: + self._report_desktop_open_failure() + + def _report_desktop_open_failure(self) -> None: + """Report that the workflow-library command did not reach the desktop.""" + self.notifications.show( + "Could not open the desktop app", + "The tray could not send the workflow-library command.", + ) + + def open_cloud_dashboard(self) -> bool: + """Open the hosted cloud dashboard in the system browser.""" + return self._open_browser( + self.config.hosted_url, + "Could not open the cloud dashboard", + ) + + def open_needs_attention(self) -> bool: + """Route a needs-attention click by deployment lane (§3c). + + On the byoc lane the click only ever opens Teach in the desktop app. + When the desktop is not connected, the tray starts it on a background + thread and sends the command once it connects. + + Returns: + False when nothing opened; the user is told rather than being left + in front of an unchanged screen. True when something opened, or + when a byoc click started the desktop app (that thread reports its + own failure). + """ + lane = self._needs_attention_lane() + if lane == LANE_BYOC and not self.ipc.is_connected(): + threading.Thread(target=self._open_teach_async, daemon=True).start() + return True + routed = route_break_click( + self.config, + ipc_client=self.ipc if self.ipc.is_connected() else None, + lane=lane, + ) + if not routed: + self._report_needs_attention_failure(lane) + return routed + + def _needs_attention_lane(self) -> str: + """Return the lane a needs-attention click routes for. + + Either tray.json or the desktop's status report can put this machine on + the byoc lane. Either one is enough, so a disagreement keeps the click + local instead of sending it to the hosted dashboard. + """ + if self.config.deployment_lane == LANE_BYOC or self.state.current.is_byoc(): + return LANE_BYOC + return LANE_CLOUD + + def _open_teach_async(self) -> None: + """Start the desktop app if needed, then open Teach (byoc lane).""" + if self.ensure_desktop_connection() and route_break_click( + self.config, + ipc_client=self.ipc, + lane=LANE_BYOC, + ): + return + self._report_needs_attention_failure(LANE_BYOC) + + def _report_needs_attention_failure(self, lane: str) -> None: + """Tell the user that a needs-attention click opened nothing.""" + if lane == LANE_BYOC: + self.notifications.show( + "Could not open Teach", + "Open the OpenAdapt desktop app to review the automations that " + "need attention. On the byoc lane the tray does not open the " + "cloud dashboard.", + ) + return + self.notifications.show( + "Could not open needs-attention", + "No browser could be opened. " + f"Open {self.config.hosted_url.rstrip('/')}/dashboard manually.", + ) + + def login(self) -> bool: + """Start the hosted login flow. + + The tray does not implement auth; it opens the ingest-token settings + page (the desktop app owns the interactive providers) so the user can + mint/paste a token that lands in the shared keychain. + """ + return self._open_browser( + f"{self.config.hosted_url.rstrip('/')}/dashboard/settings/ingest", + "Could not open login settings", + ) + + def _open_browser(self, url: str, failure_title: str) -> bool: + """Open a URL and keep a missing browser distinct from a successful open.""" + try: + if webbrowser.open(url): + return True + except Exception as e: + print(f"Failed to open {url}: {e}") + self.notifications.show( + failure_title, + f"No usable browser opened. Open {url} manually.", + ) + return False + + def pause_sync(self) -> bool: + """Ask the desktop to pause the upload/sync queue.""" + if not self.ipc.is_connected() or not self.ipc.send_pause_sync(): + self.notifications.show( + "Could not pause sync", + "The desktop app did not accept the pause command.", + ) + return False + self.state.set_sync_state(SyncState.PAUSED) + return True + + def resume_sync(self) -> bool: + """Ask the desktop to resume the upload/sync queue.""" + if not self.ipc.is_connected() or not self.ipc.send_resume_sync(): + self.notifications.show( + "Could not resume sync", + "The desktop app did not accept the resume command.", + ) + return False + self.state.set_sync_state(SyncState.SYNCED) + return True + + def _report_config_error(self) -> None: + """Tell the user when the tray is running on defaults it did not choose.""" + if self._config_error is None: + return + self.notifications.show( + "Settings could not be read", + f"{self._config_error} — running on default settings " + f"(lane: {self.config.deployment_lane}).", + urgency="critical", + ) + + # --- hosted poller callbacks -------------------------------------------- + + def _on_hosted_count(self, result: CountResult) -> None: + """Apply a needs-attention count from the cloud poller.""" + self.state.set_break_count(result.count) + + def _on_hosted_credential(self, credential: CredentialStatus) -> None: + """Apply the privacy-safe credential status from the cloud poller.""" + self.state.set_credential_status(credential) + + def _on_hosted_offline(self, offline: bool) -> None: + """Reflect the cloud poller's online/offline status on the sync channel.""" + if offline: + self.state.set_sync_state(SyncState.OFFLINE) + elif self.state.current.is_offline(): + # Recovered connectivity — clear the offline marker. + self.state.set_sync_state(SyncState.SYNCED) + + # --- IPC event handlers (desktop → tray) -------------------------------- + + def _on_ipc_recording_started(self, message) -> None: + """Handle recording started IPC event.""" + data = message.data or {} + self.state.transition( + TrayState.RECORDING, + current_capture=data.get("capture_id") or data.get("name"), + ) + + def _on_ipc_recording_stopped(self, message) -> None: + """Handle recording stopped IPC event.""" + self.state.transition(TrayState.IDLE) + + def _on_ipc_recording_error(self, message) -> None: + """Handle recording error IPC event.""" + data = message.data or {} + self.state.transition( + TrayState.ERROR, + error_message=data.get("error", "Recording error"), + ) + + def _on_ipc_status_update(self, message) -> None: + """Handle a full status update from the desktop (source of truth). + + Protocol v1 carries the canonical ``recording`` boolean and optional + ``capture_id``. Hosted status fields remain orthogonal. + """ + data = message.data or {} + + lane = data.get("deployment_lane") + if lane in (LANE_CLOUD, LANE_BYOC): + self.state.set_deployment_lane(lane) + + sync = data.get("sync_state") + if sync is not None: + self._apply_sync_state(sync) + + if "break_count" in data: + self._apply_break_count(data.get("break_count")) + + recording = data.get("recording") + if type(recording) is bool: + self.state.transition( + TrayState.RECORDING if recording else TrayState.IDLE, + current_capture=(data.get("capture_id") if recording else None), + ) + return + + # Compatibility with the pre-v1 status projection. Protocol v1 uses + # the canonical ``recording`` boolean above. + state_name = data.get("state") + if isinstance(state_name, str): + try: + self.state.transition(TrayState[state_name.upper()]) + except KeyError: + pass + + def _on_ipc_compile_progress(self, message) -> None: + """Handle a compile-progress event (recording → workflow).""" + data = message.data or {} + state = str(data.get("state") or "").lower() + capture_id = data.get("capture_id") or data.get("name") + if state == "compiled": + self.state.transition(TrayState.IDLE) + elif state in {"failed", "review_failed"}: + default_error = ( + "The action review could not open." + if state == "review_failed" + else "The recording could not be compiled." + ) + self.state.transition( + TrayState.ERROR, + current_capture=capture_id, + error_message=str(data.get("error") or default_error), + ) + elif state == "compiling": + self.state.transition( + TrayState.COMPILING, + current_capture=capture_id, + ) + elif data.get("done") is True: + # Compatibility with an older terminal projection. + self.state.transition(TrayState.IDLE) + + def _on_ipc_sync_state(self, message) -> None: + """Handle a sync-state event from the desktop.""" + data = message.data or {} + self._apply_sync_state(data.get("state")) + + def _on_ipc_break_count(self, message) -> None: + """Handle a break-count event pushed from the desktop.""" + data = message.data or {} + self._apply_break_count(data.get("count")) + + def _apply_break_count(self, value: object) -> None: + """Apply a validated count without turning an unreadable value into zero.""" + try: + result = CountResult.from_payload({"count": value}) + except InvalidCountPayload as e: + print(f"Ignored unusable IPC break count: {e}") + return + self.state.set_break_count(result.count) + + def _apply_sync_state(self, name: str | None) -> None: + """Map a sync-state name from IPC onto the SyncState channel.""" + if not name: + return + mapping = { + "synced": SyncState.SYNCED, + "syncing": SyncState.SYNCING, + "pushing": SyncState.SYNCING, + "offline": SyncState.OFFLINE, + "paused": SyncState.PAUSED, + } + sync = mapping.get(str(name).lower()) + if sync is not None: + self.state.set_sync_state(sync) + + def run(self) -> None: + """Run the application.""" + # Say it out loud before anything relies on the settings. + self._report_config_error() + + # Start hotkey listener + self.hotkeys.start() + + # Platform-specific setup + self.platform.setup() + + # Try to connect to the desktop IPC server (optional — non-blocking). + try: + if self.ipc.refresh_from_discovery() and self.ipc.connect(): + print("Connected to desktop app IPC") + else: + print("Desktop app not running — will launch on first action") + except Exception as e: + print(f"IPC connection failed: {e} - running in standalone mode") + + # Start the cloud needs-attention poller. + self.hosted.start() + + # Run the tray icon (blocks) + self.icon.run() + + def quit(self) -> None: + """Quit the application.""" + # Stop any active recording + if self.state.current.is_recording(): + self.stop_recording() + + # Cleanup components + self.hosted.stop() + self.hotkeys.stop() + self.ipc.close() + self.notifications.cleanup() + self.platform.cleanup() + + # Stop tray icon + self.icon.stop() + + +def main() -> None: + """Entry point for the tray application.""" + app = TrayApplication() + try: + app.run() + except KeyboardInterrupt: + app.quit() + except Exception as e: + print(f"Fatal error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/tray/src/openadapt_tray/assets/mark-master.png b/packages/tray/src/openadapt_tray/assets/mark-master.png new file mode 100644 index 0000000000..bdebe38325 Binary files /dev/null and b/packages/tray/src/openadapt_tray/assets/mark-master.png differ diff --git a/packages/tray/src/openadapt_tray/config.py b/packages/tray/src/openadapt_tray/config.py new file mode 100644 index 0000000000..52375941bf --- /dev/null +++ b/packages/tray/src/openadapt_tray/config.py @@ -0,0 +1,225 @@ +"""Configuration management for OpenAdapt Tray.""" + +import json +import os +from dataclasses import dataclass, field, fields +from pathlib import Path +from typing import Any + +from openadapt_tray import keychain +from openadapt_tray.shortcuts import HotkeyConfig + +# Sane bounds for the cloud needs-attention poll (spec §3c). +MIN_POLL_INTERVAL_S = 30 +DEFAULT_POLL_INTERVAL_S = 60 +OFFLINE_POLL_INTERVAL_S = 300 + + +class ConfigLoadError(Exception): + """``tray.json`` exists but could not be read. + + Distinct from "no config file", which legitimately means defaults. + """ + + def __init__(self, path: Path, cause: Exception): + """Record which file failed and why. + + Args: + path: The configuration file that could not be read. + cause: The underlying parse/IO error. + """ + super().__init__(f"Could not read {path}: {cause}") + self.path = path + self.cause = cause + + @property + def defaults(self) -> "TrayConfig": + """The default configuration, for a caller that chooses to continue.""" + return TrayConfig() + + +@dataclass +class TrayConfig: + """Tray application configuration. + + Non-secret UI/behaviour prefs persist to the platform ``tray.json``. The + hosted host + deployment lane are ALSO surfaced here so all local surfaces + agree, but the ingest **token** is NEVER stored here — it is read from the + OS keychain (see :mod:`openadapt_tray.keychain`). + """ + + # Hotkeys + hotkeys: HotkeyConfig = field(default_factory=HotkeyConfig) + + # Paths + captures_directory: str = "~/openadapt/captures" + + # Desktop app (local loopback IPC). The port is normally discovered from + # ``~/.openadapt/desktop_ipc.json``; this is an optional override/fallback. + dashboard_port: int = 8080 + auto_launch_dashboard: bool = True + desktop_ipc_port: int | None = None + + # Hosted control plane (cloud). ``deployment_lane`` drives lane-aware + # break-click routing (cloud → dashboard; byoc → local teach). + hosted_url: str = "https://app.openadapt.ai" + deployment_lane: str = "cloud" # "cloud" | "byoc" + poll_interval_s: int = DEFAULT_POLL_INTERVAL_S + + # Behavior + auto_start_on_login: bool = False + minimize_to_tray: bool = True + show_notifications: bool = True + notification_duration_ms: int = 5000 + + # Recording + default_record_audio: bool = True + default_transcribe: bool = True + stop_on_triple_ctrl: bool = True + + # Appearance + use_native_dialogs: bool = True + + @classmethod + def config_path(cls) -> Path: + """Get configuration file path.""" + # Use XDG_CONFIG_HOME on Linux, ~/Library/Application Support on macOS, + # %APPDATA% on Windows + if os.name == "nt": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + elif os.name == "posix": + if "darwin" in os.sys.platform: + base = Path.home() / "Library" / "Application Support" + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + else: + base = Path.home() / ".config" + + return base / "openadapt" / "tray.json" + + @classmethod + def load(cls) -> "TrayConfig": + """Load configuration from file. + + A file that is not there means "this user has no saved preferences", + and defaults are the right answer. A file that IS there but cannot be + read is a different thing entirely, and defaults are the WRONG answer: + ``deployment_lane`` defaults to ``"cloud"``, so silently substituting + defaults would move a ``byoc`` install -- where the fix must stay local + -- onto the hosted route without telling anybody. + + Returns: + The saved configuration, or defaults when no file exists. + + Raises: + ConfigLoadError: The file exists but could not be read or parsed. + It carries ``defaults`` so a caller that chooses to continue + does so knowingly. + """ + path = cls.config_path() + if not path.exists(): + return cls() + try: + data = json.loads(path.read_text()) + return cls._from_dict(data) + except Exception as e: + raise ConfigLoadError(path, e) from e + + @classmethod + def load_or_defaults(cls) -> tuple["TrayConfig", "ConfigLoadError | None"]: + """Load the configuration, reporting an unreadable file instead of hiding it. + + Returns: + ``(config, error)``. ``error`` is ``None`` on a clean load. When it + is set, ``config`` is the default configuration AND the caller is + obliged to surface the error -- that is the whole point of handing + both back instead of one silently-defaulted object. + """ + try: + return cls.load(), None + except ConfigLoadError as e: + return e.defaults, e + + @classmethod + def _from_dict(cls, data: dict[str, Any]) -> "TrayConfig": + """Create a TrayConfig from a dictionary. + + Unknown/stale keys (e.g. the retired ``training_output_directory``) are + ignored so upgrading over an old ``tray.json`` never crashes. + """ + data = dict(data) # don't mutate caller's dict + hotkeys_data = data.pop("hotkeys", {}) + hotkeys = HotkeyConfig( + toggle_recording=hotkeys_data.get( + "toggle_recording", HotkeyConfig.toggle_recording + ), + open_dashboard=hotkeys_data.get( + "open_dashboard", HotkeyConfig.open_dashboard + ), + stop_recording=hotkeys_data.get( + "stop_recording", HotkeyConfig.stop_recording + ), + ) + + known = {f.name for f in fields(cls) if f.name != "hotkeys"} + filtered = {k: v for k, v in data.items() if k in known} + return cls(hotkeys=hotkeys, **filtered) + + def ensure_file(self) -> Path: + """Return the ``tray.json`` path, writing these settings if it is missing. + + An existing file is never rewritten, so an unreadable ``tray.json`` + stays as the user left it. + + Raises: + OSError: The file was missing and could not be written. + """ + path = self.config_path() + if not path.exists(): + self.save() + return path + + def save(self) -> None: + """Save configuration to file.""" + path = self.config_path() + path.parent.mkdir(parents=True, exist_ok=True) + + data = self.to_dict() + path.write_text(json.dumps(data, indent=2)) + + def to_dict(self) -> dict[str, Any]: + """Convert configuration to a dictionary (non-secret prefs only).""" + return { + "hotkeys": { + "toggle_recording": self.hotkeys.toggle_recording, + "open_dashboard": self.hotkeys.open_dashboard, + "stop_recording": self.hotkeys.stop_recording, + }, + "captures_directory": self.captures_directory, + "dashboard_port": self.dashboard_port, + "auto_launch_dashboard": self.auto_launch_dashboard, + "desktop_ipc_port": self.desktop_ipc_port, + "hosted_url": self.hosted_url, + "deployment_lane": self.deployment_lane, + "poll_interval_s": self.poll_interval_s, + "auto_start_on_login": self.auto_start_on_login, + "minimize_to_tray": self.minimize_to_tray, + "show_notifications": self.show_notifications, + "notification_duration_ms": self.notification_duration_ms, + "default_record_audio": self.default_record_audio, + "default_transcribe": self.default_transcribe, + "stop_on_triple_ctrl": self.stop_on_triple_ctrl, + "use_native_dialogs": self.use_native_dialogs, + } + + def get_captures_path(self) -> Path: + """Get the expanded captures directory path.""" + return Path(self.captures_directory).expanduser() + + def effective_poll_interval_s(self) -> int: + """Return the configured poll interval, clamped to the safe minimum.""" + return max(MIN_POLL_INTERVAL_S, int(self.poll_interval_s)) + + def get_ingest_token(self) -> str | None: + """Resolve the hosted ingest token from env/keychain (never the file).""" + return keychain.get_ingest_token(self.hosted_url) diff --git a/packages/tray/src/openadapt_tray/desktop.py b/packages/tray/src/openadapt_tray/desktop.py new file mode 100644 index 0000000000..b24ef982ac --- /dev/null +++ b/packages/tray/src/openadapt_tray/desktop.py @@ -0,0 +1,135 @@ +"""Native OpenAdapt Desktop discovery and launch helpers.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from collections.abc import Callable, Mapping +from pathlib import Path + +DESKTOP_APP_NAME = "OpenAdapt Desktop" +DESKTOP_BUNDLE_IDENTIFIER = "ai.openadapt.desktop" +LINUX_DESKTOP_IDENTITY = DESKTOP_APP_NAME +LINUX_NATIVE_EXECUTABLE = Path("/usr/bin/openadapt-desktop") + + +class DesktopLaunchError(RuntimeError): + """The installed native Desktop application could not be launched.""" + + +def _run_launcher( + command: list[str], + *, + runner: Callable[..., subprocess.CompletedProcess], +) -> bool: + """Run an OS launcher and return whether it accepted the application.""" + try: + result = runner( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + return result.returncode == 0 + + +def _spawn_executable( + executable: Path, + *, + spawner: Callable[..., subprocess.Popen], +) -> bool: + """Start an exact native executable path without resolving a CLI name.""" + try: + spawner( + [str(executable)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except OSError: + return False + return True + + +def _windows_candidates(environment: Mapping[str, str]) -> tuple[Path, ...]: + """Return the native paths produced by Desktop's MSI and NSIS installers.""" + candidates: list[Path] = [] + for variable in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"): + root = environment.get(variable) + if root: + candidates.append(Path(root) / DESKTOP_APP_NAME / "openadapt-desktop.exe") + return tuple(candidates) + + +def launch_native_desktop( + *, + platform: str | None = None, + environment: Mapping[str, str] | None = None, + runner: Callable[..., subprocess.CompletedProcess] = subprocess.run, + spawner: Callable[..., subprocess.Popen] = subprocess.Popen, + finder: Callable[[str], str | None] = shutil.which, + is_file: Callable[[Path], bool] = Path.is_file, +) -> None: + """Launch the installed native Desktop application for the current OS. + + The function never resolves the Python ``openadapt-desktop`` console script. + It uses the macOS bundle identifier, a registered Linux desktop identity, + or an exact path produced by a Windows native installer. + """ + platform = platform or sys.platform + if environment is None: + environment = os.environ + + if platform == "darwin": + if _run_launcher( + ["open", "-b", DESKTOP_BUNDLE_IDENTIFIER], + runner=runner, + ): + return + raise DesktopLaunchError( + f"macOS could not open {DESKTOP_APP_NAME} ({DESKTOP_BUNDLE_IDENTIFIER})." + ) + + if platform == "win32": + for executable in _windows_candidates(environment): + if is_file(executable) and _spawn_executable( + executable, + spawner=spawner, + ): + return + raise DesktopLaunchError( + f"Windows could not find an installed {DESKTOP_APP_NAME} application." + ) + + if platform.startswith("linux"): + gtk_launch = finder("gtk-launch") + if gtk_launch and _run_launcher( + [gtk_launch, LINUX_DESKTOP_IDENTITY], + runner=runner, + ): + return + + if is_file(LINUX_NATIVE_EXECUTABLE) and _spawn_executable( + LINUX_NATIVE_EXECUTABLE, + spawner=spawner, + ): + return + + appimage = environment.get("OPENADAPT_DESKTOP_APPIMAGE") + if appimage: + executable = Path(appimage).expanduser() + if is_file(executable) and _spawn_executable( + executable, + spawner=spawner, + ): + return + + raise DesktopLaunchError( + f"Linux could not open the registered {DESKTOP_APP_NAME} application." + ) + + raise DesktopLaunchError(f"Desktop launch is not supported on {platform}.") diff --git a/packages/tray/src/openadapt_tray/hosted.py b/packages/tray/src/openadapt_tray/hosted.py new file mode 100644 index 0000000000..62f17f679c --- /dev/null +++ b/packages/tray/src/openadapt_tray/hosted.py @@ -0,0 +1,680 @@ +"""Cloud needs-attention poller for OpenAdapt Tray. + +Polls the hosted control plane for the number of automations that need +attention and surfaces it as a tray badge + a desktop notification when the +count rises. Authenticated with the ingest bearer token from the OS keychain. + +Contract (spec §3c) — the exact request this module issues:: + + GET {hosted_url}/api/needs-attention/count + Authorization: Bearer + → 200 { "count": , "halts": , "uncertain_dispatches": } + +Click routing is lane-aware: + * cloud lane → open ``{hosted_url}/dashboard`` in the browser + * byoc lane → IPC ``open_teach`` to the desktop (the fix stays local); the + hosted dashboard is never a fallback on this lane +""" + +import hashlib +import hmac +import json +import os +import re +import threading +import webbrowser +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +import httpx + +from openadapt_tray.config import ( + MIN_POLL_INTERVAL_S, + OFFLINE_POLL_INTERVAL_S, + TrayConfig, +) +from openadapt_tray.state import LANE_BYOC, CredentialState, CredentialStatus + +COUNT_ENDPOINT_PATH = "/api/needs-attention/count" +REQUEST_TIMEOUT_S = 10.0 +CREDENTIAL_WARNING_DAYS = 14 +CREDENTIAL_CONTRACT_KEYS = { + "expires_at", + "expires_in_days", + "expiring_soon", + "legacy_non_expiring", + "warning_days", +} +CREDENTIAL_WARNING_STATE_VERSION = 1 +MAX_DELIVERED_CREDENTIAL_WARNINGS = 32 +INGEST_TOKEN_PATTERN = re.compile(r"^oai_ingest_[A-Za-z0-9_-]{43}$") +CREDENTIAL_IDENTITY_DOMAIN = b"openadapt-tray/credential-identity/v1" + + +class InvalidCountPayload(ValueError): + """The count endpoint returned a body we cannot read as a count. + + Raised instead of defaulting, because an unreadable body is NOT a count of + zero: zero renders as "nothing needs attention", which is the one answer we + must never invent. + """ + + +class InvalidCredentialPayload(ValueError): + """The additive credential block does not match the closed contract.""" + + +class CredentialWarningStateError(RuntimeError): + """The local warning-deduplication state could not be read or written.""" + + +def _is_json_int(value: object) -> bool: + """Return whether a value is a JSON integer rather than a boolean.""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _parse_utc_expiry(value: object) -> str: + """Validate and return an ISO-8601 UTC expiry string.""" + if not isinstance(value, str) or not value: + raise InvalidCredentialPayload("expires_at must be a non-empty string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as e: + raise InvalidCredentialPayload("expires_at must be ISO-8601") from e + if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed): + raise InvalidCredentialPayload("expires_at must use UTC") + return value + + +def _header_values(headers: Mapping[str, str] | None) -> dict[str, str] | None: + """Return case-insensitive response headers for contract validation.""" + if headers is None: + return None + return {str(key).lower(): str(value) for key, value in headers.items()} + + +def is_valid_ingest_token(token: object) -> bool: + """Return whether a value matches the closed hosted bearer format.""" + return isinstance(token, str) and INGEST_TOKEN_PATTERN.fullmatch(token) is not None + + +def parse_credential_status( + value: object, + *, + headers: Mapping[str, str] | None = None, +) -> CredentialStatus: + """Parse the control plane's closed credential-status contract. + + The server's ``expiring_soon`` value is authoritative. The tray validates + it, but never recomputes it from the local clock or rounded day count. + """ + if not isinstance(value, dict): + raise InvalidCredentialPayload("credential must be an object") + if set(value) != CREDENTIAL_CONTRACT_KEYS: + raise InvalidCredentialPayload("credential has unexpected or missing fields") + + warning_days = value["warning_days"] + if not _is_json_int(warning_days) or warning_days != CREDENTIAL_WARNING_DAYS: + raise InvalidCredentialPayload("warning_days does not match the contract") + expiring_soon = value["expiring_soon"] + legacy = value["legacy_non_expiring"] + if not isinstance(expiring_soon, bool) or not isinstance(legacy, bool): + raise InvalidCredentialPayload("credential flags must be booleans") + + normalized_headers = _header_values(headers) + if normalized_headers is not None: + if normalized_headers.get("cache-control") != "no-store": + raise InvalidCredentialPayload("cache-control header does not match") + if normalized_headers.get( + "x-openadapt-credential-warning-days" + ) != str(CREDENTIAL_WARNING_DAYS): + raise InvalidCredentialPayload("warning-days header does not match") + + if legacy: + if value["expires_at"] is not None or value["expires_in_days"] is not None: + raise InvalidCredentialPayload("legacy credential must not carry expiry") + if expiring_soon: + raise InvalidCredentialPayload("legacy credential cannot be expiring soon") + if normalized_headers is not None and ( + "x-openadapt-credential-expires-in-days" in normalized_headers + ): + raise InvalidCredentialPayload("legacy credential must omit expiry header") + return CredentialStatus( + state=CredentialState.LEGACY, + warning_days=CREDENTIAL_WARNING_DAYS, + ) + + expires_at = _parse_utc_expiry(value["expires_at"]) + expires_in_days = value["expires_in_days"] + if not _is_json_int(expires_in_days) or expires_in_days < 0: + raise InvalidCredentialPayload("expires_in_days must be a non-negative integer") + if expires_in_days < CREDENTIAL_WARNING_DAYS and not expiring_soon: + raise InvalidCredentialPayload("expiry status contradicts the day count") + if expires_in_days > CREDENTIAL_WARNING_DAYS and expiring_soon: + raise InvalidCredentialPayload("expiry status contradicts the day count") + if normalized_headers is not None and normalized_headers.get( + "x-openadapt-credential-expires-in-days" + ) != str(expires_in_days): + raise InvalidCredentialPayload("expiry-days header does not match") + + return CredentialStatus( + state=(CredentialState.EXPIRING if expiring_soon else CredentialState.ACTIVE), + expires_at=expires_at, + expires_in_days=expires_in_days, + warning_days=CREDENTIAL_WARNING_DAYS, + ) + + +def credential_identity(hosted_url: str, token: str) -> str: + """Return a non-secret identity digest without storing or logging a token.""" + message = CREDENTIAL_IDENTITY_DOMAIN + b"\0" + hosted_url.rstrip("/").encode() + return hmac.new( + token.encode(), + message, + hashlib.sha256, + ).hexdigest() + + +def credential_warning_key(identity: str, credential: CredentialStatus) -> str: + """Bind a delivered warning to the credential identity and expiry version.""" + material = json.dumps( + { + "contract": CREDENTIAL_WARNING_STATE_VERSION, + "credential": identity, + "expires_at": credential.expires_at, + "warning_days": credential.warning_days, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(material).hexdigest() + + +class CredentialWarningStore: + """Small persistent store for delivered credential-warning identities.""" + + def __init__(self, path: Path | None = None): + self.path = path or TrayConfig.config_path().with_name( + "credential-warning-state.json" + ) + + def _read(self) -> list[str]: + if not self.path.exists(): + return [] + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + raise CredentialWarningStateError("warning state is unreadable") from e + if ( + not isinstance(payload, dict) + or set(payload) != {"version", "delivered"} + or payload["version"] != CREDENTIAL_WARNING_STATE_VERSION + or not isinstance(payload["delivered"], list) + or any(not isinstance(item, str) for item in payload["delivered"]) + ): + raise CredentialWarningStateError("warning state has an invalid schema") + return payload["delivered"][-MAX_DELIVERED_CREDENTIAL_WARNINGS:] + + def was_delivered(self, warning_key: str) -> bool: + """Return whether the exact credential warning was delivered.""" + return warning_key in self._read() + + def mark_delivered(self, warning_key: str) -> None: + """Persist one delivered warning without storing the credential.""" + try: + delivered = self._read() + except CredentialWarningStateError: + # The failed read remains visible to the caller of ``was_delivered``. + # A confirmed new delivery can safely replace this non-secret cache. + delivered = [] + delivered = [item for item in delivered if item != warning_key] + delivered.append(warning_key) + delivered = delivered[-MAX_DELIVERED_CREDENTIAL_WARNINGS:] + payload = { + "version": CREDENTIAL_WARNING_STATE_VERSION, + "delivered": delivered, + } + temporary = self.path.with_name(f".{self.path.name}.tmp") + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary.write_text( + json.dumps(payload, sort_keys=True, separators=(",", ":")), + encoding="utf-8", + ) + os.chmod(temporary, 0o600) + os.replace(temporary, self.path) + except OSError as e: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise CredentialWarningStateError("warning state could not be saved") from e + + +def _optional_int(payload: dict, key: str) -> int: + """Read an optional integer subfield. + + Absent means zero (documented tolerance for the display-only subfields). + Present but unreadable means the body is malformed, which is an error. + """ + if key not in payload: + return 0 + try: + return int(payload[key]) + except (TypeError, ValueError) as e: + raise InvalidCountPayload(f"{key!r} is not an integer: {payload[key]!r}") from e + + +@dataclass +class CountResult: + """Parsed response from the needs-attention count endpoint.""" + + count: int + halts: int = 0 + uncertain_dispatches: int = 0 + credential: CredentialStatus = field(default_factory=CredentialStatus.unknown) + credential_error: str | None = field(default=None, repr=False, compare=False) + credential_identity: str | None = field(default=None, repr=False, compare=False) + + @classmethod + def from_payload( + cls, + payload: object, + *, + headers: Mapping[str, str] | None = None, + credential_id: str | None = None, + ) -> "CountResult": + """Build a result from the JSON body. + + ``count`` is the safety-critical number: it drives the badge and the + "N automations need attention" notification. A body without a readable + integer ``count`` means we do NOT know the count, so this raises + :class:`InvalidCountPayload` rather than substituting ``0`` -- an + absent field used to render as a confident all-clear. + + The display-only subfields (``halts``, ``uncertain_dispatches``) stay + tolerant of absence; see :func:`_optional_int`. + + Args: + payload: The decoded JSON body. + + Returns: + A parsed :class:`CountResult`. + + Raises: + InvalidCountPayload: The body is not a JSON object, has no + ``count``, or carries a count that is not a non-negative + integer. + """ + if not isinstance(payload, dict): + raise InvalidCountPayload( + f"expected a JSON object, got {type(payload).__name__}" + ) + if "count" not in payload: + raise InvalidCountPayload("response body has no 'count' field") + count = payload["count"] + # JSON booleans are Python integers, and ``int(1.9)`` truncates. Both + # conversions can turn an unreadable response into a confident badge + # value, including the unsafe ``false`` -> ``0`` all-clear. + if isinstance(count, bool) or not isinstance(count, int): + raise InvalidCountPayload(f"'count' is not an integer: {count!r}") + if count < 0: + raise InvalidCountPayload(f"'count' is negative: {count}") + credential = CredentialStatus.unknown() + credential_error = None + if "credential" not in payload: + credential_error = "credential block is absent" + else: + try: + credential = parse_credential_status( + payload["credential"], headers=headers + ) + except InvalidCredentialPayload as e: + credential_error = str(e) + return cls( + count=count, + halts=_optional_int(payload, "halts"), + uncertain_dispatches=_optional_int(payload, "uncertain_dispatches"), + credential=credential, + credential_error=credential_error, + credential_identity=credential_id, + ) + + +def count_url(hosted_url: str) -> str: + """Return the fully-qualified count endpoint URL for a hosted host.""" + return f"{hosted_url.rstrip('/')}{COUNT_ENDPOINT_PATH}" + + +class HostedPoller: + """Background poller for the cloud needs-attention count. + + The poller is transport-thin and testable: :meth:`poll_once` performs a + single authenticated request and returns a :class:`CountResult` (or + ``None`` on error/offline), while :meth:`start`/:meth:`stop` run it on an + interval with exponential-ish back-off. + """ + + def __init__( + self, + config: TrayConfig, + on_count: Callable[[CountResult], None], + notifier: object | None = None, + on_break_clicked: Callable[[], None] | None = None, + on_credential: Callable[[CredentialStatus], None] | None = None, + on_credential_clicked: Callable[[], None] | None = None, + token_provider: Callable[[], str | None] | None = None, + set_offline: Callable[[bool], None] | None = None, + warning_store: CredentialWarningStore | None = None, + ): + """Initialize the poller. + + Args: + config: Tray configuration (hosted_url, lane, poll interval). + on_count: Called with each successful :class:`CountResult` (used to + drive the tray badge / state). + notifier: Optional object with a ``show(title, body, on_clicked=…)`` + method (a :class:`NotificationManager`). + on_break_clicked: Optional lane-aware click handler for the + notification. Defaults to :func:`route_break_click` behaviour. + on_credential: Receives each validated credential status, including + the fail-visible unknown and signed-out states. + on_credential_clicked: Opens the hosted credential renewal page. + token_provider: Returns the current bearer token. Defaults to + ``config.get_ingest_token``. + set_offline: Optional callback invoked with the offline boolean each + cycle (used to drive the tray sync channel). + warning_store: Persistent delivered-warning store. Tests can supply + an isolated store. + """ + self.config = config + self._on_count = on_count + self._notifier = notifier + self._on_break_clicked = on_break_clicked + self._on_credential = on_credential + self._on_credential_clicked = on_credential_clicked + self._token_provider = token_provider or config.get_ingest_token + self._set_offline = set_offline + self._warning_store = warning_store or CredentialWarningStore() + + self._last_count = 0 + self._last_had_token = False + self._current_interval = config.effective_poll_interval_s() + self._thread: threading.Thread | None = None + self._stop = threading.Event() + + # --- single-shot request ------------------------------------------------ + + def poll_once(self) -> CountResult | None: + """Perform one authenticated count request. + + Returns: + A :class:`CountResult` on success, or ``None`` if unauthenticated, + offline, or the server errored. + """ + token = self._token_provider() + if not token: + self._last_had_token = False + # Not logged in yet — treat as offline for badge purposes. + return None + self._last_had_token = True + if not is_valid_ingest_token(token): + print("needs-attention poll refused an invalid credential format") + return None + + url = count_url(self.config.hosted_url) + headers = {"Authorization": f"Bearer {token}"} + try: + with httpx.Client(timeout=REQUEST_TIMEOUT_S) as client: + resp = client.get(url, headers=headers) + if resp.status_code != 200: + print(f"needs-attention count returned {resp.status_code}") + return None + try: + identity = credential_identity(self.config.hosted_url, token) + result = CountResult.from_payload( + resp.json(), + headers=getattr(resp, "headers", None), + credential_id=identity, + ) + if result.credential_error: + print( + "needs-attention credential status unusable: " + f"{result.credential_error}" + ) + return result + except InvalidCountPayload as e: + # A body we cannot read is not a count of zero. Report it as a + # failed poll so the badge keeps its last known value instead of + # clearing to a confident all-clear. + print(f"needs-attention count response unusable: {e}") + return None + except Exception as e: + # Network error / DNS / timeout / bad JSON → offline. + print(f"needs-attention poll failed: {e}") + return None + + # --- badge + notification ----------------------------------------------- + + def _handle_result(self, result: CountResult | None) -> None: + """Apply a poll result: update badge, notify on increase, set interval.""" + if result is None: + # Offline / unauthenticated → back off, mark offline. + self._current_interval = OFFLINE_POLL_INTERVAL_S + if self._set_offline: + self._set_offline(True) + if self._on_credential: + credential = ( + CredentialStatus.unknown() + if self._last_had_token + else CredentialStatus.signed_out() + ) + self._on_credential(credential) + return + + # Online: restore configured interval (respecting the floor). + self._current_interval = max( + MIN_POLL_INTERVAL_S, self.config.effective_poll_interval_s() + ) + if self._set_offline: + self._set_offline(False) + + # Drive the badge/state. + self._on_count(result) + if self._on_credential: + self._on_credential(result.credential) + + self._maybe_warn_credential(result) + + # Notify only when the count RISES (0→N or N→N+1), never on a decrease. + if result.count > self._last_count and result.count > 0: + if self._fire_notification(result.count): + self._last_count = result.count + # Not delivered: leave ``_last_count`` behind on purpose. Advancing + # it would record the user as informed about breaks they were never + # shown, and the count would have to rise AGAIN before we tried a + # second time. Holding it back makes the next poll retry. + else: + self._last_count = result.count + + def _maybe_warn_credential(self, result: CountResult) -> None: + """Show one persistent warning for an expiring credential version.""" + credential = result.credential + if credential.state != CredentialState.EXPIRING: + return + if not result.credential_identity or credential.expires_at is None: + print("credential warning not sent: credential identity is unavailable") + return + + warning_key = credential_warning_key( + result.credential_identity, + credential, + ) + try: + if self._warning_store.was_delivered(warning_key): + return + except CredentialWarningStateError as e: + print(f"credential warning state failure: {e}") + + if not self._fire_credential_notification(credential): + return + try: + self._warning_store.mark_delivered(warning_key) + except CredentialWarningStateError as e: + # Do not mark a warning as durable when the local record failed. + # The next poll retries the user-visible warning. + print(f"credential warning state failure: {e}") + + def _fire_credential_notification(self, credential: CredentialStatus) -> bool: + """Show a PHI-free actionable warning for an expiring credential.""" + if not self._notifier: + return False + days = credential.expires_in_days + if days is None or credential.expires_at is None: + return False + deadline = datetime.fromisoformat( + credential.expires_at.replace("Z", "+00:00") + ).strftime("%Y-%m-%d") + day_text = "day" if days == 1 else "days" + try: + delivered = self._notifier.show( + "OpenAdapt sign-in expires soon", + f"Your local connection expires in {days} {day_text} " + f"({deadline}). Renew it now.", + urgency="critical", + on_clicked=self._on_credential_clicked + or self._default_credential_click, + ) + except Exception as e: + print(f"Failed to show credential warning: {e}") + return False + if not delivered: + print("credential expiry warning was not delivered; retrying later") + return False + return True + + def _default_credential_click(self) -> None: + """Open the account credential settings page.""" + try: + opened = webbrowser.open( + f"{self.config.hosted_url.rstrip('/')}/dashboard/settings/ingest" + ) + except Exception as e: + print(f"Could not open credential settings: {e}") + return + if not opened: + print("Could not open credential settings: no usable browser") + + def _fire_notification(self, count: int) -> bool: + """Fire the 'N automations need attention' notification. + + Args: + count: The number of automations needing attention. + + Returns: + True only if the notifier reported the notification as delivered. + A notifier that cannot deliver (no toast backend, a dead + notification daemon) returns False from its ``show``; that answer is + now propagated instead of discarded. + """ + if not self._notifier: + return False + noun = "automation" if count == 1 else "automations" + try: + delivered = self._notifier.show( + "Automations need attention", + f"{count} {noun} need attention", + urgency="critical", + on_clicked=self._on_break_clicked or self._default_click, + ) + except Exception as e: + print(f"Failed to show needs-attention notification: {e}") + return False + if not delivered: + print( + f"needs-attention notification was NOT delivered ({count} {noun} " + "need attention); retrying on the next poll" + ) + return False + return True + + def _default_click(self) -> None: + """Fallback click handler (cloud-lane browser open).""" + route_break_click(self.config, ipc_client=None) + + # --- loop --------------------------------------------------------------- + + def start(self) -> None: + """Start the background poll loop.""" + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def _loop(self) -> None: + """Poll on an interval until stopped.""" + while not self._stop.is_set(): + result = self.poll_once() + self._handle_result(result) + # Wait for the (possibly backed-off) interval, or until stopped. + self._stop.wait(self._current_interval) + + def stop(self) -> None: + """Stop the background poll loop.""" + self._stop.set() + if self._thread: + self._thread.join(timeout=2.0) + self._thread = None + + @property + def current_interval(self) -> int: + """The interval (seconds) the loop will wait after the last cycle.""" + return self._current_interval + + +def route_break_click( + config: TrayConfig, + ipc_client: object | None = None, + lane: str | None = None, +) -> bool: + """Route a break/needs-attention click by deployment lane. + + Args: + config: Tray configuration (provides lane + hosted_url). + ipc_client: Optional IPC client used for the byoc local-teach route. + lane: The lane to route for. Defaults to ``config.deployment_lane``. + + Returns: + True if the click was routed somewhere the user can see. False means + nothing opened -- the user clicked and the screen did not change, so + the caller must say so rather than assume the click landed. + """ + lane = config.deployment_lane if lane is None else lane + if lane == LANE_BYOC: + # On the byoc lane the fix is taught in the desktop app, so workflow + # data stays on this machine. The hosted dashboard is never a fallback + # here: when the desktop cannot take the command, nothing opens and the + # caller reports it. + if ipc_client is None: + print("byoc needs-attention click: the desktop app is not connected") + return False + try: + if ipc_client.send_open_teach(): + return True + print("Desktop refused the open-teach command") + except Exception as e: + print(f"Failed to route byoc break click to desktop: {e}") + return False + # webbrowser.open returns False when it could not find or start a browser. + # Discarding that made a dead click indistinguishable from a served one. + try: + opened = webbrowser.open(f"{config.hosted_url.rstrip('/')}/dashboard") + except Exception as e: + print(f"Could not open the hosted dashboard: {e}") + return False + if not opened: + print("Could not open the hosted dashboard: no usable browser") + return bool(opened) diff --git a/packages/tray/src/openadapt_tray/icons.py b/packages/tray/src/openadapt_tray/icons.py new file mode 100644 index 0000000000..e0712fb4b8 --- /dev/null +++ b/packages/tray/src/openadapt_tray/icons.py @@ -0,0 +1,284 @@ +"""Icon management for OpenAdapt Tray. + +The tray icon is the OpenAdapt mark (the chat/robot face), color-stylized per +recording-lifecycle state so the state stays readable at a glance in the menu +bar / system tray: + +* idle -> brand blue +* recording start -> amber (spinning up) +* recording -> red +* recording stop -> amber (winding down) +* compiling -> purple (transforming a recording into a workflow) +* error -> red + +The mark is stored once as a high-resolution transparent master +(``assets/mark-master.png``, shipped inside the package). Every state icon is +produced by tinting that master's alpha silhouette with the state colour, so all +states render as the same recognizable mark and never drift apart. Pre-rendered +PNGs committed under the repo ``assets/icons`` directory are used when present +(dev/website), and are the same tinted marks produced at runtime. +""" + +import sys +from pathlib import Path +from typing import ClassVar + +from PIL import Image, ImageDraw + +from openadapt_tray.state import TrayState + + +def _hex_to_rgb(color: str) -> tuple[int, int, int]: + """Parse a ``#RRGGBB`` (or ``RRGGBB``) hex string into an RGB tuple.""" + color = color.lstrip("#") + return int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + +class IconManager: + """Manages loading, tinting and caching of tray icons. + + Every icon is the OpenAdapt mark tinted with the state colour. The mark + silhouette comes from a packaged high-res master so the icon ships in the + wheel and renders identically whether or not pre-rendered PNGs are present. + """ + + # State to icon filename mapping (one file per state; produced by + # ``scripts/generate_icons.py`` from the same master + colours used at + # runtime, so file-backed and runtime-tinted icons are identical). + STATE_ICONS: ClassVar[dict[TrayState, str]] = { + TrayState.IDLE: "idle.png", + TrayState.RECORDING_STARTING: "recording_starting.png", + TrayState.RECORDING: "recording.png", + TrayState.RECORDING_STOPPING: "recording_stopping.png", + TrayState.COMPILING: "compiling.png", + TrayState.ERROR: "error.png", + } + + # Per-state mark tint. Keeps the historical state->colour signalling. + STATE_COLORS: ClassVar[dict[TrayState, str]] = { + TrayState.IDLE: "#4A90D9", # Brand blue + TrayState.RECORDING_STARTING: "#F5A623", # Amber (spinning up) + TrayState.RECORDING: "#D0021B", # Red + TrayState.RECORDING_STOPPING: "#F5A623", # Amber (winding down) + TrayState.COMPILING: "#7B68EE", # Purple (transforming a recording) + TrayState.ERROR: "#D0021B", # Red + } + + # Colour of the break/needs-attention badge dot. + BADGE_COLOR = "#D0021B" # Red + + # Packaged mark master (transparent silhouette). Ships in the wheel. + MASTER_PATH = Path(__file__).parent / "assets" / "mark-master.png" + + def __init__(self, assets_dir: Path | None = None): + """Initialize the icon manager. + + Args: + assets_dir: Path to the pre-rendered icons directory. If None, uses + the repo ``assets/icons`` directory when present (dev). Runtime + tinting of the packaged master is the source of truth whenever a + file is missing, so a bare wheel still shows the mark. + """ + if assets_dir is None: + module_dir = Path(__file__).parent + assets_dir = module_dir.parent.parent / "assets" / "icons" + + self.assets_dir = assets_dir + self._cache: dict[str, Image.Image] = {} + self._master: Image.Image | None = None + self._retina_scale = self._detect_retina() + + def _detect_retina(self) -> int: + """Detect if we're on a retina/HiDPI display.""" + if sys.platform == "darwin": + # On macOS, assume retina by default for newer systems + return 2 + return 1 + + def _load_master(self) -> Image.Image | None: + """Load (and cache) the transparent mark master, if available.""" + if self._master is not None: + return self._master + if self.MASTER_PATH.exists(): + try: + self._master = Image.open(self.MASTER_PATH).convert("RGBA") + except Exception: + self._master = None + return self._master + + def get(self, state: TrayState, break_count: int = 0) -> Image.Image: + """Get the icon for a given state, optionally with a break badge. + + Args: + state: The recording-lifecycle state. + break_count: Number of automations needing attention. When > 0 a + small red badge dot is overlaid on the icon. + + Returns: + PIL Image for the icon. + """ + icon_name = self.STATE_ICONS.get(state, "idle.png") + + # Badge presence is part of the cache key so badged/unbadged variants + # don't collide. + badge = break_count > 0 + cache_key = f"{icon_name}_{self._retina_scale}_badge={badge}" + if cache_key in self._cache: + return self._cache[cache_key] + + # Prefer a pre-rendered file (dev/website); otherwise tint the mark. + icon = self._load_icon(icon_name) + if icon is None: + icon = self._generate_fallback(state) + + if badge: + icon = self._apply_badge(icon) + + self._cache[cache_key] = icon + return icon + + def _apply_badge(self, icon: Image.Image) -> Image.Image: + """Overlay a small badge dot on the top-right of an icon. + + Args: + icon: The base icon image. + + Returns: + A new image with the badge composited on top. + """ + base = icon.convert("RGBA").copy() + w, _h = base.size + draw = ImageDraw.Draw(base) + + # Badge sized relative to the icon, anchored top-right. + radius = max(4, w // 4) + cx, cy = w - radius, radius + r, g, b = _hex_to_rgb(self.BADGE_COLOR) + # Punch a small transparent ring so the dot reads against the mark. + ring = max(1, radius // 3) + draw.ellipse( + [cx - radius - ring, cy - radius - ring, + cx + radius + ring, cy + radius + ring], + fill=(0, 0, 0, 0), + ) + draw.ellipse( + [cx - radius, cy - radius, cx + radius, cy + radius], + fill=(r, g, b, 255), + ) + return base + + def _load_icon(self, icon_name: str) -> Image.Image | None: + """Try to load a pre-rendered icon from the assets directory. + + Args: + icon_name: The icon filename. + + Returns: + PIL Image if found, None otherwise. + """ + if not self.assets_dir.exists(): + return None + + # Try retina version first on HiDPI displays + if self._retina_scale > 1: + retina_name = icon_name.replace(".png", "@2x.png") + retina_path = self.assets_dir / retina_name + if retina_path.exists(): + try: + return Image.open(retina_path) + except OSError as e: + # Present but unreadable/corrupt. Fall through to the 1x file + # and then to runtime tinting, but say why -- silently + # ignoring a bad PNG made a broken asset indistinguishable + # from a missing one. + print(f"Could not read icon {retina_path}: {e}") + + # Try regular version + icon_path = self.assets_dir / icon_name + if icon_path.exists(): + try: + return Image.open(icon_path) + except OSError as e: + # Fall through to runtime tinting of the packaged master. + print(f"Could not read icon {icon_path}: {e}") + + return None + + def _generate_fallback(self, state: TrayState, size: int = 64) -> Image.Image: + """Render the mark tinted for a state (used when no PNG file exists). + + Args: + state: The application state. + size: The icon size in pixels. + + Returns: + Generated PIL Image. + """ + return self.render_state_icon(state, size) + + def render_state_icon(self, state: TrayState, size: int = 64) -> Image.Image: + """Render the tinted mark for a state at an explicit size. + + Public helper shared by ``scripts/generate_icons.py`` so committed PNGs + and runtime icons come from one implementation. + """ + color = self.STATE_COLORS.get(state, self.STATE_COLORS[TrayState.IDLE]) + master = self._load_master() + if master is None: + # Last-resort fallback if the packaged master is somehow missing. + return self.create_colored_icon(color, size) + return self.tint_mark(master, color, size) + + @staticmethod + def tint_mark(master: Image.Image, color: str, size: int) -> Image.Image: + """Tint the mark master's silhouette with ``color`` at ``size`` px. + + The master is a transparent image whose alpha encodes the mark (eyes and + smile are negative space). We keep that alpha and replace the colour, so + every state is the same recognizable mark in its own colour. + + Args: + master: The transparent mark master image (RGBA). + color: Hex colour string, e.g. ``"#4A90D9"``. + size: Output width/height in pixels. + + Returns: + A square RGBA image of the tinted mark. + """ + r, g, b = _hex_to_rgb(color) + scaled = master.convert("RGBA").resize((size, size), Image.LANCZOS) + alpha = scaled.split()[3] + solid = Image.new("RGBA", (size, size), (r, g, b, 255)) + out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + out.paste(solid, (0, 0), alpha) + return out + + @staticmethod + def create_colored_icon(color: str, size: int = 64) -> Image.Image: + """Create a simple colored circle icon (legacy last-resort fallback). + + Args: + color: Hex color string (e.g., "#FF0000"). + size: Icon size in pixels. + + Returns: + PIL Image with the specified color. + """ + image = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + r, g, b = _hex_to_rgb(color) + draw = ImageDraw.Draw(image) + padding = size // 8 + draw.ellipse( + [padding, padding, size - padding, size - padding], + fill=(r, g, b, 255), + ) + return image + + def clear_cache(self) -> None: + """Clear the icon cache.""" + self._cache.clear() + + def preload_all(self) -> None: + """Preload all state icons into cache.""" + for state in TrayState: + self.get(state) diff --git a/packages/tray/src/openadapt_tray/ipc.py b/packages/tray/src/openadapt_tray/ipc.py new file mode 100644 index 0000000000..a5e8305f8f --- /dev/null +++ b/packages/tray/src/openadapt_tray/ipc.py @@ -0,0 +1,520 @@ +"""Inter-process communication for OpenAdapt Tray. + +The tray is an IPC *client* that connects to a loopback TCP socket server +exposed by the desktop app. The desktop writes its bound host/port and a +per-session shared token to a discovery file at ``~/.openadapt/desktop_ipc.json``; +the tray reads it, connects, and includes the token on every command so the +desktop can reject other local processes. + +Transport is newline-delimited JSON. Commands flow tray→desktop; events flow +desktop→tray. The desktop app is the source of truth for all state — the tray +only renders it. +""" + +import contextlib +import json +import socket +import threading +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any, Optional + +# Discovery file the desktop app writes on startup (see §3d of the spec). +DEFAULT_DISCOVERY_PATH = Path.home() / ".openadapt" / "desktop_ipc.json" +SUPPORTED_PROTOCOL_VERSION = 1 + + +class IPCMessageType(Enum): + """IPC message types exchanged with the desktop app.""" + + # Commands (tray → desktop) + START_RECORDING = "start_recording" + STOP_RECORDING = "stop_recording" + GET_STATUS = "get_status" + OPEN_WORKFLOW_LIBRARY = "open_workflow_library" + OPEN_TEACH = "open_teach" + PAUSE_SYNC = "pause_sync" + RESUME_SYNC = "resume_sync" + + # Events (desktop → tray) + RECORDING_STARTED = "recording_started" + RECORDING_STOPPED = "recording_stopped" + RECORDING_ERROR = "recording_error" + STATUS_UPDATE = "status_update" + COMPILE_PROGRESS = "compile_progress" + SYNC_STATE = "sync_state" + BREAK_COUNT = "break_count" + + +@dataclass +class IPCMessage: + """IPC message structure (newline-delimited JSON on the wire). + + Command messages (tray → desktop) carry the per-session ``token`` from the + discovery file so the desktop can authenticate the sender. Event messages + (desktop → tray) carry a ``data`` payload. + """ + + type: IPCMessageType + data: dict[str, Any] | None = None + token: str | None = None + + def to_json(self) -> str: + """Serialize to JSON string.""" + obj: dict[str, Any] = { + "type": self.type.value, + "data": self.data, + } + if self.token is not None: + obj["token"] = self.token + return json.dumps(obj) + + @classmethod + def from_json(cls, json_str: str) -> "IPCMessage": + """Deserialize from JSON string.""" + obj = json.loads(json_str) + return cls( + type=IPCMessageType(obj["type"]), + data=obj.get("data"), + token=obj.get("token"), + ) + + +@dataclass +class DesktopEndpoint: + """Discovered desktop-app IPC endpoint.""" + + protocol_version: int + host: str + port: int + token: str | None = None + + @classmethod + def load(cls, path: Path | None = None) -> Optional["DesktopEndpoint"]: + """Load the desktop IPC endpoint from the discovery file. + + Args: + path: Discovery file path. Defaults to + ``~/.openadapt/desktop_ipc.json``. + + Returns: + A :class:`DesktopEndpoint` if the file exists and is valid, + otherwise ``None`` (desktop app not running). + """ + path = path or DEFAULT_DISCOVERY_PATH + try: + if not path.exists(): + return None + data = json.loads(path.read_text()) + protocol_version = data.get("protocol_version") + if ( + type(protocol_version) is not int + or protocol_version != SUPPORTED_PROTOCOL_VERSION + ): + print( + "Could not use desktop IPC discovery file: " + f"protocol_version must be {SUPPORTED_PROTOCOL_VERSION}" + ) + return None + port = data.get("port") + if port is None: + return None + return cls( + protocol_version=protocol_version, + host=data.get("host", "127.0.0.1"), + port=int(port), + token=data.get("token"), + ) + except (OSError, ValueError, json.JSONDecodeError) as e: + print(f"Could not read desktop IPC discovery file: {e}") + return None + + +class IPCClient: + """IPC client for communicating with OpenAdapt processes.""" + + DEFAULT_HOST = "127.0.0.1" + DEFAULT_PORT = 9876 + + def __init__( + self, + host: str = DEFAULT_HOST, + port: int = DEFAULT_PORT, + token: str | None = None, + ): + """Initialize IPC client. + + Args: + host: Host address for the desktop IPC server. + port: Port number for the desktop IPC server. + token: Per-session shared token (from the discovery file) attached + to every command so the desktop can authenticate the sender. + """ + self.host = host + self.port = port + self.token = token + self._socket: socket.socket | None = None + self._listener_thread: threading.Thread | None = None + self._running = False + self._handlers: dict[IPCMessageType, Callable[[IPCMessage], None]] = {} + + @classmethod + def from_discovery(cls, path: Path | None = None) -> Optional["IPCClient"]: + """Build a client from the desktop discovery file, if present. + + Args: + path: Discovery file path. Defaults to + ``~/.openadapt/desktop_ipc.json``. + + Returns: + A configured :class:`IPCClient`, or ``None`` if the desktop app is + not running (no discovery file). + """ + endpoint = DesktopEndpoint.load(path) + if endpoint is None: + return None + return cls(host=endpoint.host, port=endpoint.port, token=endpoint.token) + + def refresh_from_discovery(self, path: Path | None = None) -> bool: + """Re-read the discovery file and update host/port/token in place. + + Useful after launching the desktop app: the discovery file appears once + the desktop's socket server is bound. + + Args: + path: Discovery file path. Defaults to the standard location. + + Returns: + True if a valid endpoint was found and applied. + """ + endpoint = DesktopEndpoint.load(path) + if endpoint is None: + return False + self.host = endpoint.host + self.port = endpoint.port + self.token = endpoint.token + return True + + def register_handler( + self, + message_type: IPCMessageType, + handler: Callable[[IPCMessage], None], + ) -> None: + """Register a message handler. + + Args: + message_type: Type of message to handle. + handler: Callback function for this message type. + """ + self._handlers[message_type] = handler + + def connect(self) -> bool: + """Connect to the IPC server. + + Returns: + True if connected successfully. + """ + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.settimeout(5.0) + self._socket.connect((self.host, self.port)) + self._socket.settimeout(None) + + # Start listener thread + self._running = True + self._listener_thread = threading.Thread( + target=self._listen_loop, + daemon=True, + ) + self._listener_thread.start() + + # The desktop is authoritative. Request a complete status snapshot + # before the tray accepts any user action based on its default + # in-memory state. + if not self.send_get_status(): + self.close() + return False + + return True + except OSError as e: + print(f"IPC connection failed: {e}") + self._socket = None + return False + + def _listen_loop(self) -> None: + """Listen for incoming messages.""" + buffer = "" + while self._running and self._socket: + try: + data = self._socket.recv(4096) + if not data: + break + + buffer += data.decode("utf-8") + + # Process complete messages (newline-delimited) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line: + self._handle_message(line) + + except TimeoutError: + continue + except OSError: + break + except Exception as e: + print(f"IPC receive error: {e}") + break + + self._running = False + + def _handle_message(self, json_str: str) -> None: + """Handle an incoming message. + + Args: + json_str: JSON-encoded message string. + """ + try: + message = IPCMessage.from_json(json_str) + handler = self._handlers.get(message.type) + if handler: + handler(message) + except Exception as e: + print(f"Error handling IPC message: {e}") + + def send(self, message: IPCMessage) -> bool: + """Send a message to the desktop IPC server. + + The per-session token is attached automatically if not already set on + the message. + + Args: + message: Message to send. + + Returns: + True if sent successfully. + """ + if not self._socket: + return False + + if message.token is None and self.token is not None: + message.token = self.token + + try: + data = message.to_json() + "\n" + self._socket.sendall(data.encode("utf-8")) + return True + except OSError as e: + print(f"IPC send error: {e}") + return False + + def send_command( + self, + message_type: IPCMessageType, + data: dict[str, Any] | None = None, + ) -> bool: + """Send a parameterless-or-simple command to the desktop. + + Args: + message_type: The command type. + data: Optional payload. + + Returns: + True if sent successfully. + """ + return self.send(IPCMessage(type=message_type, data=data)) + + def send_start_recording(self, name: str) -> bool: + """Send start recording command. + + Args: + name: Recording name. + + Returns: + True if sent successfully. + """ + return self.send( + IPCMessage( + type=IPCMessageType.START_RECORDING, + data={"name": name}, + ) + ) + + def send_stop_recording(self) -> bool: + """Send stop recording command.""" + return self.send(IPCMessage(type=IPCMessageType.STOP_RECORDING)) + + def send_get_status(self) -> bool: + """Send status request.""" + return self.send(IPCMessage(type=IPCMessageType.GET_STATUS)) + + def send_open_workflow_library(self) -> bool: + """Ask the desktop to open its local workflow library window.""" + return self.send(IPCMessage(type=IPCMessageType.OPEN_WORKFLOW_LIBRARY)) + + def send_open_teach(self, workflow_id: str | None = None) -> bool: + """Ask the desktop to open the local teach-the-fix view. + + Args: + workflow_id: Optional workflow to open teach for. + """ + data = {"workflow_id": workflow_id} if workflow_id else None + return self.send(IPCMessage(type=IPCMessageType.OPEN_TEACH, data=data)) + + def send_pause_sync(self) -> bool: + """Ask the desktop to pause the upload/sync queue.""" + return self.send(IPCMessage(type=IPCMessageType.PAUSE_SYNC)) + + def send_resume_sync(self) -> bool: + """Ask the desktop to resume the upload/sync queue.""" + return self.send(IPCMessage(type=IPCMessageType.RESUME_SYNC)) + + def is_connected(self) -> bool: + """Check if connected to IPC server.""" + return self._socket is not None and self._running + + def close(self) -> None: + """Close the IPC connection.""" + self._running = False + + if self._socket: + # A close() failure on teardown is not actionable: the fd is dropped + # either way and the process is on its way out. + with contextlib.suppress(OSError): + self._socket.close() + self._socket = None + + if self._listener_thread: + self._listener_thread.join(timeout=1.0) + self._listener_thread = None + + +class IPCServer: + """Simple IPC server for testing or standalone mode.""" + + def __init__( + self, + host: str = IPCClient.DEFAULT_HOST, + port: int = IPCClient.DEFAULT_PORT, + ): + """Initialize IPC server. + + Args: + host: Host address to bind to. + port: Port number to listen on. + """ + self.host = host + self.port = port + self._socket: socket.socket | None = None + self._running = False + self._handlers: dict[IPCMessageType, Callable[[IPCMessage], IPCMessage]] = {} + + def register_handler( + self, + message_type: IPCMessageType, + handler: Callable[[IPCMessage], IPCMessage | None], + ) -> None: + """Register a message handler. + + Args: + message_type: Type of message to handle. + handler: Callback function that may return a response. + """ + self._handlers[message_type] = handler + + def start(self) -> bool: + """Start the IPC server. + + Returns: + True if started successfully. + """ + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._socket.bind((self.host, self.port)) + self._socket.listen(5) + + self._running = True + threading.Thread(target=self._accept_loop, daemon=True).start() + + return True + except OSError as e: + print(f"IPC server start failed: {e}") + return False + + def _accept_loop(self) -> None: + """Accept incoming connections.""" + while self._running and self._socket: + try: + self._socket.settimeout(1.0) + client, _addr = self._socket.accept() + threading.Thread( + target=self._handle_client, + args=(client,), + daemon=True, + ).start() + except TimeoutError: + continue + except OSError as e: + # accept() raises only OSError subclasses. The expected one is + # stop() closing the listening socket out from under us; anything + # else means the server really has stopped accepting, so say so + # instead of ending the loop silently. + if self._running: + print(f"IPC server stopped accepting connections: {e}") + break + + def _handle_client(self, client: socket.socket) -> None: + """Handle a client connection. + + Args: + client: Client socket. + """ + buffer = "" + try: + while self._running: + data = client.recv(4096) + if not data: + break + + buffer += data.decode("utf-8") + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line: + response = self._process_message(line) + if response: + client.sendall((response.to_json() + "\n").encode("utf-8")) + except Exception as e: + print(f"Error handling IPC client: {e}") + finally: + client.close() + + def _process_message(self, json_str: str) -> IPCMessage | None: + """Process an incoming message. + + Args: + json_str: JSON-encoded message string. + + Returns: + Optional response message. + """ + try: + message = IPCMessage.from_json(json_str) + handler = self._handlers.get(message.type) + if handler: + return handler(message) + except Exception as e: + print(f"Error processing IPC message: {e}") + return None + + def stop(self) -> None: + """Stop the IPC server.""" + self._running = False + if self._socket: + # See IPCClient.close(): a teardown close() failure is not actionable. + with contextlib.suppress(OSError): + self._socket.close() + self._socket = None diff --git a/packages/tray/src/openadapt_tray/keychain.py b/packages/tray/src/openadapt_tray/keychain.py new file mode 100644 index 0000000000..30d1ae0c85 --- /dev/null +++ b/packages/tray/src/openadapt_tray/keychain.py @@ -0,0 +1,101 @@ +"""OS keychain access for the hosted ingest token. + +Secrets are NEVER written to ``tray.json`` / ``config.toml``. The ingest token +lives in the OS secure store (macOS Keychain / Windows Credential Manager / +Linux Secret Service) via ``keyring``, under the SAME service name the desktop +app's ``engine/auth/store.py`` uses so both surfaces share one credential. + +Resolution precedence for reads (mirrors the desktop ``push`` tool): + ``OPENADAPT_INGEST_TOKEN`` env var → keychain entry for the host. +""" + +import os + +# Shared with the desktop app (spec §3a/§3e): service "ai.openadapt.desktop", +# account keyed by hosted host. +KEYCHAIN_SERVICE = "ai.openadapt.desktop" +ENV_INGEST_TOKEN = "OPENADAPT_INGEST_TOKEN" + +try: + import keyring + + KEYRING_AVAILABLE = True +except Exception: # pragma: no cover - keyring backend may be unavailable + keyring = None # type: ignore[assignment] + KEYRING_AVAILABLE = False + + +def _account(host: str) -> str: + """Normalize a hosted host into a keychain account key.""" + return (host or "").rstrip("/") + + +def get_ingest_token(host: str) -> str | None: + """Resolve the ingest token for a hosted host. + + Args: + host: The hosted base URL (e.g. ``https://app.openadapt.ai``). + + Returns: + The bearer token, or ``None`` if unset. + """ + env = os.environ.get(ENV_INGEST_TOKEN) + if env: + return env + + if not KEYRING_AVAILABLE: + return None + try: + return keyring.get_password(KEYCHAIN_SERVICE, _account(host)) + except Exception as e: # pragma: no cover - backend errors + print(f"Keychain read failed: {e}") + return None + + +def set_ingest_token(host: str, token: str) -> bool: + """Store the ingest token for a hosted host in the OS keychain. + + Args: + host: The hosted base URL. + token: The bearer token to store. + + Returns: + True if stored successfully. + """ + if not KEYRING_AVAILABLE: + return False + try: + keyring.set_password(KEYCHAIN_SERVICE, _account(host), token) + return True + except Exception as e: # pragma: no cover - backend errors + print(f"Keychain write failed: {e}") + return False + + +def clear_ingest_token(host: str) -> bool: + """Remove the stored ingest token for a hosted host. + + Args: + host: The hosted base URL. + + Returns: + True if removed (or already absent). + """ + if not KEYRING_AVAILABLE: + return False + try: + keyring.delete_password(KEYCHAIN_SERVICE, _account(host)) + return True + except keyring.errors.PasswordDeleteError: + # keyring raises this when the entry is absent. The contract is "removed + # OR already absent", so this IS the success case -- returning False here + # made a signed-out tray look like a failed sign-out. + return True + except Exception as e: # pragma: no cover - backend errors + print(f"Keychain delete failed: {e}") + return False + + +def has_ingest_token(host: str) -> bool: + """Return True if an ingest token is resolvable for the host.""" + return get_ingest_token(host) is not None diff --git a/packages/tray/src/openadapt_tray/menu.py b/packages/tray/src/openadapt_tray/menu.py new file mode 100644 index 0000000000..83c9160a35 --- /dev/null +++ b/packages/tray/src/openadapt_tray/menu.py @@ -0,0 +1,361 @@ +"""Menu construction and actions for OpenAdapt Tray.""" + +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import partial +from typing import TYPE_CHECKING + +from pystray import Menu +from pystray import MenuItem as Item + +if TYPE_CHECKING: + from openadapt_tray.app import TrayApplication + +from openadapt_tray.platform.base import DialogUnavailableError +from openadapt_tray.shortcuts import format_hotkey +from openadapt_tray.state import CredentialState, TrayState + + +@dataclass +class CaptureInfo: + """Information about a capture.""" + + name: str + path: str + timestamp: str + + +class MenuBuilder: + """Builds the system tray context menu.""" + + def __init__(self, app: "TrayApplication"): + """Initialize the menu builder. + + Args: + app: The parent TrayApplication instance. + """ + self.app = app + + def build(self) -> Menu: + """Build the current menu based on application state. + + Returns: + pystray Menu instance. + """ + state = self.app.state.current + + items = [ + self._build_recording_item(state), + Menu.SEPARATOR, + self._build_captures_submenu(), + ] + + # Break/needs-attention entry (only when there are open breaks). + break_item = self._build_break_item(state) + if break_item is not None: + items.append(break_item) + + items += [ + Menu.SEPARATOR, + Item("Open Desktop App", self._open_desktop_app), + Item("Open Cloud Dashboard", self._open_cloud_dashboard), + self._build_account_item(state), + self._build_sync_item(state), + Item("Settings...", self._open_settings), + Menu.SEPARATOR, + Item("Quit", self._quit), + ] + + return Menu(*items) + + def _build_break_item(self, state) -> Item | None: + """Build the needs-attention entry when breaks are present. + + Args: + state: Current application state. + + Returns: + A menu item, or ``None`` when there are no open breaks. + """ + if not state.has_breaks(): + return None + count = state.break_count + noun = "automation" if count == 1 else "automations" + return Item( + f"⚠ {count} {noun} need attention", + lambda: self.app.open_needs_attention(), + ) + + def _build_account_item(self, state) -> Item: + """Build the hosted account status and renewal action.""" + credential = state.credential + if credential.state == CredentialState.NOT_CHECKED: + return Item("Account: checking status...", None, enabled=False) + if credential.state == CredentialState.SIGNED_OUT: + return Item("Account: sign in", self._login) + if credential.state == CredentialState.UNKNOWN: + return Item("Account: status unavailable", self._login) + if credential.state == CredentialState.LEGACY: + return Item("Account: connected · renew credential", self._login) + if credential.state == CredentialState.EXPIRING: + days = credential.expires_in_days + if days == 0: + label = "Account: sign-in expires today" + else: + noun = "day" if days == 1 else "days" + label = f"Account: sign-in expires in {days} {noun}" + return Item(label, self._login) + days = credential.expires_in_days + if days is None: + return Item("Account: connected", self._login) + noun = "day" if days == 1 else "days" + return Item(f"Account: connected · {days} {noun} left", self._login) + + def _build_sync_item(self, state) -> Item: + """Build the pause/resume-sync toggle. + + Args: + state: Current application state. + + Returns: + Menu item toggling the upload/sync queue. + """ + if state.is_offline(): + return Item("Sync (offline)", None, enabled=False) + if state.is_sync_paused(): + return Item("Resume Sync", lambda: self.app.resume_sync()) + return Item("Pause Sync", lambda: self.app.pause_sync()) + + def _build_recording_item(self, state) -> Item: + """Build record/stop recording menu item. + + Args: + state: Current application state. + + Returns: + Menu item for recording control. + """ + if state.state == TrayState.RECORDING: + label = f"Stop Recording ({state.current_capture})" + return Item(label, lambda: self.app.stop_recording()) + elif state.state == TrayState.RECORDING_STARTING: + return Item("Starting...", None, enabled=False) + elif state.state == TrayState.RECORDING_STOPPING: + return Item("Stopping...", None, enabled=False) + elif state.state == TrayState.COMPILING: + return Item("Compiling...", None, enabled=False) + else: + # The raw pynput string stays in the config and in the hotkey + # registration; only the label is formatted for people. + hotkey = format_hotkey(self.app.config.hotkeys.toggle_recording) + label = f"Start Recording ({hotkey})" + return Item( + label, + lambda: self.app.start_recording(), + enabled=state.can_start_recording(), + ) + + def _build_captures_submenu(self) -> Item: + """Build captures submenu. + + Returns: + Menu item with captures submenu. + """ + try: + captures = self._get_recent_captures() + except OSError as e: + # "We could not look" must not render as "there is nothing there". + # An unreadable captures directory now says so in the menu. + print(f"Could not read the captures directory: {e}") + return Item( + "Recent Captures", + Menu(Item("Could not read captures", None, enabled=False)), + ) + + if not captures: + return Item( + "Recent Captures", + Menu(Item("No captures", None, enabled=False)), + ) + + capture_items = [] + for c in captures[:10]: # Limit to 10 most recent + capture_items.append( + Item( + f"{c.name} ({c.timestamp})", + Menu( + Item("View", partial(self._view_capture, c.path)), + Item("Delete", partial(self._delete_capture, c.path, c.name)), + ), + ) + ) + + capture_items.append(Menu.SEPARATOR) + capture_items.append(Item("View All...", self._open_captures_list)) + + return Item("Recent Captures", Menu(*capture_items)) + + def _get_recent_captures(self) -> list[CaptureInfo]: + """Get list of recent captures. + + An empty list means the directory was read and held no captures. It + never means the directory could not be read -- that raises, so the + caller can render the two outcomes differently. + + Returns: + List of CaptureInfo objects, newest first (at most 10). + + Raises: + OSError: The captures directory exists but could not be listed or + stat'ed (permissions, a dead mount, a broken symlink). + """ + captures_dir = self.app.config.get_captures_path() + if not captures_dir.exists(): + return [] + + captures = [] + for d in sorted( + captures_dir.iterdir(), + key=lambda x: x.stat().st_mtime, + reverse=True, + ): + if d.is_dir(): + # Check for metadata.json (formal capture) + # or just any directory (simpler check) + # Displayed to the user, so render in LOCAL time -- + # parse the epoch as UTC and convert, rather than + # relying on an implicit naive-local conversion. + mtime = datetime.fromtimestamp( + d.stat().st_mtime, tz=timezone.utc + ).astimezone() + captures.append( + CaptureInfo( + name=d.name, + path=str(d), + timestamp=mtime.strftime("%Y-%m-%d %H:%M"), + ) + ) + + return captures[:10] # Limit to 10 most recent + + def _open_desktop_app(self) -> None: + """Open (or focus) the local desktop app cockpit.""" + self.app.open_desktop_app() + + def _open_cloud_dashboard(self) -> None: + """Open the hosted cloud dashboard in the browser.""" + self.app.open_cloud_dashboard() + + def _login(self) -> None: + """Start the hosted login flow.""" + self.app.login() + + def _open_settings(self) -> None: + """Open ``tray.json``, the file the tray reads its settings from.""" + try: + opened = self.app.platform.open_settings_dialog(self.app.config) + except OSError as e: + print(f"Could not open settings: {e}") + opened = False + if not opened: + self.app.notifications.show( + "Could not open settings", + f"Edit {self.app.config.config_path()} in a text editor, " + "then restart the tray.", + ) + + def _quit(self) -> None: + """Quit the application.""" + self.app.quit() + + def _view_capture(self, path: str) -> None: + """Open a capture's folder in the system file browser. + + View used to run ``openadapt visualize`` first. The current + ``openadapt`` launcher has no such command, so every click waited for + that command to fail before the folder opened. + + Args: + path: Path to capture directory. + """ + self._open_in_file_browser(path) + + def _delete_capture(self, path: str, name: str) -> None: + """Delete a capture after confirmation. + + Args: + path: Path to capture directory. + name: Capture name for display. + """ + try: + confirmed = self.app.platform.confirm_dialog( + "Delete Capture", + f"Are you sure you want to delete this capture?\n\n{name}", + ) + except DialogUnavailableError as e: + # Not asking is not the same as being told no. Deleting nothing is + # still the right action here, but the user gets told why their + # click did nothing instead of watching it vanish. + print(f"Could not show the delete confirmation: {e}") + self.app.notifications.show( + "Could not confirm deletion", + f"No confirmation dialog could be shown, so '{name}' was NOT " + "deleted.", + ) + return + + if confirmed: + try: + shutil.rmtree(path) + self.app.notifications.show( + "Capture Deleted", + f"'{name}' has been removed.", + ) + # Update menu + self.app.icon.menu = self.build() + except Exception as e: + self.app.notifications.show( + "Delete Failed", + f"Could not delete capture: {e}", + ) + + def _open_captures_list(self) -> None: + """Open the local workflow library in the desktop app.""" + self.app.open_desktop_app() + + def _open_in_file_browser(self, path: str) -> bool: + """Open a path in the system file browser. + + Args: + path: Path to open. + + Returns: + True if the file browser accepted the path. When it did not, the + user is told where the capture is. + """ + if sys.platform == "darwin": + command = ["open", path] + elif sys.platform == "win32": + command = ["explorer", path] + else: + command = ["xdg-open", path] + try: + result = subprocess.run(command, check=False, capture_output=True) + except OSError as e: + print(f"Could not start {command[0]}: {e}") + opened = False + else: + # explorer.exe exits 1 even after it opens the folder, so its exit + # status says nothing; `open` and xdg-open exit 0 on success. + opened = sys.platform == "win32" or result.returncode == 0 + if not opened: + print(f"{command[0]} exited {result.returncode}: {result.stderr}") + if not opened: + self.app.notifications.show( + "Could not open the capture", + f"No file browser opened it. The capture is at {path}.", + ) + return opened diff --git a/packages/tray/src/openadapt_tray/notifications.py b/packages/tray/src/openadapt_tray/notifications.py new file mode 100644 index 0000000000..86c4b6a9d6 --- /dev/null +++ b/packages/tray/src/openadapt_tray/notifications.py @@ -0,0 +1,440 @@ +"""Cross-platform notification manager for OpenAdapt Tray. + +This module provides a unified notification interface using desktop-notifier +for modern, native notifications across all platforms. +""" + +import asyncio +import os +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path + +_DELIVERY_CONFIRMATION_TIMEOUT_SECONDS = 5 + +try: + from desktop_notifier import Button, DesktopNotifier, ReplyField, Urgency + + DESKTOP_NOTIFIER_AVAILABLE = True +except ImportError: + DESKTOP_NOTIFIER_AVAILABLE = False + + +class NotificationManager: + """Cross-platform notification manager using desktop-notifier. + + This class provides a clean API for showing notifications with support for: + - Titles and bodies + - Icons + - Click callbacks + - Action buttons (platform dependent) + - Reply fields (platform dependent) + - Duration control + + Falls back to platform-specific implementations if desktop-notifier is unavailable. + """ + + def __init__(self): + """Initialize the notification manager.""" + self._backend = self._detect_backend() + self._tray_icon = None # Set by TrayApplication for Windows fallback + + # Initialize desktop-notifier if available + if DESKTOP_NOTIFIER_AVAILABLE and self._backend == "desktop-notifier": + self._notifier = DesktopNotifier( + app_name="OpenAdapt", + notification_limit=10, # Keep recent notifications + ) + # Create event loop for async operations + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + else: + self._notifier = None + self._loop = None + + def _detect_backend(self) -> str: + """Detect best notification backend for platform. + + Returns: + Backend name: "desktop-notifier", "macos", "windows", or "linux" + """ + if not DESKTOP_NOTIFIER_AVAILABLE: + # Fall back to legacy implementations + if sys.platform == "darwin": + return "macos" + elif sys.platform == "win32": + return "windows" + else: + return "linux" + + # On macOS, check if running from app bundle + if sys.platform == "darwin": + # Check both APP_BUNDLE env var and actual bundle structure + is_app_bundle = os.environ.get("APP_BUNDLE") or "Contents/MacOS" in str( + Path(__file__).resolve() + ) + if not is_app_bundle: + # desktop-notifier reaches macOS Notification Center only from + # an app bundle; elsewhere it swaps in a backend that drops + # every notification. A pip-installed tray is not a bundle, so + # it uses AppleScript. + return "macos" + + # Use desktop-notifier on all platforms + return "desktop-notifier" + + def set_tray_icon(self, icon) -> None: + """Set the pystray icon for Windows fallback notifications. + + Args: + icon: pystray.Icon instance. + """ + self._tray_icon = icon + + def show( + self, + title: str, + body: str, + icon_path: str | None = None, + duration_ms: int = 5000, + on_clicked: Callable | None = None, + urgency: str = "normal", + buttons: list | None = None, + reply_field: str | None = None, + ) -> bool: + """Show a notification. + + Args: + title: Notification title. + body: Notification body text. + icon_path: Optional path to icon image. + duration_ms: Notification duration in milliseconds (advisory). + on_clicked: Optional callback when notification is clicked. + urgency: Notification urgency: "low", "normal", or "critical". + buttons: Optional list of button labels for interactive notifications. + reply_field: Optional placeholder text for reply field (macOS only). + + Returns: + True if notification was shown successfully. + """ + try: + if self._backend == "desktop-notifier": + return self._show_desktop_notifier( + title, body, icon_path, on_clicked, urgency, buttons, reply_field + ) + elif self._backend == "macos": + return self._show_macos(title, body) + elif self._backend == "windows": + return self._show_windows(title, body, icon_path, duration_ms) + else: + return self._show_linux(title, body, icon_path) + except Exception as e: + print(f"Failed to show notification: {e}") + return False + + def _show_desktop_notifier( + self, + title: str, + body: str, + icon_path: str | None, + on_clicked: Callable | None, + urgency: str, + buttons: list | None, + reply_field: str | None, + ) -> bool: + """Show notification using desktop-notifier. + + Args: + title: Notification title. + body: Notification body text. + icon_path: Optional path to icon image. + on_clicked: Optional callback when notification is clicked. + urgency: Notification urgency level. + buttons: Optional list of button labels. + reply_field: Optional placeholder text for reply field. + + Returns: + True if successful. + """ + if not self._notifier or not self._loop: + return False + + # Map urgency string to enum + urgency_map = { + "low": Urgency.Low, + "normal": Urgency.Normal, + "critical": Urgency.Critical, + } + urgency_level = urgency_map.get(urgency.lower(), Urgency.Normal) + + # Convert icon path to Path object if provided + icon = Path(icon_path) if icon_path else None + + # Create button objects if provided + button_objects = None + if buttons: + button_objects = [Button(label) for label in buttons] + + # Create reply field object if provided + reply_field_object = None + if reply_field: + reply_field_object = ReplyField(title=reply_field, button_title="Send") + + # Show the notification and confirm that the backend accepted it. A queued + # coroutine is not delivery: callers use this result to decide whether a + # failed notification must be retried. + try: + if self._loop.is_running(): + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + + if running_loop is self._loop: + print( + "desktop-notifier sync API cannot confirm delivery from " + "its running event-loop thread" + ) + return False + + future = asyncio.run_coroutine_threadsafe( + self._notifier.send( + title=title, + message=body, + icon=icon, + urgency=urgency_level, + buttons=button_objects, + reply_field=reply_field_object, + on_clicked=on_clicked, + ), + self._loop, + ) + try: + future.result(timeout=_DELIVERY_CONFIRMATION_TIMEOUT_SECONDS) + except Exception: + future.cancel() + raise + else: + self._loop.run_until_complete( + self._notifier.send( + title=title, + message=body, + icon=icon, + urgency=urgency_level, + buttons=button_objects, + reply_field=reply_field_object, + on_clicked=on_clicked, + ) + ) + return True + except Exception as e: + print(f"desktop-notifier error: {e}") + return False + + def _show_macos(self, title: str, body: str) -> bool: + """Show notification on macOS using AppleScript (fallback). + + Args: + title: Notification title. + body: Notification body text. + + Returns: + True if successful. + """ + # Escape special characters for AppleScript + title = title.replace('"', '\\"').replace("\\", "\\\\") + body = body.replace('"', '\\"').replace("\\", "\\\\") + + script = f'display notification "{body}" with title "{title}"' + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + timeout=5, + check=False, # returncode is the success signal we return below + ) + return result.returncode == 0 + + def _show_windows( + self, + title: str, + body: str, + icon_path: str | None, + duration_ms: int, + ) -> bool: + """Show notification on Windows using pystray's built-in notify (fallback). + + Args: + title: Notification title. + body: Notification body text. + icon_path: Optional path to icon image. + duration_ms: Notification duration in milliseconds. + + Returns: + True if successful. + """ + if self._tray_icon is not None: + try: + self._tray_icon.notify(body, title) + return True + except Exception as e: + # Deliberate fall-through to the PowerShell toast below, but say + # why -- a silent pass made a broken pystray balloon look like a + # missing tray icon. + print(f"pystray notify failed, falling back to toast: {e}") + + # Fallback to Windows toast notification via PowerShell + try: + script = f""" + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null + $template = @" + + + + {title} + {body} + + + +"@ + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml($template) + $toast = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("OpenAdapt") + $toast.Show($xml) + """ + result = subprocess.run( + ["powershell", "-Command", script], + capture_output=True, + timeout=10, + check=False, # returncode is the success signal we return below + ) + # Previously this returned True unconditionally, so a PowerShell + # failure (WinRT unavailable, execution policy, missing toast + # notifier) was reported to the caller as a delivered notification. + # The macOS and Linux paths have always checked returncode. + if result.returncode != 0: + print( + "Windows toast notification failed " + f"(exit {result.returncode}): {result.stderr}" + ) + return False + return True + except Exception as e: + print(f"Windows toast notification failed: {e}") + return False + + def _show_linux( + self, + title: str, + body: str, + icon_path: str | None, + ) -> bool: + """Show notification on Linux using notify-send (fallback). + + Args: + title: Notification title. + body: Notification body text. + icon_path: Optional path to icon image. + + Returns: + True if successful. + """ + cmd = ["notify-send", title, body] + if icon_path: + cmd.extend(["-i", icon_path]) + + result = subprocess.run( + cmd, + capture_output=True, + timeout=5, + check=False, # returncode is the success signal we return below + ) + return result.returncode == 0 + + async def show_async( + self, + title: str, + body: str, + icon_path: str | None = None, + on_clicked: Callable | None = None, + urgency: str = "normal", + buttons: list | None = None, + reply_field: str | None = None, + ) -> bool: + """Show a notification asynchronously (desktop-notifier only). + + This is useful when called from async contexts. For most use cases, + use the synchronous show() method instead. + + Args: + title: Notification title. + body: Notification body text. + icon_path: Optional path to icon image. + on_clicked: Optional callback when notification is clicked. + urgency: Notification urgency: "low", "normal", or "critical". + buttons: Optional list of button labels for interactive notifications. + reply_field: Optional placeholder text for reply field (macOS only). + + Returns: + True if notification was shown successfully. + """ + if self._backend != "desktop-notifier" or not self._notifier: + # Fall back to synchronous version for non-desktop-notifier backends + return self.show(title, body, icon_path, on_clicked=on_clicked) + + # Map urgency string to enum + urgency_map = { + "low": Urgency.Low, + "normal": Urgency.Normal, + "critical": Urgency.Critical, + } + urgency_level = urgency_map.get(urgency.lower(), Urgency.Normal) + + # Convert icon path to Path object if provided + icon = Path(icon_path) if icon_path else None + + # Create button objects if provided + button_objects = None + if buttons: + button_objects = [Button(label) for label in buttons] + + # Create reply field object if provided + reply_field_object = None + if reply_field: + reply_field_object = ReplyField(title=reply_field, button_title="Send") + + try: + await self._notifier.send( + title=title, + message=body, + icon=icon, + urgency=urgency_level, + buttons=button_objects, + reply_field=reply_field_object, + on_clicked=on_clicked, + ) + return True + except Exception as e: + print(f"Failed to show async notification: {e}") + return False + + def cleanup(self) -> None: + """Cleanup notification resources. + + Call this when shutting down the application to ensure + proper cleanup of async resources. + """ + if self._loop and not self._loop.is_closed(): + try: + # Cancel any pending tasks + pending = asyncio.all_tasks(self._loop) + for task in pending: + task.cancel() + # Close the loop + self._loop.close() + except Exception as e: + print(f"Error during notification cleanup: {e}") diff --git a/packages/tray/src/openadapt_tray/platform/__init__.py b/packages/tray/src/openadapt_tray/platform/__init__.py new file mode 100644 index 0000000000..ecd1373cec --- /dev/null +++ b/packages/tray/src/openadapt_tray/platform/__init__.py @@ -0,0 +1,30 @@ +"""Platform-specific functionality for OpenAdapt Tray.""" + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from openadapt_tray.platform.base import PlatformHandler + + +def get_platform_handler() -> "PlatformHandler": + """Get the appropriate platform handler for the current OS. + + Returns: + Platform-specific handler instance. + """ + if sys.platform == "darwin": + from openadapt_tray.platform.macos import MacOSHandler + + return MacOSHandler() + elif sys.platform == "win32": + from openadapt_tray.platform.windows import WindowsHandler + + return WindowsHandler() + else: + from openadapt_tray.platform.linux import LinuxHandler + + return LinuxHandler() + + +__all__ = ["get_platform_handler"] diff --git a/packages/tray/src/openadapt_tray/platform/base.py b/packages/tray/src/openadapt_tray/platform/base.py new file mode 100644 index 0000000000..47ae49be6a --- /dev/null +++ b/packages/tray/src/openadapt_tray/platform/base.py @@ -0,0 +1,128 @@ +"""Abstract base class for platform-specific functionality.""" + +import subprocess +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from openadapt_tray.config import TrayConfig + + +class DialogUnavailableError(RuntimeError): + """No dialog could be shown to the user at all. + + Distinct from a dialog the user answered. "The user declined" and "we never + managed to ask" used to be the same ``False``, and "the user cancelled" and + "the prompt never appeared" used to be the same ``None`` -- so a click on + Delete or Start Recording could do nothing at all, silently, forever. + """ + + +def run_opener(command: list[str]) -> bool: + """Run a command that hands a file to another app, and report success. + + Args: + command: The opener and its arguments, such as ``["open", "-t", path]``. + + Returns: + True if the opener ran and exited 0. False if it was missing, timed + out, or exited non-zero. + """ + try: + result = subprocess.run(command, check=False, capture_output=True, timeout=10) + except (OSError, subprocess.SubprocessError) as e: + print(f"Could not run {command[0]}: {e}") + return False + if result.returncode != 0: + print(f"{command[0]} exited {result.returncode}: {result.stderr}") + return False + return True + + +class PlatformHandler(ABC): + """Abstract base class for platform-specific functionality.""" + + @abstractmethod + def setup(self) -> None: + """Platform-specific setup. + + Called once when the tray application starts. + """ + + @abstractmethod + def prompt_input(self, title: str, message: str) -> str | None: + """Show input dialog and return user input. + + Args: + title: Dialog title. + message: Prompt message. + + Returns: + User input string, or None if the user cancelled. ``None`` means + the user was asked and declined to answer -- never that we failed + to ask. + + Raises: + DialogUnavailableError: No dialog mechanism could be shown. + """ + + @abstractmethod + def confirm_dialog(self, title: str, message: str) -> bool: + """Show confirmation dialog and return result. + + Args: + title: Dialog title. + message: Confirmation message. + + Returns: + True if the user confirmed, False if the user declined. Both + answers mean the user was actually asked. + + Raises: + DialogUnavailableError: No dialog mechanism could be shown. + """ + + @abstractmethod + def open_settings_dialog(self, config: "TrayConfig") -> bool: + """Open ``tray.json`` in a text editor. + + The file is written with the current settings first if it does not + exist. Changes take effect the next time the tray starts. + + Args: + config: Current configuration. + + Returns: + True if an editor accepted the file. + """ + + @abstractmethod + def open_training_dialog(self) -> None: + """Open training configuration dialog.""" + + def setup_autostart(self, enabled: bool) -> bool: + """Configure auto-start on login. + + Args: + enabled: Whether to enable or disable auto-start. + + Returns: + True if successful. + """ + return False + + def cleanup(self) -> None: + """Cleanup any platform-specific resources. + + Called when the tray application is shutting down. + """ + + @property + def supports_native_dialogs(self) -> bool: + """Check if native dialogs are supported.""" + return True + + @property + def supports_autostart(self) -> bool: + """Check if auto-start configuration is supported.""" + return False diff --git a/packages/tray/src/openadapt_tray/platform/linux.py b/packages/tray/src/openadapt_tray/platform/linux.py new file mode 100644 index 0000000000..94a0ee2703 --- /dev/null +++ b/packages/tray/src/openadapt_tray/platform/linux.py @@ -0,0 +1,263 @@ +"""Linux-specific functionality for OpenAdapt Tray.""" + +import os +import subprocess +import webbrowser +from pathlib import Path +from typing import TYPE_CHECKING + +from openadapt_tray.platform.base import ( + DialogUnavailableError, + PlatformHandler, + run_opener, +) + +if TYPE_CHECKING: + from openadapt_tray.config import TrayConfig + +# zenity and kdialog both use exit 1 for "the user said no / cancelled". Any +# other non-zero exit (zenity uses 255) is the TOOL failing -- no display, a +# broken GTK, a timeout -- which must not be read as a user's answer. +DIALOG_DECLINED_EXIT = 1 + + +class LinuxHandler(PlatformHandler): + """Linux-specific functionality.""" + + def setup(self) -> None: + """Linux-specific setup.""" + # No special setup needed on most Linux systems + + def prompt_input(self, title: str, message: str) -> str | None: + """Show input dialog using zenity or kdialog. + + Args: + title: Dialog title. + message: Prompt message. + + Returns: + User input string, or None if the user cancelled. + + Raises: + DialogUnavailableError: zenity, kdialog and tkinter all failed to + show a dialog. + """ + # Try zenity first (GNOME) + try: + result = subprocess.run( + [ + "zenity", + "--entry", + f"--title={title}", + f"--text={message}", + ], + capture_output=True, + text=True, + timeout=60, + check=False, # returncode is inspected directly below + ) + if result.returncode == 0: + return result.stdout.strip() + if result.returncode == DIALOG_DECLINED_EXIT: + return None # The user cancelled. That is an answer. + # zenity ran but could not show anything (no display, exit 255). + # This used to return None, i.e. it was reported as the user + # cancelling, and it also skipped the kdialog/tkinter fallbacks. + print(f"zenity could not show a dialog (exit {result.returncode})") + except FileNotFoundError: + pass + except Exception as e: + print(f"Error with zenity: {e}") + + # Try kdialog (KDE) + try: + result = subprocess.run( + [ + "kdialog", + "--inputbox", + message, + "--title", + title, + ], + capture_output=True, + text=True, + timeout=60, + check=False, # returncode is inspected directly below + ) + if result.returncode == 0: + return result.stdout.strip() + if result.returncode == DIALOG_DECLINED_EXIT: + return None # The user cancelled. + print(f"kdialog could not show a dialog (exit {result.returncode})") + except FileNotFoundError: + pass + except Exception as e: + print(f"Error with kdialog: {e}") + + # Fallback to tkinter + try: + import tkinter as tk + from tkinter import simpledialog + + root = tk.Tk() + root.withdraw() + result = simpledialog.askstring(title, message, parent=root) + root.destroy() + return result + except Exception as e: + raise DialogUnavailableError( + f"no input dialog available (zenity, kdialog, tkinter): {e}" + ) from e + + def confirm_dialog(self, title: str, message: str) -> bool: + """Show confirmation dialog using zenity or kdialog. + + Args: + title: Dialog title. + message: Confirmation message. + + Returns: + True if the user clicked OK, False if the user declined. + + Raises: + DialogUnavailableError: zenity, kdialog and tkinter all failed to + show a dialog, so the user was never asked. + """ + # Try zenity first + try: + result = subprocess.run( + [ + "zenity", + "--question", + f"--title={title}", + f"--text={message}", + ], + capture_output=True, + timeout=60, + check=False, # returncode is inspected directly below + ) + if result.returncode in (0, DIALOG_DECLINED_EXIT): + return result.returncode == 0 + # zenity ran but showed nothing. Reporting that as "the user said + # no" is a guess dressed up as an answer. + print(f"zenity could not show a dialog (exit {result.returncode})") + except FileNotFoundError: + pass + except Exception as e: + print(f"Error with zenity: {e}") + + # Try kdialog + try: + result = subprocess.run( + [ + "kdialog", + "--yesno", + message, + "--title", + title, + ], + capture_output=True, + timeout=60, + check=False, # returncode is inspected directly below + ) + if result.returncode in (0, DIALOG_DECLINED_EXIT): + return result.returncode == 0 + print(f"kdialog could not show a dialog (exit {result.returncode})") + except FileNotFoundError: + pass + except Exception as e: + print(f"Error with kdialog: {e}") + + # Fallback to tkinter + try: + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + result = messagebox.askokcancel(title, message) + root.destroy() + return bool(result) + except Exception as e: + raise DialogUnavailableError( + f"no confirmation dialog available (zenity, kdialog, tkinter): {e}" + ) from e + + def open_settings_dialog(self, config: "TrayConfig") -> bool: + """Open ``tray.json`` with the desktop's default application. + + Args: + config: Current configuration. + + Returns: + True if xdg-open accepted the file. + """ + return run_opener(["xdg-open", str(config.ensure_file())]) + + def open_training_dialog(self) -> None: + """Open training dialog in browser.""" + webbrowser.open("http://localhost:8080/training/new") + + def setup_autostart(self, enabled: bool) -> bool: + """Configure XDG autostart for auto-start. + + Args: + enabled: Whether to enable or disable auto-start. + + Returns: + True if successful. + """ + autostart_dir = Path( + os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") + ) / "autostart" + desktop_file = autostart_dir / "openadapt-tray.desktop" + + try: + if enabled: + exe_path = self._find_executable() + if not exe_path: + print("Could not find openadapt-tray executable") + return False + + desktop_content = f"""[Desktop Entry] +Type=Application +Name=OpenAdapt Tray +Comment=System tray application for OpenAdapt +Exec={exe_path} +Icon=openadapt +Terminal=false +Categories=Utility; +StartupNotify=false +X-GNOME-Autostart-enabled=true +""" + autostart_dir.mkdir(parents=True, exist_ok=True) + desktop_file.write_text(desktop_content) + else: + if desktop_file.exists(): + desktop_file.unlink() + + return True + except Exception as e: + print(f"Error configuring auto-start: {e}") + return False + + def _find_executable(self) -> str | None: + """Find the openadapt-tray executable path.""" + import shutil + import sys + + # Check if running from installed script + exe = shutil.which("openadapt-tray") + if exe: + return exe + + # Fallback to Python module invocation + return f"{sys.executable} -m openadapt_tray" + + @property + def supports_autostart(self) -> bool: + """Check if auto-start configuration is supported.""" + return True + + def cleanup(self) -> None: + """Cleanup any platform-specific resources.""" diff --git a/packages/tray/src/openadapt_tray/platform/macos.py b/packages/tray/src/openadapt_tray/platform/macos.py new file mode 100644 index 0000000000..0112c628ea --- /dev/null +++ b/packages/tray/src/openadapt_tray/platform/macos.py @@ -0,0 +1,236 @@ +"""macOS-specific functionality for OpenAdapt Tray.""" + +import subprocess +import webbrowser +from pathlib import Path +from typing import TYPE_CHECKING + +from openadapt_tray.platform.base import ( + DialogUnavailableError, + PlatformHandler, + run_opener, +) + +if TYPE_CHECKING: + from openadapt_tray.config import TrayConfig + +# osascript exits 1 both when the user cancels a dialog AND when the script +# itself fails (no Apple-events permission, no window server). The two are only +# distinguishable by stderr, which carries this marker on a real failure. +OSASCRIPT_ERROR_MARKER = "execution error" + + +def _raise_if_osascript_failed( + result: subprocess.CompletedProcess, kind: str +) -> None: + """Raise when a non-zero osascript exit was a failure, not a user decision. + + Args: + result: The finished ``osascript`` process. + kind: Dialog kind, used in the error message. + + Raises: + DialogUnavailableError: stderr carries an AppleScript execution error, + which means no dialog was shown -- as opposed to the user clicking + Cancel, which exits non-zero with a quiet stderr. + """ + stderr = result.stderr or "" + if OSASCRIPT_ERROR_MARKER in stderr.lower(): + raise DialogUnavailableError( + f"osascript could not show the {kind} dialog: {stderr.strip()}" + ) + + +class MacOSHandler(PlatformHandler): + """macOS-specific functionality.""" + + def setup(self) -> None: + """Hide from Dock, show only in menu bar.""" + try: + from AppKit import NSApplication, NSApplicationActivationPolicyAccessory + + NSApplication.sharedApplication().setActivationPolicy_( + NSApplicationActivationPolicyAccessory + ) + except ImportError: + # AppKit not available, continue without Dock hiding + pass + + def prompt_input(self, title: str, message: str) -> str | None: + """Show native macOS input dialog. + + Args: + title: Dialog title. + message: Prompt message. + + Returns: + User input string, or None if the user cancelled (or did not answer + within the timeout). + + Raises: + DialogUnavailableError: osascript is missing or reported an + execution error, so no dialog ever reached the screen. + """ + # Escape special characters for AppleScript + title_escaped = title.replace('"', '\\"').replace("\\", "\\\\") + message_escaped = message.replace('"', '\\"').replace("\\", "\\\\") + + script = f''' + tell application "System Events" + display dialog "{message_escaped}" default answer "" with title "{title_escaped}" + return text returned of result + end tell + ''' + try: + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + text=True, + timeout=60, # 1 minute timeout for user input + check=False, # returncode is inspected directly below + ) + except subprocess.TimeoutExpired: + # The dialog WAS shown; the user just did not answer it. + return None + except Exception as e: + raise DialogUnavailableError(f"could not run osascript: {e}") from e + + if result.returncode == 0: + return result.stdout.strip() + _raise_if_osascript_failed(result, "input") + return None # The user cancelled. + + def confirm_dialog(self, title: str, message: str) -> bool: + """Show native macOS confirmation dialog. + + Args: + title: Dialog title. + message: Confirmation message. + + Returns: + True if the user clicked OK, False if the user declined (or did not + answer within the timeout). + + Raises: + DialogUnavailableError: osascript is missing or reported an + execution error, so the user was never asked. + """ + # Escape special characters for AppleScript + title_escaped = title.replace('"', '\\"').replace("\\", "\\\\") + message_escaped = message.replace('"', '\\"').replace("\\", "\\\\") + + script = f''' + tell application "System Events" + display dialog "{message_escaped}" with title "{title_escaped}" buttons {{"Cancel", "OK"}} default button "OK" + return button returned of result + end tell + ''' + try: + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + text=True, + timeout=60, + check=False, # returncode is inspected directly below + ) + except subprocess.TimeoutExpired: + # The dialog WAS shown; the user just did not answer it. + return False + except Exception as e: + raise DialogUnavailableError(f"could not run osascript: {e}") from e + + if result.returncode == 0: + return "OK" in result.stdout + _raise_if_osascript_failed(result, "confirmation") + return False # The user clicked Cancel. + + def open_settings_dialog(self, config: "TrayConfig") -> bool: + """Open ``tray.json`` in the default text editor. + + Args: + config: Current configuration. + + Returns: + True if the editor accepted the file. + """ + return run_opener(["open", "-t", str(config.ensure_file())]) + + def open_training_dialog(self) -> None: + """Open training dialog in browser.""" + webbrowser.open("http://localhost:8080/training/new") + + def setup_autostart(self, enabled: bool) -> bool: + """Configure Launch Agent for auto-start. + + Args: + enabled: Whether to enable or disable auto-start. + + Returns: + True if successful. + """ + plist_path = Path.home() / "Library/LaunchAgents/ai.openadapt.tray.plist" + + try: + if enabled: + # Find the openadapt-tray executable + exe_path = self._find_executable() + if not exe_path: + print("Could not find openadapt-tray executable") + return False + + plist_content = f''' + + + + Label + ai.openadapt.tray + ProgramArguments + + {exe_path} + + RunAtLoad + + KeepAlive + + +''' + plist_path.parent.mkdir(parents=True, exist_ok=True) + plist_path.write_text(plist_content) + subprocess.run(["launchctl", "load", str(plist_path)], check=True) + else: + if plist_path.exists(): + # check=False on purpose: unloading an agent that was + # never loaded exits non-zero, and we still want to delete + # the plist below. + subprocess.run( + ["launchctl", "unload", str(plist_path)], + capture_output=True, + check=False, + ) + plist_path.unlink() + return True + except Exception as e: + print(f"Error configuring auto-start: {e}") + return False + + def _find_executable(self) -> str | None: + """Find the openadapt-tray executable path.""" + import shutil + import sys + + # Check if running from installed script + exe = shutil.which("openadapt-tray") + if exe: + return exe + + # Fallback to Python module invocation + python_path = sys.executable + return f"{python_path} -m openadapt_tray" + + @property + def supports_autostart(self) -> bool: + """Check if auto-start configuration is supported.""" + return True + + def cleanup(self) -> None: + """Cleanup any platform-specific resources.""" diff --git a/packages/tray/src/openadapt_tray/platform/windows.py b/packages/tray/src/openadapt_tray/platform/windows.py new file mode 100644 index 0000000000..4c9994b9f1 --- /dev/null +++ b/packages/tray/src/openadapt_tray/platform/windows.py @@ -0,0 +1,188 @@ +"""Windows-specific functionality for OpenAdapt Tray.""" + +import subprocess +import webbrowser +from typing import TYPE_CHECKING + +from openadapt_tray.platform.base import DialogUnavailableError, PlatformHandler + +if TYPE_CHECKING: + from openadapt_tray.config import TrayConfig + + +class WindowsHandler(PlatformHandler): + """Windows-specific functionality.""" + + def setup(self) -> None: + """Windows-specific setup.""" + # No special setup needed on Windows + + def prompt_input(self, title: str, message: str) -> str | None: + """Show Windows input dialog using tkinter. + + Args: + title: Dialog title. + message: Prompt message. + + Returns: + User input string, or None if the user cancelled. + + Raises: + DialogUnavailableError: tkinter could not show a dialog, so the + user was never prompted. This used to return ``None``, which + the caller reads as "the user cancelled". + """ + try: + import tkinter as tk + from tkinter import simpledialog + + root = tk.Tk() + root.withdraw() # Hide the root window + root.attributes("-topmost", True) # Bring to front + + result = simpledialog.askstring(title, message, parent=root) + root.destroy() + return result + except Exception as e: + raise DialogUnavailableError(f"no input dialog available: {e}") from e + + def confirm_dialog(self, title: str, message: str) -> bool: + """Show Windows confirmation dialog. + + Args: + title: Dialog title. + message: Confirmation message. + + Returns: + True if the user clicked OK, False if the user declined. + + Raises: + DialogUnavailableError: Neither MessageBoxW nor tkinter could show + a dialog. This used to return ``False``, which the caller reads + as "the user said no" -- so a destructive action would appear + to be declined by a user who was never asked. + """ + try: + import ctypes + + MB_OKCANCEL = 0x01 + MB_ICONQUESTION = 0x20 + IDOK = 1 + + result = ctypes.windll.user32.MessageBoxW( + 0, message, title, MB_OKCANCEL | MB_ICONQUESTION + ) + # MessageBoxW returns 0 when it could not create the box at all. + if result == 0: + raise OSError("MessageBoxW returned 0 (dialog not created)") + return result == IDOK + except Exception as e: + print(f"Error showing confirm dialog: {e}") + # Fallback to tkinter + try: + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + result = messagebox.askokcancel(title, message) + root.destroy() + return bool(result) + except Exception as fallback_error: + raise DialogUnavailableError( + "no confirmation dialog available " + f"(MessageBoxW: {e}; tkinter: {fallback_error})" + ) from fallback_error + + def open_settings_dialog(self, config: "TrayConfig") -> bool: + """Open ``tray.json`` in Notepad. + + Notepad keeps running until the user closes it, so it is started + without waiting for it to exit. + + Args: + config: Current configuration. + + Returns: + True if Notepad started. + """ + path = config.ensure_file() + try: + subprocess.Popen(["notepad.exe", str(path)]) + except OSError as e: + print(f"Could not start Notepad: {e}") + return False + return True + + def open_training_dialog(self) -> None: + """Open training dialog in browser.""" + webbrowser.open("http://localhost:8080/training/new") + + def setup_autostart(self, enabled: bool) -> bool: + """Configure Windows Registry for auto-start. + + Args: + enabled: Whether to enable or disable auto-start. + + Returns: + True if successful. + """ + try: + import winreg + + key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" + app_name = "OpenAdapt" + + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_ALL_ACCESS + ) + + try: + if enabled: + exe_path = self._find_executable() + if exe_path: + winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, exe_path) + else: + return False + else: + try: + winreg.DeleteValue(key, app_name) + except FileNotFoundError: + pass # Already not set + finally: + winreg.CloseKey(key) + + return True + except Exception as e: + print(f"Error configuring auto-start: {e}") + return False + + def _find_executable(self) -> str | None: + """Find the openadapt-tray executable path.""" + import os + import shutil + import sys + + # Check for installed script in Scripts folder + exe = shutil.which("openadapt-tray") + if exe: + return exe + + # Check for .exe in Scripts folder. `os.path.exists` swallows its own + # OSErrors and returns False, so the try/except/pass that used to wrap + # this was unreachable, and it also hid a failed `import os`. + python_dir = sys.prefix + exe_path = f"{python_dir}\\Scripts\\openadapt-tray.exe" + if os.path.exists(exe_path): + return exe_path + + # Fallback to Python module invocation + return f'"{sys.executable}" -m openadapt_tray' + + @property + def supports_autostart(self) -> bool: + """Check if auto-start configuration is supported.""" + return True + + def cleanup(self) -> None: + """Cleanup any platform-specific resources.""" diff --git a/packages/tray/src/openadapt_tray/shortcuts.py b/packages/tray/src/openadapt_tray/shortcuts.py new file mode 100644 index 0000000000..a5d5ef5260 --- /dev/null +++ b/packages/tray/src/openadapt_tray/shortcuts.py @@ -0,0 +1,290 @@ +"""Global hotkey handling for OpenAdapt Tray.""" + +import contextlib +import importlib +import sys +import threading +from collections.abc import Callable +from dataclasses import dataclass + +# Display names for pynput key names. macOS menus use symbols and no +# separator; Windows and Linux spell the keys out and join them with "+". +# Keys not listed here are title-cased ("page_up" shows as "Page Up"). +_MAC_KEYS = { + "ctrl": "⌃", + "shift": "⇧", + "alt": "⌥", + "cmd": "⌘", + "enter": "↩", + "esc": "⎋", + "tab": "⇥", + "backspace": "⌫", + "delete": "⌦", + "up": "↑", + "down": "↓", + "left": "←", + "right": "→", +} +_PC_KEYS = { + "ctrl": "Ctrl", + "shift": "Shift", + "alt": "Alt", + "alt_gr": "AltGr", + "esc": "Esc", +} +_SIDE_SUFFIXES = ("_l", "_r", "_gr") + + +def _split_hotkey(spec: str) -> list[str] | None: + """Split a pynput hotkey string into keys, as pynput's parser does. + + A "+" right after a separator is the plus key itself, so "++" is + Ctrl and "+". Returns None for a string pynput would reject. + """ + parts = [] + start = 0 + for i, c in enumerate(spec): + if c == "+" and i != start: + parts.append(spec[start:i]) + start = i + 1 + if start == len(spec): + return None + parts.append(spec[start:]) + return parts + + +def _display_key(key: str, platform: str) -> str | None: + """Return the display name of one key from a pynput hotkey string. + + Returns None for a key pynput would reject: more than one character + without angle brackets ("ctrl"), or empty brackets ("<>"). + """ + if not (key.startswith("<") and key.endswith(">") and len(key) > 2): + return key.upper() if len(key) == 1 else None + name = key[1:-1].lower() + if name.isdigit(): + # A virtual key code has no display name; show it as configured. + return key + if platform == "darwin": + names = _MAC_KEYS + else: + names = {**_PC_KEYS, "cmd": "Win" if platform == "win32" else "Super"} + if name not in names: + # Left and right modifiers (ctrl_l, shift_r, ...) show as the modifier. + base, sep, side = name.rpartition("_") + if sep and f"_{side}" in _SIDE_SUFFIXES and base in names: + name = base + if name in names: + return names[name] + if name[0] == "f" and name[1:].isdigit(): + return name.upper() + return name.replace("_", " ").title() + + +def format_hotkey(spec: str, platform: str | None = None) -> str: + """Format a pynput hotkey string the way the platform shows shortcuts. + + "++r" becomes "⌃⇧R" on macOS and "Ctrl+Shift+R" on Windows + and Linux. A string that is not in pynput's form ("ctrl+shift+r", "+") + is returned unchanged, and a value that is not a string (null in tray.json) + is shown as ``str()`` gives it, so a bad setting still shows up in the menu + instead of breaking it. Key names are not checked against pynput's list. + + Args: + spec: The hotkey as configured, in pynput GlobalHotKeys syntax. + platform: A ``sys.platform`` value. Defaults to the running platform. + """ + if not isinstance(spec, str): + return str(spec) + platform = sys.platform if platform is None else platform + keys = _split_hotkey(spec) + if keys is None: + return spec + shown = [_display_key(key, platform) for key in keys] + if None in shown: + return spec + return "".join(shown) if platform == "darwin" else "+".join(shown) + + +def _pin_macos_keyboard_layout() -> bool: + """Read the keyboard layout on the main thread and give pynput that copy. + + pynput's macOS listeners read the current keyboard layout with the Text + Input Sources API from their own background threads. Once the process has + an NSApplication, which pystray creates, macOS 15 aborts the process when + that API runs off the main thread, so ``openadapt-tray`` died with SIGABRT + about a second after it started. Reading the layout here and handing every + listener the same value keeps the call on the main thread. pynput reads the + layout once per listener anyway, so the listeners behave as before. + + Returns: + True when the listeners can start. False means no listener may start, + because starting one would call the API off the main thread. + """ + if threading.current_thread() is not threading.main_thread(): + print("Warning: hotkeys must be started on the main thread on macOS") + return False + try: + darwin_util = importlib.import_module("pynput._util.darwin") + darwin_keyboard = importlib.import_module("pynput.keyboard._darwin") + if not hasattr(darwin_keyboard, "keycode_context"): + raise AttributeError("pynput.keyboard._darwin has no keycode_context") + with darwin_util.keycode_context() as layout: + pinned = layout + except Exception as e: + print(f"Warning: could not read the keyboard layout: {e}") + return False + + @contextlib.contextmanager + def pinned_keycode_context(): + yield pinned + + darwin_keyboard.keycode_context = pinned_keycode_context + return True + + +@dataclass +class HotkeyConfig: + """Hotkey configuration.""" + + toggle_recording: str = "++r" + open_dashboard: str = "++d" + stop_recording: str = "++" # Triple ctrl (legacy compat) + + +class HotkeyManager: + """Manages global hotkeys.""" + + def __init__(self, config: HotkeyConfig | None = None): + self.config = config or HotkeyConfig() + self._handlers: dict[str, Callable] = {} + self._listener = None + self._key_listener = None + self._ctrl_count = 0 + self._ctrl_timer: threading.Timer | None = None + self._running = False + + def register(self, hotkey: str, handler: Callable) -> None: + """Register a hotkey handler. + + Args: + hotkey: The hotkey combination string (e.g., "++r"). + handler: The callback function to invoke when the hotkey is pressed. + """ + self._handlers[hotkey] = handler + + def unregister(self, hotkey: str) -> None: + """Unregister a hotkey handler. + + Args: + hotkey: The hotkey combination string to unregister. + """ + if hotkey in self._handlers: + del self._handlers[hotkey] + + def start(self) -> None: + """Start listening for hotkeys.""" + if self._running: + return + + self._running = True + + try: + from pynput import keyboard + + if sys.platform == "darwin" and not _pin_macos_keyboard_layout(): + print("Warning: hotkeys disabled; use the tray menu instead") + return + + # Build hotkey dict for pynput + hotkeys = {} + for combo, handler in self._handlers.items(): + if combo == "++": + # Special handling for triple-ctrl + continue + hotkeys[combo] = handler + + if hotkeys: + self._listener = keyboard.GlobalHotKeys(hotkeys) + self._listener.start() + + # Also listen for triple-ctrl pattern + if "++" in self._handlers: + self._start_ctrl_listener() + + except ImportError: + print("Warning: pynput not available, hotkeys disabled") + except Exception as e: + print(f"Warning: Could not start hotkey listener: {e}") + + def _start_ctrl_listener(self) -> None: + """Start listener for triple-ctrl pattern.""" + try: + from pynput import keyboard + + def on_press(key): + if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r: + self._on_ctrl_press() + + def on_release(key): + pass + + self._key_listener = keyboard.Listener( + on_press=on_press, + on_release=on_release, + ) + self._key_listener.start() + except Exception as e: + print(f"Warning: Could not start ctrl listener: {e}") + + def _on_ctrl_press(self) -> None: + """Handle ctrl key press for triple-ctrl detection.""" + self._ctrl_count += 1 + + # Reset timer + if self._ctrl_timer: + self._ctrl_timer.cancel() + + if self._ctrl_count >= 3: + self._ctrl_count = 0 + handler = self._handlers.get("++") + if handler: + try: + handler() + except Exception as e: + print(f"Error in hotkey handler: {e}") + else: + # Reset count after 500ms + self._ctrl_timer = threading.Timer(0.5, self._reset_ctrl_count) + self._ctrl_timer.daemon = True + self._ctrl_timer.start() + + def _reset_ctrl_count(self) -> None: + """Reset the ctrl key press counter.""" + self._ctrl_count = 0 + + def stop(self) -> None: + """Stop listening for hotkeys.""" + self._running = False + + # stop() is called from quit(). pynput's platform backends can raise on + # teardown (X11 display already gone, macOS run loop torn down); the + # listener threads are daemons either way, so there is nothing to act on + # and refusing to quit over it would be worse. + if self._listener: + with contextlib.suppress(Exception): + self._listener.stop() + self._listener = None + + if self._key_listener: + with contextlib.suppress(Exception): + self._key_listener.stop() + self._key_listener = None + + if self._ctrl_timer: + self._ctrl_timer.cancel() + self._ctrl_timer = None + + def is_running(self) -> bool: + """Check if the hotkey manager is running.""" + return self._running diff --git a/packages/tray/src/openadapt_tray/state.py b/packages/tray/src/openadapt_tray/state.py new file mode 100644 index 0000000000..9255293031 --- /dev/null +++ b/packages/tray/src/openadapt_tray/state.py @@ -0,0 +1,252 @@ +"""Application state management for OpenAdapt Tray. + +The tray mirrors two ORTHOGONAL status channels sourced from the local desktop +app and the cloud control plane: + +* ``TrayState`` — the recording/compile lifecycle (idle → recording → compiling). +* ``SyncState`` — the upload/sync channel (synced ↔ syncing ↔ offline). + +They are independent: a machine can be ``COMPILING`` a freshly recorded +workflow while its previous push is still ``SYNCING``, or sit ``IDLE`` while +``OFFLINE``. Modelling them as one enum would force false exclusivity, so +``AppState`` carries them as two separate fields plus a ``break_count`` badge +sourced from the cloud needs-attention count. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum, auto + + +class TrayState(Enum): + """Recording / compile lifecycle states (the primary icon channel).""" + + IDLE = auto() + RECORDING_STARTING = auto() + RECORDING = auto() + RECORDING_STOPPING = auto() + COMPILING = auto() + ERROR = auto() + + +class SyncState(Enum): + """Upload / sync channel states (orthogonal to :class:`TrayState`).""" + + SYNCED = auto() + SYNCING = auto() # a.k.a. PUSHING — an upload is in flight + OFFLINE = auto() + PAUSED = auto() # the desktop's upload queue is paused + + # Backwards/spec-friendly alias: the spec names this channel "SYNCING/PUSHING". + @classmethod + def PUSHING(cls) -> "SyncState": + """Alias for :attr:`SYNCING` (the spec uses SYNCING/PUSHING interchangeably).""" + return cls.SYNCING + + +class CredentialState(Enum): + """Status of the hosted credential reported by the control plane.""" + + NOT_CHECKED = auto() + ACTIVE = auto() + EXPIRING = auto() + LEGACY = auto() + SIGNED_OUT = auto() + UNKNOWN = auto() + + +@dataclass(frozen=True) +class CredentialStatus: + """Privacy-safe credential status for the tray UI.""" + + state: CredentialState = CredentialState.NOT_CHECKED + expires_at: str | None = None + expires_in_days: int | None = None + warning_days: int | None = None + + @classmethod + def signed_out(cls) -> "CredentialStatus": + """Return the status used when no hosted credential is available.""" + return cls(state=CredentialState.SIGNED_OUT) + + @classmethod + def unknown(cls) -> "CredentialStatus": + """Return the fail-visible status for unreadable or unreachable data.""" + return cls(state=CredentialState.UNKNOWN) + + +# Deployment lanes (drives break-click routing; learned from desktop/config). +LANE_CLOUD = "cloud" +LANE_BYOC = "byoc" + + +@dataclass +class AppState: + """Current application state (two orthogonal channels + a break badge).""" + + # Recording / compile lifecycle. + state: TrayState = TrayState.IDLE + current_capture: str | None = None + error_message: str | None = None + + # Sync channel (independent of the recording lifecycle). + sync_state: SyncState = SyncState.SYNCED + + # Break / needs-attention badge (sourced from the cloud count endpoint). + break_count: int = 0 + + # Hosted credential state. This never contains the credential value. + credential: CredentialStatus = CredentialStatus() + + # Deployment lane — drives lane-aware break-click routing. + deployment_lane: str = LANE_CLOUD + + def can_start_recording(self) -> bool: + """Check if recording can be started.""" + return self.state == TrayState.IDLE + + def can_stop_recording(self) -> bool: + """Check if recording can be stopped.""" + return self.state == TrayState.RECORDING + + def is_recording(self) -> bool: + """Check if currently recording.""" + return self.state in ( + TrayState.RECORDING_STARTING, + TrayState.RECORDING, + TrayState.RECORDING_STOPPING, + ) + + def is_compiling(self) -> bool: + """Check if a recording is being compiled into a workflow.""" + return self.state == TrayState.COMPILING + + def is_busy(self) -> bool: + """Check if the application is busy with any recording operation.""" + return self.state not in (TrayState.IDLE, TrayState.ERROR) + + def is_syncing(self) -> bool: + """Check if an upload is currently in flight.""" + return self.sync_state == SyncState.SYNCING + + def is_offline(self) -> bool: + """Check if the sync channel is offline.""" + return self.sync_state == SyncState.OFFLINE + + def is_sync_paused(self) -> bool: + """Check if the desktop's upload queue is paused.""" + return self.sync_state == SyncState.PAUSED + + def has_breaks(self) -> bool: + """Check if any automations currently need attention.""" + return self.break_count > 0 + + def is_byoc(self) -> bool: + """Check if the active deployment lane keeps PHI on-machine (byoc).""" + return self.deployment_lane == LANE_BYOC + + +class StateManager: + """Manages application state transitions across both channels. + + Recording-lifecycle updates (:meth:`transition`) preserve the orthogonal + sync channel, break count and deployment lane unless explicitly overridden, + and vice-versa — so updating one channel never clobbers the other. + """ + + def __init__(self): + self._state = AppState() + self._listeners: list[Callable[[AppState], None]] = [] + + def add_listener(self, callback: Callable[[AppState], None]) -> None: + """Add a state change listener.""" + self._listeners.append(callback) + + def remove_listener(self, callback: Callable[[AppState], None]) -> None: + """Remove a state change listener.""" + if callback in self._listeners: + self._listeners.remove(callback) + + def _emit(self) -> None: + """Notify all listeners of the current state.""" + for listener in self._listeners: + try: + listener(self._state) + except Exception as e: + # Don't let a bad listener crash the app. + print(f"Error in state listener: {e}") + + def transition(self, new_state: TrayState, **kwargs) -> None: + """Transition the recording lifecycle and notify listeners. + + The orthogonal channels (``sync_state``, ``break_count``, + ``deployment_lane``) are preserved from the current state unless passed + explicitly in ``kwargs``. + + Args: + new_state: The new recording-lifecycle state. + **kwargs: Optional overrides (``current_capture``, ``error_message``, + ``sync_state``, ``break_count``, ``deployment_lane``). + """ + # Preserve current_capture across sub-states of an active recording. + if "current_capture" not in kwargs and new_state in ( + TrayState.RECORDING, + TrayState.RECORDING_STOPPING, + TrayState.COMPILING, + ): + kwargs.setdefault("current_capture", self._state.current_capture) + + # Preserve the orthogonal channels unless explicitly overridden. + kwargs.setdefault("sync_state", self._state.sync_state) + kwargs.setdefault("break_count", self._state.break_count) + kwargs.setdefault("credential", self._state.credential) + kwargs.setdefault("deployment_lane", self._state.deployment_lane) + + self._state = AppState(state=new_state, **kwargs) + self._emit() + + def set_sync_state(self, sync_state: SyncState) -> None: + """Update ONLY the sync channel, preserving the recording lifecycle.""" + if self._state.sync_state == sync_state: + return + from dataclasses import replace + + self._state = replace(self._state, sync_state=sync_state) + self._emit() + + def set_break_count(self, count: int) -> None: + """Update ONLY the break/needs-attention badge count.""" + count = max(0, int(count)) + if self._state.break_count == count: + return + from dataclasses import replace + + self._state = replace(self._state, break_count=count) + self._emit() + + def set_credential_status(self, credential: CredentialStatus) -> None: + """Update only the hosted credential status.""" + if self._state.credential == credential: + return + from dataclasses import replace + + self._state = replace(self._state, credential=credential) + self._emit() + + def set_deployment_lane(self, lane: str) -> None: + """Update ONLY the deployment lane (cloud|byoc).""" + if self._state.deployment_lane == lane: + return + from dataclasses import replace + + self._state = replace(self._state, deployment_lane=lane) + self._emit() + + @property + def current(self) -> AppState: + """Get the current application state.""" + return self._state + + def reset(self) -> None: + """Reset the recording lifecycle to IDLE (sync channel preserved).""" + self.transition(TrayState.IDLE) diff --git a/packages/tray/test_notification.py b/packages/tray/test_notification.py new file mode 100755 index 0000000000..0b4b6d8042 --- /dev/null +++ b/packages/tray/test_notification.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Simple test script for notification system.""" + +import sys +import time +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +from openadapt_tray.notifications import NotificationManager + + +def test_basic_notification(): + """Test basic notification.""" + print("Testing basic notification...") + nm = NotificationManager() + result = nm.show( + title="OpenAdapt Test", + body="This is a basic test notification from desktop-notifier!" + ) + print(f"Basic notification result: {result}") + return result + + +def test_notification_with_callback(): + """Test notification with callback.""" + print("\nTesting notification with callback...") + + clicked = [False] # Use list to allow modification in closure + + def on_click(): + print("Notification was clicked!") + clicked[0] = True + + nm = NotificationManager() + result = nm.show( + title="OpenAdapt Test", + body="Click this notification to test callbacks!", + on_clicked=on_click + ) + print(f"Callback notification result: {result}") + + # Wait a bit to see if user clicks + print("Waiting 10 seconds for click...") + time.sleep(10) + + if clicked[0]: + print("Callback was triggered!") + else: + print("No click detected (this is normal if you didn't click)") + + return result + + +def test_critical_notification(): + """Test critical urgency notification.""" + print("\nTesting critical notification...") + nm = NotificationManager() + result = nm.show( + title="OpenAdapt Critical", + body="This is a critical notification!", + urgency="critical" + ) + print(f"Critical notification result: {result}") + return result + + +def test_notification_with_buttons(): + """Test notification with action buttons.""" + print("\nTesting notification with buttons...") + + def on_click(): + print("Notification with buttons was clicked!") + + nm = NotificationManager() + result = nm.show( + title="OpenAdapt Actions", + body="This notification has action buttons (if supported on your platform)", + buttons=["Yes", "No", "Maybe"], + on_clicked=on_click + ) + print(f"Button notification result: {result}") + + # Wait a bit to see if user interacts + print("Waiting 10 seconds for interaction...") + time.sleep(10) + + return result + + +def main(): + """Run all tests.""" + print("=" * 60) + print("OpenAdapt Notification System Test") + print("=" * 60) + + tests = [ + test_basic_notification, + test_notification_with_callback, + test_critical_notification, + test_notification_with_buttons, + ] + + results = [] + for test in tests: + try: + result = test() + results.append((test.__name__, result)) + time.sleep(2) # Brief pause between tests + except Exception as e: + print(f"Error in {test.__name__}: {e}") + results.append((test.__name__, False)) + + # Summary + print("\n" + "=" * 60) + print("Test Summary") + print("=" * 60) + for name, result in results: + status = "PASS" if result else "FAIL" + print(f"{name}: {status}") + + # Cleanup + print("\nCleaning up...") + nm = NotificationManager() + nm.cleanup() + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/packages/tray/test_notification_simple.py b/packages/tray/test_notification_simple.py new file mode 100755 index 0000000000..67961f3845 --- /dev/null +++ b/packages/tray/test_notification_simple.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Simple quick test for notification system.""" + +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +from openadapt_tray.notifications import NotificationManager + + +def main(): + """Run quick tests.""" + print("Testing OpenAdapt Notification System") + print("=" * 60) + + nm = NotificationManager() + print(f"Backend: {nm._backend}") + print(f"Notifier available: {nm._notifier is not None}") + + # Test 1: Basic notification + print("\n1. Sending basic notification...") + result1 = nm.show( + title="OpenAdapt Test", + body="This is a basic test notification from desktop-notifier!" + ) + print(f" Result: {result1}") + + # Test 2: Critical notification + print("\n2. Sending critical notification...") + result2 = nm.show( + title="OpenAdapt Critical", + body="This is a critical notification!", + urgency="critical" + ) + print(f" Result: {result2}") + + # Test 3: Notification with callback + print("\n3. Sending notification with callback...") + clicked = [False] + + def on_click(): + print(" [Callback triggered!]") + clicked[0] = True + + result3 = nm.show( + title="OpenAdapt Callback Test", + body="Click this notification if you want to test callbacks", + on_clicked=on_click + ) + print(f" Result: {result3}") + + # Summary + print("\n" + "=" * 60) + print("Summary:") + print(f" Basic notification: {'PASS' if result1 else 'FAIL'}") + print(f" Critical notification: {'PASS' if result2 else 'FAIL'}") + print(f" Callback notification: {'PASS' if result3 else 'FAIL'}") + + # Cleanup + nm.cleanup() + print("\nAll tests completed. Check Notification Center to see notifications!") + + +if __name__ == "__main__": + main() diff --git a/packages/tray/tests/__init__.py b/packages/tray/tests/__init__.py new file mode 100644 index 0000000000..15eeb93db0 --- /dev/null +++ b/packages/tray/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for openadapt_tray package.""" diff --git a/packages/tray/tests/test_app.py b/packages/tray/tests/test_app.py new file mode 100644 index 0000000000..fd441b6cfc --- /dev/null +++ b/packages/tray/tests/test_app.py @@ -0,0 +1,710 @@ +"""Tests for the main TrayApplication.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from openadapt_tray.config import ConfigLoadError, TrayConfig +from openadapt_tray.platform.base import DialogUnavailableError +from openadapt_tray.state import SyncState, TrayState + + +def _make_test_app(): + """Build an app without platform or tray side effects.""" + from openadapt_tray.app import TrayApplication + + with ( + patch("openadapt_tray.app.get_platform_handler") as platform, + patch("openadapt_tray.app.pystray") as pystray, + ): + platform.return_value = MagicMock() + pystray.Icon.return_value = MagicMock() + return TrayApplication(config=TrayConfig()) + + +class TestTrayApplicationInit: + """Tests for TrayApplication initialization.""" + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_initialization_with_defaults(self, mock_platform, mock_pystray): + """Test that TrayApplication initializes with default config.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_icon = MagicMock() + mock_pystray.Icon.return_value = mock_icon + + app = TrayApplication() + + assert app.config is not None + assert app.state is not None + assert app.icon == mock_icon + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_initialization_with_custom_config(self, mock_platform, mock_pystray): + """Test initialization with custom config.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + config = TrayConfig(dashboard_port=9000) + app = TrayApplication(config=config) + + assert app.config.dashboard_port == 9000 + + +class TestRecordingControls: + """Tests for recording start/stop functionality.""" + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_start_recording_changes_state(self, mock_platform, mock_pystray): + """Test that start_recording transitions to RECORDING_STARTING.""" + from openadapt_tray.app import TrayApplication + + mock_platform_instance = MagicMock() + mock_platform_instance.prompt_input.return_value = "test_capture" + mock_platform.return_value = mock_platform_instance + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + + # Start recording with a name (skip prompt); the desktop dispatch runs + # on a background thread, so stub it out. + with patch.object(app, "_dispatch_start_recording"): + app.start_recording("test_capture") + + assert app.state.current.state in ( + TrayState.RECORDING_STARTING, + TrayState.RECORDING, + ) + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_cannot_start_recording_when_recording(self, mock_platform, mock_pystray): + """Test that recording cannot start when already recording.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + app.state.transition(TrayState.RECORDING, current_capture="existing") + + with patch.object(app, "_dispatch_start_recording") as mock_dispatch: + app.start_recording("new_capture") + mock_dispatch.assert_not_called() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_stop_recording_signals_desktop(self, mock_platform, mock_pystray): + """stop_recording transitions to STOPPING and signals the desktop.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + app.state.transition(TrayState.RECORDING, current_capture="test") + + with patch.object(app.ipc, "send_stop_recording") as mock_stop: + mock_stop.return_value = True + app.stop_recording() + mock_stop.assert_called_once() + + # The tray waits for the desktop's RECORDING_STOPPED event. + assert app.state.current.state == TrayState.RECORDING_STOPPING + + def test_failed_stop_command_enters_error(self): + """A command that was not sent must not render as stopping.""" + app = _make_test_app() + app.state.transition(TrayState.RECORDING, current_capture="test") + + with patch.object(app.ipc, "send_stop_recording", return_value=False): + app.stop_recording() + + assert app.state.current.state == TrayState.ERROR + assert "stop-recording" in (app.state.current.error_message or "") + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_cannot_stop_recording_when_idle(self, mock_platform, mock_pystray): + """Test that stop_recording does nothing when idle.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + assert app.state.current.state == TrayState.IDLE + + app.stop_recording() + + assert app.state.current.state == TrayState.IDLE + + +class TestToggleRecording: + """Tests for toggle recording functionality.""" + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_toggle_starts_when_idle(self, mock_platform, mock_pystray): + """Test that toggle starts recording when idle.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + + with patch.object(app, "start_recording") as mock_start: + app._toggle_recording() + mock_start.assert_called_once() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_toggle_stops_when_recording(self, mock_platform, mock_pystray): + """Test that toggle stops recording when recording.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + app.state.transition(TrayState.RECORDING, current_capture="test") + + with patch.object(app, "stop_recording") as mock_stop: + app._toggle_recording() + mock_stop.assert_called_once() + + +class TestStateNotifications: + """Tests for state change notifications.""" + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_notification_on_recording_start(self, mock_platform, mock_pystray): + """Test that notification is shown when recording starts.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + + with patch.object(app.notifications, "show") as mock_show: + app.state.transition(TrayState.RECORDING, current_capture="test") + + mock_show.assert_called() + call_args = mock_show.call_args + assert "Recording Started" in call_args[0][0] + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_no_notification_when_disabled(self, mock_platform, mock_pystray): + """Test that no notification is shown when disabled.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + config = TrayConfig(show_notifications=False) + app = TrayApplication(config=config) + + with patch.object(app.notifications, "show") as mock_show: + app.state.transition(TrayState.RECORDING, current_capture="test") + mock_show.assert_not_called() + + def test_sync_change_does_not_claim_recording_stopped(self): + """An orthogonal sync update is not a recording completion.""" + app = _make_test_app() + + with patch.object(app.notifications, "show") as mock_show: + app.state.set_sync_state(SyncState.SYNCING) + + mock_show.assert_not_called() + + def test_failed_notification_is_retried(self): + app = _make_test_app() + with patch.object(app.notifications, "show", return_value=False) as show: + app.state.transition(TrayState.RECORDING, current_capture="test") + app._show_state_notification(app.state.current) + + assert show.call_count == 2 + assert app._last_notified_tray_state == TrayState.IDLE + + def test_delivered_notification_is_not_repeated(self): + app = _make_test_app() + with patch.object(app.notifications, "show", return_value=True) as show: + app.state.transition(TrayState.RECORDING, current_capture="test") + app._show_state_notification(app.state.current) + + show.assert_called_once() + assert app._last_notified_tray_state == TrayState.RECORDING + + def test_unmessaged_transition_resets_notification_deduplication(self): + app = _make_test_app() + with patch.object(app.notifications, "show", return_value=False) as show: + app.state.transition(TrayState.RECORDING, current_capture="test") + app.state.transition(TrayState.RECORDING_STOPPING) + app.state.transition(TrayState.IDLE) + + assert show.call_count == 2 + assert show.call_args.args[0] == "Recording Stopped" + + +class TestQuit: + """Tests for application quit functionality.""" + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_quit_stops_hotkeys(self, mock_platform, mock_pystray): + """Test that quit stops hotkey listener.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + + with patch.object(app.hotkeys, "stop") as mock_stop: + app.quit() + mock_stop.assert_called_once() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_quit_closes_ipc(self, mock_platform, mock_pystray): + """Test that quit closes IPC connection.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + + with patch.object(app.ipc, "close") as mock_close: + app.quit() + mock_close.assert_called_once() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_quit_stops_recording_if_active(self, mock_platform, mock_pystray): + """Test that quit stops active recording.""" + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + app = TrayApplication() + app.state.transition(TrayState.RECORDING, current_capture="test") + + with patch.object(app, "stop_recording") as mock_stop: + app.quit() + mock_stop.assert_called_once() + + +class TestUnreadableConfigIsReported: + """Running on settings the user never chose must not look like a clean start.""" + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_run_tells_the_user_the_settings_could_not_be_read( + self, mock_platform, mock_pystray + ): + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + error = ConfigLoadError(Path("/tmp/tray.json"), ValueError("bad json")) + with patch.object( + TrayConfig, "load_or_defaults", return_value=(TrayConfig(), error) + ): + app = TrayApplication() + app.notifications = MagicMock() + app.hotkeys = MagicMock() + app.ipc = MagicMock() + app.hosted = MagicMock() + + app.run() + + app.notifications.show.assert_called_once() + title = app.notifications.show.call_args[0][0] + assert "settings" in title.lower() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_a_clean_load_says_nothing(self, mock_platform, mock_pystray): + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + + with patch.object( + TrayConfig, "load_or_defaults", return_value=(TrayConfig(), None) + ): + app = TrayApplication() + app.notifications = MagicMock() + app.hotkeys = MagicMock() + app.ipc = MagicMock() + app.hosted = MagicMock() + + app.run() + + app.notifications.show.assert_not_called() + + +class TestStartRecordingWhenNoDialogCanBeShown: + """ "The prompt never appeared" used to be indistinguishable from "cancelled".""" + + def _app(self, mock_platform, mock_pystray): + from openadapt_tray.app import TrayApplication + + mock_pystray.Icon.return_value = MagicMock() + app = TrayApplication(config=TrayConfig(use_native_dialogs=True)) + app.notifications = MagicMock() + return app + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_unavailable_dialog_still_starts_the_recording( + self, mock_platform, mock_pystray + ): + platform = MagicMock() + platform.prompt_input.side_effect = DialogUnavailableError("no display") + mock_platform.return_value = platform + + app = self._app(mock_platform, mock_pystray) + with patch.object(app, "_dispatch_start_recording"): + app.start_recording() + + assert app.state.current.state == TrayState.RECORDING_STARTING + # And the user is told why no naming prompt appeared. + app.notifications.show.assert_called_once() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_a_real_cancel_still_cancels(self, mock_platform, mock_pystray): + platform = MagicMock() + platform.prompt_input.return_value = None # the user pressed Cancel + mock_platform.return_value = platform + + app = self._app(mock_platform, mock_pystray) + with patch.object(app, "_dispatch_start_recording"): + app.start_recording() + + assert app.state.current.state == TrayState.IDLE + app.notifications.show.assert_not_called() + + +class TestNeedsAttentionClickReportsDeadEnds: + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_click_that_opened_nothing_tells_the_user( + self, mock_platform, mock_pystray + ): + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + app = TrayApplication(config=TrayConfig()) + app.notifications = MagicMock() + + with patch("openadapt_tray.app.route_break_click", return_value=False): + assert app.open_needs_attention() is False + app.notifications.show.assert_called_once() + + @patch("openadapt_tray.app.pystray") + @patch("openadapt_tray.app.get_platform_handler") + def test_click_that_opened_something_stays_quiet(self, mock_platform, mock_pystray): + from openadapt_tray.app import TrayApplication + + mock_platform.return_value = MagicMock() + mock_pystray.Icon.return_value = MagicMock() + app = TrayApplication(config=TrayConfig()) + app.notifications = MagicMock() + + with patch("openadapt_tray.app.route_break_click", return_value=True): + assert app.open_needs_attention() is True + app.notifications.show.assert_not_called() + + +class TestByocNeedsAttentionStaysLocal: + """On the byoc lane a needs-attention click only ever opens Teach locally.""" + + def _byoc_app(self, *, connected: bool): + app = _make_test_app() + app.config.deployment_lane = "byoc" + app.notifications = MagicMock() + app.ipc = MagicMock() + app.ipc.is_connected.return_value = connected + return app + + def test_connected_desktop_opens_teach(self): + app = self._byoc_app(connected=True) + app.ipc.send_open_teach.return_value = True + with patch("openadapt_tray.hosted.webbrowser.open") as browser: + assert app.open_needs_attention() is True + app.ipc.send_open_teach.assert_called_once() + browser.assert_not_called() + app.notifications.show.assert_not_called() + + def test_refused_teach_is_reported_without_opening_the_dashboard(self): + app = self._byoc_app(connected=True) + app.ipc.send_open_teach.return_value = False + with patch("openadapt_tray.hosted.webbrowser.open") as browser: + assert app.open_needs_attention() is False + browser.assert_not_called() + app.notifications.show.assert_called_once() + assert "Teach" in app.notifications.show.call_args.args[0] + + def test_disconnected_desktop_is_started_on_a_background_thread(self): + app = self._byoc_app(connected=False) + with patch("openadapt_tray.app.threading.Thread") as thread: + assert app.open_needs_attention() is True + assert thread.call_args.kwargs["target"] == app._open_teach_async + thread.return_value.start.assert_called_once() + + def test_background_launch_opens_teach_once_connected(self): + app = self._byoc_app(connected=False) + app.ipc.send_open_teach.return_value = True + with patch.object( + app, "ensure_desktop_connection", return_value=True + ), patch("openadapt_tray.hosted.webbrowser.open") as browser: + app._open_teach_async() + app.ipc.send_open_teach.assert_called_once() + browser.assert_not_called() + app.notifications.show.assert_not_called() + + def test_background_launch_failure_is_reported_without_the_dashboard(self): + app = self._byoc_app(connected=False) + with patch.object( + app, "ensure_desktop_connection", return_value=False + ), patch("openadapt_tray.hosted.webbrowser.open") as browser: + app._open_teach_async() + browser.assert_not_called() + app.ipc.send_open_teach.assert_not_called() + app.notifications.show.assert_called_once() + + def test_desktop_reported_byoc_lane_overrides_a_cloud_config(self): + """tray.json says cloud, the desktop says byoc: the click stays local.""" + from openadapt_tray.state import LANE_BYOC + + app = self._byoc_app(connected=True) + app.config.deployment_lane = "cloud" + app.state.set_deployment_lane(LANE_BYOC) + app.ipc.send_open_teach.return_value = True + with patch("openadapt_tray.hosted.webbrowser.open") as browser: + assert app.open_needs_attention() is True + app.ipc.send_open_teach.assert_called_once() + browser.assert_not_called() + + def test_cloud_lane_still_opens_the_dashboard(self): + app = self._byoc_app(connected=True) + app.config.deployment_lane = "cloud" + with patch( + "openadapt_tray.hosted.webbrowser.open", return_value=True + ) as browser: + assert app.open_needs_attention() is True + browser.assert_called_once_with("https://app.openadapt.ai/dashboard") + app.ipc.send_open_teach.assert_not_called() + + +class TestOpenActionsReportDeadEnds: + def test_connected_desktop_command_failure_is_reported(self): + app = _make_test_app() + app.notifications = MagicMock() + app.ipc = MagicMock() + app.ipc.is_connected.return_value = True + app.ipc.send_open_workflow_library.return_value = False + + app.open_desktop_app() + + app.notifications.show.assert_called_once() + + def test_desktop_launch_uses_native_application_helper(self): + app = _make_test_app() + + with patch("openadapt_tray.app.launch_native_desktop") as launch: + assert app._launch_desktop_app() is True + + launch.assert_called_once_with() + + def test_missing_native_desktop_is_reported(self): + from openadapt_tray.desktop import DesktopLaunchError + + app = _make_test_app() + app.notifications = MagicMock() + + with patch( + "openadapt_tray.app.launch_native_desktop", + side_effect=DesktopLaunchError("not installed"), + ): + assert app._launch_desktop_app() is False + + app.notifications.show.assert_called_once() + + def test_browser_failure_is_not_reported_as_opened(self): + app = _make_test_app() + app.notifications = MagicMock() + + with patch("openadapt_tray.app.webbrowser.open", return_value=False): + assert app.open_cloud_dashboard() is False + + app.notifications.show.assert_called_once() + + def test_browser_exception_is_reported(self): + app = _make_test_app() + app.notifications = MagicMock() + + with patch( + "openadapt_tray.app.webbrowser.open", side_effect=OSError("no browser") + ): + assert app.open_cloud_dashboard() is False + + app.notifications.show.assert_called_once() + + +class TestFailedIPCResultsStayFailed: + """A False command result means that the tray sent no command.""" + + def test_failed_pause_does_not_report_synced(self): + app = _make_test_app() + app.notifications = MagicMock() + app.state.set_sync_state(SyncState.SYNCING) + app.ipc = MagicMock() + app.ipc.is_connected.return_value = True + app.ipc.send_pause_sync.return_value = False + + assert app.pause_sync() is False + assert app.state.current.sync_state == SyncState.SYNCING + app.notifications.show.assert_called_once() + + def test_successful_pause_updates_sync_state(self): + app = _make_test_app() + app.state.set_sync_state(SyncState.SYNCING) + app.ipc = MagicMock() + app.ipc.is_connected.return_value = True + app.ipc.send_pause_sync.return_value = True + + assert app.pause_sync() is True + assert app.state.current.sync_state == SyncState.PAUSED + + def test_successful_resume_clears_the_pause(self): + app = _make_test_app() + app.state.set_sync_state(SyncState.PAUSED) + app.ipc = MagicMock() + app.ipc.is_connected.return_value = True + app.ipc.send_resume_sync.return_value = True + + assert app.resume_sync() is True + assert app.state.current.sync_state == SyncState.SYNCED + + def test_desktop_paused_sync_event_is_shown(self): + # openadapt-desktop emits sync_state "paused" after pause_sync. + app = _make_test_app() + app._on_ipc_sync_state(MagicMock(data={"state": "paused"})) + assert app.state.current.sync_state == SyncState.PAUSED + + def test_failed_resume_is_reported(self): + app = _make_test_app() + app.notifications = MagicMock() + app.ipc = MagicMock() + app.ipc.is_connected.return_value = True + app.ipc.send_resume_sync.return_value = False + + assert app.resume_sync() is False + app.notifications.show.assert_called_once() + + +class TestUnreadableIPCBreakCounts: + """An unreadable pushed count must not clear the last known badge.""" + + def test_missing_count_keeps_last_known_value(self): + app = _make_test_app() + app.state.set_break_count(4) + message = MagicMock(data={}) + + app._on_ipc_break_count(message) + + assert app.state.current.break_count == 4 + + def test_invalid_status_count_keeps_last_known_value(self): + app = _make_test_app() + app.state.set_break_count(4) + message = MagicMock(data={"break_count": "unknown"}) + + app._on_ipc_status_update(message) + + assert app.state.current.break_count == 4 + + +class TestCanonicalDesktopState: + """The tray consumes Desktop protocol v1 without compatibility fields.""" + + def test_status_uses_recording_boolean_and_capture_id(self): + app = _make_test_app() + + app._on_ipc_status_update( + MagicMock( + data={ + "recording": True, + "paused": False, + "capture_id": "capture-1", + } + ) + ) + + assert app.state.current.state == TrayState.RECORDING + assert app.state.current.current_capture == "capture-1" + + def test_status_false_clears_stale_recording_state(self): + app = _make_test_app() + app.state.transition(TrayState.RECORDING, current_capture="old") + + app._on_ipc_status_update( + MagicMock(data={"recording": False, "capture_id": None}) + ) + + assert app.state.current.state == TrayState.IDLE + assert app.state.current.current_capture is None + + def test_recording_event_uses_capture_id(self): + app = _make_test_app() + + app._on_ipc_recording_started(MagicMock(data={"capture_id": "capture-2"})) + + assert app.state.current.current_capture == "capture-2" + + def test_compiled_is_a_successful_terminal_state(self): + app = _make_test_app() + app.state.transition(TrayState.COMPILING, current_capture="capture-3") + + app._on_ipc_compile_progress( + MagicMock(data={"state": "compiled", "capture_id": "capture-3"}) + ) + + assert app.state.current.state == TrayState.IDLE + assert app.state.current.error_message is None + + @pytest.mark.parametrize("terminal", ["failed", "review_failed"]) + def test_compile_failure_terminal_states_stay_visible(self, terminal): + app = _make_test_app() + + app._on_ipc_compile_progress( + MagicMock( + data={ + "state": terminal, + "capture_id": "capture-4", + "error": "retained failure", + } + ) + ) + + assert app.state.current.state == TrayState.ERROR + assert app.state.current.current_capture == "capture-4" + assert app.state.current.error_message == "retained failure" diff --git a/packages/tray/tests/test_config.py b/packages/tray/tests/test_config.py new file mode 100644 index 0000000000..2bfe1bba91 --- /dev/null +++ b/packages/tray/tests/test_config.py @@ -0,0 +1,247 @@ +"""Tests for configuration management.""" + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from openadapt_tray.config import ConfigLoadError, TrayConfig +from openadapt_tray.shortcuts import HotkeyConfig + + +class TestHotkeyConfig: + """Tests for HotkeyConfig dataclass.""" + + def test_default_values(self): + """Test default hotkey values.""" + config = HotkeyConfig() + assert config.toggle_recording == "++r" + assert config.open_dashboard == "++d" + assert config.stop_recording == "++" + + def test_custom_values(self): + """Test custom hotkey values.""" + config = HotkeyConfig( + toggle_recording="+r", + open_dashboard="+d", + ) + assert config.toggle_recording == "+r" + assert config.open_dashboard == "+d" + + +class TestTrayConfig: + """Tests for TrayConfig dataclass.""" + + def test_default_values(self): + """Test default configuration values.""" + config = TrayConfig() + assert config.dashboard_port == 8080 + assert config.auto_launch_dashboard is True + assert config.show_notifications is True + assert config.stop_on_triple_ctrl is True + assert config.captures_directory == "~/openadapt/captures" + + def test_hotkeys_default(self): + """Test that default hotkeys are created.""" + config = TrayConfig() + assert isinstance(config.hotkeys, HotkeyConfig) + assert config.hotkeys.toggle_recording == "++r" + + def test_to_dict(self): + """Test conversion to dictionary.""" + config = TrayConfig() + data = config.to_dict() + + assert "hotkeys" in data + assert data["hotkeys"]["toggle_recording"] == "++r" + assert data["dashboard_port"] == 8080 + assert data["show_notifications"] is True + + def test_from_dict(self): + """Test creation from dictionary.""" + data = { + "hotkeys": { + "toggle_recording": "+r", + "open_dashboard": "+d", + "stop_recording": "++", + }, + "dashboard_port": 9000, + "show_notifications": False, + } + config = TrayConfig._from_dict(data) + + assert config.hotkeys.toggle_recording == "+r" + assert config.dashboard_port == 9000 + assert config.show_notifications is False + + def test_get_captures_path(self): + """Test captures path expansion.""" + config = TrayConfig(captures_directory="~/test/captures") + path = config.get_captures_path() + + assert isinstance(path, Path) + assert not str(path).startswith("~") + assert path.parts[-2:] == ("test", "captures") + + def test_hosted_defaults(self): + """Test hosted/loop config defaults.""" + config = TrayConfig() + assert config.hosted_url == "https://app.openadapt.ai" + assert config.deployment_lane == "cloud" + assert config.poll_interval_s == 60 + assert config.desktop_ipc_port is None + + def test_hosted_keys_roundtrip(self): + """Hosted keys survive to_dict/_from_dict.""" + config = TrayConfig( + hosted_url="https://example.test", + deployment_lane="byoc", + poll_interval_s=120, + desktop_ipc_port=54321, + ) + data = config.to_dict() + assert data["hosted_url"] == "https://example.test" + assert data["deployment_lane"] == "byoc" + assert data["poll_interval_s"] == 120 + assert data["desktop_ipc_port"] == 54321 + + loaded = TrayConfig._from_dict(data) + assert loaded.hosted_url == "https://example.test" + assert loaded.deployment_lane == "byoc" + assert loaded.poll_interval_s == 120 + assert loaded.desktop_ipc_port == 54321 + + def test_effective_poll_interval_clamps_to_floor(self): + """Poll interval never drops below the safe minimum.""" + assert TrayConfig(poll_interval_s=5).effective_poll_interval_s() == 30 + assert TrayConfig(poll_interval_s=90).effective_poll_interval_s() == 90 + + def test_from_dict_ignores_stale_keys(self): + """Unknown/retired keys (e.g. training_output_directory) are dropped.""" + data = { + "dashboard_port": 9000, + "training_output_directory": "~/old/training", # retired + "unknown_future_key": 123, + } + config = TrayConfig._from_dict(data) + assert config.dashboard_port == 9000 + assert not hasattr(config, "training_output_directory") + + def test_get_ingest_token_reads_keychain(self): + """Token resolution delegates to the keychain helper (never the file).""" + config = TrayConfig(hosted_url="https://example.test") + with patch( + "openadapt_tray.keychain.get_ingest_token", return_value="oai_ingest_x" + ) as mock_get: + assert config.get_ingest_token() == "oai_ingest_x" + mock_get.assert_called_once_with("https://example.test") + + def test_token_never_in_serialized_config(self): + """The serialized config must not contain any token field.""" + data = TrayConfig().to_dict() + assert not any("token" in k for k in data) + + def test_save_and_load(self, tmp_path): + """Test saving and loading configuration.""" + config_file = tmp_path / "tray.json" + + # Create config with custom values + config = TrayConfig( + dashboard_port=9000, + show_notifications=False, + hotkeys=HotkeyConfig(toggle_recording="+r"), + ) + + # Mock config_path to use temp directory + with patch.object(TrayConfig, "config_path", return_value=config_file): + config.save() + + # Verify file was created + assert config_file.exists() + + # Load and verify + loaded = TrayConfig.load() + assert loaded.dashboard_port == 9000 + assert loaded.show_notifications is False + assert loaded.hotkeys.toggle_recording == "+r" + + def test_load_missing_file_returns_defaults(self, tmp_path): + """Test that loading missing config returns defaults.""" + config_file = tmp_path / "nonexistent" / "tray.json" + + with patch.object(TrayConfig, "config_path", return_value=config_file): + config = TrayConfig.load() + + assert config.dashboard_port == 8080 + assert config.show_notifications is True + + def test_load_invalid_json_raises(self, tmp_path): + """An unreadable config file must NOT be reported as "no preferences". + + This used to return defaults with only a printed warning. The default + ``deployment_lane`` is ``"cloud"``, so a byoc install whose tray.json + went bad was silently moved onto the hosted break-click route -- a + failure rendered as a perfectly ordinary successful startup. + """ + config_file = tmp_path / "tray.json" + config_file.write_text("invalid json {{{") + + with patch.object( + TrayConfig, "config_path", return_value=config_file + ), pytest.raises(ConfigLoadError) as excinfo: + TrayConfig.load() + + assert excinfo.value.path == config_file + + def test_load_or_defaults_reports_the_error_alongside_the_defaults( + self, tmp_path + ): + """``load_or_defaults`` hands back BOTH, so the caller cannot miss it.""" + config_file = tmp_path / "tray.json" + config_file.write_text("invalid json {{{") + + with patch.object(TrayConfig, "config_path", return_value=config_file): + config, error = TrayConfig.load_or_defaults() + + assert isinstance(error, ConfigLoadError) + assert config.dashboard_port == 8080 + + def test_load_or_defaults_has_no_error_on_a_clean_load(self, tmp_path): + config_file = tmp_path / "tray.json" + config_file.write_text(json.dumps({"dashboard_port": 9100})) + + with patch.object(TrayConfig, "config_path", return_value=config_file): + config, error = TrayConfig.load_or_defaults() + + assert error is None + assert config.dashboard_port == 9100 + + def test_load_or_defaults_has_no_error_when_no_file_exists(self, tmp_path): + """A missing file is not a failure -- defaults are the right answer.""" + config_file = tmp_path / "nonexistent" / "tray.json" + + with patch.object(TrayConfig, "config_path", return_value=config_file): + config, error = TrayConfig.load_or_defaults() + + assert error is None + assert config.dashboard_port == 8080 + + +class TestEnsureFile: + """Settings opens tray.json, so the file has to exist first.""" + + def test_missing_file_is_written_with_current_settings(self, tmp_path): + config_file = tmp_path / "openadapt" / "tray.json" + config = TrayConfig(deployment_lane="byoc") + with patch.object(TrayConfig, "config_path", return_value=config_file): + assert config.ensure_file() == config_file + assert TrayConfig.load().deployment_lane == "byoc" + + def test_existing_file_is_never_rewritten(self, tmp_path): + # An unreadable tray.json stays exactly as the user left it. + config_file = tmp_path / "tray.json" + config_file.write_text("{ not json") + with patch.object(TrayConfig, "config_path", return_value=config_file): + assert TrayConfig().ensure_file() == config_file + assert config_file.read_text() == "{ not json" diff --git a/packages/tray/tests/test_desktop.py b/packages/tray/tests/test_desktop.py new file mode 100644 index 0000000000..cf6f631764 --- /dev/null +++ b/packages/tray/tests/test_desktop.py @@ -0,0 +1,123 @@ +"""Tests for launching the installed native Desktop application.""" + +import subprocess +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from openadapt_tray.desktop import ( + DESKTOP_BUNDLE_IDENTIFIER, + LINUX_NATIVE_EXECUTABLE, + DesktopLaunchError, + launch_native_desktop, +) + + +def _result(returncode: int) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess([], returncode) + + +def test_macos_launches_by_bundle_identifier() -> None: + runner = MagicMock(return_value=_result(0)) + + launch_native_desktop(platform="darwin", runner=runner) + + assert runner.call_args.args[0] == [ + "open", + "-b", + DESKTOP_BUNDLE_IDENTIFIER, + ] + + +def test_macos_launcher_failure_is_visible() -> None: + runner = MagicMock(return_value=_result(1)) + + with pytest.raises(DesktopLaunchError, match="macOS could not open"): + launch_native_desktop(platform="darwin", runner=runner) + + +def test_windows_launches_an_exact_native_installer_path() -> None: + spawner = MagicMock() + expected = ( + Path(r"C:\Users\person\AppData\Local") + / "OpenAdapt Desktop" + / "openadapt-desktop.exe" + ) + + launch_native_desktop( + platform="win32", + environment={"LOCALAPPDATA": r"C:\Users\person\AppData\Local"}, + spawner=spawner, + is_file=lambda path: path == expected, + ) + + assert spawner.call_args.args[0] == [str(expected)] + + +def test_windows_never_falls_back_to_the_python_console_script() -> None: + with pytest.raises(DesktopLaunchError, match="Windows could not find"): + launch_native_desktop( + platform="win32", + environment={}, + is_file=lambda _path: False, + ) + + +def test_linux_launches_by_registered_desktop_identity() -> None: + runner = MagicMock(return_value=_result(0)) + + launch_native_desktop( + platform="linux", + environment={}, + runner=runner, + finder=lambda name: "/usr/bin/gtk-launch" if name == "gtk-launch" else None, + ) + + assert runner.call_args.args[0] == [ + "/usr/bin/gtk-launch", + "OpenAdapt Desktop", + ] + + +def test_linux_can_launch_the_exact_deb_executable() -> None: + spawner = MagicMock() + + launch_native_desktop( + platform="linux", + environment={}, + spawner=spawner, + finder=lambda _name: None, + is_file=lambda path: path == LINUX_NATIVE_EXECUTABLE, + ) + + assert spawner.call_args.args[0] == [str(LINUX_NATIVE_EXECUTABLE)] + + +def test_linux_can_launch_an_explicit_native_appimage() -> None: + spawner = MagicMock() + appimage = Path("/opt/OpenAdapt Desktop.AppImage") + + launch_native_desktop( + platform="linux", + environment={"OPENADAPT_DESKTOP_APPIMAGE": str(appimage)}, + spawner=spawner, + finder=lambda _name: None, + is_file=lambda path: path == appimage, + ) + + assert spawner.call_args.args[0] == [str(appimage)] + + +def test_unknown_platform_does_not_use_linux_launchers() -> None: + runner = MagicMock() + + with pytest.raises(DesktopLaunchError, match="not supported on freebsd"): + launch_native_desktop( + platform="freebsd", + environment={}, + runner=runner, + finder=lambda _name: "/usr/bin/gtk-launch", + ) + + runner.assert_not_called() diff --git a/packages/tray/tests/test_hosted.py b/packages/tray/tests/test_hosted.py new file mode 100644 index 0000000000..3ceaee875b --- /dev/null +++ b/packages/tray/tests/test_hosted.py @@ -0,0 +1,655 @@ +"""Tests for the cloud needs-attention poller (mocks httpx).""" + +from unittest.mock import MagicMock, patch + +import pytest + +from openadapt_tray.config import ( + MIN_POLL_INTERVAL_S, + OFFLINE_POLL_INTERVAL_S, + TrayConfig, +) +from openadapt_tray.hosted import ( + CountResult, + CredentialWarningStore, + HostedPoller, + InvalidCountPayload, + InvalidCredentialPayload, + count_url, + credential_identity, + parse_credential_status, + route_break_click, +) +from openadapt_tray.state import CredentialState + +VALID_INGEST_TOKEN = "oai_ingest_" + ("A" * 43) + + +def make_config(**kw): + """Build a config with a token available via a patched keychain read.""" + return TrayConfig(hosted_url="https://example.test", **kw) + + +class _FakeResponse: + def __init__(self, status_code=200, payload=None, headers=None): + self.status_code = status_code + self._payload = payload or {} + self.headers = headers or {} + + def json(self): + return self._payload + + +class _FakeClient: + """Context-manager stand-in for httpx.Client.""" + + def __init__(self, response=None, raise_exc=None): + self._response = response + self._raise = raise_exc + self.last_url = None + self.last_headers = None + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, url, headers=None): + self.last_url = url + self.last_headers = headers + if self._raise: + raise self._raise + return self._response + + +class TestCountResult: + def test_from_payload(self): + r = CountResult.from_payload( + {"count": 3, "halts": 2, "uncertain_dispatches": 1} + ) + assert (r.count, r.halts, r.uncertain_dispatches) == (3, 2, 1) + + def test_from_payload_tolerates_missing(self): + r = CountResult.from_payload({"count": 5}) + assert r.count == 5 + assert r.halts == 0 + assert r.uncertain_dispatches == 0 + + +class TestCountUrl: + def test_count_url(self): + assert ( + count_url("https://example.test/") + == "https://example.test/api/needs-attention/count" + ) + + def test_credential_identity_is_stable_and_host_bound(self): + first = credential_identity("https://example.test", VALID_INGEST_TOKEN) + + assert first == credential_identity( + "https://example.test/", VALID_INGEST_TOKEN + ) + assert first != credential_identity( + "https://other.test", VALID_INGEST_TOKEN + ) + + +class TestPollOnce: + """Tests for the single authenticated request.""" + + def test_poll_once_success(self): + cfg = make_config() + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: VALID_INGEST_TOKEN + ) + fake = _FakeClient( + _FakeResponse(200, {"count": 4, "halts": 3, "uncertain_dispatches": 1}) + ) + with patch("openadapt_tray.hosted.httpx.Client", return_value=fake): + result = poller.poll_once() + + assert result is not None + assert result.count == 4 + assert result.halts == 3 + assert result.uncertain_dispatches == 1 + # Verify the exact request contract (endpoint + bearer auth). + assert fake.last_url == "https://example.test/api/needs-attention/count" + assert fake.last_headers["Authorization"] == f"Bearer {VALID_INGEST_TOKEN}" + + def test_poll_once_parses_closed_credential_contract(self): + cfg = make_config() + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: VALID_INGEST_TOKEN + ) + payload = { + "count": 0, + "credential": { + "expires_at": "2026-08-05T12:00:00Z", + "expires_in_days": 8, + "expiring_soon": True, + "legacy_non_expiring": False, + "warning_days": 14, + }, + } + fake = _FakeClient( + _FakeResponse( + 200, + payload, + { + "Cache-Control": "no-store", + "X-OpenAdapt-Credential-Warning-Days": "14", + "X-OpenAdapt-Credential-Expires-In-Days": "8", + }, + ) + ) + with patch("openadapt_tray.hosted.httpx.Client", return_value=fake): + result = poller.poll_once() + + assert result is not None + assert result.credential.state == CredentialState.EXPIRING + assert result.credential_identity == credential_identity( + cfg.hosted_url, VALID_INGEST_TOKEN + ) + + def test_missing_no_store_header_keeps_valid_attention_count(self): + result = CountResult.from_payload( + { + "count": 4, + "credential": { + "expires_at": "2026-08-05T12:00:00Z", + "expires_in_days": 8, + "expiring_soon": True, + "legacy_non_expiring": False, + "warning_days": 14, + }, + }, + headers={ + "X-OpenAdapt-Credential-Warning-Days": "14", + "X-OpenAdapt-Credential-Expires-In-Days": "8", + }, + ) + + assert result.count == 4 + assert result.credential.state == CredentialState.UNKNOWN + assert result.credential_error is not None + + @pytest.mark.parametrize( + ("days", "expiring"), + [(13, False), (15, True)], + ) + def test_impossible_expiry_combinations_are_rejected(self, days, expiring): + with pytest.raises(InvalidCredentialPayload): + parse_credential_status( + { + "expires_at": "2026-08-05T12:00:00Z", + "expires_in_days": days, + "expiring_soon": expiring, + "legacy_non_expiring": False, + "warning_days": 14, + } + ) + + def test_poll_once_no_token_returns_none(self): + cfg = make_config() + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: None + ) + # httpx must not even be called without a token. + with patch("openadapt_tray.hosted.httpx.Client") as mock_client: + assert poller.poll_once() is None + mock_client.assert_not_called() + + def test_poll_once_non_200_returns_none(self): + cfg = make_config() + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: VALID_INGEST_TOKEN + ) + fake = _FakeClient(_FakeResponse(401, {})) + with patch("openadapt_tray.hosted.httpx.Client", return_value=fake): + assert poller.poll_once() is None + + def test_poll_once_network_error_returns_none(self): + cfg = make_config() + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: VALID_INGEST_TOKEN + ) + fake = _FakeClient(raise_exc=OSError("no route to host")) + with patch("openadapt_tray.hosted.httpx.Client", return_value=fake): + assert poller.poll_once() is None + + def test_poll_once_rejects_wrong_token_type_before_http(self): + poller = HostedPoller( + make_config(), + on_count=lambda r: None, + token_provider=lambda: "oap_pairing_secret", + ) + + with patch("openadapt_tray.hosted.httpx.Client") as mock_client: + assert poller.poll_once() is None + mock_client.assert_not_called() + + +class TestHandleResult: + """Tests for badge updates, notifications, and back-off.""" + + def test_notifies_on_increase(self): + cfg = make_config() + counts = [] + notifier = MagicMock() + poller = HostedPoller( + cfg, + on_count=lambda r: counts.append(r.count), + notifier=notifier, + token_provider=lambda: "t", + ) + + poller._handle_result(CountResult(count=2)) + + assert counts == [2] + notifier.show.assert_called_once() + # Body reads "N automations need attention". + args, _kwargs = notifier.show.call_args + assert "2 automations need attention" in args[1] + + def test_no_notify_on_same_count(self): + cfg = make_config() + notifier = MagicMock() + poller = HostedPoller( + cfg, on_count=lambda r: None, notifier=notifier, + token_provider=lambda: "t", + ) + poller._handle_result(CountResult(count=2)) + notifier.reset_mock() + poller._handle_result(CountResult(count=2)) + notifier.show.assert_not_called() + + def test_no_notify_on_decrease(self): + cfg = make_config() + notifier = MagicMock() + poller = HostedPoller( + cfg, on_count=lambda r: None, notifier=notifier, + token_provider=lambda: "t", + ) + poller._handle_result(CountResult(count=3)) + notifier.reset_mock() + poller._handle_result(CountResult(count=1)) + notifier.show.assert_not_called() + + def test_singular_wording(self): + cfg = make_config() + notifier = MagicMock() + poller = HostedPoller( + cfg, on_count=lambda r: None, notifier=notifier, + token_provider=lambda: "t", + ) + poller._handle_result(CountResult(count=1)) + args, _ = notifier.show.call_args + assert "1 automation need" in args[1] + + def test_offline_backs_off_and_flags(self): + cfg = make_config(poll_interval_s=60) + offline_flags = [] + poller = HostedPoller( + cfg, + on_count=lambda r: None, + token_provider=lambda: "t", + set_offline=lambda o: offline_flags.append(o), + ) + poller._handle_result(None) + assert poller.current_interval == OFFLINE_POLL_INTERVAL_S + assert offline_flags == [True] + + def test_recovers_interval_when_online(self): + cfg = make_config(poll_interval_s=45) + offline_flags = [] + poller = HostedPoller( + cfg, + on_count=lambda r: None, + token_provider=lambda: "t", + set_offline=lambda o: offline_flags.append(o), + ) + poller._handle_result(None) # offline → 300s + poller._handle_result(CountResult(count=0)) # online → back to 45s + assert poller.current_interval == 45 + assert offline_flags == [True, False] + + def test_interval_never_below_floor(self): + cfg = make_config(poll_interval_s=5) # below the floor + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: "t" + ) + poller._handle_result(CountResult(count=0)) + assert poller.current_interval == MIN_POLL_INTERVAL_S + + def test_unreachable_status_is_unknown_not_healthy(self): + statuses = [] + poller = HostedPoller( + make_config(), + on_count=lambda r: None, + on_credential=statuses.append, + token_provider=lambda: "t", + ) + poller._last_had_token = True + + poller._handle_result(None) + + assert statuses[-1].state == CredentialState.UNKNOWN + + +class TestRouteBreakClick: + """Tests for lane-aware click routing (spec §3c).""" + + def test_cloud_lane_opens_dashboard(self): + cfg = make_config(deployment_lane="cloud") + with patch("openadapt_tray.hosted.webbrowser.open") as mock_open: + route_break_click(cfg, ipc_client=None) + mock_open.assert_called_once_with("https://example.test/dashboard") + + def test_byoc_lane_routes_to_desktop(self): + cfg = make_config(deployment_lane="byoc") + ipc = MagicMock() + ipc.send_open_teach.return_value = True + with patch("openadapt_tray.hosted.webbrowser.open") as mock_open: + assert route_break_click(cfg, ipc_client=ipc) is True + ipc.send_open_teach.assert_called_once() + mock_open.assert_not_called() + + def test_byoc_command_failure_never_opens_the_dashboard(self): + """A refused open-teach is reported, not rerouted to the hosted side.""" + cfg = make_config(deployment_lane="byoc") + ipc = MagicMock() + ipc.send_open_teach.return_value = False + with patch( + "openadapt_tray.hosted.webbrowser.open", return_value=True + ) as mock_open: + assert route_break_click(cfg, ipc_client=ipc) is False + mock_open.assert_not_called() + + def test_byoc_command_exception_never_opens_the_dashboard(self): + cfg = make_config(deployment_lane="byoc") + ipc = MagicMock() + ipc.send_open_teach.side_effect = OSError("socket closed") + with patch("openadapt_tray.hosted.webbrowser.open") as mock_open: + assert route_break_click(cfg, ipc_client=ipc) is False + mock_open.assert_not_called() + + def test_byoc_without_desktop_never_opens_the_dashboard(self): + cfg = make_config(deployment_lane="byoc") + with patch("openadapt_tray.hosted.webbrowser.open") as mock_open: + assert route_break_click(cfg, ipc_client=None) is False + mock_open.assert_not_called() + + def test_lane_argument_overrides_the_config_lane(self): + """The app passes the desktop-reported lane when it is byoc.""" + cfg = make_config(deployment_lane="cloud") + with patch("openadapt_tray.hosted.webbrowser.open") as mock_open: + assert route_break_click(cfg, ipc_client=None, lane="byoc") is False + mock_open.assert_not_called() + + def test_browser_exception_reports_no_route(self): + cfg = make_config(deployment_lane="cloud") + with patch( + "openadapt_tray.hosted.webbrowser.open", + side_effect=OSError("no browser"), + ): + assert route_break_click(cfg, ipc_client=None) is False + + +class TestUnreadableCountPayload: + """A body we cannot read is NOT a count of zero. + + ``count`` drives the badge and the "N automations need attention" + notification. ``payload.get("count", 0)`` used to turn any response that + lost or renamed the field into a confident all-clear -- the single most + dangerous value this module can invent. + """ + + def test_missing_count_raises_instead_of_reporting_zero(self): + with pytest.raises(InvalidCountPayload) as excinfo: + CountResult.from_payload({"halts": 0}) + assert "count" in str(excinfo.value) + + def test_null_count_raises(self): + with pytest.raises(InvalidCountPayload): + CountResult.from_payload({"count": None}) + + def test_non_numeric_count_raises(self): + with pytest.raises(InvalidCountPayload): + CountResult.from_payload({"count": "lots"}) + + @pytest.mark.parametrize("value", [False, True, 0.0, 1.9, "2"]) + def test_non_json_integer_count_raises(self, value): + with pytest.raises(InvalidCountPayload): + CountResult.from_payload({"count": value}) + + def test_negative_count_raises(self): + with pytest.raises(InvalidCountPayload): + CountResult.from_payload({"count": -1}) + + def test_non_object_body_raises(self): + with pytest.raises(InvalidCountPayload): + CountResult.from_payload([1, 2, 3]) + + def test_unreadable_subfield_raises(self): + with pytest.raises(InvalidCountPayload): + CountResult.from_payload({"count": 1, "halts": "two"}) + + def test_poll_once_reports_failure_not_zero(self): + """The poller must return None, never ``CountResult(count=0)``.""" + cfg = make_config() + poller = HostedPoller( + cfg, on_count=lambda r: None, token_provider=lambda: VALID_INGEST_TOKEN + ) + fake = _FakeClient(_FakeResponse(200, {"total": 7})) # no "count" + with patch("openadapt_tray.hosted.httpx.Client", return_value=fake): + assert poller.poll_once() is None + + def test_unreadable_body_never_clears_the_badge(self): + """The badge keeps its last known value rather than dropping to 0.""" + cfg = make_config() + counts = [] + poller = HostedPoller( + cfg, on_count=lambda r: counts.append(r.count), + token_provider=lambda: VALID_INGEST_TOKEN, + ) + good = _FakeClient(_FakeResponse(200, {"count": 4})) + with patch("openadapt_tray.hosted.httpx.Client", return_value=good): + poller._handle_result(poller.poll_once()) + bad = _FakeClient(_FakeResponse(200, {"total": 7})) + with patch("openadapt_tray.hosted.httpx.Client", return_value=bad): + poller._handle_result(poller.poll_once()) + # 4 was reported once; the unreadable body reported nothing at all. + assert counts == [4] + + +class TestNotificationDelivery: + """The notifier's answer about delivery must be acted on, not discarded. + + PR #29 made ``_show_windows`` return False when the PowerShell toast never + appeared. That honest answer was then thrown away here: ``_last_count`` was + advanced regardless, recording the user as informed about breaks they were + never shown, and suppressing every retry until the count rose again. + """ + + def _poller(self, notifier): + return HostedPoller( + make_config(), + on_count=lambda r: None, + notifier=notifier, + token_provider=lambda: "t", + ) + + def test_undelivered_notification_is_retried_on_the_next_poll(self): + notifier = MagicMock() + notifier.show.return_value = False # e.g. WinRT toast unavailable + poller = self._poller(notifier) + + poller._handle_result(CountResult(count=3)) + assert notifier.show.call_count == 1 + + # Same count again: the user still has not been told, so try again. + poller._handle_result(CountResult(count=3)) + assert notifier.show.call_count == 2 + + def test_delivered_notification_is_not_repeated(self): + notifier = MagicMock() + notifier.show.return_value = True + poller = self._poller(notifier) + + poller._handle_result(CountResult(count=3)) + poller._handle_result(CountResult(count=3)) + assert notifier.show.call_count == 1 + + def test_raising_notifier_is_treated_as_undelivered(self): + notifier = MagicMock() + notifier.show.side_effect = RuntimeError("no notification daemon") + poller = self._poller(notifier) + + poller._handle_result(CountResult(count=2)) + poller._handle_result(CountResult(count=2)) + assert notifier.show.call_count == 2 + + def test_fire_notification_reports_delivery(self): + notifier = MagicMock() + notifier.show.return_value = False + assert self._poller(notifier)._fire_notification(1) is False + notifier.show.return_value = True + assert self._poller(notifier)._fire_notification(1) is True + + def test_no_notifier_is_not_a_delivery(self): + poller = HostedPoller( + make_config(), on_count=lambda r: None, token_provider=lambda: "t" + ) + assert poller._fire_notification(1) is False + + +class TestCredentialExpiryWarning: + def _credential( + self, + *, + expiring_soon=True, + expires_in_days=8, + expires_at="2026-08-05T12:00:00Z", + ): + return parse_credential_status( + { + "expires_at": expires_at, + "expires_in_days": expires_in_days, + "expiring_soon": expiring_soon, + "legacy_non_expiring": False, + "warning_days": 14, + } + ) + + def test_server_decision_controls_warning_at_day_fourteen(self, tmp_path): + notifier = MagicMock() + notifier.show.return_value = True + poller = HostedPoller( + make_config(), + on_count=lambda r: None, + notifier=notifier, + token_provider=lambda: "t", + warning_store=CredentialWarningStore(tmp_path / "warnings.json"), + ) + poller._handle_result( + CountResult( + count=0, + credential=self._credential( + expiring_soon=False, expires_in_days=14 + ), + credential_identity="credential-a", + ) + ) + + notifier.show.assert_not_called() + + def test_delivered_warning_survives_poller_restart(self, tmp_path): + path = tmp_path / "warnings.json" + notifier = MagicMock() + notifier.show.return_value = True + raw_token = "oai_ingest_secret-value" + result = CountResult( + count=0, + credential=self._credential(), + credential_identity=credential_identity( + "https://example.test", raw_token + ), + ) + + first = HostedPoller( + make_config(), + on_count=lambda r: None, + notifier=notifier, + token_provider=lambda: "t", + warning_store=CredentialWarningStore(path), + ) + first._handle_result(result) + second = HostedPoller( + make_config(), + on_count=lambda r: None, + notifier=notifier, + token_provider=lambda: "t", + warning_store=CredentialWarningStore(path), + ) + second._handle_result(result) + + notifier.show.assert_called_once() + assert raw_token not in path.read_text() + + def test_new_identity_or_expiry_can_warn_again(self, tmp_path): + notifier = MagicMock() + notifier.show.return_value = True + store = CredentialWarningStore(tmp_path / "warnings.json") + poller = HostedPoller( + make_config(), + on_count=lambda r: None, + notifier=notifier, + token_provider=lambda: "t", + warning_store=store, + ) + credential = self._credential() + + poller._handle_result( + CountResult( + count=0, + credential=credential, + credential_identity="credential-a", + ) + ) + poller._handle_result( + CountResult( + count=0, + credential=credential, + credential_identity="credential-b", + ) + ) + poller._handle_result( + CountResult( + count=0, + credential=self._credential( + expires_at="2026-08-06T12:00:00Z" + ), + credential_identity="credential-b", + ) + ) + + assert notifier.show.call_count == 3 + + +class TestBreakClickReportsFailure: + """A click that opened nothing must not report itself as routed.""" + + def test_returns_false_when_no_browser_could_be_opened(self): + cfg = make_config(deployment_lane="cloud") + with patch("openadapt_tray.hosted.webbrowser.open", return_value=False): + assert route_break_click(cfg, ipc_client=None) is False + + def test_returns_true_when_the_browser_opened(self): + cfg = make_config(deployment_lane="cloud") + with patch("openadapt_tray.hosted.webbrowser.open", return_value=True): + assert route_break_click(cfg, ipc_client=None) is True + + def test_byoc_desktop_route_returns_true(self): + cfg = make_config(deployment_lane="byoc") + assert route_break_click(cfg, ipc_client=MagicMock()) is True diff --git a/packages/tray/tests/test_ipc.py b/packages/tray/tests/test_ipc.py new file mode 100644 index 0000000000..ca540496cd --- /dev/null +++ b/packages/tray/tests/test_ipc.py @@ -0,0 +1,256 @@ +"""Tests for the desktop IPC client + discovery.""" + +import json +from unittest.mock import patch + +from openadapt_tray.ipc import ( + DEFAULT_DISCOVERY_PATH, + SUPPORTED_PROTOCOL_VERSION, + DesktopEndpoint, + IPCClient, + IPCMessage, + IPCMessageType, +) + + +class TestIPCMessageTypes: + """Tests for the extended IPC message vocabulary (spec §3d).""" + + def test_command_types_present(self): + """All tray→desktop command types exist.""" + for name in [ + "START_RECORDING", + "STOP_RECORDING", + "GET_STATUS", + "OPEN_WORKFLOW_LIBRARY", + "OPEN_TEACH", + "PAUSE_SYNC", + "RESUME_SYNC", + ]: + assert hasattr(IPCMessageType, name) + + def test_event_types_present(self): + """All desktop→tray event types exist.""" + for name in [ + "RECORDING_STARTED", + "RECORDING_STOPPED", + "RECORDING_ERROR", + "STATUS_UPDATE", + "COMPILE_PROGRESS", + "SYNC_STATE", + "BREAK_COUNT", + ]: + assert hasattr(IPCMessageType, name) + + def test_rl_training_type_removed(self): + """The retired RL training-progress type must be gone.""" + assert not hasattr(IPCMessageType, "TRAINING_PROGRESS") + + +class TestIPCMessageSerialization: + """Tests for message (de)serialization including the session token.""" + + def test_roundtrip_with_token(self): + """A command with a token round-trips through JSON.""" + msg = IPCMessage( + type=IPCMessageType.START_RECORDING, + data={"name": "w"}, + token="sess-123", + ) + restored = IPCMessage.from_json(msg.to_json()) + assert restored.type == IPCMessageType.START_RECORDING + assert restored.data == {"name": "w"} + assert restored.token == "sess-123" + + def test_token_omitted_when_absent(self): + """No token key is emitted when the message has none.""" + msg = IPCMessage(type=IPCMessageType.GET_STATUS) + assert "token" not in json.loads(msg.to_json()) + + def test_new_types_roundtrip(self): + """New event types deserialize correctly.""" + for mt in [ + IPCMessageType.COMPILE_PROGRESS, + IPCMessageType.SYNC_STATE, + IPCMessageType.BREAK_COUNT, + ]: + restored = IPCMessage.from_json( + IPCMessage(type=mt, data={"x": 1}).to_json() + ) + assert restored.type == mt + + +class TestDesktopEndpointDiscovery: + """Tests for the ~/.openadapt/desktop_ipc.json discovery file.""" + + def test_load_missing_returns_none(self, tmp_path): + """Missing discovery file → None (desktop not running).""" + assert DesktopEndpoint.load(tmp_path / "nope.json") is None + + def test_load_valid(self, tmp_path): + """A valid discovery file yields host/port/token.""" + f = tmp_path / "desktop_ipc.json" + f.write_text( + json.dumps( + { + "protocol_version": SUPPORTED_PROTOCOL_VERSION, + "host": "127.0.0.1", + "port": 51234, + "token": "sess-abc", + } + ) + ) + ep = DesktopEndpoint.load(f) + assert ep is not None + assert ep.protocol_version == SUPPORTED_PROTOCOL_VERSION + assert ep.host == "127.0.0.1" + assert ep.port == 51234 + assert ep.token == "sess-abc" + + def test_load_invalid_json_returns_none(self, tmp_path): + """Corrupt discovery file → None, not a crash.""" + f = tmp_path / "desktop_ipc.json" + f.write_text("{ not json") + assert DesktopEndpoint.load(f) is None + + def test_load_without_port_returns_none(self, tmp_path): + """A discovery file missing the port is unusable.""" + f = tmp_path / "desktop_ipc.json" + f.write_text(json.dumps({"protocol_version": SUPPORTED_PROTOCOL_VERSION})) + assert DesktopEndpoint.load(f) is None + + def test_load_without_protocol_version_returns_none(self, tmp_path): + """An unversioned endpoint cannot define the command contract.""" + f = tmp_path / "desktop_ipc.json" + f.write_text(json.dumps({"port": 51234, "token": "sess-abc"})) + assert DesktopEndpoint.load(f) is None + + def test_load_with_unknown_protocol_version_returns_none(self, tmp_path): + """A future Desktop protocol must not be guessed by an older tray.""" + f = tmp_path / "desktop_ipc.json" + f.write_text( + json.dumps( + { + "protocol_version": SUPPORTED_PROTOCOL_VERSION + 1, + "port": 51234, + "token": "sess-abc", + } + ) + ) + assert DesktopEndpoint.load(f) is None + + def test_default_discovery_path(self): + """The default discovery path is under ~/.openadapt/.""" + assert DEFAULT_DISCOVERY_PATH.name == "desktop_ipc.json" + assert ".openadapt" in str(DEFAULT_DISCOVERY_PATH) + + +class TestIPCClientDiscovery: + """Tests for building/refreshing the client from discovery.""" + + def test_from_discovery_missing_returns_none(self, tmp_path): + """No discovery file → from_discovery returns None.""" + assert IPCClient.from_discovery(tmp_path / "nope.json") is None + + def test_from_discovery_configures_client(self, tmp_path): + """from_discovery wires host/port/token onto the client.""" + f = tmp_path / "desktop_ipc.json" + f.write_text( + json.dumps( + { + "protocol_version": SUPPORTED_PROTOCOL_VERSION, + "port": 40000, + "token": "tok", + } + ) + ) + client = IPCClient.from_discovery(f) + assert client is not None + assert client.port == 40000 + assert client.token == "tok" + + def test_refresh_from_discovery(self, tmp_path): + """refresh_from_discovery updates an existing client in place.""" + f = tmp_path / "desktop_ipc.json" + f.write_text( + json.dumps( + { + "protocol_version": SUPPORTED_PROTOCOL_VERSION, + "port": 12345, + "token": "t1", + } + ) + ) + client = IPCClient() + assert client.refresh_from_discovery(f) is True + assert client.port == 12345 + assert client.token == "t1" + + def test_send_attaches_token(self): + """send() attaches the client token to a tokenless message.""" + client = IPCClient(token="sess-xyz") + sent = {} + + class FakeSock: + def sendall(self, data): + sent["data"] = data + + client._socket = FakeSock() + assert client.send(IPCMessage(type=IPCMessageType.PAUSE_SYNC)) is True + payload = json.loads(sent["data"].decode().strip()) + assert payload["token"] == "sess-xyz" + assert payload["type"] == "pause_sync" + + def test_command_helpers_send_correct_types(self): + """The new command helpers emit the right message types.""" + client = IPCClient(token="t") + seen = [] + + class FakeSock: + def sendall(self, data): + seen.append(json.loads(data.decode().strip())["type"]) + + client._socket = FakeSock() + client.send_open_workflow_library() + client.send_open_teach("wf-1") + client.send_pause_sync() + client.send_resume_sync() + assert seen == [ + "open_workflow_library", + "open_teach", + "pause_sync", + "resume_sync", + ] + + def test_connect_requests_status_as_the_first_authenticated_command(self): + """A new connection starts from Desktop state, not Tray defaults.""" + sent = [] + + class FakeSock: + def settimeout(self, _timeout): + pass + + def connect(self, _endpoint): + pass + + def recv(self, _size): + return b"" + + def sendall(self, data): + sent.append(json.loads(data.decode().strip())) + + def close(self): + pass + + client = IPCClient(token="session-token") + with patch("openadapt_tray.ipc.socket.socket", return_value=FakeSock()): + assert client.connect() is True + client.close() + + assert sent == [ + { + "type": "get_status", + "data": None, + "token": "session-token", + } + ] diff --git a/packages/tray/tests/test_keychain.py b/packages/tray/tests/test_keychain.py new file mode 100644 index 0000000000..a581f615b4 --- /dev/null +++ b/packages/tray/tests/test_keychain.py @@ -0,0 +1,85 @@ +"""Tests for OS keychain access to the ingest token.""" + +from unittest.mock import patch + +from keyring.errors import PasswordDeleteError + +from openadapt_tray import keychain + + +class TestIngestToken: + def test_env_var_takes_precedence(self, monkeypatch): + """OPENADAPT_INGEST_TOKEN env var wins over the keychain.""" + monkeypatch.setenv(keychain.ENV_INGEST_TOKEN, "env-token") + with patch.object(keychain, "keyring") as mock_keyring: + token = keychain.get_ingest_token("https://example.test") + assert token == "env-token" + mock_keyring.get_password.assert_not_called() + + def test_reads_from_keyring(self, monkeypatch): + """Falls back to the keychain when no env var is set.""" + monkeypatch.delenv(keychain.ENV_INGEST_TOKEN, raising=False) + with patch.object(keychain, "KEYRING_AVAILABLE", True), patch.object( + keychain, "keyring" + ) as mock_keyring: + mock_keyring.get_password.return_value = "kc-token" + token = keychain.get_ingest_token("https://example.test/") + assert token == "kc-token" + # Account key is the host with any trailing slash stripped. + mock_keyring.get_password.assert_called_once_with( + keychain.KEYCHAIN_SERVICE, "https://example.test" + ) + + def test_missing_returns_none(self, monkeypatch): + monkeypatch.delenv(keychain.ENV_INGEST_TOKEN, raising=False) + with patch.object(keychain, "KEYRING_AVAILABLE", True), patch.object( + keychain, "keyring" + ) as mock_keyring: + mock_keyring.get_password.return_value = None + assert keychain.get_ingest_token("https://example.test") is None + + def test_shared_service_name_matches_desktop(self): + """The keychain service must match the desktop app's store (§3a).""" + assert keychain.KEYCHAIN_SERVICE == "ai.openadapt.desktop" + + def test_has_ingest_token(self, monkeypatch): + monkeypatch.setenv(keychain.ENV_INGEST_TOKEN, "x") + assert keychain.has_ingest_token("https://example.test") is True + + +class TestClearIngestToken: + """``clear_ingest_token`` promises "removed (or already absent)".""" + + def test_removed_returns_true(self): + with patch.object(keychain, "KEYRING_AVAILABLE", True), patch.object( + keychain, "keyring" + ) as mock_keyring: + mock_keyring.errors.PasswordDeleteError = PasswordDeleteError + assert keychain.clear_ingest_token("https://example.test") is True + + def test_absent_entry_is_success(self): + """Deleting a token that was never stored is a no-op success. + + keyring raises ``PasswordDeleteError`` when the entry is absent. This + used to be caught and reported as failure, so signing out on a machine + that had never signed in looked like a failed sign-out. + """ + with patch.object(keychain, "KEYRING_AVAILABLE", True), patch.object( + keychain, "keyring" + ) as mock_keyring: + mock_keyring.errors.PasswordDeleteError = PasswordDeleteError + mock_keyring.delete_password.side_effect = PasswordDeleteError("absent") + assert keychain.clear_ingest_token("https://example.test") is True + + def test_backend_error_returns_false(self): + """A real backend failure is still a failure.""" + with patch.object(keychain, "KEYRING_AVAILABLE", True), patch.object( + keychain, "keyring" + ) as mock_keyring: + mock_keyring.errors.PasswordDeleteError = PasswordDeleteError + mock_keyring.delete_password.side_effect = RuntimeError("backend down") + assert keychain.clear_ingest_token("https://example.test") is False + + def test_no_keyring_backend_returns_false(self): + with patch.object(keychain, "KEYRING_AVAILABLE", False): + assert keychain.clear_ingest_token("https://example.test") is False diff --git a/packages/tray/tests/test_menu.py b/packages/tray/tests/test_menu.py new file mode 100644 index 0000000000..566ec268da --- /dev/null +++ b/packages/tray/tests/test_menu.py @@ -0,0 +1,405 @@ +"""Tests for menu construction.""" + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from openadapt_tray.menu import CaptureInfo, MenuBuilder +from openadapt_tray.platform.base import DialogUnavailableError +from openadapt_tray.state import ( + AppState, + CredentialState, + CredentialStatus, + SyncState, + TrayState, +) + + +class TestCaptureInfo: + """Tests for CaptureInfo dataclass.""" + + def test_capture_info_creation(self): + """Test CaptureInfo creation.""" + info = CaptureInfo( + name="test_capture", + path="/path/to/capture", + timestamp="2024-01-15 10:30", + ) + assert info.name == "test_capture" + assert info.path == "/path/to/capture" + assert info.timestamp == "2024-01-15 10:30" + + +class TestMenuBuilder: + """Tests for MenuBuilder class.""" + + def create_mock_app(self, state=None): + """Create a mock TrayApplication.""" + app = MagicMock() + app.state = MagicMock() + app.state.current = state or AppState(state=TrayState.IDLE) + app.config = MagicMock() + app.config.hotkeys = MagicMock() + app.config.hotkeys.toggle_recording = "++r" + app.config.dashboard_port = 8080 + app.config.get_captures_path.return_value = MagicMock(exists=lambda: False) + app.platform = MagicMock() + app.notifications = MagicMock() + return app + + def test_build_returns_menu(self): + """Test that build returns a pystray Menu.""" + + app = self.create_mock_app() + builder = MenuBuilder(app) + + menu = builder.build() + + # pystray.Menu is the expected type + assert menu is not None + + def test_recording_item_when_idle(self): + """Test recording item shows 'Start Recording' when idle.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE)) + builder = MenuBuilder(app) + + item = builder._build_recording_item(app.state.current) + + assert "Start Recording" in str(item.text) + + @pytest.mark.parametrize( + ("platform", "label"), + [ + ("darwin", "Start Recording (⌃⇧R)"), + ("win32", "Start Recording (Ctrl+Shift+R)"), + ("linux", "Start Recording (Ctrl+Shift+R)"), + ], + ) + def test_recording_item_shows_formatted_hotkey(self, platform, label): + """The idle label shows the hotkey as people read it, not pynput syntax.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE)) + builder = MenuBuilder(app) + + with patch("sys.platform", platform): + item = builder._build_recording_item(app.state.current) + + text = str(item.text) + assert "<" not in text + assert ">" not in text + assert text == label + + def test_recording_item_with_null_hotkey_still_builds(self): + """A null toggle_recording in tray.json must not break the menu.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE)) + app.config.hotkeys.toggle_recording = None + builder = MenuBuilder(app) + + item = builder._build_recording_item(app.state.current) + + assert str(item.text) == "Start Recording (None)" + assert builder.build() is not None + + def test_recording_item_when_recording(self): + """Test recording item shows 'Stop Recording' when recording.""" + app = self.create_mock_app( + AppState(state=TrayState.RECORDING, current_capture="test") + ) + builder = MenuBuilder(app) + + item = builder._build_recording_item(app.state.current) + + assert "Stop Recording" in str(item.text) + + def test_recording_item_disabled_when_starting(self): + """Test recording item is disabled when starting.""" + app = self.create_mock_app(AppState(state=TrayState.RECORDING_STARTING)) + builder = MenuBuilder(app) + + item = builder._build_recording_item(app.state.current) + + assert "Starting" in str(item.text) + + def test_recording_item_when_compiling(self): + """Test recording item shows 'Compiling' when compiling.""" + app = self.create_mock_app(AppState(state=TrayState.COMPILING)) + builder = MenuBuilder(app) + + item = builder._build_recording_item(app.state.current) + + assert "Compiling" in str(item.text) + + def test_break_item_absent_without_breaks(self): + """No needs-attention item when there are no breaks.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE, break_count=0)) + builder = MenuBuilder(app) + + assert builder._build_break_item(app.state.current) is None + + def test_break_item_present_with_breaks(self): + """Needs-attention item shows the count when breaks exist.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE, break_count=2)) + builder = MenuBuilder(app) + + item = builder._build_break_item(app.state.current) + assert item is not None + assert "2" in str(item.text) + assert "need attention" in str(item.text) + + def test_sync_item_offline_disabled(self): + """Sync item is disabled and labelled offline when offline.""" + from openadapt_tray.state import SyncState + + app = self.create_mock_app( + AppState(state=TrayState.IDLE, sync_state=SyncState.OFFLINE) + ) + builder = MenuBuilder(app) + + item = builder._build_sync_item(app.state.current) + assert "offline" in str(item.text).lower() + + def test_sync_item_pause_when_online(self): + """Sync item offers Pause Sync when online.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE)) + builder = MenuBuilder(app) + + item = builder._build_sync_item(app.state.current) + assert "Pause Sync" in str(item.text) + + def test_sync_item_resume_when_paused(self): + """A paused queue offers Resume Sync, so the pause can be undone.""" + app = self.create_mock_app( + AppState(state=TrayState.IDLE, sync_state=SyncState.PAUSED) + ) + builder = MenuBuilder(app) + + item = builder._build_sync_item(app.state.current) + assert "Resume Sync" in str(item.text) + item(None) + app.resume_sync.assert_called_once() + + @pytest.mark.parametrize( + ("credential", "label"), + [ + (CredentialStatus.signed_out(), "sign in"), + (CredentialStatus.unknown(), "status unavailable"), + ( + CredentialStatus( + state=CredentialState.EXPIRING, + expires_at="2026-08-05T12:00:00Z", + expires_in_days=8, + warning_days=14, + ), + "expires in 8 days", + ), + ], + ) + def test_account_item_exposes_actionable_status(self, credential, label): + app = self.create_mock_app(AppState(credential=credential)) + item = MenuBuilder(app)._build_account_item(app.state.current) + + assert label in str(item.text).lower() + + def test_get_recent_captures_empty(self): + """Test get_recent_captures returns empty list when no captures.""" + app = self.create_mock_app() + builder = MenuBuilder(app) + + captures = builder._get_recent_captures() + + assert captures == [] + + def test_open_desktop_app_delegates(self): + """Test the desktop-app menu action delegates to the app.""" + app = self.create_mock_app() + builder = MenuBuilder(app) + + builder._open_desktop_app() + + app.open_desktop_app.assert_called_once() + + def test_open_cloud_dashboard_delegates(self): + """Test the cloud-dashboard menu action delegates to the app.""" + app = self.create_mock_app() + builder = MenuBuilder(app) + + builder._open_cloud_dashboard() + + app.open_cloud_dashboard.assert_called_once() + + def test_login_delegates(self): + """Test the login menu action delegates to the app.""" + app = self.create_mock_app() + builder = MenuBuilder(app) + + builder._login() + + app.login.assert_called_once() + + def test_build_full_menu(self): + """The full menu builds without error in an idle state.""" + app = self.create_mock_app(AppState(state=TrayState.IDLE, break_count=1)) + builder = MenuBuilder(app) + menu = builder.build() + assert menu is not None + + def test_quit_calls_app_quit(self): + """Test quit calls app.quit.""" + app = self.create_mock_app() + builder = MenuBuilder(app) + + builder._quit() + + app.quit.assert_called_once() + + +class TestCapturesSubmenuDistinguishesEmptyFromUnreadable: + """"Nothing there" and "could not look" must not render the same. + + ``_get_recent_captures`` used to swallow every exception and return ``[]``, + so an unreadable captures directory (permissions, a dead mount) produced + the same reassuring "No captures" entry as a directory that really was + empty. + """ + + def _app_with_captures_dir(self, captures_dir): + app = MagicMock() + app.config.get_captures_path.return_value = captures_dir + return app + + def test_empty_directory_says_no_captures(self, tmp_path): + builder = MenuBuilder(self._app_with_captures_dir(tmp_path)) + item = builder._build_captures_submenu() + labels = [str(i.text) for i in item.submenu.items] + assert "No captures" in labels + + def test_unreadable_directory_says_so(self): + captures_dir = MagicMock() + captures_dir.exists.return_value = True + captures_dir.iterdir.side_effect = PermissionError("permission denied") + builder = MenuBuilder(self._app_with_captures_dir(captures_dir)) + + item = builder._build_captures_submenu() + + labels = [str(i.text) for i in item.submenu.items] + assert "Could not read captures" in labels + assert "No captures" not in labels + + def test_get_recent_captures_raises_rather_than_returning_empty(self): + captures_dir = MagicMock() + captures_dir.exists.return_value = True + captures_dir.iterdir.side_effect = PermissionError("permission denied") + builder = MenuBuilder(self._app_with_captures_dir(captures_dir)) + + with pytest.raises(OSError): + builder._get_recent_captures() + + +class TestViewCapture: + """View opens the capture folder and says so when nothing opened.""" + + def test_view_opens_the_folder_without_the_launcher(self): + builder = MenuBuilder(MagicMock()) + with patch( + "openadapt_tray.menu.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, b"", b""), + ) as run: + builder._view_capture("/tmp/capture") + run.assert_called_once() + command = run.call_args.args[0] + assert command[-1] == "/tmp/capture" + assert "openadapt" not in command + builder.app.notifications.show.assert_not_called() + + @pytest.mark.parametrize( + ("platform", "opener"), + [("darwin", "open"), ("linux", "xdg-open"), ("win32", "explorer")], + ) + def test_uses_the_platform_file_browser(self, platform, opener): + builder = MenuBuilder(MagicMock()) + with patch("openadapt_tray.menu.sys.platform", platform), patch( + "openadapt_tray.menu.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, b"", b""), + ) as run: + assert builder._open_in_file_browser("/tmp/capture") is True + assert run.call_args.args[0] == [opener, "/tmp/capture"] + + def test_missing_file_browser_is_reported(self): + builder = MenuBuilder(MagicMock()) + with patch("openadapt_tray.menu.sys.platform", "linux"), patch( + "openadapt_tray.menu.subprocess.run", side_effect=FileNotFoundError + ): + assert builder._open_in_file_browser("/tmp/capture") is False + builder.app.notifications.show.assert_called_once() + assert "/tmp/capture" in builder.app.notifications.show.call_args.args[1] + + def test_failed_file_browser_is_reported(self): + builder = MenuBuilder(MagicMock()) + with patch("openadapt_tray.menu.sys.platform", "darwin"), patch( + "openadapt_tray.menu.subprocess.run", + return_value=subprocess.CompletedProcess([], 1, b"", b"no such file"), + ): + assert builder._open_in_file_browser("/tmp/capture") is False + builder.app.notifications.show.assert_called_once() + + def test_explorer_exit_status_is_not_a_failure(self): + # explorer.exe exits 1 even after it opened the folder. + builder = MenuBuilder(MagicMock()) + with patch("openadapt_tray.menu.sys.platform", "win32"), patch( + "openadapt_tray.menu.subprocess.run", + return_value=subprocess.CompletedProcess([], 1, b"", b""), + ): + assert builder._open_in_file_browser("C:/capture") is True + builder.app.notifications.show.assert_not_called() + + +class TestOpenSettings: + """A Settings click that opened nothing says where the file is.""" + + def test_opened_settings_stay_quiet(self): + app = MagicMock() + app.platform.open_settings_dialog.return_value = True + MenuBuilder(app)._open_settings() + app.notifications.show.assert_not_called() + + def test_unopened_settings_name_the_file(self): + app = MagicMock() + app.platform.open_settings_dialog.return_value = False + app.config.config_path.return_value = "/home/me/.config/openadapt/tray.json" + MenuBuilder(app)._open_settings() + app.notifications.show.assert_called_once() + assert "tray.json" in app.notifications.show.call_args.args[1] + + def test_unwritable_settings_file_is_reported(self): + app = MagicMock() + app.platform.open_settings_dialog.side_effect = PermissionError("read-only") + MenuBuilder(app)._open_settings() + app.notifications.show.assert_called_once() + + +class TestDeleteCaptureWhenNoDialogCanBeShown: + """"We never asked" must not be silently read as "the user said no".""" + + def test_unavailable_dialog_deletes_nothing_and_says_so(self): + app = MagicMock() + app.platform.confirm_dialog.side_effect = DialogUnavailableError("no display") + builder = MenuBuilder(app) + + with patch("shutil.rmtree") as rmtree: + builder._delete_capture("/tmp/capture", "cap") + + rmtree.assert_not_called() + app.notifications.show.assert_called_once() + title = app.notifications.show.call_args[0][0] + assert "confirm" in title.lower() + + def test_declining_deletes_nothing_and_stays_quiet(self): + app = MagicMock() + app.platform.confirm_dialog.return_value = False + builder = MenuBuilder(app) + + with patch("shutil.rmtree") as rmtree: + builder._delete_capture("/tmp/capture", "cap") + + rmtree.assert_not_called() + app.notifications.show.assert_not_called() diff --git a/packages/tray/tests/test_menu_labels.py b/packages/tray/tests/test_menu_labels.py new file mode 100644 index 0000000000..c7785d6d66 --- /dev/null +++ b/packages/tray/tests/test_menu_labels.py @@ -0,0 +1,56 @@ +"""Labels in the menu as people see it, built from the default settings.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from openadapt_tray.config import TrayConfig +from openadapt_tray.menu import MenuBuilder +from openadapt_tray.state import AppState, CredentialStatus, TrayState + + +def _labels(menu): + """Return every item label in a pystray menu, submenus included.""" + labels = [] + for item in menu.items: + if item is menu.SEPARATOR: + continue + labels.append(str(item.text)) + if item.submenu is not None: + labels += _labels(item.submenu) + return labels + + +@pytest.mark.parametrize( + "credential", + [CredentialStatus(), CredentialStatus.signed_out(), CredentialStatus.unknown()], +) +@pytest.mark.parametrize( + ("platform", "first"), + [ + ("darwin", "Start Recording (⌃⇧R)"), + ("win32", "Start Recording (Ctrl+Shift+R)"), + ("linux", "Start Recording (Ctrl+Shift+R)"), + ], +) +def test_default_idle_menu_shows_readable_labels(tmp_path, platform, first, credential): + """The menu from a fresh install shows no pynput syntax and no Login item. + + The tray once showed "Start Recording (++r)" and a + "Login..." item. This builds the whole menu from the real default + settings, not a mock, and checks every label. + """ + config = TrayConfig(captures_directory=str(tmp_path / "captures")) + app = MagicMock() + app.config = config + app.state.current = AppState(state=TrayState.IDLE, credential=credential) + + with patch("sys.platform", platform): + labels = _labels(MenuBuilder(app).build()) + + assert labels[0] == first + assert [label for label in labels if "<" in label or ">" in label] == [] + assert [label for label in labels if label.lower().startswith("login")] == [] + assert any(label.startswith("Account: ") for label in labels) + # The setting and the hotkey registration keep pynput's syntax. + assert config.hotkeys.toggle_recording == "++r" diff --git a/packages/tray/tests/test_notifications.py b/packages/tray/tests/test_notifications.py new file mode 100644 index 0000000000..8b00a95625 --- /dev/null +++ b/packages/tray/tests/test_notifications.py @@ -0,0 +1,125 @@ +"""Tests for the notification fallback backends. + +The fallback paths are the ones that report success back to the caller, so the +contract under test is: ``_show_*`` returns True only when the notification was +actually delivered. +""" + +import subprocess +from unittest.mock import MagicMock, patch + +from openadapt_tray.notifications import NotificationManager + + +def _manager() -> NotificationManager: + """Build a manager without touching any real notification backend.""" + with patch.object(NotificationManager, "_detect_backend", return_value="windows"): + return NotificationManager() + + +class TestShowWindows: + """``_show_windows`` falls back pystray balloon -> PowerShell toast.""" + + def test_tray_icon_balloon_is_preferred(self): + manager = _manager() + manager.set_tray_icon(MagicMock()) + with patch("subprocess.run") as run: + assert manager._show_windows("t", "b", None, 5000) is True + manager._tray_icon.notify.assert_called_once_with("b", "t") + run.assert_not_called() + + def test_balloon_failure_falls_back_to_toast(self): + manager = _manager() + icon = MagicMock() + icon.notify.side_effect = RuntimeError("no balloon") + manager.set_tray_icon(icon) + with patch("subprocess.run") as run: + run.return_value = subprocess.CompletedProcess([], 0, b"", b"") + assert manager._show_windows("t", "b", None, 5000) is True + run.assert_called_once() + + def test_failed_toast_reports_failure(self): + """A non-zero PowerShell exit must NOT be reported as delivered. + + This used to ``return True`` unconditionally, so a machine where the + WinRT toast API is unavailable reported every notification as shown. + """ + manager = _manager() + with patch("subprocess.run") as run: + run.return_value = subprocess.CompletedProcess([], 1, b"", b"boom") + assert manager._show_windows("t", "b", None, 5000) is False + + def test_toast_exception_reports_failure(self): + manager = _manager() + with patch("subprocess.run", side_effect=FileNotFoundError("powershell")): + assert manager._show_windows("t", "b", None, 5000) is False + + +class TestShowLinux: + def test_returncode_drives_result(self): + manager = _manager() + with patch("subprocess.run") as run: + run.return_value = subprocess.CompletedProcess([], 0, b"", b"") + assert manager._show_linux("t", "b", None) is True + run.return_value = subprocess.CompletedProcess([], 1, b"", b"") + assert manager._show_linux("t", "b", None) is False + + +class TestDesktopNotifierDelivery: + def _manager(self): + manager = _manager() + manager._notifier = MagicMock() + manager._notifier.send.return_value = MagicMock() + manager._loop = MagicMock() + manager._loop.is_running.return_value = True + return manager + + @patch("openadapt_tray.notifications.asyncio.run_coroutine_threadsafe") + @patch("openadapt_tray.notifications.asyncio.get_running_loop") + @patch("openadapt_tray.notifications.Urgency", create=True) + def test_running_loop_backend_failure_is_not_delivery( + self, _urgency, get_running_loop, run_coroutine_threadsafe + ): + manager = self._manager() + get_running_loop.side_effect = RuntimeError("no loop in caller thread") + future = run_coroutine_threadsafe.return_value + future.result.side_effect = RuntimeError("notification daemon rejected request") + + assert ( + manager._show_desktop_notifier("t", "b", None, None, "normal", None, None) + is False + ) + + future.cancel.assert_called_once() + + @patch("openadapt_tray.notifications.asyncio.run_coroutine_threadsafe") + @patch("openadapt_tray.notifications.asyncio.get_running_loop") + @patch("openadapt_tray.notifications.Urgency", create=True) + def test_running_loop_waits_for_backend_confirmation( + self, _urgency, get_running_loop, run_coroutine_threadsafe + ): + manager = self._manager() + get_running_loop.side_effect = RuntimeError("no loop in caller thread") + + assert ( + manager._show_desktop_notifier("t", "b", None, None, "normal", None, None) + is True + ) + + run_coroutine_threadsafe.return_value.result.assert_called_once_with(timeout=5) + + @patch("openadapt_tray.notifications.asyncio.run_coroutine_threadsafe") + @patch("openadapt_tray.notifications.asyncio.get_running_loop") + @patch("openadapt_tray.notifications.Urgency", create=True) + def test_sync_api_refuses_unconfirmed_delivery_on_its_event_loop( + self, _urgency, get_running_loop, run_coroutine_threadsafe + ): + manager = self._manager() + get_running_loop.return_value = manager._loop + + assert ( + manager._show_desktop_notifier("t", "b", None, None, "normal", None, None) + is False + ) + + run_coroutine_threadsafe.assert_not_called() diff --git a/packages/tray/tests/test_platform.py b/packages/tray/tests/test_platform.py new file mode 100644 index 0000000000..30c079fc33 --- /dev/null +++ b/packages/tray/tests/test_platform.py @@ -0,0 +1,383 @@ +"""Tests for platform detection and handlers.""" + +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from openadapt_tray.platform import get_platform_handler +from openadapt_tray.platform.base import DialogUnavailableError, PlatformHandler +from openadapt_tray.platform.linux import LinuxHandler +from openadapt_tray.platform.macos import MacOSHandler +from openadapt_tray.platform.windows import WindowsHandler + + +class TestPlatformDetection: + """Tests for platform detection.""" + + @patch("sys.platform", "darwin") + def test_macos_detection(self): + """Test that macOS platform is detected.""" + from openadapt_tray.platform.macos import MacOSHandler + + handler = get_platform_handler() + assert isinstance(handler, MacOSHandler) + + @patch("sys.platform", "win32") + def test_windows_detection(self): + """Test that Windows platform is detected.""" + from openadapt_tray.platform.windows import WindowsHandler + + handler = get_platform_handler() + assert isinstance(handler, WindowsHandler) + + @patch("sys.platform", "linux") + def test_linux_detection(self): + """Test that Linux platform is detected.""" + from openadapt_tray.platform.linux import LinuxHandler + + handler = get_platform_handler() + assert isinstance(handler, LinuxHandler) + + +class TestPlatformHandlerBase: + """Tests for PlatformHandler base class behavior.""" + + def test_base_class_is_abstract(self): + """Test that PlatformHandler cannot be instantiated directly.""" + with pytest.raises(TypeError): + PlatformHandler() + + def test_supports_native_dialogs_default(self): + """Test default value for supports_native_dialogs.""" + + class TestHandler(PlatformHandler): + def setup(self): + pass + + def prompt_input(self, title, message): + return None + + def confirm_dialog(self, title, message): + return False + + def open_settings_dialog(self, config): + pass + + def open_training_dialog(self): + pass + + handler = TestHandler() + assert handler.supports_native_dialogs is True + + def test_supports_autostart_default(self): + """Test default value for supports_autostart.""" + + class TestHandler(PlatformHandler): + def setup(self): + pass + + def prompt_input(self, title, message): + return None + + def confirm_dialog(self, title, message): + return False + + def open_settings_dialog(self, config): + pass + + def open_training_dialog(self): + pass + + handler = TestHandler() + assert handler.supports_autostart is False + + def test_setup_autostart_returns_false_by_default(self): + """Test default setup_autostart returns False.""" + + class TestHandler(PlatformHandler): + def setup(self): + pass + + def prompt_input(self, title, message): + return None + + def confirm_dialog(self, title, message): + return False + + def open_settings_dialog(self, config): + pass + + def open_training_dialog(self): + pass + + handler = TestHandler() + assert handler.setup_autostart(True) is False + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only") +class TestMacOSHandler: + """Tests for macOS-specific handler.""" + + def test_setup_hides_dock(self): + """Test that setup attempts to hide from Dock.""" + from openadapt_tray.platform.macos import MacOSHandler + + handler = MacOSHandler() + # Should not raise even if AppKit not available + handler.setup() + + @patch("subprocess.run") + def test_prompt_input_calls_osascript(self, mock_run): + """Test that prompt_input uses osascript.""" + from openadapt_tray.platform.macos import MacOSHandler + + mock_run.return_value = MagicMock(returncode=0, stdout="test input\n") + + handler = MacOSHandler() + result = handler.prompt_input("Title", "Message") + + assert result == "test input" + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "osascript" + + @patch("subprocess.run") + def test_confirm_dialog_returns_true_on_ok(self, mock_run): + """Test that confirm_dialog returns True when OK clicked.""" + from openadapt_tray.platform.macos import MacOSHandler + + mock_run.return_value = MagicMock(returncode=0, stdout="OK\n") + + handler = MacOSHandler() + result = handler.confirm_dialog("Title", "Message") + + assert result is True + + @patch("subprocess.run") + def test_confirm_dialog_returns_false_on_cancel(self, mock_run): + """Test that confirm_dialog returns False when cancelled.""" + from openadapt_tray.platform.macos import MacOSHandler + + mock_run.return_value = MagicMock(returncode=1, stdout="") + + handler = MacOSHandler() + result = handler.confirm_dialog("Title", "Message") + + assert result is False + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows only") +class TestWindowsHandler: + """Tests for Windows-specific handler.""" + + def test_setup_does_nothing(self): + """Test that Windows setup completes without error.""" + from openadapt_tray.platform.windows import WindowsHandler + + handler = WindowsHandler() + handler.setup() # Should not raise + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux only") +class TestLinuxHandler: + """Tests for Linux-specific handler.""" + + def test_setup_does_nothing(self): + """Test that Linux setup completes without error.""" + from openadapt_tray.platform.linux import LinuxHandler + + handler = LinuxHandler() + handler.setup() # Should not raise + + +class TestLinuxDialogsSeparateFailureFromAnswer: + """A dialog tool that could not run must not speak for the user. + + zenity/kdialog exit 1 when the user cancels and 255 when the tool itself + fails (no DISPLAY, broken GTK). Treating every non-zero exit as the user's + answer both invented an answer AND skipped the remaining fallbacks. + """ + + def _result(self, returncode, stdout=""): + return subprocess.CompletedProcess([], returncode, stdout, "") + + def test_prompt_input_exit_1_is_a_real_cancel(self): + handler = LinuxHandler() + with patch("subprocess.run", return_value=self._result(1)) as run: + assert handler.prompt_input("t", "m") is None + # Cancel is an answer: no point asking kdialog the same question. + assert run.call_count == 1 + + def test_prompt_input_tool_failure_tries_the_next_tool(self): + handler = LinuxHandler() + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd[0]) + if cmd[0] == "zenity": + return self._result(255) # zenity could not show anything + return self._result(0, "typed name") + + with patch("subprocess.run", side_effect=fake_run): + assert handler.prompt_input("t", "m") == "typed name" + assert calls == ["zenity", "kdialog"] + + def test_prompt_input_raises_when_nothing_can_ask(self): + handler = LinuxHandler() + with patch("subprocess.run", return_value=self._result(255)), patch.dict( + sys.modules, {"tkinter": None} + ), pytest.raises(DialogUnavailableError): + handler.prompt_input("t", "m") + + def test_confirm_dialog_exit_1_is_a_real_no(self): + handler = LinuxHandler() + with patch("subprocess.run", return_value=self._result(1)) as run: + assert handler.confirm_dialog("t", "m") is False + assert run.call_count == 1 + + def test_confirm_dialog_tool_failure_tries_the_next_tool(self): + handler = LinuxHandler() + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd[0]) + if cmd[0] == "zenity": + return self._result(255) + return self._result(0) + + with patch("subprocess.run", side_effect=fake_run): + assert handler.confirm_dialog("t", "m") is True + assert calls == ["zenity", "kdialog"] + + def test_confirm_dialog_raises_rather_than_answering_no_for_the_user(self): + handler = LinuxHandler() + with patch("subprocess.run", return_value=self._result(255)), patch.dict( + sys.modules, {"tkinter": None} + ), pytest.raises(DialogUnavailableError): + handler.confirm_dialog("t", "m") + + +class TestWindowsDialogsSeparateFailureFromAnswer: + def test_confirm_dialog_raises_when_no_dialog_can_be_shown(self): + handler = WindowsHandler() + # Block BOTH mechanisms, on every OS: blocking ctypes matters on + # Windows runners, where the real MessageBoxW would block forever. + with patch.dict( + sys.modules, {"ctypes": None, "tkinter": None} + ), pytest.raises(DialogUnavailableError): + handler.confirm_dialog("t", "m") + + def test_prompt_input_raises_when_no_dialog_can_be_shown(self): + handler = WindowsHandler() + with patch.dict(sys.modules, {"tkinter": None}), pytest.raises( + DialogUnavailableError + ): + handler.prompt_input("t", "m") + + +class TestMacOSDialogsSeparateFailureFromAnswer: + """osascript exits 1 for a user cancel AND for an execution error.""" + + def _result(self, returncode, stdout="", stderr=""): + return subprocess.CompletedProcess([], returncode, stdout, stderr) + + def test_cancel_is_an_answer(self): + handler = MacOSHandler() + with patch("subprocess.run", return_value=self._result(1, "", "")): + assert handler.confirm_dialog("t", "m") is False + assert handler.prompt_input("t", "m") is None + + def test_execution_error_raises_instead_of_answering_for_the_user(self): + handler = MacOSHandler() + failure = self._result( + 1, "", "execution error: Not authorized to send Apple events (-1743)" + ) + with patch("subprocess.run", return_value=failure): + with pytest.raises(DialogUnavailableError): + handler.confirm_dialog("t", "m") + with pytest.raises(DialogUnavailableError): + handler.prompt_input("t", "m") + + def test_missing_osascript_raises(self): + handler = MacOSHandler() + with patch("subprocess.run", side_effect=FileNotFoundError("osascript")): + with pytest.raises(DialogUnavailableError): + handler.confirm_dialog("t", "m") + with pytest.raises(DialogUnavailableError): + handler.prompt_input("t", "m") + + def test_ok_is_still_ok(self): + handler = MacOSHandler() + with patch("subprocess.run", return_value=self._result(0, "OK", "")): + assert handler.confirm_dialog("t", "m") is True + + +class TestSettingsOpensTrayJson: + """Settings opens tray.json; it used to open a dead localhost:8080 page.""" + + def _config(self, tmp_path): + from openadapt_tray.config import TrayConfig + + config = TrayConfig() + path = tmp_path / "tray.json" + return config, path + + @pytest.mark.parametrize( + ("handler_class", "command"), + [(MacOSHandler, ["open", "-t"]), (LinuxHandler, ["xdg-open"])], + ) + def test_opens_the_file_and_reports_success( + self, tmp_path, handler_class, command + ): + from openadapt_tray.config import TrayConfig + + config, path = self._config(tmp_path) + with patch.object(TrayConfig, "config_path", return_value=path), patch( + "openadapt_tray.platform.base.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, b"", b""), + ) as run: + assert handler_class().open_settings_dialog(config) is True + assert run.call_args.args[0] == [*command, str(path)] + assert path.exists() + + def test_windows_starts_notepad_without_waiting(self, tmp_path): + from openadapt_tray.config import TrayConfig + + config, path = self._config(tmp_path) + with patch.object(TrayConfig, "config_path", return_value=path), patch( + "openadapt_tray.platform.windows.subprocess.Popen" + ) as popen: + assert WindowsHandler().open_settings_dialog(config) is True + assert popen.call_args.args[0] == ["notepad.exe", str(path)] + + def test_windows_missing_notepad_reports_failure(self, tmp_path): + from openadapt_tray.config import TrayConfig + + config, path = self._config(tmp_path) + with patch.object(TrayConfig, "config_path", return_value=path), patch( + "openadapt_tray.platform.windows.subprocess.Popen", + side_effect=FileNotFoundError, + ): + assert WindowsHandler().open_settings_dialog(config) is False + + def test_failed_opener_reports_failure(self, tmp_path): + from openadapt_tray.config import TrayConfig + + config, path = self._config(tmp_path) + with patch.object(TrayConfig, "config_path", return_value=path), patch( + "openadapt_tray.platform.base.subprocess.run", + return_value=subprocess.CompletedProcess([], 1, b"", b"no editor"), + ): + assert LinuxHandler().open_settings_dialog(config) is False + + def test_missing_opener_reports_failure(self, tmp_path): + from openadapt_tray.config import TrayConfig + + config, path = self._config(tmp_path) + with patch.object(TrayConfig, "config_path", return_value=path), patch( + "openadapt_tray.platform.base.subprocess.run", + side_effect=FileNotFoundError, + ): + assert LinuxHandler().open_settings_dialog(config) is False diff --git a/packages/tray/tests/test_public_metadata.py b/packages/tray/tests/test_public_metadata.py new file mode 100644 index 0000000000..01067f7d82 --- /dev/null +++ b/packages/tray/tests/test_public_metadata.py @@ -0,0 +1,62 @@ +"""Keep public package metadata aligned with the shipped release boundary.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_public_metadata_identifies_unreleased_supporting_surface() -> None: + # Collapse wrapping so an assertion matches the sentence rather than the + # line breaks a reflow happens to leave behind. + readme = re.sub(r"\s+", " ", (ROOT / "README.md").read_text()) + pyproject = (ROOT / "pyproject.toml").read_text() + + # The README must keep saying that the tray only relays commands and + # status, and that the tray and openadapt-desktop have not been proven + # together end to end. These assert the claims, not one particular + # heading, so the wording can improve without weakening the guard. + assert "The tray doesn't record or run anything itself." in readme + assert "doesn't record, compile, replay, or check workflow results" in readme + assert "haven't been run together end to end" in readme + assert "openadapt-flow" in readme + assert "## Limits" in readme + assert "Development Status :: 2 - Pre-Alpha" in pyproject + assert 'description = "Menu-bar and system-tray controls and status' in ( + pyproject + ) + assert "openadapt train" not in readme + assert "Training Control" not in readme + assert "monitoring training" not in readme + + +def test_first_screen_uses_no_internal_process_language() -> None: + # A stranger reading the README or the PyPI summary should learn what the + # tray does, not the release process behind it. + readme = (ROOT / "README.md").read_text().lower() + pyproject = (ROOT / "pyproject.toml").read_text().lower() + description = re.search(r'^description = "(.*)"$', pyproject, re.MULTILINE) + assert description is not None + for text in (readme, description.group(1)): + for term in ( + "admission", + "admitted", + "signed ledger", + "evidence authority", + "product target", + "lifecycle state", + "governed", + "honest", + ): + assert term not in text, term + + +def test_readme_local_links_exist() -> None: + readme = (ROOT / "README.md").read_text() + links = re.findall(r"\[[^]]*\]\(([^)]+)\)", readme) + + for link in links: + target = link.split("#", 1)[0] + if not target or "://" in target or target.startswith("mailto:"): + continue + assert (ROOT / target).exists(), f"README link does not exist: {link}" diff --git a/packages/tray/tests/test_release_contract.py b/packages/tray/tests/test_release_contract.py new file mode 100644 index 0000000000..1e20352ac2 --- /dev/null +++ b/packages/tray/tests/test_release_contract.py @@ -0,0 +1,113 @@ +"""Regression tests for versioned release inputs. + +openadapt-tray releases from the OpenAdapt monorepo through the root +``Release package`` workflow, which refuses a version whose changelog section is +missing. A release bump changes pyproject.toml, the module ``__version__`` and +the changelog in one reviewed pull request; ``python +scripts/check_release_consistency.py --write-lock`` then stamps the lock. +""" + +import re +import shutil +from pathlib import Path + +import pytest + +from scripts.check_release_consistency import ( + release_versions, + synchronize_release_lock, +) + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_WORKFLOW = ROOT.parents[1] / ".github/workflows/release-package.yml" + + +def test_release_versions_are_synchronized() -> None: + versions = release_versions() + assert len(set(versions.values())) == 1, versions + + + + +def _copy_release_inputs(destination: Path) -> None: + (destination / "src/openadapt_tray").mkdir(parents=True) + shutil.copy(ROOT / "pyproject.toml", destination / "pyproject.toml") + shutil.copy(ROOT / "uv.lock", destination / "uv.lock") + shutil.copy( + ROOT / "src/openadapt_tray/__init__.py", + destination / "src/openadapt_tray/__init__.py", + ) + + +def test_release_bump_synchronizes_only_editable_root_version(tmp_path: Path) -> None: + _copy_release_inputs(tmp_path) + pyproject_path = tmp_path / "pyproject.toml" + package_init_path = tmp_path / "src/openadapt_tray/__init__.py" + lock_path = tmp_path / "uv.lock" + current_version = release_versions(tmp_path)["pyproject.toml"] + simulated_version = "999.999.999" + + pyproject_path.write_text( + pyproject_path.read_text(encoding="utf-8").replace( + f'version = "{current_version}"', + f'version = "{simulated_version}"', + 1, + ), + encoding="utf-8", + ) + package_init_path.write_text( + package_init_path.read_text(encoding="utf-8").replace( + f'__version__ = "{current_version}"', + f'__version__ = "{simulated_version}"', + 1, + ), + encoding="utf-8", + ) + before = lock_path.read_text(encoding="utf-8") + + assert synchronize_release_lock(tmp_path) + after = lock_path.read_text(encoding="utf-8") + assert release_versions(tmp_path) == { + "pyproject.toml": simulated_version, + "src/openadapt_tray/__init__.py": simulated_version, + "uv.lock": simulated_version, + } + assert after == before.replace( + f'name = "openadapt-tray"\nversion = "{current_version}"', + f'name = "openadapt-tray"\nversion = "{simulated_version}"', + 1, + ) + assert not synchronize_release_lock(tmp_path) + + +def test_release_lock_sync_rejects_ambiguous_editable_root(tmp_path: Path) -> None: + _copy_release_inputs(tmp_path) + lock_path = tmp_path / "uv.lock" + lock = lock_path.read_text(encoding="utf-8") + root_entry = re.search( + r'(?ms)^\[\[package\]\]\s*\nname = "openadapt-tray".*?(?=^\[\[package\]\]|\Z)', + lock, + ) + assert root_entry + lock_path.write_text(lock + "\n" + root_entry.group(0), encoding="utf-8") + + with pytest.raises(ValueError, match="expected one editable"): + synchronize_release_lock(tmp_path) + + +def test_changelog_starts_with_the_project_version() -> None: + version = release_versions()["pyproject.toml"] + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + headers = re.findall( + r"^## v([^ ]+) \(\d{4}-\d{2}-\d{2}\)$", changelog, re.MULTILINE + ) + assert headers, "CHANGELOG.md has no release sections" + assert headers[0] == version + + +def test_monorepo_release_workflow_publishes_this_package() -> None: + if not RELEASE_WORKFLOW.is_file(): + pytest.skip("not running from the OpenAdapt monorepo checkout") + workflow = RELEASE_WORKFLOW.read_text(encoding="utf-8") + assert '"tray": "openadapt-tray"' in workflow + assert re.search(r"options: \[[^\]]*\btray\b", workflow) diff --git a/packages/tray/tests/test_shortcuts.py b/packages/tray/tests/test_shortcuts.py new file mode 100644 index 0000000000..79498c1d19 --- /dev/null +++ b/packages/tray/tests/test_shortcuts.py @@ -0,0 +1,350 @@ +"""Tests for hotkey/shortcut handling.""" + +import contextlib +import sys +import threading +import types +from unittest.mock import MagicMock, patch + +import pytest + +from openadapt_tray import shortcuts +from openadapt_tray.shortcuts import HotkeyConfig, HotkeyManager, format_hotkey + + +class TestFormatHotkey: + """The menu shows a hotkey in the platform's form, not pynput syntax.""" + + @pytest.mark.parametrize( + ("spec", "platform", "expected"), + [ + ("++r", "darwin", "⌃⇧R"), + ("++r", "win32", "Ctrl+Shift+R"), + ("++r", "linux", "Ctrl+Shift+R"), + ("+r", "darwin", "⌘R"), + ("+r", "win32", "Win+R"), + ("+r", "linux", "Super+R"), + ("+++x", "darwin", "⌥⌃⇧X"), + ("+++x", "win32", "Alt+Ctrl+Shift+X"), + ("+", "darwin", "⌃F5"), + ("+", "linux", "Ctrl+Page Up"), + ("+", "darwin", "⌘Space"), + ("++e", "darwin", "⌘⌥E"), + ("+e", "linux", "AltGr+E"), + ("++", "win32", "Ctrl++"), + ], + ) + def test_formats_for_platform(self, spec, platform, expected): + assert format_hotkey(spec, platform) == expected + + def test_defaults_to_running_platform(self): + with patch("sys.platform", "darwin"): + assert format_hotkey("++r") == "⌃⇧R" + with patch("sys.platform", "win32"): + assert format_hotkey("++r") == "Ctrl+Shift+R" + + @pytest.mark.parametrize( + "spec", ["", "+", "+", "ctrl+shift+r", "<>+r", "+rr"] + ) + @pytest.mark.parametrize("platform", ["darwin", "win32", "linux"]) + def test_malformed_spec_is_shown_unchanged(self, spec, platform): + # A bad hotkey in tray.json must not stop the menu from rendering, and + # must not be dressed up as a shortcut pynput would never register. + assert format_hotkey(spec, platform) == spec + + @pytest.mark.parametrize(("value", "shown"), [(None, "None"), (5, "5")]) + def test_non_string_setting_is_shown_as_text(self, value, shown): + # tray.json can hold null or a number here; the menu must still build. + assert format_hotkey(value, "darwin") == shown + + def test_virtual_key_code_is_left_as_written(self): + # pynput accepts "<65>" as a virtual key code; there is no honest + # display name for it, so show what the user configured. + assert format_hotkey("+<65>", "win32") == "Ctrl+<65>" + + +class TestHotkeyManager: + """Tests for HotkeyManager class.""" + + @pytest.fixture(autouse=True) + def _no_real_layout_read(self): + # These tests mock pynput.keyboard. On macOS, start() would otherwise + # read the real keyboard layout and patch the real pynput module. + with patch.object(shortcuts, "_pin_macos_keyboard_layout", return_value=True): + yield + + def test_initialization(self): + """Test HotkeyManager initialization.""" + manager = HotkeyManager() + assert manager.config is not None + assert manager.is_running() is False + + def test_initialization_with_config(self): + """Test HotkeyManager initialization with custom config.""" + config = HotkeyConfig(toggle_recording="+r") + manager = HotkeyManager(config) + assert manager.config.toggle_recording == "+r" + + def test_register_handler(self): + """Test registering a hotkey handler.""" + manager = HotkeyManager() + handler = MagicMock() + + manager.register("+a", handler) + assert "+a" in manager._handlers + assert manager._handlers["+a"] == handler + + def test_unregister_handler(self): + """Test unregistering a hotkey handler.""" + manager = HotkeyManager() + handler = MagicMock() + + manager.register("+a", handler) + manager.unregister("+a") + assert "+a" not in manager._handlers + + def test_unregister_nonexistent_handler(self): + """Test unregistering a non-existent handler doesn't raise.""" + manager = HotkeyManager() + manager.unregister("+nonexistent") # Should not raise + + def test_start_creates_listener(self): + """Test that start creates hotkey listener.""" + with patch("pynput.keyboard") as mock_keyboard: + manager = HotkeyManager() + handler = MagicMock() + manager.register("+a", handler) + + mock_listener = MagicMock() + mock_keyboard.GlobalHotKeys.return_value = mock_listener + + manager.start() + + assert manager.is_running() + mock_keyboard.GlobalHotKeys.assert_called_once() + mock_listener.start.assert_called_once() + + manager.stop() + + def test_start_twice_does_nothing(self): + """Test that starting twice doesn't create multiple listeners.""" + with patch("pynput.keyboard") as mock_keyboard: + manager = HotkeyManager() + handler = MagicMock() + manager.register("+a", handler) + + mock_listener = MagicMock() + mock_keyboard.GlobalHotKeys.return_value = mock_listener + + manager.start() + manager.start() # Second start should be no-op + + assert mock_keyboard.GlobalHotKeys.call_count == 1 + manager.stop() + + def test_stop_stops_listener(self): + """Test that stop stops the listener.""" + with patch("pynput.keyboard") as mock_keyboard: + manager = HotkeyManager() + handler = MagicMock() + manager.register("+a", handler) + + mock_listener = MagicMock() + mock_keyboard.GlobalHotKeys.return_value = mock_listener + + manager.start() + manager.stop() + + assert manager.is_running() is False + mock_listener.stop.assert_called_once() + + def test_triple_ctrl_not_registered_by_default(self): + """Test triple ctrl handler setup when not registered.""" + manager = HotkeyManager() + handler = MagicMock() + manager.register("+a", handler) # Regular hotkey, not triple-ctrl + + # Should not have triple-ctrl handler + assert "++" not in manager._handlers + + def test_ctrl_count_reset(self): + """Test that ctrl count resets after timeout.""" + manager = HotkeyManager() + manager._ctrl_count = 2 + manager._reset_ctrl_count() + assert manager._ctrl_count == 0 + + def test_missing_pynput_handles_gracefully(self): + """Test graceful handling when pynput is not available.""" + with patch("pynput.keyboard") as mock_keyboard: + manager = HotkeyManager() + handler = MagicMock() + manager.register("+a", handler) + mock_keyboard.GlobalHotKeys.side_effect = Exception("Mock error") + + manager.start() # Should not raise + # Should still mark as running since the attempt was made + assert manager.is_running() is True + + def test_stop_without_start(self): + """Test that stop without start doesn't raise.""" + manager = HotkeyManager() + manager.stop() # Should not raise + + +class TestTripleCtrlDetection: + """Tests for triple-ctrl detection logic.""" + + def test_on_ctrl_press_increments_count(self): + """Test that ctrl press increments counter.""" + manager = HotkeyManager() + manager._ctrl_count = 0 + manager._on_ctrl_press() + assert manager._ctrl_count == 1 + + def test_triple_ctrl_triggers_handler(self): + """Test that three ctrl presses trigger the handler.""" + manager = HotkeyManager() + handler = MagicMock() + manager.register("++", handler) + + manager._ctrl_count = 0 + manager._on_ctrl_press() + manager._on_ctrl_press() + manager._on_ctrl_press() + + handler.assert_called_once() + assert manager._ctrl_count == 0 # Reset after trigger + + def test_triple_ctrl_resets_after_trigger(self): + """Test that count resets after triggering.""" + manager = HotkeyManager() + handler = MagicMock() + manager.register("++", handler) + + for _ in range(3): + manager._on_ctrl_press() + + assert manager._ctrl_count == 0 + + def test_ctrl_timer_created_on_press(self): + """Test that a timer is created on ctrl press.""" + manager = HotkeyManager() + handler = MagicMock() + manager.register("++", handler) + + manager._on_ctrl_press() + assert manager._ctrl_timer is not None + assert manager._ctrl_count == 1 + + # Cleanup + if manager._ctrl_timer: + manager._ctrl_timer.cancel() + + def test_bad_handler_doesnt_crash(self): + """Test that a failing handler doesn't crash the manager.""" + manager = HotkeyManager() + + def bad_handler(): + raise ValueError("Intentional error") + + manager.register("++", bad_handler) + + # Should not raise + for _ in range(3): + manager._on_ctrl_press() + + +class TestMacOSKeyboardLayoutPinning: + """pynput's listener threads must not call the macOS layout API themselves. + + With an NSApplication in the process, macOS 15 aborts when that API runs + off the main thread, and ``openadapt-tray`` exited with SIGABRT at startup. + """ + + def _fake_pynput_darwin(self, read_layout): + @contextlib.contextmanager + def keycode_context(): + yield read_layout() + + util = types.ModuleType("pynput._util.darwin") + util.keycode_context = keycode_context + listener = types.ModuleType("pynput.keyboard._darwin") + listener.keycode_context = keycode_context + return {"pynput._util.darwin": util, "pynput.keyboard._darwin": listener} + + def test_listeners_get_the_layout_read_on_the_main_thread(self): + reads = [] + + def read_layout(): + reads.append(threading.current_thread()) + return (91, b"layout") + + modules = self._fake_pynput_darwin(read_layout) + with patch.dict(sys.modules, modules): + assert shortcuts._pin_macos_keyboard_layout() is True + listener_module = sys.modules["pynput.keyboard._darwin"] + + seen = [] + + def listener_thread(): + with listener_module.keycode_context() as layout: + seen.append(layout) + + thread = threading.Thread(target=listener_thread) + thread.start() + thread.join() + + assert reads == [threading.main_thread()] + assert seen == [(91, b"layout")] + + def test_refuses_off_the_main_thread(self): + results = [] + thread = threading.Thread( + target=lambda: results.append(shortcuts._pin_macos_keyboard_layout()) + ) + thread.start() + thread.join() + assert results == [False] + + def test_unreadable_layout_refuses(self): + def read_layout(): + raise OSError("no input source") + + with patch.dict(sys.modules, self._fake_pynput_darwin(read_layout)): + assert shortcuts._pin_macos_keyboard_layout() is False + + def test_changed_pynput_internals_refuse(self): + modules = self._fake_pynput_darwin(lambda: (91, b"layout")) + del modules["pynput.keyboard._darwin"].keycode_context + with patch.dict(sys.modules, modules): + assert shortcuts._pin_macos_keyboard_layout() is False + + def test_macos_start_pins_the_layout_before_any_listener(self): + order = [] + with patch("pynput.keyboard") as keyboard, patch( + "openadapt_tray.shortcuts.sys.platform", "darwin" + ), patch.object( + shortcuts, + "_pin_macos_keyboard_layout", + side_effect=lambda: order.append("pin") or True, + ): + keyboard.GlobalHotKeys.side_effect = lambda *a: order.append( + "listener" + ) or MagicMock() + manager = HotkeyManager() + manager.register("+a", MagicMock()) + manager.start() + manager.stop() + assert order == ["pin", "listener"] + + def test_macos_start_without_a_pinned_layout_starts_no_listener(self): + with patch("pynput.keyboard") as keyboard, patch( + "openadapt_tray.shortcuts.sys.platform", "darwin" + ), patch.object(shortcuts, "_pin_macos_keyboard_layout", return_value=False): + manager = HotkeyManager() + manager.register("+a", MagicMock()) + manager.register("++", MagicMock()) + manager.start() + keyboard.GlobalHotKeys.assert_not_called() + keyboard.Listener.assert_not_called() diff --git a/packages/tray/tests/test_state.py b/packages/tray/tests/test_state.py new file mode 100644 index 0000000000..b5e6bf6182 --- /dev/null +++ b/packages/tray/tests/test_state.py @@ -0,0 +1,259 @@ +"""Tests for state management.""" + + +from openadapt_tray.state import ( + LANE_BYOC, + LANE_CLOUD, + AppState, + CredentialState, + CredentialStatus, + StateManager, + SyncState, + TrayState, +) + + +class TestTrayState: + """Tests for TrayState enum.""" + + def test_all_states_defined(self): + """Verify all expected recording-lifecycle states are defined.""" + expected_states = [ + "IDLE", + "RECORDING_STARTING", + "RECORDING", + "RECORDING_STOPPING", + "COMPILING", + "ERROR", + ] + actual_states = [s.name for s in TrayState] + assert actual_states == expected_states + + def test_rl_states_removed(self): + """The retired RL training states must not exist.""" + names = [s.name for s in TrayState] + assert "TRAINING" not in names + assert "TRAINING_PAUSED" not in names + + +class TestSyncState: + """Tests for the orthogonal SyncState channel.""" + + def test_sync_states_defined(self): + """Verify the sync channel states.""" + names = {s.name for s in SyncState} + assert names == {"SYNCED", "SYNCING", "OFFLINE", "PAUSED"} + + def test_pushing_is_syncing_alias(self): + """PUSHING is a spec alias for SYNCING.""" + assert SyncState.PUSHING() is SyncState.SYNCING + + +class TestAppState: + """Tests for AppState dataclass.""" + + def test_default_state(self): + """Test default AppState values.""" + state = AppState() + assert state.state == TrayState.IDLE + assert state.current_capture is None + assert state.error_message is None + assert state.sync_state == SyncState.SYNCED + assert state.break_count == 0 + assert state.deployment_lane == LANE_CLOUD + + def test_can_start_recording_when_idle(self): + """Test that recording can start when idle.""" + state = AppState(state=TrayState.IDLE) + assert state.can_start_recording() is True + + def test_cannot_start_recording_when_recording(self): + """Test that recording cannot start when already recording.""" + state = AppState(state=TrayState.RECORDING) + assert state.can_start_recording() is False + + def test_can_stop_recording_when_recording(self): + """Test that recording can stop when active.""" + state = AppState(state=TrayState.RECORDING) + assert state.can_stop_recording() is True + + def test_cannot_stop_recording_when_idle(self): + """Test that recording cannot stop when idle.""" + state = AppState(state=TrayState.IDLE) + assert state.can_stop_recording() is False + + def test_is_recording_states(self): + """Test is_recording for various states.""" + assert AppState(state=TrayState.RECORDING).is_recording() is True + assert AppState(state=TrayState.RECORDING_STARTING).is_recording() is True + assert AppState(state=TrayState.RECORDING_STOPPING).is_recording() is True + assert AppState(state=TrayState.IDLE).is_recording() is False + assert AppState(state=TrayState.COMPILING).is_recording() is False + + def test_is_compiling(self): + """Test is_compiling for various states.""" + assert AppState(state=TrayState.COMPILING).is_compiling() is True + assert AppState(state=TrayState.RECORDING).is_compiling() is False + + def test_is_busy_states(self): + """Test is_busy for various states.""" + assert AppState(state=TrayState.IDLE).is_busy() is False + assert AppState(state=TrayState.ERROR).is_busy() is False + assert AppState(state=TrayState.RECORDING).is_busy() is True + assert AppState(state=TrayState.COMPILING).is_busy() is True + + def test_sync_and_break_helpers(self): + """Test the orthogonal sync + break helpers.""" + assert AppState(sync_state=SyncState.SYNCING).is_syncing() is True + assert AppState(sync_state=SyncState.OFFLINE).is_offline() is True + assert AppState(break_count=3).has_breaks() is True + assert AppState(break_count=0).has_breaks() is False + assert AppState(deployment_lane=LANE_BYOC).is_byoc() is True + assert AppState(deployment_lane=LANE_CLOUD).is_byoc() is False + + +class TestStateManager: + """Tests for StateManager class.""" + + def test_initial_state_is_idle(self): + """Test that initial state is IDLE.""" + manager = StateManager() + assert manager.current.state == TrayState.IDLE + + def test_transition_updates_state(self): + """Test that transition updates the state.""" + manager = StateManager() + manager.transition(TrayState.RECORDING, current_capture="test") + assert manager.current.state == TrayState.RECORDING + assert manager.current.current_capture == "test" + + def test_listener_called_on_transition(self): + """Test that listeners are called on state transition.""" + manager = StateManager() + received_states = [] + + def listener(state): + received_states.append(state) + + manager.add_listener(listener) + manager.transition(TrayState.RECORDING, current_capture="test") + + assert len(received_states) == 1 + assert received_states[0].state == TrayState.RECORDING + + def test_multiple_listeners(self): + """Test that multiple listeners are all called.""" + manager = StateManager() + call_counts = [0, 0] + + def listener1(state): + call_counts[0] += 1 + + def listener2(state): + call_counts[1] += 1 + + manager.add_listener(listener1) + manager.add_listener(listener2) + manager.transition(TrayState.RECORDING) + + assert call_counts == [1, 1] + + def test_remove_listener(self): + """Test that removed listeners are not called.""" + manager = StateManager() + call_count = [0] + + def listener(state): + call_count[0] += 1 + + manager.add_listener(listener) + manager.transition(TrayState.RECORDING) + assert call_count[0] == 1 + + manager.remove_listener(listener) + manager.transition(TrayState.IDLE) + assert call_count[0] == 1 # Not incremented + + def test_reset_returns_to_idle(self): + """Test that reset returns to IDLE state.""" + manager = StateManager() + manager.transition(TrayState.RECORDING, current_capture="test") + manager.reset() + assert manager.current.state == TrayState.IDLE + assert manager.current.current_capture is None + + def test_bad_listener_does_not_crash(self): + """Test that a failing listener doesn't crash the manager.""" + manager = StateManager() + + def bad_listener(state): + raise ValueError("Intentional error") + + manager.add_listener(bad_listener) + + # Should not raise + manager.transition(TrayState.RECORDING) + assert manager.current.state == TrayState.RECORDING + + def test_transition_preserves_orthogonal_channels(self): + """Recording transitions must not clobber sync/break/lane.""" + manager = StateManager() + manager.set_sync_state(SyncState.SYNCING) + manager.set_break_count(4) + manager.set_deployment_lane(LANE_BYOC) + + manager.transition(TrayState.RECORDING, current_capture="w") + + assert manager.current.state == TrayState.RECORDING + assert manager.current.sync_state == SyncState.SYNCING + assert manager.current.break_count == 4 + assert manager.current.deployment_lane == LANE_BYOC + + def test_set_sync_state_preserves_recording(self): + """Sync updates must not clobber the recording lifecycle.""" + manager = StateManager() + manager.transition(TrayState.RECORDING, current_capture="w") + manager.set_sync_state(SyncState.OFFLINE) + + assert manager.current.state == TrayState.RECORDING + assert manager.current.current_capture == "w" + assert manager.current.sync_state == SyncState.OFFLINE + + def test_set_break_count_clamps_and_notifies(self): + """Break count updates clamp to >=0 and notify listeners once.""" + manager = StateManager() + received = [] + manager.add_listener(lambda s: received.append(s.break_count)) + + manager.set_break_count(3) + manager.set_break_count(3) # no change → no extra notification + manager.set_break_count(-5) # clamps to 0 + + assert manager.current.break_count == 0 + assert received == [3, 0] + + def test_credential_update_preserves_attention_count(self): + manager = StateManager() + manager.set_break_count(3) + manager.set_credential_status( + CredentialStatus( + state=CredentialState.EXPIRING, + expires_at="2026-08-05T12:00:00Z", + expires_in_days=8, + warning_days=14, + ) + ) + + assert manager.current.break_count == 3 + assert manager.current.credential.state == CredentialState.EXPIRING + + def test_reset_preserves_sync_channel(self): + """reset() returns recording to IDLE but keeps the sync channel.""" + manager = StateManager() + manager.set_sync_state(SyncState.SYNCING) + manager.transition(TrayState.RECORDING, current_capture="w") + manager.reset() + + assert manager.current.state == TrayState.IDLE + assert manager.current.current_capture is None + assert manager.current.sync_state == SyncState.SYNCING diff --git a/packages/tray/uv.lock b/packages/tray/uv.lock new file mode 100644 index 0000000000..b95482231d --- /dev/null +++ b/packages/tray/uv.lock @@ -0,0 +1,1043 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, +] + +[[package]] +name = "dbus-fast" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/a4/e54607cf8b0a696beba591f1a543cff5b6a9e4b4f842fd55f7ba741d678d/dbus_fast-3.1.2.tar.gz", hash = "sha256:6c9e1b45e4b5e7df0c021bf1bf3f27649374e47c3de1afdba6d00a7d7bba4b3a", size = 73191, upload-time = "2025-11-17T03:41:10.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/81/df380f31ff7646c010c166c160391e86d697b66b2024f56418e2e79bffd6/dbus_fast-3.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12a0896821dd8b03f960d1bfabd1fa7f4af580f45ec070c1fe90ad9d093f7e56", size = 826852, upload-time = "2025-11-17T03:49:17.418Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/862bb5a67a6ee83dcd20cc2d916a6aad45df441f57ed2860a894fc69bb21/dbus_fast-3.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abe5e38cd78844a66154bfb2c11e70840849cd4ef8acf63504d3ee7ef14d0d15", size = 871036, upload-time = "2025-11-17T03:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a3/b8f82873aa0466dbe86e89e8e8fb6f89db5bbd90a31dddfa1f4e109f81ef/dbus_fast-3.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:793e58c123ad513c11a97f1dd423518342b806c4d0d8d7a0763b60a8daeb32d2", size = 832326, upload-time = "2025-11-17T03:49:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/58/1d/be600b1eb685b7f73606ae78349a93f154164ba7d61345a6be7997c2cdbe/dbus_fast-3.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a5726eba4ad6a9ed951e6a402e2c69418d4cc82668709183c78a7ca24ad17cd8", size = 877282, upload-time = "2025-11-17T03:49:21.749Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7c/c401f6f90fe049425f381a5219bb499e2b71ea6862a06f4787c3afe8107a/dbus_fast-3.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33be2457766da461d3c79627aa6b007a65dd9af0e9b305ca43d7a7dd2794824a", size = 825097, upload-time = "2025-11-17T03:49:24.781Z" }, + { url = "https://files.pythonhosted.org/packages/f1/15/f33579339eaf50b64be460b6f34fb567819f7d229d946fa5cc599ce34aae/dbus_fast-3.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15279fd88952442c8b6b0b910b6c5eff74e9380dde74db0841523f3e6206377f", size = 870328, upload-time = "2025-11-17T03:49:26.311Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/06d0061394395784daf578e8ae688b4c5bf7595bab22db88955a6c35e8a0/dbus_fast-3.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fb4db6cc605193576b6825d1827ff6bde9c09c23e385e33b05db74ed8916021f", size = 830406, upload-time = "2025-11-17T03:49:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/ab/40/96a994e65cff239f73355f716c2b28348552eccb8279824dcbb42a09af80/dbus_fast-3.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42b1e35bbfcf52f8abb971362d3e1d9b9e0febb93b43d1c5d099106143c31a35", size = 876465, upload-time = "2025-11-17T03:49:29.648Z" }, + { url = "https://files.pythonhosted.org/packages/b8/87/1aa99b4ab3e051962071a0e443cfa725e80802c950484a501f01014ab0df/dbus_fast-3.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91362a0f2151926a882c652ee2ae7c41495a82228b045e7461e1ce687ab4b173", size = 789953, upload-time = "2025-11-17T03:49:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/fc/99/59bc4854b2a2355352373d08d07b89d5318c181a1356de307b07b8ae5d99/dbus_fast-3.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439c300cf0f1b9b4b81c1a55ac1ed65c2b90f203570c4d0243d2fc3eac8fc7cc", size = 839666, upload-time = "2025-11-17T03:49:33.81Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/69ae337cd516bce574fc1dd592ce6c3fdd46acae0168c9c20b26ce284396/dbus_fast-3.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9290039b2454357735a35cf81b98c208c19c1b4a244532bbb52135c5dc0b7f8c", size = 796378, upload-time = "2025-11-17T03:49:35.125Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/a6178c07c769ecdba3d46f8c458adfab25db9a4e5d16a8b567b2c61c3e03/dbus_fast-3.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9d275923c4ec24b63b1edf4871f05fc673fc08e1a838a9ddd02938b9c28fa44", size = 847893, upload-time = "2025-11-17T03:49:36.79Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/81e1acbe08e195f38a3ef863702dba88ade8fe56594041ff7e5a6ef9d137/dbus_fast-3.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdaa7c1cf132b72a8c66fd36c612b112063296d2d518463064ff44dc670d452a", size = 787581, upload-time = "2025-11-17T03:49:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/1a/59/da668ad9947db726747e29aa1978642919a5cfbe633a473f76446ea3915d/dbus_fast-3.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:973afa96fcb97c680d50a66163ad2aa7327177e136a29fbeae280c660584536a", size = 836242, upload-time = "2025-11-17T03:49:40.854Z" }, + { url = "https://files.pythonhosted.org/packages/de/c4/ecb51606a7f5f41e6eeb2e347757b6ffbd83b9e51c85bb7787a737016ee4/dbus_fast-3.1.2-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:cea152a01991cb8b77eeb2403b156e5a8ba4300b729636aa732fc891c22e44d4", size = 812530, upload-time = "2025-11-17T03:41:08.634Z" }, + { url = "https://files.pythonhosted.org/packages/fd/47/42eac517b9fe3949f3cb66dcc1d2b5fa4380bf606bef2374f3c77d7f4cb2/dbus_fast-3.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:618b819b19724477b77f5bf3f300d92fa51d0974bd25499e10c3417eadc4a732", size = 793323, upload-time = "2025-11-17T03:49:42.765Z" }, + { url = "https://files.pythonhosted.org/packages/92/ef/0956c728d7a4963b6503f201f4aa3c3b366ec21490b526bf155b8fa0ce52/dbus_fast-3.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66279b8491ba9d593c4793b423abbf1dce14dbb3f3e6d9967bb62be8c39244b4", size = 845052, upload-time = "2025-11-17T03:49:44.157Z" }, + { url = "https://files.pythonhosted.org/packages/e0/87/2d322dbf5357393a426ae2873d60016008566b0a60a2c7f6e4f1783add50/dbus_fast-3.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8116564196c7e83cfc81be186378da7f093d36fbfef0669e1fe1f20ac891c50a", size = 801269, upload-time = "2025-11-17T03:49:47.418Z" }, + { url = "https://files.pythonhosted.org/packages/72/27/1bc75fcd4ea5fb298a28b9d204b65b0d00c7c89ba13a3e47185d48b8db8b/dbus_fast-3.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c55db7b62878bc039736d2687b1bd5eb4a5596b97a4b230c9d919daa961a1d9c", size = 842854, upload-time = "2025-11-17T03:49:48.986Z" }, + { url = "https://files.pythonhosted.org/packages/40/c4/f042243ff28cf083c189f7eff5cc04b86c150a33b3cdef7ef21b520d93a5/dbus_fast-3.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8064b36900098c31a3fe8dab7ef3931c853cbcf9f163ccb437a7379c61e6acc3", size = 808090, upload-time = "2025-11-17T03:49:50.697Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/1e1443cb8a35e11e24009882463c461ee5103c20c5fc7f04a9e3443462ce/dbus_fast-3.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:038d3e8803f62b1d789ce0c602cc8c317c47c21e67bb2dd544b9c0fc97b4b2e2", size = 850179, upload-time = "2025-11-17T03:49:51.956Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/9ebef541d192c22529af38d151a818ca18f6c3699dfc3644ff0e66e44599/dbus_fast-3.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71c99fb09c3a5637a0729230ac5f888b61abf754e10f23c629be476da830887c", size = 1529979, upload-time = "2025-11-17T03:49:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4f/0e0b0359d41ffaa652b066759321d8f2608246090c827061c43b72dcefae/dbus_fast-3.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a78eb3f19ff81fb7a8b16075160ebd1edc6135c59c929da0832511f315b5ede", size = 1606236, upload-time = "2025-11-17T03:49:56.667Z" }, + { url = "https://files.pythonhosted.org/packages/8e/18/1ad08968773f1d1822a1e147dbc98c9af220b582d2c8b95705c7e8f8019f/dbus_fast-3.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:366550946b281a5b4bb8d70815667d24565141e3c23dc7d40267a315b16def2c", size = 1545417, upload-time = "2025-11-17T03:49:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/89/94/b7ff6279e642b014cd4aef4d914b9fca3917c2c9c35df49db062023cbdfc/dbus_fast-3.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d7cc1315586e4c50875c9a2d56b9ad2e056ec75e2f27c43cd80392f72d0f6e3", size = 1623709, upload-time = "2025-11-17T03:49:59.571Z" }, +] + +[[package]] +name = "desktop-notifier" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "packaging" }, + { name = "rubicon-objc", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-applicationmodel-core", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-data-xml-dom", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-ui-notifications", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/df/67c6ff92d870881b5a6e073773a209eeb2e263a15cd112ddddff5c14abe8/desktop_notifier-6.2.0.tar.gz", hash = "sha256:528167b691ce40031fa92f67c9f452b7be29846613e19d11ec0c49cb5242d338", size = 34487, upload-time = "2025-08-08T18:14:50.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/36/b32a806dbb7bce308fecc977220c03ca7990831491bebc582b02619ff980/desktop_notifier-6.2.0-py3-none-any.whl", hash = "sha256:193b0885e694a99ae22f72234c91afbac633074295b44183bf9716dd4077b8f3", size = 34406, upload-time = "2025-08-08T18:14:49.497Z" }, +] + +[[package]] +name = "evdev" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce83f04d14bcedcfb2c38c7793ea56bfb906a6fadae8cb/evdev-1.9.2.tar.gz", hash = "sha256:5d3278892ce1f92a74d6bf888cc8525d9f68af85dbe336c95d1c87fb8f423069", size = 33301, upload-time = "2025-05-01T19:53:47.69Z" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "openadapt-tray" +version = "0.3.7" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "desktop-notifier" }, + { name = "httpx" }, + { name = "keyring" }, + { name = "pillow" }, + { name = "pynput" }, + { name = "pystray" }, +] + +[package.optional-dependencies] +all = [ + { name = "pyobjc-framework-cocoa" }, + { name = "rumps" }, +] +dev = [ + { name = "pygments" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "ruff" }, +] +macos-native = [ + { name = "pyobjc-framework-cocoa" }, + { name = "rumps" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.3.3" }, + { name = "desktop-notifier", specifier = ">=6.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "keyring", specifier = ">=24.0" }, + { name = "openadapt-tray", extras = ["macos-native"], marker = "extra == 'all'" }, + { name = "pillow", specifier = ">=12.3.0" }, + { name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" }, + { name = "pynput", specifier = ">=1.7.0" }, + { name = "pyobjc-framework-cocoa", marker = "extra == 'macos-native'", specifier = ">=9.0" }, + { name = "pystray", specifier = ">=0.19.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.16,<0.18" }, + { name = "rumps", marker = "extra == 'macos-native'", specifier = ">=0.4.0" }, +] +provides-extras = ["macos-native", "dev", "all"] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pynput" +version = "1.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/c3/dccf44c68225046df5324db0cc7d563a560635355b3e5f1d249468268a6f/pynput-1.8.1.tar.gz", hash = "sha256:70d7c8373ee98911004a7c938742242840a5628c004573d84ba849d4601df81e", size = 82289, upload-time = "2025-03-17T17:12:01.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748, upload-time = "2025-11-14T09:30:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/9d/3cf36e7b08832e71f5d48ddfa1047865cf2dfc53df8c0f2a82843ea9507a/pyobjc_framework_applicationservices-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4fd1b008757182b9e2603a63c6ffa930cc412fab47294ec64260ab3f8ec695d", size = 32791, upload-time = "2025-11-14T09:36:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/17/86/d07eff705ff909a0ffa96d14fc14026e9fc9dd716233648c53dfd5056b8e/pyobjc_framework_applicationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bdddd492eeac6d14ff2f5bd342aba29e30dffa72a2d358c08444da22129890e2", size = 32784, upload-time = "2025-11-14T09:36:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835, upload-time = "2025-11-14T09:36:11.855Z" }, + { url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859, upload-time = "2025-11-14T09:36:15.208Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115, upload-time = "2025-11-14T09:36:18.384Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997, upload-time = "2025-11-14T09:36:21.58Z" }, + { url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238, upload-time = "2025-11-14T09:36:24.751Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825, upload-time = "2025-11-14T09:40:28.354Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-coretext" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1c/ddecc72a672d681476c668bcedcfb8ade16383c028eac566ac7458fb91ef/pyobjc_framework_coretext-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1c8315dcef6699c2953461d97117fe81402f7c29cff36d2950dacce028a362fd", size = 29987, upload-time = "2025-11-14T09:46:58.028Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/7b8efc41e743adfa2d74b92dec263c91bcebfb188d2a8f5eea1886a195ff/pyobjc_framework_coretext-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f6742ba5b0bb7629c345e99eff928fbfd9e9d3d667421ac1a2a43bdb7ba9833", size = 29990, upload-time = "2025-11-14T09:47:01.206Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108, upload-time = "2025-11-14T09:47:04.228Z" }, + { url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110, upload-time = "2025-11-14T09:47:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697, upload-time = "2025-11-14T09:47:10.932Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095, upload-time = "2025-11-14T09:47:13.893Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702, upload-time = "2025-11-14T09:47:17.292Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f4/50c42c84796886e4d360407fb629000bb68d843b2502c88318375441676f/pyobjc_framework_quartz-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c6f312ae79ef8b3019dcf4b3374c52035c7c7bc4a09a1748b61b041bb685a0ed", size = 217799, upload-time = "2025-11-14T09:59:32.62Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, +] + +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "rubicon-objc" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/d2/d39ecd205661a5c14c90dbd92a722a203848a3621785c9783716341de427/rubicon_objc-0.5.3.tar.gz", hash = "sha256:74c25920c5951a05db9d3a1aac31d23816ec7dacc841a5b124d911b99ea71b9a", size = 171512, upload-time = "2025-12-03T03:51:10.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ab/e834c01138c272fb2e37d2f3c7cba708bc694dbc7b3f03b743f29ceb92d5/rubicon_objc-0.5.3-py3-none-any.whl", hash = "sha256:31dedcda9be38435f5ec067906e1eea5d0ddb790330e98a22e94ff424758b415", size = 64414, upload-time = "2025-12-03T03:51:09.082Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "rumps" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/e2/2e6a47951290bd1a2831dcc50aec4b25d104c0cf00e8b7868cbd29cf3bfe/rumps-0.4.0.tar.gz", hash = "sha256:17fb33c21b54b1e25db0d71d1d793dc19dc3c0b7d8c79dc6d833d0cffc8b1596", size = 39257, upload-time = "2022-10-15T05:15:10.386Z" } + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/28/26d86ca6d2f155f31ca61e069312034a8922a5a89f5d0fc68abb7c04aad1/winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3", size = 210993, upload-time = "2025-06-06T06:44:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/46/a4/f096687e0d1877d206bc5d1f5f07ff90e00b0772d69d4559ab2b6b37090b/winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257", size = 242210, upload-time = "2025-06-06T06:44:02.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/81/46927ce4d79fc8f40f193f35204bce79eff7c496d888825a7a74d8560b6e/winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae", size = 415833, upload-time = "2025-06-06T06:44:03.379Z" }, + { url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798, upload-time = "2025-06-06T06:44:04.775Z" }, + { url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032, upload-time = "2025-06-06T06:44:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659, upload-time = "2025-06-06T06:44:07.007Z" }, + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090, upload-time = "2025-06-06T06:44:08.151Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391, upload-time = "2025-06-06T06:44:09.442Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242, upload-time = "2025-06-06T06:44:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022, upload-time = "2025-06-06T06:44:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349, upload-time = "2025-06-06T06:44:12.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126, upload-time = "2025-06-06T06:44:13.702Z" }, + { url = "https://files.pythonhosted.org/packages/c8/87/88bd98419a9da77a68e030593fee41702925a7ad8a8aec366945258cbb31/winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad", size = 210257, upload-time = "2025-09-20T07:06:41.054Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/e5c2a10d287edd9d3ee8dc24bf7d7f335636b92bf47119768b7dd2fd1669/winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814", size = 241873, upload-time = "2025-09-20T07:06:42.059Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/eb9e78397132175f70dd51dfa4f93e489c17d6b313ae9dce60369b8d84a7/winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650", size = 416222, upload-time = "2025-09-20T07:06:43.376Z" }, +] + +[[package]] +name = "winrt-windows-applicationmodel-core" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/0d/3ae880680ba3da0fe062f7a4785b955bab331ae45fd8941c7be55e12e005/winrt_windows_applicationmodel_core-3.2.1.tar.gz", hash = "sha256:7cd21a4a7e25b099703a607f17ce804b5166cdf6444437440590fafea990fb46", size = 13656, upload-time = "2025-06-06T14:40:46.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/d8/a3d4e18f5dfaae1fda714244bec24357b5fb8a990169a68da112b464376d/winrt_windows_applicationmodel_core-3.2.1-cp310-cp310-win32.whl", hash = "sha256:c0fff9ab54efb59684c5e7429d7e65e4a01e7ff3d71bb36a38258563392e1cbd", size = 67801, upload-time = "2025-06-06T06:49:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/36/a9/687d8534ea0cb24e93ac08b7685b06587359b0967b1c3dbff1a8bdd6079d/winrt_windows_applicationmodel_core-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ac941b450b26e76e2e082385a8587cf977e855a982518034f373668b5037d86", size = 70301, upload-time = "2025-06-06T06:49:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/1b/33/51e51b08112e86741088d9c4cf410f75b3dfd88a81955cae8aed76aadc8e/winrt_windows_applicationmodel_core-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:13b3c71b8b67cd24ece0d27b92c2ccb77d83ac223708db5acfb4b6a30d96a627", size = 67013, upload-time = "2025-06-06T06:49:39.029Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/081c70ad22bd346cb4dd16f734de000e1099494faeb5a9ddf6643aeac589/winrt_windows_applicationmodel_core-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fca22ddc37e0ca5ac58cc2810fec648dd5cdf621fbe625e894f2910c604ad01f", size = 67661, upload-time = "2025-06-06T06:49:40.144Z" }, + { url = "https://files.pythonhosted.org/packages/66/b4/204ab51c7ac02d1e81d4aae6a6abe45355cc7962ed3cd1f22036c73c096c/winrt_windows_applicationmodel_core-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:57a215f8e9777d6d98a1af7d4e07ed44722a4dff6c67339d75d180e1160aa2f5", size = 70191, upload-time = "2025-06-06T06:49:40.945Z" }, + { url = "https://files.pythonhosted.org/packages/99/ec/9d2d320926faac606e8d9700198cde315675c652140289e5e92c5514a919/winrt_windows_applicationmodel_core-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:182ea82b484ae90770a31100f27b3195cf0fc14dbcb535998f522066a21c511e", size = 66907, upload-time = "2025-06-06T06:49:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d0/3d215ea3786d432b91bc6af51088bb2e3821ac116faf184247072f711d4e/winrt_windows_applicationmodel_core-3.2.1-cp312-cp312-win32.whl", hash = "sha256:2a55b0a7205d7ba391719772111d2ac1f79f881770ccb6d81cdbb6d099eb46ad", size = 68033, upload-time = "2025-06-06T06:49:42.556Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e0/dd9d0c928592166b36047b6ab71a896d4d12356624a0da65fe7fec8be147/winrt_windows_applicationmodel_core-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:79cdba542f3e2ae7fdc8072a94fb1cace0ebad7f5c272cd250ca8c5baae59133", size = 70162, upload-time = "2025-06-06T06:49:43.393Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e4/186c8af1bda1cd09df3403dd985ae7324006fa1d5fe3a4bd1c7f1388fd38/winrt_windows_applicationmodel_core-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:fa0da9015b59d88e48ff7251cfd3307daf3762ffdbf6633afd7298846005cbbe", size = 67268, upload-time = "2025-06-06T06:49:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a3/9665e9a31764d1b3c33f156c004c41367fd8c982ab9c7fb3d8a37b3d61a4/winrt_windows_applicationmodel_core-3.2.1-cp313-cp313-win32.whl", hash = "sha256:806adb41eace8f2e131c41f1d8b192269d661fe6cc061559385384d05851d5d7", size = 68046, upload-time = "2025-06-06T06:49:45.031Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/ee5cb5e8d79c46e500961af8323a4b565d51ffaae569d78ab382d387f466/winrt_windows_applicationmodel_core-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:4ff4ed0a24c793bf34ef4c56410f9f862d76bfd01757f78a475145cf0879032d", size = 70165, upload-time = "2025-06-06T06:49:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/32237d4cefa045ff472f2103635eaa94cfc374128ac1e9811fd339fcafcb/winrt_windows_applicationmodel_core-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:a61390e9cc257cd67b8ecedee82ecbed7cf04b3c2371f1380608146709656f31", size = 67274, upload-time = "2025-06-06T06:49:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/b5449887abdd369be490291227c5a2d3829dea37f83326f58506eaff1447/winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2a940b018989ba7159ace4c93eab5f5bce303ac5325ab29eeb0effe1d4a486a7", size = 70241, upload-time = "2025-09-20T07:07:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/78/17352b0dba22ede5103554dec637fd419c1e1d54078b09a73fdcd8678cec/winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:52f2e1cd5fa0a5b0592ddc40b423255491a40a2d6e4cf6ae766dd3b4810557ec", size = 71994, upload-time = "2025-09-20T07:07:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/cb6ae65cfabfc527aea0d702c2cfcaf58859813e38172fffc66d5dd845f5/winrt_windows_applicationmodel_core-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0c18662c835fbb5044a5c3ca021d8f2f3f290c6238543ed74112a18e3d7d878", size = 70084, upload-time = "2025-09-20T07:07:50.466Z" }, +] + +[[package]] +name = "winrt-windows-data-xml-dom" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/11/6b1e59abf5f8f0cba1fee13447c9d077d144332b9b664bb236fdfa878558/winrt_windows_data_xml_dom-3.2.1.tar.gz", hash = "sha256:db81f5956bf63ce8c9d8bfb6d0794ab2849f26e62a6256b833af8de3d6451399", size = 50032, upload-time = "2025-06-06T14:41:15.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/c7/579d87415e2cbb6e32c1b66d7d6ea62b8c159b1a4c2ef6904a2736492ac6/winrt_windows_data_xml_dom-3.2.1-cp310-cp310-win32.whl", hash = "sha256:ac2c823a328f6a532b45067d3ced3490aebc14afaac9fd9d04a1dad7cb8be74b", size = 158208, upload-time = "2025-06-06T06:58:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/f9/af/ea46193569ada4262d9ee7c57d48f312719136b7729fc39eae8bbe87800b/winrt_windows_data_xml_dom-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:bf9125e5c84e595d7dafb36c20ed49170c14b96c0c544ba9acba1f1b5a347a2a", size = 189116, upload-time = "2025-06-06T06:58:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/61/1f/afc526b7cd6a167f8092e8a58d883519af9e4a7f028ce3fb883072e363dd/winrt_windows_data_xml_dom-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:0617d77e9be9adb13a8620394dac0ae03714a2cadc5ed7728f4761ec1148fd77", size = 160845, upload-time = "2025-06-06T06:58:50.75Z" }, + { url = "https://files.pythonhosted.org/packages/82/c4/7d068980b5d6865b0b91a2d4de151e7b6dbd2f5e154737fd83a4e88cca05/winrt_windows_data_xml_dom-3.2.1-cp311-cp311-win32.whl", hash = "sha256:50e09bff8652adacb91115d5c6ff6372f4c9c9d277d63b5e4d3958b8c34420cf", size = 157996, upload-time = "2025-06-06T06:58:52.051Z" }, + { url = "https://files.pythonhosted.org/packages/9c/37/41a4d70ed6b8b30d8e4571540fde2d6d9a476260fa951567979d2e36eb84/winrt_windows_data_xml_dom-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:a507631812307ad65d8928a382ac2b9f21b81627db11410570e6686951e0dc55", size = 188944, upload-time = "2025-06-06T06:58:52.981Z" }, + { url = "https://files.pythonhosted.org/packages/6e/65/6fdf6c8f214ef0fe778018d3fa0e2b37e5cdb0a0ac5a563b401d7f58ae61/winrt_windows_data_xml_dom-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0d8ed4093792d207e8961bd50665374e15b49d8f7a274369257e4ad93e0731a5", size = 160759, upload-time = "2025-06-06T06:58:53.799Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/215e54dce3099507d2a1009792d32555ec1354bfb43cb3431e7389ec3dda/winrt_windows_data_xml_dom-3.2.1-cp312-cp312-win32.whl", hash = "sha256:be2e9edbc4702c0cfd88ae48260b55bb30ee3e15c86c6c4240aaf4f2c54ff380", size = 158777, upload-time = "2025-06-06T06:58:54.755Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f0/b44bdce659118fb684a45f6c4e4932cb7356883079b7ce284352433ecf3f/winrt_windows_data_xml_dom-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:b531a61a30106ecc7c7b3ad8f1b22e98d8366bbc2c60c47f5053fe62479109d6", size = 186784, upload-time = "2025-06-06T06:58:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/78/57/cbd053f01b0a50073e4b6e92d1d1e97e992a8cd7dea3dd9afc35a19f5e57/winrt_windows_data_xml_dom-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:0b3eed7fd618d0bfbafea730bbcb24cb6a94dcd742b2b9c7d5d66319494e5f35", size = 160634, upload-time = "2025-06-06T06:58:56.589Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/682185135537c99629663935155c2332bc500553a2f6aad134deadd2c2d3/winrt_windows_data_xml_dom-3.2.1-cp313-cp313-win32.whl", hash = "sha256:7091ee8d472a18336887edee6bf90d9261783f82b1a0fec00ba0e6a1a00623eb", size = 158825, upload-time = "2025-06-06T06:58:57.484Z" }, + { url = "https://files.pythonhosted.org/packages/e6/02/89057b180a2dda10af0ae8a256180e7f6ec8f688b547501a89a36c72c4e4/winrt_windows_data_xml_dom-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b51b67c0bf88995827151d4e8ff0edcb2120406d4aedcb9574854b904b92a11f", size = 186818, upload-time = "2025-06-06T06:58:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/e5/aa/9894e19b34275307e4857ea3ada1d230bcdc7e399ce0ae504c6309c7e55d/winrt_windows_data_xml_dom-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:e4ed27ec593b96bcd13fffd9fd0bf74744b0dacc249faa5af0ef1d903ed59c1b", size = 160670, upload-time = "2025-06-06T06:58:59.225Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/22556c7dd4d777c35a270d776a87c4de7e8316d91b51888816e565d4f1f0/winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win32.whl", hash = "sha256:77258030a725a32359fd2a93855cafa0e3f3f38e67afd5dfe74a8a215cbb31c4", size = 160792, upload-time = "2025-09-20T07:09:36.528Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d2/3a4b7e38feafdec21cb044e0c1ee2c76a71cfbda02bfedcc50634e9fb652/winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:1cf1b6f31fff4e4c0ae30f1643b169da72b3b053a2996010f2c3a1e26b5d4970", size = 189170, upload-time = "2025-09-20T07:09:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/45/cc/9d301892e9a640a63ac56f565d38d5897002598e18acdfbd4ed168836ab3/winrt_windows_data_xml_dom-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:6de83f853d889444e4897413c2fa8183f86aa602edd34abf36a4fdc69db1650e", size = 169804, upload-time = "2025-09-20T07:09:38.448Z" }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/d387332c4378b41f87211b7dc40a4cfc6b7047dc227448aaa207624fc911/winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b", size = 111969, upload-time = "2025-06-06T07:10:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/046c1e2424627c3db66d764871186de4d26936e8a138d6bf04dc143e4606/winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39", size = 118695, upload-time = "2025-06-06T07:10:56.782Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2e/2463bc4ad984836fb3ecf1abac62df67bc5cabab004cad09b828b86ed51b/winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4", size = 109690, upload-time = "2025-06-06T07:10:57.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944, upload-time = "2025-06-06T07:10:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465, upload-time = "2025-06-06T07:10:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609, upload-time = "2025-06-06T07:11:00.54Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169, upload-time = "2025-06-06T07:11:01.438Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668, upload-time = "2025-06-06T07:11:02.475Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671, upload-time = "2025-06-06T07:11:03.538Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184, upload-time = "2025-06-06T07:11:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672, upload-time = "2025-06-06T07:11:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673, upload-time = "2025-06-06T07:11:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/d77346e39fe0c81f718cde49f83fe77c368c0e14c6418f72dfa1e7ef22d0/winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da", size = 114590, upload-time = "2025-09-20T07:11:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/a1/56/4d2b545bea0f34f68df6d4d4ca22950ff8a935497811dccdc0ca58737a05/winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5", size = 122148, upload-time = "2025-09-20T07:11:50.826Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/b9d3a11cac73444c0a3703200161cd7267dab5ab85fd00e1f965526e74a8/winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87", size = 114360, upload-time = "2025-09-20T07:11:51.626Z" }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/26/ed3d35ea262999d28be957c35a32e93360eac0ef9f14e75d32cd6b5c6a37/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389", size = 59880, upload-time = "2025-06-06T07:11:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/b4a1aeba2d13c1f2ad3d851d5092b8397c05f34fb318d6a7d499f5b5720b/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6", size = 70650, upload-time = "2025-06-06T07:11:11.396Z" }, + { url = "https://files.pythonhosted.org/packages/9f/74/f8a4a29202da24f2af2c4a8f515b0a44fe46bc4d25b3d54ea2249e980bd3/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6", size = 59216, upload-time = "2025-06-06T07:11:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693, upload-time = "2025-06-06T07:11:13.388Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828, upload-time = "2025-06-06T07:11:14.54Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051, upload-time = "2025-06-06T07:11:15.379Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0b/7802349391466d3f7e8f62f588f36a1a0b6560abfcdbdaa426fe21d322b4/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6", size = 60060, upload-time = "2025-06-06T07:11:16.173Z" }, + { url = "https://files.pythonhosted.org/packages/37/94/5b888713e472746635a382e523513ab1b8200af55c5b56bc70e1e4369115/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b", size = 69058, upload-time = "2025-06-06T07:11:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/829273622c9b37c67b97f187b92be318404f7d33db045e31d72b7d50f54c/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84", size = 58793, upload-time = "2025-06-06T07:11:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063, upload-time = "2025-06-06T07:11:18.65Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057, upload-time = "2025-06-06T07:11:19.446Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792, upload-time = "2025-06-06T07:11:20.24Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/b3301d964422d4611c181348149a7c5956a2a76e6339de451a000d4ae8e7/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab", size = 62211, upload-time = "2025-09-20T07:11:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/20/59/5f2c940ff606297129e93ebd6030c813e6a43a786de7fc33ccb268e0b06b/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4", size = 70399, upload-time = "2025-09-20T07:11:53.254Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2c8eb89062c71d4be73d618457ed68e7e2ba29a660ac26349d44fc121cbf/winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01", size = 61392, upload-time = "2025-09-20T07:11:53.993Z" }, +] + +[[package]] +name = "winrt-windows-ui-notifications" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/f9/4798657fd6999e6dae2c84876b40e91873ccd5f056b27c40ebd7f4ff11d8/winrt_windows_ui_notifications-3.2.1.tar.gz", hash = "sha256:eb29bf1c3306f3ceabd71d092b5505d5fe73fbc0660347dcc63ebc4204d34d21", size = 28942, upload-time = "2025-06-06T14:43:54.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/2c/961881ccf72921590d8a97e586d3c193c4b7edbf20b221b6b8f295f7cf2d/winrt_windows_ui_notifications-3.2.1-cp310-cp310-win32.whl", hash = "sha256:ad09c01d72298954c9401f085f1519045a07d140103bd93a041d6a9664c7dbd1", size = 154588, upload-time = "2025-06-06T14:11:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/ab/75/2ecfe4fe2b046b0c7bb1143133f1a59f4f3b91fff72c76dc5a07f22c10f3/winrt_windows_ui_notifications-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9e3a4ae4669387e675df4ffc21591fea9c0d8af4d175679583b9dceec49ef468", size = 167574, upload-time = "2025-06-06T14:11:43.125Z" }, + { url = "https://files.pythonhosted.org/packages/8a/71/77867c84764d9b060a6af2388ee62eaaa8bf779b53f1043fcb8375b9150f/winrt_windows_ui_notifications-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:716341570e4e425ebdb4ff4cd41ca11f8bf305c780f1cb95b4d7453a3fe730dc", size = 155124, upload-time = "2025-06-06T14:11:44.15Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/1f4a332781b8818c9cc7f8c985b57448e5d371bf57dcef765a2fa990d4db/winrt_windows_ui_notifications-3.2.1-cp311-cp311-win32.whl", hash = "sha256:ad2bbdde0529766b2c4866204c0a7dedea0666fab8ec4c971824f2c1f40e90c6", size = 154334, upload-time = "2025-06-06T14:11:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/fa18f6aa07fb9a00c57aec369f6e59877c76e26625ec0ca82cc2514c87a2/winrt_windows_ui_notifications-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:61ee8904ca17681c3e96fcf40bc115b8e1efe72c460e6df34b47eb254343018a", size = 167688, upload-time = "2025-06-06T14:11:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/ecc9721108bf4518028dc185b8eb95d598e1b1c808e562b1e78c11687925/winrt_windows_ui_notifications-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:db2dbf9dc6b836575f94a1fe165ac184d841ba5aeaed6870a3fe68367341cef3", size = 155009, upload-time = "2025-06-06T14:11:46.94Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/d495b602154ee70ede2baf3fdb1c60703a581f678e1acc70089a3bdc7d60/winrt_windows_ui_notifications-3.2.1-cp312-cp312-win32.whl", hash = "sha256:041643bd83762900cdd5704a6e08f886879f78952cfc9834b184ee81df924d72", size = 154616, upload-time = "2025-06-06T14:11:47.788Z" }, + { url = "https://files.pythonhosted.org/packages/f7/68/0775e390275cc8fcbd0515eed51e43d78562bfa5d647454e3c8992c2ac8c/winrt_windows_ui_notifications-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7f2378246f6db14d16bf93830f1bcd8efa1c5b4c740878ad86d2f482fb08feab", size = 165782, upload-time = "2025-06-06T14:11:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fa/09e304022e0ccb4816b27627b04783526e496fe84fd84fade79465b8ad2f/winrt_windows_ui_notifications-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:e7eda8cb11a1e82d2da87b2b98cbf73ce153417cb93debe95c739534c6dbde7c", size = 155243, upload-time = "2025-06-06T14:11:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/ab/34/eee24f0ff5522ff3d9c91c983c706fc2a06ed78a5aa84dd9b9fe51125b78/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win32.whl", hash = "sha256:cff07f0193365c38a07cf87030c609b57b7ca7fcbf8740c720425edfa6205fa8", size = 154502, upload-time = "2025-06-06T14:11:52.135Z" }, + { url = "https://files.pythonhosted.org/packages/14/7c/8163639827003c411787d6f6f682e543206ecb47abd29291d184006b5834/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:9f8d109c8c6634dac258a32d7f3d939aa50605bd7a6a43137a3ffc2ab4a8f667", size = 165795, upload-time = "2025-06-06T14:11:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/0d/10/89012eeb14b932eb3f0ac541d8788d794e09f8fe1934fdae1760d0cbd252/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:aec78f6a632e2ae57a55b7aee60f8df848ccffd1c4d14c3c9fbfaa6bb564e1a2", size = 155184, upload-time = "2025-06-06T14:11:53.863Z" }, + { url = "https://files.pythonhosted.org/packages/68/43/c81672457002908b8c0f1e4672db1d0b8885c2894c6768fe9b0162af9ee5/winrt_windows_ui_notifications-3.2.1-cp314-cp314-win32.whl", hash = "sha256:00a7579ca0870134c27def1418c259fb18efe5b88aa399de85b4712f1add1b55", size = 157561, upload-time = "2025-09-20T07:19:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/17/d8/910a58698d722120f2401c3bfeb6018361d92f3b2814caae839f54693305/winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:943599c727abf710ae94644b1d521e11857bd568e080e894a8be11aa717e383a", size = 170152, upload-time = "2025-09-20T07:19:11.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a3/1db0f2ec28a4eadd0876c0ac3a3d78514027c5afe35737b164e03c98b234/winrt_windows_ui_notifications-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2a496c7e796b537966df7b26753b20bc40c5c1fbe96b3b3051a1debc00b80f07", size = 163245, upload-time = "2025-09-20T07:19:12.352Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/scripts/check_source_boundary.py b/scripts/check_source_boundary.py index 3779f8ef69..6d5142f78d 100644 --- a/scripts/check_source_boundary.py +++ b/scripts/check_source_boundary.py @@ -91,6 +91,7 @@ "packages/capture/source-policy.public.json", "packages/privacy/source-policy.public.json", "packages/desktop/source-policy.public.json", + "packages/tray/source-policy.public.json", ) # Binary-ish extensions we skip for content scanning (path scan still applies). diff --git a/scripts/verify_release_hosting.py b/scripts/verify_release_hosting.py index a4514656cb..1c53002b7e 100644 --- a/scripts/verify_release_hosting.py +++ b/scripts/verify_release_hosting.py @@ -15,7 +15,7 @@ # The launcher tags v. A package from packages/ tags # -v; the project names are an exact list. RELEASE_TAG = re.compile( - r"^(?:openadapt-(?:agent|capture|desktop|flow|privacy|types)-)?" + r"^(?:openadapt-(?:agent|capture|desktop|flow|privacy|tray|types)-)?" r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$" ) REQUIRED_TAG_RULES = frozenset({"creation", "update", "deletion"}) diff --git a/tests/test_release_hosting.py b/tests/test_release_hosting.py index 7af4ecb392..f111fd4e6f 100644 --- a/tests/test_release_hosting.py +++ b/tests/test_release_hosting.py @@ -100,6 +100,7 @@ def _package_ruleset(pattern: str, *rule_types: str) -> dict: "openadapt-privacy", "openadapt-desktop", "openadapt-capture", + "openadapt-tray", ], ) def test_release_hosting_accepts_a_package_tag_under_its_own_rules(package: str) -> None: