411 lines
15 KiB
Python
411 lines
15 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
|
||
from scientific_surfing.service_manager import ServiceManager
|
||
|
||
|
||
class CoreManager:
|
||
"""Manages user configuration with import, export, and edit operations."""
|
||
|
||
def __init__(self, core_config_manager: CoreConfigManager):
|
||
self.storage = core_config_manager.storage
|
||
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 get_binary_path(self) -> Path:
|
||
"""Get the path to the mihomo executable."""
|
||
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")
|
||
return binary_path
|
||
|
||
def install_service(self, service_name: str = "mihomo", description: str = "Mihomo proxy service") -> bool:
|
||
"""
|
||
Install mihomo as a system service.
|
||
|
||
Args:
|
||
service_name: Name for the service (default: "mihomo")
|
||
description: Service description
|
||
|
||
Returns:
|
||
bool: True if installation successful, False otherwise
|
||
"""
|
||
try:
|
||
binary_path = self.get_binary_path()
|
||
if not binary_path.exists():
|
||
print(f"❌ Mihomo binary not found at: {binary_path}")
|
||
print(" Please run 'core update' first to download the binary")
|
||
return False
|
||
|
||
# Setup service arguments
|
||
config_dir = self.core_config_manager.storage.config_dir
|
||
config_file = config_dir / "generated_config.yaml"
|
||
|
||
# Ensure config file exists
|
||
if not config_file.exists():
|
||
self.core_config_manager.generate_config()
|
||
print(f"✅ Generated initial configuration: {config_file}")
|
||
|
||
# Build service arguments
|
||
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)
|
||
print(f"✅ Service '{service_name}' installed successfully")
|
||
print(f" Config directory: {config_dir}")
|
||
print(f" Config file: {config_file}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to install service: {e}")
|
||
return False
|
||
|
||
def uninstall_service(self, service_name: str = "mihomo") -> bool:
|
||
"""
|
||
Uninstall mihomo system service.
|
||
|
||
Args:
|
||
service_name: Name of the service to uninstall (default: "mihomo")
|
||
|
||
Returns:
|
||
bool: True if uninstallation successful, False otherwise
|
||
"""
|
||
try:
|
||
service_manager = ServiceManager(self.storage.config_dir)
|
||
service_manager.uninstall(service_name)
|
||
print(f"✅ Service '{service_name}' uninstalled successfully")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to uninstall service: {e}")
|
||
return False
|
||
|
||
def start_service(self, service_name: str = "mihomo") -> bool:
|
||
"""
|
||
Start mihomo system service.
|
||
|
||
Args:
|
||
service_name: Name of the service to start (default: "mihomo")
|
||
|
||
Returns:
|
||
bool: True if start successful, False otherwise
|
||
"""
|
||
try:
|
||
service_manager = ServiceManager(self.storage.config_dir)
|
||
service_manager.start(service_name)
|
||
print(f"✅ Service '{service_name}' started successfully")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to start service: {e}")
|
||
return False
|
||
|
||
def stop_service(self, service_name: str = "mihomo") -> bool:
|
||
"""
|
||
Stop mihomo system service.
|
||
|
||
Args:
|
||
service_name: Name of the service to stop (default: "mihomo")
|
||
|
||
Returns:
|
||
bool: True if stop successful, False otherwise
|
||
"""
|
||
try:
|
||
service_manager = ServiceManager(self.storage.config_dir)
|
||
service_manager.stop(service_name)
|
||
print(f"✅ Service '{service_name}' stopped successfully")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to stop service: {e}")
|
||
return False
|
||
|
||
def restart_service(self, service_name: str = "mihomo") -> bool:
|
||
"""
|
||
Restart mihomo system service.
|
||
|
||
Args:
|
||
service_name: Name of the service to restart (default: "mihomo")
|
||
|
||
Returns:
|
||
bool: True if restart successful, False otherwise
|
||
"""
|
||
try:
|
||
service_manager = ServiceManager(self.storage.config_dir)
|
||
service_manager.restart(service_name)
|
||
print(f"✅ Service '{service_name}' restarted successfully")
|
||
return True
|
||
|
||
except Exception as e:
|
||
print(f"❌ Failed to restart service: {e}")
|
||
return False
|
||
|
||
def get_service_status(self, service_name: str = "mihomo") -> str:
|
||
"""
|
||
Get the status of mihomo system service.
|
||
|
||
Args:
|
||
service_name: Name of the service to check (default: "mihomo")
|
||
|
||
Returns:
|
||
str: Service status description
|
||
"""
|
||
try:
|
||
system = platform.system().lower()
|
||
|
||
if system == "windows":
|
||
result = subprocess.run(["sc", "query", service_name], capture_output=True, text=True)
|
||
if result.returncode == 0:
|
||
for line in result.stdout.split('\n'):
|
||
if "STATE" in line:
|
||
return line.strip()
|
||
return "Service exists but status unknown"
|
||
else:
|
||
return "Service not installed or not found"
|
||
|
||
elif system == "linux":
|
||
result = subprocess.run(["systemctl", "is-active", service_name], capture_output=True, text=True)
|
||
if result.returncode == 0:
|
||
status = result.stdout.strip()
|
||
if status == "active":
|
||
return "Service is running"
|
||
else:
|
||
return f"Service is {status}"
|
||
else:
|
||
return "Service not installed or not found"
|
||
|
||
elif system == "darwin":
|
||
result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
|
||
if service_name in result.stdout or f"com.{service_name}" in result.stdout:
|
||
return "Service is loaded (check launchctl status for details)"
|
||
else:
|
||
return "Service not installed or not loaded"
|
||
|
||
else:
|
||
return f"Unsupported system: {system}"
|
||
|
||
except Exception as e:
|
||
return f"Error checking service status: {e}"
|
||
|
||
|
||
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 |