From d5955bbbddae61516fc839322c298eaf627f811c Mon Sep 17 00:00:00 2001 From: HeXiangLong <3234374354@qq.com> Date: Mon, 10 Nov 2025 17:28:10 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=A1=AB=E5=85=85=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/__init__.py | 21 +++++ core/proxy_manager.py | 76 +++++++++++++++++ core/update_manager.py | 115 +++++++++++++++++++++++++ core/v2ray_manager.py | 64 ++++++++++++++ main.py | 57 +++++++++++++ requirements.txt | 5 ++ ui/__init__.py | 23 +++++ ui/main_window.py | 185 +++++++++++++++++++++++++++++++++++++++++ ui/setup_dialog.py | 85 +++++++++++++++++++ ui/traffic_widget.py | 163 ++++++++++++++++++++++++++++++++++++ ui/update_dialog.py | 143 +++++++++++++++++++++++++++++++ update_helper.py | 63 ++++++++++++++ utils/__init__.py | 56 +++++++++++++ utils/config_utils.py | 161 +++++++++++++++++++++++++++++++++++ utils/network_utils.py | 86 +++++++++++++++++++ utils/system_utils.py | 48 +++++++++++ 16 files changed, 1351 insertions(+) diff --git a/core/__init__.py b/core/__init__.py index e69de29..e839163 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -0,0 +1,21 @@ +""" +EZProxy Core Module +================== + +核心功能模块,包含v2ray管理、代理管理和更新管理 +""" + +# 导入核心组件 +from .v2ray_manager import V2RayManager +from .proxy_manager import ProxyManager +from .update_manager import UpdateManager + +# 定义包的公开接口 +__all__ = [ + 'V2RayManager', + 'ProxyManager', + 'UpdateManager' +] + +# 包版本信息 +__version__ = "1.0.0" \ No newline at end of file diff --git a/core/proxy_manager.py b/core/proxy_manager.py index e69de29..af7900a 100644 --- a/core/proxy_manager.py +++ b/core/proxy_manager.py @@ -0,0 +1,76 @@ +import os +import sys +import logging +import subprocess +from pathlib import Path +from utils.system_utils import get_architecture + +class ProxyManager: + def __init__(self): + self.is_enabled = False + self.http_proxy = "127.0.0.1:1081" + self.socks_proxy = "127.0.0.1:1080" + + def enable(self): + """启用系统代理""" + if self.is_enabled: + return True + + try: + # 获取当前网络服务 + service_cmd = "networksetup -listnetworkserviceorder | grep $(route -n get default | grep 'interface' | awk '{print $2}') -A1 | grep -o '[^ ]*$'" + service = subprocess.check_output(service_cmd, shell=True, text=True).strip() + + # 设置HTTP/HTTPS代理 + http_cmd = f"networksetup -setwebproxy '{service}' {self.http_proxy.split(':')[0]} {self.http_proxy.split(':')[1]}" + https_cmd = f"networksetup -setsecurewebproxy '{service}' {self.http_proxy.split(':')[0]} {self.http_proxy.split(':')[1]}" + + # 设置SOCKS代理 + socks_cmd = f"networksetup -setsocksfirewallproxy '{service}' {self.socks_proxy.split(':')[0]} {self.socks_proxy.split(':')[1]}" + + # 使用AppleScript请求管理员权限 + script = f""" + do shell script "{http_cmd}" with administrator privileges + do shell script "{https_cmd}" with administrator privileges + do shell script "{socks_cmd}" with administrator privileges + do shell script "networksetup -setwebproxystate '{service}' on" with administrator privileges + do shell script "networksetup -setsecurewebproxystate '{service}' on" with administrator privileges + do shell script "networksetup -setsocksfirewallproxystate '{service}' on" with administrator privileges + """ + + subprocess.run(['osascript', '-e', script], check=True) + self.is_enabled = True + logging.info("System proxy enabled successfully") + return True + except Exception as e: + logging.error(f"Failed to enable system proxy: {str(e)}") + return False + + def disable(self): + """禁用系统代理""" + if not self.is_enabled: + return True + + try: + # 获取当前网络服务 + service_cmd = "networksetup -listnetworkserviceorder | grep $(route -n get default | grep 'interface' | awk '{print $2}') -A1 | grep -o '[^ ]*$'" + service = subprocess.check_output(service_cmd, shell=True, text=True).strip() + + # 使用AppleScript请求管理员权限 + script = f""" + do shell script "networksetup -setwebproxystate '{service}' off" with administrator privileges + do shell script "networksetup -setsecurewebproxystate '{service}' off" with administrator privileges + do shell script "networksetup -setsocksfirewallproxystate '{service}' off" with administrator privileges + """ + + subprocess.run(['osascript', '-e', script], check=True) + self.is_enabled = False + logging.info("System proxy disabled successfully") + return True + except Exception as e: + logging.error(f"Failed to disable system proxy: {str(e)}") + return False + + def get_proxy_status(self): + """获取代理状态""" + return self.is_enabled \ No newline at end of file diff --git a/core/update_manager.py b/core/update_manager.py index e69de29..afd88cf 100644 --- a/core/update_manager.py +++ b/core/update_manager.py @@ -0,0 +1,115 @@ +import yaml +import requests +import zipfile +from pathlib import Path +from utils.config_utils import load_config, save_config + +class UpdateManager: + UPDATE_URL = "https://o.nmgjg.com.cn/EZProxy/update.yaml" + CONFIG_URL = "https://o.nmgjg.com.cn/EZProxy/v2ray.json" + APP_URL = "https://o.nmgjg.com.cn/EZProxy/main.zip" + + def __init__(self, app_dir): + self.app_dir = Path(app_dir) + self.local_update_yaml = self.app_dir / "conf" / "update.yaml" + self.config = load_config() + + def check_updates(self): + """检查更新""" + try: + response = requests.get(self.UPDATE_URL, timeout=10) + response.raise_for_status() + remote = yaml.safe_load(response.text) + + # 检查配置更新 + config_update = self._check_config_update(remote) + + # 检查应用更新 + app_update = self._check_app_update(remote) + + return { + 'config': config_update, + 'app': app_update, + 'changelog_url': remote.get('changelog_url', '') + } + except Exception as e: + logging.error(f"检查更新失败: {str(e)}") + return None + + def _check_config_update(self, remote): + """检查配置模板更新""" + local_version = self.config.get('config_version', '0.0') + remote_version = remote.get('config_version', '0.0') + + if remote_version != local_version: + return { + 'available': True, + 'version': remote_version, + 'force': remote.get('force_config_update', False) + } + return {'available': False} + + def update_config(self): + """更新配置模板""" + try: + response = requests.get(self.CONFIG_URL, timeout=15) + response.raise_for_status() + + template_path = self.app_dir / "conf" / "v2ray_template.json" + with open(template_path, 'w') as f: + f.write(response.text) + + # 更新本地版本号 + self.config['config_version'] = self._get_remote_version()['config_version'] + save_config(self.config) + return True + except Exception as e: + logging.error(f"更新配置失败: {str(e)}") + return False + + def _prepare_update_helper(self): + """准备更新助手""" + helper_path = self.app_dir / "update_helper.py" + temp_dir = get_temp_dir() + temp_dir.mkdir(parents=True, exist_ok=True) + + # 复制更新助手到临时目录 + temp_helper = temp_dir / "update_helper.py" + with open(helper_path, 'r') as src, open(temp_helper, 'w') as dst: + dst.write(src.read()) + + # 设置执行权限 + temp_helper.chmod(0o755) + return temp_helper + + def start_app_update(self): + """启动应用更新流程""" + temp_dir = get_temp_dir() + zip_path = temp_dir / "main.zip" + + try: + # 下载更新包 + response = requests.get(self.APP_URL, stream=True, timeout=30) + response.raise_for_status() + + with open(zip_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + # 准备更新助手 + helper = self._prepare_update_helper() + + # 启动更新助手(在新进程中) + import subprocess + subprocess.Popen([ + sys.executable, + str(helper), + str(zip_path), + str(self.app_dir), + str(temp_dir) + ]) + + return True + except Exception as e: + logging.error(f"更新准备失败: {str(e)}") + return False \ No newline at end of file diff --git a/core/v2ray_manager.py b/core/v2ray_manager.py index e69de29..aa295a5 100644 --- a/core/v2ray_manager.py +++ b/core/v2ray_manager.py @@ -0,0 +1,64 @@ +import json +import logging +import subprocess +from pathlib import Path +from threading import Thread +from queue import Queue + +class V2RayManager: + def __init__(self, config_path, log_path): + self.config_path = Path(config_path) + self.log_path = Path(log_path) + self.process = None + self.log_queue = Queue() + self.running = False + + def start(self): + """启动v2ray核心""" + if self.running: + return True + + v2ray_bin = get_v2ray_binary_path() + cmd = [str(v2ray_bin), "-config", str(self.config_path)] + + try: + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True + ) + self.running = True + + # 启动日志监听线程 + Thread(target=self._log_reader, daemon=True).start() + return True + except Exception as e: + logging.error(f"启动v2ray失败: {str(e)}") + return False + + def stop(self): + """停止v2ray核心""" + if not self.running: + return + + if self.process: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + + self.running = False + + def _log_reader(self): + """读取v2ray日志""" + while self.running and self.process: + line = self.process.stdout.readline() + if not line: + break + self.log_queue.put(line.strip()) + with open(self.log_path, 'a') as f: + f.write(line) + + self.running = False \ No newline at end of file diff --git a/main.py b/main.py index e69de29..6318167 100644 --- a/main.py +++ b/main.py @@ -0,0 +1,57 @@ +import sys +import os +import logging +from pathlib import Path +from PyQt5.QtWidgets import QApplication +from ui.main_window import MainWindow +from utils.config_utils import init_config, load_config + +class AppContext: + def __init__(self): + self.app_dir = Path(__file__).parent.resolve() + self.conf_dir = self.app_dir / "conf" + self.log_dir = self.app_dir / "logs" + self.resources = self.app_dir / "resources" + + # 初始化目录 + self.conf_dir.mkdir(exist_ok=True) + self.log_dir.mkdir(exist_ok=True) + + # 初始化配置 + init_config(self.conf_dir) + self.config = load_config() + + # 配置日志 + logging.basicConfig( + filename=self.log_dir / "app.log", + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + def validate_config(self): + """验证配置有效性""" + return bool(self.config.get('proxy_url')) + +if __name__ == "__main__": + # 设置macOS应用属性 + if sys.platform == "darwin": + from Foundation import NSBundle + bundle = NSBundle.mainBundle() + info = bundle.localizedInfoDictionary() or bundle.infoDictionary() + info["CFBundleName"] = "EZProxy" + info["CFBundleIconFile"] = "icon.icns" + + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + context = AppContext() + window = MainWindow(context) + + # 首次运行检查代理配置 + if not context.validate_config(): + from ui.setup_dialog import SetupDialog + setup = SetupDialog(context) + if setup.exec_() != QDialog.Accepted: + sys.exit(0) + + sys.exit(app.exec_()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29..6e0159b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt5==5.15.9 +PyYAML==6.0.1 +requests==2.31.0 +psutil==5.9.5 +pyobjc==9.2 # 用于macOS系统集成 diff --git a/ui/__init__.py b/ui/__init__.py index e69de29..0fba8eb 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -0,0 +1,23 @@ +""" +EZProxy UI Module +================ + +用户界面模块,提供所有GUI组件 +""" + +# 导入UI组件 +from .main_window import MainWindow +from .setup_dialog import SetupDialog +from .traffic_widget import TrafficWidget +from .update_dialog import UpdateDialog + +# 定义包的公开接口 +__all__ = [ + 'MainWindow', + 'SetupDialog', + 'TrafficWidget', + 'UpdateDialog' +] + +# 包版本信息 +__version__ = "1.0.0" \ No newline at end of file diff --git a/ui/main_window.py b/ui/main_window.py index e69de29..fc6d08a 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -0,0 +1,185 @@ +from PyQt5.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QTextEdit, QLabel, QSplitter, + QMessageBox, QSystemTrayIcon, QMenu +) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QIcon, QTextCursor +from .traffic_widget import TrafficWidget +from core.v2ray_manager import V2RayManager +from core.proxy_manager import ProxyManager + +class MainWindow(QMainWindow): + def __init__(self, app_context): + super().__init__() + self.app_context = app_context + self.v2ray = V2RayManager( + app_context.config['v2ray_config_path'], + app_context.config['v2ray_log_path'] + ) + self.proxy = ProxyManager() + self.tray_icon = None + + self.init_ui() + self.init_tray() + self.init_timers() + + def init_ui(self): + # 主窗口设置 + self.setWindowTitle("EZProxy") + self.setWindowIcon(QIcon(str(self.app_context.resources / "icon.icns"))) + self.setMinimumSize(800, 600) + + # 创建中央部件 + central_widget = QWidget() + main_layout = QVBoxLayout(central_widget) + + # 顶部控制栏 + control_layout = QHBoxLayout() + self.toggle_btn = QPushButton("启用代理") + self.toggle_btn.clicked.connect(self.toggle_proxy) + self.update_btn = QPushButton("检查更新") + self.update_btn.clicked.connect(self.check_updates) + control_layout.addWidget(self.toggle_btn) + control_layout.addWidget(self.update_btn) + control_layout.addStretch() + + # 日志显示区域 + self.log_view = QTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setFontFamily("Menlo") + + # 流量监控组件 + self.traffic_widget = TrafficWidget() + + # 创建分割器 + splitter = QSplitter(Qt.Vertical) + splitter.addWidget(self.log_view) + splitter.addWidget(self.traffic_widget) + splitter.setSizes([400, 200]) + + # 添加到主布局 + main_layout.addLayout(control_layout) + main_layout.addWidget(splitter) + + self.setCentralWidget(central_widget) + + def init_tray(self): + """初始化系统托盘""" + self.tray_icon = QSystemTrayIcon(self) + self.tray_icon.setIcon(QIcon(str(self.app_context.resources / "icon.icns"))) + + tray_menu = QMenu() + toggle_action = tray_menu.addAction("启用代理") + toggle_action.triggered.connect(self.toggle_proxy) + + update_action = tray_menu.addAction("检查更新") + update_action.triggered.connect(self.check_updates) + + quit_action = tray_menu.addAction("退出") + quit_action.triggered.connect(self.confirm_exit) + + self.tray_icon.setContextMenu(tray_menu) + self.tray_icon.activated.connect(self.tray_activated) + self.tray_icon.show() + + # 默认隐藏到托盘 + self.hide() + + def init_timers(self): + """初始化定时器""" + # 日志更新定时器 + self.log_timer = QTimer() + self.log_timer.timeout.connect(self.update_logs) + self.log_timer.start(500) + + # 流量更新定时器 + self.traffic_timer = QTimer() + self.traffic_timer.timeout.connect(self.update_traffic) + self.traffic_timer.start(1000) + + # 更新检查定时器 (1小时) + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.auto_check_updates) + self.update_timer.start(3600000) # 1小时 + + def toggle_proxy(self): + """切换代理状态""" + if self.v2ray.running: + self.stop_proxy() + else: + self.start_proxy() + + def start_proxy(self): + """启动代理服务""" + # 验证配置 + if not self.app_context.validate_config(): + QMessageBox.warning(self, "配置错误", "请先设置有效的代理地址") + return + + # 启动v2ray + if not self.v2ray.start(): + QMessageBox.critical(self, "启动失败", "无法启动v2ray核心") + return + + # 配置系统代理 + if not self.proxy.enable(): + QMessageBox.warning(self, "代理警告", "系统代理配置失败,但v2ray仍在运行") + + self.toggle_btn.setText("停止代理") + self.tray_icon.setToolTip("EZProxy - 已启用") + + def stop_proxy(self): + """停止代理服务""" + self.proxy.disable() + self.v2ray.stop() + + self.toggle_btn.setText("启用代理") + self.tray_icon.setToolTip("EZProxy - 已停止") + + def update_logs(self): + """更新日志显示""" + while not self.v2ray.log_queue.empty(): + log_line = self.v2ray.log_queue.get() + self.log_view.append(log_line) + + # 保持滚动到底部 + scrollbar = self.log_view.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def update_traffic(self): + """更新流量统计""" + if self.v2ray.running: + stats = self.v2ray.get_traffic_stats() + self.traffic_widget.update_stats(stats) + + def closeEvent(self, event): + """关闭事件处理""" + event.ignore() + self.hide() + self.tray_icon.showMessage( + "EZProxy", + "应用已最小化到系统托盘", + QSystemTrayIcon.Information, + 2000 + ) + + def confirm_exit(self): + """确认退出""" + if self.v2ray.running: + reply = QMessageBox.question( + self, "确认退出", + "代理服务正在运行,确定要退出吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply == QMessageBox.No: + return + + self.stop_proxy() + QApplication.quit() + + def tray_activated(self, reason): + """托盘图标激活""" + if reason == QSystemTrayIcon.DoubleClick: + self.show() \ No newline at end of file diff --git a/ui/setup_dialog.py b/ui/setup_dialog.py index e69de29..96b8024 100644 --- a/ui/setup_dialog.py +++ b/ui/setup_dialog.py @@ -0,0 +1,85 @@ +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QMessageBox +) +from PyQt5.QtCore import Qt +from urllib.parse import urlparse + +class SetupDialog(QDialog): + def __init__(self, app_context, parent=None): + super().__init__(parent) + self.app_context = app_context + self.setWindowTitle("设置代理地址") + self.setMinimumWidth(500) + + layout = QVBoxLayout() + + # 说明标签 + desc_label = QLabel("请输入您的VLESS代理地址 (格式: vless://...)") + desc_label.setWordWrap(True) + layout.addWidget(desc_label) + + # 代理地址输入 + self.url_input = QLineEdit() + self.url_input.setPlaceholderText("vless://uuid@server:port?type=tcp&security=reality&...") + layout.addWidget(self.url_input) + + # 按钮布局 + btn_layout = QHBoxLayout() + self.test_btn = QPushButton("测试连接") + self.test_btn.clicked.connect(self.test_connection) + self.save_btn = QPushButton("保存并继续") + self.save_btn.clicked.connect(self.save_and_continue) + self.cancel_btn = QPushButton("取消") + self.cancel_btn.clicked.connect(self.reject) + + btn_layout.addWidget(self.test_btn) + btn_layout.addStretch() + btn_layout.addWidget(self.save_btn) + btn_layout.addWidget(self.cancel_btn) + + layout.addLayout(btn_layout) + self.setLayout(layout) + + def test_connection(self): + """测试代理连接""" + url = self.url_input.text().strip() + if not url: + QMessageBox.warning(self, "输入错误", "请输入代理地址") + return + + if not url.startswith('vless://'): + QMessageBox.warning(self, "格式错误", "代理地址必须以 vless:// 开头") + return + + try: + parsed = urlparse(url) + if not parsed.hostname or not parsed.port: + raise ValueError("无效的服务器地址或端口") + + QMessageBox.information(self, "测试成功", + f"代理地址解析成功:\n服务器: {parsed.hostname}\n端口: {parsed.port}") + except Exception as e: + QMessageBox.warning(self, "测试失败", f"代理地址格式错误: {str(e)}") + + def save_and_continue(self): + """保存配置并继续""" + url = self.url_input.text().strip() + if not url: + QMessageBox.warning(self, "输入错误", "请输入代理地址") + return + + if not url.startswith('vless://'): + QMessageBox.warning(self, "格式错误", "代理地址必须以 vless:// 开头") + return + + # 保存到配置 + config = self.app_context.config + config['proxy_url'] = url + + # 保存配置文件 + if not save_config(config): + QMessageBox.critical(self, "保存失败", "无法保存配置文件") + return + + self.accept() \ No newline at end of file diff --git a/ui/traffic_widget.py b/ui/traffic_widget.py index e69de29..a6609b6 100644 --- a/ui/traffic_widget.py +++ b/ui/traffic_widget.py @@ -0,0 +1,163 @@ +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QProgressBar, QGroupBox +) +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtCharts import ( + QChart, QChartView, QLineSeries, + QValueAxis, QBarSet, QBarSeries, QBarCategoryAxis +) +from PyQt5.QtGui import QPainter, QColor, QPen + +class TrafficWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.init_ui() + self.init_chart() + self.reset_stats() + + # 定时器用于模拟数据 + self.timer = QTimer() + self.timer.timeout.connect(self.update_fake_data) + self.timer.start(1000) + + def init_ui(self): + """初始化UI""" + layout = QVBoxLayout() + + # 速度显示 + speed_layout = QHBoxLayout() + self.download_label = QLabel("下载: 0.00 KB/s") + self.upload_label = QLabel("上传: 0.00 KB/s") + self.total_label = QLabel("总计: 0.00 MB") + + speed_layout.addWidget(self.download_label) + speed_layout.addWidget(self.upload_label) + speed_layout.addWidget(self.total_label) + speed_layout.addStretch() + + layout.addLayout(speed_layout) + + # 进度条 + self.progress = QProgressBar() + self.progress.setRange(0, 100) + layout.addWidget(self.progress) + + # 图表 + self.chart_view = QChartView() + self.chart_view.setRenderHint(QPainter.Antialiasing) + layout.addWidget(self.chart_view) + + self.setLayout(layout) + + def init_chart(self): + """初始化图表""" + self.chart = QChart() + self.chart.setTitle("实时流量统计") + self.chart.legend().setVisible(True) + + # 创建系列 + self.download_series = QLineSeries() + self.download_series.setName("下载") + self.download_series.setColor(QColor(52, 152, 219)) + + self.upload_series = QLineSeries() + self.upload_series.setName("上传") + self.upload_series.setColor(QColor(46, 204, 113)) + + self.chart.addSeries(self.download_series) + self.chart.addSeries(self.upload_series) + + # 创建坐标轴 + self.axis_x = QValueAxis() + self.axis_x.setTitleText("时间 (秒)") + self.axis_x.setRange(0, 60) + self.axis_x.setTickCount(7) + + self.axis_y = QValueAxis() + self.axis_y.setTitleText("流量 (KB)") + self.axis_y.setRange(0, 100) + + self.chart.addAxis(self.axis_x, Qt.AlignBottom) + self.chart.addAxis(self.axis_y, Qt.AlignLeft) + + self.download_series.attachAxis(self.axis_x) + self.download_series.attachAxis(self.axis_y) + self.upload_series.attachAxis(self.axis_x) + self.upload_series.attachAxis(self.axis_y) + + self.chart_view.setChart(self.chart) + + def reset_stats(self): + """重置统计数据""" + self.download_speed = 0.0 + self.upload_speed = 0.0 + self.total_download = 0.0 + self.total_upload = 0.0 + self.time_points = [] + self.download_points = [] + self.upload_points = [] + + def update_stats(self, stats): + """更新统计数据""" + self.download_speed = stats.get('download_speed', 0.0) + self.upload_speed = stats.get('upload_speed', 0.0) + self.total_download = stats.get('total_download', 0.0) + self.total_upload = stats.get('total_upload', 0.0) + + # 更新显示 + self.download_label.setText(f"下载: {self.download_speed:.2f} KB/s") + self.upload_label.setText(f"上传: {self.upload_speed:.2f} KB/s") + self.total_label.setText(f"总计: {(self.total_download + self.total_upload) / 1024:.2f} MB") + + # 更新进度条 + total_speed = self.download_speed + self.upload_speed + progress = min(100, int(total_speed / 1024)) # 假设1MB/s为满 + self.progress.setValue(progress) + self.progress.setFormat(f"总流量: {total_speed:.1f} KB/s") + + # 更新图表 + self.add_data_point() + + def add_data_point(self): + """添加数据点到图表""" + current_time = len(self.time_points) + + # 保留最近60秒的数据 + if len(self.time_points) >= 60: + self.time_points.pop(0) + self.download_points.pop(0) + self.upload_points.pop(0) + + self.time_points.append(current_time) + self.download_points.append(self.download_speed) + self.upload_points.append(self.upload_speed) + + # 更新系列数据 + self.download_series.clear() + self.upload_series.clear() + + for i, (t, d, u) in enumerate(zip(self.time_points, self.download_points, self.upload_points)): + self.download_series.append(i, d) + self.upload_series.append(i, u) + + # 调整Y轴范围 + max_value = max(max(self.download_points), max(self.upload_points), 100) * 1.1 + self.axis_y.setRange(0, max_value) + + def update_fake_data(self): + """模拟数据更新(实际应用中应替换为真实数据)""" + import random + fake_download = random.uniform(10, 500) + fake_upload = random.uniform(5, 100) + fake_total_download = self.total_download + fake_download + fake_total_upload = self.total_upload + fake_upload + + stats = { + 'download_speed': fake_download, + 'upload_speed': fake_upload, + 'total_download': fake_total_download, + 'total_upload': fake_total_upload + } + + self.update_stats(stats) \ No newline at end of file diff --git a/ui/update_dialog.py b/ui/update_dialog.py index e69de29..ad2b0c5 100644 --- a/ui/update_dialog.py +++ b/ui/update_dialog.py @@ -0,0 +1,143 @@ +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QProgressBar, QTextEdit, QMessageBox +) +from PyQt5.QtCore import Qt, QThread, pyqtSignal +import webbrowser +import logging + +class UpdateThread(QThread): + progress_updated = pyqtSignal(int, str) + finished = pyqtSignal(bool, str) + + def __init__(self, update_manager, update_type): + super().__init__() + self.update_manager = update_manager + self.update_type = update_type + + def run(self): + try: + self.progress_updated.emit(10, "准备更新...") + + if self.update_type == 'config': + success = self.update_manager.update_config() + message = "配置模板更新成功" if success else "配置模板更新失败" + elif self.update_type == 'app': + success = self.update_manager.start_app_update() + message = "应用更新已启动,程序将自动重启" if success else "应用更新启动失败" + else: + success = False + message = "未知更新类型" + + self.progress_updated.emit(100, "更新完成") + self.finished.emit(success, message) + except Exception as e: + logging.error(f"Update failed: {str(e)}") + self.finished.emit(False, f"更新失败: {str(e)}") + +class UpdateDialog(QDialog): + def __init__(self, update_info, update_manager, parent=None): + super().__init__(parent) + self.update_info = update_info + self.update_manager = update_manager + self.setWindowTitle("更新可用") + self.setMinimumSize(500, 300) + + layout = QVBoxLayout() + + # 版本信息 + if update_info['config']['available']: + config_label = QLabel(f"配置模板更新: {update_info['config']['version']}") + layout.addWidget(config_label) + + if update_info['app']['available']: + app_label = QLabel(f"应用更新: {update_info['app']['version']}") + layout.addWidget(app_label) + + # 更新日志 + self.changelog_view = QTextEdit() + self.changelog_view.setReadOnly(True) + self.changelog_view.setHtml("

