ai-screenshooter 1.2.3__tar.gz → 1.5.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-screenshooter
3
- Version: 1.2.3
3
+ Version: 1.5.0
4
4
  Summary: A CLI tool to capture and send AI-powered screenshots
5
5
  Home-page: https://github.com/tech4vision/ai-screenshoter
6
6
  Author: Last Shot AI
@@ -13,6 +13,7 @@ Requires-Dist: pynput
13
13
  Requires-Dist: requests
14
14
  Requires-Dist: Pillow
15
15
  Requires-Dist: pygetwindow
16
+ Requires-Dist: pyperclip
16
17
  Dynamic: author
17
18
  Dynamic: author-email
18
19
  Dynamic: classifier
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-screenshooter
3
- Version: 1.2.3
3
+ Version: 1.5.0
4
4
  Summary: A CLI tool to capture and send AI-powered screenshots
5
5
  Home-page: https://github.com/tech4vision/ai-screenshoter
6
6
  Author: Last Shot AI
@@ -13,6 +13,7 @@ Requires-Dist: pynput
13
13
  Requires-Dist: requests
14
14
  Requires-Dist: Pillow
15
15
  Requires-Dist: pygetwindow
16
+ Requires-Dist: pyperclip
16
17
  Dynamic: author
17
18
  Dynamic: author-email
18
19
  Dynamic: classifier
@@ -2,3 +2,4 @@ pynput
2
2
  requests
3
3
  Pillow
4
4
  pygetwindow
