From 3ea07c01839b6caf896c818faf67e49c7f704ff1 Mon Sep 17 00:00:00 2001 From: DevChat Tester Date: Sat, 1 Nov 2025 00:19:37 +0800 Subject: [PATCH] feat: support macOS --- README.md | 7 + scientific_surfing/core_manager.py | 31 ++- scientific_surfing/macos_service_wrapper.py | 257 ++++++++++++++++++ scientific_surfing/service_manager.py | 22 +- .../templates/default-core-config.yaml | 4 +- 5 files changed, 314 insertions(+), 7 deletions(-) create mode 100755 scientific_surfing/macos_service_wrapper.py diff --git a/README.md b/README.md index 5e92904..df14875 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ python -m scientific_surfing core-config apply python -m scientific_surfing core update [--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 This project uses Poetry for dependency management: diff --git a/scientific_surfing/core_manager.py b/scientific_surfing/core_manager.py index 2154235..8796beb 100644 --- a/scientific_surfing/core_manager.py +++ b/scientific_surfing/core_manager.py @@ -87,7 +87,13 @@ class CoreManager: # Setup directories binary_dir = self.core_config_manager.storage.config_dir / "bin" 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 if binary_path.exists() and not force: @@ -227,8 +233,27 @@ class CoreManager: def get_binary_path(self) -> Path: """Get the path to the mihomo executable.""" 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_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 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}") # 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.install(service_name, str(binary_path), description, service_args) diff --git a/scientific_surfing/macos_service_wrapper.py b/scientific_surfing/macos_service_wrapper.py new file mode 100755 index 0000000..a203b20 --- /dev/null +++ b/scientific_surfing/macos_service_wrapper.py @@ -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 [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() diff --git a/scientific_surfing/service_manager.py b/scientific_surfing/service_manager.py index e8b0cdf..7ae0228 100644 --- a/scientific_surfing/service_manager.py +++ b/scientific_surfing/service_manager.py @@ -382,9 +382,27 @@ class MacOSServiceManager(ServiceManagerProtocol): def _create_launchd_plist(self, config: ServiceConfig) -> None: """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: - 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" {arg}" for arg in program_args]) diff --git a/scientific_surfing/templates/default-core-config.yaml b/scientific_surfing/templates/default-core-config.yaml index 0068ba4..5f26c87 100644 --- a/scientific_surfing/templates/default-core-config.yaml +++ b/scientific_surfing/templates/default-core-config.yaml @@ -100,9 +100,9 @@ external-controller-unix: mihomo.sock # 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问 external-ui: ui -external-ui-name: xd +#external-ui-name: xd # 目前支持下载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服务器 # !!!该URL不会验证secret, 如果开启请自行保证安全问题 !!!