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