Initial commit with Python .gitignore
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(poetry init:*)",
|
||||||
|
"Bash(mkdir:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(poetry run python:*)",
|
||||||
|
"Bash(del test_upgrade.py)",
|
||||||
|
"Bash(git add .)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
140
.gitignore
vendored
Normal file
140
.gitignore
vendored
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Code style
|
||||||
|
- Use type hinting everywhere
|
||||||
|
- Use pydantic based models instead of dict
|
||||||
|
- Adopt Inversion of Control pattern whenever possible, use constructor injection for class, extract pure function if it has to depend on some global variable
|
||||||
|
|
||||||
|
# Workflow
|
||||||
|
- Be sure to typecheck when you’re done making a series of code changes
|
||||||
50
README.md
Normal file
50
README.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Scientific Surfing
|
||||||
|
|
||||||
|
A Python package for surfing internet scientifically.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Clash RSS Subscription Support**: Download and transform clash rss subscription
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Clone into local
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/klesh/scientific-surfing.git
|
||||||
|
cd scientific-surfing
|
||||||
|
poetry install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Add the root directory to system PATH
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```
|
||||||
|
# add a subscription
|
||||||
|
python -m scientific_surfing subscription add <name> <clash-rss-subscription-url>
|
||||||
|
|
||||||
|
# refresh a subscription
|
||||||
|
python -m scientific_surfing subscription refresh name
|
||||||
|
|
||||||
|
# delete a subscription
|
||||||
|
python -m scientific_surfing subscription rm <name>
|
||||||
|
|
||||||
|
# rename a subscription
|
||||||
|
python -m scientific_surfing subscription rename <name> <new-name>
|
||||||
|
|
||||||
|
# activate a subscription
|
||||||
|
python -m scientific_surfing subscription activate <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
This project uses Poetry for dependency management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry install
|
||||||
|
poetry run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details.
|
||||||
1247
poetry.lock
generated
Normal file
1247
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "scientific-surfing"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Python package for surfing the internet scientifically"
|
||||||
|
authors = ["Scientific Surfing Team <team@scientific-surfing.com>"]
|
||||||
|
readme = "README.md"
|
||||||
|
packages = [{include = "scientific_surfing"}]
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.8"
|
||||||
|
requests = "^2.25.0"
|
||||||
|
PyYAML = "^6.0.0"
|
||||||
|
pydantic = "^2.0.0"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
pytest = "^6.0.0"
|
||||||
|
pytest-cov = "^2.0.0"
|
||||||
|
black = "^21.0.0"
|
||||||
|
flake8 = "^3.8.0"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ['py38']
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
11
scientific_surfing/__init__.py
Normal file
11
scientific_surfing/__init__.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
Scientific Surfing - A Python package for surfing internet scientifically.
|
||||||
|
|
||||||
|
|
||||||
|
This package provides tools and utilities for surfing internet scientifically,
|
||||||
|
including clash rss subscription support, custom routing rules, and others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__author__ = "Scientific Surfing Team"
|
||||||
|
__email__ = "team@scientific-surfing.com"
|
||||||
8
scientific_surfing/__main__.py
Normal file
8
scientific_surfing/__main__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Entry point for python -m scientific_surfing
|
||||||
|
"""
|
||||||
|
|
||||||
|
from scientific_surfing.cli import main
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
177
scientific_surfing/cli.py
Normal file
177
scientific_surfing/cli.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Command-line interface for scientific-surfing package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from scientific_surfing.subscription_manager import SubscriptionManager
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Create the argument parser."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Scientific Surfing - CLI for managing clash RSS subscriptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
||||||
|
|
||||||
|
# Subscription commands
|
||||||
|
subscription_parser = subparsers.add_parser('subscription', help='Manage subscriptions')
|
||||||
|
subscription_subparsers = subscription_parser.add_subparsers(dest='subcommand', help='Subscription operations')
|
||||||
|
|
||||||
|
# Add subscription command
|
||||||
|
add_parser = subscription_subparsers.add_parser('add', help='Add a new subscription')
|
||||||
|
add_parser.add_argument('name', help='Custom name for the subscription')
|
||||||
|
add_parser.add_argument('url', help='Clash RSS subscription URL')
|
||||||
|
|
||||||
|
# Refresh subscription command
|
||||||
|
refresh_parser = subscription_subparsers.add_parser('refresh', help='Refresh a subscription')
|
||||||
|
refresh_parser.add_argument('name', help='Name of the subscription to refresh')
|
||||||
|
|
||||||
|
# Delete subscription command (rm)
|
||||||
|
delete_parser = subscription_subparsers.add_parser('rm', help='Delete a subscription')
|
||||||
|
delete_parser.add_argument('name', help='Name of the subscription to delete')
|
||||||
|
|
||||||
|
# Rename subscription command
|
||||||
|
rename_parser = subscription_subparsers.add_parser('rename', help='Rename a subscription')
|
||||||
|
rename_parser.add_argument('name', help='Current name of the subscription')
|
||||||
|
rename_parser.add_argument('new_name', help='New name for the subscription')
|
||||||
|
|
||||||
|
# Activate subscription command
|
||||||
|
activate_parser = subscription_subparsers.add_parser('activate', help='Activate a subscription')
|
||||||
|
activate_parser.add_argument('name', help='Name of the subscription to activate')
|
||||||
|
|
||||||
|
# List subscriptions command
|
||||||
|
list_parser = subscription_subparsers.add_parser('list', help='List all subscriptions')
|
||||||
|
|
||||||
|
# Storage info command
|
||||||
|
storage_parser = subscription_subparsers.add_parser('storage', help='Show storage information')
|
||||||
|
|
||||||
|
# Core config commands
|
||||||
|
core_config_parser = subparsers.add_parser('core-config', help='Manage core configuration')
|
||||||
|
core_config_subparsers = core_config_parser.add_subparsers(dest='core_config_command', help='Configuration operations')
|
||||||
|
|
||||||
|
# Import config
|
||||||
|
import_parser = core_config_subparsers.add_parser('import', help='Import configuration from file')
|
||||||
|
import_parser.add_argument('source', help='Path to configuration file to import')
|
||||||
|
|
||||||
|
# Export config
|
||||||
|
export_parser = core_config_subparsers.add_parser('export', help='Export configuration to file')
|
||||||
|
export_parser.add_argument('destination', help='Path to save configuration file')
|
||||||
|
|
||||||
|
# Edit config
|
||||||
|
edit_parser = core_config_subparsers.add_parser('edit', help='Edit configuration with system editor')
|
||||||
|
|
||||||
|
# Reset config
|
||||||
|
reset_parser = core_config_subparsers.add_parser('reset', help='Reset configuration to default values')
|
||||||
|
|
||||||
|
# Show config
|
||||||
|
show_parser = core_config_subparsers.add_parser('show', help='Show current configuration')
|
||||||
|
|
||||||
|
# Apply config
|
||||||
|
apply_parser = core_config_subparsers.add_parser('apply', help='Apply active subscription to generate final config')
|
||||||
|
|
||||||
|
# Upgrade mihomo binary
|
||||||
|
upgrade_parser = core_config_subparsers.add_parser('upgrade', help='Download and upgrade mihomo binary from GitHub releases')
|
||||||
|
upgrade_parser.add_argument('--version', help='Specific version to download (e.g., v1.18.5). If not specified, downloads latest')
|
||||||
|
upgrade_parser.add_argument('--force', action='store_true', help='Force download even if binary already exists')
|
||||||
|
|
||||||
|
# Core commands
|
||||||
|
core_parser = subparsers.add_parser('core', help='Manage scientific-surfing core components')
|
||||||
|
core_subparsers = core_parser.add_subparsers(dest='core_command', help='Core operations')
|
||||||
|
|
||||||
|
# Update core command
|
||||||
|
update_parser = core_subparsers.add_parser('update', help='Update scientific-surfing core components')
|
||||||
|
update_parser.add_argument('--version', help='Specific version to download (e.g., v1.18.5). If not specified, downloads latest')
|
||||||
|
update_parser.add_argument('--force', action='store_true', help='Force update even if binary already exists')
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main CLI entry point."""
|
||||||
|
parser = create_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.command == 'subscription':
|
||||||
|
if not hasattr(args, 'subcommand') or not args.subcommand:
|
||||||
|
parser.parse_args(['subscription', '--help'])
|
||||||
|
return
|
||||||
|
|
||||||
|
manager = SubscriptionManager()
|
||||||
|
if args.subcommand == 'add':
|
||||||
|
manager.add_subscription(args.name, args.url)
|
||||||
|
elif args.subcommand == 'refresh':
|
||||||
|
manager.refresh_subscription(args.name)
|
||||||
|
elif args.subcommand == 'rm':
|
||||||
|
manager.delete_subscription(args.name)
|
||||||
|
elif args.subcommand == 'rename':
|
||||||
|
manager.rename_subscription(args.name, args.new_name)
|
||||||
|
elif args.subcommand == 'activate':
|
||||||
|
manager.activate_subscription(args.name)
|
||||||
|
elif args.subcommand == 'list':
|
||||||
|
manager.list_subscriptions()
|
||||||
|
elif args.subcommand == 'storage':
|
||||||
|
manager.show_storage_info()
|
||||||
|
else:
|
||||||
|
parser.parse_args(['subscription', '--help'])
|
||||||
|
|
||||||
|
elif args.command == 'core-config':
|
||||||
|
if not hasattr(args, 'core_config_command') or not args.core_config_command:
|
||||||
|
parser.parse_args(['core-config', '--help'])
|
||||||
|
return
|
||||||
|
|
||||||
|
from scientific_surfing.corecfg_manager import CoreConfigManager
|
||||||
|
from scientific_surfing.core_manager import CoreManager
|
||||||
|
core_config_manager = CoreConfigManager()
|
||||||
|
core_manager = CoreManager(core_config_manager)
|
||||||
|
|
||||||
|
if args.core_config_command == 'import':
|
||||||
|
core_config_manager.import_config(args.source)
|
||||||
|
elif args.core_config_command == 'export':
|
||||||
|
core_config_manager.export_config(args.destination)
|
||||||
|
elif args.core_config_command == 'edit':
|
||||||
|
core_config_manager.edit_config()
|
||||||
|
elif args.core_config_command == 'reset':
|
||||||
|
core_config_manager.reset_config()
|
||||||
|
elif args.core_config_command == 'show':
|
||||||
|
core_config_manager.show_config()
|
||||||
|
elif args.core_config_command == 'apply':
|
||||||
|
core_config_manager.apply()
|
||||||
|
elif args.core_config_command == 'upgrade':
|
||||||
|
core_manager.update(version=args.version, force=args.force)
|
||||||
|
else:
|
||||||
|
parser.parse_args(['core-config', '--help'])
|
||||||
|
|
||||||
|
elif args.command == 'core':
|
||||||
|
if not hasattr(args, 'core_command') or not args.core_command:
|
||||||
|
parser.parse_args(['core', '--help'])
|
||||||
|
return
|
||||||
|
|
||||||
|
from scientific_surfing.corecfg_manager import CoreConfigManager
|
||||||
|
from scientific_surfing.core_manager import CoreManager
|
||||||
|
core_config_manager = CoreConfigManager()
|
||||||
|
core_manager = CoreManager(core_config_manager)
|
||||||
|
|
||||||
|
if args.core_command == 'update':
|
||||||
|
core_manager.update(version=args.version, force=args.force)
|
||||||
|
else:
|
||||||
|
parser.parse_args(['core', '--help'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n❌ Operation cancelled by user")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
435
scientific_surfing/core_manager.py
Normal file
435
scientific_surfing/core_manager.py
Normal 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
|
||||||
370
scientific_surfing/corecfg_manager.py
Normal file
370
scientific_surfing/corecfg_manager.py
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
"""
|
||||||
|
User configuration manager for scientific-surfing.
|
||||||
|
Handles user preferences with import, export, and edit operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from scientific_surfing.models import Config
|
||||||
|
from scientific_surfing.storage import StorageManager
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfigManager:
|
||||||
|
"""Manages user configuration with import, export, and edit operations."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.storage = StorageManager()
|
||||||
|
self.config_file = self.storage.config_dir / "core-config.yaml"
|
||||||
|
self.default_config_path = Path(__file__).parent / "templates" / "default-core-config.yaml"
|
||||||
|
|
||||||
|
def _ensure_config_exists(self) -> bool:
|
||||||
|
"""Ensure config.yaml exists, create from default if not."""
|
||||||
|
if not self.config_file.exists():
|
||||||
|
if self.default_config_path.exists():
|
||||||
|
self.storage.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(self.default_config_path, self.config_file)
|
||||||
|
print(f"✅ Created default config at: {self.config_file}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Default config template not found")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def load_config(self) -> dict:
|
||||||
|
"""Load configuration from YAML file."""
|
||||||
|
if not self.config_file.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return {}
|
||||||
|
except (yaml.YAMLError, IOError) as e:
|
||||||
|
print(f"Warning: Failed to load config: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_config(self, config: dict) -> bool:
|
||||||
|
"""Save configuration to YAML file."""
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||||
|
# Convert Pydantic model to dict for YAML serialization
|
||||||
|
data = config
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
return True
|
||||||
|
except (yaml.YAMLError, IOError, ValueError) as e:
|
||||||
|
print(f"Error: Failed to save config: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def import_config(self, source_path: str) -> bool:
|
||||||
|
"""Import configuration from a YAML file."""
|
||||||
|
source = Path(source_path)
|
||||||
|
if not source.exists():
|
||||||
|
print(f"❌ Source file not found: {source_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(source, 'r', encoding='utf-8') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
print("❌ Invalid YAML format")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Validate with Pydantic model
|
||||||
|
config = Config(**data)
|
||||||
|
|
||||||
|
# Save to user config
|
||||||
|
self.save_config(config)
|
||||||
|
print(f"✅ Imported configuration from: {source_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
print(f"❌ Invalid YAML: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to import: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def export_config(self, destination_path: str) -> bool:
|
||||||
|
"""Export current configuration to a YAML file."""
|
||||||
|
destination = Path(destination_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = self.load_config()
|
||||||
|
|
||||||
|
# Ensure destination directory exists
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Export as YAML
|
||||||
|
with open(destination, 'w', encoding='utf-8') as f:
|
||||||
|
data = config.dict()
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
print(f"✅ Exported configuration to: {destination_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to export: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def edit_config(self) -> bool:
|
||||||
|
"""Edit configuration using system default editor."""
|
||||||
|
if not self._ensure_config_exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get system editor
|
||||||
|
editor = os.environ.get('EDITOR') or os.environ.get('VISUAL')
|
||||||
|
if not editor:
|
||||||
|
# Try common editors
|
||||||
|
for cmd in ['code', 'subl', 'atom', 'vim', 'nano', 'notepad']:
|
||||||
|
if shutil.which(cmd):
|
||||||
|
editor = cmd
|
||||||
|
break
|
||||||
|
|
||||||
|
if not editor:
|
||||||
|
print("❌ No editor found. Please set EDITOR or VISUAL environment variable")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Backup current config
|
||||||
|
backup_path = self.config_file.with_suffix('.yaml.backup')
|
||||||
|
if self.config_file.exists():
|
||||||
|
shutil.copy2(self.config_file, backup_path)
|
||||||
|
|
||||||
|
# Open editor
|
||||||
|
subprocess.run([editor, str(self.config_file)], check=True)
|
||||||
|
|
||||||
|
# Validate edited config
|
||||||
|
try:
|
||||||
|
config = self.load_config()
|
||||||
|
print("✅ Configuration edited successfully")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
# Restore backup if validation fails
|
||||||
|
if backup_path.exists():
|
||||||
|
shutil.copy2(backup_path, self.config_file)
|
||||||
|
print(f"❌ Invalid configuration: {e}")
|
||||||
|
print("🔄 Restored previous configuration")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
print("❌ Editor command failed")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to edit configuration: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reset_config(self) -> bool:
|
||||||
|
"""Reset configuration to default values."""
|
||||||
|
if self.default_config_path.exists():
|
||||||
|
shutil.copy2(self.default_config_path, self.config_file)
|
||||||
|
print("✅ Configuration reset to default values")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("❌ Default config template not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def show_config(self) -> None:
|
||||||
|
"""Display current configuration."""
|
||||||
|
config = self.load_config()
|
||||||
|
print("⚙️ Current Configuration:")
|
||||||
|
print(f" Auto-refresh: {config.auto_refresh}")
|
||||||
|
print(f" Refresh interval: {config.refresh_interval_hours} hours")
|
||||||
|
print(f" User-Agent: {config.default_user_agent}")
|
||||||
|
print(f" Timeout: {config.timeout_seconds} seconds")
|
||||||
|
|
||||||
|
def get_config(self) -> Config:
|
||||||
|
"""Get current configuration."""
|
||||||
|
return self.load_config()
|
||||||
|
|
||||||
|
def update_config(self, **kwargs) -> bool:
|
||||||
|
"""Update specific configuration values."""
|
||||||
|
config = self.load_config()
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(config, key):
|
||||||
|
setattr(config, key, value)
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Unknown configuration key: {key}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.save_config(config)
|
||||||
|
|
||||||
|
def _execute_hook(self, hook_path: Path, config_file_path: Path) -> bool:
|
||||||
|
"""Execute a hook script with the generated config file path."""
|
||||||
|
if not hook_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine the interpreter based on file extension and platform
|
||||||
|
if hook_path.suffix.lower() == '.py':
|
||||||
|
cmd = [sys.executable, str(hook_path), str(config_file_path)]
|
||||||
|
elif hook_path.suffix.lower() == '.js':
|
||||||
|
cmd = ['node', str(hook_path), str(config_file_path)]
|
||||||
|
elif hook_path.suffix.lower() == '.nu':
|
||||||
|
cmd = ['nu', str(hook_path), str(config_file_path)]
|
||||||
|
else:
|
||||||
|
# On Unix-like systems, execute directly
|
||||||
|
if os.name != 'nt':
|
||||||
|
cmd = [str(hook_path), str(config_file_path)]
|
||||||
|
# Make sure the script is executable
|
||||||
|
os.chmod(hook_path, 0o755)
|
||||||
|
else:
|
||||||
|
# On Windows, try to execute directly (batch files, etc.)
|
||||||
|
cmd = [str(hook_path), str(config_file_path)]
|
||||||
|
|
||||||
|
print(f"🔧 Executing hook: {hook_path.name}")
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=hook_path.parent,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f"✅ Hook executed successfully: {hook_path.name}")
|
||||||
|
if result.stdout.strip():
|
||||||
|
print(f" Output: {result.stdout.strip()}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ Hook failed: {hook_path.name}")
|
||||||
|
if result.stderr.strip():
|
||||||
|
print(f" Error: {result.stderr.strip()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(f"⏰ Hook timed out: {hook_path.name}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to execute hook {hook_path.name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _execute_hooks(self, config_file_path: Path) -> None:
|
||||||
|
"""Execute all hooks in the hooks directory after config generation."""
|
||||||
|
hooks_dir = self.storage.config_dir / "hooks"
|
||||||
|
if not hooks_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Look for core_config_generated.* files
|
||||||
|
hook_pattern = "core_config_generated.*"
|
||||||
|
hook_files = list(hooks_dir.glob(hook_pattern))
|
||||||
|
|
||||||
|
if not hook_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"🔧 Found {len(hook_files)} hook(s) to execute")
|
||||||
|
|
||||||
|
# Sort hooks for consistent execution order
|
||||||
|
hook_files.sort()
|
||||||
|
|
||||||
|
for hook_file in hook_files:
|
||||||
|
self._execute_hook(hook_file, config_file_path)
|
||||||
|
|
||||||
|
def apply(self) -> bool:
|
||||||
|
"""Apply active subscription to generate final config file."""
|
||||||
|
from scientific_surfing.subscription_manager import SubscriptionManager
|
||||||
|
|
||||||
|
# Load current configuration
|
||||||
|
config = self.load_config()
|
||||||
|
|
||||||
|
# Load subscriptions to get active subscription
|
||||||
|
subscription_manager = SubscriptionManager()
|
||||||
|
active_subscription = subscription_manager.subscriptions_data.get_active_subscription()
|
||||||
|
|
||||||
|
if not active_subscription:
|
||||||
|
print("❌ No active subscription found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not active_subscription.file_path or not Path(active_subscription.file_path).exists():
|
||||||
|
print("❌ Active subscription file not found. Please refresh the subscription first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load the subscription content
|
||||||
|
with open(active_subscription.file_path, 'r', encoding='utf-8') as f:
|
||||||
|
subscription_content = f.read()
|
||||||
|
|
||||||
|
# Parse subscription YAML
|
||||||
|
subscription_data = yaml.safe_load(subscription_content)
|
||||||
|
if not isinstance(subscription_data, dict):
|
||||||
|
subscription_data = {}
|
||||||
|
|
||||||
|
# Create final config by merging subscription with user config
|
||||||
|
final_config = deep_merge(subscription_data, config)
|
||||||
|
external_ui = final_config.get("external-ui")
|
||||||
|
if external_ui:
|
||||||
|
final_config["external-ui"] = os.path.join(self.storage.config_dir, external_ui)
|
||||||
|
|
||||||
|
# Define essential defaults that should be present in any Clash config
|
||||||
|
essential_defaults = {
|
||||||
|
'port': 7890,
|
||||||
|
'socks-port': 7891,
|
||||||
|
'mixed-port': 7890,
|
||||||
|
'allow-lan': False,
|
||||||
|
'mode': 'rule',
|
||||||
|
'log-level': 'info',
|
||||||
|
'external-controller': '127.0.0.1:9090',
|
||||||
|
'ipv6': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add missing essential keys from subscription
|
||||||
|
for key, default_value in essential_defaults.items():
|
||||||
|
if key not in final_config:
|
||||||
|
final_config[key] = default_value
|
||||||
|
|
||||||
|
# Ensure basic DNS configuration exists if not provided by subscription
|
||||||
|
if 'dns' not in final_config:
|
||||||
|
final_config['dns'] = {
|
||||||
|
'enable': True,
|
||||||
|
'listen': '0.0.0.0:53',
|
||||||
|
'enhanced-mode': 'fake-ip',
|
||||||
|
'fake-ip-range': '198.18.0.1/16',
|
||||||
|
'nameserver': [
|
||||||
|
'https://doh.pub/dns-query',
|
||||||
|
'https://dns.alidns.com/dns-query'
|
||||||
|
],
|
||||||
|
'fallback': [
|
||||||
|
'https://1.1.1.1/dns-query',
|
||||||
|
'https://8.8.8.8/dns-query'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate final config file
|
||||||
|
generated_path = self.storage.config_dir / "generated_config.yaml"
|
||||||
|
|
||||||
|
with open(generated_path, 'w', encoding='utf-8') as f:
|
||||||
|
yaml.dump(final_config, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
print(f"✅ Generated final configuration: {generated_path}")
|
||||||
|
print(f" Active subscription: {active_subscription.name}")
|
||||||
|
print(f" Source file: {active_subscription.file_path}")
|
||||||
|
|
||||||
|
# Execute hooks after successful config generation
|
||||||
|
self._execute_hooks(generated_path)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
print(f"❌ Invalid YAML in subscription: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to apply configuration: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
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
|
||||||
107
scientific_surfing/models.py
Normal file
107
scientific_surfing/models.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
Pydantic models for scientific-surfing data structures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
|
||||||
|
class Subscription(BaseModel):
|
||||||
|
"""Model for a single subscription."""
|
||||||
|
|
||||||
|
name: str = Field(..., description="Name of the subscription")
|
||||||
|
url: str = Field(..., description="Clash RSS subscription URL")
|
||||||
|
status: str = Field(default="inactive", description="Status: active or inactive")
|
||||||
|
last_refresh: Optional[datetime] = Field(default=None, description="Last refresh timestamp")
|
||||||
|
file_path: Optional[str] = Field(default=None, description="Path to downloaded file")
|
||||||
|
file_size: Optional[int] = Field(default=None, description="Size of downloaded file in bytes")
|
||||||
|
status_code: Optional[int] = Field(default=None, description="HTTP status code of last refresh")
|
||||||
|
content_hash: Optional[int] = Field(default=None, description="Hash of downloaded content")
|
||||||
|
last_error: Optional[str] = Field(default=None, description="Last error message if any")
|
||||||
|
|
||||||
|
@validator('status')
|
||||||
|
def validate_status(cls, v):
|
||||||
|
if v not in ['active', 'inactive']:
|
||||||
|
raise ValueError('Status must be either "active" or "inactive"')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {
|
||||||
|
datetime: lambda v: v.isoformat() if v else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseModel):
|
||||||
|
"""Model for application configuration."""
|
||||||
|
|
||||||
|
auto_refresh: bool = Field(default=False, description="Auto-refresh subscriptions")
|
||||||
|
refresh_interval_hours: int = Field(default=24, description="Refresh interval in hours")
|
||||||
|
default_user_agent: str = Field(
|
||||||
|
default="scientific-surfing/0.1.0",
|
||||||
|
description="Default User-Agent for HTTP requests"
|
||||||
|
)
|
||||||
|
timeout_seconds: int = Field(default=30, description="HTTP request timeout in seconds")
|
||||||
|
|
||||||
|
@validator('refresh_interval_hours')
|
||||||
|
def validate_refresh_interval(cls, v):
|
||||||
|
if v < 1:
|
||||||
|
raise ValueError('Refresh interval must be at least 1 hour')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('timeout_seconds')
|
||||||
|
def validate_timeout(cls, v):
|
||||||
|
if v < 1:
|
||||||
|
raise ValueError('Timeout must be at least 1 second')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionsData(BaseModel):
|
||||||
|
"""Model for the entire subscriptions collection."""
|
||||||
|
|
||||||
|
subscriptions: Dict[str, Subscription] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
def get_active_subscription(self) -> Optional[Subscription]:
|
||||||
|
"""Get the currently active subscription."""
|
||||||
|
for subscription in self.subscriptions.values():
|
||||||
|
if subscription.status == 'active':
|
||||||
|
return subscription
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_active(self, name: str) -> bool:
|
||||||
|
"""Set a subscription as active and deactivate others."""
|
||||||
|
if name not in self.subscriptions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for sub_name, subscription in self.subscriptions.items():
|
||||||
|
subscription.status = 'active' if sub_name == name else 'inactive'
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_subscription(self, name: str, url: str) -> Subscription:
|
||||||
|
"""Add a new subscription."""
|
||||||
|
subscription = Subscription(name=name, url=url)
|
||||||
|
|
||||||
|
# If this is the first subscription, set it as active
|
||||||
|
if not self.subscriptions:
|
||||||
|
subscription.status = 'active'
|
||||||
|
|
||||||
|
self.subscriptions[name] = subscription
|
||||||
|
return subscription
|
||||||
|
|
||||||
|
def remove_subscription(self, name: str) -> bool:
|
||||||
|
"""Remove a subscription."""
|
||||||
|
if name not in self.subscriptions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
del self.subscriptions[name]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def rename_subscription(self, old_name: str, new_name: str) -> bool:
|
||||||
|
"""Rename a subscription."""
|
||||||
|
if old_name not in self.subscriptions or new_name in self.subscriptions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
subscription = self.subscriptions.pop(old_name)
|
||||||
|
subscription.name = new_name
|
||||||
|
self.subscriptions[new_name] = subscription
|
||||||
|
return True
|
||||||
114
scientific_surfing/storage.py
Normal file
114
scientific_surfing/storage.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Cross-platform data storage for scientific-surfing.
|
||||||
|
Handles configuration and subscription data storage using YAML format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from scientific_surfing.models import SubscriptionsData
|
||||||
|
|
||||||
|
|
||||||
|
class StorageManager:
|
||||||
|
"""Manages cross-platform data storage for subscriptions and configuration."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config_dir = self._get_config_dir()
|
||||||
|
self.config_file = self.config_dir / "config.yaml"
|
||||||
|
self.subscriptions_file = self.config_dir / "subscriptions.yaml"
|
||||||
|
self._ensure_config_dir()
|
||||||
|
|
||||||
|
def _get_config_dir(self) -> Path:
|
||||||
|
"""Get the appropriate configuration directory for the current platform."""
|
||||||
|
system = platform.system().lower()
|
||||||
|
|
||||||
|
if system == "windows":
|
||||||
|
# Windows: %APPDATA%/scientific_surfing
|
||||||
|
app_data = os.environ.get("APPDATA")
|
||||||
|
if app_data:
|
||||||
|
return Path(app_data) / "scientific_surfing"
|
||||||
|
else:
|
||||||
|
return Path.home() / "AppData" / "Roaming" / "scientific_surfing"
|
||||||
|
|
||||||
|
elif system == "darwin":
|
||||||
|
# macOS: ~/Library/Application Support/scientific_surfing
|
||||||
|
return Path.home() / "Library" / "Application Support" / "scientific_surfing"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Linux and other Unix-like systems: ~/.config/scientific_surfing
|
||||||
|
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if xdg_config_home:
|
||||||
|
return Path(xdg_config_home) / "scientific_surfing"
|
||||||
|
else:
|
||||||
|
return Path.home() / ".config" / "scientific_surfing"
|
||||||
|
|
||||||
|
def _ensure_config_dir(self) -> None:
|
||||||
|
"""Ensure the configuration directory exists."""
|
||||||
|
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def load_subscriptions(self) -> SubscriptionsData:
|
||||||
|
"""Load subscriptions from YAML file."""
|
||||||
|
if not self.subscriptions_file.exists():
|
||||||
|
return SubscriptionsData()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.subscriptions_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return SubscriptionsData(**data)
|
||||||
|
return SubscriptionsData()
|
||||||
|
except (yaml.YAMLError, IOError) as e:
|
||||||
|
print(f"Warning: Failed to load subscriptions: {e}")
|
||||||
|
return SubscriptionsData()
|
||||||
|
|
||||||
|
def save_subscriptions(self, subscriptions: SubscriptionsData) -> bool:
|
||||||
|
"""Save subscriptions to YAML file."""
|
||||||
|
try:
|
||||||
|
with open(self.subscriptions_file, 'w', encoding='utf-8') as f:
|
||||||
|
# Convert Pydantic model to dict for YAML serialization
|
||||||
|
data = subscriptions.dict()
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
return True
|
||||||
|
except (yaml.YAMLError, IOError, ValueError) as e:
|
||||||
|
print(f"Error: Failed to save subscriptions: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load_config(self) -> dict:
|
||||||
|
"""Load configuration from YAML file."""
|
||||||
|
if not self.config_file.exists():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
return {}
|
||||||
|
except (yaml.YAMLError, IOError) as e:
|
||||||
|
print(f"Warning: Failed to load config: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_config(self, config: dict) -> bool:
|
||||||
|
"""Save configuration to YAML file."""
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||||
|
# Convert Pydantic model to dict for YAML serialization
|
||||||
|
data = config
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
|
||||||
|
return True
|
||||||
|
except (yaml.YAMLError, IOError, ValueError) as e:
|
||||||
|
print(f"Error: Failed to save config: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_storage_info(self) -> Dict[str, str]:
|
||||||
|
"""Get information about the storage location."""
|
||||||
|
return {
|
||||||
|
'config_dir': str(self.config_dir),
|
||||||
|
'config_file': str(self.config_file),
|
||||||
|
'subscriptions_file': str(self.subscriptions_file),
|
||||||
|
'platform': platform.system(),
|
||||||
|
'exists': str(self.config_dir.exists())
|
||||||
|
}
|
||||||
161
scientific_surfing/subscription_manager.py
Normal file
161
scientific_surfing/subscription_manager.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Subscription management module for scientific-surfing.
|
||||||
|
Handles subscription operations with persistent storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from scientific_surfing.storage import StorageManager
|
||||||
|
from scientific_surfing.models import Subscription, SubscriptionsData, Config
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionManager:
|
||||||
|
"""Manages clash RSS subscriptions with persistent storage."""
|
||||||
|
storage: StorageManager = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.storage = StorageManager()
|
||||||
|
self.subscriptions_data = self.storage.load_subscriptions()
|
||||||
|
self.config = self.storage.load_config()
|
||||||
|
|
||||||
|
# Create subscriptions directory for storing downloaded files
|
||||||
|
self.subscriptions_dir = self.storage.config_dir / "subscriptions"
|
||||||
|
self.subscriptions_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def add_subscription(self, url: str, name: str) -> None:
|
||||||
|
"""Add a new subscription."""
|
||||||
|
subscription = self.subscriptions_data.add_subscription(name, url)
|
||||||
|
|
||||||
|
if self.storage.save_subscriptions(self.subscriptions_data):
|
||||||
|
self.refresh_subscription(name)
|
||||||
|
print(f"✅ Added subscription: {name} -> {url}")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to save subscription")
|
||||||
|
|
||||||
|
def refresh_subscription(self, name: str) -> None:
|
||||||
|
"""Refresh a subscription by downloading from URL."""
|
||||||
|
if name not in self.subscriptions_data.subscriptions:
|
||||||
|
print(f"❌ Subscription '{name}' not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
subscription = self.subscriptions_data.subscriptions[name]
|
||||||
|
url = subscription.url
|
||||||
|
|
||||||
|
print(f"🔄 Refreshing subscription: {name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download the subscription content
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.config.default_user_agent
|
||||||
|
}
|
||||||
|
timeout = self.config.timeout_seconds
|
||||||
|
|
||||||
|
response = requests.get(url, headers=headers, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# File path without timestamp
|
||||||
|
file_path = self.subscriptions_dir / f"{name}.yml"
|
||||||
|
|
||||||
|
# Handle existing file by renaming with creation date
|
||||||
|
if file_path.exists():
|
||||||
|
# Get creation time of existing file
|
||||||
|
stat = file_path.stat()
|
||||||
|
try:
|
||||||
|
# Try st_birthtime first (macOS/Unix)
|
||||||
|
creation_time = datetime.fromtimestamp(stat.st_birthtime)
|
||||||
|
except AttributeError:
|
||||||
|
# Fallback to st_ctime (Windows)
|
||||||
|
creation_time = datetime.fromtimestamp(stat.st_ctime)
|
||||||
|
|
||||||
|
backup_name = f"{name}_{creation_time.strftime('%Y%m%d_%H%M%S')}.yml"
|
||||||
|
backup_path = self.subscriptions_dir / backup_name
|
||||||
|
|
||||||
|
# Rename existing file
|
||||||
|
file_path.rename(backup_path)
|
||||||
|
print(f" 🔄 Backed up existing file to: {backup_name}")
|
||||||
|
|
||||||
|
# Save the new downloaded content
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(response.text)
|
||||||
|
|
||||||
|
# Update subscription metadata
|
||||||
|
subscription.last_refresh = datetime.now()
|
||||||
|
subscription.file_path = str(file_path)
|
||||||
|
subscription.file_size = len(response.text)
|
||||||
|
subscription.status_code = response.status_code
|
||||||
|
subscription.content_hash = hash(response.text)
|
||||||
|
subscription.last_error = None
|
||||||
|
|
||||||
|
if self.storage.save_subscriptions(self.subscriptions_data):
|
||||||
|
print(f"✅ Subscription '{name}' refreshed successfully")
|
||||||
|
print(f" 📁 Saved to: {file_path}")
|
||||||
|
print(f" 📊 Size: {len(response.text)} bytes")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to save subscription metadata")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"❌ Failed to download subscription: {e}")
|
||||||
|
subscription.last_error = str(e)
|
||||||
|
self.storage.save_subscriptions(self.subscriptions_data)
|
||||||
|
|
||||||
|
except IOError as e:
|
||||||
|
print(f"❌ Failed to save file: {e}")
|
||||||
|
subscription.last_error = str(e)
|
||||||
|
self.storage.save_subscriptions(self.subscriptions_data)
|
||||||
|
|
||||||
|
def delete_subscription(self, name: str) -> None:
|
||||||
|
"""Delete a subscription."""
|
||||||
|
if self.subscriptions_data.remove_subscription(name):
|
||||||
|
if self.storage.save_subscriptions(self.subscriptions_data):
|
||||||
|
print(f"🗑️ Deleted subscription: {name}")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to delete subscription")
|
||||||
|
else:
|
||||||
|
print(f"❌ Subscription '{name}' not found")
|
||||||
|
|
||||||
|
def rename_subscription(self, old_name: str, new_name: str) -> None:
|
||||||
|
"""Rename a subscription."""
|
||||||
|
if self.subscriptions_data.rename_subscription(old_name, new_name):
|
||||||
|
if self.storage.save_subscriptions(self.subscriptions_data):
|
||||||
|
print(f"✅ Renamed subscription: {old_name} -> {new_name}")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to rename subscription")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed to rename subscription: '{old_name}' not found or '{new_name}' already exists")
|
||||||
|
|
||||||
|
def activate_subscription(self, name: str) -> None:
|
||||||
|
"""Activate a subscription."""
|
||||||
|
if self.subscriptions_data.set_active(name):
|
||||||
|
if self.storage.save_subscriptions(self.subscriptions_data):
|
||||||
|
print(f"✅ Activated subscription: {name}")
|
||||||
|
else:
|
||||||
|
print("❌ Failed to activate subscription")
|
||||||
|
else:
|
||||||
|
print(f"❌ Subscription '{name}' not found")
|
||||||
|
|
||||||
|
def list_subscriptions(self) -> None:
|
||||||
|
"""List all subscriptions."""
|
||||||
|
if not self.subscriptions_data.subscriptions:
|
||||||
|
print("No subscriptions found")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("📋 Subscriptions:")
|
||||||
|
for name, subscription in self.subscriptions_data.subscriptions.items():
|
||||||
|
active_marker = "✅" if subscription.status == 'active' else " "
|
||||||
|
last_refresh_str = ""
|
||||||
|
if subscription.last_refresh:
|
||||||
|
last_refresh_str = f" (last: {subscription.last_refresh.strftime('%Y-%m-%d %H:%M:%S')})"
|
||||||
|
print(f" {active_marker} {name}: {subscription.url} ({subscription.status}){last_refresh_str}")
|
||||||
|
|
||||||
|
def show_storage_info(self) -> None:
|
||||||
|
"""Show storage information."""
|
||||||
|
info = self.storage.get_storage_info()
|
||||||
|
print("📁 Storage Information:")
|
||||||
|
print(f" Platform: {info['platform']}")
|
||||||
|
print(f" Config Directory: {info['config_dir']}")
|
||||||
|
print(f" Config File: {info['config_file']}")
|
||||||
|
print(f" Subscriptions File: {info['subscriptions_file']}")
|
||||||
|
print(f" Directory Exists: {info['exists']}")
|
||||||
359
scientific_surfing/templates/default-core-config.yaml
Normal file
359
scientific_surfing/templates/default-core-config.yaml
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
#unified-delay: true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# port: 7890 # HTTP(S) 代理服务器端口
|
||||||
|
# socks-port: 7891 # SOCKS5 代理端口
|
||||||
|
mixed-port: 7890 # HTTP(S) 和 SOCKS 代理混合端口
|
||||||
|
# redir-port: 7892 # 透明代理端口,用于 Linux 和 MacOS
|
||||||
|
|
||||||
|
# Transparent proxy server port for Linux (TProxy TCP and TProxy UDP)
|
||||||
|
# tproxy-port: 7893
|
||||||
|
|
||||||
|
allow-lan: false # 允许局域网连接
|
||||||
|
bind-address: "*" # 绑定 IP 地址,仅作用于 allow-lan 为 true,'*'表示所有地址
|
||||||
|
authentication: # http,socks 入口的验证用户名,密码
|
||||||
|
- "username:password"
|
||||||
|
skip-auth-prefixes: # 设置跳过验证的 IP 段
|
||||||
|
- 127.0.0.1/8
|
||||||
|
- ::1/128
|
||||||
|
lan-allowed-ips: # 允许连接的 IP 地址段,仅作用于 allow-lan 为 true, 默认值为 0.0.0.0/0 和::/0
|
||||||
|
- 0.0.0.0/0
|
||||||
|
- ::/0
|
||||||
|
lan-disallowed-ips: # 禁止连接的 IP 地址段,黑名单优先级高于白名单,默认值为空
|
||||||
|
- 192.168.0.3/32
|
||||||
|
|
||||||
|
# find-process-mode has 3 values:always, strict, off
|
||||||
|
# - always, 开启,强制匹配所有进程
|
||||||
|
# - strict, 默认,由 mihomo 判断是否开启
|
||||||
|
# - off, 不匹配进程,推荐在路由器上使用此模式
|
||||||
|
find-process-mode: strict
|
||||||
|
|
||||||
|
mode: rule
|
||||||
|
|
||||||
|
#自定义 geodata url
|
||||||
|
geox-url:
|
||||||
|
geoip: "https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat"
|
||||||
|
geosite: "https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat"
|
||||||
|
mmdb: "https://fastly.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb"
|
||||||
|
|
||||||
|
geo-auto-update: false # 是否自动更新 geodata
|
||||||
|
geo-update-interval: 24 # 更新间隔,单位:小时
|
||||||
|
|
||||||
|
# Matcher implementation used by GeoSite, available implementations:
|
||||||
|
# - succinct (default, same as rule-set)
|
||||||
|
# - mph (from V2Ray, also `hybrid` in Xray)
|
||||||
|
# geosite-matcher: succinct
|
||||||
|
|
||||||
|
log-level: debug # 日志等级 silent/error/warning/info/debug
|
||||||
|
|
||||||
|
ipv6: true # 开启 IPv6 总开关,关闭阻断所有 IPv6 链接和屏蔽 DNS 请求 AAAA 记录
|
||||||
|
|
||||||
|
tls:
|
||||||
|
certificate: string # 证书 PEM 格式,或者 证书的路径
|
||||||
|
private-key: string # 证书对应的私钥 PEM 格式,或者私钥路径
|
||||||
|
# 下面两项为mTLS配置项,如果client-auth-type设置为 "verify-if-given" 或 "require-and-verify" 则client-auth-cert必须不为空
|
||||||
|
# client-auth-type: "" # 可选值:""、"request"、"require-any"、"verify-if-given"、"require-and-verify"
|
||||||
|
# client-auth-cert: string # 证书 PEM 格式,或者 证书的路径
|
||||||
|
# 如果填写则开启ech(可由 mihomo generate ech-keypair <明文域名> 生成)
|
||||||
|
# ech-key: |
|
||||||
|
# -----BEGIN ECH KEYS-----
|
||||||
|
# ACATwY30o/RKgD6hgeQxwrSiApLaCgU+HKh7B6SUrAHaDwBD/g0APwAAIAAgHjzK
|
||||||
|
# madSJjYQIf9o1N5GXjkW4DEEeb17qMxHdwMdNnwADAABAAEAAQACAAEAAwAIdGVz
|
||||||
|
# dC5jb20AAA==
|
||||||
|
# -----END ECH KEYS-----
|
||||||
|
custom-certifactes:
|
||||||
|
- |
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
format/pem...
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
||||||
|
external-controller: 0.0.0.0:9097 # RESTful API 监听地址
|
||||||
|
external-controller-tls: 0.0.0.0:9443 # RESTful API HTTPS 监听地址,需要配置 tls 部分配置文件
|
||||||
|
# secret: "123456" # `Authorization:Bearer ${secret}`
|
||||||
|
secret: scientific_surfing-secret-dC5jb20AAA
|
||||||
|
|
||||||
|
# RESTful API CORS标头配置
|
||||||
|
|
||||||
|
external-controller-cors:
|
||||||
|
allow-origins:
|
||||||
|
- "*"
|
||||||
|
allow-private-network: true
|
||||||
|
allow-origins:
|
||||||
|
- tauri://localhost
|
||||||
|
- http://tauri.localhost
|
||||||
|
- https://yacd.metacubex.one
|
||||||
|
- https://metacubex.github.io
|
||||||
|
- https://board.zash.run.place
|
||||||
|
|
||||||
|
# RESTful API Unix socket 监听地址( windows版本大于17063也可以使用,即大于等于1803/RS4版本即可使用 )
|
||||||
|
# !!!注意: 从Unix socket访问api接口不会验证secret, 如果开启请自行保证安全问题 !!!
|
||||||
|
# 测试方法: curl -v --unix-socket "mihomo.sock" http://localhost/
|
||||||
|
external-controller-unix: mihomo.sock
|
||||||
|
|
||||||
|
# RESTful API Windows namedpipe 监听地址
|
||||||
|
# !!!注意: 从Windows namedpipe访问api接口不会验证secret, 如果开启请自行保证安全问题 !!!
|
||||||
|
# external-controller-pipe: \\.\pipe\mihomo
|
||||||
|
# external-controller-pipe: \\.\pipe\verge-mihomo
|
||||||
|
|
||||||
|
# tcp-concurrent: true # TCP 并发连接所有 IP, 将使用最快握手的 TCP
|
||||||
|
|
||||||
|
# 配置 WEB UI 目录,使用 http://{{external-controller}}/ui 访问
|
||||||
|
external-ui: /path/to/ui/folder/
|
||||||
|
external-ui-name: xd
|
||||||
|
# 目前支持下载zip,tgz格式的压缩包
|
||||||
|
external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
|
||||||
|
|
||||||
|
# 在RESTful API端口上开启DOH服务器
|
||||||
|
# !!!该URL不会验证secret, 如果开启请自行保证安全问题 !!!
|
||||||
|
external-doh-server: /dns-query
|
||||||
|
|
||||||
|
# interface-name: en0 # 设置出口网卡
|
||||||
|
|
||||||
|
# 全局 TLS 指纹,优先低于 proxy 内的 client-fingerprint
|
||||||
|
# 可选: "chrome","firefox","safari","ios","random","none" options.
|
||||||
|
# Utls is currently support TLS transport in TCP/grpc/WS/HTTP for VLESS/Vmess and trojan.
|
||||||
|
global-client-fingerprint: chrome
|
||||||
|
|
||||||
|
# TCP keep alive interval
|
||||||
|
# disable-keep-alive: false #目前在android端强制为true
|
||||||
|
# keep-alive-idle: 15
|
||||||
|
# keep-alive-interval: 15
|
||||||
|
|
||||||
|
# routing-mark:6666 # 配置 fwmark 仅用于 Linux
|
||||||
|
experimental:
|
||||||
|
# Disable quic-go GSO support. This may result in reduced performance on Linux.
|
||||||
|
# This is not recommended for most users.
|
||||||
|
# Only users encountering issues with quic-go's internal implementation should enable this,
|
||||||
|
# and they should disable it as soon as the issue is resolved.
|
||||||
|
# This field will be removed when quic-go fixes all their issues in GSO.
|
||||||
|
# This equivalent to the environment variable QUIC_GO_DISABLE_GSO=1.
|
||||||
|
#quic-go-disable-gso: true
|
||||||
|
|
||||||
|
# 类似于 /etc/hosts, 仅支持配置单个 IP
|
||||||
|
hosts:
|
||||||
|
# '*.mihomo.dev': 127.0.0.1
|
||||||
|
# '.dev': 127.0.0.1
|
||||||
|
# 'alpha.mihomo.dev': '::1'
|
||||||
|
# test.com: [1.1.1.1, 2.2.2.2]
|
||||||
|
# home.lan: lan # lan 为特别字段,将加入本地所有网卡的地址
|
||||||
|
# baidu.com: google.com # 只允许配置一个别名
|
||||||
|
|
||||||
|
profile: # 存储 select 选择记录
|
||||||
|
store-selected: false
|
||||||
|
|
||||||
|
# 持久化 fake-ip
|
||||||
|
store-fake-ip: true
|
||||||
|
|
||||||
|
# Tun 配置
|
||||||
|
tun:
|
||||||
|
enable: true
|
||||||
|
device: Mihomo
|
||||||
|
stack: mixed # gvisor/mixed
|
||||||
|
dns-hijack:
|
||||||
|
- any:53 # 需要劫持的 DNS
|
||||||
|
auto-detect-interface: true # 自动识别出口网卡
|
||||||
|
auto-route: true # 配置路由表
|
||||||
|
mtu: 1500 # 最大传输单元
|
||||||
|
# gso: false # 启用通用分段卸载,仅支持 Linux
|
||||||
|
# gso-max-size: 65536 # 通用分段卸载包的最大大小
|
||||||
|
auto-redirect: false # 自动配置 iptables 以重定向 TCP 连接。仅支持 Linux。带有 auto-redirect 的 auto-route 现在可以在路由器上按预期工作,无需干预。
|
||||||
|
strict-route: false # 将所有连接路由到 tun 来防止泄漏,但你的设备将无法其他设备被访问
|
||||||
|
# disable-icmp-forwarding: true # 禁用 ICMP 转发,防止某些情况下的 ICMP 环回问题,ping 将不会显示真实的延迟
|
||||||
|
# route-address-set: # 将指定规则集中的目标 IP CIDR 规则添加到防火墙, 不匹配的流量将绕过路由, 仅支持 Linux,且需要 nftables,`auto-route` 和 `auto-redirect` 已启用。
|
||||||
|
# - ruleset-1
|
||||||
|
# - ruleset-2
|
||||||
|
# route-exclude-address-set: # 将指定规则集中的目标 IP CIDR 规则添加到防火墙, 匹配的流量将绕过路由, 仅支持 Linux,且需要 nftables,`auto-route` 和 `auto-redirect` 已启用。
|
||||||
|
# - ruleset-3
|
||||||
|
# - ruleset-4
|
||||||
|
# route-address: # 启用 auto-route 时使用自定义路由而不是默认路由
|
||||||
|
# - 0.0.0.0/1
|
||||||
|
# - 128.0.0.0/1
|
||||||
|
# - "::/1"
|
||||||
|
# - "8000::/1"
|
||||||
|
# inet4-route-address: # 启用 auto-route 时使用自定义路由而不是默认路由(旧写法)
|
||||||
|
# - 0.0.0.0/1
|
||||||
|
# - 128.0.0.0/1
|
||||||
|
# inet6-route-address: # 启用 auto-route 时使用自定义路由而不是默认路由(旧写法)
|
||||||
|
# - "::/1"
|
||||||
|
# - "8000::/1"
|
||||||
|
# endpoint-independent-nat: false # 启用独立于端点的 NAT
|
||||||
|
# include-interface: # 限制被路由的接口。默认不限制,与 `exclude-interface` 冲突
|
||||||
|
# - "lan0"
|
||||||
|
# exclude-interface: # 排除路由的接口,与 `include-interface` 冲突
|
||||||
|
# - "lan1"
|
||||||
|
# include-uid: # UID 规则仅在 Linux 下被支持,并且需要 auto-route
|
||||||
|
# - 0
|
||||||
|
# include-uid-range: # 限制被路由的的用户范围
|
||||||
|
# - 1000:9999
|
||||||
|
# exclude-uid: # 排除路由的的用户
|
||||||
|
#- 1000
|
||||||
|
# exclude-uid-range: # 排除路由的的用户范围
|
||||||
|
# - 1000:9999
|
||||||
|
|
||||||
|
# Android 用户和应用规则仅在 Android 下被支持
|
||||||
|
# 并且需要 auto-route
|
||||||
|
|
||||||
|
# include-android-user: # 限制被路由的 Android 用户
|
||||||
|
# - 0
|
||||||
|
# - 10
|
||||||
|
# include-package: # 限制被路由的 Android 应用包名
|
||||||
|
# - com.android.chrome
|
||||||
|
# exclude-package: # 排除被路由的 Android 应用包名
|
||||||
|
# - com.android.captiveportallogin
|
||||||
|
|
||||||
|
# 嗅探域名 可选配置
|
||||||
|
sniffer:
|
||||||
|
enable: false
|
||||||
|
## 对 redir-host 类型识别的流量进行强制嗅探
|
||||||
|
## 如:Tun、Redir 和 TProxy 并 DNS 为 redir-host 皆属于
|
||||||
|
# force-dns-mapping: false
|
||||||
|
## 对所有未获取到域名的流量进行强制嗅探
|
||||||
|
# parse-pure-ip: false
|
||||||
|
# 是否使用嗅探结果作为实际访问,默认 true
|
||||||
|
# 全局配置,优先级低于 sniffer.sniff 实际配置
|
||||||
|
override-destination: false
|
||||||
|
sniff: # TLS 和 QUIC 默认如果不配置 ports 默认嗅探 443
|
||||||
|
QUIC:
|
||||||
|
# ports: [ 443 ]
|
||||||
|
TLS:
|
||||||
|
# ports: [443, 8443]
|
||||||
|
|
||||||
|
# 默认嗅探 80
|
||||||
|
HTTP: # 需要嗅探的端口
|
||||||
|
ports: [80, 8080-8880]
|
||||||
|
# 可覆盖 sniffer.override-destination
|
||||||
|
override-destination: true
|
||||||
|
force-domain:
|
||||||
|
- +.v2ex.com
|
||||||
|
# skip-src-address: # 对于来源ip跳过嗅探
|
||||||
|
# - 192.168.0.3/32
|
||||||
|
# skip-dst-address: # 对于目标ip跳过嗅探
|
||||||
|
# - 192.168.0.3/32
|
||||||
|
## 对嗅探结果进行跳过
|
||||||
|
# skip-domain:
|
||||||
|
# - Mijia Cloud
|
||||||
|
# 需要嗅探协议
|
||||||
|
# 已废弃,若 sniffer.sniff 配置则此项无效
|
||||||
|
sniffing:
|
||||||
|
- tls
|
||||||
|
- http
|
||||||
|
# 强制对此域名进行嗅探
|
||||||
|
|
||||||
|
# 仅对白名单中的端口进行嗅探,默认为 443,80
|
||||||
|
# 已废弃,若 sniffer.sniff 配置则此项无效
|
||||||
|
port-whitelist:
|
||||||
|
- "80"
|
||||||
|
- "443"
|
||||||
|
# - 8000-9999
|
||||||
|
|
||||||
|
tunnels: # one line config
|
||||||
|
- tcp/udp,127.0.0.1:6553,114.114.114.114:53,proxy
|
||||||
|
- tcp,127.0.0.1:6666,rds.mysql.com:3306,vpn
|
||||||
|
# full yaml config
|
||||||
|
- network: [tcp, udp]
|
||||||
|
address: 127.0.0.1:7777
|
||||||
|
target: target.com
|
||||||
|
proxy: proxy
|
||||||
|
|
||||||
|
# DNS 配置
|
||||||
|
dns:
|
||||||
|
cache-algorithm: arc
|
||||||
|
enable: false # 关闭将使用系统 DNS
|
||||||
|
prefer-h3: false # 是否开启 DoH 支持 HTTP/3,将并发尝试
|
||||||
|
listen: 0.0.0.0:53 # 开启 DNS 服务器监听
|
||||||
|
# ipv6: false # false 将返回 AAAA 的空结果
|
||||||
|
# ipv6-timeout: 300 # 单位:ms,内部双栈并发时,向上游查询 AAAA 时,等待 AAAA 的时间,默认 100ms
|
||||||
|
# 用于解析 nameserver,fallback 以及其他 DNS 服务器配置的,DNS 服务域名
|
||||||
|
# 只能使用纯 IP 地址,可使用加密 DNS
|
||||||
|
default-nameserver:
|
||||||
|
- 114.114.114.114
|
||||||
|
- 8.8.8.8
|
||||||
|
- tls://1.12.12.12:853
|
||||||
|
- tls://223.5.5.5:853
|
||||||
|
- system # append DNS server from system configuration. If not found, it would print an error log and skip.
|
||||||
|
enhanced-mode: fake-ip # or redir-host
|
||||||
|
|
||||||
|
fake-ip-range: 198.18.0.1/16 # fake-ip 池设置
|
||||||
|
|
||||||
|
# 配置不使用 fake-ip 的域名
|
||||||
|
fake-ip-filter:
|
||||||
|
- '*.lan'
|
||||||
|
- localhost.ptlogin2.qq.com
|
||||||
|
# fakeip-filter 为 rule-providers 中的名为 fakeip-filter 规则订阅,
|
||||||
|
# 且 behavior 必须为 domain/classical,当为 classical 时仅会生效域名类规则
|
||||||
|
- rule-set:fakeip-filter
|
||||||
|
# fakeip-filter 为 geosite 中名为 fakeip-filter 的分类(需要自行保证该分类存在)
|
||||||
|
- geosite:fakeip-filter
|
||||||
|
# 配置fake-ip-filter的匹配模式,默认为blacklist,即如果匹配成功不返回fake-ip
|
||||||
|
# 可设置为whitelist,即只有匹配成功才返回fake-ip
|
||||||
|
fake-ip-filter-mode: blacklist
|
||||||
|
|
||||||
|
# use-hosts: true # 查询 hosts
|
||||||
|
|
||||||
|
# 配置后面的nameserver、fallback和nameserver-policy向dns服务器的连接过程是否遵守遵守rules规则
|
||||||
|
# 如果为false(默认值)则这三部分的dns服务器在未特别指定的情况下会直连
|
||||||
|
# 如果为true,将会按照rules的规则匹配链接方式(走代理或直连),如果有特别指定则任然以指定值为准
|
||||||
|
# 仅当proxy-server-nameserver非空时可以开启此选项, 强烈不建议和prefer-h3一起使用
|
||||||
|
# 此外,这三者配置中的dns服务器如果出现域名会采用default-nameserver配置项解析,也请确保正确配置default-nameserver
|
||||||
|
respect-rules: false
|
||||||
|
|
||||||
|
# DNS 主要域名配置
|
||||||
|
# 支持 UDP,TCP,DoT,DoH,DoQ
|
||||||
|
# 这部分为主要 DNS 配置,影响所有直连,确保使用对大陆解析精准的 DNS
|
||||||
|
nameserver:
|
||||||
|
- 114.114.114.114 # default value
|
||||||
|
- 8.8.8.8 # default value
|
||||||
|
- tls://223.5.5.5:853 # DNS over TLS
|
||||||
|
- https://doh.pub/dns-query # DNS over HTTPS
|
||||||
|
- https://dns.alidns.com/dns-query#h3=true # 强制 HTTP/3,与 perfer-h3 无关,强制开启 DoH 的 HTTP/3 支持,若不支持将无法使用
|
||||||
|
- https://mozilla.cloudflare-dns.com/dns-query#DNS&h3=true # 指定策略组和使用 HTTP/3
|
||||||
|
- dhcp://en0 # dns from dhcp
|
||||||
|
- quic://dns.adguard.com:784 # DNS over QUIC
|
||||||
|
# - '8.8.8.8#RULES' # 效果同respect-rules,但仅对该服务器生效
|
||||||
|
# - '8.8.8.8#en0' # 兼容指定 DNS 出口网卡
|
||||||
|
|
||||||
|
# 当配置 fallback 时,会查询 nameserver 中返回的 IP 是否为 CN,非必要配置
|
||||||
|
# 当不是 CN,则使用 fallback 中的 DNS 查询结果
|
||||||
|
# 确保配置 fallback 时能够正常查询
|
||||||
|
# fallback:
|
||||||
|
# - tcp://1.1.1.1
|
||||||
|
# - 'tcp://1.1.1.1#ProxyGroupName' # 指定 DNS 过代理查询,ProxyGroupName 为策略组名或节点名,过代理配置优先于配置出口网卡,当找不到策略组或节点名则设置为出口网卡
|
||||||
|
|
||||||
|
# 专用于节点域名解析的 DNS 服务器,非必要配置项,如果不填则遵循nameserver-policy、nameserver和fallback的配置
|
||||||
|
# proxy-server-nameserver:
|
||||||
|
# - https://dns.google/dns-query
|
||||||
|
# - tls://one.one.one.one
|
||||||
|
|
||||||
|
# 专用于direct出口域名解析的 DNS 服务器,非必要配置项,如果不填则遵循nameserver-policy、nameserver和fallback的配置
|
||||||
|
# direct-nameserver:
|
||||||
|
# - system://
|
||||||
|
# direct-nameserver-follow-policy: false # 是否遵循nameserver-policy,默认为不遵守,仅当direct-nameserver不为空时生效
|
||||||
|
|
||||||
|
# 配置 fallback 使用条件
|
||||||
|
# fallback-filter:
|
||||||
|
# geoip: true # 配置是否使用 geoip
|
||||||
|
# geoip-code: CN # 当 nameserver 域名的 IP 查询 geoip 库为 CN 时,不使用 fallback 中的 DNS 查询结果
|
||||||
|
# 配置强制 fallback,优先于 IP 判断,具体分类自行查看 geosite 库
|
||||||
|
# geosite:
|
||||||
|
# - gfw
|
||||||
|
# 如果不匹配 ipcidr 则使用 nameservers 中的结果
|
||||||
|
# ipcidr:
|
||||||
|
# - 240.0.0.0/4
|
||||||
|
# domain:
|
||||||
|
# - '+.google.com'
|
||||||
|
# - '+.facebook.com'
|
||||||
|
# - '+.youtube.com'
|
||||||
|
|
||||||
|
# 配置查询域名使用的 DNS 服务器
|
||||||
|
nameserver-policy:
|
||||||
|
# 'www.baidu.com': '114.114.114.114'
|
||||||
|
# '+.internal.crop.com': '10.0.0.1'
|
||||||
|
"geosite:cn,private,apple":
|
||||||
|
- https://doh.pub/dns-query
|
||||||
|
- https://dns.alidns.com/dns-query
|
||||||
|
"geosite:category-ads-all": rcode://success
|
||||||
|
"www.baidu.com,+.google.cn": [223.5.5.5, https://dns.alidns.com/dns-query]
|
||||||
|
## global,dns 为 rule-providers 中的名为 global 和 dns 规则订阅,
|
||||||
|
## 且 behavior 必须为 domain/classical,当为 classical 时仅会生效域名类规则
|
||||||
|
# "rule-set:global,dns": 8.8.8.8
|
||||||
33
scientific_surfing/templates/hooks/core_config_generated.js
Normal file
33
scientific_surfing/templates/hooks/core_config_generated.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test hook script for core config generation (Node.js version)
|
||||||
|
* This script will be executed after the configuration is generated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (process.argv.length < 3) {
|
||||||
|
console.error('Error: No config file path provided');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = process.argv[2];
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
console.error(`Error: Config file not found: ${configPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(configPath);
|
||||||
|
console.log(`Node.js hook executed successfully! Config file: ${configPath}`);
|
||||||
|
console.log(`File size: ${stats.size} bytes`);
|
||||||
|
|
||||||
|
// You can add custom processing here
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
31
scientific_surfing/templates/hooks/core_config_generated.py
Normal file
31
scientific_surfing/templates/hooks/core_config_generated.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test hook script for core config generation.
|
||||||
|
This script will be executed after the configuration is generated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Error: No config file path provided")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config_path = Path(sys.argv[1])
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
print(f"Error: Config file not found: {config_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Hook executed successfully! Config file: {config_path}")
|
||||||
|
print(f"File size: {config_path.stat().st_size} bytes")
|
||||||
|
|
||||||
|
# You can add custom processing here
|
||||||
|
# For example, copy the file to another location, modify it, etc.
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
89
test_upgrade.py
Normal file
89
test_upgrade.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for the upgrade method in corecfg_manager.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from scientific_surfing.corecfg_manager import CoreConfigManager
|
||||||
|
|
||||||
|
def test_upgrade():
|
||||||
|
"""Test the upgrade method functionality."""
|
||||||
|
manager = CoreConfigManager()
|
||||||
|
|
||||||
|
print("Testing upgrade method...")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Test 1: Check if upgrade method exists
|
||||||
|
if hasattr(manager, 'upgrade'):
|
||||||
|
print("[OK] upgrade method exists")
|
||||||
|
else:
|
||||||
|
print("[FAIL] upgrade method not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 2: Test OS detection (without actually downloading)
|
||||||
|
import platform
|
||||||
|
system = platform.system().lower()
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
|
||||||
|
print(f"Detected OS: {system}")
|
||||||
|
print(f"Detected Architecture: {machine}")
|
||||||
|
|
||||||
|
# Test 3: Test platform mapping
|
||||||
|
platform_map = {
|
||||||
|
'windows': {
|
||||||
|
'amd64': 'mihomo-windows-amd64.exe',
|
||||||
|
'386': 'mihomo-windows-386.exe',
|
||||||
|
'arm64': 'mihomo-windows-arm64.exe',
|
||||||
|
'arm': 'mihomo-windows-arm32v7.exe'
|
||||||
|
},
|
||||||
|
'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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if system in platform_map and normalized_arch in platform_map[system]:
|
||||||
|
binary_name = platform_map[system][normalized_arch]
|
||||||
|
print(f"[OK] Would download: {binary_name}")
|
||||||
|
else:
|
||||||
|
print(f"[FAIL] Unsupported platform: {system}/{normalized_arch}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Test 4: Test directory creation
|
||||||
|
from scientific_surfing.storage import StorageManager
|
||||||
|
storage = StorageManager()
|
||||||
|
binary_dir = storage.config_dir / "bin"
|
||||||
|
print(f"Binary directory: {binary_dir}")
|
||||||
|
|
||||||
|
print("\n[OK] All tests passed! The upgrade method is ready to use.")
|
||||||
|
print("\nUsage examples:")
|
||||||
|
print(" manager.upgrade() # Download latest version")
|
||||||
|
print(" manager.upgrade(version='v1.18.5') # Download specific version")
|
||||||
|
print(" manager.upgrade(force=True) # Force re-download")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_upgrade()
|
||||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Test package for scientific-surfing
|
||||||
Reference in New Issue
Block a user