create-screenfix 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-screenfix.js +40 -0
- package/lib/doctor.js +120 -0
- package/lib/init.js +165 -0
- package/lib/uninstall.js +67 -0
- package/lib/update.js +62 -0
- package/package.json +35 -0
- package/python/build/lib/screenfix/__init__.py +3 -0
- package/python/build/lib/screenfix/annotation_window.py +222 -0
- package/python/build/lib/screenfix/clipboard_watcher.py +164 -0
- package/python/build/lib/screenfix/config.py +66 -0
- package/python/build/lib/screenfix/daemon.py +204 -0
- package/python/build/lib/screenfix/mcp_server.py +318 -0
- package/python/build/lib/screenfix/task_tracker.py +129 -0
- package/python/pyproject.toml +24 -0
- package/python/requirements.txt +5 -0
- package/python/screenfix/__init__.py +3 -0
- package/python/screenfix/annotation_window.py +222 -0
- package/python/screenfix/clipboard_watcher.py +164 -0
- package/python/screenfix/config.py +66 -0
- package/python/screenfix/daemon.py +204 -0
- package/python/screenfix/mcp_server.py +318 -0
- package/python/screenfix/task_tracker.py +129 -0
- package/python/screenfix.egg-info/PKG-INFO +11 -0
- package/python/screenfix.egg-info/SOURCES.txt +14 -0
- package/python/screenfix.egg-info/dependency_links.txt +1 -0
- package/python/screenfix.egg-info/entry_points.txt +3 -0
- package/python/screenfix.egg-info/requires.txt +5 -0
- package/python/screenfix.egg-info/top_level.txt +1 -0
- package/templates/commands/screenfix-list-tasks.md +6 -0
- package/templates/commands/screenfix-start.md +8 -0
- package/templates/commands/screenfix-status.md +6 -0
- package/templates/commands/screenfix-stop.md +6 -0
- package/templates/commands/screenfix-tasks-next.md +14 -0
- package/templates/commands/screenfix-tasks-yolo.md +14 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clipboard watcher for detecting new screenshots.
|
|
3
|
+
|
|
4
|
+
Watches the system clipboard for new image data and triggers
|
|
5
|
+
a callback when detected. Use Cmd+Ctrl+Shift+4 to copy screenshot
|
|
6
|
+
to clipboard (instant, no preview delay).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
from AppKit import NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF
|
|
16
|
+
from Foundation import NSData
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Temp directory for clipboard images
|
|
20
|
+
CLIPBOARD_TEMP_DIR = Path("/tmp/screenfix/clipboard")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClipboardWatcher:
|
|
24
|
+
"""Watches clipboard for new images."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, callback: Callable[[str], None], poll_interval: float = 0.3):
|
|
27
|
+
"""
|
|
28
|
+
Initialize the clipboard watcher.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
callback: Function to call when new image detected.
|
|
32
|
+
Called with path to saved image file.
|
|
33
|
+
poll_interval: How often to check clipboard (seconds).
|
|
34
|
+
"""
|
|
35
|
+
self._callback = callback
|
|
36
|
+
self._poll_interval = poll_interval
|
|
37
|
+
self._running = False
|
|
38
|
+
self._thread: Optional[threading.Thread] = None
|
|
39
|
+
self._last_change_count = 0
|
|
40
|
+
|
|
41
|
+
def start(self):
|
|
42
|
+
"""Start watching the clipboard."""
|
|
43
|
+
if self._running:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Ensure temp directory exists
|
|
47
|
+
CLIPBOARD_TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
# Get initial change count
|
|
50
|
+
pasteboard = NSPasteboard.generalPasteboard()
|
|
51
|
+
self._last_change_count = pasteboard.changeCount()
|
|
52
|
+
|
|
53
|
+
self._running = True
|
|
54
|
+
self._thread = threading.Thread(target=self._watch_loop, daemon=True)
|
|
55
|
+
self._thread.start()
|
|
56
|
+
|
|
57
|
+
def stop(self):
|
|
58
|
+
"""Stop watching the clipboard."""
|
|
59
|
+
self._running = False
|
|
60
|
+
if self._thread:
|
|
61
|
+
self._thread.join(timeout=2.0)
|
|
62
|
+
self._thread = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_running(self) -> bool:
|
|
66
|
+
"""Check if watcher is running."""
|
|
67
|
+
return self._running
|
|
68
|
+
|
|
69
|
+
def _watch_loop(self):
|
|
70
|
+
"""Main watch loop running in background thread."""
|
|
71
|
+
while self._running:
|
|
72
|
+
try:
|
|
73
|
+
self._check_clipboard()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
import sys
|
|
76
|
+
print(f"Clipboard watch error: {e}", file=sys.stderr)
|
|
77
|
+
|
|
78
|
+
time.sleep(self._poll_interval)
|
|
79
|
+
|
|
80
|
+
def _check_clipboard(self):
|
|
81
|
+
"""Check if clipboard has new image data."""
|
|
82
|
+
pasteboard = NSPasteboard.generalPasteboard()
|
|
83
|
+
current_count = pasteboard.changeCount()
|
|
84
|
+
|
|
85
|
+
# No change
|
|
86
|
+
if current_count == self._last_change_count:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
self._last_change_count = current_count
|
|
90
|
+
|
|
91
|
+
# Check for image data (PNG preferred, fallback to TIFF)
|
|
92
|
+
image_data = None
|
|
93
|
+
extension = ".png"
|
|
94
|
+
|
|
95
|
+
# Try PNG first
|
|
96
|
+
png_data = pasteboard.dataForType_(NSPasteboardTypePNG)
|
|
97
|
+
if png_data:
|
|
98
|
+
image_data = png_data
|
|
99
|
+
extension = ".png"
|
|
100
|
+
else:
|
|
101
|
+
# Try TIFF (screenshots are often TIFF on clipboard)
|
|
102
|
+
tiff_data = pasteboard.dataForType_(NSPasteboardTypeTIFF)
|
|
103
|
+
if tiff_data:
|
|
104
|
+
image_data = tiff_data
|
|
105
|
+
extension = ".tiff"
|
|
106
|
+
|
|
107
|
+
if not image_data:
|
|
108
|
+
return # No image on clipboard
|
|
109
|
+
|
|
110
|
+
# Save to temp file
|
|
111
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
112
|
+
filename = f"clipboard_{timestamp}{extension}"
|
|
113
|
+
temp_path = CLIPBOARD_TEMP_DIR / filename
|
|
114
|
+
|
|
115
|
+
# Write the image data
|
|
116
|
+
image_data.writeToFile_atomically_(str(temp_path), True)
|
|
117
|
+
|
|
118
|
+
# Clear clipboard to prevent re-triggers
|
|
119
|
+
pasteboard.clearContents()
|
|
120
|
+
|
|
121
|
+
# Convert TIFF to PNG if needed (for consistency)
|
|
122
|
+
if extension == ".tiff":
|
|
123
|
+
png_path = temp_path.with_suffix(".png")
|
|
124
|
+
if self._convert_to_png(temp_path, png_path):
|
|
125
|
+
os.remove(temp_path)
|
|
126
|
+
temp_path = png_path
|
|
127
|
+
|
|
128
|
+
# Trigger callback
|
|
129
|
+
self._callback(str(temp_path))
|
|
130
|
+
|
|
131
|
+
def _convert_to_png(self, tiff_path: Path, png_path: Path) -> bool:
|
|
132
|
+
"""Convert TIFF to PNG using CoreGraphics."""
|
|
133
|
+
try:
|
|
134
|
+
from Quartz import (
|
|
135
|
+
CGImageSourceCreateWithURL,
|
|
136
|
+
CGImageSourceCreateImageAtIndex,
|
|
137
|
+
CGImageDestinationCreateWithURL,
|
|
138
|
+
CGImageDestinationAddImage,
|
|
139
|
+
CGImageDestinationFinalize,
|
|
140
|
+
kCGImagePropertyOrientation,
|
|
141
|
+
)
|
|
142
|
+
from Foundation import NSURL
|
|
143
|
+
|
|
144
|
+
# Read TIFF
|
|
145
|
+
source_url = NSURL.fileURLWithPath_(str(tiff_path))
|
|
146
|
+
source = CGImageSourceCreateWithURL(source_url, None)
|
|
147
|
+
if not source:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
image = CGImageSourceCreateImageAtIndex(source, 0, None)
|
|
151
|
+
if not image:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
# Write PNG
|
|
155
|
+
dest_url = NSURL.fileURLWithPath_(str(png_path))
|
|
156
|
+
destination = CGImageDestinationCreateWithURL(dest_url, "public.png", 1, None)
|
|
157
|
+
if not destination:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
CGImageDestinationAddImage(destination, image, None)
|
|
161
|
+
return CGImageDestinationFinalize(destination)
|
|
162
|
+
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Configuration management for ScreenFix."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
DEFAULT_CONFIG = {
|
|
8
|
+
"save_directory": "./screenfix/screenshots",
|
|
9
|
+
"tasks_file": "./screenfix/tasks/screenfix-tasks.md",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = Path.home() / ".config" / "screenfix"
|
|
13
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Config:
|
|
17
|
+
"""Configuration manager for ScreenFix."""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self._config = DEFAULT_CONFIG.copy()
|
|
21
|
+
self._load()
|
|
22
|
+
|
|
23
|
+
def _load(self):
|
|
24
|
+
"""Load configuration from file."""
|
|
25
|
+
if CONFIG_FILE.exists():
|
|
26
|
+
try:
|
|
27
|
+
with open(CONFIG_FILE, "r") as f:
|
|
28
|
+
saved = json.load(f)
|
|
29
|
+
self._config.update(saved)
|
|
30
|
+
except (json.JSONDecodeError, IOError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def save(self):
|
|
34
|
+
"""Save configuration to file."""
|
|
35
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with open(CONFIG_FILE, "w") as f:
|
|
37
|
+
json.dump(self._config, f, indent=2)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def save_directory(self) -> str:
|
|
41
|
+
"""Directory where screenshots are saved."""
|
|
42
|
+
return self._config["save_directory"]
|
|
43
|
+
|
|
44
|
+
@save_directory.setter
|
|
45
|
+
def save_directory(self, value: str):
|
|
46
|
+
self._config["save_directory"] = os.path.expanduser(value)
|
|
47
|
+
self.save()
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def tasks_file(self) -> str:
|
|
51
|
+
"""Path to the tasks.md file."""
|
|
52
|
+
return self._config["tasks_file"]
|
|
53
|
+
|
|
54
|
+
@tasks_file.setter
|
|
55
|
+
def tasks_file(self, value: str):
|
|
56
|
+
self._config["tasks_file"] = os.path.expanduser(value)
|
|
57
|
+
self.save()
|
|
58
|
+
|
|
59
|
+
def ensure_directories(self):
|
|
60
|
+
"""Ensure save and tasks directories exist."""
|
|
61
|
+
Path(self.save_directory).mkdir(parents=True, exist_ok=True)
|
|
62
|
+
Path(self.tasks_file).parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Global config instance
|
|
66
|
+
config = Config()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ScreenFix Daemon - Clipboard watcher for instant screenshot capture.
|
|
4
|
+
|
|
5
|
+
Watches the clipboard for new images and shows annotation window instantly.
|
|
6
|
+
Use Cmd+Ctrl+Shift+4 to capture screenshot to clipboard (no preview delay).
|
|
7
|
+
|
|
8
|
+
Run with: python -m screenfix.daemon
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from AppKit import (
|
|
20
|
+
NSApplication,
|
|
21
|
+
NSApplicationActivationPolicyAccessory,
|
|
22
|
+
NSEventMaskAny,
|
|
23
|
+
)
|
|
24
|
+
from Foundation import NSDate, NSDefaultRunLoopMode
|
|
25
|
+
|
|
26
|
+
from .config import config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# State file for communication with MCP server
|
|
30
|
+
STATE_FILE = Path.home() / ".config" / "screenfix" / "state.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def update_state(listening: bool = None, last_capture: str = None):
|
|
34
|
+
"""Update the state file for MCP server to read."""
|
|
35
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
state = {"pid": os.getpid(), "listening": False}
|
|
38
|
+
|
|
39
|
+
if STATE_FILE.exists():
|
|
40
|
+
try:
|
|
41
|
+
with open(STATE_FILE, "r") as f:
|
|
42
|
+
state = json.load(f)
|
|
43
|
+
except (json.JSONDecodeError, IOError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
state["pid"] = os.getpid()
|
|
47
|
+
if listening is not None:
|
|
48
|
+
state["listening"] = listening
|
|
49
|
+
if last_capture is not None:
|
|
50
|
+
state["last_capture"] = last_capture
|
|
51
|
+
state["last_capture_time"] = datetime.now().isoformat()
|
|
52
|
+
|
|
53
|
+
with open(STATE_FILE, "w") as f:
|
|
54
|
+
json.dump(state, f, indent=2)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def clear_state():
|
|
58
|
+
"""Clear the state file on exit."""
|
|
59
|
+
if STATE_FILE.exists():
|
|
60
|
+
try:
|
|
61
|
+
os.remove(STATE_FILE)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ScreenFixDaemon:
|
|
67
|
+
"""Daemon that watches clipboard for screenshots."""
|
|
68
|
+
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self._running = False
|
|
71
|
+
self._clipboard_watcher = None
|
|
72
|
+
self._app = None
|
|
73
|
+
self._main_thread_queue = queue.Queue()
|
|
74
|
+
|
|
75
|
+
def _on_clipboard_image(self, image_path: str):
|
|
76
|
+
"""Called when a new image is detected on clipboard."""
|
|
77
|
+
print(f"Screenshot detected: {image_path}", file=sys.stderr)
|
|
78
|
+
self._main_thread_queue.put(image_path)
|
|
79
|
+
|
|
80
|
+
def _process_main_thread_queue(self):
|
|
81
|
+
"""Process any pending work on the main thread."""
|
|
82
|
+
try:
|
|
83
|
+
while True:
|
|
84
|
+
image_path = self._main_thread_queue.get_nowait()
|
|
85
|
+
self._show_annotation(image_path)
|
|
86
|
+
except queue.Empty:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def _show_annotation(self, image_path: str):
|
|
90
|
+
"""Show the annotation window. Must be called from main thread."""
|
|
91
|
+
from .annotation_window import show_annotation_window
|
|
92
|
+
show_annotation_window(image_path)
|
|
93
|
+
|
|
94
|
+
def _setup_signal_handlers(self):
|
|
95
|
+
"""Set up signal handlers for graceful shutdown."""
|
|
96
|
+
def handle_signal(signum, frame):
|
|
97
|
+
print(f"\nShutting down...", file=sys.stderr)
|
|
98
|
+
self.stop()
|
|
99
|
+
|
|
100
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
101
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
102
|
+
|
|
103
|
+
def start(self):
|
|
104
|
+
"""Start the daemon."""
|
|
105
|
+
if self._running:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
self._running = True
|
|
109
|
+
config.ensure_directories()
|
|
110
|
+
|
|
111
|
+
# Initialize NSApplication for UI
|
|
112
|
+
self._app = NSApplication.sharedApplication()
|
|
113
|
+
self._app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
114
|
+
self._app.finishLaunching()
|
|
115
|
+
|
|
116
|
+
self._setup_signal_handlers()
|
|
117
|
+
|
|
118
|
+
print("ScreenFix daemon started", file=sys.stderr)
|
|
119
|
+
print(f"Screenshots will be saved to: {config.save_directory}", file=sys.stderr)
|
|
120
|
+
print(f"Tasks will be saved to: {config.tasks_file}", file=sys.stderr)
|
|
121
|
+
print("\nUse Cmd+Ctrl+Shift+4 to capture (INSTANT, no delay!)", file=sys.stderr)
|
|
122
|
+
print("Press Ctrl+C to stop.\n", file=sys.stderr)
|
|
123
|
+
|
|
124
|
+
# Start clipboard watcher
|
|
125
|
+
from .clipboard_watcher import ClipboardWatcher
|
|
126
|
+
self._clipboard_watcher = ClipboardWatcher(self._on_clipboard_image)
|
|
127
|
+
self._clipboard_watcher.start()
|
|
128
|
+
|
|
129
|
+
update_state(listening=True)
|
|
130
|
+
|
|
131
|
+
# Run the main loop
|
|
132
|
+
while self._running:
|
|
133
|
+
self._process_main_thread_queue()
|
|
134
|
+
|
|
135
|
+
while True:
|
|
136
|
+
event = self._app.nextEventMatchingMask_untilDate_inMode_dequeue_(
|
|
137
|
+
NSEventMaskAny,
|
|
138
|
+
NSDate.dateWithTimeIntervalSinceNow_(0.05),
|
|
139
|
+
NSDefaultRunLoopMode,
|
|
140
|
+
True
|
|
141
|
+
)
|
|
142
|
+
if event is None:
|
|
143
|
+
break
|
|
144
|
+
self._app.sendEvent_(event)
|
|
145
|
+
|
|
146
|
+
def stop(self):
|
|
147
|
+
"""Stop the daemon."""
|
|
148
|
+
self._running = False
|
|
149
|
+
|
|
150
|
+
if self._clipboard_watcher:
|
|
151
|
+
self._clipboard_watcher.stop()
|
|
152
|
+
self._clipboard_watcher = None
|
|
153
|
+
|
|
154
|
+
clear_state()
|
|
155
|
+
|
|
156
|
+
if self._app:
|
|
157
|
+
self._app.terminate_(None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_daemon_running() -> bool:
|
|
161
|
+
"""Check if the daemon is already running."""
|
|
162
|
+
if not STATE_FILE.exists():
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
with open(STATE_FILE, "r") as f:
|
|
167
|
+
state = json.load(f)
|
|
168
|
+
|
|
169
|
+
pid = state.get("pid")
|
|
170
|
+
if pid:
|
|
171
|
+
try:
|
|
172
|
+
os.kill(pid, 0)
|
|
173
|
+
return True
|
|
174
|
+
except OSError:
|
|
175
|
+
clear_state()
|
|
176
|
+
return False
|
|
177
|
+
except (json.JSONDecodeError, IOError):
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main():
|
|
184
|
+
"""Main entry point."""
|
|
185
|
+
if is_daemon_running():
|
|
186
|
+
print("ScreenFix daemon is already running!", file=sys.stderr)
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
daemon = ScreenFixDaemon()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
daemon.start()
|
|
193
|
+
except KeyboardInterrupt:
|
|
194
|
+
pass
|
|
195
|
+
except Exception as e:
|
|
196
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
197
|
+
import traceback
|
|
198
|
+
traceback.print_exc()
|
|
199
|
+
finally:
|
|
200
|
+
daemon.stop()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|