feat: Windows 服务 安装,启动,停止 功能

This commit is contained in:
2025-10-18 22:16:54 +08:00
parent b4ce2046a9
commit 6a54381310
14 changed files with 1882 additions and 504 deletions

View File

@ -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 youre done making a series of code changes
- Be sure to typecheck when youre done making a series of code changes
- Be sure to update README.md after making code changes

96
playground.ipynb Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -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'])

View File

@ -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():

View File

@ -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)

View File

@ -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."""

View 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)

View File

@ -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":

View File

@ -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)

View File

@ -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"

View 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
View 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": {}
}