ai-screenshooter 1.2.2__py3-none-any.whl → 1.3.0__py3-none-any.whl

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
@@ -0,0 +1,6 @@
1
+ ai_screenshot.py,sha256=rju6Mqbr8x25-Teq1RmW3IBza3saCGf9tsKYqHYua7I,11917
2
+ ai_screenshooter-1.3.0.dist-info/METADATA,sha256=xIPXW03LTaUWu0uiCdLONEA__xMyjwV9FNWlDkNA3eo,642
3
+ ai_screenshooter-1.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
4
+ ai_screenshooter-1.3.0.dist-info/entry_points.txt,sha256=1drSTc2kJW1l3MieZG5PVUTe03ltNc0Kijp3ltxOHdc,56
5
+ ai_screenshooter-1.3.0.dist-info/top_level.txt,sha256=mMQnnKAkjRiDyQ-B6jeDVJ_5bBWBN63x-fugfQHvcMQ,14
6
+ ai_screenshooter-1.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
ai_screenshot.py CHANGED
@@ -1,17 +1,34 @@
1
1
  import argparse
2
2
  import os
3
3
  import sys
4
+ import signal
5
+ import logging
6
+ import atexit
7
+ import time
8
+ import subprocess
4
9
  import requests
5
10
  import pygetwindow as gw
6
- import time
11
+ from pathlib import Path
7
12
  from PIL import ImageGrab
8
13
  from pynput import keyboard
9
14
 
10
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
11
27
  screenshot_list = []
12
28
  API_TOKEN = None
13
-
29
+ API_URL = None
14
30
  current_keys = set()
31
+ logger = logging.getLogger("ai-screenshooter")
15
32
 
16
33
  if sys.platform == "win32":
17
34
  import ctypes
@@ -26,6 +43,137 @@ elif sys.platform == "darwin":
26
43
  elif sys.platform == "linux":
27
44
  pass
28
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
+
29
177
  def get_active_window_bounds():
30
178
  """Returns the active window's bounds (x, y, width, height) in a cross-platform way."""
31
179
  time.sleep(0.1)
@@ -37,56 +185,61 @@ def get_active_window_bounds():
37
185
  geometry = gw.getWindowGeometry(active_window)
38
186
  if geometry:
39
187
  x, y, width, height = geometry
40
- print(f"🖥️ Active window detected: {active_window} at ({x}, {y}, {width}, {height})")
188
+ logger.info(f"Active window detected: {active_window} at ({x}, {y}, {width}, {height})")
41
189
  return x, y, width, height
42
190
 
43
191
  elif active_window:
44
192
  x, y = active_window.left, active_window.top
45
193
  width, height = active_window.width, active_window.height
46
- print(f"🖥️ Active window detected: {active_window.title} at ({x}, {y}, {width}, {height})")
194
+ logger.info(f"Active window detected: {active_window.title} at ({x}, {y}, {width}, {height})")
47
195
  return x, y, width, height
48
196
  else:
49
- print("⚠️ No active window detected, defaulting to full screen.")
197
+ logger.warning("No active window detected, defaulting to full screen.")
50
198
 
51
199
  except Exception as e:
52
- print(f"Error detecting active window: {e}")
200
+ logger.error(f"Error detecting active window: {e}")
53
201
 
54
202
  return None
55
203
 
56
204
 
57
205
  def capture_screenshot():
58
206
  global screenshot_list
59
- screenshot_path = f"screenshot_{len(screenshot_list)}.jpg"
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"
60
212
 
61
213
  try:
62
- print("🔄 Refreshing active window detection...")
63
- window_bounds = get_active_window_bounds() # Ensure fresh window detection
214
+ logger.info("Refreshing active window detection...")
215
+ window_bounds = get_active_window_bounds()
64
216
 
65
217
  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})")
218
+ x, y, width, height = map(int, window_bounds)
219
+ logger.info(f"Capturing active window at ({x}, {y}, {width}, {height})")
68
220
  screenshot = ImageGrab.grab(bbox=(x, y, x + width, y + height))
69
221
  else:
70
- print("⚠️ No active window detected, capturing full screen.")
222
+ logger.warning("No active window detected, capturing full screen.")
71
223
  screenshot = ImageGrab.grab()
72
224
 
