Compare commits
10 Commits
3cbe529b7a
...
eefb908268
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eefb908268
|
||
|
|
d71bac7b93
|
||
|
|
5b71b2275b
|
||
|
|
c0477d2f40
|
||
|
|
ba656291ab
|
||
|
|
1a5de8cf8a
|
||
|
|
a5ca7f5d33
|
||
|
|
b31f39268e
|
||
|
|
bce6ec39f8
|
||
|
|
bcd8d4da1a
|
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,3 +6,9 @@ build/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg
|
*.egg
|
||||||
.venv/
|
.venv/
|
||||||
|
a11y_dump.txt
|
||||||
|
agent-tools/
|
||||||
|
.cursor/
|
||||||
|
docs/
|
||||||
|
chat-summaries/
|
||||||
|
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 cottongin
|
||||||
|
|
||||||
|
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.
|
||||||
105
README.md
Normal file
105
README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# cursor-flasher
|
||||||
|
|
||||||
|
A macOS daemon that flashes a pulsing border and plays a sound when your [Cursor](https://cursor.com) AI agent needs attention.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Uses [Cursor hooks](https://cursor.com/docs/agent/hooks) for reliable detection:
|
||||||
|
|
||||||
|
- **`preToolUse`** — fires when the agent wants to run a shell command, write a file, or use any tool that may need approval. **Pulses** the border continuously and plays a sound until you click the Cursor window.
|
||||||
|
- **`stop`** — fires when the agent loop ends. **Flashes** the border once, briefly.
|
||||||
|
|
||||||
|
Only tools in the `approval_tools` list trigger the pulse (default: `Shell`, `Write`, `Delete`). Auto-approved tools like `Read` and `Grep` are ignored.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- macOS
|
||||||
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
- Cursor IDE
|
||||||
|
- **Accessibility** permission for your terminal (System Settings → Privacy & Security → Accessibility) — needed for window enumeration
|
||||||
|
- **Input Monitoring** permission for the daemon process (System Settings → Privacy & Security → Input Monitoring) — needed for input-based pulse dismissal
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and install
|
||||||
|
git clone https://code.cottongin.xyz/cursor-flasher && cd cursor-flasher
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Install Cursor hooks (global, applies to all projects)
|
||||||
|
uv run cursor-flasher install
|
||||||
|
|
||||||
|
# Start the daemon
|
||||||
|
uv run cursor-flasher start
|
||||||
|
```
|
||||||
|
|
||||||
|
The `install` command copies the hook script to `~/.cursor/hooks/` and adds entries to `~/.cursor/hooks.json`. Cursor auto-reloads hooks.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cursor-flasher start # background daemon
|
||||||
|
uv run cursor-flasher start --foreground # foreground (for debugging)
|
||||||
|
uv run cursor-flasher status
|
||||||
|
uv run cursor-flasher stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Optional config file at `~/.cursor-flasher/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
running: # approval pulse (continuous until you interact)
|
||||||
|
color: "#FF9500" # border color (hex)
|
||||||
|
width: 4 # border thickness in pixels
|
||||||
|
opacity: 0.85 # max border opacity
|
||||||
|
pulse_speed: 1.5 # pulse cycle speed in seconds
|
||||||
|
sound: "Glass" # macOS system sound ("" to disable)
|
||||||
|
volume: 0.5 # 0.0 to 1.0
|
||||||
|
# sounds: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
|
||||||
|
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
|
||||||
|
|
||||||
|
completed: # agent stop flash (brief fade-in/out)
|
||||||
|
color: "#00FF00" # different color for completion
|
||||||
|
width: 4
|
||||||
|
opacity: 0.85
|
||||||
|
duration: 1.5 # flash duration in seconds
|
||||||
|
sound: "" # no sound by default (Cursor plays its own)
|
||||||
|
volume: 0.0
|
||||||
|
|
||||||
|
flash:
|
||||||
|
mode: "screen" # "window", "screen", or "allscreens"
|
||||||
|
|
||||||
|
# Tools that trigger the pulse + sound (approval mode).
|
||||||
|
# Others are silently ignored (e.g., Read, Grep, Glob, Task).
|
||||||
|
approval_tools:
|
||||||
|
- Shell
|
||||||
|
- Write
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
general:
|
||||||
|
approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals)
|
||||||
|
cooldown: 2.0 # minimum seconds between flashes
|
||||||
|
```
|
||||||
|
|
||||||
|
Each mode (`running` and `completed`) has its own color, border style, and sound settings. Set `sound: ""` to disable sound for a particular mode.
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cursor-flasher uninstall
|
||||||
|
uv run cursor-flasher stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Flashing on every tool call (too noisy):**
|
||||||
|
- Edit `~/.cursor-flasher/config.yaml` and narrow down `approval_tools` to just `Shell`.
|
||||||
|
|
||||||
|
**No flash at all:**
|
||||||
|
- Check daemon: `uv run cursor-flasher status`
|
||||||
|
- Check hooks installed: `ls ~/.cursor/hooks/cursor-flasher-notify.py`
|
||||||
|
- Check Cursor Settings → Hooks tab for execution logs
|
||||||
|
|
||||||
|
**Pulse doesn't stop:**
|
||||||
|
- Click the Cursor window to bring it to focus — the pulse auto-dismisses when Cursor is the frontmost app.
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# Cursor Flasher — Design Document
|
|
||||||
|
|
||||||
**Date:** 2026-03-10
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
When Cursor's AI agent finishes its turn and is waiting for user input (approval, answering a question, or continuing the conversation), there's no visual or audible signal. The user has to keep checking the window manually.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
A macOS background daemon that monitors Cursor's accessibility tree and displays a pulsing border overlay around the Cursor window when the agent is waiting for user input. Optionally plays a system sound.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Detection: Accessibility Tree Polling
|
|
||||||
|
|
||||||
A Python process using `pyobjc` polls Cursor's accessibility tree every ~500ms via `AXUIElement` APIs.
|
|
||||||
|
|
||||||
**Detection signals:**
|
|
||||||
- **Approval needed:** Accept/Reject button elements appear in the a11y tree
|
|
||||||
- **Agent turn complete:** Stop/Cancel button disappears, or thinking indicator goes away
|
|
||||||
- **Chat input active:** Chat text area becomes the focused element after being inactive
|
|
||||||
|
|
||||||
**State machine:**
|
|
||||||
- `IDLE` — not monitoring (Cursor not in chat or not running)
|
|
||||||
- `AGENT_WORKING` — agent is generating (Stop button visible, thinking indicator present)
|
|
||||||
- `WAITING_FOR_USER` — agent is done, user hasn't interacted yet → **trigger flash**
|
|
||||||
- `USER_INTERACTING` — user started typing/clicking → dismiss flash, return to IDLE
|
|
||||||
|
|
||||||
The detection heuristics will be tuned during development after dumping Cursor's full a11y tree.
|
|
||||||
|
|
||||||
### Visual Effect: Native macOS Overlay
|
|
||||||
|
|
||||||
- A borderless, transparent, non-interactive `NSWindow` positioned over Cursor's window frame
|
|
||||||
- Draws only a pulsing border (interior is fully click-through)
|
|
||||||
- Core Animation drives the pulse: opacity oscillates between configurable min/max
|
|
||||||
- Default: ~4px amber border, 1.5s cycle
|
|
||||||
|
|
||||||
**Dismissal triggers:**
|
|
||||||
- Keyboard input to Cursor (via accessibility or `CGEventTap`)
|
|
||||||
- Mouse click in Cursor's chat area
|
|
||||||
- Timeout (default 5 minutes)
|
|
||||||
- Agent starts working again (Stop button reappears)
|
|
||||||
|
|
||||||
### Sound
|
|
||||||
|
|
||||||
On transition to `WAITING_FOR_USER`, optionally plays a macOS system sound (default: "Glass"). Configurable sound name, volume, and on/off toggle.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
File: `~/.cursor-flasher/config.yaml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pulse:
|
|
||||||
color: "#FF9500"
|
|
||||||
width: 4
|
|
||||||
speed: 1.5
|
|
||||||
opacity_min: 0.3
|
|
||||||
opacity_max: 1.0
|
|
||||||
|
|
||||||
sound:
|
|
||||||
enabled: true
|
|
||||||
name: "Glass"
|
|
||||||
volume: 0.5
|
|
||||||
|
|
||||||
detection:
|
|
||||||
poll_interval: 0.5
|
|
||||||
cooldown: 3.0
|
|
||||||
|
|
||||||
timeout:
|
|
||||||
auto_dismiss: 300
|
|
||||||
```
|
|
||||||
|
|
||||||
### Process Management
|
|
||||||
|
|
||||||
- CLI: `cursor-flasher start`, `cursor-flasher stop`, `cursor-flasher status`
|
|
||||||
- Runs as a background daemon
|
|
||||||
- No menu bar icon (MVP scope)
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- Python 3.10+
|
|
||||||
- `pyobjc-framework-Cocoa` — NSWindow, NSApplication, Core Animation
|
|
||||||
- `pyobjc-framework-Quartz` — AXUIElement, CGEventTap, window management
|
|
||||||
- `PyYAML` — configuration file parsing
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
- `pip install -e .` from the project directory
|
|
||||||
- Requires Accessibility permission: System Settings > Privacy & Security > Accessibility (grant to Terminal or Python)
|
|
||||||
|
|
||||||
## Scope / Non-goals
|
|
||||||
|
|
||||||
- **In scope:** Detection, overlay, sound, CLI, config file
|
|
||||||
- **Not in scope (MVP):** Menu bar icon, auto-start on login, multi-monitor awareness, Linux/Windows support
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- **A11y tree fragility:** Cursor UI updates could change element names/structure, breaking detection. Mitigation: make detection patterns configurable, log warnings on detection failures.
|
|
||||||
- **Accessibility permissions:** Users must grant permission manually. Mitigation: clear error message and instructions on first run.
|
|
||||||
- **Performance:** Polling a11y tree every 500ms could have CPU cost. Mitigation: only poll when Cursor is the frontmost app or recently active.
|
|
||||||
File diff suppressed because it is too large
Load Diff
40
hooks/notify.py
Executable file
40
hooks/notify.py
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
|
||||||
|
|
||||||
|
Installed as a Cursor hook (preToolUse, stop) to trigger a window flash
|
||||||
|
when the agent needs user attention. Reads hook JSON from stdin, extracts
|
||||||
|
workspace and event info, and sends it to the daemon's socket.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
workspace_roots = data.get("workspace_roots") or []
|
||||||
|
workspace = workspace_roots[0] if workspace_roots else ""
|
||||||
|
event = data.get("hook_event_name", "")
|
||||||
|
tool = data.get("tool_name", "")
|
||||||
|
|
||||||
|
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(1)
|
||||||
|
s.connect(SOCKET_PATH)
|
||||||
|
s.sendall(msg.encode())
|
||||||
|
s.close()
|
||||||
|
except (ConnectionRefusedError, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,10 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "cursor-flasher"
|
name = "cursor-flasher"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Flash Cursor's window when the AI agent is waiting for input"
|
description = "Flash Cursor's window when the AI agent needs attention"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [{ name = "cottongin" }]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"pyobjc-framework-applicationservices>=12.1",
|
||||||
"pyobjc-framework-Cocoa",
|
"pyobjc-framework-Cocoa",
|
||||||
"pyobjc-framework-Quartz",
|
"pyobjc-framework-Quartz",
|
||||||
"PyYAML",
|
"PyYAML",
|
||||||
@@ -16,8 +20,14 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest", "pytest-mock"]
|
dev = ["pytest", "pytest-mock"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://code.cottongin.xyz/cursor-flasher"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cursor-flasher = "cursor_flasher.cli:main"
|
cursor-flasher = "cursor_flasher.cli:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
dev-dependencies = ["pytest", "pytest-mock"]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pyobjc-framework-Cocoa
|
|
||||||
pyobjc-framework-Quartz
|
|
||||||
PyYAML
|
|
||||||
pytest
|
|
||||||
pytest-mock
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Dump the accessibility tree of the Cursor application.
|
|
||||||
|
|
||||||
Usage: python scripts/dump_a11y_tree.py [--depth N]
|
|
||||||
|
|
||||||
Requires Accessibility permissions for the running terminal.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from ApplicationServices import (
|
|
||||||
AXUIElementCreateApplication,
|
|
||||||
AXUIElementCopyAttributeNames,
|
|
||||||
AXUIElementCopyAttributeValue,
|
|
||||||
)
|
|
||||||
from Cocoa import NSWorkspace
|
|
||||||
|
|
||||||
|
|
||||||
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
|
|
||||||
|
|
||||||
|
|
||||||
def find_cursor_pid() -> int | None:
|
|
||||||
"""Find the PID of the running Cursor application."""
|
|
||||||
workspace = NSWorkspace.sharedWorkspace()
|
|
||||||
for app in workspace.runningApplications():
|
|
||||||
bundle = app.bundleIdentifier() or ""
|
|
||||||
if bundle == CURSOR_BUNDLE_ID:
|
|
||||||
return app.processIdentifier()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def dump_element(element, depth: int = 0, max_depth: int = 5) -> None:
|
|
||||||
"""Recursively print an AXUIElement's attributes."""
|
|
||||||
if depth > max_depth:
|
|
||||||
return
|
|
||||||
|
|
||||||
indent = " " * depth
|
|
||||||
names_err, attr_names = AXUIElementCopyAttributeNames(element, None)
|
|
||||||
if names_err or not attr_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
role = ""
|
|
||||||
title = ""
|
|
||||||
value = ""
|
|
||||||
description = ""
|
|
||||||
|
|
||||||
for name in attr_names:
|
|
||||||
err, val = AXUIElementCopyAttributeValue(element, name, None)
|
|
||||||
if err:
|
|
||||||
continue
|
|
||||||
if name == "AXRole":
|
|
||||||
role = str(val)
|
|
||||||
elif name == "AXTitle":
|
|
||||||
title = str(val) if val else ""
|
|
||||||
elif name == "AXValue":
|
|
||||||
value_str = str(val)[:100] if val else ""
|
|
||||||
value = value_str
|
|
||||||
elif name == "AXDescription":
|
|
||||||
description = str(val) if val else ""
|
|
||||||
|
|
||||||
label = role
|
|
||||||
if title:
|
|
||||||
label += f' title="{title}"'
|
|
||||||
if description:
|
|
||||||
label += f' desc="{description}"'
|
|
||||||
if value:
|
|
||||||
label += f' value="{value}"'
|
|
||||||
|
|
||||||
print(f"{indent}{label}")
|
|
||||||
|
|
||||||
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
|
|
||||||
if not err and children:
|
|
||||||
for child in children:
|
|
||||||
dump_element(child, depth + 1, max_depth)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Dump Cursor's accessibility tree")
|
|
||||||
parser.add_argument("--depth", type=int, default=8, help="Max depth to traverse")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
pid = find_cursor_pid()
|
|
||||||
if pid is None:
|
|
||||||
print("Cursor is not running.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Found Cursor at PID {pid}")
|
|
||||||
app_element = AXUIElementCreateApplication(pid)
|
|
||||||
dump_element(app_element, max_depth=args.depth)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Manual test: shows a pulsing border around Cursor for 10 seconds."""
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from Cocoa import NSApplication, NSRunLoop, NSDate
|
|
||||||
|
|
||||||
from cursor_flasher.config import Config
|
|
||||||
from cursor_flasher.overlay import OverlayManager
|
|
||||||
from cursor_flasher.detector import CursorDetector
|
|
||||||
|
|
||||||
app = NSApplication.sharedApplication()
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
overlay = OverlayManager(config)
|
|
||||||
detector = CursorDetector()
|
|
||||||
|
|
||||||
pid = detector._find_cursor_pid()
|
|
||||||
if pid is None:
|
|
||||||
print("Cursor not running")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Showing overlay for PID {pid} for 10 seconds...")
|
|
||||||
overlay.show(pid)
|
|
||||||
|
|
||||||
end_time = time.time() + 10
|
|
||||||
while time.time() < end_time:
|
|
||||||
NSRunLoop.currentRunLoop().runUntilDate_(
|
|
||||||
NSDate.dateWithTimeIntervalSinceNow_(0.1)
|
|
||||||
)
|
|
||||||
|
|
||||||
overlay.hide()
|
|
||||||
print("Done.")
|
|
||||||
212
src/cursor_flasher/cli.py
Normal file
212
src/cursor_flasher/cli.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""CLI for cursor-flasher: install hooks, start/stop daemon."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PID_FILE = Path.home() / ".cursor-flasher" / "daemon.pid"
|
||||||
|
CURSOR_HOOKS_DIR = Path.home() / ".cursor" / "hooks"
|
||||||
|
CURSOR_HOOKS_JSON = Path.home() / ".cursor" / "hooks.json"
|
||||||
|
|
||||||
|
HOOK_SCRIPT_NAME = "cursor-flasher-notify.py"
|
||||||
|
|
||||||
|
HOOKS_CONFIG = {
|
||||||
|
"preToolUse": [
|
||||||
|
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||||
|
],
|
||||||
|
"postToolUse": [
|
||||||
|
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||||
|
],
|
||||||
|
"postToolUseFailure": [
|
||||||
|
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||||
|
],
|
||||||
|
"stop": [
|
||||||
|
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pid() -> None:
|
||||||
|
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
PID_FILE.write_text(str(os.getpid()))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pid() -> int | None:
|
||||||
|
if not PID_FILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pid = int(PID_FILE.read_text().strip())
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return pid
|
||||||
|
except (ValueError, ProcessLookupError, PermissionError):
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_pid() -> None:
|
||||||
|
PID_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_hook_source() -> Path:
|
||||||
|
"""Find the hook script source in the package."""
|
||||||
|
pkg_dir = Path(__file__).resolve().parent.parent.parent
|
||||||
|
return pkg_dir / "hooks" / "notify.py"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_install(args: argparse.Namespace) -> None:
|
||||||
|
"""Install Cursor hooks for flash notifications."""
|
||||||
|
CURSOR_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
src = _find_hook_source()
|
||||||
|
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
|
||||||
|
|
||||||
|
if not src.exists():
|
||||||
|
print(f"ERROR: Hook source not found at {src}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
dst.chmod(0o755)
|
||||||
|
print(f" Installed hook script: {dst}")
|
||||||
|
|
||||||
|
existing: dict = {}
|
||||||
|
if CURSOR_HOOKS_JSON.exists():
|
||||||
|
try:
|
||||||
|
existing = json.loads(CURSOR_HOOKS_JSON.read_text())
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
existing = {}
|
||||||
|
|
||||||
|
existing.setdefault("version", 1)
|
||||||
|
hooks = existing.setdefault("hooks", {})
|
||||||
|
|
||||||
|
for event, entries in HOOKS_CONFIG.items():
|
||||||
|
event_hooks = hooks.setdefault(event, [])
|
||||||
|
for entry in entries:
|
||||||
|
already = any(h.get("command") == entry["command"] for h in event_hooks)
|
||||||
|
if not already:
|
||||||
|
event_hooks.append(entry)
|
||||||
|
|
||||||
|
CURSOR_HOOKS_JSON.write_text(json.dumps(existing, indent=2) + "\n")
|
||||||
|
print(f" Updated hooks config: {CURSOR_HOOKS_JSON}")
|
||||||
|
print("\nInstallation complete. Cursor will auto-reload hooks.")
|
||||||
|
print("Start the daemon with: cursor-flasher start")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||||
|
"""Remove Cursor hooks."""
|
||||||
|
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
|
||||||
|
if dst.exists():
|
||||||
|
dst.unlink()
|
||||||
|
print(f" Removed hook script: {dst}")
|
||||||
|
|
||||||
|
if CURSOR_HOOKS_JSON.exists():
|
||||||
|
try:
|
||||||
|
config = json.loads(CURSOR_HOOKS_JSON.read_text())
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
hooks = config.get("hooks", {})
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for event in HOOKS_CONFIG:
|
||||||
|
if event in hooks:
|
||||||
|
before = len(hooks[event])
|
||||||
|
hooks[event] = [
|
||||||
|
h for h in hooks[event]
|
||||||
|
if h.get("command") != f"./hooks/{HOOK_SCRIPT_NAME}"
|
||||||
|
]
|
||||||
|
if len(hooks[event]) < before:
|
||||||
|
changed = True
|
||||||
|
if not hooks[event]:
|
||||||
|
del hooks[event]
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
CURSOR_HOOKS_JSON.write_text(json.dumps(config, indent=2) + "\n")
|
||||||
|
print(f" Cleaned hooks config: {CURSOR_HOOKS_JSON}")
|
||||||
|
|
||||||
|
print("\nUninstall complete.")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start(args: argparse.Namespace) -> None:
|
||||||
|
existing = _read_pid()
|
||||||
|
if existing is not None:
|
||||||
|
print(f"Daemon already running (PID {existing})")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.foreground:
|
||||||
|
from cursor_flasher.daemon import run_daemon
|
||||||
|
|
||||||
|
_write_pid()
|
||||||
|
try:
|
||||||
|
run_daemon()
|
||||||
|
finally:
|
||||||
|
_remove_pid()
|
||||||
|
else:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
print(f"Daemon started (PID {pid})")
|
||||||
|
return
|
||||||
|
|
||||||
|
os.setsid()
|
||||||
|
from cursor_flasher.daemon import run_daemon
|
||||||
|
|
||||||
|
_write_pid()
|
||||||
|
try:
|
||||||
|
run_daemon()
|
||||||
|
finally:
|
||||||
|
_remove_pid()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stop(args: argparse.Namespace) -> None:
|
||||||
|
pid = _read_pid()
|
||||||
|
if pid is None:
|
||||||
|
print("Daemon is not running")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
_remove_pid()
|
||||||
|
print(f"Daemon stopped (PID {pid})")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args: argparse.Namespace) -> None:
|
||||||
|
pid = _read_pid()
|
||||||
|
if pid is None:
|
||||||
|
print("Daemon is not running")
|
||||||
|
else:
|
||||||
|
print(f"Daemon is running (PID {pid})")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="cursor-flasher",
|
||||||
|
description="Flash the Cursor window when the AI agent needs attention",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
install_parser = sub.add_parser("install", help="Install Cursor hooks")
|
||||||
|
install_parser.set_defaults(func=cmd_install)
|
||||||
|
|
||||||
|
uninstall_parser = sub.add_parser("uninstall", help="Remove Cursor hooks")
|
||||||
|
uninstall_parser.set_defaults(func=cmd_uninstall)
|
||||||
|
|
||||||
|
start_parser = sub.add_parser("start", help="Start the daemon")
|
||||||
|
start_parser.add_argument(
|
||||||
|
"--foreground", "-f", action="store_true",
|
||||||
|
help="Run in the foreground (don't daemonize)",
|
||||||
|
)
|
||||||
|
start_parser.set_defaults(func=cmd_start)
|
||||||
|
|
||||||
|
stop_parser = sub.add_parser("stop", help="Stop the daemon")
|
||||||
|
stop_parser.set_defaults(func=cmd_stop)
|
||||||
|
|
||||||
|
status_parser = sub.add_parser("status", help="Check daemon status")
|
||||||
|
status_parser.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not hasattr(args, "func"):
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
args.func(args)
|
||||||
@@ -1,54 +1,82 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
STYLE_FIELDS = {
|
||||||
|
"color": str,
|
||||||
|
"width": int,
|
||||||
|
"opacity": float,
|
||||||
|
"duration": float,
|
||||||
|
"pulse_speed": float,
|
||||||
|
"sound": str,
|
||||||
|
"volume": float,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StyleConfig:
|
||||||
|
color: str = "#FF9500"
|
||||||
|
width: int = 4
|
||||||
|
opacity: float = 0.85
|
||||||
|
duration: float = 1.5
|
||||||
|
pulse_speed: float = 1.5
|
||||||
|
sound: str = "Glass"
|
||||||
|
volume: float = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _default_running() -> StyleConfig:
|
||||||
|
return StyleConfig()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_completed() -> StyleConfig:
|
||||||
|
return StyleConfig(sound="", volume=0.0)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
pulse_color: str = "#FF9500"
|
running: StyleConfig = field(default_factory=_default_running)
|
||||||
pulse_width: int = 4
|
completed: StyleConfig = field(default_factory=_default_completed)
|
||||||
pulse_speed: float = 1.5
|
|
||||||
pulse_opacity_min: float = 0.3
|
|
||||||
pulse_opacity_max: float = 1.0
|
|
||||||
|
|
||||||
sound_enabled: bool = True
|
flash_mode: str = "screen"
|
||||||
sound_name: str = "Glass"
|
|
||||||
sound_volume: float = 0.5
|
|
||||||
|
|
||||||
poll_interval: float = 0.5
|
approval_tools: list[str] = field(
|
||||||
cooldown: float = 3.0
|
default_factory=lambda: ["Shell", "Write", "Delete"]
|
||||||
|
)
|
||||||
|
|
||||||
auto_dismiss: int = 300
|
approval_delay: float = 2.5
|
||||||
|
cooldown: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
FIELD_MAP: dict[str, dict[str, str]] = {
|
GENERAL_FIELD_MAP: dict[str, str] = {
|
||||||
"pulse": {
|
"approval_delay": "approval_delay",
|
||||||
"color": "pulse_color",
|
"cooldown": "cooldown",
|
||||||
"width": "pulse_width",
|
|
||||||
"speed": "pulse_speed",
|
|
||||||
"opacity_min": "pulse_opacity_min",
|
|
||||||
"opacity_max": "pulse_opacity_max",
|
|
||||||
},
|
|
||||||
"sound": {
|
|
||||||
"enabled": "sound_enabled",
|
|
||||||
"name": "sound_name",
|
|
||||||
"volume": "sound_volume",
|
|
||||||
},
|
|
||||||
"detection": {
|
|
||||||
"poll_interval": "poll_interval",
|
|
||||||
"cooldown": "cooldown",
|
|
||||||
},
|
|
||||||
"timeout": {
|
|
||||||
"auto_dismiss": "auto_dismiss",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml"
|
DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_style(raw_section: dict, defaults: StyleConfig) -> StyleConfig:
|
||||||
|
"""Build a StyleConfig from a YAML section, falling back to defaults."""
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
for key, typ in STYLE_FIELDS.items():
|
||||||
|
if key in raw_section:
|
||||||
|
overrides[key] = typ(raw_section[key])
|
||||||
|
|
||||||
|
return StyleConfig(
|
||||||
|
color=overrides.get("color", defaults.color),
|
||||||
|
width=overrides.get("width", defaults.width),
|
||||||
|
opacity=overrides.get("opacity", defaults.opacity),
|
||||||
|
duration=overrides.get("duration", defaults.duration),
|
||||||
|
pulse_speed=overrides.get("pulse_speed", defaults.pulse_speed),
|
||||||
|
sound=overrides.get("sound", defaults.sound),
|
||||||
|
volume=overrides.get("volume", defaults.volume),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
||||||
"""Load config from YAML, falling back to defaults for missing values."""
|
"""Load config from YAML, falling back to defaults for missing values."""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -60,13 +88,28 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
|||||||
if not raw or not isinstance(raw, dict):
|
if not raw or not isinstance(raw, dict):
|
||||||
return Config()
|
return Config()
|
||||||
|
|
||||||
overrides: dict[str, Any] = {}
|
config_kwargs: dict[str, Any] = {}
|
||||||
for section, mapping in FIELD_MAP.items():
|
|
||||||
section_data = raw.get(section, {})
|
|
||||||
if not isinstance(section_data, dict):
|
|
||||||
continue
|
|
||||||
for yaml_key, field_name in mapping.items():
|
|
||||||
if yaml_key in section_data:
|
|
||||||
overrides[field_name] = section_data[yaml_key]
|
|
||||||
|
|
||||||
return Config(**overrides)
|
running_raw = raw.get("running")
|
||||||
|
if isinstance(running_raw, dict):
|
||||||
|
config_kwargs["running"] = _parse_style(running_raw, _default_running())
|
||||||
|
|
||||||
|
completed_raw = raw.get("completed")
|
||||||
|
if isinstance(completed_raw, dict):
|
||||||
|
config_kwargs["completed"] = _parse_style(completed_raw, _default_completed())
|
||||||
|
|
||||||
|
flash_raw = raw.get("flash")
|
||||||
|
if isinstance(flash_raw, dict) and "mode" in flash_raw:
|
||||||
|
config_kwargs["flash_mode"] = flash_raw["mode"]
|
||||||
|
|
||||||
|
general_raw = raw.get("general", {})
|
||||||
|
if isinstance(general_raw, dict):
|
||||||
|
for yaml_key, field_name in GENERAL_FIELD_MAP.items():
|
||||||
|
if yaml_key in general_raw:
|
||||||
|
config_kwargs[field_name] = general_raw[yaml_key]
|
||||||
|
|
||||||
|
tools = raw.get("approval_tools")
|
||||||
|
if isinstance(tools, list):
|
||||||
|
config_kwargs["approval_tools"] = [str(t) for t in tools]
|
||||||
|
|
||||||
|
return Config(**config_kwargs)
|
||||||
|
|||||||
293
src/cursor_flasher/daemon.py
Normal file
293
src/cursor_flasher/daemon.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Daemon that listens for flash triggers from Cursor hooks via a Unix socket."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
from Cocoa import NSApplication, NSRunLoop, NSDate
|
||||||
|
from Quartz import (
|
||||||
|
CGEventSourceSecondsSinceLastEventType,
|
||||||
|
kCGEventSourceStateHIDSystemState,
|
||||||
|
kCGEventLeftMouseDown,
|
||||||
|
kCGEventRightMouseDown,
|
||||||
|
kCGEventKeyDown,
|
||||||
|
)
|
||||||
|
|
||||||
|
from cursor_flasher.config import Config, load_config
|
||||||
|
from cursor_flasher.overlay import OverlayManager
|
||||||
|
from cursor_flasher.sound import play_alert
|
||||||
|
from cursor_flasher.windows import (
|
||||||
|
find_window_by_workspace,
|
||||||
|
screen_frame_for_window,
|
||||||
|
all_screen_frames,
|
||||||
|
is_cursor_frontmost,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("cursor_flasher")
|
||||||
|
|
||||||
|
SOCKET_DIR = os.path.expanduser("~/.cursor-flasher")
|
||||||
|
SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
|
||||||
|
|
||||||
|
INPUT_DISMISS_GRACE = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class _PendingApproval:
|
||||||
|
"""An approval trigger waiting for the delay to expire before pulsing."""
|
||||||
|
__slots__ = ("workspace", "tool", "timestamp")
|
||||||
|
|
||||||
|
def __init__(self, workspace: str, tool: str, timestamp: float):
|
||||||
|
self.workspace = workspace
|
||||||
|
self.tool = tool
|
||||||
|
self.timestamp = timestamp
|
||||||
|
|
||||||
|
|
||||||
|
class FlasherDaemon:
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.config = config
|
||||||
|
self.overlay = OverlayManager()
|
||||||
|
self._running = False
|
||||||
|
self._server: socket.socket | None = None
|
||||||
|
self._last_flash: float = 0
|
||||||
|
self._pending: _PendingApproval | None = None
|
||||||
|
self._pulse_started_at: float = 0
|
||||||
|
self._cursor_was_frontmost: bool = False
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
NSApplication.sharedApplication()
|
||||||
|
self._running = True
|
||||||
|
self._setup_socket()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, self._handle_signal)
|
||||||
|
signal.signal(signal.SIGINT, self._handle_signal)
|
||||||
|
|
||||||
|
logger.info("Cursor Flasher daemon started (socket: %s)", SOCKET_PATH)
|
||||||
|
logger.info(
|
||||||
|
"Approval tools: %s delay: %.1fs",
|
||||||
|
self.config.approval_tools,
|
||||||
|
self.config.approval_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
self._check_socket()
|
||||||
|
self._check_pending()
|
||||||
|
self._check_input_dismiss()
|
||||||
|
self._check_focus()
|
||||||
|
NSRunLoop.currentRunLoop().runUntilDate_(
|
||||||
|
NSDate.dateWithTimeIntervalSinceNow_(0.1)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _setup_socket(self) -> None:
|
||||||
|
os.makedirs(SOCKET_DIR, exist_ok=True)
|
||||||
|
if os.path.exists(SOCKET_PATH):
|
||||||
|
os.unlink(SOCKET_PATH)
|
||||||
|
|
||||||
|
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
self._server.bind(SOCKET_PATH)
|
||||||
|
self._server.listen(5)
|
||||||
|
self._server.setblocking(False)
|
||||||
|
|
||||||
|
def _check_socket(self) -> None:
|
||||||
|
if self._server is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn, _ = self._server.accept()
|
||||||
|
try:
|
||||||
|
data = conn.recv(4096)
|
||||||
|
if data:
|
||||||
|
self._handle_message(data)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _check_pending(self) -> None:
|
||||||
|
"""Promote a pending approval to an active pulse after the delay expires."""
|
||||||
|
if self._pending is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - self._pending.timestamp
|
||||||
|
if elapsed < self.config.approval_delay:
|
||||||
|
return
|
||||||
|
|
||||||
|
pending = self._pending
|
||||||
|
self._pending = None
|
||||||
|
|
||||||
|
window = find_window_by_workspace(pending.workspace)
|
||||||
|
if window is None:
|
||||||
|
logger.warning("No Cursor window found for pending approval")
|
||||||
|
return
|
||||||
|
|
||||||
|
frames = self._resolve_frames(window["frame"])
|
||||||
|
logger.info(
|
||||||
|
"Pulsing for approval (after %.1fs delay): tool=%s window=%s",
|
||||||
|
elapsed, pending.tool, window["title"],
|
||||||
|
)
|
||||||
|
self.overlay.pulse(frames, self.config.running)
|
||||||
|
self._pulse_started_at = time.monotonic()
|
||||||
|
self._cursor_was_frontmost = is_cursor_frontmost()
|
||||||
|
play_alert(self.config.running)
|
||||||
|
self._last_flash = time.monotonic()
|
||||||
|
|
||||||
|
def _check_input_dismiss(self) -> None:
|
||||||
|
"""Dismiss pulse when user clicks or types while Cursor is frontmost.
|
||||||
|
|
||||||
|
Polls CGEventSourceSecondsSinceLastEventType which reads system-wide
|
||||||
|
HID counters — works reliably from forked daemon processes unlike
|
||||||
|
CGEventTap callbacks which silently fail without a window server
|
||||||
|
connection.
|
||||||
|
"""
|
||||||
|
if not self.overlay.is_pulsing:
|
||||||
|
return
|
||||||
|
if not is_cursor_frontmost():
|
||||||
|
return
|
||||||
|
|
||||||
|
pulse_age = time.monotonic() - self._pulse_started_at
|
||||||
|
if pulse_age < INPUT_DISMISS_GRACE:
|
||||||
|
return
|
||||||
|
|
||||||
|
last_click = CGEventSourceSecondsSinceLastEventType(
|
||||||
|
kCGEventSourceStateHIDSystemState, kCGEventLeftMouseDown
|
||||||
|
)
|
||||||
|
last_rclick = CGEventSourceSecondsSinceLastEventType(
|
||||||
|
kCGEventSourceStateHIDSystemState, kCGEventRightMouseDown
|
||||||
|
)
|
||||||
|
last_key = CGEventSourceSecondsSinceLastEventType(
|
||||||
|
kCGEventSourceStateHIDSystemState, kCGEventKeyDown
|
||||||
|
)
|
||||||
|
|
||||||
|
last_input = min(last_click, last_rclick, last_key)
|
||||||
|
|
||||||
|
if last_input < (pulse_age - INPUT_DISMISS_GRACE):
|
||||||
|
logger.info(
|
||||||
|
"User input in Cursor — dismissing pulse "
|
||||||
|
"(input %.1fs ago, pulse %.1fs old)",
|
||||||
|
last_input, pulse_age,
|
||||||
|
)
|
||||||
|
self._dismiss_pulse()
|
||||||
|
|
||||||
|
def _check_focus(self) -> None:
|
||||||
|
"""Dismiss pulse when user switches TO Cursor via Cmd+Tab or similar.
|
||||||
|
|
||||||
|
Detects the transition from another app to Cursor.
|
||||||
|
"""
|
||||||
|
if not self.overlay.is_pulsing:
|
||||||
|
return
|
||||||
|
|
||||||
|
frontmost = is_cursor_frontmost()
|
||||||
|
if frontmost and not self._cursor_was_frontmost:
|
||||||
|
logger.info("Cursor became frontmost — dismissing pulse")
|
||||||
|
self._dismiss_pulse()
|
||||||
|
self._cursor_was_frontmost = frontmost
|
||||||
|
|
||||||
|
def _dismiss_pulse(self) -> None:
|
||||||
|
"""Centralized pulse dismissal."""
|
||||||
|
self.overlay.dismiss()
|
||||||
|
|
||||||
|
def _handle_message(self, raw: bytes) -> None:
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
logger.warning("Invalid message received")
|
||||||
|
return
|
||||||
|
|
||||||
|
workspace = msg.get("workspace", "")
|
||||||
|
event = msg.get("event", "")
|
||||||
|
tool = msg.get("tool", "")
|
||||||
|
|
||||||
|
logger.info("Received: event=%s tool=%s pulsing=%s pending=%s",
|
||||||
|
event, tool, self.overlay.is_pulsing,
|
||||||
|
self._pending is not None)
|
||||||
|
|
||||||
|
if event == "preToolUse":
|
||||||
|
self._handle_approval(workspace, tool)
|
||||||
|
elif event in ("postToolUse", "postToolUseFailure"):
|
||||||
|
self._handle_dismiss(event, tool)
|
||||||
|
elif event == "stop":
|
||||||
|
self._handle_stop(workspace)
|
||||||
|
else:
|
||||||
|
logger.debug("Ignoring event: %s", event)
|
||||||
|
|
||||||
|
def _handle_approval(self, workspace: str, tool: str) -> None:
|
||||||
|
if tool and tool not in self.config.approval_tools:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if (now - self._last_flash) < self.config.cooldown:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Queuing approval: tool=%s workspace=%s", tool, workspace)
|
||||||
|
self._pending = _PendingApproval(workspace, tool, now)
|
||||||
|
|
||||||
|
def _handle_dismiss(self, event: str, tool: str) -> None:
|
||||||
|
if self._pending is not None:
|
||||||
|
logger.debug(
|
||||||
|
"Cancelled pending approval (auto-approved): %s tool=%s",
|
||||||
|
event, tool,
|
||||||
|
)
|
||||||
|
self._pending = None
|
||||||
|
|
||||||
|
if self.overlay.is_pulsing:
|
||||||
|
logger.info("Dismissing pulse: %s tool=%s", event, tool)
|
||||||
|
self._dismiss_pulse()
|
||||||
|
|
||||||
|
def _handle_stop(self, workspace: str) -> None:
|
||||||
|
self._pending = None
|
||||||
|
|
||||||
|
if self.overlay.is_pulsing:
|
||||||
|
self._dismiss_pulse()
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if (now - self._last_flash) < self.config.cooldown:
|
||||||
|
return
|
||||||
|
|
||||||
|
window = find_window_by_workspace(workspace)
|
||||||
|
if window is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
frames = self._resolve_frames(window["frame"])
|
||||||
|
logger.info("Flash for stop: window=%s", window["title"])
|
||||||
|
self.overlay.flash(frames, self.config.completed)
|
||||||
|
play_alert(self.config.completed)
|
||||||
|
self._last_flash = now
|
||||||
|
|
||||||
|
def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
|
||||||
|
"""Return frame(s) based on flash_mode config."""
|
||||||
|
mode = self.config.flash_mode
|
||||||
|
if mode == "allscreens":
|
||||||
|
return all_screen_frames()
|
||||||
|
if mode == "screen":
|
||||||
|
return [screen_frame_for_window(window_frame)]
|
||||||
|
return [window_frame]
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
self.overlay.hide()
|
||||||
|
if self._server is not None:
|
||||||
|
self._server.close()
|
||||||
|
self._server = None
|
||||||
|
if os.path.exists(SOCKET_PATH):
|
||||||
|
os.unlink(SOCKET_PATH)
|
||||||
|
logger.info("Cursor Flasher daemon stopped")
|
||||||
|
|
||||||
|
def _handle_signal(self, signum, frame):
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
logger.info("Received signal %d, shutting down", signum)
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def run_daemon() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
config = load_config()
|
||||||
|
daemon = FlasherDaemon(config)
|
||||||
|
daemon.run()
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Accessibility-based detection of Cursor's agent state.
|
|
||||||
|
|
||||||
Detection strategy (based on a11y tree analysis):
|
|
||||||
- Cursor is an Electron app; web content is exposed via AXWebArea.
|
|
||||||
- In-app buttons render as AXStaticText with their label in the 'value' attr,
|
|
||||||
NOT as AXButton elements (those are native window controls only).
|
|
||||||
- We collect both AXStaticText values and AXButton titles, then match against
|
|
||||||
known keywords for "agent working" and "approval needed" states.
|
|
||||||
"""
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import re
|
|
||||||
|
|
||||||
from ApplicationServices import (
|
|
||||||
AXUIElementCreateApplication,
|
|
||||||
AXUIElementCopyAttributeNames,
|
|
||||||
AXUIElementCopyAttributeValue,
|
|
||||||
)
|
|
||||||
from Cocoa import NSWorkspace
|
|
||||||
|
|
||||||
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
|
|
||||||
|
|
||||||
AGENT_WORKING_EXACT = {"Stop", "Cancel generating"}
|
|
||||||
AGENT_WORKING_PATTERNS = [re.compile(r"^Generating\b", re.IGNORECASE)]
|
|
||||||
|
|
||||||
APPROVAL_EXACT = {"Accept", "Reject", "Accept All", "Deny"}
|
|
||||||
APPROVAL_PATTERNS = [
|
|
||||||
re.compile(r"^Run\b", re.IGNORECASE),
|
|
||||||
re.compile(r"^Allow\b", re.IGNORECASE),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UISignals:
|
|
||||||
agent_working: bool = False
|
|
||||||
approval_needed: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _text_matches(text: str, exact_set: set[str], patterns: list[re.Pattern]) -> bool:
|
|
||||||
if text in exact_set:
|
|
||||||
return True
|
|
||||||
return any(p.search(text) for p in patterns)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ui_signals(elements: list[dict]) -> UISignals:
|
|
||||||
"""Parse flattened UI elements into detection signals."""
|
|
||||||
agent_working = False
|
|
||||||
approval_needed = False
|
|
||||||
|
|
||||||
for el in elements:
|
|
||||||
role = el.get("role", "")
|
|
||||||
label = ""
|
|
||||||
if role == "AXStaticText":
|
|
||||||
label = el.get("value", "")
|
|
||||||
elif role == "AXButton":
|
|
||||||
label = el.get("title", "")
|
|
||||||
|
|
||||||
if not label:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if _text_matches(label, AGENT_WORKING_EXACT, AGENT_WORKING_PATTERNS):
|
|
||||||
agent_working = True
|
|
||||||
if _text_matches(label, APPROVAL_EXACT, APPROVAL_PATTERNS):
|
|
||||||
approval_needed = True
|
|
||||||
|
|
||||||
return UISignals(agent_working=agent_working, approval_needed=approval_needed)
|
|
||||||
|
|
||||||
|
|
||||||
class CursorDetector:
|
|
||||||
"""Polls Cursor's accessibility tree for agent state signals."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._pid: int | None = None
|
|
||||||
|
|
||||||
def poll(self) -> UISignals | None:
|
|
||||||
"""Poll Cursor's a11y tree and return detected signals, or None if Cursor isn't running."""
|
|
||||||
pid = self._find_cursor_pid()
|
|
||||||
if pid is None:
|
|
||||||
self._pid = None
|
|
||||||
return None
|
|
||||||
|
|
||||||
self._pid = pid
|
|
||||||
app_element = AXUIElementCreateApplication(pid)
|
|
||||||
elements = self._collect_elements(app_element, max_depth=15)
|
|
||||||
return parse_ui_signals(elements)
|
|
||||||
|
|
||||||
def _find_cursor_pid(self) -> int | None:
|
|
||||||
workspace = NSWorkspace.sharedWorkspace()
|
|
||||||
for app in workspace.runningApplications():
|
|
||||||
bundle = app.bundleIdentifier() or ""
|
|
||||||
if bundle == CURSOR_BUNDLE_ID:
|
|
||||||
return app.processIdentifier()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _collect_elements(
|
|
||||||
self, element, max_depth: int = 15, depth: int = 0
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Walk the a11y tree collecting button and static text elements."""
|
|
||||||
if depth > max_depth:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results: list[dict] = []
|
|
||||||
err, attr_names = AXUIElementCopyAttributeNames(element, None)
|
|
||||||
if err or not attr_names:
|
|
||||||
return results
|
|
||||||
|
|
||||||
role = ""
|
|
||||||
title = ""
|
|
||||||
value = ""
|
|
||||||
|
|
||||||
for name in attr_names:
|
|
||||||
val_err, val = AXUIElementCopyAttributeValue(element, name, None)
|
|
||||||
if val_err:
|
|
||||||
continue
|
|
||||||
if name == "AXRole":
|
|
||||||
role = str(val)
|
|
||||||
elif name == "AXTitle":
|
|
||||||
title = str(val) if val else ""
|
|
||||||
elif name == "AXValue":
|
|
||||||
value = str(val) if val else ""
|
|
||||||
|
|
||||||
if role == "AXStaticText" and value:
|
|
||||||
results.append({"role": role, "value": value})
|
|
||||||
elif role == "AXButton" and title:
|
|
||||||
results.append({"role": role, "title": title})
|
|
||||||
|
|
||||||
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
|
|
||||||
if not err and children:
|
|
||||||
for child in children:
|
|
||||||
results.extend(self._collect_elements(child, max_depth, depth + 1))
|
|
||||||
|
|
||||||
return results
|
|
||||||
@@ -1,33 +1,25 @@
|
|||||||
"""Native macOS overlay window that draws a pulsing border around a target window."""
|
"""Native macOS overlay that draws a flash or pulsing border around one or more frames."""
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
import objc
|
import objc
|
||||||
from Cocoa import (
|
from Cocoa import (
|
||||||
NSApplication,
|
|
||||||
NSWindow,
|
NSWindow,
|
||||||
NSBorderlessWindowMask,
|
NSBorderlessWindowMask,
|
||||||
NSColor,
|
NSColor,
|
||||||
NSView,
|
NSView,
|
||||||
NSBezierPath,
|
NSBezierPath,
|
||||||
NSTimer,
|
NSTimer,
|
||||||
NSScreen,
|
|
||||||
)
|
)
|
||||||
from Foundation import NSInsetRect
|
from Foundation import NSInsetRect
|
||||||
from Quartz import (
|
|
||||||
CGWindowListCopyWindowInfo,
|
|
||||||
kCGWindowListOptionOnScreenOnly,
|
|
||||||
kCGNullWindowID,
|
|
||||||
kCGWindowOwnerPID,
|
|
||||||
kCGWindowBounds,
|
|
||||||
kCGWindowLayer,
|
|
||||||
kCGWindowNumber,
|
|
||||||
)
|
|
||||||
|
|
||||||
from cursor_flasher.config import Config
|
from cursor_flasher.config import StyleConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger("cursor_flasher")
|
||||||
|
|
||||||
|
|
||||||
def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
|
def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
|
||||||
"""Convert a hex color string to NSColor."""
|
|
||||||
hex_str = hex_str.lstrip("#")
|
hex_str = hex_str.lstrip("#")
|
||||||
r = int(hex_str[0:2], 16) / 255.0
|
r = int(hex_str[0:2], 16) / 255.0
|
||||||
g = int(hex_str[2:4], 16) / 255.0
|
g = int(hex_str[2:4], 16) / 255.0
|
||||||
@@ -35,185 +27,176 @@ def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
|
|||||||
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
|
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
|
||||||
|
|
||||||
|
|
||||||
class PulseBorderView(NSView):
|
class FlashBorderView(NSView):
|
||||||
"""Custom view that draws a pulsing border rectangle."""
|
"""View that draws a solid border rectangle at a given opacity."""
|
||||||
|
|
||||||
def initWithFrame_config_(self, frame, config):
|
def initWithFrame_(self, frame):
|
||||||
self = objc.super(PulseBorderView, self).initWithFrame_(frame)
|
self = objc.super(FlashBorderView, self).initWithFrame_(frame)
|
||||||
if self is None:
|
if self is None:
|
||||||
return None
|
return None
|
||||||
self._config = config
|
self._style = StyleConfig()
|
||||||
self._phase = 0.0
|
self._alpha = 0.0
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def drawRect_(self, rect):
|
def drawRect_(self, rect):
|
||||||
opacity_range = self._config.pulse_opacity_max - self._config.pulse_opacity_min
|
if self._alpha <= 0:
|
||||||
alpha = self._config.pulse_opacity_min + opacity_range * (
|
return
|
||||||
0.5 + 0.5 * math.sin(self._phase)
|
color = hex_to_nscolor(self._style.color, self._alpha)
|
||||||
)
|
|
||||||
|
|
||||||
color = hex_to_nscolor(self._config.pulse_color, alpha)
|
|
||||||
color.setStroke()
|
color.setStroke()
|
||||||
|
width = self._style.width
|
||||||
width = self._config.pulse_width
|
|
||||||
inset = width / 2.0
|
inset = width / 2.0
|
||||||
bounds = objc.super(PulseBorderView, self).bounds()
|
bounds = objc.super(FlashBorderView, self).bounds()
|
||||||
inset_rect = NSInsetRect(bounds, inset, inset)
|
inset_rect = NSInsetRect(bounds, inset, inset)
|
||||||
path = NSBezierPath.bezierPathWithRect_(inset_rect)
|
path = NSBezierPath.bezierPathWithRect_(inset_rect)
|
||||||
path.setLineWidth_(width)
|
path.setLineWidth_(width)
|
||||||
path.stroke()
|
path.stroke()
|
||||||
|
|
||||||
def setPhase_(self, phase):
|
def setAlpha_(self, alpha):
|
||||||
self._phase = phase
|
self._alpha = alpha
|
||||||
self.setNeedsDisplay_(True)
|
self.setNeedsDisplay_(True)
|
||||||
|
|
||||||
|
|
||||||
class _OverlayEntry:
|
class _Mode(enum.Enum):
|
||||||
"""A single overlay window + view pair."""
|
IDLE = "idle"
|
||||||
__slots__ = ("window", "view")
|
FLASH = "flash"
|
||||||
|
PULSE = "pulse"
|
||||||
def __init__(self, window: NSWindow, view: PulseBorderView):
|
|
||||||
self.window = window
|
|
||||||
self.view = view
|
|
||||||
|
|
||||||
|
|
||||||
class OverlayManager:
|
class OverlayManager:
|
||||||
"""Manages overlay windows for all Cursor windows belonging to a PID.
|
"""Manages overlay borders on one or more frames simultaneously.
|
||||||
|
|
||||||
Creates one transparent overlay per Cursor window and keeps them
|
Two modes:
|
||||||
positioned and animated in sync.
|
- flash(): brief fade-in/hold/fade-out, auto-dismisses
|
||||||
|
- pulse(): continuous sine-wave pulsing until dismiss() is called
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, config: Config):
|
def __init__(self):
|
||||||
self._config = config
|
self._panels: list[tuple[NSWindow, FlashBorderView]] = []
|
||||||
self._overlays: list[_OverlayEntry] = []
|
|
||||||
self._timer: NSTimer | None = None
|
self._timer: NSTimer | None = None
|
||||||
self._phase = 0.0
|
self._elapsed: float = 0.0
|
||||||
self._target_pid: int | None = None
|
self._mode: _Mode = _Mode.IDLE
|
||||||
|
self._style: StyleConfig = StyleConfig()
|
||||||
|
|
||||||
def show(self, pid: int) -> None:
|
@property
|
||||||
"""Show pulsing overlays around every window belonging to `pid`."""
|
def is_pulsing(self) -> bool:
|
||||||
self._target_pid = pid
|
return self._mode == _Mode.PULSE
|
||||||
frames = self._get_all_window_frames(pid)
|
|
||||||
if not frames:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._sync_overlays(frames)
|
def flash(self, frames: list[tuple], style: StyleConfig) -> None:
|
||||||
|
"""Brief flash: fade in, hold, fade out, auto-dismiss."""
|
||||||
|
self._show(frames, _Mode.FLASH, style)
|
||||||
|
|
||||||
for entry in self._overlays:
|
def pulse(self, frames: list[tuple], style: StyleConfig) -> None:
|
||||||
entry.window.orderFrontRegardless()
|
"""Continuous pulse: sine-wave opacity until dismiss() is called."""
|
||||||
|
self._show(frames, _Mode.PULSE, style)
|
||||||
|
|
||||||
self._start_animation()
|
def dismiss(self) -> None:
|
||||||
|
"""Stop any animation and hide all overlays."""
|
||||||
|
self._stop_timer()
|
||||||
|
self._mode = _Mode.IDLE
|
||||||
|
for window, view in self._panels:
|
||||||
|
view.setAlpha_(0.0)
|
||||||
|
window.setAlphaValue_(0.0)
|
||||||
|
window.orderOut_(None)
|
||||||
|
logger.debug("Overlay dismissed (%d panels hidden)", len(self._panels))
|
||||||
|
|
||||||
def hide(self) -> None:
|
def hide(self) -> None:
|
||||||
"""Hide all overlay windows."""
|
self.dismiss()
|
||||||
self._stop_animation()
|
|
||||||
for entry in self._overlays:
|
|
||||||
entry.window.orderOut_(None)
|
|
||||||
|
|
||||||
def update_positions(self) -> None:
|
def _show(self, frames: list[tuple], mode: _Mode, style: StyleConfig) -> None:
|
||||||
"""Reposition overlays to track current Cursor window positions."""
|
self._stop_timer()
|
||||||
if self._target_pid is None:
|
self._elapsed = 0.0
|
||||||
return
|
self._mode = mode
|
||||||
frames = self._get_all_window_frames(self._target_pid)
|
self._style = style
|
||||||
self._sync_overlays(frames)
|
|
||||||
|
|
||||||
def _sync_overlays(self, frames: list[tuple]) -> None:
|
self._ensure_panels(len(frames))
|
||||||
"""Ensure we have exactly len(frames) overlays, positioned correctly.
|
|
||||||
|
|
||||||
Reuses existing overlay windows where possible, creates new ones
|
for i, frame in enumerate(frames):
|
||||||
if more windows appeared, and hides extras if windows closed.
|
window, view = self._panels[i]
|
||||||
"""
|
view._style = style
|
||||||
needed = len(frames)
|
window.setFrame_display_(frame, True)
|
||||||
existing = len(self._overlays)
|
|
||||||
|
|
||||||
for i in range(needed):
|
|
||||||
frame = frames[i]
|
|
||||||
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
||||||
|
view.setFrame_(content_rect)
|
||||||
|
view.setAlpha_(style.opacity)
|
||||||
|
window.setAlphaValue_(1.0)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
|
||||||
if i < existing:
|
for j in range(len(frames), len(self._panels)):
|
||||||
entry = self._overlays[i]
|
self._panels[j][0].orderOut_(None)
|
||||||
entry.window.setFrame_display_(frame, True)
|
|
||||||
entry.view.setFrame_(content_rect)
|
|
||||||
else:
|
|
||||||
entry = self._create_overlay(frame)
|
|
||||||
self._overlays.append(entry)
|
|
||||||
entry.window.orderFrontRegardless()
|
|
||||||
|
|
||||||
for i in range(needed, existing):
|
interval = 1.0 / 30.0
|
||||||
self._overlays[i].window.orderOut_(None)
|
|
||||||
|
|
||||||
if needed < existing:
|
|
||||||
self._overlays = self._overlays[:needed]
|
|
||||||
|
|
||||||
def _create_overlay(self, frame) -> _OverlayEntry:
|
|
||||||
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
||||||
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
|
|
||||||
)
|
|
||||||
window.setOpaque_(False)
|
|
||||||
window.setBackgroundColor_(NSColor.clearColor())
|
|
||||||
window.setLevel_(25) # Above normal windows
|
|
||||||
window.setIgnoresMouseEvents_(True)
|
|
||||||
window.setHasShadow_(False)
|
|
||||||
|
|
||||||
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
|
||||||
view = PulseBorderView.alloc().initWithFrame_config_(
|
|
||||||
content_rect, self._config
|
|
||||||
)
|
|
||||||
window.setContentView_(view)
|
|
||||||
return _OverlayEntry(window, view)
|
|
||||||
|
|
||||||
def _start_animation(self) -> None:
|
|
||||||
if self._timer is not None:
|
|
||||||
return
|
|
||||||
interval = 1.0 / 30.0 # 30 fps
|
|
||||||
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
||||||
interval, self, "_tick:", None, True
|
interval, self, "_tick:", None, True
|
||||||
)
|
)
|
||||||
|
|
||||||
def _stop_animation(self) -> None:
|
def _ensure_panels(self, count: int) -> None:
|
||||||
|
"""Grow the panel pool if needed."""
|
||||||
|
while len(self._panels) < count:
|
||||||
|
dummy = ((0, 0), (1, 1))
|
||||||
|
self._panels.append(self._create_overlay(dummy))
|
||||||
|
|
||||||
|
def _create_overlay(self, frame) -> tuple:
|
||||||
|
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
||||||
|
frame, NSBorderlessWindowMask, 2, False
|
||||||
|
)
|
||||||
|
window.setOpaque_(False)
|
||||||
|
window.setBackgroundColor_(NSColor.clearColor())
|
||||||
|
window.setLevel_(2147483631)
|
||||||
|
window.setIgnoresMouseEvents_(True)
|
||||||
|
window.setHasShadow_(False)
|
||||||
|
|
||||||
|
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
|
||||||
|
view = FlashBorderView.alloc().initWithFrame_(content_rect)
|
||||||
|
window.setContentView_(view)
|
||||||
|
return window, view
|
||||||
|
|
||||||
|
def _stop_timer(self) -> None:
|
||||||
if self._timer is not None:
|
if self._timer is not None:
|
||||||
self._timer.invalidate()
|
self._timer.invalidate()
|
||||||
self._timer = None
|
self._timer = None
|
||||||
self._phase = 0.0
|
|
||||||
|
|
||||||
@objc.python_method
|
@objc.python_method
|
||||||
def _tick_impl(self):
|
def _tick_impl(self):
|
||||||
speed = self._config.pulse_speed
|
dt = 1.0 / 30.0
|
||||||
step = (2.0 * math.pi) / (speed * 30.0)
|
self._elapsed += dt
|
||||||
self._phase += step
|
|
||||||
for entry in self._overlays:
|
match self._mode:
|
||||||
entry.view.setPhase_(self._phase)
|
case _Mode.FLASH:
|
||||||
self.update_positions()
|
self._tick_flash()
|
||||||
|
case _Mode.PULSE:
|
||||||
|
self._tick_pulse()
|
||||||
|
case _Mode.IDLE:
|
||||||
|
self._stop_timer()
|
||||||
|
|
||||||
|
def _tick_flash(self) -> None:
|
||||||
|
duration = self._style.duration
|
||||||
|
fade_in = 0.15
|
||||||
|
fade_out = 0.4
|
||||||
|
hold_end = duration - fade_out
|
||||||
|
|
||||||
|
if self._elapsed < fade_in:
|
||||||
|
alpha = self._style.opacity * (self._elapsed / fade_in)
|
||||||
|
elif self._elapsed < hold_end:
|
||||||
|
alpha = self._style.opacity
|
||||||
|
elif self._elapsed < duration:
|
||||||
|
progress = (self._elapsed - hold_end) / fade_out
|
||||||
|
alpha = self._style.opacity * (1.0 - progress)
|
||||||
|
else:
|
||||||
|
self.dismiss()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._set_all_alpha(alpha)
|
||||||
|
|
||||||
|
def _tick_pulse(self) -> None:
|
||||||
|
speed = self._style.pulse_speed
|
||||||
|
phase = (2.0 * math.pi * self._elapsed) / speed
|
||||||
|
opacity_min = 0.3
|
||||||
|
t = 0.5 + 0.5 * math.sin(phase)
|
||||||
|
alpha = opacity_min + (self._style.opacity - opacity_min) * t
|
||||||
|
self._set_all_alpha(alpha)
|
||||||
|
|
||||||
|
def _set_all_alpha(self, alpha: float) -> None:
|
||||||
|
for _, view in self._panels:
|
||||||
|
view.setAlpha_(alpha)
|
||||||
|
|
||||||
def _tick_(self, timer) -> None:
|
def _tick_(self, timer) -> None:
|
||||||
self._tick_impl()
|
self._tick_impl()
|
||||||
|
|
||||||
def _get_all_window_frames(self, pid: int) -> list[tuple]:
|
|
||||||
"""Get screen frames for all on-screen windows belonging to `pid`."""
|
|
||||||
window_list = CGWindowListCopyWindowInfo(
|
|
||||||
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
|
||||||
)
|
|
||||||
if not window_list:
|
|
||||||
return []
|
|
||||||
|
|
||||||
screen_height = NSScreen.mainScreen().frame().size.height
|
|
||||||
frames = []
|
|
||||||
|
|
||||||
for info in window_list:
|
|
||||||
if info.get(kCGWindowOwnerPID) != pid:
|
|
||||||
continue
|
|
||||||
if info.get(kCGWindowLayer, 999) != 0:
|
|
||||||
continue
|
|
||||||
bounds = info.get(kCGWindowBounds)
|
|
||||||
if bounds is None:
|
|
||||||
continue
|
|
||||||
w = bounds["Width"]
|
|
||||||
h = bounds["Height"]
|
|
||||||
if w < 100 or h < 100:
|
|
||||||
continue
|
|
||||||
x = bounds["X"]
|
|
||||||
y = screen_height - bounds["Y"] - h
|
|
||||||
frames.append(((x, y), (w, h)))
|
|
||||||
|
|
||||||
return frames
|
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
"""System sound playback."""
|
"""System sound playback."""
|
||||||
from Cocoa import NSSound
|
from Cocoa import NSSound
|
||||||
|
|
||||||
from cursor_flasher.config import Config
|
from cursor_flasher.config import StyleConfig
|
||||||
|
|
||||||
|
|
||||||
def play_alert(config: Config) -> None:
|
def play_alert(style: StyleConfig) -> None:
|
||||||
"""Play the configured alert sound if enabled."""
|
if not style.sound:
|
||||||
if not config.sound_enabled:
|
|
||||||
return
|
return
|
||||||
|
sound = NSSound.soundNamed_(style.sound)
|
||||||
sound = NSSound.soundNamed_(config.sound_name)
|
|
||||||
if sound is None:
|
if sound is None:
|
||||||
return
|
return
|
||||||
|
sound.setVolume_(style.volume)
|
||||||
sound.setVolume_(config.sound_volume)
|
|
||||||
sound.play()
|
sound.play()
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import enum
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
class FlasherState(enum.Enum):
|
|
||||||
IDLE = "idle"
|
|
||||||
AGENT_WORKING = "agent_working"
|
|
||||||
WAITING_FOR_USER = "waiting_for_user"
|
|
||||||
|
|
||||||
|
|
||||||
class StateMachine:
|
|
||||||
def __init__(self, cooldown: float = 3.0):
|
|
||||||
self.state = FlasherState.IDLE
|
|
||||||
self.cooldown = cooldown
|
|
||||||
self._last_dismiss_time: float = 0
|
|
||||||
|
|
||||||
def update(self, *, agent_working: bool, approval_needed: bool) -> bool:
|
|
||||||
"""Update state based on detected signals. Returns True if state changed."""
|
|
||||||
old = self.state
|
|
||||||
|
|
||||||
if approval_needed and self.state != FlasherState.WAITING_FOR_USER:
|
|
||||||
if not self._in_cooldown():
|
|
||||||
self.state = FlasherState.WAITING_FOR_USER
|
|
||||||
return self.state != old
|
|
||||||
|
|
||||||
match self.state:
|
|
||||||
case FlasherState.IDLE:
|
|
||||||
if agent_working:
|
|
||||||
self.state = FlasherState.AGENT_WORKING
|
|
||||||
case FlasherState.AGENT_WORKING:
|
|
||||||
if not agent_working:
|
|
||||||
if not self._in_cooldown():
|
|
||||||
self.state = FlasherState.WAITING_FOR_USER
|
|
||||||
else:
|
|
||||||
self.state = FlasherState.IDLE
|
|
||||||
case FlasherState.WAITING_FOR_USER:
|
|
||||||
if agent_working:
|
|
||||||
self.state = FlasherState.AGENT_WORKING
|
|
||||||
|
|
||||||
return self.state != old
|
|
||||||
|
|
||||||
def dismiss(self) -> bool:
|
|
||||||
"""User interaction detected — dismiss the flash."""
|
|
||||||
if self.state == FlasherState.WAITING_FOR_USER:
|
|
||||||
self.state = FlasherState.IDLE
|
|
||||||
self._last_dismiss_time = time.monotonic()
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _in_cooldown(self) -> bool:
|
|
||||||
if self._last_dismiss_time == 0:
|
|
||||||
return False
|
|
||||||
return (time.monotonic() - self._last_dismiss_time) < self.cooldown
|
|
||||||
153
src/cursor_flasher/windows.py
Normal file
153
src/cursor_flasher/windows.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""Cursor window discovery and geometry via macOS accessibility APIs.
|
||||||
|
|
||||||
|
Only used for finding Cursor windows and reading their screen position.
|
||||||
|
Detection of agent state is handled by Cursor hooks, not a11y polling.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ApplicationServices import (
|
||||||
|
AXUIElementCreateApplication,
|
||||||
|
AXUIElementCopyAttributeValue,
|
||||||
|
AXValueGetValue,
|
||||||
|
kAXValueTypeCGPoint,
|
||||||
|
kAXValueTypeCGSize,
|
||||||
|
)
|
||||||
|
from Cocoa import NSScreen, NSWorkspace
|
||||||
|
|
||||||
|
logger = logging.getLogger("cursor_flasher")
|
||||||
|
|
||||||
|
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
|
||||||
|
|
||||||
|
|
||||||
|
def is_cursor_frontmost() -> bool:
|
||||||
|
"""Return True if Cursor is the frontmost (active) application."""
|
||||||
|
app = NSWorkspace.sharedWorkspace().frontmostApplication()
|
||||||
|
if app is None:
|
||||||
|
return False
|
||||||
|
return (app.bundleIdentifier() or "") == CURSOR_BUNDLE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def find_cursor_pid() -> int | None:
|
||||||
|
workspace = NSWorkspace.sharedWorkspace()
|
||||||
|
for app in workspace.runningApplications():
|
||||||
|
bundle = app.bundleIdentifier() or ""
|
||||||
|
if bundle == CURSOR_BUNDLE_ID:
|
||||||
|
return app.processIdentifier()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cursor_windows() -> list[dict]:
|
||||||
|
"""Return a list of Cursor windows with their titles and AX-coordinate frames.
|
||||||
|
|
||||||
|
Each entry: {"title": str, "frame": ((x, y), (w, h))}
|
||||||
|
Frame is in AppKit coordinates (bottom-left origin).
|
||||||
|
"""
|
||||||
|
pid = find_cursor_pid()
|
||||||
|
if pid is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
app = AXUIElementCreateApplication(pid)
|
||||||
|
err, children = AXUIElementCopyAttributeValue(app, "AXChildren", None)
|
||||||
|
if err or not children:
|
||||||
|
return []
|
||||||
|
|
||||||
|
screen_height = NSScreen.mainScreen().frame().size.height
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
err, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
|
||||||
|
if err or str(role) != "AXWindow":
|
||||||
|
continue
|
||||||
|
|
||||||
|
err, title = AXUIElementCopyAttributeValue(child, "AXTitle", None)
|
||||||
|
title = str(title) if not err and title else ""
|
||||||
|
|
||||||
|
frame = _read_frame(child, screen_height)
|
||||||
|
if frame is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append({"title": title, "frame": frame})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def find_window_by_workspace(workspace_path: str) -> dict | None:
|
||||||
|
"""Find the Cursor window whose title contains the workspace folder name.
|
||||||
|
|
||||||
|
Cursor titles are typically "<filename> — <project-name>".
|
||||||
|
Returns None if no match or ambiguous (avoids flashing the wrong window).
|
||||||
|
Only falls back to the sole window if there's exactly one.
|
||||||
|
"""
|
||||||
|
windows = get_cursor_windows()
|
||||||
|
|
||||||
|
if not windows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not workspace_path:
|
||||||
|
return windows[0] if len(windows) == 1 else None
|
||||||
|
|
||||||
|
folder_name = workspace_path.rstrip("/").rsplit("/", 1)[-1]
|
||||||
|
if not folder_name:
|
||||||
|
return windows[0] if len(windows) == 1 else None
|
||||||
|
|
||||||
|
matches = [w for w in windows if folder_name in w["title"]]
|
||||||
|
|
||||||
|
if len(matches) == 1:
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
if len(matches) > 1:
|
||||||
|
exact = [w for w in matches if w["title"].endswith(folder_name)]
|
||||||
|
if len(exact) == 1:
|
||||||
|
return exact[0]
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
return windows[0] if len(windows) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def screen_frame_for_window(window_frame: tuple) -> tuple:
|
||||||
|
"""Return the NSScreen frame of the monitor containing the window's center.
|
||||||
|
|
||||||
|
Falls back to the main screen if no screen contains the center point.
|
||||||
|
"""
|
||||||
|
wx, wy = window_frame[0]
|
||||||
|
ww, wh = window_frame[1]
|
||||||
|
cx = wx + ww / 2.0
|
||||||
|
cy = wy + wh / 2.0
|
||||||
|
|
||||||
|
for screen in NSScreen.screens():
|
||||||
|
sf = screen.frame()
|
||||||
|
sx, sy = sf.origin.x, sf.origin.y
|
||||||
|
sw, sh = sf.size.width, sf.size.height
|
||||||
|
if sx <= cx < sx + sw and sy <= cy < sy + sh:
|
||||||
|
return ((sx, sy), (sw, sh))
|
||||||
|
|
||||||
|
main = NSScreen.mainScreen().frame()
|
||||||
|
return ((main.origin.x, main.origin.y), (main.size.width, main.size.height))
|
||||||
|
|
||||||
|
|
||||||
|
def all_screen_frames() -> list[tuple]:
|
||||||
|
"""Return frames for every connected screen."""
|
||||||
|
frames = []
|
||||||
|
for screen in NSScreen.screens():
|
||||||
|
sf = screen.frame()
|
||||||
|
frames.append(((sf.origin.x, sf.origin.y), (sf.size.width, sf.size.height)))
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def _read_frame(ax_window, screen_height: float) -> tuple | None:
|
||||||
|
_, pos_val = AXUIElementCopyAttributeValue(ax_window, "AXPosition", None)
|
||||||
|
_, size_val = AXUIElementCopyAttributeValue(ax_window, "AXSize", None)
|
||||||
|
if pos_val is None or size_val is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_, point = AXValueGetValue(pos_val, kAXValueTypeCGPoint, None)
|
||||||
|
_, size = AXValueGetValue(size_val, kAXValueTypeCGSize, None)
|
||||||
|
if point is None or size is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
x = point.x
|
||||||
|
w = size.width
|
||||||
|
h = size.height
|
||||||
|
y = screen_height - point.y - h
|
||||||
|
|
||||||
|
return ((x, y), (w, h))
|
||||||
@@ -1,56 +1,119 @@
|
|||||||
import pytest
|
from cursor_flasher.config import Config, StyleConfig, load_config
|
||||||
from pathlib import Path
|
|
||||||
from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG_PATH
|
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultConfig:
|
class TestDefaultConfig:
|
||||||
def test_has_pulse_settings(self):
|
def test_running_defaults(self):
|
||||||
cfg = Config()
|
c = Config()
|
||||||
assert cfg.pulse_color == "#FF9500"
|
assert c.running.color == "#FF9500"
|
||||||
assert cfg.pulse_width == 4
|
assert c.running.width == 4
|
||||||
assert cfg.pulse_speed == 1.5
|
assert c.running.duration == 1.5
|
||||||
assert cfg.pulse_opacity_min == 0.3
|
assert c.running.opacity == 0.85
|
||||||
assert cfg.pulse_opacity_max == 1.0
|
assert c.running.pulse_speed == 1.5
|
||||||
|
assert c.running.sound == "Glass"
|
||||||
|
assert c.running.volume == 0.5
|
||||||
|
|
||||||
def test_has_sound_settings(self):
|
def test_completed_defaults(self):
|
||||||
cfg = Config()
|
c = Config()
|
||||||
assert cfg.sound_enabled is True
|
assert c.completed.color == "#FF9500"
|
||||||
assert cfg.sound_name == "Glass"
|
assert c.completed.width == 4
|
||||||
assert cfg.sound_volume == 0.5
|
assert c.completed.sound == ""
|
||||||
|
assert c.completed.volume == 0.0
|
||||||
|
|
||||||
def test_has_detection_settings(self):
|
def test_has_approval_tools(self):
|
||||||
cfg = Config()
|
c = Config()
|
||||||
assert cfg.poll_interval == 0.5
|
assert c.approval_tools == ["Shell", "Write", "Delete"]
|
||||||
assert cfg.cooldown == 3.0
|
|
||||||
|
|
||||||
def test_has_timeout_settings(self):
|
def test_has_cooldown(self):
|
||||||
cfg = Config()
|
c = Config()
|
||||||
assert cfg.auto_dismiss == 300
|
assert c.cooldown == 2.0
|
||||||
|
|
||||||
|
def test_has_flash_mode(self):
|
||||||
|
c = Config()
|
||||||
|
assert c.flash_mode == "screen"
|
||||||
|
|
||||||
|
|
||||||
class TestLoadConfig:
|
class TestLoadConfig:
|
||||||
def test_loads_from_yaml(self, tmp_path):
|
|
||||||
config_file = tmp_path / "config.yaml"
|
|
||||||
config_file.write_text(
|
|
||||||
"pulse:\n"
|
|
||||||
' color: "#00FF00"\n'
|
|
||||||
" width: 8\n"
|
|
||||||
"sound:\n"
|
|
||||||
" enabled: false\n"
|
|
||||||
)
|
|
||||||
cfg = load_config(config_file)
|
|
||||||
assert cfg.pulse_color == "#00FF00"
|
|
||||||
assert cfg.pulse_width == 8
|
|
||||||
assert cfg.sound_enabled is False
|
|
||||||
assert cfg.pulse_speed == 1.5
|
|
||||||
assert cfg.sound_name == "Glass"
|
|
||||||
|
|
||||||
def test_missing_file_returns_defaults(self, tmp_path):
|
def test_missing_file_returns_defaults(self, tmp_path):
|
||||||
cfg = load_config(tmp_path / "nonexistent.yaml")
|
c = load_config(tmp_path / "nope.yaml")
|
||||||
assert cfg.pulse_color == "#FF9500"
|
assert c == Config()
|
||||||
|
|
||||||
def test_empty_file_returns_defaults(self, tmp_path):
|
def test_empty_file_returns_defaults(self, tmp_path):
|
||||||
config_file = tmp_path / "config.yaml"
|
p = tmp_path / "config.yaml"
|
||||||
config_file.write_text("")
|
p.write_text("")
|
||||||
cfg = load_config(config_file)
|
c = load_config(p)
|
||||||
assert cfg.pulse_color == "#FF9500"
|
assert c == Config()
|
||||||
|
|
||||||
|
def test_loads_running_overrides(self, tmp_path):
|
||||||
|
p = tmp_path / "config.yaml"
|
||||||
|
p.write_text(
|
||||||
|
"running:\n color: '#00FF00'\n duration: 2.0\n sound: Ping\n"
|
||||||
|
)
|
||||||
|
c = load_config(p)
|
||||||
|
assert c.running.color == "#00FF00"
|
||||||
|
assert c.running.duration == 2.0
|
||||||
|
assert c.running.sound == "Ping"
|
||||||
|
assert c.running.width == 4
|
||||||
|
|
||||||
|
def test_loads_completed_overrides(self, tmp_path):
|
||||||
|
p = tmp_path / "config.yaml"
|
||||||
|
p.write_text(
|
||||||
|
"completed:\n color: '#0000FF'\n sound: Hero\n volume: 0.8\n"
|
||||||
|
)
|
||||||
|
c = load_config(p)
|
||||||
|
assert c.completed.color == "#0000FF"
|
||||||
|
assert c.completed.sound == "Hero"
|
||||||
|
assert c.completed.volume == 0.8
|
||||||
|
|
||||||
|
def test_loads_flash_mode(self, tmp_path):
|
||||||
|
p = tmp_path / "config.yaml"
|
||||||
|
p.write_text("flash:\n mode: allscreens\n")
|
||||||
|
c = load_config(p)
|
||||||
|
assert c.flash_mode == "allscreens"
|
||||||
|
|
||||||
|
def test_loads_general_overrides(self, tmp_path):
|
||||||
|
p = tmp_path / "config.yaml"
|
||||||
|
p.write_text("general:\n cooldown: 5.0\n approval_delay: 3.0\n")
|
||||||
|
c = load_config(p)
|
||||||
|
assert c.cooldown == 5.0
|
||||||
|
assert c.approval_delay == 3.0
|
||||||
|
|
||||||
|
def test_loads_approval_tools(self, tmp_path):
|
||||||
|
p = tmp_path / "config.yaml"
|
||||||
|
p.write_text("approval_tools:\n - Shell\n - MCP\n")
|
||||||
|
c = load_config(p)
|
||||||
|
assert c.approval_tools == ["Shell", "MCP"]
|
||||||
|
|
||||||
|
def test_full_config(self, tmp_path):
|
||||||
|
p = tmp_path / "config.yaml"
|
||||||
|
p.write_text(
|
||||||
|
"running:\n"
|
||||||
|
" color: '#FF0000'\n"
|
||||||
|
" width: 6\n"
|
||||||
|
" opacity: 0.9\n"
|
||||||
|
" pulse_speed: 2.0\n"
|
||||||
|
" sound: Glass\n"
|
||||||
|
" volume: 0.8\n"
|
||||||
|
"completed:\n"
|
||||||
|
" color: '#00FF00'\n"
|
||||||
|
" sound: ''\n"
|
||||||
|
"flash:\n"
|
||||||
|
" mode: window\n"
|
||||||
|
"general:\n"
|
||||||
|
" approval_delay: 1.0\n"
|
||||||
|
" cooldown: 3.0\n"
|
||||||
|
"approval_tools:\n"
|
||||||
|
" - Shell\n"
|
||||||
|
)
|
||||||
|
c = load_config(p)
|
||||||
|
assert c.running.color == "#FF0000"
|
||||||
|
assert c.running.width == 6
|
||||||
|
assert c.running.opacity == 0.9
|
||||||
|
assert c.running.pulse_speed == 2.0
|
||||||
|
assert c.running.sound == "Glass"
|
||||||
|
assert c.running.volume == 0.8
|
||||||
|
assert c.completed.color == "#00FF00"
|
||||||
|
assert c.completed.sound == ""
|
||||||
|
assert c.flash_mode == "window"
|
||||||
|
assert c.approval_delay == 1.0
|
||||||
|
assert c.cooldown == 3.0
|
||||||
|
assert c.approval_tools == ["Shell"]
|
||||||
|
|||||||
388
tests/test_daemon.py
Normal file
388
tests/test_daemon.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""Tests for the daemon's message handling logic."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from cursor_flasher.config import Config, StyleConfig
|
||||||
|
from cursor_flasher.daemon import FlasherDaemon
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlasherDaemon:
|
||||||
|
def _make_daemon(self, **config_overrides) -> FlasherDaemon:
|
||||||
|
config = Config(**config_overrides)
|
||||||
|
with patch("cursor_flasher.daemon.OverlayManager"):
|
||||||
|
daemon = FlasherDaemon(config)
|
||||||
|
daemon.overlay.is_pulsing = False
|
||||||
|
return daemon
|
||||||
|
|
||||||
|
def test_preToolUse_queues_pending(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert daemon._pending is not None
|
||||||
|
assert daemon._pending.tool == "Shell"
|
||||||
|
daemon.overlay.pulse.assert_not_called()
|
||||||
|
|
||||||
|
def test_pending_promotes_after_delay(self):
|
||||||
|
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen")
|
||||||
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||||
|
screen = ((0, 0), (1920, 1080))
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
||||||
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._check_pending()
|
||||||
|
|
||||||
|
daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.running)
|
||||||
|
|
||||||
|
def test_postToolUse_cancels_pending(self):
|
||||||
|
"""Auto-approved tools: postToolUse arrives before delay, cancels the pulse."""
|
||||||
|
daemon = self._make_daemon(approval_delay=10.0)
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
assert daemon._pending is not None
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
assert daemon._pending is None
|
||||||
|
daemon.overlay.pulse.assert_not_called()
|
||||||
|
|
||||||
|
def test_preToolUse_skips_non_approval_tool(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Read"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert daemon._pending is None
|
||||||
|
|
||||||
|
def test_preToolUse_respects_custom_tool_list(self):
|
||||||
|
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen", approval_tools=["Shell", "MCP"])
|
||||||
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "MCP"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=((0, 0), (1920, 1080))), \
|
||||||
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._check_pending()
|
||||||
|
|
||||||
|
daemon.overlay.pulse.assert_called_once()
|
||||||
|
|
||||||
|
def test_stop_flashes_briefly(self):
|
||||||
|
daemon = self._make_daemon(flash_mode="screen")
|
||||||
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||||
|
screen = ((0, 0), (1920, 1080))
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.flash.assert_called_once_with([screen], daemon.config.completed)
|
||||||
|
|
||||||
|
def test_stop_flashes_window_frame_when_window_mode(self):
|
||||||
|
daemon = self._make_daemon(flash_mode="window")
|
||||||
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.flash.assert_called_once_with(
|
||||||
|
[((0, 0), (800, 600))], daemon.config.completed
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_allscreens_mode_uses_all_screens(self):
|
||||||
|
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
|
||||||
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
||||||
|
screens = [((0, 0), (1920, 1080)), ((-1920, 0), (1920, 1080))]
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.all_screen_frames", return_value=screens), \
|
||||||
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._check_pending()
|
||||||
|
|
||||||
|
daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.running)
|
||||||
|
|
||||||
|
def test_stop_dismisses_active_pulse(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_called_once()
|
||||||
|
daemon.overlay.flash.assert_not_called()
|
||||||
|
|
||||||
|
def test_stop_clears_pending(self):
|
||||||
|
daemon = self._make_daemon(approval_delay=10.0)
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
assert daemon._pending is not None
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
assert daemon._pending is None
|
||||||
|
|
||||||
|
def test_postToolUse_dismisses_active_pulse(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_called_once()
|
||||||
|
|
||||||
|
def test_postToolUseFailure_dismisses_pulse(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "postToolUseFailure", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_called_once()
|
||||||
|
|
||||||
|
def test_cooldown_prevents_rapid_triggers(self):
|
||||||
|
daemon = self._make_daemon(cooldown=5.0)
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
daemon._last_flash = time.monotonic()
|
||||||
|
daemon._pending = None
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
assert daemon._pending is None
|
||||||
|
|
||||||
|
def test_invalid_json_ignored(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon._handle_message(b"not json")
|
||||||
|
daemon.overlay.pulse.assert_not_called()
|
||||||
|
daemon.overlay.flash.assert_not_called()
|
||||||
|
|
||||||
|
def test_no_window_found(self):
|
||||||
|
daemon = self._make_daemon(approval_delay=0.0)
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/nope", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=None):
|
||||||
|
daemon._check_pending()
|
||||||
|
|
||||||
|
daemon.overlay.pulse.assert_not_called()
|
||||||
|
|
||||||
|
def test_focus_transition_dismisses_pulse(self):
|
||||||
|
"""Pulse dismisses when user switches TO Cursor from another app."""
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._cursor_was_frontmost = False
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||||
|
daemon._check_focus()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_called_once()
|
||||||
|
|
||||||
|
def test_focus_no_dismiss_when_cursor_already_frontmost(self):
|
||||||
|
"""No dismiss if Cursor was already frontmost (no transition)."""
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._cursor_was_frontmost = True
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||||
|
daemon._check_focus()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_not_called()
|
||||||
|
|
||||||
|
def test_focus_tracks_state_changes(self):
|
||||||
|
"""_cursor_was_frontmost updates each tick."""
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._cursor_was_frontmost = True
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
|
||||||
|
daemon._check_focus()
|
||||||
|
assert daemon._cursor_was_frontmost is False
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||||
|
daemon._check_focus()
|
||||||
|
daemon.overlay.dismiss.assert_called_once()
|
||||||
|
|
||||||
|
def test_focus_no_dismiss_when_not_pulsing(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = False
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
||||||
|
daemon._check_focus()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_not_called()
|
||||||
|
|
||||||
|
def test_input_dismiss_when_pulsing_and_cursor_focused(self):
|
||||||
|
"""Recent input + Cursor frontmost + past grace period = dismiss."""
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._pulse_started_at = time.monotonic() - 2.0
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||||
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.2):
|
||||||
|
daemon._check_input_dismiss()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_called_once()
|
||||||
|
|
||||||
|
def test_input_dismiss_skipped_during_grace_period(self):
|
||||||
|
"""No dismiss if pulse just started (within grace period)."""
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._pulse_started_at = time.monotonic() - 0.1
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||||
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.05):
|
||||||
|
daemon._check_input_dismiss()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_not_called()
|
||||||
|
|
||||||
|
def test_input_dismiss_skipped_when_not_pulsing(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = False
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||||
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.1):
|
||||||
|
daemon._check_input_dismiss()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_not_called()
|
||||||
|
|
||||||
|
def test_input_dismiss_skipped_when_cursor_not_frontmost(self):
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._pulse_started_at = time.monotonic() - 2.0
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
|
||||||
|
daemon._check_input_dismiss()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_not_called()
|
||||||
|
|
||||||
|
def test_input_dismiss_ignores_old_input(self):
|
||||||
|
"""Input from before the pulse started should not trigger dismiss."""
|
||||||
|
daemon = self._make_daemon()
|
||||||
|
daemon.overlay.is_pulsing = True
|
||||||
|
daemon._pulse_started_at = time.monotonic() - 2.0
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
||||||
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=5.0):
|
||||||
|
daemon._check_input_dismiss()
|
||||||
|
|
||||||
|
daemon.overlay.dismiss.assert_not_called()
|
||||||
|
|
||||||
|
def test_running_style_sound_plays_on_approval(self):
|
||||||
|
"""Running style with sound configured plays on approval pulse."""
|
||||||
|
running = StyleConfig(sound="Glass", volume=0.5)
|
||||||
|
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", running=running)
|
||||||
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
||||||
|
daemon._check_pending()
|
||||||
|
|
||||||
|
mock_alert.assert_called_once_with(running)
|
||||||
|
|
||||||
|
def test_completed_style_sound_plays_on_stop(self):
|
||||||
|
"""Completed style with sound configured plays on stop flash."""
|
||||||
|
completed = StyleConfig(sound="Ping", volume=0.7)
|
||||||
|
daemon = self._make_daemon(flash_mode="window", completed=completed)
|
||||||
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_alert.assert_called_once_with(completed)
|
||||||
|
|
||||||
|
def test_no_sound_when_style_sound_empty(self):
|
||||||
|
"""No sound plays when the style has sound="" (the completed default)."""
|
||||||
|
completed = StyleConfig(sound="", volume=0.0)
|
||||||
|
daemon = self._make_daemon(flash_mode="window", completed=completed)
|
||||||
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_alert.assert_called_once_with(completed)
|
||||||
|
|
||||||
|
def test_custom_colors_per_mode(self):
|
||||||
|
"""Different colors for running vs completed are passed through."""
|
||||||
|
running = StyleConfig(color="#FF0000")
|
||||||
|
completed = StyleConfig(color="#00FF00")
|
||||||
|
daemon = self._make_daemon(
|
||||||
|
approval_delay=0.0, flash_mode="window",
|
||||||
|
running=running, completed=completed,
|
||||||
|
)
|
||||||
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
||||||
|
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._check_pending()
|
||||||
|
|
||||||
|
daemon.overlay.pulse.assert_called_once_with(
|
||||||
|
[((0, 0), (800, 600))], running
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.pulse.reset_mock()
|
||||||
|
daemon._last_flash = 0
|
||||||
|
|
||||||
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
||||||
|
patch("cursor_flasher.daemon.play_alert"):
|
||||||
|
daemon._handle_message(
|
||||||
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
daemon.overlay.flash.assert_called_once_with(
|
||||||
|
[((0, 0), (800, 600))], completed
|
||||||
|
)
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from cursor_flasher.detector import (
|
|
||||||
CursorDetector,
|
|
||||||
parse_ui_signals,
|
|
||||||
UISignals,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseUISignals:
|
|
||||||
def test_no_elements_means_no_signals(self):
|
|
||||||
signals = parse_ui_signals([])
|
|
||||||
assert signals.agent_working is False
|
|
||||||
assert signals.approval_needed is False
|
|
||||||
|
|
||||||
def test_stop_text_means_agent_working(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Stop"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.agent_working is True
|
|
||||||
|
|
||||||
def test_cancel_generating_means_agent_working(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Cancel generating"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.agent_working is True
|
|
||||||
|
|
||||||
def test_accept_text_means_approval_needed(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Accept"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.approval_needed is True
|
|
||||||
|
|
||||||
def test_reject_text_means_approval_needed(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Reject"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.approval_needed is True
|
|
||||||
|
|
||||||
def test_run_this_time_means_approval_needed(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Run this time only (⏎)"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.approval_needed is True
|
|
||||||
|
|
||||||
def test_both_signals(self):
|
|
||||||
elements = [
|
|
||||||
{"role": "AXStaticText", "value": "Stop"},
|
|
||||||
{"role": "AXStaticText", "value": "Accept"},
|
|
||||||
]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.agent_working is True
|
|
||||||
assert signals.approval_needed is True
|
|
||||||
|
|
||||||
def test_irrelevant_text_ignored(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Settings"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.agent_working is False
|
|
||||||
assert signals.approval_needed is False
|
|
||||||
|
|
||||||
def test_button_role_also_detected(self):
|
|
||||||
elements = [{"role": "AXButton", "title": "Accept"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.approval_needed is True
|
|
||||||
|
|
||||||
def test_partial_match_on_run_command(self):
|
|
||||||
elements = [{"role": "AXStaticText", "value": "Run command"}]
|
|
||||||
signals = parse_ui_signals(elements)
|
|
||||||
assert signals.approval_needed is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestCursorDetector:
|
|
||||||
def test_returns_none_when_cursor_not_running(self):
|
|
||||||
detector = CursorDetector()
|
|
||||||
with patch.object(detector, "_find_cursor_pid", return_value=None):
|
|
||||||
signals = detector.poll()
|
|
||||||
assert signals is None
|
|
||||||
105
tests/test_hook.py
Normal file
105
tests/test_hook.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for the hook notification script."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _short_sock_path():
|
||||||
|
"""Create a short socket path that fits macOS's 104-char limit."""
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".sock", dir="/tmp")
|
||||||
|
os.close(fd)
|
||||||
|
os.unlink(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _run_hook_main(stdin_data: str, socket_path: str):
|
||||||
|
"""Run the hook's main() with patched stdin and socket path."""
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks"))
|
||||||
|
import notify
|
||||||
|
|
||||||
|
with patch.object(notify, "SOCKET_PATH", socket_path), \
|
||||||
|
patch("sys.stdin", io.StringIO(stdin_data)):
|
||||||
|
notify.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHookNotify:
|
||||||
|
def test_sends_message_to_socket(self):
|
||||||
|
sock_path = _short_sock_path()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
server.bind(sock_path)
|
||||||
|
server.listen(1)
|
||||||
|
|
||||||
|
def accept():
|
||||||
|
conn, _ = server.accept()
|
||||||
|
data = conn.recv(4096)
|
||||||
|
received.append(json.loads(data))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=accept)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"workspace_roots": ["/Users/me/project"],
|
||||||
|
"hook_event_name": "preToolUse",
|
||||||
|
"tool_name": "Shell",
|
||||||
|
})
|
||||||
|
_run_hook_main(hook_input, sock_path)
|
||||||
|
t.join(timeout=2)
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
if os.path.exists(sock_path):
|
||||||
|
os.unlink(sock_path)
|
||||||
|
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["workspace"] == "/Users/me/project"
|
||||||
|
assert received[0]["event"] == "preToolUse"
|
||||||
|
assert received[0]["tool"] == "Shell"
|
||||||
|
|
||||||
|
def test_handles_missing_socket_gracefully(self):
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"workspace_roots": ["/Users/me/project"],
|
||||||
|
"hook_event_name": "stop",
|
||||||
|
})
|
||||||
|
_run_hook_main(hook_input, "/tmp/nonexistent.sock")
|
||||||
|
|
||||||
|
def test_handles_empty_workspace_roots(self):
|
||||||
|
sock_path = _short_sock_path()
|
||||||
|
received = []
|
||||||
|
|
||||||
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
server.bind(sock_path)
|
||||||
|
server.listen(1)
|
||||||
|
|
||||||
|
def accept():
|
||||||
|
conn, _ = server.accept()
|
||||||
|
data = conn.recv(4096)
|
||||||
|
received.append(json.loads(data))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=accept)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"workspace_roots": [],
|
||||||
|
"hook_event_name": "stop",
|
||||||
|
})
|
||||||
|
_run_hook_main(hook_input, sock_path)
|
||||||
|
t.join(timeout=2)
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
|
if os.path.exists(sock_path):
|
||||||
|
os.unlink(sock_path)
|
||||||
|
|
||||||
|
assert received[0]["workspace"] == ""
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from cursor_flasher.sound import play_alert
|
|
||||||
from cursor_flasher.config import Config
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlayAlert:
|
|
||||||
def test_does_nothing_when_disabled(self):
|
|
||||||
config = Config(sound_enabled=False)
|
|
||||||
play_alert(config)
|
|
||||||
|
|
||||||
@patch("cursor_flasher.sound.NSSound")
|
|
||||||
def test_plays_named_sound(self, mock_nssound):
|
|
||||||
mock_sound_obj = MagicMock()
|
|
||||||
mock_nssound.soundNamed_.return_value = mock_sound_obj
|
|
||||||
config = Config(sound_enabled=True, sound_name="Glass", sound_volume=0.7)
|
|
||||||
play_alert(config)
|
|
||||||
mock_nssound.soundNamed_.assert_called_once_with("Glass")
|
|
||||||
mock_sound_obj.setVolume_.assert_called_once_with(0.7)
|
|
||||||
mock_sound_obj.play.assert_called_once()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from cursor_flasher.state import FlasherState, StateMachine
|
|
||||||
|
|
||||||
|
|
||||||
class TestStateMachine:
|
|
||||||
def test_initial_state_is_idle(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
assert sm.state == FlasherState.IDLE
|
|
||||||
|
|
||||||
def test_idle_to_agent_working(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
changed = sm.update(agent_working=True, approval_needed=False)
|
|
||||||
assert sm.state == FlasherState.AGENT_WORKING
|
|
||||||
assert changed is True
|
|
||||||
|
|
||||||
def test_agent_working_to_waiting(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
changed = sm.update(agent_working=False, approval_needed=False)
|
|
||||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
|
||||||
assert changed is True
|
|
||||||
|
|
||||||
def test_approval_needed_triggers_waiting(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
changed = sm.update(agent_working=False, approval_needed=True)
|
|
||||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
|
||||||
assert changed is True
|
|
||||||
|
|
||||||
def test_idle_does_not_jump_to_waiting(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
changed = sm.update(agent_working=False, approval_needed=False)
|
|
||||||
assert sm.state == FlasherState.IDLE
|
|
||||||
assert changed is False
|
|
||||||
|
|
||||||
def test_waiting_to_user_interacting(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
sm.update(agent_working=False, approval_needed=False)
|
|
||||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
|
||||||
changed = sm.dismiss()
|
|
||||||
assert sm.state == FlasherState.IDLE
|
|
||||||
assert changed is True
|
|
||||||
|
|
||||||
def test_waiting_to_agent_working(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
sm.update(agent_working=False, approval_needed=False)
|
|
||||||
changed = sm.update(agent_working=True, approval_needed=False)
|
|
||||||
assert sm.state == FlasherState.AGENT_WORKING
|
|
||||||
assert changed is True
|
|
||||||
|
|
||||||
def test_no_change_returns_false(self):
|
|
||||||
sm = StateMachine()
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
changed = sm.update(agent_working=True, approval_needed=False)
|
|
||||||
assert changed is False
|
|
||||||
|
|
||||||
def test_cooldown_prevents_immediate_retrigger(self):
|
|
||||||
sm = StateMachine(cooldown=5.0)
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
sm.update(agent_working=False, approval_needed=False)
|
|
||||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
|
||||||
sm.dismiss()
|
|
||||||
sm.update(agent_working=True, approval_needed=False)
|
|
||||||
changed = sm.update(agent_working=False, approval_needed=False)
|
|
||||||
assert sm.cooldown == 5.0
|
|
||||||
|
|
||||||
def test_direct_approval_from_idle(self):
|
|
||||||
"""If we detect approval buttons without seeing agent_working first,
|
|
||||||
still transition to WAITING_FOR_USER."""
|
|
||||||
sm = StateMachine()
|
|
||||||
changed = sm.update(agent_working=False, approval_needed=True)
|
|
||||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
|
||||||
assert changed is True
|
|
||||||
348
uv.lock
generated
Normal file
348
uv.lock
generated
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
version = 1
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cursor-flasher"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyobjc-framework-applicationservices" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
{ name = "pyobjc-framework-quartz" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "pyobjc-framework-applicationservices", specifier = ">=12.1" },
|
||||||
|
{ name = "pyobjc-framework-cocoa" },
|
||||||
|
{ name = "pyobjc-framework-quartz" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'" },
|
||||||
|
{ name = "pytest-mock", marker = "extra == 'dev'" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exceptiongroup"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user