Initial commit with Python .gitignore

This commit is contained in:
2025-10-16 12:17:34 +08:00
commit 90719b8416
19 changed files with 3387 additions and 0 deletions

View File

@ -0,0 +1,435 @@
"""
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