mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
639 lines
22 KiB
Python
639 lines
22 KiB
Python
import os
|
|
import ctypes
|
|
import subprocess
|
|
from typing import Dict, Any, Optional, List
|
|
from openspace.utils.logging import Logger
|
|
from PIL import Image, ImageGrab
|
|
|
|
try:
|
|
from pywinauto import Desktop
|
|
import win32ui
|
|
import win32gui
|
|
import pygetwindow as gw
|
|
WINDOWS_LIBS_AVAILABLE = True
|
|
except ImportError:
|
|
WINDOWS_LIBS_AVAILABLE = False
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
|
|
class WindowsAdapter:
|
|
"""Windows platform-specific functionality adapter"""
|
|
|
|
def __init__(self):
|
|
if not WINDOWS_LIBS_AVAILABLE:
|
|
logger.warning("Windows libraries are not fully installed, some features may not be available")
|
|
self.available = WINDOWS_LIBS_AVAILABLE
|
|
|
|
def capture_screenshot_with_cursor(self, output_path: str) -> bool:
|
|
"""
|
|
Capture screenshot using ImageGrab (including cursor)
|
|
|
|
Args:
|
|
output_path: Output file path
|
|
|
|
Returns:
|
|
Whether successful
|
|
"""
|
|
try:
|
|
# Use ImageGrab to capture screenshot
|
|
img = ImageGrab.grab(bbox=None, include_layered_windows=True)
|
|
|
|
# Try to add cursor
|
|
try:
|
|
if WINDOWS_LIBS_AVAILABLE:
|
|
cursor, hotspot = self._get_cursor()
|
|
if cursor:
|
|
# Get scaling ratio
|
|
ratio = ctypes.windll.shcore.GetScaleFactorForDevice(0) / 100
|
|
pos_win = win32gui.GetCursorPos()
|
|
pos = (
|
|
round(pos_win[0] * ratio - hotspot[0]),
|
|
round(pos_win[1] * ratio - hotspot[1])
|
|
)
|
|
img.paste(cursor, pos, cursor)
|
|
logger.info("Windows screenshot successfully (with cursor)")
|
|
else:
|
|
logger.info("Windows screenshot successfully (without cursor)")
|
|
except Exception as e:
|
|
logger.warning(f"Cannot add cursor to screenshot: {e}")
|
|
logger.info("Windows screenshot successfully (without cursor)")
|
|
|
|
img.save(output_path)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows screenshot failed: {e}")
|
|
return False
|
|
|
|
def _get_cursor(self) -> tuple:
|
|
"""
|
|
Get current cursor image and hotspot
|
|
|
|
Returns:
|
|
(cursor_image, (hotspot_x, hotspot_y))
|
|
"""
|
|
try:
|
|
hcursor = win32gui.GetCursorInfo()[1]
|
|
hdc = win32ui.CreateDCFromHandle(win32gui.GetDC(0))
|
|
hbmp = win32ui.CreateBitmap()
|
|
hbmp.CreateCompatibleBitmap(hdc, 36, 36)
|
|
hdc_compatible = hdc.CreateCompatibleDC()
|
|
hdc_compatible.SelectObject(hbmp)
|
|
hdc_compatible.DrawIcon((0, 0), hcursor)
|
|
|
|
bmpinfo = hbmp.GetInfo()
|
|
bmpstr = hbmp.GetBitmapBits(True)
|
|
cursor = Image.frombuffer(
|
|
'RGB',
|
|
(bmpinfo['bmWidth'], bmpinfo['bmHeight']),
|
|
bmpstr, 'raw', 'BGRX', 0, 1
|
|
).convert("RGBA")
|
|
|
|
win32gui.DestroyIcon(hcursor)
|
|
win32gui.DeleteObject(hbmp.GetHandle())
|
|
hdc_compatible.DeleteDC()
|
|
|
|
# Make black pixels transparent
|
|
pixdata = cursor.load()
|
|
width, height = cursor.size
|
|
for y in range(height):
|
|
for x in range(width):
|
|
if pixdata[x, y] == (0, 0, 0, 255):
|
|
pixdata[x, y] = (0, 0, 0, 0)
|
|
|
|
hotspot = win32gui.GetIconInfo(hcursor)[1:3]
|
|
|
|
return (cursor, hotspot)
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Failed to get cursor image: {e}")
|
|
return (None, (0, 0))
|
|
|
|
def activate_window(self, window_name: str, strict: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Activate window (Windows uses pygetwindow)
|
|
|
|
Args:
|
|
window_name: Window title
|
|
strict: Whether to strictly match
|
|
|
|
Returns:
|
|
Result dictionary
|
|
"""
|
|
if not WINDOWS_LIBS_AVAILABLE:
|
|
return {'status': 'error', 'message': 'Windows libraries not available'}
|
|
|
|
try:
|
|
windows = gw.getWindowsWithTitle(window_name)
|
|
|
|
if not windows:
|
|
logger.warning(f"Window not found: {window_name}")
|
|
return {'status': 'error', 'message': f'Window {window_name} not found'}
|
|
|
|
window = None
|
|
if strict:
|
|
# Strict match
|
|
for wnd in windows:
|
|
if wnd.title == window_name:
|
|
window = wnd
|
|
break
|
|
if not window:
|
|
return {'status': 'error', 'message': f'Window {window_name} not found (strict mode)'}
|
|
else:
|
|
window = windows[0]
|
|
|
|
window.activate()
|
|
logger.info(f"Windows window activated successfully: {window_name}")
|
|
return {'status': 'success', 'message': 'Window activated'}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows window activation failed: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def close_window(self, window_name: str, strict: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Close window (Windows uses pygetwindow)
|
|
|
|
Args:
|
|
window_name: Window title
|
|
strict: Whether to strictly match
|
|
|
|
Returns:
|
|
Result dictionary
|
|
"""
|
|
if not WINDOWS_LIBS_AVAILABLE:
|
|
return {'status': 'error', 'message': 'Windows libraries not available'}
|
|
|
|
try:
|
|
windows = gw.getWindowsWithTitle(window_name)
|
|
|
|
if not windows:
|
|
logger.warning(f"Window not found: {window_name}")
|
|
return {'status': 'error', 'message': f'Window {window_name} not found'}
|
|
|
|
window = None
|
|
if strict:
|
|
for wnd in windows:
|
|
if wnd.title == window_name:
|
|
window = wnd
|
|
break
|
|
if not window:
|
|
return {'status': 'error', 'message': f'Window {window_name} not found (strict mode)'}
|
|
else:
|
|
window = windows[0]
|
|
|
|
window.close()
|
|
logger.info(f"Windows window closed successfully: {window_name}")
|
|
return {'status': 'success', 'message': 'Window closed'}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows window close failed: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def get_accessibility_tree(self, max_depth: int = 10, max_width: int = 50) -> Dict[str, Any]:
|
|
"""
|
|
Get Windows accessibility tree (using pywinauto)
|
|
|
|
Args:
|
|
max_depth: Maximum depth
|
|
max_width: Maximum number of child elements per level
|
|
|
|
Returns:
|
|
Accessibility tree data
|
|
"""
|
|
if not WINDOWS_LIBS_AVAILABLE:
|
|
return {'error': 'Windows accessibility libraries not available'}
|
|
|
|
try:
|
|
# Get desktop
|
|
desktop = Desktop(backend="uia")
|
|
|
|
# Serialize accessibility tree
|
|
tree = self._serialize_uia_element(
|
|
desktop,
|
|
depth=0,
|
|
max_depth=max_depth,
|
|
max_width=max_width,
|
|
visited=set()
|
|
)
|
|
|
|
return {
|
|
'tree': tree,
|
|
'platform': 'Windows'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows get accessibility tree failed: {e}")
|
|
return {'error': str(e)}
|
|
|
|
def _serialize_uia_element(
|
|
self,
|
|
element,
|
|
depth: int = 0,
|
|
max_depth: int = 10,
|
|
max_width: int = 50,
|
|
visited: set = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Serialize Windows UIA element to dictionary
|
|
|
|
Args:
|
|
element: UIA element
|
|
depth: Current depth
|
|
max_depth: Maximum depth
|
|
max_width: Maximum width
|
|
visited: Set of visited elements
|
|
|
|
Returns:
|
|
Serialized dictionary
|
|
"""
|
|
if visited is None:
|
|
visited = set()
|
|
|
|
if depth > max_depth or element in visited:
|
|
return None
|
|
|
|
visited.add(element)
|
|
|
|
try:
|
|
result = {
|
|
'depth': depth
|
|
}
|
|
|
|
# Get basic attributes
|
|
try:
|
|
result['class_name'] = element.class_name()
|
|
except:
|
|
result['class_name'] = 'unknown'
|
|
|
|
try:
|
|
result['name'] = element.window_text()
|
|
except:
|
|
result['name'] = ''
|
|
|
|
# Get states
|
|
states = {}
|
|
state_methods = [
|
|
'is_enabled', 'is_visible', 'is_minimized', 'is_maximized',
|
|
'is_focused', 'is_checked', 'is_selected'
|
|
]
|
|
|
|
for method_name in state_methods:
|
|
if hasattr(element, method_name):
|
|
try:
|
|
method = getattr(element, method_name)
|
|
states[method_name] = method()
|
|
except:
|
|
pass
|
|
|
|
if states:
|
|
result['states'] = states
|
|
|
|
# Get position and size
|
|
try:
|
|
rectangle = element.rectangle()
|
|
result['position'] = {
|
|
'left': rectangle.left,
|
|
'top': rectangle.top
|
|
}
|
|
result['size'] = {
|
|
'width': rectangle.width(),
|
|
'height': rectangle.height()
|
|
}
|
|
except:
|
|
pass
|
|
|
|
# Recursively get child elements
|
|
result['children'] = []
|
|
try:
|
|
children = element.children()
|
|
for i, child in enumerate(children[:max_width]):
|
|
try:
|
|
child_data = self._serialize_uia_element(
|
|
child,
|
|
depth + 1,
|
|
max_depth,
|
|
max_width,
|
|
visited
|
|
)
|
|
if child_data:
|
|
result['children'].append(child_data)
|
|
except Exception as e:
|
|
logger.debug(f"Cannot serialize child element {i}: {e}")
|
|
continue
|
|
except Exception as e:
|
|
logger.debug(f"Cannot get child elements: {e}")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Failed to serialize element (depth={depth}): {e}")
|
|
return None
|
|
|
|
def list_windows(self) -> List[Dict[str, Any]]:
|
|
"""
|
|
List all windows
|
|
|
|
Returns:
|
|
Window list
|
|
"""
|
|
if not WINDOWS_LIBS_AVAILABLE:
|
|
return []
|
|
|
|
try:
|
|
windows = gw.getAllWindows()
|
|
|
|
return [
|
|
{
|
|
'title': win.title,
|
|
'left': win.left,
|
|
'top': win.top,
|
|
'width': win.width,
|
|
'height': win.height,
|
|
'visible': win.visible,
|
|
'active': win.isActive
|
|
}
|
|
for win in windows
|
|
if win.title # Only return windows with titles
|
|
]
|
|
|
|
except Exception as e:
|
|
logger.error(f"List windows failed: {e}")
|
|
return []
|
|
|
|
def set_wallpaper(self, image_path: str) -> Dict[str, Any]:
|
|
"""
|
|
Set desktop wallpaper
|
|
|
|
Args:
|
|
image_path: Image path
|
|
|
|
Returns:
|
|
Result dictionary
|
|
"""
|
|
try:
|
|
image_path = os.path.expanduser(image_path)
|
|
image_path = os.path.abspath(image_path)
|
|
|
|
if not os.path.exists(image_path):
|
|
return {'status': 'error', 'message': f'Image not found: {image_path}'}
|
|
|
|
# Use Windows API to set wallpaper
|
|
SPI_SETDESKWALLPAPER = 20
|
|
ctypes.windll.user32.SystemParametersInfoW(
|
|
SPI_SETDESKWALLPAPER,
|
|
0,
|
|
image_path,
|
|
3 # SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
|
|
)
|
|
|
|
logger.info(f"Windows wallpaper set successfully: {image_path}")
|
|
return {'status': 'success', 'message': 'Wallpaper set successfully'}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows set wallpaper failed: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
def get_system_info(self) -> Dict[str, Any]:
|
|
"""
|
|
Get Windows system information
|
|
|
|
Returns:
|
|
System information dictionary
|
|
"""
|
|
try:
|
|
import platform as plat
|
|
|
|
return {
|
|
'platform': 'Windows',
|
|
'version': plat.version(),
|
|
'release': plat.release(),
|
|
'edition': plat.win32_edition() if hasattr(plat, 'win32_edition') else 'Unknown',
|
|
'available': self.available
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get system information: {e}")
|
|
return {
|
|
'platform': 'Windows',
|
|
'error': str(e)
|
|
}
|
|
|
|
def start_recording(self, output_path: str) -> Dict[str, Any]:
|
|
try:
|
|
try:
|
|
result = subprocess.run(['ffmpeg', '-version'],
|
|
capture_output=True,
|
|
check=True,
|
|
timeout=5,
|
|
creationflags=subprocess.CREATE_NO_WINDOW)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return {
|
|
'status': 'error',
|
|
'message': 'ffmpeg not installed. Download from: https://ffmpeg.org/download.html'
|
|
}
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
width = user32.GetSystemMetrics(0) # SM_CXSCREEN
|
|
height = user32.GetSystemMetrics(1) # SM_CYSCREEN
|
|
except:
|
|
width, height = 1920, 1080
|
|
|
|
command = [
|
|
'ffmpeg',
|
|
'-y',
|
|
'-f', 'gdigrab',
|
|
'-draw_mouse', '1',
|
|
'-framerate', '30',
|
|
'-video_size', f'{width}x{height}',
|
|
'-i', 'desktop',
|
|
'-c:v', 'libx264',
|
|
'-preset', 'ultrafast',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-r', '30',
|
|
output_path
|
|
]
|
|
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
creationflags=subprocess.CREATE_NO_WINDOW
|
|
)
|
|
|
|
import time
|
|
time.sleep(1)
|
|
|
|
if process.poll() is not None:
|
|
error_output = process.stderr.read() if process.stderr else "Unknown error"
|
|
return {
|
|
'status': 'error',
|
|
'message': f'Failed to start recording: {error_output}'
|
|
}
|
|
|
|
logger.info(f"Windows recording started: {output_path}")
|
|
return {
|
|
'status': 'success',
|
|
'message': 'Recording started',
|
|
'process': process
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows start recording failed: {e}")
|
|
return {
|
|
'status': 'error',
|
|
'message': str(e)
|
|
}
|
|
|
|
def stop_recording(self, process) -> Dict[str, Any]:
|
|
try:
|
|
if not process or process.poll() is not None:
|
|
return {
|
|
'status': 'error',
|
|
'message': 'No recording in progress'
|
|
}
|
|
|
|
import signal
|
|
try:
|
|
process.send_signal(signal.CTRL_C_EVENT)
|
|
except:
|
|
process.terminate()
|
|
|
|
try:
|
|
process.wait(timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
logger.warning("ffmpeg did not respond, killing process")
|
|
process.kill()
|
|
process.wait()
|
|
|
|
logger.info("Windows recording stopped successfully")
|
|
return {
|
|
'status': 'success',
|
|
'message': 'Recording stopped'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Windows stop recording failed: {e}")
|
|
return {
|
|
'status': 'error',
|
|
'message': str(e)
|
|
}
|
|
|
|
def get_running_applications(self) -> List[Dict[str, str]]:
|
|
"""
|
|
Get list of all running applications
|
|
|
|
Returns:
|
|
Application list
|
|
"""
|
|
if not WINDOWS_LIBS_AVAILABLE:
|
|
return []
|
|
|
|
try:
|
|
import psutil
|
|
|
|
apps = []
|
|
seen_names = set()
|
|
|
|
for proc in psutil.process_iter(['pid', 'name', 'exe']):
|
|
try:
|
|
pinfo = proc.info
|
|
name = pinfo['name']
|
|
exe = pinfo['exe']
|
|
|
|
# Skip system processes
|
|
if not exe or name in ['System', 'Registry', 'svchost.exe', 'csrss.exe']:
|
|
continue
|
|
|
|
# Skip duplicates
|
|
if name in seen_names:
|
|
continue
|
|
|
|
seen_names.add(name)
|
|
|
|
apps.append({
|
|
'name': name,
|
|
'pid': pinfo['pid'],
|
|
'path': exe or ''
|
|
})
|
|
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
pass
|
|
|
|
return apps
|
|
|
|
except ImportError:
|
|
logger.warning("psutil not installed, cannot get running applications")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Failed to get running applications list: {e}")
|
|
return []
|
|
|
|
def get_screen_size(self) -> Dict[str, int]:
|
|
"""
|
|
Get screen size
|
|
|
|
Returns:
|
|
Screen size dictionary
|
|
"""
|
|
try:
|
|
user32 = ctypes.windll.user32
|
|
width = user32.GetSystemMetrics(0) # SM_CXSCREEN
|
|
height = user32.GetSystemMetrics(1) # SM_CYSCREEN
|
|
return {'width': width, 'height': height}
|
|
except Exception as e:
|
|
logger.error(f"Failed to get screen size: {e}")
|
|
return {'width': 1920, 'height': 1080} # Default value
|
|
|
|
def get_terminal_output(self) -> Optional[str]:
|
|
"""
|
|
Get terminal output (Windows Command Prompt, PowerShell, or Windows Terminal)
|
|
|
|
Note: Due to Windows architecture, getting terminal output is complex.
|
|
This method attempts to find active console windows.
|
|
|
|
Returns:
|
|
Terminal output content (limited functionality on Windows)
|
|
"""
|
|
try:
|
|
# Windows doesn't provide easy access to terminal content like Linux/macOS
|
|
# This is a limitation of the Windows platform
|
|
# We can try to use PowerShell to get recent command history
|
|
|
|
# Try to get PowerShell history
|
|
try:
|
|
history_path = os.path.expanduser(
|
|
'~\\AppData\\Roaming\\Microsoft\\Windows\\PowerShell\\PSReadLine\\ConsoleHost_history.txt'
|
|
)
|
|
if os.path.exists(history_path):
|
|
with open(history_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
# Get last 50 lines
|
|
lines = f.readlines()
|
|
recent_history = ''.join(lines[-50:])
|
|
if recent_history:
|
|
return f"PowerShell History (last 50 commands):\n{recent_history}"
|
|
except Exception as e:
|
|
logger.debug(f"Cannot read PowerShell history: {e}")
|
|
|
|
# Try to get Command Prompt history using doskey
|
|
try:
|
|
result = subprocess.run(
|
|
['doskey', '/history'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
creationflags=subprocess.CREATE_NO_WINDOW
|
|
)
|
|
if result.returncode == 0 and result.stdout:
|
|
return f"Command Prompt History:\n{result.stdout}"
|
|
except Exception as e:
|
|
logger.debug(f"Cannot get Command Prompt history: {e}")
|
|
|
|
logger.warning("Windows terminal output is limited - only command history available")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get terminal output: {e}")
|
|
return None
|