"""
Application Launcher Service
Launches Unity3D, Resonite, VRChat, VRoid Studio, and other applications
with support for multi-desktop/multi-monitor setups on Windows
"""
import asyncio
import logging
import os
import platform
import subprocess
from pathlib import Path
from typing import Any
import psutil
logger = logging.getLogger(__name__)
# Application configurations
APP_CONFIGS = {
"unity3d": {
"name": "Unity Editor",
"default_paths": [
r"C:\Program Files\Unity\Hub\Editor\*\Editor\Unity.exe",
r"C:\Program Files\Unity\Editor\Unity.exe",
r"C:\Program Files (x86)\Unity\Editor\Unity.exe",
],
"env_var": "UNITY_EDITOR_PATH",
"args": ["-projectPath", "{project_path}"],
"supports_desktop": True,
},
"resonite": {
"name": "Resonite",
"default_paths": [
r"C:\Program Files\Resonite\Resonite.exe",
r"C:\Program Files (x86)\Resonite\Resonite.exe",
os.path.expanduser(r"~\AppData\Local\Programs\Resonite\Resonite.exe"),
],
"env_var": "RESONITE_PATH",
"args": [],
"supports_desktop": True,
},
"vrchat": {
"name": "VRChat",
"default_paths": [
r"C:\Program Files (x86)\Steam\steamapps\common\VRChat\VRChat.exe",
r"C:\Program Files\VRChat\VRChat.exe",
],
"env_var": "VRCHAT_PATH",
"args": [],
"supports_desktop": True,
},
"vroid": {
"name": "VRoid Studio",
"default_paths": [
r"C:\Program Files\VRoidStudio\VRoidStudio.exe",
r"C:\Program Files (x86)\VRoidStudio\VRoidStudio.exe",
],
"env_var": "VROIDSTUDIO_PATH",
"args": [],
"supports_desktop": True,
},
}
class AppLauncher:
"""Service for launching applications with multi-desktop support"""
def __init__(self):
self.running_apps: dict[str, dict[str, Any]] = {}
self._load_configs()
def _load_configs(self):
"""Load application paths from environment variables"""
for _app_id, config in APP_CONFIGS.items():
env_path = os.getenv(config["env_var"])
if env_path and os.path.exists(env_path):
config["resolved_path"] = env_path
else:
# Try to find in default paths
for pattern in config["default_paths"]:
if "*" in pattern:
# Handle wildcard paths
base = Path(pattern).parent.parent
if base.exists():
for editor_dir in base.glob("*"):
exe_path = editor_dir / "Editor" / "Unity.exe"
if exe_path.exists():
config["resolved_path"] = str(exe_path)
break
else:
if os.path.exists(pattern):
config["resolved_path"] = pattern
break
def _find_app_path(self, app_id: str) -> str | None:
"""Find application executable path"""
if app_id not in APP_CONFIGS:
return None
config = APP_CONFIGS[app_id]
# Check resolved path first
if "resolved_path" in config:
return config["resolved_path"]
# Check environment variable
env_path = os.getenv(config["env_var"])
if env_path and os.path.exists(env_path):
return env_path
# Check default paths
for path in config["default_paths"]:
if "*" in path:
# Handle wildcard (Unity Hub)
base = Path(path).parent.parent
if base.exists():
for editor_dir in base.glob("*"):
exe_path = editor_dir / "Editor" / "Unity.exe"
if exe_path.exists():
return str(exe_path)
elif os.path.exists(path):
return path
return None
def _is_running(self, app_id: str) -> bool:
"""Check if application is already running"""
if app_id not in APP_CONFIGS:
return False
config = APP_CONFIGS[app_id]
app_name = config["name"].lower()
for proc in psutil.process_iter(["name", "exe"]):
try:
proc_name = proc.info["name"].lower() if proc.info["name"] else ""
proc_exe = proc.info["exe"].lower() if proc.info["exe"] else ""
if app_name in proc_name or app_name in proc_exe:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return False
async def launch_app(
self,
app_id: str,
desktop_number: int | None = None,
project_path: str | None = None,
fullscreen: bool = False,
monitor: int | None = None,
) -> dict[str, Any]:
"""
Launch an application
Args:
app_id: Application identifier (unity3d, resonite, vrchat, vroid)
desktop_number: Virtual desktop number (1-based, Windows 10+)
project_path: Project path (for Unity)
fullscreen: Launch in fullscreen mode
monitor: Monitor number (1-based)
Returns:
Launch result with status and process info
"""
if app_id not in APP_CONFIGS:
return {"success": False, "error": f"Unknown application: {app_id}", "app_id": app_id}
# Check if already running
if self._is_running(app_id):
return {
"success": True,
"status": "already_running",
"app_id": app_id,
"message": f"{APP_CONFIGS[app_id]['name']} is already running",
}
# Find application path
app_path = self._find_app_path(app_id)
if not app_path:
return {
"success": False,
"error": f"{APP_CONFIGS[app_id]['name']} not found. Please set {APP_CONFIGS[app_id]['env_var']} environment variable.",
"app_id": app_id,
"suggested_env_var": APP_CONFIGS[app_id]["env_var"],
}
# Prepare arguments
config = APP_CONFIGS[app_id]
args = config["args"].copy()
# Replace placeholders
if project_path and "{project_path}" in str(args):
args = [arg.replace("{project_path}", project_path) for arg in args]
# Build command
cmd = [app_path] + args
try:
# Launch process
if platform.system() == "Windows":
# Windows-specific launch options
creation_flags = 0
if fullscreen:
creation_flags |= 0x00000010 # CREATE_NEW_CONSOLE
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=creation_flags,
)
else:
process = await asyncio.create_subprocess_exec(
*cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
# Store process info
self.running_apps[app_id] = {
"process": process,
"pid": process.pid,
"app_path": app_path,
"desktop": desktop_number,
"monitor": monitor,
"fullscreen": fullscreen,
"launched_at": asyncio.get_event_loop().time(),
}
# Move to virtual desktop if specified (Windows 10+)
if desktop_number and platform.system() == "Windows":
await self._move_to_desktop(app_id, desktop_number)
# Move to monitor if specified
if monitor and platform.system() == "Windows":
await self._move_to_monitor(app_id, monitor)
return {
"success": True,
"status": "launched",
"app_id": app_id,
"app_name": config["name"],
"pid": process.pid,
"desktop": desktop_number,
"monitor": monitor,
"message": f"{config['name']} launched successfully",
}
except Exception as e:
logger.error(f"Failed to launch {app_id}: {e}")
return {"success": False, "error": str(e), "app_id": app_id}
async def _move_to_desktop(self, app_id: str, desktop_number: int):
"""Move application window to virtual desktop (Windows 10+)"""
if app_id not in self.running_apps:
return
try:
# Use Windows API to move to virtual desktop
# This requires pywin32 or similar
# For now, log the intent
logger.info(f"Moving {app_id} to virtual desktop {desktop_number}")
# TODO: Implement actual virtual desktop movement
except Exception as e:
logger.warning(f"Failed to move to desktop: {e}")
async def _move_to_monitor(self, app_id: str, monitor_number: int):
"""Move application window to specific monitor"""
if app_id not in self.running_apps:
return
try:
logger.info(f"Moving {app_id} to monitor {monitor_number}")
# TODO: Implement monitor movement using Windows API
except Exception as e:
logger.warning(f"Failed to move to monitor: {e}")
def get_running_apps(self) -> dict[str, dict[str, Any]]:
"""Get status of all running applications"""
result = {}
for app_id, info in self.running_apps.items():
try:
process = psutil.Process(info["pid"])
result[app_id] = {
"running": process.is_running(),
"pid": info["pid"],
"desktop": info.get("desktop"),
"monitor": info.get("monitor"),
"fullscreen": info.get("fullscreen", False),
}
except psutil.NoSuchProcess:
# Process ended
del self.running_apps[app_id]
return result
async def stop_app(self, app_id: str) -> dict[str, Any]:
"""Stop a running application"""
if app_id not in self.running_apps:
return {"success": False, "error": f"{app_id} is not running", "app_id": app_id}
try:
info = self.running_apps[app_id]
process = psutil.Process(info["pid"])
process.terminate()
# Wait for termination
try:
await asyncio.wait_for(asyncio.to_thread(process.wait, timeout=5), timeout=5)
except TimeoutError:
# Force kill if needed
process.kill()
del self.running_apps[app_id]
return {
"success": True,
"app_id": app_id,
"message": f"{APP_CONFIGS[app_id]['name']} stopped",
}
except Exception as e:
logger.error(f"Failed to stop {app_id}: {e}")
return {"success": False, "error": str(e), "app_id": app_id}
# Global launcher instance
app_launcher = AppLauncher()