""" User configuration manager for scientific-surfing. Handles user preferences with import, export, and edit operations. """ import os import platform import gzip import zipfile import shutil import subprocess import signal from typing import Optional, Dict, Any import requests from pathlib import Path from scientific_surfing.corecfg_manager import CoreConfigManager class CoreManager: """Manages user configuration with import, export, and edit operations.""" def __init__(self, core_config_manager: CoreConfigManager): self.core_config_manager = core_config_manager def update(self, version: Optional[str] = None, force: bool = False) -> bool: """ Download and update mihomo binary from GitHub releases. Args: version: Specific version to download (e.g., 'v1.18.5'). If None, downloads latest. force: Force download even if binary already exists. Returns: bool: True if update successful, False otherwise. """ try: # Determine current OS and architecture system = platform.system().lower() machine = platform.machine().lower() # Map platform to mihomo binary naming (base name without extension) platform_map = { 'windows': { 'amd64': 'mihomo-windows-amd64', '386': 'mihomo-windows-386', 'arm64': 'mihomo-windows-arm64', 'arm': 'mihomo-windows-arm32v7' }, 'linux': { 'amd64': 'mihomo-linux-amd64', '386': 'mihomo-linux-386', 'arm64': 'mihomo-linux-arm64', 'arm': 'mihomo-linux-armv7' }, 'darwin': { 'amd64': 'mihomo-darwin-amd64', 'arm64': 'mihomo-darwin-arm64' } } # Normalize architecture names arch_map = { 'x86_64': 'amd64', 'amd64': 'amd64', 'i386': '386', 'i686': '386', 'arm64': 'arm64', 'aarch64': 'arm64', 'armv7l': 'arm', 'arm': 'arm' } if system not in platform_map: print(f"❌ Unsupported operating system: {system}") return False normalized_arch = arch_map.get(machine, machine) if normalized_arch not in platform_map[system]: print(f"❌ Unsupported architecture: {machine} ({normalized_arch})") return False binary_name = platform_map[system][normalized_arch] # 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") # Check if binary already exists if binary_path.exists() and not force: print(f"ℹ️ Binary already exists at: {binary_path}") print(" Use --force to overwrite") return True # Get release info if version: # Specific version release_url = f"https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/{version}" else: # Latest release release_url = "https://api.github.com/repos/MetaCubeX/mihomo/releases/latest" print(f"[INFO] Fetching release info from: {release_url}") headers = { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'scientific-surfing/1.0' } response = requests.get(release_url, headers=headers, timeout=30) response.raise_for_status() release_data = response.json() release_version = release_data['tag_name'] print(f"[INFO] Found release: {release_version}") # Find the correct asset assets = release_data.get('assets', []) target_asset = None # Determine file extension based on system file_extension = '.zip' if system == 'windows' else '.gz' expected_filename = f"{binary_name}-{release_version}{file_extension}" # Look for exact match first for asset in assets: if asset['name'] == expected_filename: target_asset = asset break # Fallback to prefix matching if exact match not found if not target_asset: binary_name_prefix = f"{binary_name}-{release_version}" for asset in assets: if asset['name'].startswith(binary_name_prefix) and (asset['name'].endswith('.gz') or asset['name'].endswith('.zip')): target_asset = asset break if not target_asset: print(f"[ERROR] Binary not found for {system}/{normalized_arch}: {expected_filename}") print("Available binaries:") for asset in assets: if 'mihomo' in asset['name'] and (asset['name'].endswith('.gz') or asset['name'].endswith('.zip')): print(f" - {asset['name']}") return False # Download the compressed file download_url = target_asset['browser_download_url'] compressed_filename = target_asset['name'] print(f"[DOWNLOAD] Downloading: {compressed_filename}") print(f" Size: {target_asset['size']:,} bytes") download_response = requests.get(download_url, stream=True, timeout=60) download_response.raise_for_status() # Download to temporary file temp_compressed_path = binary_path.with_suffix(f".tmp{file_extension}") temp_extracted_path = binary_path.with_suffix('.tmp') with open(temp_compressed_path, 'wb') as f: for chunk in download_response.iter_content(chunk_size=8192): if chunk: f.write(chunk) # Verify download if temp_compressed_path.stat().st_size != target_asset['size']: temp_compressed_path.unlink() print("[ERROR] Download verification failed - size mismatch") return False # Extract the binary try: if file_extension == '.gz': # Extract .gz file with gzip.open(temp_compressed_path, 'rb') as f_in: with open(temp_extracted_path, 'wb') as f_out: shutil.copyfileobj(f_in, f_out) elif file_extension == '.zip': # Extract .zip file with zipfile.ZipFile(temp_compressed_path, 'r') as zip_ref: # Find the executable file in the zip file_info = zip_ref.filelist[0] with zip_ref.open(file_info.filename) as f_in: with open(temp_extracted_path, 'wb') as f_out: shutil.copyfileobj(f_in, f_out) else: raise ValueError(f"Unsupported file format: {file_extension}") except Exception as e: temp_compressed_path.unlink() if temp_extracted_path.exists(): temp_extracted_path.unlink() print(f"[ERROR] Failed to extract binary: {e}") return False # Clean up compressed file temp_compressed_path.unlink() # Make executable on Unix-like systems if system != 'windows': os.chmod(temp_extracted_path, 0o755) # Move to final location if binary_path.exists(): backup_path = binary_path.with_suffix('.backup') binary_path.rename(backup_path) print(f"[INFO] Backed up existing binary to: {backup_path}") temp_extracted_path.rename(binary_path) print(f"[SUCCESS] Successfully updated mihomo {release_version}") print(f" Location: {binary_path}") print(f" Size: {binary_path.stat().st_size:,} bytes") return True except requests.exceptions.RequestException as e: print(f"[ERROR] Network error: {e}") return False except Exception as e: print(f"[ERROR] Upgrade failed: {e}") return False def daemon(self, config_path: Optional[str] = None) -> bool: """ Run the mihomo executable as a daemon with the generated configuration. Args: config_path: Path to the configuration file. If None, uses generated_config.yaml Returns: bool: True if daemon started successfully, 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") if not binary_path.exists(): print(f"❌ Mihomo binary not found at: {binary_path}") print(" Run 'core update' to download the binary first.") 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) 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 print(f"[INFO] Starting mihomo daemon...") print(f" Binary: {binary_path}") print(f" Config: {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}") return True except Exception as e: print(f"❌ Failed to start daemon: {e}") return False def stop_daemon(self) -> bool: """ Stop the running mihomo daemon. Returns: bool: True if daemon stopped successfully, 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 except Exception as e: print(f"❌ Failed to stop daemon: {e}") return False def daemon_status(self) -> Dict[str, Any]: """ Get the status of the mihomo daemon. Returns: Dict containing daemon status information. """ 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" if not pid_file.exists(): status["error"] = "PID file not found" return status with open(pid_file, 'r') as f: pid = int(f.read().strip()) # Check if process is running 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 else: status["error"] = "Process not found" pid_file.unlink() # Clean up stale PID file 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 except (ProcessLookupError, subprocess.CalledProcessError): status["error"] = "Process not found" pid_file.unlink() # Clean up stale PID file except Exception as e: status["error"] = str(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(): if k in dict1 and isinstance(dict1[k], dict) and isinstance(v, dict): dict1[k] = deep_merge(dict1[k], v) elif k in dict1 and isinstance(dict1[k], list) and isinstance(v, list): dict1[k].extend(v) # Example: extend lists. Adjust logic for other list merging needs. else: dict1[k] = v return dict1