feat: support macOS

This commit is contained in:
DevChat Tester
2025-11-01 00:19:37 +08:00
parent 6a54381310
commit 3ea07c0183
5 changed files with 314 additions and 7 deletions

View File

@ -88,6 +88,13 @@ python -m scientific_surfing core-config apply
python -m scientific_surfing core update [--version <version>] [--force] python -m scientific_surfing core update [--version <version>] [--force]
``` ```
### Service Management
macOS
```bash
sudo env SF_CONFIG_DIR=(readlink -f ~/basicfiles/cli/scientific_surfing) python -m scientific_surfing core service install
```
## Development ## Development
This project uses Poetry for dependency management: This project uses Poetry for dependency management:

View File

@ -87,7 +87,13 @@ class CoreManager:
# Setup directories # Setup directories
binary_dir = self.core_config_manager.storage.config_dir / "bin" binary_dir = self.core_config_manager.storage.config_dir / "bin"
binary_dir.mkdir(parents=True, exist_ok=True) binary_dir.mkdir(parents=True, exist_ok=True)
binary_path = binary_dir / ("mihomo.exe" if system == "windows" else "mihomo")
# Add OS and arch as suffix to the binary name
suffix = f"-{system}-{normalized_arch}"
if system == "windows":
binary_path = binary_dir / f"mihomo{suffix}.exe"
else:
binary_path = binary_dir / f"mihomo{suffix}"
# Check if binary already exists # Check if binary already exists
if binary_path.exists() and not force: if binary_path.exists() and not force:
@ -227,8 +233,27 @@ class CoreManager:
def get_binary_path(self) -> Path: def get_binary_path(self) -> Path:
"""Get the path to the mihomo executable.""" """Get the path to the mihomo executable."""
system = platform.system().lower() system = platform.system().lower()
machine = platform.machine().lower()
# Normalize architecture names
arch_map = {
'x86_64': 'amd64',
'amd64': 'amd64',
'i386': '386',
'i686': '386',
'arm64': 'arm64',
'aarch64': 'arm64',
'armv7l': 'arm',
'arm': 'arm'
}
normalized_arch = arch_map.get(machine, machine)
binary_dir = self.core_config_manager.storage.config_dir / "bin" binary_dir = self.core_config_manager.storage.config_dir / "bin"
binary_path = binary_dir / ("mihomo.exe" if system == "windows" else "mihomo") suffix = f"-{system}-{normalized_arch}"
if system == "windows":
binary_path = binary_dir / f"mihomo{suffix}.exe"
else:
binary_path = binary_dir / f"mihomo{suffix}"
return binary_path return binary_path
def install_service(self, service_name: str = "mihomo", description: str = "Mihomo proxy service") -> bool: def install_service(self, service_name: str = "mihomo", description: str = "Mihomo proxy service") -> bool:
@ -259,7 +284,7 @@ class CoreManager:
print(f"✅ Generated initial configuration: {config_file}") print(f"✅ Generated initial configuration: {config_file}")
# Build service arguments # Build service arguments
service_args = f"-d \"{config_dir}\" -f \"{config_file}\"" service_args = f"-d {config_dir} -f {config_file}"
service_manager = ServiceManager(self.storage.config_dir) service_manager = ServiceManager(self.storage.config_dir)
service_manager.install(service_name, str(binary_path), description, service_args) service_manager.install(service_name, str(binary_path), description, service_args)

View File

