diff --git a/lite/config.json b/lite/config.json index 3979cd5..bcfb439 100644 --- a/lite/config.json +++ b/lite/config.json @@ -1,5 +1,5 @@ { - "version": "999909182036", + "version": "999909182137", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/", "nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client", "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest" diff --git a/lite/main.py b/lite/main.py index 372813d..a317e63 100644 --- a/lite/main.py +++ b/lite/main.py @@ -7,7 +7,7 @@ from pathlib import Path from PyQt5.QtWidgets import ( QApplication, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, - QCheckBox, QMenu, QDialog, QProgressDialog, + QCheckBox, QMenu, QDialog, QProgressDialog, QAction, QSystemTrayIcon, ) from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor @@ -816,7 +816,9 @@ def _endpoint_label(): domain = _endpoint_domain() if domain == "???": return "无可用节点(请登录)" - return f"{domain} ({_endpoint_country()})" + display = _node_display_mode() + name = str(node.get("remark") or "").strip() if display == "remark" else "" + return f"{name or domain} ({_endpoint_country()})" def _proxy_auth_basic(node=None): """正向代理 Basic 认证凭证:methods.http.{username,password}(新格式) @@ -1433,6 +1435,47 @@ def _set_bypass_lan(enabled): data["bypass_lan"] = bool(enabled) _settings_save(data) + +def _node_display_mode(): + """主页面节点信息显示方式:domain 或 remark。""" + return "remark" if _settings_load().get("node_display") == "remark" else "domain" + + +def _set_node_display_mode(mode): + """保存主页面节点信息显示方式。""" + data = _settings_load() + data["node_display"] = "remark" if mode == "remark" else "domain" + _settings_save(data) + + +def _close_behavior(): + """窗口关闭行为:exit(退出)或 tray(最小化到托盘)。""" + return "tray" if _settings_load().get("close_behavior") == "tray" else "exit" + + +def _set_close_behavior(mode): + """保存窗口关闭行为。""" + data = _settings_load() + data["close_behavior"] = "tray" if mode == "tray" else "exit" + _settings_save(data) + + +def _reset_user_config(): + """重置 settings.json;保留登录、节点缓存、第三方订阅和 PAC 数据。""" + global _WHITELIST, _ROUTE_RULES, _WHITELIST_VERSION, _NODES, _SELECTED_NODE + _settings_save({}) + _WHITELIST = None + _ROUTE_RULES = None + _WHITELIST_VERSION += 1 + _is_whitelisted.cache_clear() + _NODES = None + _SELECTED_NODE = 0 + default_port = _port_val() + global _TLS_LOCAL_PORT, PROXY_PORT + _TLS_LOCAL_PORT = default_port + PROXY_PORT = default_port + log("已重置用户配置:本地偏好、域名路由、跳板和当前节点选择已恢复默认") + # ── 域名路由(兼容旧代理白名单,settings.json 的 whitelist 键) ──────── # 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用, # "action": "direct" | "node", "node_ref": 节点稳定标识}。 @@ -2633,11 +2676,16 @@ def _settings_services(): get_builtin_pac=_use_builtin_pac, get_update_via_proxy=_update_via_proxy, get_skip_update_check=_skip_update_check, + get_node_display_mode=_node_display_mode, + get_close_behavior=_close_behavior, parse_port=_parse_port, set_port=_set_local_port, set_builtin_pac=_set_builtin_pac, set_update_via_proxy=_set_update_via_proxy, set_skip_update_check=_set_skip_update_check, + set_node_display_mode=_set_node_display_mode, + set_close_behavior=_set_close_behavior, + reset_user_config=_reset_user_config, open_whitelist=lambda parent: FixedRouteDialog(_fixed_route_services(), parent).exec_(), open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(), open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(), @@ -2674,6 +2722,9 @@ class MainWindow(QWidget): def __init__(self): super().__init__() self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的) + self._quitting = False + self._tray = None + self._tray_menu = None # 启动完成前仍允许用户取消。事件由 GUI 线程置位,后台启动线程只读取; # 锁保护系统代理写入和取消清理之间的交接,避免遗留代理。 self._start_cancel_event = None @@ -2695,6 +2746,7 @@ class MainWindow(QWidget): _user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前 self.init_ui() + self._setup_tray() # 每 2 秒刷新一次状态指示灯 self.timer = QTimer() @@ -2714,6 +2766,65 @@ class MainWindow(QWidget): # 节点未配置时延迟弹出登录框(等窗口显示后) QTimer.singleShot(600, self._maybe_show_login) + def _setup_tray(self): + """创建托盘图标和动态状态菜单;系统不支持托盘时保留窗口模式。""" + if not QSystemTrayIcon.isSystemTrayAvailable(): + log("当前系统不支持托盘图标,关闭窗口时只能退出程序") + return + self._tray = QSystemTrayIcon(load_icon(), self) + self._tray.setToolTip("Royirin Rosmontis Private Proxy") + self._tray_menu = QMenu(self) + self._tray_menu.aboutToShow.connect(self._update_tray_menu) + self._tray.setContextMenu(self._tray_menu) + self._tray.activated.connect(self._on_tray_activated) + self._tray.show() + self._update_tray_menu() + + def _on_tray_activated(self, reason): + """双击托盘图标显示主页面。""" + if reason == QSystemTrayIcon.DoubleClick: + self._show_main_window() + + def _show_main_window(self): + """从托盘恢复主页面并置于前台。""" + self.show() + self.showNormal() + self.raise_() + self.activateWindow() + + def _update_tray_menu(self): + """刷新托盘菜单中的三行状态信息。""" + if self._tray_menu is None: + return + self._tray_menu.clear() + + show_action = QAction("显示主页面", self._tray_menu) + show_action.triggered.connect(self._show_main_window) + self._tray_menu.addAction(show_action) + self._tray_menu.addSeparator() + + proxy_on = is_proxy_on() + pac_on = _should_use_internal_pac(_use_pac_mode(), _use_builtin_pac()) + bypass_lan = _bypass_lan() + for text in ( + f"代理:{'开' if proxy_on else '关'}", + f"PAC:{'启用' if pac_on else '关闭'}", + f"内网IP:{'不代理' if bypass_lan else '代理'}", + ): + status_action = QAction(text, self._tray_menu) + status_action.setEnabled(False) + self._tray_menu.addAction(status_action) + + self._tray_menu.addSeparator() + exit_action = QAction("退出", self._tray_menu) + exit_action.triggered.connect(self._exit_from_tray) + self._tray_menu.addAction(exit_action) + + def _exit_from_tray(self): + """托盘菜单中的显式退出。""" + self._quitting = True + self.close() + def _verify_node_thread(self): """等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。 @@ -2904,7 +3015,11 @@ class MainWindow(QWidget): layout.addWidget(hints) def closeEvent(self, event): - """关闭窗口时自动清理代理和隧道。""" + """按用户设置退出或隐藏到托盘。""" + if not self._quitting and _close_behavior() == "tray" and self._tray is not None: + event.ignore() + self.hide() + return try: self.quit_app() finally: @@ -3114,8 +3229,15 @@ class MainWindow(QWidget): def _show_settings_dialog(self): """打开设置弹窗;第三方订阅与登录状态可独立管理。""" SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_() + self._refresh_preferences() self._refresh_subtitle() + def _refresh_preferences(self): + """设置页关闭后同步主页面的本地偏好显示。""" + self.cb_pac.setChecked(_use_pac_mode()) + self.cb_bypass_lan.setChecked(_bypass_lan()) + self._update_tray_menu() + # ── 节点登录 ── def _maybe_show_login(self): """首次无节点时提示登录;用户可跳过并在主界面添加第三方订阅。""" @@ -3206,6 +3328,7 @@ class MainWindow(QWidget): def _refresh_subtitle(self): """刷新节点副标题文字。""" self.subtitle.setText(_endpoint_label()) + self._update_tray_menu() def _update_node_label(self): """代理运行中时提示禁止切换节点。""" @@ -3299,6 +3422,7 @@ class MainWindow(QWidget): self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s") self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s") self.traffic_total.setText(_fmt_bytes(up_total + down_total)) + self._update_tray_menu() def _check_update(self, manual=False): """后台检查服务器是否有新版本;manual=True 时无视「跳过检查更新」设置。""" # 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取 @@ -3515,6 +3639,9 @@ class MainWindow(QWidget): 本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。 """ + self._quitting = True + if self._tray is not None: + self._tray.hide() if self._start_in_progress and self._start_cancel_event is not None: self._start_cancel_event.set() try: @@ -3608,7 +3735,9 @@ def main(): app = QApplication(sys.argv) icon = load_icon() app.setWindowIcon(icon) - app.setQuitOnLastWindowClosed(True) + # 主窗口可按设置隐藏到托盘;显式点击托盘“退出”或关闭行为为“退出”时, + # MainWindow.quit_app() 会负责结束 QApplication。 + app.setQuitOnLastWindowClosed(False) window = MainWindow() window.show() diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py index 272a2e0..fe23ef3 100644 --- a/lite/rrpp_ui/dialogs.py +++ b/lite/rrpp_ui/dialogs.py @@ -9,6 +9,7 @@ from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5.QtWidgets import ( QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit, + QButtonGroup, QMessageBox, ) from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE @@ -933,11 +934,16 @@ class SettingsServices: get_builtin_pac: object get_update_via_proxy: object get_skip_update_check: object + get_node_display_mode: object + get_close_behavior: object parse_port: object set_port: object set_builtin_pac: object set_update_via_proxy: object set_skip_update_check: object + set_node_display_mode: object + set_close_behavior: object + reset_user_config: object open_whitelist: object open_jump: object open_third_party: object @@ -955,7 +961,7 @@ class SettingsDialog(QDialog): self._services = services self.setWindowTitle("设置") self.setWindowIcon(services.load_icon()) - self.setFixedSize(300, 380) + self.setFixedSize(300, 470) self.setStyleSheet(f""" QDialog {{ background-color: {C_BG}; }} QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }} @@ -963,8 +969,8 @@ class SettingsDialog(QDialog): padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }} """) layout = QVBoxLayout(self) - layout.setSpacing(10) - layout.setContentsMargins(20, 10, 20, 10) + layout.setSpacing(7) + layout.setContentsMargins(20, 8, 20, 8) title = QLabel("设置") title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;") title.setAlignment(Qt.AlignCenter) @@ -991,7 +997,7 @@ class SettingsDialog(QDialog): self.lb_hint.setFixedHeight(14) self.lb_hint.setAlignment(Qt.AlignCenter) layout.addWidget(self.lb_hint) - layout.addStretch() + layout.addSpacing(2) whitelist_row = QHBoxLayout() whitelist_label = QLabel("域名路由") @@ -1092,6 +1098,74 @@ class SettingsDialog(QDialog): self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志") skip_row.addWidget(self.cb_skip_update) layout.addLayout(skip_row) + + option_style = f""" + QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER}; + border-radius: 6px; padding: 2px 8px; font-size: 11px; min-width: 32px; }} + QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }} + QPushButton:checked {{ background-color: {C_TITLE}; color: #ffffff; border-color: {C_TITLE}; }} + """ + + node_display_row = QHBoxLayout() + node_display_label = QLabel("节点信息显示") + node_display_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + node_display_label.setToolTip("控制主页面节点副标题显示域名还是节点备注") + node_display_row.addWidget(node_display_label) + node_display_row.addStretch() + self.btn_node_domain = QPushButton("域名") + self.btn_node_remark = QPushButton("备注") + for button in (self.btn_node_domain, self.btn_node_remark): + button.setCheckable(True) + button.setCursor(Qt.PointingHandCursor) + button.setStyleSheet(option_style) + node_display_row.addWidget(button) + self.node_display_group = QButtonGroup(self) + self.node_display_group.setExclusive(True) + self.node_display_group.addButton(self.btn_node_domain) + self.node_display_group.addButton(self.btn_node_remark) + if services.get_node_display_mode() == "remark": + self.btn_node_remark.setChecked(True) + else: + self.btn_node_domain.setChecked(True) + layout.addLayout(node_display_row) + + close_behavior_row = QHBoxLayout() + close_behavior_label = QLabel("默认关闭行为") + close_behavior_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + close_behavior_label.setToolTip("点击主窗口关闭按钮时退出程序,或隐藏到系统托盘") + close_behavior_row.addWidget(close_behavior_label) + close_behavior_row.addStretch() + self.btn_close_exit = QPushButton("退出") + self.btn_close_tray = QPushButton("托盘") + for button in (self.btn_close_exit, self.btn_close_tray): + button.setCheckable(True) + button.setCursor(Qt.PointingHandCursor) + button.setStyleSheet(option_style) + close_behavior_row.addWidget(button) + self.close_behavior_group = QButtonGroup(self) + self.close_behavior_group.setExclusive(True) + self.close_behavior_group.addButton(self.btn_close_exit) + self.close_behavior_group.addButton(self.btn_close_tray) + if services.get_close_behavior() == "tray": + self.btn_close_tray.setChecked(True) + else: + self.btn_close_exit.setChecked(True) + layout.addLayout(close_behavior_row) + + reset_row = QHBoxLayout() + reset_label = QLabel("重置用户配置") + reset_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + reset_label.setToolTip("重置本地偏好、域名路由、跳板和当前节点选择;不会删除登录和订阅") + reset_row.addWidget(reset_label) + reset_row.addStretch() + self.btn_reset = QPushButton("重置") + self.btn_reset.setCursor(Qt.PointingHandCursor) + self.btn_reset.setStyleSheet(option_style) + self.btn_reset.setEnabled(not port_locked) + self.btn_reset.clicked.connect(self._reset_user_config) + reset_row.addWidget(self.btn_reset) + layout.addLayout(reset_row) + layout.addLayout(log_row) layout.addStretch() @@ -1148,6 +1222,27 @@ class SettingsDialog(QDialog): def _open_logs(self): self._services.open_logs(self) + def _reset_user_config(self): + reply = QMessageBox.question( + self, + "重置用户配置", + "确定要恢复本地偏好、域名路由、跳板和当前节点选择的默认值吗?\n\n" + "登录凭据、节点缓存、第三方订阅和 PAC 数据不会被删除。", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + self._services.reset_user_config() + self.ed_port.setText(str(self._services.get_port())) + self.cb_builtin_pac.setChecked(self._services.get_builtin_pac()) + self.cb_update_proxy.setChecked(self._services.get_update_via_proxy()) + self.cb_skip_update.setChecked(self._services.get_skip_update_check()) + self.btn_node_domain.setChecked(True) + self.btn_close_exit.setChecked(True) + self.lb_hint.setText("用户配置已重置") + self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;") + def _save_settings(self): port = self._services.parse_port(self.ed_port.text()) if port is None: @@ -1158,6 +1253,12 @@ class SettingsDialog(QDialog): self._services.set_builtin_pac(bool(self.cb_builtin_pac.isChecked())) self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked())) self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked())) + self._services.set_node_display_mode( + "remark" if self.btn_node_remark.isChecked() else "domain" + ) + self._services.set_close_behavior( + "tray" if self.btn_close_tray.isChecked() else "exit" + ) self.ed_port.setText(str(port)) self.lb_hint.setText("已保存,下次开启代理生效") self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")