diff --git a/CleanDesktopOrganizer.spec b/CleanDesktopOrganizer.spec index 271343c..c5f88d0 100644 --- a/CleanDesktopOrganizer.spec +++ b/CleanDesktopOrganizer.spec @@ -37,7 +37,7 @@ exe = EXE( upx=True, upx_exclude=[], runtime_tmpdir=None, - console=True, + console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, diff --git a/__pycache__/shortcut_target.cpython-313.pyc b/__pycache__/shortcut_target.cpython-313.pyc index af634f8..fef1a15 100644 Binary files a/__pycache__/shortcut_target.cpython-313.pyc and b/__pycache__/shortcut_target.cpython-313.pyc differ diff --git a/db/__pycache__/__init__.cpython-313.pyc b/db/__pycache__/__init__.cpython-313.pyc index b8ed6de..8433c2e 100644 Binary files a/db/__pycache__/__init__.cpython-313.pyc and b/db/__pycache__/__init__.cpython-313.pyc differ diff --git a/db/__pycache__/database.cpython-313.pyc b/db/__pycache__/database.cpython-313.pyc index 7b728aa..3b1efd2 100644 Binary files a/db/__pycache__/database.cpython-313.pyc and b/db/__pycache__/database.cpython-313.pyc differ diff --git a/main.py b/main.py index 39381a5..2865d52 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ from ui.ball import FloatBall, BALL_SIZE import ui.theme as theme from db import database -__VERSION__ = "0.0.1" +__VERSION__ = "0.0.2" # ===================== 打包兼容核心函数 ===================== def get_resource_path(relative_path): diff --git a/requirements.txt b/requirements.txt index f565544..f76ea70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ PyQt6>=6.4.0 +pillow>=9.0.0 \ No newline at end of file diff --git a/ui/__pycache__/__init__.cpython-313.pyc b/ui/__pycache__/__init__.cpython-313.pyc index 26f1ea1..b5fd8a8 100644 Binary files a/ui/__pycache__/__init__.cpython-313.pyc and b/ui/__pycache__/__init__.cpython-313.pyc differ diff --git a/ui/__pycache__/ball.cpython-313.pyc b/ui/__pycache__/ball.cpython-313.pyc index af91f78..da4eca8 100644 Binary files a/ui/__pycache__/ball.cpython-313.pyc and b/ui/__pycache__/ball.cpython-313.pyc differ diff --git a/ui/__pycache__/dialog_style.cpython-313.pyc b/ui/__pycache__/dialog_style.cpython-313.pyc index 978dbcd..b8d10bd 100644 Binary files a/ui/__pycache__/dialog_style.cpython-313.pyc and b/ui/__pycache__/dialog_style.cpython-313.pyc differ diff --git a/ui/__pycache__/dock.cpython-313.pyc b/ui/__pycache__/dock.cpython-313.pyc index ac00814..833556d 100644 Binary files a/ui/__pycache__/dock.cpython-313.pyc and b/ui/__pycache__/dock.cpython-313.pyc differ diff --git a/ui/__pycache__/flow_layout.cpython-313.pyc b/ui/__pycache__/flow_layout.cpython-313.pyc index 81b4381..40bbfa3 100644 Binary files a/ui/__pycache__/flow_layout.cpython-313.pyc and b/ui/__pycache__/flow_layout.cpython-313.pyc differ diff --git a/ui/__pycache__/group.cpython-313.pyc b/ui/__pycache__/group.cpython-313.pyc index f355e40..1908be3 100644 Binary files a/ui/__pycache__/group.cpython-313.pyc and b/ui/__pycache__/group.cpython-313.pyc differ diff --git a/ui/__pycache__/item.cpython-313.pyc b/ui/__pycache__/item.cpython-313.pyc index bcd80b3..3b2955f 100644 Binary files a/ui/__pycache__/item.cpython-313.pyc and b/ui/__pycache__/item.cpython-313.pyc differ diff --git a/ui/__pycache__/settings_window.cpython-313.pyc b/ui/__pycache__/settings_window.cpython-313.pyc index a062859..11558ac 100644 Binary files a/ui/__pycache__/settings_window.cpython-313.pyc and b/ui/__pycache__/settings_window.cpython-313.pyc differ diff --git a/ui/__pycache__/theme.cpython-313.pyc b/ui/__pycache__/theme.cpython-313.pyc index 2f4b84d..ddf896c 100644 Binary files a/ui/__pycache__/theme.cpython-313.pyc and b/ui/__pycache__/theme.cpython-313.pyc differ diff --git a/ui/ball.py b/ui/ball.py index d92a915..f6b2723 100644 --- a/ui/ball.py +++ b/ui/ball.py @@ -43,12 +43,25 @@ def _load_logo_pixmap(path): def _win32_set_ellipse_window_rgn(hwnd, w, h): - """用系统区域裁切 HWND。""" + """用系统区域裁切 HWND,w/h 为逻辑像素,内部换算为物理像素。""" if not hwnd or w < 2 or h < 2: return gdi32 = ctypes.windll.gdi32 user32 = ctypes.windll.user32 - hrgn = gdi32.CreateEllipticRgn(0, 0, w, h) + # 获取窗口所在显示器的 DPI,换算为物理像素 + try: + MONITOR_DEFAULTTONEAREST = 2 + hmon = ctypes.windll.user32.MonitorFromWindow(wintypes.HWND(hwnd), MONITOR_DEFAULTTONEAREST) + dpi_x = ctypes.c_uint(0) + dpi_y = ctypes.c_uint(0) + MDT_EFFECTIVE_DPI = 0 + ctypes.windll.shcore.GetDpiForMonitor(hmon, MDT_EFFECTIVE_DPI, ctypes.byref(dpi_x), ctypes.byref(dpi_y)) + scale = dpi_x.value / 96.0 + except Exception: + scale = 1.0 + pw = max(2, int(w * scale)) + ph = max(2, int(h * scale)) + hrgn = gdi32.CreateEllipticRgn(0, 0, pw, ph) if not hrgn: return user32.SetWindowRgn(hwnd, hrgn, True) @@ -139,14 +152,21 @@ class FloatBall(QWidget): _win32_set_ellipse_window_rgn(wid, self.width(), self.height()) _win32_disable_dwm_rounded_shell(wid) + def moveEvent(self, event): + super().moveEvent(event) + # 跨屏移动后 DPI 可能变化,重新设置物理像素 region + if sys.platform == "win32": + wid = int(self.winId()) + if wid: + _win32_set_ellipse_window_rgn(wid, self.width(), self.height()) + def _apply_window_flags(self): flags = ( Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool | Qt.WindowType.NoDropShadowWindowHint + | Qt.WindowType.WindowStaysOnTopHint ) - if self._stays_on_top: - flags |= Qt.WindowType.WindowStaysOnTopHint self.setWindowFlags(flags) def _get_glow(self): return self._glow @@ -154,6 +174,14 @@ class FloatBall(QWidget): from PyQt6.QtCore import pyqtProperty _glow_prop = pyqtProperty(float, _get_glow, _set_glow) + def _current_screen_geometry(self): + """返回当前窗口所在屏幕的可用区域,找不到时回退到主屏幕。""" + center = self.geometry().center() + screen = QApplication.screenAt(center) + if screen is None: + screen = QApplication.primaryScreen() + return screen.availableGeometry() + def _place_default(self): screen = QApplication.primaryScreen().availableGeometry() self.move( @@ -161,6 +189,19 @@ class FloatBall(QWidget): screen.top() + screen.height() // 2 - self.height() // 2, ) + def place_on_screen_of(self, widget): + """将悬浮球放到指定 widget 所在屏幕的右侧中央。""" + # frameGeometry().center() 直接是全局坐标,对顶层窗口最可靠 + global_center = widget.frameGeometry().center() + screen_obj = QApplication.screenAt(global_center) + if screen_obj is None: + screen_obj = QApplication.primaryScreen() + screen = screen_obj.availableGeometry() + self.move( + screen.right() - self.width() - 20, + screen.top() + screen.height() // 2 - self.height() // 2, + ) + def _paint_radius_outer(self): w, h = self.width(), self.height() return min(w, h) * 0.5 - 0.5 @@ -224,15 +265,17 @@ class FloatBall(QWidget): # 3. Logo(cover 铺满圆) if not self._logo.isNull(): p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) - cover = max(1, int(2 * r)) + side = max(1, int(2 * r)) scaled = self._logo.scaled( - cover, cover, + side, side, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation ) - lx = int(cx - scaled.width() / 2) - ly = int(cy - scaled.height() / 2) - p.drawPixmap(lx, ly, scaled) + # 居中裁切到 side×side,避免 Expanding 后图片偏大导致偏移 + ox = (scaled.width() - side) // 2 + oy = (scaled.height() - side) // 2 + cropped = scaled.copy(ox, oy, side, side) + p.drawPixmap(int(cx - side / 2), int(cy - side / 2), cropped) # 4. 覆在图上的雾面(毛玻璃「蒙在内容上」) veil = QLinearGradient(cx, cy - r, cx, cy + r) @@ -280,7 +323,11 @@ class FloatBall(QWidget): if self._dragging: new_pos = self.pos() + delta self._drag_start = event.globalPosition().toPoint() - screen = QApplication.primaryScreen().availableGeometry() + # 用鼠标当前位置所在屏幕来限制边界,支持多显示器拖动 + cursor_screen = QApplication.screenAt(event.globalPosition().toPoint()) + if cursor_screen is None: + cursor_screen = QApplication.primaryScreen() + screen = cursor_screen.availableGeometry() new_pos.setX(max(screen.left(), min(new_pos.x(), screen.right() - self.width()))) new_pos.setY(max(screen.top(), min(new_pos.y(), screen.bottom() - self.height()))) self.move(new_pos) @@ -307,7 +354,7 @@ class FloatBall(QWidget): self._glow_anim.start() def _snap_to_edge(self): - screen = QApplication.primaryScreen().availableGeometry() + screen = self._current_screen_geometry() cx = self.x() + self.width() // 2 cy = self.y() + self.height() // 2 dists = { diff --git a/ui/dock.py b/ui/dock.py index 6ed0c0f..af3126d 100644 --- a/ui/dock.py +++ b/ui/dock.py @@ -38,10 +38,11 @@ from db import database from ui.group import GroupWidget import ui.theme as theme import ui.dialog_style as dialog_style +from ui.updater import Updater PANEL_W = 260 PANEL_H = 40 -MIN_W, MIN_H = 180, 40 +MIN_W, MIN_H = 400, 5 ANIM_MS = 180 RESIZE_M = 8 @@ -354,6 +355,8 @@ class PanelWindow(QWidget): self._time_timer.setInterval(1000) self._time_timer.timeout.connect(self._refresh_time_label) + self._updater = Updater(self) + def showEvent(self, event): super().showEvent(event) if not self._screen_hooked: @@ -499,18 +502,63 @@ class PanelWindow(QWidget): self._time_timer.start() # ── UI ────────────────────────────────────────────── + def _get_quick_actions(self): + """返回快捷动作列表,悬浮球右键菜单和左侧快捷栏共用同一份。 + 每项: (tooltip, qtawesome_icon, callback) + 排除重启/退出。""" + return [ + ("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd), + ("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell), + ("打开默认浏览器", "fa5s.globe", self._open_default_browser), + ] + def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) self.container = QWidget() self.container.setObjectName("container") - # container 也开启鼠标追踪,子 widget 的 MouseMove 会冒泡上来 self.container.setMouseTracking(True) - inner = QVBoxLayout(self.container) + # 横向:左侧快捷栏 + 右侧主内容 + h_layout = QHBoxLayout(self.container) + h_layout.setContentsMargins(0, 0, 0, 0) + h_layout.setSpacing(0) + + # ── 左侧快捷栏 + self._quick_bar = QWidget() + self._quick_bar.setObjectName("quick_bar") + self._quick_bar.setFixedWidth(50) + quick_layout = QVBoxLayout(self._quick_bar) + quick_layout.setContentsMargins(0, 10, 0, 10) + quick_layout.setSpacing(6) + quick_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + + self._quick_btns = [] + for tooltip, icon_name, callback in self._get_quick_actions(): + btn = QPushButton() + btn.setFixedSize(34, 34) + btn.setToolTip(tooltip) + btn.setStyleSheet("border:none; background:transparent; border-radius:4px;") + try: + btn.setIcon(qta.icon(icon_name, color="#888")) + btn.setIconSize(QSize(16, 16)) + except Exception: + btn.setText(tooltip[0]) + btn.clicked.connect(callback) + quick_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter) + self._quick_btns.append((btn, icon_name)) + + quick_layout.addStretch() + h_layout.addWidget(self._quick_bar) + + # ── 右侧主内容 + right_widget = QWidget() + right_widget.setMouseTracking(True) + inner = QVBoxLayout(right_widget) inner.setContentsMargins(8, 10, 8, 8) inner.setSpacing(6) + h_layout.addWidget(right_widget) # ── 标题栏(仅此处可拖动窗口,避免分组内框选时带动面板) self._title_bar = QWidget() @@ -683,6 +731,12 @@ class PanelWindow(QWidget): bottom_layout.addWidget(self.menu_btn) bottom_layout.addStretch() + self.update_btn = QPushButton() + self.update_btn.setFixedSize(28, 28) + self.update_btn.setToolTip("检查更新") + self.update_btn.setStyleSheet("border:none; background:transparent; border-radius:4px;") + self.update_btn.clicked.connect(lambda: self._updater.check(silent_if_latest=False)) + bottom_layout.addWidget(self.update_btn) bottom_layout.addWidget(self.theme_btn) bottom_layout.addWidget(self.settings_btn) bottom_layout.addWidget(self.quit_btn) @@ -781,6 +835,26 @@ class PanelWindow(QWidget): if hasattr(self, "time_title_label"): self.time_title_label.setStyleSheet("font-size:11px; color: #888;") + # 左侧快捷栏 + bar_side_bg = "rgba(0,0,0,20)" if is_dark else "rgba(0,0,0,6)" + self._quick_bar.setStyleSheet( + f""" + QWidget#quick_bar {{ + background: {bar_side_bg}; + border-right: 1px solid {t['panel_border']}; + border-radius: 10px 0 0 10px; + }} + QPushButton {{ border:none; background:transparent; border-radius:4px; }} + QPushButton:hover {{ background:{t['header_hover']}; }} + """ + ) + for btn, icon_name in self._quick_btns: + try: + btn.setIcon(qta.icon(icon_name, color=ic)) + btn.setIconSize(QSize(14, 14)) + except Exception: + pass + # 底部工具栏 bar_bg2 = "rgba(0,0,0,30)" if is_dark else "rgba(0,0,0,8)" self.bottom_bar.setStyleSheet( @@ -798,6 +872,8 @@ class PanelWindow(QWidget): qta.icon("fa5s.sun" if is_dark else "fa5s.moon", color=ic) ) self.theme_btn.setIconSize(QSize(14, 14)) + self.update_btn.setIcon(qta.icon("fa5s.sync-alt", color=ic)) + self.update_btn.setIconSize(QSize(14, 14)) self.menu_btn.setIcon(qta.icon("fa5s.bars", color=ic)) self.menu_btn.setIconSize(QSize(14, 14)) self.settings_btn.setIcon(qta.icon("fa5s.cog", color=ic)) @@ -820,6 +896,23 @@ class PanelWindow(QWidget): if ret == QMessageBox.StandardButton.Yes: QApplication.quit() + def _restart_application(self): + import subprocess + subprocess.Popen([sys.executable] + sys.argv) + QApplication.quit() + + def _open_admin_cmd(self): + import ctypes + ctypes.windll.shell32.ShellExecuteW(None, "runas", "cmd.exe", None, None, 1) + + def _open_admin_powershell(self): + import ctypes + ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", None, None, 1) + + def _open_default_browser(self): + import webbrowser + webbrowser.open("https://www.baidu.com") + def _toggle_theme(self): theme.set_theme("light" if theme.name() == "dark" else "dark") self._apply_theme() @@ -1050,13 +1143,9 @@ class PanelWindow(QWidget): """收缩:隐藏内容区,窗口缩到只剩标题栏高度""" self._body.hide() self.weather_bar.hide() - # 记录当前高度,展开时恢复 + self._quick_bar.hide() self._expanded_height = self.height() - title_h = ( - self.container.layout().contentsMargins().top() - + self.container.layout().contentsMargins().bottom() - + self._title_bar.sizeHint().height() - ) + title_h = self._title_bar.sizeHint().height() + 20 self.setFixedHeight(title_h) ic = "#cccccc" if theme.name() == "dark" else "#555555" self._min_btn.setIcon(qta.icon("fa5s.chevron-down", color=ic)) @@ -1066,6 +1155,7 @@ class PanelWindow(QWidget): """展开:恢复内容区和窗口高度""" self.weather_bar.show() self._body.show() + self._quick_bar.show() h = getattr(self, "_expanded_height", 520) self.setMinimumHeight(MIN_H) self.setMaximumHeight(16777215) @@ -1096,7 +1186,11 @@ class PanelWindow(QWidget): y = int(database.get_setting("panel_y", "")) w = int(database.get_setting("panel_w", str(PANEL_W))) h = int(database.get_setting("panel_h", str(PANEL_H))) - screen = QApplication.primaryScreen().availableGeometry() + # 用保存坐标所在屏幕来限制范围,支持多显示器 + screen_obj = QApplication.screenAt(QPoint(x, y)) + if screen_obj is None: + screen_obj = QApplication.primaryScreen() + screen = screen_obj.availableGeometry() x = max(screen.left(), min(x, screen.right() - w)) y = max(screen.top(), min(y, screen.bottom() - h)) self._persist_w = w @@ -1119,7 +1213,11 @@ class PanelWindow(QWidget): # ── 显示/隐藏 ──────────────────────────────────────── def show_near(self, ball_pos: QPoint, ball_size: int): - screen = QApplication.primaryScreen().availableGeometry() + # 根据球所在屏幕来定位 panel,避免跑到主屏幕 + screen_obj = QApplication.screenAt(ball_pos) + if screen_obj is None: + screen_obj = QApplication.primaryScreen() + screen = screen_obj.availableGeometry() pw, ph = self.width(), self.height() bx, by = ball_pos.x(), ball_pos.y() @@ -1165,6 +1263,7 @@ class PanelWindow(QWidget): def minimize_to_ball(self): self.hide_panel() if hasattr(self, "_ball_ref"): + self._ball_ref.place_on_screen_of(self) self._ball_ref.show() self._ball_ref.setWindowOpacity(1.0) @@ -1360,20 +1459,11 @@ class PanelWindow(QWidget): QMenu::separator { height:1px; background:#444; margin:4px 8px; } """ ) - menu.addAction("🖥 显示面板").triggered.connect( - lambda: ( - self.show_near(self._ball_ref.pos(), self._ball_ref.width()) - if hasattr(self, "_ball_ref") - else None - ) - ) - menu.addAction("🔽 最小化到悬浮球").triggered.connect(self.minimize_to_ball) + for tooltip, _icon, callback in self._get_quick_actions(): + menu.addAction(tooltip).triggered.connect(callback) menu.addSeparator() - self._autostart_act = menu.addAction("🚀 开机自启: 开") - self._autostart_act.triggered.connect(self._toggle_autostart) - self._autostart_enabled = True - menu.addSeparator() - menu.addAction("❌ 退出").triggered.connect(QApplication.quit) + menu.addAction("🔄 重启本程序").triggered.connect(self._restart_application) + menu.addAction("❌ 退出本程序").triggered.connect(QApplication.quit) self.tray.setContextMenu(menu) self.tray.activated.connect(self._on_tray_activated) diff --git a/ui/settings_window.py b/ui/settings_window.py index f7a92b9..cf7b16b 100644 --- a/ui/settings_window.py +++ b/ui/settings_window.py @@ -270,6 +270,7 @@ class SettingsWindow(QDialog): grid = QHBoxLayout(card) grid.setContentsMargins(14, 14, 14, 14) grid.setSpacing(18) + grid.setAlignment(Qt.AlignmentFlag.AlignHCenter) donate_dir = os.path.join( os.path.dirname(os.path.dirname(__file__)), "assets", "imgs", "donate" diff --git a/ui/updater.py b/ui/updater.py new file mode 100644 index 0000000..4c4777a --- /dev/null +++ b/ui/updater.py @@ -0,0 +1,169 @@ +""" +软件自动更新模块 +- 检查版本:GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware +- 下载新版 exe,替换当前程序后重启 +""" +import os +import sys +import subprocess +import tempfile +from PyQt6.QtCore import QThread, pyqtSignal, QObject +from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication +import urllib.request + + +UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware" + + +def _current_version() -> str: + """从 main 模块取当前版本号,避免循环导入。""" + try: + import importlib + m = importlib.import_module("__main__") + return getattr(m, "__VERSION__", "0.0.0") + except Exception: + return "0.0.0" + + +def _is_newer(latest: str, current: str) -> bool: + try: + def _parse(v): + return tuple(int(x) for x in v.strip().lstrip("v").split(".")) + return _parse(latest) > _parse(current) + except Exception: + return latest != current + + +class _CheckWorker(QThread): + result = pyqtSignal(dict) # 成功:返回 data 字段 + error = pyqtSignal(str) + + def run(self): + try: + import json + import urllib.request + with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=10) as resp: + body = json.loads(resp.read().decode()) + if body.get("code") == 200: + self.result.emit(body["data"]) + else: + self.error.emit(body.get("msg", "接口返回异常")) + except Exception as e: + self.error.emit(str(e)) + + +class _DownloadWorker(QThread): + progress = pyqtSignal(int) # 0-100 + finished = pyqtSignal(str) # 下载完成,返回临时文件路径 + error = pyqtSignal(str) + + def __init__(self, url: str): + super().__init__() + self._url = url + + def run(self): + try: + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".exe") + tmp.close() + dst = tmp.name + + def _reporthook(count, block_size, total_size): + if total_size > 0: + pct = min(100, int(count * block_size * 100 / total_size)) + self.progress.emit(pct) + + urllib.request.urlretrieve(self._url, dst, _reporthook) + self.progress.emit(100) + self.finished.emit(dst) + except Exception as e: + self.error.emit(str(e)) + + +def _replace_and_restart(new_exe: str): + """ + 写一个批处理脚本:等待当前进程退出 → 覆盖 exe → 启动新版。 + 仅在打包为 exe 时执行真正替换;开发环境直接启动下载文件。 + """ + current_exe = sys.executable if getattr(sys, "frozen", False) else None + + if current_exe: + bat = tempfile.NamedTemporaryFile(delete=False, suffix=".bat", mode="w", encoding="gbk") + bat.write(f"""@echo off +ping 127.0.0.1 -n 3 >nul +move /y "{new_exe}" "{current_exe}" +start "" "{current_exe}" +del "%~f0" +""") + bat.close() + subprocess.Popen(["cmd", "/c", bat.name], creationflags=subprocess.CREATE_NO_WINDOW) + else: + # 开发环境:直接运行下载的 exe + subprocess.Popen([new_exe]) + + QApplication.quit() + + +class Updater(QObject): + """对外接口:调用 check() 即可。""" + + def __init__(self, parent=None): + super().__init__(parent) + self._parent_widget = parent + + def check(self, silent_if_latest: bool = False): + self._silent = silent_if_latest + self._worker = _CheckWorker() + self._worker.result.connect(self._on_check_result) + self._worker.error.connect(self._on_check_error) + self._worker.start() + + def _on_check_result(self, data: dict): + current = _current_version() + latest = data.get("latestVersion", "") + url = data.get("downloadUrl", "") + + if not _is_newer(latest, current): + if not self._silent: + QMessageBox.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}") + return + + notes = data.get("releaseNotes", "") or "" + msg = f"发现新版本 v{latest}(当前 v{current})" + if notes: + msg += f"\n\n更新内容:\n{notes}" + msg += "\n\n是否立即下载更新?" + + ret = QMessageBox.question( + self._parent_widget, "发现新版本", msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if ret != QMessageBox.StandardButton.Yes: + return + + self._download(url) + + def _on_check_error(self, err: str): + if not self._silent: + QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") + + def _download(self, url: str): + self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget) + self._progress.setWindowTitle("下载更新") + self._progress.setMinimumDuration(0) + self._progress.setValue(0) + + self._dl_worker = _DownloadWorker(url) + self._dl_worker.progress.connect(self._progress.setValue) + self._dl_worker.finished.connect(self._on_download_done) + self._dl_worker.error.connect(self._on_download_error) + self._progress.canceled.connect(self._dl_worker.terminate) + self._dl_worker.start() + + def _on_download_done(self, path: str): + self._progress.close() + _replace_and_restart(path) + + def _on_download_error(self, err: str): + self._progress.close() + QMessageBox.critical(self._parent_widget, "下载失败", f"下载更新失败:\n{err}")