@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
macOS service wrapper for mihomo.
This wrapper handles DNS configuration when starting/stopping the mihomo service.
"""
import sys
import subprocess
import signal
import os
from pathlib import Path
from typing import Optional, List, Dict
class MacOSDNSManager:
"""Manages macOS DNS settings."""
@staticmethod
def get_network_services() -> List[str]:
"""Get list of network services."""
try:
result = subprocess.run(
["networksetup", "-listallnetworkservices"],
capture_output=True,
text=True,
check=True
)
# Skip first line (header)
services = [line.strip() for line in result.stdout.split('\n')[1:] if line.strip() and not line.startswith('*')]
return services
except subprocess.CalledProcessError as e:
print(f"Failed to list network services: {e}", file=sys.stderr)
return []
@staticmethod
def get_current_dns(service: str) -> List[str]:
"""
Get current DNS servers for a network service.
Args:
service: Network service name
Returns:
List[str]: List of DNS server addresses
"""
try:
result = subprocess.run(
["networksetup", "-getdnsservers", service],
capture_output=True,
text=True,
check=True
)
if result.returncode:
return ["Empty"]
dns_servers = [line.strip() for line in result.stdout.split('\n') if line.strip() and " " not in line.strip()]
return dns_servers
except subprocess.CalledProcessError:
return ["Empty"]
@staticmethod
def set_dns(service: str, dns_servers: List[str]) -> bool:
"""
Set DNS servers for a network service.
Args:
service: Network service name
dns_servers: List of DNS server addresses (empty list to reset to DHCP)
Returns:
bool: True if successful, False otherwise
"""
try:
if not dns_servers:
# Reset to DHCP/automatic
subprocess.run(
["networksetup", "-setdnsservers", service, "empty"],
capture_output=True,
check=True
)
else:
subprocess.run(
["networksetup", "-setdnsservers", service] + dns_servers,
capture_output=True,
check=True
)
return True
except subprocess.CalledProcessError as e:
print(f"Failed to set DNS for {service}: {e}", file=sys.stderr)
return False
class MihomoServiceWrapper:
"""Wrapper for mihomo service on macOS."""
def __init__(self, executable: str, args: List[str], config_dir: Path):
"""
Initialize the service wrapper.
Args:
executable: Path to mihomo executable
args: Command line arguments
config_dir: Configuration directory
"""
self.executable = executable
self.args = args
self.config_dir = config_dir
self.process: Optional[subprocess.Popen] = None
self.dns_manager = MacOSDNSManager()
self.original_dns: Dict[str, List[str]] = {}
self.shutting_down = False
def start(self) -> bool:
"""
Start the mihomo service and configure DNS.
Returns:
bool: True if successful, False otherwise
"""
try:
# Start mihomo process
cmd = [self.executable] + self.args
self.process = subprocess.Popen(
cmd,
stdout=sys.stdout,
stderr=sys.stderr,
cwd=str(self.config_dir),
)
print(f"Started mihomo service (PID: {self.process.pid})", file=sys.stderr)
# Configure DNS for network services
dns_server = "198.18.0.2"
services = self.dns_manager.get_network_services()
for service in services:
# Save original DNS settings
original_dns = self.dns_manager.get_current_dns(service)
self.original_dns[service] = original_dns
# Set new DNS
if self.dns_manager.set_dns(service, [dns_server]):
print(f"Set DNS for {service}: {dns_server}", file=sys.stderr)
else:
print(f"Warning: Failed to set DNS for {service}", file=sys.stderr)
return True
except Exception as e:
print(f"Failed to start service: {e}", file=sys.stderr)
return False
def stop(self) -> bool:
"""
Stop the mihomo service and restore DNS settings.
Returns:
bool: True if successful, False otherwise
"""
try:
# Restore original DNS settings
for service, dns_servers in self.original_dns.items():
if self.dns_manager.set_dns(service, dns_servers):
if dns_servers:
print(f"Restored DNS for {service}: {', '.join(dns_servers)}", file=sys.stderr)
else:
print(f"Reset DNS for {service} to automatic", file=sys.stderr)
else:
print(f"Warning: Failed to restore DNS for {service}", file=sys.stderr)
# Stop mihomo process
if self.process:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
print(f"Stopped mihomo service (PID: {self.process.pid})", file=sys.stderr)
return True
except Exception as e:
print(f"Failed to stop service: {e}", file=sys.stderr)
return False
def run(self) -> None:
"""Run the service wrapper (blocking)."""
self.shutting_down = False
# Setup signal handlers
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
# Start service
if not self.start():
sys.exit(1)
print("Service started, press Ctrl+C to stop", file=sys.stderr)
# Wait for process to exit
try:
if self.process:
# Use poll with timeout to allow signal handling
while self.process.poll() is None and not self.shutting_down:
try:
self.process.wait(timeout=0.5)
except subprocess.TimeoutExpired:
continue
if self.process.poll() is not None:
print(f"Process exited with code: {self.process.returncode}", file=sys.stderr)
except KeyboardInterrupt:
print("\nReceived KeyboardInterrupt", file=sys.stderr)
finally:
if not self.shutting_down:
self.stop()
def _handle_signal(self, signum: int, _frame) -> None:
"""Handle termination signals."""
if self.shutting_down:
# Force kill on second signal
print(f"Force killing...", file=sys.stderr)
if self.process:
self.process.kill()
sys.exit(1)
print(f"Received signal {signum}, shutting down...", file=sys.stderr)
self.shutting_down = True
self.stop()
sys.exit(0)
def main() -> None:
"""Main entry point."""
if len(sys.argv) < 3:
print("Usage: macos_service_wrapper.py <executable> <config_dir> [args...]", file=sys.stderr)
sys.exit(1)
executable = sys.argv[1]
config_dir = Path(sys.argv[2])
args = sys.argv[3:] if len(sys.argv) > 3 else []
# Validate inputs
if not Path(executable).exists():
print(f"Error: Executable not found: {executable}", file=sys.stderr)
sys.exit(1)
if not config_dir.exists():
print(f"Error: Config directory not found: {config_dir}", file=sys.stderr)
sys.exit(1)
# Create and run wrapper
wrapper = MihomoServiceWrapper(executable, args, config_dir)
wrapper.run()
if __name__ == "__main__":
main()

View File

@ -382,9 +382,27 @@ class MacOSServiceManager(ServiceManagerProtocol):
def _create_launchd_plist(self, config: ServiceConfig) -> None: def _create_launchd_plist(self, config: ServiceConfig) -> None:
"""Create a launchd plist file.""" """Create a launchd plist file."""
program_args = [str(config.executable_path)] import shlex
# Use the macOS service wrapper instead of directly running the executable
wrapper_script = Path(__file__).parent / "macos_service_wrapper.py"
if not wrapper_script.exists():
raise RuntimeError(f"macOS service wrapper not found: {wrapper_script}")
# Get the working directory from executable path
working_dir = config.executable_path.parent
# Build program arguments: python3 wrapper.py executable working_dir [args...]
program_args = [
sys.executable, # python3
str(wrapper_script),
str(config.executable_path),
str(working_dir)
]
if config.args: if config.args:
program_args.extend(config.args.split()) # Use shlex.split to properly handle quoted strings
program_args.extend(shlex.split(config.args))
program_args_xml = "\n".join([f" <string>{arg}</string>" for arg in program_args]) program_args_xml = "\n".join([f" <string>{arg}</string>" for arg in program_args])

View File

@ -100,9 +100,9 @@ external-controller-unix: mihomo.sock
# 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问 # 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问
external-ui: ui external-ui: ui
external-ui-name: xd #external-ui-name: xd
# 目前支持下载zip,tgz格式的压缩包 # 目前支持下载zip,tgz格式的压缩包
external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip" #external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
# 在RESTful API端口上开启DOH服务器 # 在RESTful API端口上开启DOH服务器
# 该URL不会验证secret 如果开启请自行保证安全问题 # 该URL不会验证secret 如果开启请自行保证安全问题