点击'查看详情'查看更新日志

") + layout.addWidget(QLabel("更新内容:")) + layout.addWidget(self.changelog_view) + + # 按钮布局 + btn_layout = QHBoxLayout() + + self.details_btn = QPushButton("查看详情") + self.details_btn.clicked.connect(self.show_changelog) + + self.skip_btn = QPushButton("跳过本次更新") + self.skip_btn.setEnabled(not (update_info['config'].get('force', False) or + update_info['app'].get('force', False))) + self.skip_btn.clicked.connect(self.skip_update) + + self.update_btn = QPushButton("立即更新") + self.update_btn.clicked.connect(self.start_update) + + btn_layout.addWidget(self.details_btn) + btn_layout.addStretch() + btn_layout.addWidget(self.skip_btn) + btn_layout.addWidget(self.update_btn) + + layout.addLayout(btn_layout) + + # 进度条 + self.progress = QProgressBar() + self.progress.setVisible(False) + layout.addWidget(self.progress) + + self.setLayout(layout) + + def show_changelog(self): + """在浏览器中打开更新日志""" + url = self.update_info.get('changelog_url', '') + if url: + webbrowser.open(url) + else: + QMessageBox.information(self, "无更新日志", "没有提供更新日志链接") + + def skip_update(self): + """跳过更新""" + reply = QMessageBox.question( + self, "跳过更新", + "您确定要跳过本次更新吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.reject() + + def start_update(self): + """开始更新""" + # 禁用按钮 + self.details_btn.setEnabled(False) + self.skip_btn.setEnabled(False) + self.update_btn.setEnabled(False) + + # 显示进度条 + self.progress.setVisible(True) + + # 创建更新线程 + self.update_thread = UpdateThread(self.update_manager, 'both') + self.update_thread.progress_updated.connect(self.update_progress) + self.update_thread.finished.connect(self.update_finished) + self.update_thread.start() + + def update_progress(self, value, message): + """更新进度""" + self.progress.setValue(value) + self.progress.setFormat(message) + + def update_finished(self, success, message): + """更新完成""" + if success: + QMessageBox.information(self, "更新成功", message) + self.accept() + else: + QMessageBox.critical(self, "更新失败", message) + self.details_btn.setEnabled(True) + self.skip_btn.setEnabled(True) + self.update_btn.setEnabled(True) + self.progress.setVisible(False) \ No newline at end of file diff --git a/update_helper.py b/update_helper.py index e69de29..be67165 100644 --- a/update_helper.py +++ b/update_helper.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import sys +import time +import shutil +import zipfile +from pathlib import Path + +def main(): + if len(sys.argv) != 4: + print("Usage: update_helper.py ") + sys.exit(1) + + zip_path = Path(sys.argv[1]) + app_dir = Path(sys.argv[2]) + temp_dir = Path(sys.argv[3]) + + try: + # 解压更新包到临时目录 + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + + # 备份当前应用 + backup_dir = app_dir.parent / f"{app_dir.name}_backup" + if backup_dir.exists(): + shutil.rmtree(backup_dir) + shutil.copytree(app_dir, backup_dir) + + # 复制更新文件 (跳过conf目录) + for item in temp_dir.iterdir(): + if item.name == "conf": + continue + + dest = app_dir / item.name + if item.is_dir(): + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(item, dest) + else: + if dest.exists(): + dest.unlink() + shutil.copy2(item, dest) + + # 设置执行权限 + for bin_file in app_dir.rglob("v2ray*"): + bin_file.chmod(0o755) + + # 清理 + shutil.rmtree(temp_dir, ignore_errors=True) + + # 重启应用 + main_executable = app_dir / "main.py" + os.execv(sys.executable, [sys.executable, str(main_executable)]) + + except Exception as e: + print(f"Update failed: {str(e)}") + # 恢复备份 + if backup_dir.exists(): + shutil.rmtree(app_dir) + shutil.copytree(backup_dir, app_dir) + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py index e69de29..dda5d7f 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -0,0 +1,56 @@ +""" +EZProxy Utilities Module +======================== + +工具函数模块,提供各种辅助功能 +""" + +# 导入工具函数 +from .config_utils import ( + init_config, + load_config, + save_config, + parse_vless_url, + generate_v2ray_config +) + +from .system_utils import ( + get_architecture, + get_v2ray_binary_path, + set_system_proxy, + get_temp_dir +) + +from .network_utils import ( + download_file, + fetch_json, + fetch_yaml, + check_connectivity, + get_public_ip +) + +# 定义包的公开接口 +__all__ = [ + # 配置工具 + 'init_config', + 'load_config', + 'save_config', + 'parse_vless_url', + 'generate_v2ray_config', + + # 系统工具 + 'get_architecture', + 'get_v2ray_binary_path', + 'set_system_proxy', + 'get_temp_dir', + + # 网络工具 + 'download_file', + 'fetch_json', + 'fetch_yaml', + 'check_connectivity', + 'get_public_ip' +] + +# 包版本信息 +__version__ = "1.0.0" \ No newline at end of file diff --git a/utils/config_utils.py b/utils/config_utils.py index e69de29..2bffe5d 100644 --- a/utils/config_utils.py +++ b/utils/config_utils.py @@ -0,0 +1,161 @@ +import os +import yaml +import json +import logging +from pathlib import Path +from urllib.parse import urlparse, parse_qs + +def init_config(conf_dir): + """初始化配置文件""" + config_path = Path(conf_dir) / "config.yaml" + + if not config_path.exists(): + default_config = { + 'proxy_url': '', + 'config_version': '1.0', + 'app_version': '1.0', + 'domains': [ + 'google.com', + 'youtube.com', + 'facebook.com', + 'twitter.com', + 'instagram.com', + 'netflix.com', + 'github.com', + 'gitlab.com', + 'stackoverflow.com', + 'reddit.com' + ], + 'v2ray_config_path': str(Path(conf_dir) / "v2ray.json"), + 'v2ray_template_path': str(Path(conf_dir) / "v2ray_template.json"), + 'v2ray_log_path': str(Path(__file__).parent.parent / "logs" / "v2ray.log") + } + + with open(config_path, 'w') as f: + yaml.dump(default_config, f) + logging.info("Default config file created") + + return load_config() + +def load_config(): + """加载配置文件""" + config_path = Path(__file__).parent.parent / "conf" / "config.yaml" + + try: + with open(config_path, 'r') as f: + return yaml.safe_load(f) + except Exception as e: + logging.error(f"Failed to load config: {str(e)}") + return { + 'proxy_url': '', + 'config_version': '1.0', + 'app_version': '1.0', + 'domains': [], + 'v2ray_config_path': str(Path(__file__).parent.parent / "conf" / "v2ray.json"), + 'v2ray_template_path': str(Path(__file__).parent.parent / "conf" / "v2ray_template.json"), + 'v2ray_log_path': str(Path(__file__).parent.parent / "logs" / "v2ray.log") + } + +def save_config(config): + """保存配置文件""" + config_path = Path(__file__).parent.parent / "conf" / "config.yaml" + + try: + with open(config_path, 'w') as f: + yaml.dump(config, f) + return True + except Exception as e: + logging.error(f"Failed to save config: {str(e)}") + return False + +def parse_vless_url(url): + """解析vless://URL""" + try: + # 移除URL编码 + url = url.replace('%2F', '/').replace('%3D', '=').replace('%3F', '?') + + # 解析URL + parsed = urlparse(url) + query_params = parse_qs(parsed.query) + + # 提取必要参数 + uuid = parsed.username + address = parsed.hostname + port = parsed.port + name = parsed.fragment + + # 提取REALITY参数 + security = query_params.get('security', ['none'])[0] + encryption = query_params.get('encryption', ['none'])[0] + flow = query_params.get('flow', [''])[0] + type_ = query_params.get('type', ['tcp'])[0] + sni = query_params.get('sni', [''])[0] + fp = query_params.get('fp', ['chrome'])[0] + pbk = query_params.get('pbk', [''])[0] + sid = query_params.get('sid', [''])[0] + spx = query_params.get('spx', [''])[0] + + return { + 'uuid': uuid, + 'address': address, + 'port': port, + 'name': name, + 'security': security, + 'encryption': encryption, + 'flow': flow, + 'type': type_, + 'sni': sni, + 'fp': fp, + 'pbk': pbk, + 'sid': sid, + 'spx': spx + } + except Exception as e: + logging.error(f"Failed to parse vless URL: {str(e)}") + return None + +def generate_v2ray_config(template_path, output_path, proxy_info, domains): + """生成v2ray配置文件""" + try: + # 读取模板 + with open(template_path, 'r') as f: + template = json.load(f) + + # 替换模板中的占位符 + outbound = template['outbounds'][0] + + # 设置vless服务器信息 + outbound['settings']['vnext'][0]['address'] = proxy_info['address'] + outbound['settings']['vnext'][0]['port'] = proxy_info['port'] + outbound['settings']['vnext'][0]['users'][0]['id'] = proxy_info['uuid'] + outbound['settings']['vnext'][0]['users'][0]['encryption'] = proxy_info['encryption'] + outbound['settings']['vnext'][0]['users'][0]['flow'] = proxy_info['flow'] + + # 设置streamSettings + stream_settings = outbound['streamSettings'] + stream_settings['network'] = proxy_info['type'] + stream_settings['security'] = proxy_info['security'] + + if proxy_info['security'] == 'reality': + reality_settings = { + 'serverName': proxy_info['sni'], + 'fingerprint': proxy_info['fp'], + 'publicKey': proxy_info['pbk'], + 'shortId': proxy_info['sid'], + 'spiderX': proxy_info['spx'] + } + stream_settings['realitySettings'] = reality_settings + + # 替换域名列表 + for rule in template['routing']['rules']: + if 'domain' in rule and 'REPLACE_DOMAINS' in rule['domain']: + rule['domain'] = domains + + # 保存配置 + with open(output_path, 'w') as f: + json.dump(template, f, indent=2) + + return True + except Exception as e: + logging.error(f"Failed to generate v2ray config: {str(e)}") + return False \ No newline at end of file diff --git a/utils/network_utils.py b/utils/network_utils.py index e69de29..0b8bfd9 100644 --- a/utils/network_utils.py +++ b/utils/network_utils.py @@ -0,0 +1,86 @@ +import requests +import json +import yaml +import logging +import time +from pathlib import Path + +def download_file(url, save_path, timeout=30): + """下载文件""" + try: + response = requests.get(url, timeout=timeout, stream=True) + response.raise_for_status() + + with open(save_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + return True + except Exception as e: + logging.error(f"Failed to download file from {url}: {str(e)}") + return False + +def fetch_json(url, timeout=15): + """获取JSON数据""" + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + return response.json() + except Exception as e: + logging.error(f"Failed to fetch JSON from {url}: {str(e)}") + return None + +def fetch_yaml(url, timeout=15): + """获取YAML数据""" + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + return yaml.safe_load(response.text) + except Exception as e: + logging.error(f"Failed to fetch YAML from {url}: {str(e)}") + return None + +def check_connectivity(host="8.8.8.8", port=53, timeout=3): + """检查网络连接""" + import socket + + try: + socket.setdefaulttimeout(timeout) + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) + return True + except socket.error: + return False + +def get_public_ip(): + """获取公网IP""" + try: + response = requests.get('https://api.ipify.org?format=json', timeout=10) + response.raise_for_status() + return response.json()['ip'] + except Exception as e: + logging.error(f"Failed to get public IP: {str(e)}") + return None + +def measure_speedtest(): + """简单测速""" + try: + # 下载测试 + start_time = time.time() + response = requests.get('https://speedtest.tele2.net/10MB.zip', timeout=30, stream=True) + response.raise_for_status() + + total_bytes = 0 + for chunk in response.iter_content(chunk_size=8192): + total_bytes += len(chunk) + + elapsed_time = time.time() - start_time + download_speed = (total_bytes * 8) / (elapsed_time * 1024 * 1024) # Mbps + + return { + 'download_speed': download_speed, + 'elapsed_time': elapsed_time, + 'total_bytes': total_bytes + } + except Exception as e: + logging.error(f"Speed test failed: {str(e)}") + return None \ No newline at end of file diff --git a/utils/system_utils.py b/utils/system_utils.py index e69de29..33cf7c3 100644 --- a/utils/system_utils.py +++ b/utils/system_utils.py @@ -0,0 +1,48 @@ +import platform +import subprocess +from pathlib import Path + +def get_architecture(): + """检测系统架构""" + machine = platform.machine().lower() + if 'arm' in machine or 'aarch64' in machine: + return 'arm64' + return 'amd64' + +def get_v2ray_binary_path(): + """获取v2ray二进制路径""" + arch = get_architecture() + base_dir = Path(__file__).parent.parent + v2ray_dir = base_dir / "v2ray" + + if arch == 'arm64': + return v2ray_dir / "v2ray-macos-arm64" + return v2ray_dir / "v2ray-macos-64" + +def set_system_proxy(enable, host="127.0.0.1", port=1081): + """配置macOS系统代理""" + # 获取当前网络服务 + service_cmd = "networksetup -listnetworkserviceorder | grep $(route -n get default | grep 'interface' | awk '{print $2}') -A1 | grep -o '[^ ]*$'" + service = subprocess.check_output(service_cmd, shell=True).decode().strip() + + if enable: + cmd = f""" + osascript -e 'do shell script "networksetup -setwebproxy \\"{service}\\" {host} {port}" with administrator privileges' + osascript -e 'do shell script "networksetup -setsecurewebproxy \\"{service}\\" {host} {port}" with administrator privileges' + """ + else: + cmd = f""" + osascript -e 'do shell script "networksetup -setwebproxystate \\"{service}\\" off" with administrator privileges' + osascript -e 'do shell script "networksetup -setsecurewebproxystate \\"{service}\\" off" with administrator privileges' + """ + + try: + subprocess.run(cmd, shell=True, check=True) + return True + except subprocess.CalledProcessError: + return False + +def get_temp_dir(): + """获取安全的临时目录""" + import tempfile + return Path(tempfile.gettempdir()) / f"ezproxy_update_{os.getpid()}" \ No newline at end of file