5
+ pyperclip
@@ -0,0 +1,434 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import signal
5
+ import logging
6
+ import atexit
7
+ import time
8
+ import subprocess
9
+ import requests
10
+ import pygetwindow as gw
11
+ import pyperclip
12
+ from pathlib import Path
13
+ from PIL import ImageGrab
14
+ from pynput import keyboard
15
+
16
+
17
+ # Constants
18
+ PID_FILE = Path.home() / ".ai-screenshooter.pid"
19
+ LOG_FILE = Path.home() / ".ai-screenshooter.log"
20
+ SCREENSHOT_DIR = Path.home() / ".ai-screenshooter" / "screenshots"
21
+ TIMEOUT_SECONDS = 5 * 60 * 60 # 5 hours
22
+
23
+ # Server URLs
24
+ PROD_URL = "https://service.tech4vision.net/ai-management-service/api/v1/sessions/code-challenge"
25
+ LOCAL_URL = "http://localhost:8082/api/v1/sessions/code-challenge"
26
+
27
+ # Global state
28
+ screenshot_list = []
29
+ API_TOKEN = None
30
+ API_URL = None
31
+ current_keys = set()
32
+ logger = logging.getLogger("ai-screenshooter")
33
+
34
+ if sys.platform == "win32":
35
+ import ctypes
36
+ from ctypes import Structure, c_long
37
+ windll = ctypes.windll
38
+
39
+ class RECT(Structure):
40
+ _fields_ = [("left", c_long), ("top", c_long), ("right", c_long), ("bottom", c_long)]
41
+ elif sys.platform == "darwin":
42
+ from AppKit import NSWorkspace
43
+ from Quartz import CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, kCGNullWindowID
44
+ elif sys.platform == "linux":
45
+ pass
46
+
47
+
48
+ # ============ PID File Management ============
49
+
50
+ def get_pid_from_file():
51
+ """Read PID from file, return None if invalid."""
52
+ if not PID_FILE.exists():
53
+ return None
54
+ try:
55
+ pid = int(PID_FILE.read_text().strip())
56
+ return pid if pid > 0 else None
57
+ except (ValueError, IOError):
58
+ return None
59
+
60
+
61
+ def is_process_running(pid):
62
+ """Check if a process with the given PID is running."""
63
+ try:
64
+ os.kill(pid, 0) # Signal 0 doesn't kill, just checks
65
+ return True
66
+ except ProcessLookupError:
67
+ return False
68
+ except PermissionError:
69
+ # Process exists but we don't have permission
70
+ return True
71
+
72
+
73
+ def write_pid_file():
74
+ """Write current PID to file."""
75
+ PID_FILE.write_text(str(os.getpid()))
76
+
77
+
78
+ def cleanup_pid_file():
79
+ """Remove PID file on exit."""
80
+ try:
81
+ if PID_FILE.exists():
82
+ PID_FILE.unlink()
83
+ except Exception:
84
+ pass
85
+
86
+
87
+ # ============ Process Management ============
88
+
89
+ def kill_existing_process():
90
+ """Kill any existing instance. Returns True if killed."""
91
+ pid = get_pid_from_file()
92
+ if not pid or not is_process_running(pid):
93
+ cleanup_pid_file()
94
+ return False
95
+
96
+ try:
97
+ os.kill(pid, signal.SIGTERM)
98
+ # Wait up to 3 seconds for graceful shutdown
99
+ for _ in range(30):
100
+ time.sleep(0.1)
101
+ if not is_process_running(pid):
102
+ break
103
+ else:
104
+ # Force kill if still running
105
+ os.kill(pid, signal.SIGKILL)
106
+ except ProcessLookupError:
107
+ pass
108
+
109
+ cleanup_pid_file()
110
+ return True
111
+
112
+
113
+ def start_background_process(token, local):
114
+ """Start a new background process using subprocess (avoids fork issues)."""
115
+ # Build command to run this script with --daemon flag
116
+ cmd = [
117
+ sys.executable,
118
+ "-m", "ai_screenshot",
119
+ "start",
120
+ "--token", token,
121
+ "--daemon" # Internal flag for the actual daemon process
122
+ ]
123
+ if local:
124
+ cmd.append("--local")
125
+
126
+ # Start the subprocess detached from terminal
127
+ with open(os.devnull, 'w') as devnull:
128
+ process = subprocess.Popen(
129
+ cmd,
130
+ stdout=devnull,
131
+ stderr=devnull,
132
+ stdin=devnull,
133
+ start_new_session=True, # Detach from terminal
134
+ )
135
+
136
+ print(f"Started background process (PID: {process.pid})")
137
+ print(f"PID file: {PID_FILE}")
138
+ print(f"Log file: {LOG_FILE}")
139
+
140
+
141
+ # ============ Signal Handlers ============
142
+
143
+ def handle_sigterm(signum, frame):
144
+ """Handle SIGTERM for graceful shutdown."""
145
+ logger.info("Received SIGTERM, shutting down...")
146
+ cleanup_pid_file()
147
+ sys.exit(0)
148
+
149
+
150
+ def handle_sigalrm(signum, frame):
151
+ """Handle SIGALRM for auto-termination after timeout."""
152
+ logger.info("5-hour timeout reached, auto-terminating...")
153
+ cleanup_pid_file()
154
+ sys.exit(0)
155
+
156
+
157
+ # ============ Logging Setup ============
158
+
159
+ def setup_logging(daemon_mode):
160
+ """Configure logging based on mode."""
161
+ logger.setLevel(logging.INFO)
162
+ logger.handlers.clear()
163
+
164
+ if daemon_mode:
165
+ # File logging for daemon mode
166
+ handler = logging.FileHandler(LOG_FILE)
167
+ handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
168
+ else:
169
+ # Console logging for foreground mode
170
+ handler = logging.StreamHandler()
171
+ handler.setFormatter(logging.Formatter('%(message)s'))
172
+
173
+ logger.addHandler(handler)
174
+
175
+
176
+ # ============ Screenshot Functions ============
177
+
178
+ def get_active_window_bounds():
179
+ """Returns the active window's bounds (x, y, width, height) in a cross-platform way."""
180
+ time.sleep(0.1)
181
+
182
+ try:
183
+ active_window = gw.getActiveWindow()
184
+
185
+ if isinstance(active_window, str): # Ensure it's a window name
186
+ geometry = gw.getWindowGeometry(active_window)
187
+ if geometry:
188
+ x, y, width, height = geometry
189
+ logger.info(f"Active window detected: {active_window} at ({x}, {y}, {width}, {height})")
190
+ return x, y, width, height
191
+
192
+ elif active_window:
193
+ x, y = active_window.left, active_window.top
194
+ width, height = active_window.width, active_window.height
195
+ logger.info(f"Active window detected: {active_window.title} at ({x}, {y}, {width}, {height})")
196
+ return x, y, width, height
197
+ else:
198
+ logger.warning("No active window detected, defaulting to full screen.")
199
+
200
+ except Exception as e:
201
+ logger.error(f"Error detecting active window: {e}")
202
+
203
+ return None
204
+
205
+
206
+ def capture_screenshot():
207
+ global screenshot_list
208
+
209
+ # Ensure screenshot directory exists
210
+ SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
211
+
212
+ screenshot_path = SCREENSHOT_DIR / f"screenshot_{len(screenshot_list)}.jpg"
213
+
214
+ try:
215
+ logger.info("Refreshing active window detection...")
216
+ window_bounds = get_active_window_bounds()
217
+
218
+ if window_bounds:
219
+ x, y, width, height = map(int, window_bounds)
220
+ logger.info(f"Capturing active window at ({x}, {y}, {width}, {height})")
221
+ screenshot = ImageGrab.grab(bbox=(x, y, x + width, y + height))
222
+ else:
223
+ logger.warning("No active window detected, capturing full screen.")
224
+ screenshot = ImageGrab.grab()
225
+
226
+ screenshot.convert("RGB").save(str(screenshot_path), "JPEG", quality=50)
227
+
228
+ if screenshot_path.exists():
229
+ screenshot_list.append(str(screenshot_path))
230
+ logger.info(f"Screenshot captured: {screenshot_path}")
231
+ else:
232
+ logger.error(f"Screenshot capture failed: {screenshot_path}")
233
+ except Exception as e:
234
+ logger.error(f"Error capturing screenshot: {e}")
235
+
236
+
237
+ def send_screenshots():
238
+ global screenshot_list
239
+ if not API_TOKEN:
240
+ logger.error("No API token provided!")
241
+ return
242
+ if not screenshot_list:
243
+ logger.warning("No screenshots to send.")
244
+ return
245
+
246
+ files = []
247
+ for f in screenshot_list:
248
+ if os.path.exists(f):
249
+ files.append(("files", (os.path.basename(f), open(f, "rb"))))
250
+ else:
251
+ logger.warning(f"Screenshot file not found: {f}")
252
+
253
+ if not files:
254
+ logger.warning("No valid screenshots to send.")
255
+ return
256
+
257
+ try:
258
+ response = requests.post(
259
+ API_URL,
260
+ headers={"Authorization": f"Bearer {API_TOKEN}"},
261
+ files=files,
262
+ )
263
+
264
+ if response.status_code == 200:
265
+ logger.info("Screenshots uploaded successfully.")
266
+ screenshot_list = []
267
+ else:
268
+ logger.error(f"Upload failed: {response.text}")
269
+ except Exception as e:
270
+ logger.error(f"Error uploading screenshots: {e}")
271
+
272
+
273
+ def send_clipboard_text():
274
+ """Send clipboard content to Code tab API."""
275
+ if not API_TOKEN:
276
+ logger.error("No API token provided!")
277
+ return
278
+
279
+ try:
280
+ text = pyperclip.paste()
281
+ if not text or not text.strip():
282
+ logger.warning("Clipboard is empty.")
283
+ return
284
+
285
+ response = requests.post(
286
+ f"{API_URL}/chat",
287
+ headers={
288
+ "Authorization": f"Bearer {API_TOKEN}",
289
+ "Content-Type": "application/json"
290
+ },
291
+ json={"message": text}
292
+ )
293
+
294
+ if response.status_code == 200:
295
+ logger.info("Text sent to Code tab successfully.")
296
+ else:
297
+ logger.error(f"Failed to send text: {response.text}")
298
+ except Exception as e:
299
+ logger.error(f"Error sending clipboard text: {e}")
300
+
301
+
302
+ # ============ Keyboard Handlers ============
303
+
304
+ def on_press(key):
305
+ current_keys.add(key)
306
+ try:
307
+ if key == keyboard.Key.down and keyboard.Key.esc in current_keys:
308
+ logger.info("Capturing screenshot...")
309
+ capture_screenshot()
310
+ elif key == keyboard.Key.up and keyboard.Key.esc in current_keys:
311
+ logger.info("Sending all screenshots...")
312
+ send_screenshots()
313
+ elif key == keyboard.Key.right and keyboard.Key.esc in current_keys:
314
+ logger.info("Sending clipboard text to Code tab...")
315
+ send_clipboard_text()
316
+ except AttributeError:
317
+ pass
318
+
319
+
320
+ def on_release(key):
321
+ try:
322
+ current_keys.remove(key)
323
+ except KeyError:
324
+ pass
325
+
326
+
327
+ # ============ CLI Commands ============
328
+
329
+ def cmd_start(args):
330
+ """Handle the start command."""
331
+ global API_TOKEN, API_URL
332
+
333
+ # If --background flag, spawn a new process and exit
334
+ if args.background:
335
+ print("Starting in background mode...")
336
+ killed = kill_existing_process()
337
+ if killed:
338
+ print("Killed existing instance.")
339
+
340
+ start_background_process(args.token, args.local)
341
+ return
342
+
343
+ # If --daemon flag (internal), this is the actual daemon process
344
+ is_daemon = getattr(args, 'daemon', False)
345
+
346
+ if is_daemon:
347
+ # Write PID file
348
+ write_pid_file()
349
+ atexit.register(cleanup_pid_file)
350
+
351
+ # Set up logging to file
352
+ setup_logging(daemon_mode=True)
353
+
354
+ # Set 5-hour auto-termination timer
355
+ signal.signal(signal.SIGALRM, handle_sigalrm)
356
+ signal.alarm(TIMEOUT_SECONDS)
357
+ else:
358
+ setup_logging(daemon_mode=False)
359
+
360
+ # Signal handler for graceful shutdown
361
+ signal.signal(signal.SIGTERM, handle_sigterm)
362
+
363
+ # Setup API config
364
+ API_TOKEN = args.token
365
+ API_URL = LOCAL_URL if args.local else PROD_URL
366
+
367
+ server_mode = "LOCAL" if args.local else "PRODUCTION"
368
+ logger.info("AI Screenshot CLI started.")
369
+ logger.info(f"Server: {server_mode} ({API_URL})")
370
+ logger.info("Press ESC + Down to capture a screenshot.")
371
+ logger.info("Press ESC + Up to send all stored screenshots.")
372
+ logger.info("Press ESC + Right to send clipboard text to Code tab.")
373
+ if not is_daemon:
374
+ logger.info("Running... (Press Ctrl + C to exit)")
375
+
376
+ # Listen for hotkeys using pynput
377
+ with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
378
+ listener.join()
379
+
380
+
381
+ def cmd_status(args):
382
+ """Handle the status command."""
383
+ pid = get_pid_from_file()
384
+ if pid and is_process_running(pid):
385
+ print(f"ai-screenshooter is running (PID: {pid})")
386
+ return 0
387
+ else:
388
+ print("ai-screenshooter is not running")
389
+ if PID_FILE.exists():
390
+ print(f"(stale PID file exists at {PID_FILE})")
391
+ return 1
392
+
393
+
394
+ def cmd_stop(args):
395
+ """Handle the stop command."""
396
+ if kill_existing_process():
397
+ print("ai-screenshooter stopped")
398
+ return 0
399
+ else:
400
+ print("ai-screenshooter is not running")
401
+ return 1
402
+
403
+
404
+ # ============ Main Entry Point ============
405
+
406
+ def main():
407
+ parser = argparse.ArgumentParser(description="AI Screenshot CLI")
408
+ subparsers = parser.add_subparsers(dest="command", required=True)
409
+
410
+ # start command
411
+ start_parser = subparsers.add_parser("start", help="Start listening for hotkeys")
412
+ start_parser.add_argument("--token", required=True, help="API Token for authentication")
413
+ start_parser.add_argument("--local", action="store_true", help="Use localhost server instead of production")
414
+ start_parser.add_argument("--background", "-b", action="store_true", help="Run in background (daemon mode)")
415
+ start_parser.add_argument("--daemon", action="store_true", help=argparse.SUPPRESS) # Internal flag
416
+
417
+ # status command
418
+ subparsers.add_parser("status", help="Check if ai-screenshooter is running")
419
+
420
+ # stop command
421
+ subparsers.add_parser("stop", help="Stop the running ai-screenshooter instance")
422
+
423
+ args = parser.parse_args()
424
+
425
+ if args.command == "start":
426
+ cmd_start(args)
427
+ elif args.command == "status":
428
+ sys.exit(cmd_status(args))
429
+ elif args.command == "stop":
430
+ sys.exit(cmd_stop(args))
431
+
432
+
433
+ if __name__ == "__main__":
434
+ main()
@@ -2,14 +2,15 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="ai-screenshooter",
5
- version="1.2.3",
5
+ version="1.5.0",
6
6
  packages=find_packages(),
