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,222 @@
|
|
|
1
|
+
"""Annotation window for adding instructions to screenshots."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from AppKit import (
|
|
9
|
+
NSWindow,
|
|
10
|
+
NSWindowStyleMaskTitled,
|
|
11
|
+
NSWindowStyleMaskClosable,
|
|
12
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
13
|
+
NSBackingStoreBuffered,
|
|
14
|
+
NSImageView,
|
|
15
|
+
NSImage,
|
|
16
|
+
NSScrollView,
|
|
17
|
+
NSTextView,
|
|
18
|
+
NSButton,
|
|
19
|
+
NSBezelStyleRounded,
|
|
20
|
+
NSTextField,
|
|
21
|
+
NSFont,
|
|
22
|
+
NSApplication,
|
|
23
|
+
NSImageScaleProportionallyDown,
|
|
24
|
+
NSPanel,
|
|
25
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces,
|
|
26
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary,
|
|
27
|
+
)
|
|
28
|
+
from Quartz import kCGMaximumWindowLevelKey, CGWindowLevelForKey
|
|
29
|
+
from Foundation import NSMakeRect, NSObject
|
|
30
|
+
import objc
|
|
31
|
+
|
|
32
|
+
from .config import config
|
|
33
|
+
from .task_tracker import add_task
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_current_controller = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_screenshot(temp_path: str) -> str:
|
|
40
|
+
"""Save screenshot from temp location to final location."""
|
|
41
|
+
save_dir = Path(config.save_directory)
|
|
42
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
45
|
+
filename = f"screenshot_{timestamp}.png"
|
|
46
|
+
final_path = save_dir / filename
|
|
47
|
+
|
|
48
|
+
shutil.move(temp_path, final_path)
|
|
49
|
+
return str(final_path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cleanup_temp_file(temp_path: str):
|
|
53
|
+
"""Remove temporary file."""
|
|
54
|
+
try:
|
|
55
|
+
if os.path.exists(temp_path):
|
|
56
|
+
os.remove(temp_path)
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AnnotationWindowDelegate(NSObject):
|
|
62
|
+
"""Delegate to handle window events."""
|
|
63
|
+
|
|
64
|
+
def initWithController_(self, controller):
|
|
65
|
+
self = objc.super(AnnotationWindowDelegate, self).init()
|
|
66
|
+
if self is None:
|
|
67
|
+
return None
|
|
68
|
+
self.controller = controller
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def windowWillClose_(self, notification):
|
|
72
|
+
"""Called when window is about to close."""
|
|
73
|
+
global _current_controller
|
|
74
|
+
_current_controller = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AnnotationWindowController(NSObject):
|
|
78
|
+
"""Controller for the annotation window."""
|
|
79
|
+
|
|
80
|
+
def initWithImagePath_(self, image_path):
|
|
81
|
+
self = objc.super(AnnotationWindowController, self).init()
|
|
82
|
+
if self is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
self.image_path = image_path
|
|
86
|
+
self.window = None
|
|
87
|
+
self.text_view = None
|
|
88
|
+
self.delegate = None
|
|
89
|
+
|
|
90
|
+
self._create_window()
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def _create_window(self):
|
|
94
|
+
"""Create the annotation window."""
|
|
95
|
+
image = NSImage.alloc().initWithContentsOfFile_(self.image_path)
|
|
96
|
+
if not image:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
img_size = image.size()
|
|
100
|
+
|
|
101
|
+
max_display_width = 400
|
|
102
|
+
max_display_height = 300
|
|
103
|
+
|
|
104
|
+
width_scale = min(1.0, max_display_width / img_size.width) if img_size.width > 0 else 1.0
|
|
105
|
+
height_scale = min(1.0, max_display_height / img_size.height) if img_size.height > 0 else 1.0
|
|
106
|
+
scale = min(width_scale, height_scale)
|
|
107
|
+
|
|
108
|
+
display_width = int(img_size.width * scale)
|
|
109
|
+
display_height = int(img_size.height * scale)
|
|
110
|
+
|
|
111
|
+
padding = 20
|
|
112
|
+
window_width = max(display_width + padding * 2, 450)
|
|
113
|
+
text_area_height = 80
|
|
114
|
+
button_area_height = 50
|
|
115
|
+
label_height = 25
|
|
116
|
+
window_height = display_height + text_area_height + button_area_height + label_height + padding * 3
|
|
117
|
+
|
|
118
|
+
style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskNonactivatingPanel
|
|
119
|
+
self.window = NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
120
|
+
NSMakeRect(200, 200, window_width, window_height),
|
|
121
|
+
style,
|
|
122
|
+
NSBackingStoreBuffered,
|
|
123
|
+
False,
|
|
124
|
+
)
|
|
125
|
+
self.window.setTitle_("ScreenFix - Add Instructions")
|
|
126
|
+
|
|
127
|
+
self.window.setFloatingPanel_(True)
|
|
128
|
+
self.window.setHidesOnDeactivate_(False)
|
|
129
|
+
|
|
130
|
+
max_level = CGWindowLevelForKey(kCGMaximumWindowLevelKey)
|
|
131
|
+
self.window.setLevel_(max_level - 1)
|
|
132
|
+
|
|
133
|
+
self.window.setCollectionBehavior_(
|
|
134
|
+
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
|
135
|
+
NSWindowCollectionBehaviorFullScreenAuxiliary
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
self.delegate = AnnotationWindowDelegate.alloc().initWithController_(self)
|
|
139
|
+
self.window.setDelegate_(self.delegate)
|
|
140
|
+
|
|
141
|
+
content = self.window.contentView()
|
|
142
|
+
y_pos = padding
|
|
143
|
+
|
|
144
|
+
# Cancel button
|
|
145
|
+
cancel_btn = NSButton.alloc().initWithFrame_(NSMakeRect(padding, y_pos, 100, 32))
|
|
146
|
+
cancel_btn.setTitle_("Cancel")
|
|
147
|
+
cancel_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
148
|
+
cancel_btn.setTarget_(self)
|
|
149
|
+
cancel_btn.setAction_(objc.selector(self.cancel_, signature=b"v@:@"))
|
|
150
|
+
content.addSubview_(cancel_btn)
|
|
151
|
+
|
|
152
|
+
# Save button
|
|
153
|
+
save_btn = NSButton.alloc().initWithFrame_(NSMakeRect(window_width - padding - 100, y_pos, 100, 32))
|
|
154
|
+
save_btn.setTitle_("Save")
|
|
155
|
+
save_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
156
|
+
save_btn.setTarget_(self)
|
|
157
|
+
save_btn.setAction_(objc.selector(self.save_, signature=b"v@:@"))
|
|
158
|
+
save_btn.setKeyEquivalent_("\r")
|
|
159
|
+
content.addSubview_(save_btn)
|
|
160
|
+
|
|
161
|
+
y_pos += button_area_height
|
|
162
|
+
|
|
163
|
+
# Text view
|
|
164
|
+
scroll_frame = NSMakeRect(padding, y_pos, window_width - padding * 2, text_area_height)
|
|
165
|
+
scroll_view = NSScrollView.alloc().initWithFrame_(scroll_frame)
|
|
166
|
+
scroll_view.setHasVerticalScroller_(True)
|
|
167
|
+
scroll_view.setBorderType_(1)
|
|
168
|
+
|
|
169
|
+
text_frame = NSMakeRect(0, 0, scroll_frame.size.width - 4, scroll_frame.size.height)
|
|
170
|
+
self.text_view = NSTextView.alloc().initWithFrame_(text_frame)
|
|
171
|
+
self.text_view.setFont_(NSFont.systemFontOfSize_(13))
|
|
172
|
+
scroll_view.setDocumentView_(self.text_view)
|
|
173
|
+
content.addSubview_(scroll_view)
|
|
174
|
+
|
|
175
|
+
y_pos += text_area_height + 5
|
|
176
|
+
|
|
177
|
+
# Label
|
|
178
|
+
label = NSTextField.labelWithString_("Instructions for Claude Code:")
|
|
179
|
+
label.setFrame_(NSMakeRect(padding, y_pos, 300, label_height))
|
|
180
|
+
content.addSubview_(label)
|
|
181
|
+
|
|
182
|
+
y_pos += label_height + 5
|
|
183
|
+
|
|
184
|
+
# Image view
|
|
185
|
+
image_view = NSImageView.alloc().initWithFrame_(NSMakeRect(padding, y_pos, display_width, display_height))
|
|
186
|
+
image_view.setImage_(image)
|
|
187
|
+
image_view.setImageScaling_(NSImageScaleProportionallyDown)
|
|
188
|
+
content.addSubview_(image_view)
|
|
189
|
+
|
|
190
|
+
def show(self):
|
|
191
|
+
"""Display the window on top of fullscreen apps."""
|
|
192
|
+
self.window.center()
|
|
193
|
+
self.window.orderFrontRegardless()
|
|
194
|
+
self.window.makeKeyWindow()
|
|
195
|
+
self.window.makeFirstResponder_(self.text_view)
|
|
196
|
+
|
|
197
|
+
def cancel_(self, sender):
|
|
198
|
+
"""Handle cancel button click."""
|
|
199
|
+
cleanup_temp_file(self.image_path)
|
|
200
|
+
self.window.close()
|
|
201
|
+
|
|
202
|
+
def save_(self, sender):
|
|
203
|
+
"""Handle save button click."""
|
|
204
|
+
instructions = self.text_view.string()
|
|
205
|
+
saved_path = save_screenshot(self.image_path)
|
|
206
|
+
|
|
207
|
+
if instructions and instructions.strip():
|
|
208
|
+
add_task(instructions.strip(), saved_path)
|
|
209
|
+
|
|
210
|
+
self.window.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def show_annotation_window(image_path: str) -> None:
|
|
214
|
+
"""Display the annotation window for a screenshot."""
|
|
215
|
+
global _current_controller
|
|
216
|
+
|
|
217
|
+
if _current_controller and _current_controller.window:
|
|
218
|
+
_current_controller.window.close()
|
|
219
|
+
|
|
220
|
+
_current_controller = AnnotationWindowController.alloc().initWithImagePath_(image_path)
|
|
221
|
+
if _current_controller:
|
|
222
|
+
_current_controller.show()
|
|
@@ -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()
|