iflow-mcp_bethington-cheat-engine-server-python 0.1.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.
- iflow_mcp_bethington_cheat_engine_server_python-0.1.0.dist-info/METADATA +16 -0
- iflow_mcp_bethington_cheat_engine_server_python-0.1.0.dist-info/RECORD +40 -0
- iflow_mcp_bethington_cheat_engine_server_python-0.1.0.dist-info/WHEEL +5 -0
- iflow_mcp_bethington_cheat_engine_server_python-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_bethington_cheat_engine_server_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_bethington_cheat_engine_server_python-0.1.0.dist-info/top_level.txt +1 -0
- server/cheatengine/__init__.py +19 -0
- server/cheatengine/ce_bridge.py +1670 -0
- server/cheatengine/lua_interface.py +460 -0
- server/cheatengine/table_parser.py +1221 -0
- server/config/__init__.py +20 -0
- server/config/settings.py +347 -0
- server/config/whitelist.py +378 -0
- server/gui_automation/__init__.py +43 -0
- server/gui_automation/core/__init__.py +8 -0
- server/gui_automation/core/integration.py +951 -0
- server/gui_automation/demos/__init__.py +8 -0
- server/gui_automation/demos/basic_demo.py +754 -0
- server/gui_automation/demos/notepad_demo.py +460 -0
- server/gui_automation/demos/simple_demo.py +319 -0
- server/gui_automation/tools/__init__.py +8 -0
- server/gui_automation/tools/mcp_tools.py +974 -0
- server/main.py +519 -0
- server/memory/__init__.py +0 -0
- server/memory/analyzer.py +0 -0
- server/memory/reader.py +0 -0
- server/memory/scanner.py +0 -0
- server/memory/symbols.py +0 -0
- server/process/__init__.py +16 -0
- server/process/launcher.py +608 -0
- server/process/manager.py +185 -0
- server/process/monitors.py +202 -0
- server/process/permissions.py +131 -0
- server/process_whitelist.json +119 -0
- server/pyautogui/__init__.py +0 -0
- server/utils/__init__.py +37 -0
- server/utils/data_types.py +368 -0
- server/utils/formatters.py +430 -0
- server/utils/validators.py +340 -0
- server/window_automation/__init__.py +59 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application Launcher Module
|
|
3
|
+
Handles execution and termination of whitelisted applications
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import subprocess
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
import json
|
|
12
|
+
from typing import Dict, List, Optional, Any, Tuple
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
import psutil
|
|
18
|
+
|
|
19
|
+
# Import whitelist functionality
|
|
20
|
+
from server.config.whitelist import ProcessWhitelist
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LaunchedApplication:
|
|
26
|
+
"""Information about a launched application"""
|
|
27
|
+
process_name: str
|
|
28
|
+
pid: int
|
|
29
|
+
exe_path: str
|
|
30
|
+
launch_time: datetime
|
|
31
|
+
command_line: List[str]
|
|
32
|
+
working_directory: str
|
|
33
|
+
status: str = "running" # running, terminated, crashed
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
36
|
+
"""Convert to dictionary for serialization"""
|
|
37
|
+
return {
|
|
38
|
+
'process_name': self.process_name,
|
|
39
|
+
'pid': self.pid,
|
|
40
|
+
'exe_path': self.exe_path,
|
|
41
|
+
'launch_time': self.launch_time.isoformat(),
|
|
42
|
+
'command_line': self.command_line,
|
|
43
|
+
'working_directory': self.working_directory,
|
|
44
|
+
'status': self.status
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class ApplicationLauncher:
|
|
48
|
+
"""Manages launching and termination of whitelisted applications"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, whitelist: ProcessWhitelist):
|
|
51
|
+
self.whitelist = whitelist
|
|
52
|
+
self.launched_applications: Dict[int, LaunchedApplication] = {}
|
|
53
|
+
self._session_file = None
|
|
54
|
+
|
|
55
|
+
# Common Windows application paths
|
|
56
|
+
self.system_paths = [
|
|
57
|
+
os.environ.get('WINDIR', 'C:\\Windows'),
|
|
58
|
+
os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'System32'),
|
|
59
|
+
os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'SysWOW64'),
|
|
60
|
+
os.environ.get('PROGRAMFILES', 'C:\\Program Files'),
|
|
61
|
+
os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)'),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# Add common application directories
|
|
65
|
+
self.common_app_paths = [
|
|
66
|
+
os.path.join(os.environ.get('PROGRAMFILES', 'C:\\Program Files'), 'Windows NT', 'Accessories'),
|
|
67
|
+
os.path.join(os.environ.get('PROGRAMFILES', 'C:\\Program Files'), 'Microsoft VS Code'),
|
|
68
|
+
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'Microsoft VS Code'),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
logger.info("Application launcher initialized")
|
|
72
|
+
|
|
73
|
+
def set_session_file(self, session_file_path: str):
|
|
74
|
+
"""Set the session file for persistence"""
|
|
75
|
+
self._session_file = session_file_path
|
|
76
|
+
self._load_session()
|
|
77
|
+
|
|
78
|
+
def _load_session(self):
|
|
79
|
+
"""Load previously launched applications from session file"""
|
|
80
|
+
if not self._session_file or not os.path.exists(self._session_file):
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with open(self._session_file, 'r') as f:
|
|
85
|
+
session_data = json.load(f)
|
|
86
|
+
|
|
87
|
+
for app_data in session_data.get('launched_applications', []):
|
|
88
|
+
try:
|
|
89
|
+
# Check if process is still running
|
|
90
|
+
pid = app_data['pid']
|
|
91
|
+
if psutil.pid_exists(pid):
|
|
92
|
+
app = LaunchedApplication(
|
|
93
|
+
process_name=app_data['process_name'],
|
|
94
|
+
pid=pid,
|
|
95
|
+
exe_path=app_data['exe_path'],
|
|
96
|
+
launch_time=datetime.fromisoformat(app_data['launch_time']),
|
|
97
|
+
command_line=app_data['command_line'],
|
|
98
|
+
working_directory=app_data['working_directory'],
|
|
99
|
+
status='running'
|
|
100
|
+
)
|
|
101
|
+
self.launched_applications[pid] = app
|
|
102
|
+
logger.info(f"Restored session for PID {pid}: {app.process_name}")
|
|
103
|
+
else:
|
|
104
|
+
logger.info(f"Process {pid} ({app_data['process_name']}) no longer running")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.warning(f"Failed to restore session entry: {e}")
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Failed to load session file: {e}")
|
|
110
|
+
|
|
111
|
+
def _save_session(self):
|
|
112
|
+
"""Save current launched applications to session file"""
|
|
113
|
+
if not self._session_file:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Update status of all applications
|
|
118
|
+
self._update_application_status()
|
|
119
|
+
|
|
120
|
+
session_data = {
|
|
121
|
+
'last_updated': datetime.now().isoformat(),
|
|
122
|
+
'launched_applications': [app.to_dict() for app in self.launched_applications.values()]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Ensure directory exists
|
|
126
|
+
os.makedirs(os.path.dirname(self._session_file), exist_ok=True)
|
|
127
|
+
|
|
128
|
+
with open(self._session_file, 'w') as f:
|
|
129
|
+
json.dump(session_data, f, indent=2)
|
|
130
|
+
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Failed to save session file: {e}")
|
|
133
|
+
|
|
134
|
+
def find_executable(self, process_name: str) -> Optional[str]:
|
|
135
|
+
"""Find the full path to an executable"""
|
|
136
|
+
|
|
137
|
+
# If it's already a full path and exists
|
|
138
|
+
if os.path.isabs(process_name) and os.path.exists(process_name):
|
|
139
|
+
return process_name
|
|
140
|
+
|
|
141
|
+
# Try just the name in PATH
|
|
142
|
+
try:
|
|
143
|
+
result = subprocess.run(['where', process_name],
|
|
144
|
+
capture_output=True, text=True, shell=True)
|
|
145
|
+
if result.returncode == 0:
|
|
146
|
+
paths = result.stdout.strip().split('\n')
|
|
147
|
+
if paths and paths[0].strip():
|
|
148
|
+
return paths[0].strip()
|
|
149
|
+
except:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Search in common system paths
|
|
153
|
+
for search_path in self.system_paths + self.common_app_paths:
|
|
154
|
+
if os.path.exists(search_path):
|
|
155
|
+
full_path = os.path.join(search_path, process_name)
|
|
156
|
+
if os.path.exists(full_path):
|
|
157
|
+
return full_path
|
|
158
|
+
|
|
159
|
+
# Search in PATH environment variable
|
|
160
|
+
path_env = os.environ.get('PATH', '')
|
|
161
|
+
for path_dir in path_env.split(os.pathsep):
|
|
162
|
+
if os.path.exists(path_dir):
|
|
163
|
+
full_path = os.path.join(path_dir, process_name)
|
|
164
|
+
if os.path.exists(full_path):
|
|
165
|
+
return full_path
|
|
166
|
+
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _find_running_process(self, process_name: str) -> Optional[int]:
|
|
170
|
+
"""Find a running process by name
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
process_name: Name of the process to find
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
PID of the running process, or None if not found
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
# Remove .exe extension for comparison
|
|
180
|
+
base_name = process_name.lower().replace('.exe', '')
|
|
181
|
+
|
|
182
|
+
# Map common launcher names to actual app names
|
|
183
|
+
app_mapping = {
|
|
184
|
+
'calc': ['calculatorapp', 'calculator'],
|
|
185
|
+
'notepad': ['notepad'], # Notepad.exe vs notepad.exe
|
|
186
|
+
'mspaint': ['paint', 'mspaint', 'paintdotnet'],
|
|
187
|
+
'wordpad': ['wordpad'],
|
|
188
|
+
'dbengine-x86_64': ['dbengine-x86_64', 'dbengine'],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Get possible actual process names
|
|
192
|
+
possible_names = [base_name]
|
|
193
|
+
if base_name in app_mapping:
|
|
194
|
+
possible_names.extend(app_mapping[base_name])
|
|
195
|
+
|
|
196
|
+
for proc in psutil.process_iter(['pid', 'name', 'create_time']):
|
|
197
|
+
try:
|
|
198
|
+
proc_name = proc.info['name'].lower().replace('.exe', '')
|
|
199
|
+
|
|
200
|
+
# Check if this process matches any of our possible names
|
|
201
|
+
for possible_name in possible_names:
|
|
202
|
+
if (possible_name in proc_name or proc_name in possible_name):
|
|
203
|
+
pid = proc.info['pid']
|
|
204
|
+
|
|
205
|
+
# Make sure it's not one we already launched
|
|
206
|
+
if pid not in self.launched_applications:
|
|
207
|
+
# Check if this is a recently created process (within last 10 seconds)
|
|
208
|
+
process_age = time.time() - proc.info['create_time']
|
|
209
|
+
if process_age <= 10: # Recently created
|
|
210
|
+
logger.info(f"Found recently created process: PID {pid} ({proc.info['name']}) for {process_name}")
|
|
211
|
+
return pid
|
|
212
|
+
|
|
213
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.warning(f"Error finding running process {process_name}: {e}")
|
|
218
|
+
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def get_whitelisted_applications(self) -> List[Dict[str, Any]]:
|
|
222
|
+
"""Get list of all whitelisted applications"""
|
|
223
|
+
if not self.whitelist.is_enabled():
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
applications = []
|
|
227
|
+
for entry in self.whitelist.entries:
|
|
228
|
+
if entry.enabled:
|
|
229
|
+
app_info = {
|
|
230
|
+
'process_name': entry.process_name,
|
|
231
|
+
'description': entry.description,
|
|
232
|
+
'category': entry.category,
|
|
233
|
+
'exact_match': entry.exact_match,
|
|
234
|
+
'executable_path': self.find_executable(entry.process_name) if entry.exact_match else None
|
|
235
|
+
}
|
|
236
|
+
applications.append(app_info)
|
|
237
|
+
|
|
238
|
+
return applications
|
|
239
|
+
|
|
240
|
+
def launch_application(self, process_name: str, arguments: Optional[List[str]] = None,
|
|
241
|
+
working_directory: Optional[str] = None) -> Tuple[bool, str, Optional[int]]:
|
|
242
|
+
"""Launch a whitelisted application
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
process_name: Name of the process to launch
|
|
246
|
+
arguments: Optional command line arguments
|
|
247
|
+
working_directory: Optional working directory
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Tuple of (success, message, pid)
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
# Check if process is whitelisted
|
|
254
|
+
# For whitelist checking, use just the filename (not full path)
|
|
255
|
+
check_name = os.path.basename(process_name) if os.path.isabs(process_name) else process_name
|
|
256
|
+
if self.whitelist.is_enabled() and not self.whitelist.is_allowed(check_name):
|
|
257
|
+
return False, f"Process '{check_name}' is not whitelisted", None
|
|
258
|
+
|
|
259
|
+
# Find executable
|
|
260
|
+
exe_path = self.find_executable(process_name)
|
|
261
|
+
if not exe_path:
|
|
262
|
+
return False, f"Could not find executable for '{process_name}'", None
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# Prepare command line
|
|
266
|
+
cmd_line = [exe_path]
|
|
267
|
+
if arguments:
|
|
268
|
+
cmd_line.extend(arguments)
|
|
269
|
+
|
|
270
|
+
# Set working directory
|
|
271
|
+
if not working_directory:
|
|
272
|
+
# Use the directory containing the executable, or current directory as fallback
|
|
273
|
+
if os.path.dirname(exe_path):
|
|
274
|
+
working_directory = os.path.dirname(exe_path)
|
|
275
|
+
else:
|
|
276
|
+
working_directory = os.getcwd()
|
|
277
|
+
|
|
278
|
+
logger.info(f"Launching application: {' '.join(cmd_line)}")
|
|
279
|
+
logger.info(f"Working directory: {working_directory}")
|
|
280
|
+
|
|
281
|
+
# Launch the process - use startupinfo to detach properly on Windows
|
|
282
|
+
if sys.platform == 'win32':
|
|
283
|
+
# Windows constants for subprocess
|
|
284
|
+
SW_NORMAL = 1
|
|
285
|
+
STARTF_USESHOWWINDOW = 1
|
|
286
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
287
|
+
|
|
288
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
289
|
+
startupinfo.dwFlags |= STARTF_USESHOWWINDOW
|
|
290
|
+
startupinfo.wShowWindow = SW_NORMAL
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
process = subprocess.Popen(
|
|
294
|
+
cmd_line,
|
|
295
|
+
cwd=working_directory,
|
|
296
|
+
startupinfo=startupinfo,
|
|
297
|
+
creationflags=CREATE_NEW_PROCESS_GROUP
|
|
298
|
+
)
|
|
299
|
+
except OSError as e:
|
|
300
|
+
if e.winerror == 740: # ERROR_ELEVATION_REQUIRED
|
|
301
|
+
# Try launching with elevated permissions using shell execute
|
|
302
|
+
logger.info(f"Application requires elevation, attempting elevated launch...")
|
|
303
|
+
import ctypes
|
|
304
|
+
try:
|
|
305
|
+
# Use ShellExecuteW with 'runas' verb for elevation
|
|
306
|
+
result = ctypes.windll.shell32.ShellExecuteW(
|
|
307
|
+
None,
|
|
308
|
+
"runas", # This triggers UAC prompt
|
|
309
|
+
exe_path,
|
|
310
|
+
" ".join(arguments) if arguments else None,
|
|
311
|
+
working_directory,
|
|
312
|
+
SW_NORMAL
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if result > 32: # Success
|
|
316
|
+
# ShellExecute doesn't return a process object, so we need to find the process
|
|
317
|
+
logger.info("Elevated launch initiated, waiting for process to start...")
|
|
318
|
+
time.sleep(2) # Give time for UAC and process startup
|
|
319
|
+
|
|
320
|
+
# Try to find the process that was just launched
|
|
321
|
+
actual_pid = self._find_running_process(process_name)
|
|
322
|
+
if actual_pid:
|
|
323
|
+
app = LaunchedApplication(
|
|
324
|
+
process_name=process_name,
|
|
325
|
+
pid=actual_pid,
|
|
326
|
+
exe_path=exe_path,
|
|
327
|
+
launch_time=datetime.now(),
|
|
328
|
+
command_line=cmd_line,
|
|
329
|
+
working_directory=working_directory,
|
|
330
|
+
status='running'
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
self.launched_applications[actual_pid] = app
|
|
334
|
+
self._save_session()
|
|
335
|
+
|
|
336
|
+
logger.info(f"Successfully launched {process_name} with elevated privileges (PID: {actual_pid})")
|
|
337
|
+
return True, f"Successfully launched {process_name} with elevated privileges (PID: {actual_pid})", actual_pid
|
|
338
|
+
else:
|
|
339
|
+
# Process might still be starting or user cancelled UAC
|
|
340
|
+
logger.warning("Elevated launch initiated but process not found yet (user may have cancelled UAC)")
|
|
341
|
+
return False, "Elevated launch initiated but process not detected (UAC may have been cancelled)", None
|
|
342
|
+
else:
|
|
343
|
+
logger.error(f"ShellExecute failed with result: {result}")
|
|
344
|
+
return False, f"Failed to launch with elevation (error code: {result})", None
|
|
345
|
+
|
|
346
|
+
except Exception as shell_error:
|
|
347
|
+
logger.error(f"Failed to launch with elevation: {shell_error}")
|
|
348
|
+
raise e # Re-raise original error
|
|
349
|
+
else:
|
|
350
|
+
raise # Re-raise if not elevation error
|
|
351
|
+
else:
|
|
352
|
+
process = subprocess.Popen(
|
|
353
|
+
cmd_line,
|
|
354
|
+
cwd=working_directory
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Wait a moment for the process to start
|
|
358
|
+
time.sleep(0.5)
|
|
359
|
+
|
|
360
|
+
# Check if process started successfully
|
|
361
|
+
process_poll = process.poll()
|
|
362
|
+
if process_poll is None:
|
|
363
|
+
# Process is still running - traditional application
|
|
364
|
+
app = LaunchedApplication(
|
|
365
|
+
process_name=process_name,
|
|
366
|
+
pid=process.pid,
|
|
367
|
+
exe_path=exe_path,
|
|
368
|
+
launch_time=datetime.now(),
|
|
369
|
+
command_line=cmd_line,
|
|
370
|
+
working_directory=working_directory,
|
|
371
|
+
status='running'
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
self.launched_applications[process.pid] = app
|
|
375
|
+
self._save_session()
|
|
376
|
+
|
|
377
|
+
logger.info(f"Successfully launched {process_name} with PID {process.pid}")
|
|
378
|
+
return True, f"Successfully launched {process_name} (PID: {process.pid})", process.pid
|
|
379
|
+
elif process_poll == 0:
|
|
380
|
+
# Process exited with code 0 (success) - likely a launcher that spawned the real app
|
|
381
|
+
logger.info(f"Launcher process for {process_name} exited successfully (code 0)")
|
|
382
|
+
|
|
383
|
+
# Wait a bit more for the actual application to start
|
|
384
|
+
time.sleep(1.5)
|
|
385
|
+
|
|
386
|
+
# Try to find the actual running process by name
|
|
387
|
+
actual_pid = self._find_running_process(process_name)
|
|
388
|
+
if actual_pid:
|
|
389
|
+
# Found the actual running process
|
|
390
|
+
app = LaunchedApplication(
|
|
391
|
+
process_name=process_name,
|
|
392
|
+
pid=actual_pid,
|
|
393
|
+
exe_path=exe_path,
|
|
394
|
+
launch_time=datetime.now(),
|
|
395
|
+
command_line=cmd_line,
|
|
396
|
+
working_directory=working_directory,
|
|
397
|
+
status='running'
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
self.launched_applications[actual_pid] = app
|
|
401
|
+
self._save_session()
|
|
402
|
+
|
|
403
|
+
logger.info(f"Successfully launched {process_name} with actual PID {actual_pid} (launcher PID {process.pid} exited)")
|
|
404
|
+
return True, f"Successfully launched {process_name} (PID: {actual_pid})", actual_pid
|
|
405
|
+
else:
|
|
406
|
+
# Launcher succeeded but we can't find the running process
|
|
407
|
+
# This might be a UWP app or service - consider it successful anyway
|
|
408
|
+
logger.info(f"Launcher for {process_name} succeeded but actual process not found (may be UWP/service)")
|
|
409
|
+
|
|
410
|
+
# Create a record with the launcher PID for tracking purposes
|
|
411
|
+
app = LaunchedApplication(
|
|
412
|
+
process_name=process_name,
|
|
413
|
+
pid=process.pid,
|
|
414
|
+
exe_path=exe_path,
|
|
415
|
+
launch_time=datetime.now(),
|
|
416
|
+
command_line=cmd_line,
|
|
417
|
+
working_directory=working_directory,
|
|
418
|
+
status='launched' # Special status for launcher-spawned apps
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
self.launched_applications[process.pid] = app
|
|
422
|
+
self._save_session()
|
|
423
|
+
|
|
424
|
+
return True, f"Successfully launched {process_name} via launcher (launcher PID: {process.pid})", process.pid
|
|
425
|
+
else:
|
|
426
|
+
# Process terminated with non-zero exit code - actual failure
|
|
427
|
+
stdout, stderr = process.communicate()
|
|
428
|
+
error_msg = f"Process failed with exit code: {process_poll}"
|
|
429
|
+
if stderr:
|
|
430
|
+
error_msg += f"\\nError: {stderr.decode('utf-8', errors='ignore')}"
|
|
431
|
+
|
|
432
|
+
logger.error(f"Failed to launch {process_name}: {error_msg}")
|
|
433
|
+
return False, error_msg, None
|
|
434
|
+
|
|
435
|
+
except Exception as e:
|
|
436
|
+
logger.error(f"Failed to launch {process_name}: {e}")
|
|
437
|
+
return False, f"Failed to launch application: {str(e)}", None
|
|
438
|
+
|
|
439
|
+
def terminate_application(self, pid: int, force: bool = False) -> Tuple[bool, str]:
|
|
440
|
+
"""Terminate a launched application
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
pid: Process ID to terminate
|
|
444
|
+
force: Whether to force termination (kill vs terminate)
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Tuple of (success, message)
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
# Check if we launched this process
|
|
452
|
+
if pid not in self.launched_applications:
|
|
453
|
+
# Check if process exists anyway
|
|
454
|
+
if not psutil.pid_exists(pid):
|
|
455
|
+
return False, f"Process {pid} does not exist"
|
|
456
|
+
|
|
457
|
+
# Allow termination of any process if it exists
|
|
458
|
+
logger.warning(f"Terminating process {pid} that was not launched by this session")
|
|
459
|
+
|
|
460
|
+
# Get process object
|
|
461
|
+
try:
|
|
462
|
+
process = psutil.Process(pid)
|
|
463
|
+
process_name = process.name()
|
|
464
|
+
except psutil.NoSuchProcess:
|
|
465
|
+
# Remove from our tracking if it's already gone
|
|
466
|
+
if pid in self.launched_applications:
|
|
467
|
+
self.launched_applications[pid].status = 'terminated'
|
|
468
|
+
self._save_session()
|
|
469
|
+
return True, f"Process {pid} was already terminated"
|
|
470
|
+
|
|
471
|
+
logger.info(f"Terminating process {pid} ({process_name}), force={force}")
|
|
472
|
+
|
|
473
|
+
if force:
|
|
474
|
+
# Force kill the process
|
|
475
|
+
process.kill()
|
|
476
|
+
message = f"Force killed process {pid} ({process_name})"
|
|
477
|
+
else:
|
|
478
|
+
# Try graceful termination first
|
|
479
|
+
process.terminate()
|
|
480
|
+
|
|
481
|
+
# Wait for process to terminate gracefully
|
|
482
|
+
try:
|
|
483
|
+
process.wait(timeout=5) # Wait up to 5 seconds
|
|
484
|
+
message = f"Gracefully terminated process {pid} ({process_name})"
|
|
485
|
+
except psutil.TimeoutExpired:
|
|
486
|
+
# Force kill if graceful termination failed
|
|
487
|
+
logger.warning(f"Graceful termination timeout, force killing {pid}")
|
|
488
|
+
process.kill()
|
|
489
|
+
message = f"Force killed process {pid} ({process_name}) after timeout"
|
|
490
|
+
|
|
491
|
+
# Update our tracking
|
|
492
|
+
if pid in self.launched_applications:
|
|
493
|
+
self.launched_applications[pid].status = 'terminated'
|
|
494
|
+
self._save_session()
|
|
495
|
+
|
|
496
|
+
logger.info(message)
|
|
497
|
+
return True, message
|
|
498
|
+
|
|
499
|
+
except psutil.NoSuchProcess:
|
|
500
|
+
if pid in self.launched_applications:
|
|
501
|
+
self.launched_applications[pid].status = 'terminated'
|
|
502
|
+
self._save_session()
|
|
503
|
+
return True, f"Process {pid} was already terminated"
|
|
504
|
+
except psutil.AccessDenied:
|
|
505
|
+
return False, f"Access denied when trying to terminate process {pid}"
|
|
506
|
+
except Exception as e:
|
|
507
|
+
logger.error(f"Failed to terminate process {pid}: {e}")
|
|
508
|
+
return False, f"Failed to terminate process: {str(e)}"
|
|
509
|
+
|
|
510
|
+
def get_launched_applications(self) -> List[Dict[str, Any]]:
|
|
511
|
+
"""Get list of applications launched in this session"""
|
|
512
|
+
self._update_application_status()
|
|
513
|
+
return [app.to_dict() for app in self.launched_applications.values()]
|
|
514
|
+
|
|
515
|
+
def get_application_by_pid(self, pid: int) -> Optional[Dict[str, Any]]:
|
|
516
|
+
"""Get information about a specific launched application"""
|
|
517
|
+
if pid in self.launched_applications:
|
|
518
|
+
self._update_application_status()
|
|
519
|
+
return self.launched_applications[pid].to_dict()
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
def _update_application_status(self):
|
|
523
|
+
"""Update the status of all tracked applications"""
|
|
524
|
+
# Create a list of items to avoid dictionary modification during iteration
|
|
525
|
+
items_to_update = list(self.launched_applications.items())
|
|
526
|
+
|
|
527
|
+
for pid, app in items_to_update:
|
|
528
|
+
# Check if this PID is still in our dictionary (might have been moved)
|
|
529
|
+
if pid not in self.launched_applications:
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
if app.status in ['running', 'launched']:
|
|
533
|
+
if not psutil.pid_exists(pid):
|
|
534
|
+
# For apps where the original PID no longer exists, try to find the actual process
|
|
535
|
+
# This handles both launcher-spawned apps and apps that immediately spawn new processes
|
|
536
|
+
actual_pid = self._find_running_process(app.process_name)
|
|
537
|
+
if actual_pid and actual_pid != pid:
|
|
538
|
+
# Update to the actual running process
|
|
539
|
+
app.pid = actual_pid
|
|
540
|
+
app.status = 'running'
|
|
541
|
+
# Move the entry to the new PID
|
|
542
|
+
del self.launched_applications[pid]
|
|
543
|
+
self.launched_applications[actual_pid] = app
|
|
544
|
+
logger.info(f"Updated {app.process_name} from original PID {pid} to actual PID {actual_pid}")
|
|
545
|
+
else:
|
|
546
|
+
app.status = 'terminated'
|
|
547
|
+
else:
|
|
548
|
+
try:
|
|
549
|
+
process = psutil.Process(pid)
|
|
550
|
+
if process.status() in [psutil.STATUS_ZOMBIE, psutil.STATUS_DEAD]:
|
|
551
|
+
app.status = 'terminated'
|
|
552
|
+
elif app.status == 'launched':
|
|
553
|
+
# Launcher process still exists, upgrade to running
|
|
554
|
+
app.status = 'running'
|
|
555
|
+
except psutil.NoSuchProcess:
|
|
556
|
+
app.status = 'terminated'
|
|
557
|
+
except Exception:
|
|
558
|
+
pass # Keep current status if we can't determine
|
|
559
|
+
|
|
560
|
+
def cleanup_terminated_applications(self) -> int:
|
|
561
|
+
"""Remove terminated applications from tracking
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
Number of applications removed
|
|
565
|
+
"""
|
|
566
|
+
self._update_application_status()
|
|
567
|
+
|
|
568
|
+
terminated_pids = [pid for pid, app in self.launched_applications.items()
|
|
569
|
+
if app.status == 'terminated']
|
|
570
|
+
|
|
571
|
+
for pid in terminated_pids:
|
|
572
|
+
del self.launched_applications[pid]
|
|
573
|
+
|
|
574
|
+
if terminated_pids:
|
|
575
|
+
self._save_session()
|
|
576
|
+
logger.info(f"Cleaned up {len(terminated_pids)} terminated applications")
|
|
577
|
+
|
|
578
|
+
return len(terminated_pids)
|
|
579
|
+
|
|
580
|
+
def terminate_all_launched_applications(self, force: bool = False) -> Dict[str, Any]:
|
|
581
|
+
"""Terminate all applications launched in this session
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
force: Whether to force termination
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
Dictionary with termination results
|
|
588
|
+
"""
|
|
589
|
+
self._update_application_status()
|
|
590
|
+
|
|
591
|
+
running_apps = [app for app in self.launched_applications.values()
|
|
592
|
+
if app.status in ['running', 'launched']]
|
|
593
|
+
|
|
594
|
+
if not running_apps:
|
|
595
|
+
return {'terminated': 0, 'failed': 0, 'messages': ['No running applications to terminate']}
|
|
596
|
+
|
|
597
|
+
results = {'terminated': 0, 'failed': 0, 'messages': []}
|
|
598
|
+
|
|
599
|
+
for app in running_apps:
|
|
600
|
+
success, message = self.terminate_application(app.pid, force)
|
|
601
|
+
if success:
|
|
602
|
+
results['terminated'] += 1
|
|
603
|
+
else:
|
|
604
|
+
results['failed'] += 1
|
|
605
|
+
results['messages'].append(f"PID {app.pid} ({app.process_name}): {message}")
|
|
606
|
+
|
|
607
|
+
logger.info(f"Bulk termination complete: {results['terminated']} terminated, {results['failed']} failed")
|
|
608
|
+
return results
|