7
7
  py_modules=["ai_screenshot"],
8
8
  install_requires=[
9
9
  "pynput",
10
10
  "requests",
11
11
  "Pillow",
12
- "pygetwindow"
12
+ "pygetwindow",
13
+ "pyperclip"
13
14
  ],
14
15
  entry_points={
15
16
  "console_scripts": [
@@ -1,161 +0,0 @@
1
- import argparse
2
- import os
3
- import sys
4
- import requests
5
- import pygetwindow as gw
6
- import time
7
- from PIL import ImageGrab
8
- from pynput import keyboard
9
-
10
-
11
- screenshot_list = []
12
- API_TOKEN = None
13
- API_URL = None
14
-
15
- # Server URLs
16
- PROD_URL = "https://service.tech4vision.net/ai-management-service/api/v1/sessions/code-challenge"
17
- LOCAL_URL = "http://localhost:8082/api/v1/sessions/code-challenge"
18
-
19
- current_keys = set()
20
-
21
- if sys.platform == "win32":
22
- import ctypes
23
- from ctypes import Structure, c_long
24
- windll = ctypes.windll
25
-
26
- class RECT(Structure):
27
- _fields_ = [("left", c_long), ("top", c_long), ("right", c_long), ("bottom", c_long)]
28
- elif sys.platform == "darwin":
29
- from AppKit import NSWorkspace
30
- from Quartz import CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, kCGNullWindowID
31
- elif sys.platform == "linux":
32
- pass
33
-
34
- def get_active_window_bounds():
35
- """Returns the active window's bounds (x, y, width, height) in a cross-platform way."""
36
- time.sleep(0.1)
37
-
38
- try:
39
- active_window = gw.getActiveWindow()
40
-
41
- if isinstance(active_window, str): # Ensure it's a window name
42
- geometry = gw.getWindowGeometry(active_window)
43
- if geometry:
44
- x, y, width, height = geometry
45
- print(f"🖥️ Active window detected: {active_window} at ({x}, {y}, {width}, {height})")
46
- return x, y, width, height
47
-
48
- elif active_window:
49
- x, y = active_window.left, active_window.top
50
- width, height = active_window.width, active_window.height
51
- print(f"🖥️ Active window detected: {active_window.title} at ({x}, {y}, {width}, {height})")
52
- return x, y, width, height
53
- else:
54
- print("⚠️ No active window detected, defaulting to full screen.")
55
-
56
- except Exception as e:
57
- print(f"❌ Error detecting active window: {e}")
58
-
59
- return None
60
-
61
-
62
- def capture_screenshot():
63
- global screenshot_list
64
- screenshot_path = f"screenshot_{len(screenshot_list)}.jpg"
65
-
66
- try:
67
- print("🔄 Refreshing active window detection...")
68
- window_bounds = get_active_window_bounds() # Ensure fresh window detection
69
-
70
- if window_bounds:
71
- x, y, width, height = map(int, window_bounds) # Convert all values to integers
72
- print(f"📸 Capturing active window at ({x}, {y}, {width}, {height})")
73
- screenshot = ImageGrab.grab(bbox=(x, y, x + width, y + height))
74
- else:
75
- print("⚠️ No active window detected, capturing full screen.")
76
- screenshot = ImageGrab.grab()
77
-
78
- screenshot.convert("RGB").save(screenshot_path, "JPEG", quality=50)
79
-
80
- if os.path.exists(screenshot_path):
81
- screenshot_list.append(screenshot_path)
82
- print(f"✅ Screenshot captured: {screenshot_path}")
83
- else:
84
- print(f"❌ Screenshot capture failed: {screenshot_path}")
85
- except Exception as e:
86
- print(f"❌ Error capturing screenshot: {e}")
87
-
88
- def send_screenshots():
89
- global screenshot_list
90
- if not API_TOKEN:
91
- print("❌ No API token provided!")
92
- return
93
- if not screenshot_list:
94
- print("⚠️ No screenshots to send.")
95
- return
96
-
97
- files = []
98
- for f in screenshot_list:
99
- if os.path.exists(f):
100
- files.append(("files", (os.path.basename(f), open(f, "rb"))))
101
- else:
102
- print(f"⚠️ Warning: Screenshot file not found: {f}")
103
-
104
- if not files:
105
- print("⚠️ No valid screenshots to send.")
106
- return
107
-
108
- response = requests.post(
109
- API_URL,
110
- headers={"Authorization": f"Bearer {API_TOKEN}"},
111
- files=files,
112
- )
113
-
114
- if response.status_code == 200:
115
- print("✅ Screenshots uploaded successfully.")
116
- screenshot_list = []
117
- else:
118
- print(f"❌ Upload failed: {response.text}")
119
-
120
- def on_press(key):
121
- current_keys.add(key)
122
- try:
123
- if key == keyboard.Key.down and keyboard.Key.esc in current_keys:
124
- print("📸 Capturing screenshot...")
125
- capture_screenshot()
126
- elif key == keyboard.Key.up and keyboard.Key.esc in current_keys:
127
- print("📤 Sending all screenshots...")
128
- send_screenshots()
129
- except AttributeError:
130
- pass
131
-
132
- def on_release(key):
133
- try:
134
- current_keys.remove(key)
135
- except KeyError:
136
- pass
137
-
138
- def main():
139
- parser = argparse.ArgumentParser(description="AI Screenshot CLI")
140
- parser.add_argument("start", help="Start listening for hotkeys")
141
- parser.add_argument("--token", required=True, help="API Token for authentication")
142
- parser.add_argument("--local", action="store_true", help="Use localhost server instead of production")
143
-
144
- args = parser.parse_args()
145
- global API_TOKEN, API_URL
146
- API_TOKEN = args.token
147
- API_URL = LOCAL_URL if args.local else PROD_URL
148
-
149
- server_mode = "LOCAL" if args.local else "PRODUCTION"
150
- print("📸 AI Screenshot CLI started.")
151
- print(f"🌐 Server: {server_mode} ({API_URL})")
152
- print("✅ Press ESC + ↓ to capture a screenshot.")
153
- print("✅ Press ESC + ↑ to send all stored screenshots.")
154
- print("📌 Running... (Press Ctrl + C to exit)")
155
-
156
- # Listen for hotkeys using pynput
157
- with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
158
- listener.join() # Keep script running
159
-
160
- if __name__ == "__main__":
161
- main()