435 lines
17 KiB
Python
435 lines
17 KiB
Python
"""
|
||
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 |