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
|
||||
|
||||
# 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"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
python = "^3.10"
|
||||
requests = "^2.25.0"
|
||||
PyYAML = "^6.0.0"
|
||||
pydantic = "^2.0.0"
|
||||
ipykernel = "^7.0.1"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
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('--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_parser = subparsers.add_parser('hook', help='Manage hook scripts')
|
||||
hook_subparsers = hook_parser.add_subparsers(dest='hook_command', help='Hook operations')
|
||||
@ -170,9 +199,41 @@ def main() -> None:
|
||||
parser.parse_args(['core', '--help'])
|
||||
return
|
||||
|
||||
|
||||
if args.core_command == 'update':
|
||||
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:
|
||||
parser.parse_args(['core', '--help'])
|
||||
|
||||
|
||||
@ -15,12 +15,14 @@ import requests
|
||||
from pathlib import Path
|
||||
|
||||
from scientific_surfing.corecfg_manager import CoreConfigManager
|
||||
from scientific_surfing.service_manager import ServiceManager
|
||||
|
||||
|
||||
class CoreManager:
|
||||
"""Manages user configuration with import, export, and edit operations."""
|
||||
|
||||
def __init__(self, core_config_manager: CoreConfigManager):
|
||||
self.storage = core_config_manager.storage
|
||||
self.core_config_manager = core_config_manager
|
||||
|
||||
def update(self, version: Optional[str] = None, force: bool = False) -> bool:
|
||||
@ -222,207 +224,181 @@ class CoreManager:
|
||||
print(f"[ERROR] Upgrade failed: {e}")
|
||||
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:
|
||||
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:
|
||||
bool: True if daemon started successfully, False otherwise.
|
||||
bool: True if installation successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Determine 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")
|
||||
|
||||
binary_path = self.get_binary_path()
|
||||
if not binary_path.exists():
|
||||
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
|
||||
|
||||
# Determine config path
|
||||
if config_path is None:
|
||||
config_file = self.core_config_manager.storage.config_dir / "generated_config.yaml"
|
||||
else:
|
||||
config_file = Path(config_path)
|
||||
# Setup service arguments
|
||||
config_dir = self.core_config_manager.storage.config_dir
|
||||
config_file = config_dir / "generated_config.yaml"
|
||||
|
||||
# Ensure config file exists
|
||||
if not config_file.exists():
|
||||
print(f"❌ Configuration file not found: {config_file}")
|
||||
print(" Run 'core-config apply' to generate the configuration first.")
|
||||
return False
|
||||
self.core_config_manager.generate_config()
|
||||
print(f"✅ Generated initial configuration: {config_file}")
|
||||
|
||||
print(f"[INFO] Starting mihomo daemon...")
|
||||
print(f" Binary: {binary_path}")
|
||||
print(f" Config: {config_file}")
|
||||
# Build service arguments
|
||||
service_args = f"-d \"{config_dir}\" -f \"{config_file}\""
|
||||
|
||||
# Prepare command
|
||||
cmd = [
|
||||
str(binary_path),
|
||||
"-f", str(config_file),
|
||||
"-d", str(self.core_config_manager.storage.config_dir)
|
||||
]
|
||||
|
||||
# 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}")
|
||||
service_manager = ServiceManager(self.storage.config_dir)
|
||||
service_manager.install(service_name, str(binary_path), description, service_args)
|
||||
print(f"✅ Service '{service_name}' installed successfully")
|
||||
print(f" Config directory: {config_dir}")
|
||||
print(f" Config file: {config_file}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to start daemon: {e}")
|
||||
print(f"❌ Failed to install service: {e}")
|
||||
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:
|
||||
bool: True if daemon stopped successfully, False otherwise.
|
||||
bool: True if uninstallation successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
pid_file = self.core_config_manager.storage.config_dir / "mihomo.pid"
|
||||
|
||||
if not pid_file.exists():
|
||||
print("❌ No daemon appears to be running (PID file not found)")
|
||||
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
|
||||
service_manager = ServiceManager(self.storage.config_dir)
|
||||
service_manager.uninstall(service_name)
|
||||
print(f"✅ Service '{service_name}' uninstalled successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to stop daemon: {e}")
|
||||
print(f"❌ Failed to uninstall service: {e}")
|
||||
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:
|
||||
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:
|
||||
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():
|
||||
status["error"] = "PID file not found"
|
||||
return status
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to start service: {e}")
|
||||
return False
|
||||
|
||||
with open(pid_file, 'r') as f:
|
||||
pid = int(f.read().strip())
|
||||
def stop_service(self, service_name: str = "mihomo") -> bool:
|
||||
"""
|
||||
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()
|
||||
try:
|
||||
if system == "windows":
|
||||
# Windows: Use tasklist
|
||||
result = subprocess.run(["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True, text=True)
|
||||
if str(pid) in result.stdout:
|
||||
status["running"] = True
|
||||
status["pid"] = pid
|
||||
|
||||
if system == "windows":
|
||||
result = subprocess.run(["sc", "query", service_name], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split('\n'):
|
||||
if "STATE" in line:
|
||||
return line.strip()
|
||||
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:
|
||||
status["error"] = "Process not found"
|
||||
pid_file.unlink() # Clean up stale PID file
|
||||
return f"Service is {status}"
|
||||
else:
|
||||
# Unix-like systems: Use kill signal 0
|
||||
os.kill(pid, 0) # Signal 0 just checks if process exists
|
||||
status["running"] = True
|
||||
status["pid"] = pid
|
||||
return "Service not installed or not found"
|
||||
|
||||
except (ProcessLookupError, subprocess.CalledProcessError):
|
||||
status["error"] = "Process not found"
|
||||
pid_file.unlink() # Clean up stale PID file
|
||||
elif system == "darwin":
|
||||
result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
|
||||
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:
|
||||
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):
|
||||
for k, v in dict2.items():
|
||||
|
||||
@ -288,13 +288,15 @@ class CoreConfigManager:
|
||||
print("❌ No active subscription found")
|
||||
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.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# 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()
|
||||
|
||||
# Parse subscription YAML
|
||||
@ -350,7 +352,6 @@ class CoreConfigManager:
|
||||
|
||||
print(f"✅ Generated final configuration: {generated_path}")
|
||||
print(f" Active subscription: {active_subscription.name}")
|
||||
print(f" Source file: {active_subscription.file_path}")
|
||||
|
||||
# Execute hooks after successful config generation
|
||||
self._execute_hooks(generated_path)
|
||||
|
||||
@ -3,6 +3,7 @@ Pydantic models for scientific-surfing data structures.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
@ -14,7 +15,6 @@ class Subscription(BaseModel):
|
||||
url: str = Field(..., description="Clash RSS subscription URL")
|
||||
status: str = Field(default="inactive", description="Status: active or inactive")
|
||||
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")
|
||||
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")
|
||||
@ -31,6 +31,8 @@ class Subscription(BaseModel):
|
||||
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):
|
||||
"""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 yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
from typing import Dict
|
||||
|
||||
from scientific_surfing.models import SubscriptionsData
|
||||
|
||||
@ -16,13 +16,17 @@ class StorageManager:
|
||||
"""Manages cross-platform data storage for subscriptions and configuration."""
|
||||
|
||||
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.subscriptions_file = self.config_dir / "subscriptions.yaml"
|
||||
self._ensure_config_dir()
|
||||
|
||||
def _get_config_dir(self) -> Path:
|
||||
"""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()
|
||||
|
||||
if system == "windows":
|
||||
|
||||
@ -3,6 +3,7 @@ Subscription management module for scientific-surfing.
|
||||
Handles subscription operations with persistent storage.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import requests
|
||||
from scientific_surfing.storage import StorageManager
|
||||
|
||||
@ -78,7 +79,6 @@ class SubscriptionManager:
|
||||
|
||||
# Update subscription metadata
|
||||
subscription.last_refresh = datetime.now()
|
||||
subscription.file_path = str(file_path)
|
||||
subscription.file_size = len(response.text)
|
||||
subscription.status_code = response.status_code
|
||||
subscription.content_hash = hash(response.text)
|
||||
|
||||
@ -99,7 +99,7 @@ external-controller-unix: mihomo.sock
|
||||
# tcp-concurrent: true # TCP 并发连接所有 IP, 将使用最快握手的 TCP
|
||||
|
||||
# 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问
|
||||
external-ui: /path/to/ui/folder/
|
||||
external-ui: ui
|
||||
external-ui-name: xd
|
||||
# 目前支持下载zip,tgz格式的压缩包
|
||||
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