73
- screenshot.convert("RGB").save(screenshot_path, "JPEG", quality=50)
225
+ screenshot.convert("RGB").save(str(screenshot_path), "JPEG", quality=50)
74
226
 
75
- if os.path.exists(screenshot_path):
76
- screenshot_list.append(screenshot_path)
77
- print(f"Screenshot captured: {screenshot_path}")
227
+ if screenshot_path.exists():
228
+ screenshot_list.append(str(screenshot_path))
229
+ logger.info(f"Screenshot captured: {screenshot_path}")
78
230
  else:
79
- print(f"Screenshot capture failed: {screenshot_path}")
231
+ logger.error(f"Screenshot capture failed: {screenshot_path}")
80
232
  except Exception as e:
81
- print(f"Error capturing screenshot: {e}")
233
+ logger.error(f"Error capturing screenshot: {e}")
234
+
82
235
 
83
236
  def send_screenshots():
84
237
  global screenshot_list
85
238
  if not API_TOKEN:
86
- print("No API token provided!")
239
+ logger.error("No API token provided!")
87
240
  return
88
241
  if not screenshot_list:
89
- print("⚠️ No screenshots to send.")
242
+ logger.warning("No screenshots to send.")
90
243
  return
91
244
 
92
245
  files = []
@@ -94,59 +247,154 @@ def send_screenshots():
94
247
  if os.path.exists(f):
95
248
  files.append(("files", (os.path.basename(f), open(f, "rb"))))
96
249
  else:
97
- print(f"⚠️ Warning: Screenshot file not found: {f}")
250
+ logger.warning(f"Screenshot file not found: {f}")
98
251
 
99
252
  if not files:
100
- print("⚠️ No valid screenshots to send.")
253
+ logger.warning("No valid screenshots to send.")
101
254
  return
102
255
 
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
- )
256
+ try:
257
+ response = requests.post(
258
+ API_URL,
259
+ headers={"Authorization": f"Bearer {API_TOKEN}"},
260
+ files=files,
261
+ )
108
262
 
109
- if response.status_code == 200:
110
- print("Screenshots uploaded successfully.")
111
- screenshot_list = []
112
- else:
113
- print(f"Upload failed: {response.text}")
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 ============
114
273
 
115
274
  def on_press(key):
116
275
  current_keys.add(key)
117
276
  try:
118
277
  if key == keyboard.Key.down and keyboard.Key.esc in current_keys:
119
- print("📸 Capturing screenshot...")
278
+ logger.info("Capturing screenshot...")
120
279
  capture_screenshot()
121
280
  elif key == keyboard.Key.up and keyboard.Key.esc in current_keys:
122
- print("📤 Sending all screenshots...")
281
+ logger.info("Sending all screenshots...")
123
282
  send_screenshots()
124
283
  except AttributeError:
125
284
  pass
126
285
 
286
+
127
287
  def on_release(key):
128
288
  try:
129
289
  current_keys.remove(key)
130
290
  except KeyError:
131
291
  pass
132
292
 
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
293
 
138
- args = parser.parse_args()
139
- global API_TOKEN
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
140
331
  API_TOKEN = args.token
332
+ API_URL = LOCAL_URL if args.local else PROD_URL
141
333
 
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)")
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)")
146
341
 
147
342
  # Listen for hotkeys using pynput
148
343
  with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
149
- listener.join() # Keep script running
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
+
150
398
 
151
399
  if __name__ == "__main__":
152
- main()
400
+ main()
@@ -1,6 +0,0 @@
1
- ai_screenshot.py,sha256=xEGTKArrtLHb5_p5MJKYTnQY7gVYRuh2FxmvcquP2Fg,4992
2
- ai_screenshooter-1.2.2.dist-info/METADATA,sha256=LylyS5dRnWxsHfRJkDX2A5viLbncrRr5sB3HTZtVg9M,642
3
- ai_screenshooter-1.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
4
- ai_screenshooter-1.2.2.dist-info/entry_points.txt,sha256=1drSTc2kJW1l3MieZG5PVUTe03ltNc0Kijp3ltxOHdc,56
5
- ai_screenshooter-1.2.2.dist-info/top_level.txt,sha256=mMQnnKAkjRiDyQ-B6jeDVJ_5bBWBN63x-fugfQHvcMQ,14
6
- ai_screenshooter-1.2.2.dist-info/RECORD,,