feat: support macOS
This commit is contained in:
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
257
scientific_surfing/macos_service_wrapper.py
Executable file
257
scientific_surfing/macos_service_wrapper.py
Executable 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()
|
||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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, 如果开启请自行保证安全问题 !!!
|
||||||
|
|||||||
Reference in New Issue
Block a user