feat: Windows 服务 安装,启动,停止 功能
This commit is contained in:
@ -4,4 +4,5 @@
|
|||||||
- Adopt Inversion of Control pattern whenever possible, use constructor injection for class, extract pure function if it has to depend on some global variable
|
- Adopt Inversion of Control pattern whenever possible, use constructor injection for class, extract pure function if it has to depend on some global variable
|
||||||
|
|
||||||
# Workflow
|
# Workflow
|
||||||
- Be sure to typecheck when you’re done making a series of code changes
|
- Be sure to typecheck when you’re done making a series of code changes
|
||||||
|
- Be sure to update README.md after making code changes
|
||||||
96
playground.ipynb
Normal file
96
playground.ipynb
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"cells": [
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": null,
|
||||||
|
"id": "4dbde0c5",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"import yaml\n",
|
||||||
|
"\n",
|
||||||
|
"\n",
|
||||||
|
"with open(r'C:\\Users\\Klesh\\basicfiles\\cli\\scientific_surfing\\generated_config.yaml', 'r', encoding=\"utf-8\") as f:\n",
|
||||||
|
" config = yaml.safe_load(f)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 8,
|
||||||
|
"id": "16e45ae8",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "stdout",
|
||||||
|
"output_type": "stream",
|
||||||
|
"text": [
|
||||||
|
"{'cipher': 'rc4-md5', 'name': 'taiwan06', 'obfs': 'plain', 'obfs-param': '2c9120876.douyin.com', 'password': 'di15PV', 'port': 6506, 'protocol': 'auth_aes128_md5', 'protocol-param': '120876:VCgmuD', 'server': 'cdn02.0821.meituan88.com', 'type': 'ssr', 'udp': True}\n"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source": [
|
||||||
|
"server = next(filter(lambda p: \"台湾06\" in p[\"name\"], config[\"proxies\"]))\n",
|
||||||
|
"server[\"name\"] = \"taiwan06\"\n",
|
||||||
|
"print(server)\n"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 5,
|
||||||
|
"id": "cc472edc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"config2 = config.copy()"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 12,
|
||||||
|
"id": "3db89abe",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"config2[\"proxies\"] = [server]\n",
|
||||||
|
"config2[\"proxy-groups\"] = {\n",
|
||||||
|
" \"name\": \"defaultgroup\",\n",
|
||||||
|
" \"type\": \"select\",\n",
|
||||||
|
" \"proxies\": [server[\"name\"]],\n",
|
||||||
|
"}\n",
|
||||||
|
"config2[\"rules\"] = config[\"rules\"][:17]"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cell_type": "code",
|
||||||
|
"execution_count": 13,
|
||||||
|
"id": "2630b0fc",
|
||||||
|
"metadata": {},
|
||||||
|
"outputs": [],
|
||||||
|
"source": [
|
||||||
|
"with open(r'C:\\Users\\Klesh\\basicfiles\\cli\\scientific_surfing\\simple.yaml', 'w', encoding=\"utf-8\") as f:\n",
|
||||||
|
" yaml.dump(config2, f, default_flow_style=False, allow_unicode=True)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"kernelspec": {
|
||||||
|
"display_name": "scientific-surfing-4fYWmyKm-py3.12",
|
||||||
|
"language": "python",
|
||||||
|
"name": "python3"
|
||||||
|
},
|
||||||
|
"language_info": {
|
||||||
|
"codemirror_mode": {
|
||||||
|
"name": "ipython",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"file_extension": ".py",
|
||||||
|
"mimetype": "text/x-python",
|
||||||
|
"name": "python",
|
||||||
|
"nbconvert_exporter": "python",
|
||||||
|
"pygments_lexer": "ipython3",
|
||||||
|
"version": "3.12.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nbformat": 4,
|
||||||
|
"nbformat_minor": 5
|
||||||
|
}
|
||||||
1025
poetry.lock
generated
1025
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -7,10 +7,11 @@ readme = "README.md"
|
|||||||
packages = [{include = "scientific_surfing"}]
|
packages = [{include = "scientific_surfing"}]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.8"
|
python = "^3.10"
|
||||||
requests = "^2.25.0"
|
requests = "^2.25.0"
|
||||||
PyYAML = "^6.0.0"
|
PyYAML = "^6.0.0"
|
||||||
pydantic = "^2.0.0"
|
pydantic = "^2.0.0"
|
||||||
|
ipykernel = "^7.0.1"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^6.0.0"
|
pytest = "^6.0.0"
|
||||||
|
|||||||
@ -84,6 +84,35 @@ def create_parser() -> argparse.ArgumentParser:
|
|||||||
update_parser.add_argument('--version', help='Specific version to download (e.g., v1.18.5). If not specified, downloads latest')
|
update_parser.add_argument('--version', help='Specific version to download (e.g., v1.18.5). If not specified, downloads latest')
|
||||||
update_parser.add_argument('--force', action='store_true', help='Force update even if binary already exists')
|
update_parser.add_argument('--force', action='store_true', help='Force update even if binary already exists')
|
||||||
|
|
||||||
|
# Service management commands
|
||||||
|
service_parser = core_subparsers.add_parser('service', help='Manage mihomo as a system service')
|
||||||
|
service_subparsers = service_parser.add_subparsers(dest='service_command', help='Service operations')
|
||||||
|
|
||||||
|
# Install service command
|
||||||
|
install_service_parser = service_subparsers.add_parser('install', help='Install mihomo as a system service')
|
||||||
|
install_service_parser.add_argument('--name', default='mihomo', help='Service name (default: mihomo)')
|
||||||
|
install_service_parser.add_argument('--description', default='Mihomo proxy service', help='Service description')
|
||||||
|
|
||||||
|
# Uninstall service command
|
||||||
|
uninstall_service_parser = service_subparsers.add_parser('uninstall', help='Uninstall mihomo system service')
|
||||||
|
uninstall_service_parser.add_argument('--name', default='mihomo', help='Service name (default: mihomo)')
|
||||||
|
|
||||||
|
# Start service command
|
||||||
|
start_service_parser = service_subparsers.add_parser('start', help='Start mihomo system service')
|
||||||
|
start_service_parser.add_argument('--name', default='mihomo', help='Service name (default: mihomo)')
|
||||||
|
|
||||||
|
# Stop service command
|
||||||
|
stop_service_parser = service_subparsers.add_parser('stop', help='Stop mihomo system service')
|
||||||
|
stop_service_parser.add_argument('--name', default='mihomo', help='Service name (default: mihomo)')
|
||||||
|
|
||||||
|
# Restart service command
|
||||||
|
restart_service_parser = service_subparsers.add_parser('restart', help='Restart mihomo system service')
|
||||||
|
restart_service_parser.add_argument('--name', default='mihomo', help='Service name (default: mihomo)')
|
||||||
|
|
||||||
|
# Status service command
|
||||||
|
status_service_parser = service_subparsers.add_parser('status', help='Check mihomo system service status')
|
||||||
|
status_service_parser.add_argument('--name', default='mihomo', help='Service name (default: mihomo)')
|
||||||
|
|
||||||
# Hook commands
|
# Hook commands
|
||||||
hook_parser = subparsers.add_parser('hook', help='Manage hook scripts')
|
hook_parser = subparsers.add_parser('hook', help='Manage hook scripts')
|
||||||
hook_subparsers = hook_parser.add_subparsers(dest='hook_command', help='Hook operations')
|
hook_subparsers = hook_parser.add_subparsers(dest='hook_command', help='Hook operations')
|
||||||
@ -170,9 +199,41 @@ def main() -> None:
|
|||||||
parser.parse_args(['core', '--help'])
|
parser.parse_args(['core', '--help'])
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
if args.core_command == 'update':
|
if args.core_command == 'update':
|
||||||
core_manager.update(version=args.version, force=args.force)
|
core_manager.update(version=args.version, force=args.force)
|
||||||
|
elif args.core_command == 'service':
|
||||||
|
if not hasattr(args, 'service_command') or not args.service_command:
|
||||||
|
parser.parse_args(['core', 'service', '--help'])
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.service_command == 'install':
|
||||||
|
success = core_manager.install_service(
|
||||||
|
service_name=args.name,
|
||||||
|
description=args.description
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
elif args.service_command == 'uninstall':
|
||||||
|
success = core_manager.uninstall_service(service_name=args.name)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
elif args.service_command == 'start':
|
||||||
|
success = core_manager.start_service(service_name=args.name)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
elif args.service_command == 'stop':
|
||||||
|
success = core_manager.stop_service(service_name=args.name)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
elif args.service_command == 'restart':
|
||||||
|
success = core_manager.restart_service(service_name=args.name)
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
elif args.service_command == 'status':
|
||||||
|
status = core_manager.get_service_status(service_name=args.name)
|
||||||
|
print(f"Service '{args.name}' status: {status}")
|
||||||
|
else:
|
||||||
|
parser.parse_args(['core', 'service', '--help'])
|
||||||
else:
|
else:
|
||||||
parser.parse_args(['core', '--help'])
|
parser.parse_args(['core', '--help'])
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,14 @@ import requests
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from scientific_surfing.corecfg_manager import CoreConfigManager
|
from scientific_surfing.corecfg_manager import CoreConfigManager
|
||||||
|
from scientific_surfing.service_manager import ServiceManager
|
||||||
|
|
||||||
|
|
||||||
class CoreManager:
|
class CoreManager:
|
||||||
"""Manages user configuration with import, export, and edit operations."""
|
"""Manages user configuration with import, export, and edit operations."""
|
||||||
|
|
||||||
def __init__(self, core_config_manager: CoreConfigManager):
|
def __init__(self, core_config_manager: CoreConfigManager):
|
||||||
|
self.storage = core_config_manager.storage
|
||||||
self.core_config_manager = core_config_manager
|
self.core_config_manager = core_config_manager
|
||||||
|
|
||||||
def update(self, version: Optional[str] = None, force: bool = False) -> bool:
|
def update(self, version: Optional[str] = None, force: bool = False) -> bool:
|
||||||
@ -222,207 +224,181 @@ class CoreManager:
|
|||||||
print(f"[ERROR] Upgrade failed: {e}")
|
print(f"[ERROR] Upgrade failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def daemon(self, config_path: Optional[str] = None) -> bool:
|
def get_binary_path(self) -> Path:
|
||||||
|
"""Get the path to the mihomo executable."""
|
||||||
|
system = platform.system().lower()
|
||||||
|
binary_dir = self.core_config_manager.storage.config_dir / "bin"
|
||||||
|
binary_path = binary_dir / ("mihomo.exe" if system == "windows" else "mihomo")
|
||||||
|
return binary_path
|
||||||
|
|
||||||
|
def install_service(self, service_name: str = "mihomo", description: str = "Mihomo proxy service") -> bool:
|
||||||
"""
|
"""
|
||||||
Run the mihomo executable as a daemon with the generated configuration.
|
Install mihomo as a system service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config_path: Path to the configuration file. If None, uses generated_config.yaml
|
service_name: Name for the service (default: "mihomo")
|
||||||
|
description: Service description
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if daemon started successfully, False otherwise.
|
bool: True if installation successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Determine binary path
|
binary_path = self.get_binary_path()
|
||||||
system = platform.system().lower()
|
|
||||||
binary_dir = self.core_config_manager.storage.config_dir / "bin"
|
|
||||||
binary_path = binary_dir / ("mihomo.exe" if system == "windows" else "mihomo")
|
|
||||||
|
|
||||||
if not binary_path.exists():
|
if not binary_path.exists():
|
||||||
print(f"❌ Mihomo binary not found at: {binary_path}")
|
print(f"❌ Mihomo binary not found at: {binary_path}")
|
||||||
print(" Run 'core update' to download the binary first.")
|
print(" Please run 'core update' first to download the binary")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Determine config path
|
# Setup service arguments
|
||||||
if config_path is None:
|
config_dir = self.core_config_manager.storage.config_dir
|
||||||
config_file = self.core_config_manager.storage.config_dir / "generated_config.yaml"
|
config_file = config_dir / "generated_config.yaml"
|
||||||
else:
|
|
||||||
config_file = Path(config_path)
|
|
||||||
|
|
||||||
|
# Ensure config file exists
|
||||||
if not config_file.exists():
|
if not config_file.exists():
|
||||||
print(f"❌ Configuration file not found: {config_file}")
|
self.core_config_manager.generate_config()
|
||||||
print(" Run 'core-config apply' to generate the configuration first.")
|
print(f"✅ Generated initial configuration: {config_file}")
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"[INFO] Starting mihomo daemon...")
|
# Build service arguments
|
||||||
print(f" Binary: {binary_path}")
|
service_args = f"-d \"{config_dir}\" -f \"{config_file}\""
|
||||||
print(f" Config: {config_file}")
|
|
||||||
|
|
||||||
# Prepare command
|
service_manager = ServiceManager(self.storage.config_dir)
|
||||||
cmd = [
|
service_manager.install(service_name, str(binary_path), description, service_args)
|
||||||
str(binary_path),
|
print(f"✅ Service '{service_name}' installed successfully")
|
||||||
"-f", str(config_file),
|
print(f" Config directory: {config_dir}")
|
||||||
"-d", str(self.core_config_manager.storage.config_dir)
|
print(f" Config file: {config_file}")
|
||||||
]
|
|
||||||
|
|
||||||
# Start the process
|
|
||||||
if system == "windows":
|
|
||||||
# Windows: Use CREATE_NEW_PROCESS_GROUP to avoid console window
|
|
||||||
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if hasattr(subprocess, 'CREATE_NEW_PROCESS_GROUP') else 0
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
creationflags=creation_flags,
|
|
||||||
cwd=str(self.core_config_manager.storage.config_dir)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Unix-like systems
|
|
||||||
process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
preexec_fn=os.setsid if hasattr(os, 'setsid') else None,
|
|
||||||
cwd=str(self.core_config_manager.storage.config_dir)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if process started successfully
|
|
||||||
try:
|
|
||||||
return_code = process.poll()
|
|
||||||
if return_code is not None:
|
|
||||||
stdout, stderr = process.communicate(timeout=2)
|
|
||||||
print(f"❌ Failed to start daemon (exit code: {return_code})")
|
|
||||||
if stderr:
|
|
||||||
print(f" Error: {stderr.decode().strip()}")
|
|
||||||
return False
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
# Process is still running, which is good
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Save PID for later management
|
|
||||||
pid_file = self.core_config_manager.storage.config_dir / "mihomo.pid"
|
|
||||||
with open(pid_file, 'w') as f:
|
|
||||||
f.write(str(process.pid))
|
|
||||||
|
|
||||||
print(f"✅ Daemon started successfully (PID: {process.pid})")
|
|
||||||
print(f" PID file: {pid_file}")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Failed to start daemon: {e}")
|
print(f"❌ Failed to install service: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def stop_daemon(self) -> bool:
|
def uninstall_service(self, service_name: str = "mihomo") -> bool:
|
||||||
"""
|
"""
|
||||||
Stop the running mihomo daemon.
|
Uninstall mihomo system service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name: Name of the service to uninstall (default: "mihomo")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if daemon stopped successfully, False otherwise.
|
bool: True if uninstallation successful, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
pid_file = self.core_config_manager.storage.config_dir / "mihomo.pid"
|
service_manager = ServiceManager(self.storage.config_dir)
|
||||||
|
service_manager.uninstall(service_name)
|
||||||
if not pid_file.exists():
|
print(f"✅ Service '{service_name}' uninstalled successfully")
|
||||||
print("❌ No daemon appears to be running (PID file not found)")
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
with open(pid_file, 'r') as f:
|
|
||||||
pid = int(f.read().strip())
|
|
||||||
|
|
||||||
system = platform.system().lower()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if system == "windows":
|
|
||||||
# Windows: Use taskkill
|
|
||||||
subprocess.run(["taskkill", "/F", "/PID", str(pid)],
|
|
||||||
check=True, capture_output=True, text=True)
|
|
||||||
else:
|
|
||||||
# Unix-like systems: Use kill
|
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
|
|
||||||
# Wait a bit and check if process is still running
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0) # Signal 0 just checks if process exists
|
|
||||||
# Process still exists, try SIGKILL
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
# Process already terminated
|
|
||||||
pass
|
|
||||||
|
|
||||||
pid_file.unlink()
|
|
||||||
print(f"✅ Daemon stopped successfully (PID: {pid})")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except (ProcessLookupError, subprocess.CalledProcessError):
|
|
||||||
# Process not found, clean up PID file
|
|
||||||
pid_file.unlink()
|
|
||||||
print("ℹ️ Daemon was not running, cleaned up PID file")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Failed to stop daemon: {e}")
|
print(f"❌ Failed to uninstall service: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def daemon_status(self) -> Dict[str, Any]:
|
def start_service(self, service_name: str = "mihomo") -> bool:
|
||||||
"""
|
"""
|
||||||
Get the status of the mihomo daemon.
|
Start mihomo system service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name: Name of the service to start (default: "mihomo")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing daemon status information.
|
bool: True if start successful, False otherwise
|
||||||
"""
|
"""
|
||||||
status = {
|
|
||||||
"running": False,
|
|
||||||
"pid": None,
|
|
||||||
"binary_path": None,
|
|
||||||
"config_path": None,
|
|
||||||
"error": None
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pid_file = self.core_config_manager.storage.config_dir / "mihomo.pid"
|
service_manager = ServiceManager(self.storage.config_dir)
|
||||||
|
service_manager.start(service_name)
|
||||||
|
print(f"✅ Service '{service_name}' started successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
if not pid_file.exists():
|
except Exception as e:
|
||||||
status["error"] = "PID file not found"
|
print(f"❌ Failed to start service: {e}")
|
||||||
return status
|
return False
|
||||||
|
|
||||||
with open(pid_file, 'r') as f:
|
def stop_service(self, service_name: str = "mihomo") -> bool:
|
||||||
pid = int(f.read().strip())
|
"""
|
||||||
|
Stop mihomo system service.
|
||||||
|
|
||||||
# Check if process is running
|
Args:
|
||||||
|
service_name: Name of the service to stop (default: "mihomo")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if stop successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service_manager = ServiceManager(self.storage.config_dir)
|
||||||
|
service_manager.stop(service_name)
|
||||||
|
print(f"✅ Service '{service_name}' stopped successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to stop service: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def restart_service(self, service_name: str = "mihomo") -> bool:
|
||||||
|
"""
|
||||||
|
Restart mihomo system service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name: Name of the service to restart (default: "mihomo")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if restart successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
service_manager = ServiceManager(self.storage.config_dir)
|
||||||
|
service_manager.restart(service_name)
|
||||||
|
print(f"✅ Service '{service_name}' restarted successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to restart service: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_service_status(self, service_name: str = "mihomo") -> str:
|
||||||
|
"""
|
||||||
|
Get the status of mihomo system service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name: Name of the service to check (default: "mihomo")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Service status description
|
||||||
|
"""
|
||||||
|
try:
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
try:
|
|
||||||
if system == "windows":
|
if system == "windows":
|
||||||
# Windows: Use tasklist
|
result = subprocess.run(["sc", "query", service_name], capture_output=True, text=True)
|
||||||
result = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"],
|
if result.returncode == 0:
|
||||||
capture_output=True, text=True)
|
for line in result.stdout.split('\n'):
|
||||||
if str(pid) in result.stdout:
|
if "STATE" in line:
|
||||||
status["running"] = True
|
return line.strip()
|
||||||
status["pid"] = pid
|
return "Service exists but status unknown"
|
||||||
|
else:
|
||||||
|
return "Service not installed or not found"
|
||||||
|
|
||||||
|
elif system == "linux":
|
||||||
|
result = subprocess.run(["systemctl", "is-active", service_name], capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
status = result.stdout.strip()
|
||||||
|
if status == "active":
|
||||||
|
return "Service is running"
|
||||||
else:
|
else:
|
||||||
status["error"] = "Process not found"
|
return f"Service is {status}"
|
||||||
pid_file.unlink() # Clean up stale PID file
|
|
||||||
else:
|
else:
|
||||||
# Unix-like systems: Use kill signal 0
|
return "Service not installed or not found"
|
||||||
os.kill(pid, 0) # Signal 0 just checks if process exists
|
|
||||||
status["running"] = True
|
|
||||||
status["pid"] = pid
|
|
||||||
|
|
||||||
except (ProcessLookupError, subprocess.CalledProcessError):
|
elif system == "darwin":
|
||||||
status["error"] = "Process not found"
|
result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
|
||||||
pid_file.unlink() # Clean up stale PID file
|
if service_name in result.stdout or f"com.{service_name}" in result.stdout:
|
||||||
|
return "Service is loaded (check launchctl status for details)"
|
||||||
|
else:
|
||||||
|
return "Service not installed or not loaded"
|
||||||
|
|
||||||
|
else:
|
||||||
|
return f"Unsupported system: {system}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status["error"] = str(e)
|
return f"Error checking service status: {e}"
|
||||||
|
|
||||||
# Add binary and config paths
|
|
||||||
system = platform.system().lower()
|
|
||||||
binary_path = self.core_config_manager.storage.config_dir / "bin" / ("mihomo.exe" if system == "windows" else "mihomo")
|
|
||||||
config_path = self.core_config_manager.storage.config_dir / "generated_config.yaml"
|
|
||||||
|
|
||||||
status["binary_path"] = str(binary_path) if binary_path.exists() else None
|
|
||||||
status["config_path"] = str(config_path) if config_path.exists() else None
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
def deep_merge(dict1, dict2):
|
def deep_merge(dict1, dict2):
|
||||||
for k, v in dict2.items():
|
for k, v in dict2.items():
|
||||||
|
|||||||
@ -288,13 +288,15 @@ class CoreConfigManager:
|
|||||||
print("❌ No active subscription found")
|
print("❌ No active subscription found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not active_subscription.file_path or not Path(active_subscription.file_path).exists():
|
file_path = active_subscription.get_file_path(self.storage.config_dir)
|
||||||
|
|
||||||
|
if not file_path or not Path(file_path).exists():
|
||||||
print("❌ Active subscription file not found. Please refresh the subscription first.")
|
print("❌ Active subscription file not found. Please refresh the subscription first.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load the subscription content
|
# Load the subscription content
|
||||||
with open(active_subscription.file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
subscription_content = f.read()
|
subscription_content = f.read()
|
||||||
|
|
||||||
# Parse subscription YAML
|
# Parse subscription YAML
|
||||||
@ -350,7 +352,6 @@ class CoreConfigManager:
|
|||||||
|
|
||||||
print(f"✅ Generated final configuration: {generated_path}")
|
print(f"✅ Generated final configuration: {generated_path}")
|
||||||
print(f" Active subscription: {active_subscription.name}")
|
print(f" Active subscription: {active_subscription.name}")
|
||||||
print(f" Source file: {active_subscription.file_path}")
|
|
||||||
|
|
||||||
# Execute hooks after successful config generation
|
# Execute hooks after successful config generation
|
||||||
self._execute_hooks(generated_path)
|
self._execute_hooks(generated_path)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Pydantic models for scientific-surfing data structures.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import os
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
@ -14,7 +15,6 @@ class Subscription(BaseModel):
|
|||||||
url: str = Field(..., description="Clash RSS subscription URL")
|
url: str = Field(..., description="Clash RSS subscription URL")
|
||||||
status: str = Field(default="inactive", description="Status: active or inactive")
|
status: str = Field(default="inactive", description="Status: active or inactive")
|
||||||
last_refresh: Optional[datetime] = Field(default=None, description="Last refresh timestamp")
|
last_refresh: Optional[datetime] = Field(default=None, description="Last refresh timestamp")
|
||||||
file_path: Optional[str] = Field(default=None, description="Path to downloaded file")
|
|
||||||
file_size: Optional[int] = Field(default=None, description="Size of downloaded file in bytes")
|
file_size: Optional[int] = Field(default=None, description="Size of downloaded file in bytes")
|
||||||
status_code: Optional[int] = Field(default=None, description="HTTP status code of last refresh")
|
status_code: Optional[int] = Field(default=None, description="HTTP status code of last refresh")
|
||||||
content_hash: Optional[int] = Field(default=None, description="Hash of downloaded content")
|
content_hash: Optional[int] = Field(default=None, description="Hash of downloaded content")
|
||||||
@ -31,6 +31,8 @@ class Subscription(BaseModel):
|
|||||||
datetime: lambda v: v.isoformat() if v else None
|
datetime: lambda v: v.isoformat() if v else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_file_path(self, config_dir: str):
|
||||||
|
return os.path.join(config_dir, "subscriptions", f"{self.name}.yml")
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
"""Model for application configuration."""
|
"""Model for application configuration."""
|
||||||
|
|||||||
612
scientific_surfing/service_manager.py
Normal file
612
scientific_surfing/service_manager.py
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
"""
|
||||||
|
Cross-platform service manager for installing, uninstalling, and restarting services.
|
||||||
|
|
||||||
|
This module provides a unified interface for managing system services across
|
||||||
|
Windows, Linux, and macOS operating systems.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import ctypes
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from scientific_surfing.storage import StorageManager
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceConfig(BaseModel):
|
||||||
|
"""Configuration model for service installation."""
|
||||||
|
name: str = Field(..., description="Name of the service")
|
||||||
|
executable_path: Path = Field(..., description="Path to the service executable")
|
||||||
|
description: Optional[str] = Field(None, description="Service description")
|
||||||
|
args: Optional[str] = Field(None, description="Command line arguments for the service")
|
||||||
|
|
||||||
|
@field_validator('name')
|
||||||
|
@classmethod
|
||||||
|
def validate_name(cls, v: str) -> str:
|
||||||
|
"""Validate service name format."""
|
||||||
|
if not v or not v.strip():
|
||||||
|
raise ValueError("Service name cannot be empty")
|
||||||
|
if ' ' in v.strip():
|
||||||
|
raise ValueError("Service name cannot contain spaces")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
@field_validator('executable_path')
|
||||||
|
@classmethod
|
||||||
|
def validate_executable_path(cls, v: Path) -> Path:
|
||||||
|
"""Validate executable path exists and is executable."""
|
||||||
|
if not v.exists():
|
||||||
|
raise ValueError(f"Executable path does not exist: {v}")
|
||||||
|
if not v.is_file():
|
||||||
|
raise ValueError(f"Path is not a file: {v}")
|
||||||
|
if not os.access(v, os.X_OK):
|
||||||
|
raise ValueError(f"File is not executable: {v}")
|
||||||
|
return v.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceManagerProtocol(Protocol):
|
||||||
|
"""Protocol defining the interface for service managers."""
|
||||||
|
config_dir: str
|
||||||
|
|
||||||
|
def __init__(self, config_dir: str):
|
||||||
|
self.config_dir = config_dir
|
||||||
|
|
||||||
|
def install(self, config: ServiceConfig) -> None:
|
||||||
|
"""Install a service with the given configuration."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def uninstall(self, name: str) -> None:
|
||||||
|
"""Uninstall a service by name."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def start(self, name: str) -> None:
|
||||||
|
"""Start a service by name."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def stop(self, name: str) -> None:
|
||||||
|
"""Stop a service by name."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def restart(self, name: str) -> None:
|
||||||
|
"""Restart a service by name."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsServiceManager(ServiceManagerProtocol):
|
||||||
|
"""Windows-specific service manager using sc.exe."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_admin() -> bool:
|
||||||
|
"""Check if the current process has administrator privileges."""
|
||||||
|
try:
|
||||||
|
return ctypes.windll.shell32.IsUserAnAdmin()
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_error_message(operation: str, service_name: str, error: str) -> str:
|
||||||
|
"""Format a user-friendly error message for service operations."""
|
||||||
|
if "access is denied" in error.lower() or "5" in error:
|
||||||
|
return (
|
||||||
|
f"Failed to {operation} service '{service_name}': Access denied.\n\n"
|
||||||
|
f"Administrator privileges are required to {operation} Windows services.\n\n"
|
||||||
|
f"Solutions:\n"
|
||||||
|
f"• Run this script as administrator (right-click → 'Run as administrator')\n"
|
||||||
|
f"• Open an elevated Command Prompt and run the command manually\n"
|
||||||
|
f"• Ensure User Account Control (UAC) is enabled and accept the prompt"
|
||||||
|
)
|
||||||
|
elif "1060" in error:
|
||||||
|
return (
|
||||||
|
f"Failed to {operation} service '{service_name}': Service not found.\n\n"
|
||||||
|
f"The specified service does not exist. Check the service name and try again."
|
||||||
|
)
|
||||||
|
elif "1062" in error and operation in ["stop", "restart"]:
|
||||||
|
return (
|
||||||
|
f"Service '{service_name}' is not currently running.\n\n"
|
||||||
|
f"This is not an error - the service was already stopped."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return f"Failed to {operation} service '{service_name}': {error}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_as_admin(cmd: list[str], description: str = "Service Management") -> None:
|
||||||
|
"""Run a command with administrator privileges using UAC elevation."""
|
||||||
|
if WindowsServiceManager._is_admin():
|
||||||
|
# Already running as admin, execute directly
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to {description.lower()}: {result.stderr}")
|
||||||
|
else:
|
||||||
|
# Provide clear instructions for manual elevation
|
||||||
|
command_str = " ".join(cmd)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Administrator privileges required to {description.lower()}.\n\n"
|
||||||
|
f"Command: {command_str}\n\n"
|
||||||
|
f"Please do one of the following:\n"
|
||||||
|
f"1. Run this script as administrator (right-click → 'Run as administrator')\n"
|
||||||
|
f"2. Open an elevated Command Prompt and run: {command_str}\n"
|
||||||
|
f"3. Accept the UAC prompt when it appears"
|
||||||
|
)
|
||||||
|
|
||||||
|
def install(self, config: ServiceConfig) -> None:
|
||||||
|
"""Install a Windows service using Python service wrapper."""
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from .windows_service_wrapper import WindowServiceConfig
|
||||||
|
|
||||||
|
# Check for pywin32 dependency
|
||||||
|
try:
|
||||||
|
import win32serviceutil
|
||||||
|
import win32service
|
||||||
|
import win32event
|
||||||
|
import servicemanager
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pywin32 package is required for Windows service support. "
|
||||||
|
"Install it with: pip install pywin32"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create service configuration for the wrapper
|
||||||
|
|
||||||
|
windows_service_config = WindowServiceConfig(
|
||||||
|
name = "mihomo",
|
||||||
|
display_name = "Scentific Surfing Service",
|
||||||
|
description = "Surfing the Internal scientifically",
|
||||||
|
working_dir = str(config.executable_path.parent),
|
||||||
|
bin_path = str(config.executable_path),
|
||||||
|
args = config.args or '',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create permanent config file in a stable location
|
||||||
|
config_dir = self.config_dir
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_file = config_dir / f"{config.name}_config.json"
|
||||||
|
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
json.dump(asdict(windows_service_config), f, indent=2)
|
||||||
|
|
||||||
|
# Path to the wrapper script
|
||||||
|
wrapper_script = Path(__file__).parent / "windows_service_wrapper.py"
|
||||||
|
if not wrapper_script.exists():
|
||||||
|
raise RuntimeError(f"Windows service wrapper not found: {wrapper_script}")
|
||||||
|
|
||||||
|
# Build the command to run the Python service wrapper using the proper service class
|
||||||
|
python_exe = sys.executable
|
||||||
|
service_cmd = [
|
||||||
|
python_exe, str(wrapper_script)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Quote the path to handle spaces properly
|
||||||
|
escaped_cmd = " ".join(service_cmd)
|
||||||
|
if ' ' in escaped_cmd:
|
||||||
|
escaped_cmd = f'"{escaped_cmd}"'
|
||||||
|
|
||||||
|
# Create service using sc.exe with the Python wrapper - using proper command format
|
||||||
|
python_exe = sys.executable
|
||||||
|
wrapper_script = Path(__file__).parent / "windows_service_wrapper.py"
|
||||||
|
|
||||||
|
# Build the command with proper quoting for Windows
|
||||||
|
service_cmd = f'"{python_exe}" "{wrapper_script}" "{config_file}"'
|
||||||
|
|
||||||
|
# Create service using sc.exe
|
||||||
|
cmd = [
|
||||||
|
"sc", "create", config.name,
|
||||||
|
"binPath=", service_cmd,
|
||||||
|
"start=", "auto"
|
||||||
|
]
|
||||||
|
|
||||||
|
if config.description:
|
||||||
|
cmd.extend(["DisplayName=", config.description])
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._run_as_admin(cmd, f"install service '{config.name}'")
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Clean up config file on failure
|
||||||
|
try:
|
||||||
|
config_file.unlink(missing_ok=True)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(self._format_error_message("install", config.name, str(e)))
|
||||||
|
|
||||||
|
def uninstall(self, name: str) -> None:
|
||||||
|
"""Uninstall a Windows service."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stop the service first
|
||||||
|
try:
|
||||||
|
self._run_as_admin(["sc", "stop", name], f"stop service '{name}'")
|
||||||
|
except:
|
||||||
|
# Ignore if service is not running
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Delete the service
|
||||||
|
self._run_as_admin(["sc", "delete", name], f"uninstall service '{name}'")
|
||||||
|
|
||||||
|
# Clean up configuration file
|
||||||
|
config_dir = Path.home() / ".scientific_surfing" / "service_configs"
|
||||||
|
config_file = config_dir / f"{name}_config.json"
|
||||||
|
try:
|
||||||
|
config_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Remove directory if empty
|
||||||
|
try:
|
||||||
|
config_dir.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass # Directory not empty
|
||||||
|
except:
|
||||||
|
pass # Ignore cleanup errors
|
||||||
|
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise RuntimeError(self._format_error_message("uninstall", name, str(e)))
|
||||||
|
|
||||||
|
def start(self, name: str) -> None:
|
||||||
|
"""Start a Windows service."""
|
||||||
|
try:
|
||||||
|
self._run_as_admin(["sc", "start", name], f"start service '{name}'")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise RuntimeError(self._format_error_message("start", name, str(e)))
|
||||||
|
|
||||||
|
def stop(self, name: str) -> None:
|
||||||
|
"""Stop a Windows service."""
|
||||||
|
try:
|
||||||
|
self._run_as_admin(["sc", "stop", name], f"stop service '{name}'")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise RuntimeError(self._format_error_message("stop", name, str(e)))
|
||||||
|
|
||||||
|
def restart(self, name: str) -> None:
|
||||||
|
"""Restart a Windows service."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
self._run_as_admin(["sc", "stop", name], f"stop service '{name}'")
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Ignore if service is not running (error 1062)
|
||||||
|
if "1062" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._run_as_admin(["sc", "start", name], f"start service '{name}'")
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise RuntimeError(self._format_error_message("restart", name, str(e)))
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxServiceManager(ServiceManagerProtocol):
|
||||||
|
"""Linux-specific service manager using systemd."""
|
||||||
|
|
||||||
|
def _get_service_file_path(self, name: str) -> Path:
|
||||||
|
"""Get the path to the systemd service file."""
|
||||||
|
return Path("/etc/systemd/system") / f"{name}.service"
|
||||||
|
|
||||||
|
def _create_service_file(self, config: ServiceConfig) -> None:
|
||||||
|
"""Create a systemd service file."""
|
||||||
|
exec_start = f"{config.executable_path}"
|
||||||
|
if config.args:
|
||||||
|
exec_start += f" {config.args}"
|
||||||
|
|
||||||
|
service_content = f"""[Unit]
|
||||||
|
Description={config.description or config.name}
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart={exec_start}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
User=root
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
service_path = self._get_service_file_path(config.name)
|
||||||
|
try:
|
||||||
|
with open(service_path, 'w') as f:
|
||||||
|
f.write(service_content)
|
||||||
|
|
||||||
|
# Set appropriate permissions
|
||||||
|
os.chmod(service_path, 0o644)
|
||||||
|
except OSError as e:
|
||||||
|
raise RuntimeError(f"Failed to create service file: {e}")
|
||||||
|
|
||||||
|
def install(self, config: ServiceConfig) -> None:
|
||||||
|
"""Install a Linux service using systemd."""
|
||||||
|
self._create_service_file(config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Reload systemd
|
||||||
|
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Enable and start the service
|
||||||
|
subprocess.run(["systemctl", "enable", config.name], capture_output=True, text=True, check=True)
|
||||||
|
subprocess.run(["systemctl", "start", config.name], capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to install service: {e.stderr}")
|
||||||
|
|
||||||
|
def uninstall(self, name: str) -> None:
|
||||||
|
"""Uninstall a Linux service."""
|
||||||
|
try:
|
||||||
|
# Stop and disable the service
|
||||||
|
subprocess.run(["systemctl", "stop", name], capture_output=True)
|
||||||
|
subprocess.run(["systemctl", "disable", name], capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Remove service file
|
||||||
|
service_path = self._get_service_file_path(name)
|
||||||
|
if service_path.exists():
|
||||||
|
service_path.unlink()
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to uninstall service: {e.stderr}")
|
||||||
|
|
||||||
|
def start(self, name: str) -> None:
|
||||||
|
"""Start a Linux service."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["systemctl", "start", name], capture_output=True, text=True, check=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to start service: {result.stderr}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to start service: {e.stderr}")
|
||||||
|
|
||||||
|
def stop(self, name: str) -> None:
|
||||||
|
"""Stop a Linux service."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["systemctl", "stop", name], capture_output=True, text=True, check=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to stop service: {result.stderr}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to stop service: {e.stderr}")
|
||||||
|
|
||||||
|
def restart(self, name: str) -> None:
|
||||||
|
"""Restart a Linux service."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["systemctl", "restart", name], capture_output=True, text=True, check=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to restart service: {result.stderr}")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to restart service: {e.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
class MacOSServiceManager(ServiceManagerProtocol):
|
||||||
|
"""macOS-specific service manager using launchd."""
|
||||||
|
|
||||||
|
def _get_launchd_path(self, name: str) -> Path:
|
||||||
|
"""Get the path to the launchd plist file."""
|
||||||
|
return Path("/Library/LaunchDaemons") / f"com.{name}.plist"
|
||||||
|
|
||||||
|
def _create_launchd_plist(self, config: ServiceConfig) -> None:
|
||||||
|
"""Create a launchd plist file."""
|
||||||
|
program_args = [str(config.executable_path)]
|
||||||
|
if config.args:
|
||||||
|
program_args.extend(config.args.split())
|
||||||
|
|
||||||
|
program_args_xml = "\n".join([f" <string>{arg}</string>" for arg in program_args])
|
||||||
|
|
||||||
|
plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.{config.name}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
{program_args_xml}
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/{config.name}.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/{config.name}.error.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
|
||||||
|
plist_path = self._get_launchd_path(config.name)
|
||||||
|
try:
|
||||||
|
with open(plist_path, 'w') as f:
|
||||||
|
f.write(plist_content)
|
||||||
|
|
||||||
|
# Set appropriate permissions
|
||||||
|
os.chmod(plist_path, 0o644)
|
||||||
|
except OSError as e:
|
||||||
|
raise RuntimeError(f"Failed to create launchd plist: {e}")
|
||||||
|
|
||||||
|
def install(self, config: ServiceConfig) -> None:
|
||||||
|
"""Install a macOS service using launchd."""
|
||||||
|
self._create_launchd_plist(config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load the service
|
||||||
|
plist_path = self._get_launchd_path(config.name)
|
||||||
|
subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
subprocess.run(["launchctl", "start", f"com.{config.name}"], capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to install service: {e.stderr}")
|
||||||
|
|
||||||
|
def uninstall(self, name: str) -> None:
|
||||||
|
"""Uninstall a macOS service."""
|
||||||
|
try:
|
||||||
|
# Stop and unload the service
|
||||||
|
subprocess.run(["launchctl", "stop", f"com.{name}"], capture_output=True)
|
||||||
|
|
||||||
|
plist_path = self._get_launchd_path(name)
|
||||||
|
if plist_path.exists():
|
||||||
|
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True, check=True)
|
||||||
|
plist_path.unlink()
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to uninstall service: {e.stderr}")
|
||||||
|
|
||||||
|
def start(self, name: str) -> None:
|
||||||
|
"""Start a macOS service."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["launchctl", "start", f"com.{name}"], capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to start service: {e.stderr}")
|
||||||
|
|
||||||
|
def stop(self, name: str) -> None:
|
||||||
|
"""Stop a macOS service."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["launchctl", "stop", f"com.{name}"], capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to stop service: {e.stderr}")
|
||||||
|
|
||||||
|
def restart(self, name: str) -> None:
|
||||||
|
"""Restart a macOS service."""
|
||||||
|
try:
|
||||||
|
subprocess.run(["launchctl", "stop", f"com.{name}"], capture_output=True)
|
||||||
|
subprocess.run(["launchctl", "start", f"com.{name}"], capture_output=True, text=True, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuntimeError(f"Failed to restart service: {e.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceManager:
|
||||||
|
"""Main service manager that delegates to platform-specific implementations."""
|
||||||
|
|
||||||
|
def __init__(self, config_dir: str) -> None:
|
||||||
|
"""Initialize the service manager with the appropriate platform implementation."""
|
||||||
|
system = platform.system().lower()
|
||||||
|
|
||||||
|
if system == "windows":
|
||||||
|
self._manager: ServiceManagerProtocol = WindowsServiceManager(config_dir)
|
||||||
|
elif system == "linux":
|
||||||
|
self._manager = LinuxServiceManager(config_dir)
|
||||||
|
elif system == "darwin":
|
||||||
|
self._manager = MacOSServiceManager(config_dir)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unsupported operating system: {system}")
|
||||||
|
|
||||||
|
def install(self, name: str, executable_path: str, description: Optional[str] = None, args: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Install a service with the given name and executable path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the service to install
|
||||||
|
executable_path: Path to the service executable
|
||||||
|
description: Optional description for the service
|
||||||
|
args: Optional command line arguments for the service
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If parameters are invalid
|
||||||
|
RuntimeError: If installation fails
|
||||||
|
"""
|
||||||
|
config = ServiceConfig(
|
||||||
|
name=name,
|
||||||
|
executable_path=Path(executable_path),
|
||||||
|
description=description,
|
||||||
|
args=args
|
||||||
|
)
|
||||||
|
self._manager.install(config)
|
||||||
|
|
||||||
|
def uninstall(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Uninstall a service by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the service to uninstall
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If uninstallation fails
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("Service name cannot be empty")
|
||||||
|
self._manager.uninstall(name.strip())
|
||||||
|
|
||||||
|
def start(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Start a service by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the service to start
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If service name is invalid
|
||||||
|
RuntimeError: If start fails
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("Service name cannot be empty")
|
||||||
|
self._manager.start(name.strip())
|
||||||
|
|
||||||
|
def stop(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Stop a service by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the service to stop
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If service name is invalid
|
||||||
|
RuntimeError: If stop fails
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("Service name cannot be empty")
|
||||||
|
self._manager.stop(name.strip())
|
||||||
|
|
||||||
|
def restart(self, name: str) -> None:
|
||||||
|
"""
|
||||||
|
Restart a service by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the service to restart
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If service name is invalid
|
||||||
|
RuntimeError: If restart fails
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("Service name cannot be empty")
|
||||||
|
self._manager.restart(name.strip())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Example usage
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: python service_manager.py <install|uninstall|start|stop|restart> <service_name> [executable_path] [description] [args]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
action = sys.argv[1]
|
||||||
|
service_name = sys.argv[2]
|
||||||
|
storage = StorageManager()
|
||||||
|
service_manager = ServiceManager(storage.config_dir)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == "install":
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("Error: install requires executable_path")
|
||||||
|
sys.exit(1)
|
||||||
|
executable_path = sys.argv[3]
|
||||||
|
description = sys.argv[4] if len(sys.argv) > 4 else None
|
||||||
|
args = sys.argv[5] if len(sys.argv) > 5 else None
|
||||||
|
service_manager.install(service_name, executable_path, description, args)
|
||||||
|
print(f"Service '{service_name}' installed successfully")
|
||||||
|
elif action == "uninstall":
|
||||||
|
service_manager.uninstall(service_name)
|
||||||
|
print(f"Service '{service_name}' uninstalled successfully")
|
||||||
|
elif action == "start":
|
||||||
|
service_manager.start(service_name)
|
||||||
|
print(f"Service '{service_name}' started successfully")
|
||||||
|
elif action == "stop":
|
||||||
|
service_manager.stop(service_name)
|
||||||
|
print(f"Service '{service_name}' stopped successfully")
|
||||||
|
elif action == "restart":
|
||||||
|
service_manager.restart(service_name)
|
||||||
|
print(f"Service '{service_name}' restarted successfully")
|
||||||
|
else:
|
||||||
|
print(f"Error: Unknown action '{action}'")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@ -7,7 +7,7 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
import yaml
|
import yaml
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict
|
from typing import Dict
|
||||||
|
|
||||||
from scientific_surfing.models import SubscriptionsData
|
from scientific_surfing.models import SubscriptionsData
|
||||||
|
|
||||||
@ -16,13 +16,17 @@ class StorageManager:
|
|||||||
"""Manages cross-platform data storage for subscriptions and configuration."""
|
"""Manages cross-platform data storage for subscriptions and configuration."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.config_dir = self._get_config_dir()
|
self.config_dir = self._get_config_dir()
|
||||||
self.config_file = self.config_dir / "config.yaml"
|
self.config_file = self.config_dir / "config.yaml"
|
||||||
self.subscriptions_file = self.config_dir / "subscriptions.yaml"
|
self.subscriptions_file = self.config_dir / "subscriptions.yaml"
|
||||||
self._ensure_config_dir()
|
self._ensure_config_dir()
|
||||||
|
|
||||||
def _get_config_dir(self) -> Path:
|
def _get_config_dir(self) -> Path:
|
||||||
"""Get the appropriate configuration directory for the current platform."""
|
"""Get the appropriate configuration directory for the current platform."""
|
||||||
|
config_dir = os.getenv("SF_CONFIG_DIR")
|
||||||
|
if config_dir:
|
||||||
|
return Path(config_dir)
|
||||||
|
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
|
|
||||||
if system == "windows":
|
if system == "windows":
|
||||||
|
|||||||
@ -3,6 +3,7 @@ Subscription management module for scientific-surfing.
|
|||||||
Handles subscription operations with persistent storage.
|
Handles subscription operations with persistent storage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
import requests
|
import requests
|
||||||
from scientific_surfing.storage import StorageManager
|
from scientific_surfing.storage import StorageManager
|
||||||
|
|
||||||
@ -78,7 +79,6 @@ class SubscriptionManager:
|
|||||||
|
|
||||||
# Update subscription metadata
|
# Update subscription metadata
|
||||||
subscription.last_refresh = datetime.now()
|
subscription.last_refresh = datetime.now()
|
||||||
subscription.file_path = str(file_path)
|
|
||||||
subscription.file_size = len(response.text)
|
subscription.file_size = len(response.text)
|
||||||
subscription.status_code = response.status_code
|
subscription.status_code = response.status_code
|
||||||
subscription.content_hash = hash(response.text)
|
subscription.content_hash = hash(response.text)
|
||||||
|
|||||||
@ -99,7 +99,7 @@ external-controller-unix: mihomo.sock
|
|||||||
# tcp-concurrent: true # TCP 并发连接所有 IP, 将使用最快握手的 TCP
|
# tcp-concurrent: true # TCP 并发连接所有 IP, 将使用最快握手的 TCP
|
||||||
|
|
||||||
# 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问
|
# 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问
|
||||||
external-ui: /path/to/ui/folder/
|
external-ui: ui
|
||||||
external-ui-name: xd
|
external-ui-name: xd
|
||||||
# 目前支持下载zip,tgz格式的压缩包
|
# 目前支持下载zip,tgz格式的压缩包
|
||||||
external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
|
external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
|
||||||
|
|||||||
261
scientific_surfing/windows_service_wrapper.py
Normal file
261
scientific_surfing/windows_service_wrapper.py
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import win32serviceutil
|
||||||
|
import win32service
|
||||||
|
import servicemanager # Simple setup and logging
|
||||||
|
from typing import Optional
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import win32event
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WindowServiceConfig:
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
working_dir: str
|
||||||
|
bin_path: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
args: Optional[str] = None
|
||||||
|
env: Optional[dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def log_stream(stream, logger_func):
|
||||||
|
"""Read lines from stream and log them using the provided logger function"""
|
||||||
|
for line in iter(stream.readline, ''):
|
||||||
|
if line.strip(): # Only log non-empty lines
|
||||||
|
logger_func(line.strip())
|
||||||
|
stream.close()
|
||||||
|
|
||||||
|
class WindowsServiceFramework(win32serviceutil.ServiceFramework):
|
||||||
|
|
||||||
|
# required
|
||||||
|
_svc_name_ = "mihomo"
|
||||||
|
_svc_display_name_ = "Scentific Surfing Service"
|
||||||
|
|
||||||
|
_config: WindowServiceConfig = None
|
||||||
|
|
||||||
|
stop_requested: bool = False
|
||||||
|
|
||||||
|
def __init__(self, args):
|
||||||
|
super().__init__(args)
|
||||||
|
self.setup_logging()
|
||||||
|
self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
|
||||||
|
self.process = None
|
||||||
|
WindowsServiceFramework.load_service_config()
|
||||||
|
|
||||||
|
def setup_logging(self):
|
||||||
|
"""Setup logging to both Event Viewer and desktop file"""
|
||||||
|
try:
|
||||||
|
log_file_path = f"Y:/{self._svc_name_}_service.log"
|
||||||
|
|
||||||
|
# Create logger
|
||||||
|
self.log = logging.getLogger(self._svc_name_)
|
||||||
|
self.log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Clear existing handlers
|
||||||
|
self.log.handlers.clear()
|
||||||
|
|
||||||
|
# File handler for desktop
|
||||||
|
file_handler = logging.FileHandler(log_file_path, mode='w')
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
|
||||||
|
# Console handler for Event Viewer
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_formatter = logging.Formatter('%(levelname)s: %(message)s')
|
||||||
|
console_handler.setFormatter(console_formatter)
|
||||||
|
|
||||||
|
# Add handlers
|
||||||
|
self.log.addHandler(file_handler)
|
||||||
|
self.log.addHandler(console_handler)
|
||||||
|
|
||||||
|
self.log.info(f"Logging initialized. Log file: {log_file_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback to servicemanager logging
|
||||||
|
servicemanager.LogInfoMsg(f"Failed to setup file logging: {e}")
|
||||||
|
self.log = servicemanager
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_service_config(cls):
|
||||||
|
config_path = sys.argv[1]
|
||||||
|
with open(config_path, 'r', encoding="utf-8") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
service_config = WindowServiceConfig(**config_data)
|
||||||
|
cls._config = service_config
|
||||||
|
cls._svc_name_ = service_config.name
|
||||||
|
cls._svc_display_name_ = service_config.display_name
|
||||||
|
|
||||||
|
def SvcStop(self):
|
||||||
|
"""Stop the service"""
|
||||||
|
self.log.info("Service stop requested via SvcStop")
|
||||||
|
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
|
||||||
|
self.stop()
|
||||||
|
self.ReportServiceStatus(win32service.SERVICE_STOPPED)
|
||||||
|
self.log.info("Service stopped successfully")
|
||||||
|
|
||||||
|
def SvcDoRun(self):
|
||||||
|
"""Start the service; does not return until stopped"""
|
||||||
|
self.log.info("Service starting via SvcDoRun")
|
||||||
|
self.ReportServiceStatus(win32service.SERVICE_START_PENDING)
|
||||||
|
self.ReportServiceStatus(win32service.SERVICE_RUNNING)
|
||||||
|
self.log.info("Service status set to RUNNING")
|
||||||
|
# Run the service
|
||||||
|
try:
|
||||||
|
self.run()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Service crashed with exception: {e}")
|
||||||
|
self.log.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.ReportServiceStatus(win32service.SERVICE_STOPPED)
|
||||||
|
self.log.info("Service status set to STOPPED")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the service"""
|
||||||
|
self.log.info("Stop method called")
|
||||||
|
win32event.SetEvent(self.hWaitStop)
|
||||||
|
self.stop_requested = True
|
||||||
|
self.log.info("Service stop requested flag set")
|
||||||
|
|
||||||
|
# Terminate the subprocess
|
||||||
|
if self.process and self.process.poll() is None:
|
||||||
|
self.log.info(f"Terminating process with PID: {self.process.pid}")
|
||||||
|
try:
|
||||||
|
# self.process.terminate()
|
||||||
|
self.process.send_signal(signal.CTRL_C_EVENT)
|
||||||
|
time.sleep(1)
|
||||||
|
self.process.terminate()
|
||||||
|
self.log.info("Process termination signal sent")
|
||||||
|
# Give process time to terminate gracefully
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=30)
|
||||||
|
self.log.info("Process terminated gracefully within timeout")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.log.warning("Process did not terminate gracefully, forcing kill")
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait()
|
||||||
|
self.log.info("Process killed forcefully")
|
||||||
|
|
||||||
|
self.log.info("Wrapped process terminated successfully")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error terminating wrapped process: {e}")
|
||||||
|
self.log.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main service loop. This is where work is done!"""
|
||||||
|
self.log.info("Starting service run method")
|
||||||
|
|
||||||
|
# Log configuration details
|
||||||
|
self.log.info(f"Service configuration: {self._config}")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['PYTHONIOENCODING'] = 'utf-8'
|
||||||
|
if self._config.env:
|
||||||
|
env.update(self._config.env)
|
||||||
|
self.log.info(f"Environment variables updated: {list(self._config.env.keys())}")
|
||||||
|
|
||||||
|
cmd = self._config.bin_path
|
||||||
|
if self._config.args:
|
||||||
|
cmd = f"{cmd} {self._config.args}"
|
||||||
|
|
||||||
|
self.log.info(f"Command to execute: {' '.join(cmd)}")
|
||||||
|
self.log.info(f"Working directory: {self._config.working_dir}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Launch the process
|
||||||
|
self.log.info("Launching subprocess...")
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=self._config.working_dir,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
universal_newlines=True,
|
||||||
|
shell=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.log.info(f"Process started successfully with PID: {self.process.pid}")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Failed to launch executable: {e}")
|
||||||
|
self.log.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# redirect logs
|
||||||
|
self.stdout_thread = Thread(target=log_stream, args=(self.process.stdout, self.log.info))
|
||||||
|
self.stderr_thread = Thread(target=log_stream, args=(self.process.stderr, self.log.error))
|
||||||
|
self.stdout_thread.start()
|
||||||
|
self.stderr_thread.start()
|
||||||
|
|
||||||
|
self.log.info("Entering process wait loop...")
|
||||||
|
self._wait_for_process()
|
||||||
|
|
||||||
|
def _wait_for_process(self):
|
||||||
|
"""Wait for the wrapped process to complete."""
|
||||||
|
self.log.info("Starting process wait loop")
|
||||||
|
loop_count = 0
|
||||||
|
|
||||||
|
while self.process.poll() is None and not self.stop_requested:
|
||||||
|
loop_count += 1
|
||||||
|
if loop_count % 10 == 0: # Log every 5 seconds
|
||||||
|
self.log.debug(f"Process still running... (check #{loop_count})")
|
||||||
|
|
||||||
|
# Check for stop requests with short timeout (500ms for responsiveness)
|
||||||
|
result = win32event.WaitForSingleObject(self.hWaitStop, 500)
|
||||||
|
if result == win32event.WAIT_OBJECT_0:
|
||||||
|
self.stop_requested = True
|
||||||
|
self.log.info("Stop signal received via WaitForSingleObject")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.log.info(f"Exited wait loop. Process poll: {self.process.poll()}, stop_requested: {self.stop_requested}")
|
||||||
|
|
||||||
|
# Process has terminated or stop was requested
|
||||||
|
if self.process and self.process.poll() is None:
|
||||||
|
self.log.info("Process still running, initiating termination")
|
||||||
|
try:
|
||||||
|
self.process.terminate()
|
||||||
|
self.log.info("Termination signal sent to process")
|
||||||
|
self.process.wait(timeout=10)
|
||||||
|
self.log.info("Process terminated gracefully")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.log.warning("Process did not terminate gracefully, forcing kill")
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait()
|
||||||
|
self.log.info("Process killed forcefully")
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Error terminating process: {e}")
|
||||||
|
self.log.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
# Capture final output
|
||||||
|
if self.process:
|
||||||
|
self.log.info("Capturing final process output...")
|
||||||
|
self.stdout_thread.join()
|
||||||
|
self.stderr_thread.join()
|
||||||
|
self.log.info(f"Process terminated with exit code: {self.process.returncode}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} /path/to/service_config.json")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
servicemanager.Initialize()
|
||||||
|
servicemanager.PrepareToHostSingle(WindowsServiceFramework)
|
||||||
|
servicemanager.StartServiceCtrlDispatcher()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
10
test_config.json
Normal file
10
test_config.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"executable_path": "test.exe",
|
||||||
|
"arguments": "--test",
|
||||||
|
"working_directory": "",
|
||||||
|
"restart_on_failure": true,
|
||||||
|
"max_restarts": 5,
|
||||||
|
"restart_delay": 10,
|
||||||
|
"log_level": "INFO",
|
||||||
|
"environment_variables": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user