diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/CleanDesktopOrganizer.spec b/CleanDesktopOrganizer.spec index 62aab32..a97916e 100644 --- a/CleanDesktopOrganizer.spec +++ b/CleanDesktopOrganizer.spec @@ -2,7 +2,8 @@ from PyInstaller.utils.hooks import collect_all datas = [('assets', 'assets'), ('logo.png', '.'), ('logo.ico', '.')] -binaries = [] +# update_helper.exe 打包进 onefile,运行时解压到 _MEIPASS +binaries = [('update_helper.exe', '.')] if __import__('os').path.exists('update_helper.exe') else [] hiddenimports = ['qtawesome'] tmp_ret = collect_all('qtawesome') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] diff --git a/Readme.md b/Readme.md index 1e6bbe8..157685a 100644 --- a/Readme.md +++ b/Readme.md @@ -1,5 +1,13 @@ -# 编译命令 -python -m PyInstaller CleanDesktopOrganizer.spec +# 打包成 onefile -# 清除缓存重新打包命令 +```powershell +# 第一步:先打 update_helper(主程序依赖它,CleanDesktopOrganizer.spec 会自动带上) +python -m PyInstaller update_helper.spec --clean + +# 第二步:打 onefile 主程序 python -m PyInstaller CleanDesktopOrganizer.spec --clean +``` + +产物:`dist\CleanDesktopOrganizer.exe`(单文件,约 100MB,含 update_helper) + +更新日志:`%TEMP%\update.log`(更新失败时查这个) diff --git a/__pycache__/shortcut_target.cpython-314.pyc b/__pycache__/shortcut_target.cpython-314.pyc index ee1076c..b331ab8 100644 Binary files a/__pycache__/shortcut_target.cpython-314.pyc and b/__pycache__/shortcut_target.cpython-314.pyc differ diff --git a/app_info.py b/app_info.py index ad0fc0d..4bded36 100644 --- a/app_info.py +++ b/app_info.py @@ -1,5 +1,5 @@ APP_NAME = "牛马软件柜" -__VERSION__ = "0.0.4" +__VERSION__ = "0.0.5" def app_title() -> str: diff --git a/db/__pycache__/__init__.cpython-314.pyc b/db/__pycache__/__init__.cpython-314.pyc index 788c4ac..a3e0171 100644 Binary files a/db/__pycache__/__init__.cpython-314.pyc and b/db/__pycache__/__init__.cpython-314.pyc differ diff --git a/db/__pycache__/database.cpython-314.pyc b/db/__pycache__/database.cpython-314.pyc index 5c3f1a0..90afefa 100644 Binary files a/db/__pycache__/database.cpython-314.pyc and b/db/__pycache__/database.cpython-314.pyc differ diff --git a/db/database.py b/db/database.py index 50fbc2d..718b2a1 100644 --- a/db/database.py +++ b/db/database.py @@ -5,7 +5,7 @@ import shutil # 数据库放到 Windows 常见的 APPDATA 目录(避免写在项目目录) APP_NAME = "CleanDesktopOrganizer" _SCHEMA_VERSION_KEY = "schema_version" -LATEST_SCHEMA_VERSION = 1 +LATEST_SCHEMA_VERSION = 7 _PROJECT_DB_PATH = os.path.join( os.path.dirname(os.path.dirname(__file__)), "data.db" @@ -119,8 +119,68 @@ def _apply_migrations(conn: sqlite3.Connection): if not _table_has_column(conn, "groups", "folder_path"): conn.execute("ALTER TABLE groups ADD COLUMN folder_path TEXT DEFAULT ''") + def m2(): + # 桌面文件框 + conn.execute( + """ + CREATE TABLE IF NOT EXISTS file_boxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '文件框', + x INTEGER DEFAULT 100, + y INTEGER DEFAULT 100, + w INTEGER DEFAULT 240, + h INTEGER DEFAULT 320, + collapsed INTEGER DEFAULT 0, + position INTEGER DEFAULT 0, + opacity REAL DEFAULT 0.5 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS file_box_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + box_id INTEGER NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + position INTEGER DEFAULT 0, + FOREIGN KEY (box_id) REFERENCES file_boxes(id) ON DELETE CASCADE + ) + """ + ) + + def m3(): + # 给已存在的 file_boxes 表补 opacity 字段 + if not _table_has_column(conn, "file_boxes", "opacity"): + conn.execute("ALTER TABLE file_boxes ADD COLUMN opacity REAL DEFAULT 0.5") + + def m4(): + # folder_path:不为空时 FileBox 变成文件夹浏览模式,实时显示该目录内容 + if not _table_has_column(conn, "file_boxes", "folder_path"): + conn.execute("ALTER TABLE file_boxes ADD COLUMN folder_path TEXT DEFAULT ''") + + def m5(): + # view_mode:拖入模式下的视图方式 —— "icon"(默认)或 "list" + if not _table_has_column(conn, "file_boxes", "view_mode"): + conn.execute("ALTER TABLE file_boxes ADD COLUMN view_mode TEXT DEFAULT 'icon'") + + def m6(): + # 把所有已存在的 file_boxes 的 opacity 刷成 0.5(之前是 1.0) + conn.execute("UPDATE file_boxes SET opacity=0.5 WHERE opacity IS NULL OR opacity >= 0.99") + + def m7(): + # group_id:关联主程序分组,非 NULL 时 FileBox 进入"分组联动模式" + if not _table_has_column(conn, "file_boxes", "group_id"): + conn.execute("ALTER TABLE file_boxes ADD COLUMN group_id INTEGER DEFAULT NULL") + migrations = { 1: m1, + 2: m2, + 3: m3, + 4: m4, + 5: m5, + 6: m6, + 7: m7, } cur = _get_schema_version(conn) @@ -320,3 +380,122 @@ def reset_groups_and_items(recreate_default_group: bool = True): conn.execute("INSERT INTO groups (name, position) VALUES ('常用程序', 0)") conn.commit() conn.close() + + +# ── FileBoxes ────────────────────────────────────────── +def get_file_boxes(): + conn = get_conn() + rows = conn.execute("SELECT * FROM file_boxes ORDER BY position").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_file_box(title: str, x: int = 100, y: int = 100, w: int = 240, h: int = 320) -> int: + conn = get_conn() + row = conn.execute("SELECT COALESCE(MAX(position), -1) FROM file_boxes").fetchone() + pos = (row[0] if row else -1) + 1 + cur = conn.execute( + "INSERT INTO file_boxes (title, x, y, w, h, position) VALUES (?,?,?,?,?,?)", + (title, x, y, w, h, pos), + ) + conn.commit() + box_id = cur.lastrowid + conn.close() + return box_id + + +def update_file_box(box_id: int, **kwargs): + """更新 file_boxes 记录,支持 title/x/y/w/h/collapsed/group_id/folder_path/view_mode/opacity。""" + if not kwargs: + return + allowed = ("title", "x", "y", "w", "h", "collapsed", "group_id", "folder_path", "view_mode", "opacity") + fields = [k for k in kwargs.keys() if k in allowed] + if not fields: + return + conn = get_conn() + sets = ", ".join(f"{f}=?" for f in fields) + conn.execute( + f"UPDATE file_boxes SET {sets} WHERE id=?", + [kwargs[f] for f in fields] + [box_id], + ) + conn.commit() + conn.close() + + +def create_file_box_from_group(group_id: int) -> int | None: + """把已有分组变成桌面文件框(group_id 关联)。返回新 file_boxes.id,失败返回 None。""" + conn = get_conn() + # 检查分组存在 + group = conn.execute("SELECT id, name FROM groups WHERE id=?", (group_id,)).fetchone() + if not group: + conn.close(); return None + # 检查是否已有关联的 FileBox(避免重复) + existing = conn.execute("SELECT id FROM file_boxes WHERE group_id=?", (group_id,)).fetchone() + if existing: + conn.close(); return existing["id"] + pos_row = conn.execute("SELECT COALESCE(MAX(position), -1) FROM file_boxes").fetchone() + pos = (pos_row[0] if pos_row else -1) + 1 + cur = conn.execute( + "INSERT INTO file_boxes (title, x, y, w, h, position, opacity, group_id) VALUES (?,?,?,?,?,?,?,?)", + (group["name"], 150, 150, 260, 340, pos, 0.5, group_id), + ) + conn.commit() + box_id = cur.lastrowid + conn.close() + return box_id + + +def delete_file_box(box_id: int): + conn = get_conn() + conn.execute("DELETE FROM file_box_items WHERE box_id=?", (box_id,)) + conn.execute("DELETE FROM file_boxes WHERE id=?", (box_id,)) + conn.commit() + conn.close() + + +def get_file_box_items(box_id: int): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM file_box_items WHERE box_id=? ORDER BY position", (box_id,) + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def add_file_box_item(box_id: int, name: str, path: str) -> int: + conn = get_conn() + row = conn.execute( + "SELECT COALESCE(MAX(position), -1) FROM file_box_items WHERE box_id=?", (box_id,) + ).fetchone() + pos = (row[0] if row else -1) + 1 + cur = conn.execute( + "INSERT INTO file_box_items (box_id, name, path, position) VALUES (?,?,?,?)", + (box_id, name, path, pos), + ) + conn.commit() + item_id = cur.lastrowid + conn.close() + return item_id + + +def delete_file_box_item(item_id: int): + conn = get_conn() + conn.execute("DELETE FROM file_box_items WHERE id=?", (item_id,)) + conn.commit() + conn.close() + + +def update_file_box_item(item_id: int, **kwargs): + if not kwargs: + return + fields = [k for k in kwargs.keys() if k in ("name", "path", "position")] + if not fields: + return + conn = get_conn() + sets = ", ".join(f"{f}=?" for f in fields) + conn.execute( + f"UPDATE file_box_items SET {sets} WHERE id=?", + [kwargs[f] for f in fields] + [item_id], + ) + conn.commit() + conn.close() diff --git a/main.py b/main.py index 74a4899..de289de 100644 --- a/main.py +++ b/main.py @@ -78,10 +78,16 @@ def _install_requirements_on_startup() -> None: class GlobalHotkey(QAbstractNativeEventFilter): - """用 Win32 RegisterHotKey 注册真正的全局快捷键 Alt+`""" - _HOTKEY_ID = 0x4E4D # 任意唯一 ID - _MOD_ALT = 0x0001 - _VK_BACKTICK = 0xC0 # VK_OEM_3 = ` 键 + """ + Win32 RegisterHotKey 注册两个全局快捷键: + - Alt+` : 唤醒/折叠面板(在"面板↔悬浮球"间切换) + - Ctrl+` : 缩小成悬浮球(任何状态都最小化到悬浮球) + """ + _HK_ALT = 0x4E4D # Alt+` + _HK_CTRL = 0x4E4E # Ctrl+` + _MOD_ALT = 0x0001 + _MOD_CTRL = 0x0002 + _VK_BACKTICK = 0xC0 # ` 键 def __init__(self, panel: "PanelWindow", ball: "FloatBall"): super().__init__() @@ -91,15 +97,16 @@ class GlobalHotkey(QAbstractNativeEventFilter): def install(self): if sys.platform != "win32": return - # 注册线程级别的全局热键,避免因主窗口 hide/show 导致 HWND 重建/失效 - ctypes.windll.user32.RegisterHotKey( - None, self._HOTKEY_ID, self._MOD_ALT, self._VK_BACKTICK - ) + user32 = ctypes.windll.user32 + user32.RegisterHotKey(None, self._HK_ALT, self._MOD_ALT, self._VK_BACKTICK) + user32.RegisterHotKey(None, self._HK_CTRL, self._MOD_CTRL, self._VK_BACKTICK) QApplication.instance().installNativeEventFilter(self) def uninstall(self): if sys.platform == "win32": - ctypes.windll.user32.UnregisterHotKey(None, self._HOTKEY_ID) + user32 = ctypes.windll.user32 + user32.UnregisterHotKey(None, self._HK_ALT) + user32.UnregisterHotKey(None, self._HK_CTRL) QApplication.instance().removeNativeEventFilter(self) def nativeEventFilter(self, event_type, message): @@ -107,18 +114,32 @@ class GlobalHotkey(QAbstractNativeEventFilter): import ctypes msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents WM_HOTKEY = 0x0312 - if msg.message == WM_HOTKEY and msg.wParam == self._HOTKEY_ID: - self._toggle() + if msg.message == WM_HOTKEY: + if msg.wParam == self._HK_ALT: + self._toggle_alt() + elif msg.wParam == self._HK_CTRL: + self._toggle_ctrl() return False, 0 - def _toggle(self): - if self._panel.is_really_visible(): - self._panel.minimize_to_ball() + def _toggle_alt(self): + """Alt+`:只唤醒面板(如果已显示就忽略,不再缩小)""" + if self._panel.isVisible(): + # 已经显示着了 —— 不做任何事(取消原来的缩小行为) + # 只尝试把它 raise 到最前 + self._panel.raise_() + self._panel.activateWindow() else: + # 悬浮球状态 → 唤醒面板 self._panel.show_near(self._ball.pos(), BALL_SIZE) self._panel.raise_() self._panel.activateWindow() + def _toggle_ctrl(self): + """Ctrl+`:强制缩小成悬浮球(任何状态都生效)""" + if self._panel.isVisible(): + self._panel.minimize_to_ball() + # 已经是悬浮球状态,什么都不做 + # ===================== 打包兼容核心函数 ===================== def get_resource_path(relative_path): """ @@ -182,7 +203,9 @@ def _wake_existing_or_exit() -> bool: def main(): - if not _wake_existing_or_exit(): + # 外部更新器启动时带 --from-update,跳过单实例检测(旧 mutex 可能还没完全释放) + from_update = "--from-update" in sys.argv + if not from_update and not _wake_existing_or_exit(): return _install_requirements_on_startup() @@ -216,6 +239,7 @@ def main(): panel._ball_ref = ball panel._apply_pin_window_layer() + panel._load_file_boxes() ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE)) ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos)) diff --git a/shortcut_target.py b/shortcut_target.py index 4e81c18..ab08e0a 100644 --- a/shortcut_target.py +++ b/shortcut_target.py @@ -1,12 +1,35 @@ """将用户选中的 .lnk / .url 解析为实际要存储的路径(目标程序或 URL)。""" import base64 import json +import locale import os import re import subprocess import sys +def _decode_subprocess_output(raw: bytes) -> str: + """ + 解码子进程输出:优先用系统默认编码,兼容中文 Windows 上 PowerShell 的 GBK 输出, + 同时也能正确处理 UTF-8 等情况。 + """ + if not raw: + return "" + # 先尝试系统默认编码(中文 Windows 上是 GBK) + encodings_to_try = [] + sys_enc = locale.getpreferredencoding(False) + if sys_enc: + encodings_to_try.append(sys_enc) + # 再尝试 UTF-8(带/不带 BOM) + encodings_to_try.extend(["utf-8-sig", "utf-8", "gbk"]) + for enc in encodings_to_try: + try: + return raw.decode(enc) + except (UnicodeDecodeError, LookupError): + continue + return raw.decode("utf-8", errors="replace") + + def _looks_like_shell_lnk(path: str) -> bool: """Windows Shell Link 文件头为 4C 00 00 00(扩展名异常时仍可识别)。""" try: @@ -50,14 +73,11 @@ def _resolve_windows_lnk(lnk_path: str) -> str | None: ps, ], capture_output=True, - text=True, - encoding="utf-8", - errors="replace", env=env, timeout=15, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) - raw = (r.stdout or "").strip().lstrip("\ufeff") + raw = _decode_subprocess_output(r.stdout or b"").strip().lstrip("\ufeff") if not raw: return None data = json.loads(raw) diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc index efb2ce1..a31195e 100644 Binary files a/ui/__pycache__/__init__.cpython-314.pyc and b/ui/__pycache__/__init__.cpython-314.pyc differ diff --git a/ui/__pycache__/ball.cpython-314.pyc b/ui/__pycache__/ball.cpython-314.pyc index a5e1fee..b300900 100644 Binary files a/ui/__pycache__/ball.cpython-314.pyc and b/ui/__pycache__/ball.cpython-314.pyc differ diff --git a/ui/__pycache__/dialog_style.cpython-314.pyc b/ui/__pycache__/dialog_style.cpython-314.pyc index 6a397a0..b9d22df 100644 Binary files a/ui/__pycache__/dialog_style.cpython-314.pyc and b/ui/__pycache__/dialog_style.cpython-314.pyc differ diff --git a/ui/__pycache__/dock.cpython-314.pyc b/ui/__pycache__/dock.cpython-314.pyc index 4b5584d..f79ecd0 100644 Binary files a/ui/__pycache__/dock.cpython-314.pyc and b/ui/__pycache__/dock.cpython-314.pyc differ diff --git a/ui/__pycache__/flow_layout.cpython-314.pyc b/ui/__pycache__/flow_layout.cpython-314.pyc index 342c24f..bca9538 100644 Binary files a/ui/__pycache__/flow_layout.cpython-314.pyc and b/ui/__pycache__/flow_layout.cpython-314.pyc differ diff --git a/ui/__pycache__/group.cpython-314.pyc b/ui/__pycache__/group.cpython-314.pyc index cedb468..3bae190 100644 Binary files a/ui/__pycache__/group.cpython-314.pyc and b/ui/__pycache__/group.cpython-314.pyc differ diff --git a/ui/__pycache__/item.cpython-314.pyc b/ui/__pycache__/item.cpython-314.pyc index 67ac674..34ecbb5 100644 Binary files a/ui/__pycache__/item.cpython-314.pyc and b/ui/__pycache__/item.cpython-314.pyc differ diff --git a/ui/__pycache__/settings_window.cpython-314.pyc b/ui/__pycache__/settings_window.cpython-314.pyc index 6552fac..ebc9e9f 100644 Binary files a/ui/__pycache__/settings_window.cpython-314.pyc and b/ui/__pycache__/settings_window.cpython-314.pyc differ diff --git a/ui/__pycache__/theme.cpython-314.pyc b/ui/__pycache__/theme.cpython-314.pyc index 6e096e4..7e53ece 100644 Binary files a/ui/__pycache__/theme.cpython-314.pyc and b/ui/__pycache__/theme.cpython-314.pyc differ diff --git a/ui/box.py b/ui/box.py new file mode 100644 index 0000000..a4f8f8b --- /dev/null +++ b/ui/box.py @@ -0,0 +1,954 @@ +# -*- coding: utf-8 -*- +"""桌面文件框:可拖动、可缩放、可吸附、可调透明度、可显示文件夹内容的独立桌面窗口。""" +import os +import sys +import subprocess + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QMenu, QListView, + QMessageBox, QApplication, QSizePolicy, QSlider, QSpinBox, QDialog, + QDialogButtonBox, QFormLayout, QAbstractItemView, QFileDialog, +) +from PyQt6.QtCore import ( + Qt, QPoint, QRect, QRectF, QSize, QPropertyAnimation, QEasingCurve, QTimer, + QDir, QModelIndex, QUrl, +) +from PyQt6.QtGui import ( + QPainter, QColor, QPen, QBrush, QIcon, QPixmap, QRegion, QPainterPath, + QFileSystemModel, QStandardItemModel, QStandardItem, +) +import qtawesome as qta + +from db import database +from ui.item import extract_icon +import ui.theme as theme +import ui.dialog_style as dialog_style +import shortcut_target + + +# ── 文件夹模式:双击条目打开文件/进入子目录 ──────────── +class FolderListView(QListView): + """显示文件夹内容的列表视图(文件夹浏览模式用)。""" + + def __init__(self, model: QFileSystemModel, root_path: str, parent=None): + super().__init__(parent) + self.setModel(model) + self.setRootIndex(model.index(root_path)) + self.setViewMode(QListView.ViewMode.IconMode) + self.setResizeMode(QListView.ResizeMode.Adjust) + self.setMovement(QListView.Movement.Static) + self.setUniformItemSizes(True) + self.setIconSize(QSize(40, 40)) + self.setGridSize(QSize(72, 88)) + self.setSpacing(4) + self.setWordWrap(True) + self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.doubleClicked.connect(self._on_double_clicked) + # customContextMenuPolicy 由 FileBox._install_drag_filter_recursive 统一设置 + self.setStyleSheet( + """ + QListView { background:transparent; color:#ddd; border:none; + font-size:11px; outline:none; } + QListView::item { padding:4px; border-radius:4px; } + QListView::item:hover { background:rgba(74,158,255,30); } + QListView::item:selected { background:rgba(74,158,255,50); } + """ + ) + + def _on_double_clicked(self, index: QModelIndex): + model = self.model() + path = model.filePath(index) + if not path or not os.path.exists(path): return + if os.path.isdir(path): + self.setRootIndex(index) + else: + try: os.startfile(path) + except Exception: + try: subprocess.Popen([path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as e: dialog_style.warning(self, "打开失败", str(e)) + + +_MENU_QSS = """ +QMenu { background:#2b2b2b; color:#ddd; border:1px solid #444; padding:4px; border-radius:6px; } +QMenu::item { padding:6px 16px; border-radius:3px; } +QMenu::item:selected { background:#3a5a8a; } +""" + + +# ── FileBox 窗口 ─────────────────────────────────────── +# ── 全局圆角常量(QSS 必须一致) ────────────────────────── +# 8px 与 Win11 DWMWCP_ROUND 的系统裁剪半径贴合: +# QSS 半径 > DWM 半径时(如 12px),圆弧外 8~12px 环带会露出方形亚克力底 +RADIUS = 8 + + +class FileBox(QWidget): + """桌面文件框:两种模式 —— 拖入卡片 / 文件夹浏览。""" + + MIN_W = 180 + MIN_H = 200 + _DRAG_MARGIN = 8 + _SNAP_THRESHOLD = 15 + _BOX_GAP = 2 + + _instances: list["FileBox"] = [] + _panel_ref = None + + def __init__(self, box_data: dict): + super().__init__() + self._box_data = box_data + self._box_id = box_data["id"] + self._collapsed = bool(box_data.get("collapsed", 0)) + self._opacity = float(box_data.get("opacity", 0.5)) + self._folder_path = (box_data.get("folder_path") or "").strip() + self._group_id = box_data.get("group_id") # None 或 int + # 模式优先级:group_id > folder_path > 拖入 + self._is_group_linked = self._group_id is not None + self._is_folder_mode = (not self._is_group_linked) and bool(self._folder_path) + self._view_mode = (box_data.get("view_mode") or "icon").strip() + if self._view_mode not in ("icon", "list"): + self._view_mode = "icon" + + self._drag_pos: QPoint | None = None + self._resize_edge: str | None = None + self._resize_start_global: QPoint | None = None + self._resize_start_geo: QRect | None = None + self._save_timer = QTimer(self) + self._save_timer.setSingleShot(True) + self._save_timer.setInterval(500) + self._save_timer.timeout.connect(self._save_all) + + # ⚠️ 顺序关键:先设 flags,再设透明属性 + # setWindowFlags() 会重建原生窗口并丢掉 WA_TranslucentBackground, + # 属性若设在 flags 之前 = 透明失效 = QSS 圆角画在不透明方形窗口上(直角根因) + self._apply_flags() + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setAcceptDrops(True) + self.setMouseTracking(True) + + w = max(self.MIN_W, int(box_data.get("w", 240))) + h = max(self.MIN_H, int(box_data.get("h", 320))) + self.setGeometry(int(box_data.get("x", 100)), int(box_data.get("y", 100)), w, h) + + self._build_ui() + self._apply_theme() + # 窗口本体强制透明(防止 Qt/palette 画默认背景色透出来) + self.setStyleSheet("FileBox { background: transparent; }") + # Windows 11: 让 DWM 在 OS 层强制圆角(物理裁剪,不依赖 QSS/半透明) + self._apply_win11_round_corners() + # 圆角和透明度现在完全靠 QSS(跟 dock.py 同款 container 方案), + # 不再需要 setWindowOpacity / _update_mask / paintEvent 自绘 + + FileBox._instances.append(self) + + if self._is_folder_mode: + self._setup_folder_model() + + if self._collapsed: + self._collapse_content() + + # ── Flags ────────────────────────────────────────────── + def _apply_flags(self): + # Tool:不出现在任务栏、不在 Alt+Tab 切换列表里 + # FramelessWindowHint:无边框 + # NoDropShadowWindowHint 不加——它会杀死 layered window 路径 → WA_TranslucentBackground 失效 + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool) + + def _apply_win11_round_corners(self): + """Windows 11: DWMWA_WINDOW_CORNER_PREFERENCE(33) = DWMWCP_ROUND(2)。 + 让 DWM 在 OS 层物理裁剪窗口四角 —— 无边框工具窗口默认是直角, + 这是唯一 100% 生效的圆角方案(dock 主面板靠 Win32 合成 API 也拿到了同样效果)。 + Windows 10 无此 API,静默失败,由 QSS border-radius + 半透明兜底。""" + if sys.platform != "win32": + return + try: + import ctypes + hwnd = int(self.winId()) # 强制创建原生窗口 + pref = ctypes.c_int(2) # DWMWCP_ROUND + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, 33, ctypes.byref(pref), ctypes.sizeof(pref) + ) + except Exception: + pass + + def resizeEvent(self, event): + super().resizeEvent(event) + + def showEvent(self, event): + super().showEvent(event) + # 保险:窗口若被重建(flags 变更/系统回收),透明属性、DWM 圆角、 + # Win32 磨砂合成全部会丢,每次 show 重新应用(幂等) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self._apply_win11_round_corners() + self._apply_win32_blur(theme.name() == "dark") + + # ── UI 构建 ──────────────────────────────────────────── + def _build_ui(self): + # FileBox 自身:透明背景(靠 WA_TranslucentBackground) + # 里面 container 用 QSS 画圆角 + 背景 + border(跟 dock.py 同款) + outer = QVBoxLayout(self) + outer.setContentsMargins(1, 1, 1, 1); outer.setSpacing(0) + + self._container = QWidget() + self._container.setObjectName("container") + root = QVBoxLayout(self._container) + root.setContentsMargins(0, 0, 0, 0); root.setSpacing(0) + outer.addWidget(self._container) + + self._title_bar = QWidget() + self._title_bar.setObjectName("title_bar") + self._title_bar.setFixedHeight(28); self._title_bar.setAcceptDrops(True) + h = QHBoxLayout(self._title_bar); h.setContentsMargins(10, 0, 6, 0); h.setSpacing(2) + + self._title_label = QLabel(self._box_data.get("title", "文件框")) + self._title_label.setCursor(Qt.CursorShape.PointingHandCursor) + self._title_label.setAcceptDrops(True) + self._title_label.mouseDoubleClickEvent = self._rename_box_title + h.addWidget(self._title_label); h.addStretch() + + if self._is_folder_mode: + h.addWidget(self._mk_title_btn("fa5s.folder-open", "选择文件夹", self._choose_folder)) + h.addWidget(self._mk_title_btn("fa5s.times", "切换为拖入模式", self._convert_to_drag_mode)) + else: + h.addWidget(self._mk_title_btn("fa5s.pen", "重命名", self._rename_box_title)) + # 视图切换按钮(只在拖入模式显示) + self._view_btn = self._mk_title_btn( + "fa5s.th-large" if self._view_mode == "list" else "fa5s.th", + "切换列表/图标视图", self._toggle_view_mode, + ) + h.addWidget(self._view_btn) + + h.addWidget(self._mk_title_btn("fa5s.adjust", "透明度(所有框)", self._open_opacity_dialog)) + h.addWidget(self._mk_title_btn("fa5s.minus", "最小化", self._toggle_collapse)) + + self._close_btn = QPushButton() + self._close_btn.setFixedSize(22, 22); self._close_btn.setAcceptDrops(True) + self._close_btn.setStyleSheet( + "QPushButton { border:none; background:transparent; border-radius:4px; }" + "QPushButton:hover { background:#e55; color:white; }" + ) + self._close_btn.setIcon(qta.icon("fa5s.times", color="#aaa")) + self._close_btn.setIconSize(QSize(11, 11)); self._close_btn.setToolTip("关闭") + self._close_btn.clicked.connect(self._close_box) + h.addWidget(self._close_btn) + + root.addWidget(self._title_bar) + + self._content = QWidget(); self._content.setAcceptDrops(True) + self._content.setStyleSheet("QWidget { background: transparent; }") + self._content_layout = QVBoxLayout(self._content) + self._content_layout.setContentsMargins(6, 4, 6, 6); self._content_layout.setSpacing(4) + + if self._is_folder_mode: + self._build_folder_content() + else: + self._build_drag_content() + + root.addWidget(self._content, 1) + self._install_drag_filter_recursive() + + # ── 拖入卡片内容(QListView + QStandardItemModel) ────── + def _build_drag_content(self): + """拖入卡片模式:用 QListView + QStandardItemModel,支持图标/列表两种视图。""" + self._item_model = QStandardItemModel(self) + self._item_list = QListView(self._content) + self._item_list.setModel(self._item_model) + # customContextMenuPolicy 由 FileBox._install_drag_filter_recursive 统一设置 + self._item_list.doubleClicked.connect(self._on_item_double_clicked) + self._item_list.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self._item_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # ⚠️ 必须 ExtendedSelection 才支持拖框选 + self._item_list.setDragEnabled(False) # 关掉内部拖(防止和外部文件拖放冲突) + self._item_list.setAcceptDrops(True) + self._item_list.setStyleSheet( + """ + QListView { background:transparent; color:#ddd; border:none; + font-size:11px; outline:none; } + QListView::item { padding:3px; border-radius:4px; } + QListView::item:hover { background:rgba(74,158,255,30); } + QListView::item:selected { background:rgba(74,158,255,50); } + """ + ) + self._apply_view_mode() + self._content_layout.addWidget(self._item_list) + + # 空提示 + self._empty_label = QLabel("拖文件进来") + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_label.setStyleSheet("color:#555; font-size:11px; background:transparent;") + self._content_layout.addWidget(self._empty_label) + self.refresh_items() + + def _apply_view_mode(self): + """根据 self._view_mode 切换 QListView 显示方式。""" + if self._view_mode == "list": + self._item_list.setViewMode(QListView.ViewMode.ListMode) + self._item_list.setIconSize(QSize(16, 16)) + self._item_list.setGridSize(QSize()) + self._item_list.setMovement(QListView.Movement.Static) + self._item_list.setResizeMode(QListView.ResizeMode.Adjust) + self._item_list.setSpacing(2) + self._item_list.setWordWrap(False) + else: + self._item_list.setViewMode(QListView.ViewMode.IconMode) + self._item_list.setIconSize(QSize(28, 28)) + self._item_list.setGridSize(QSize(72, 88)) + self._item_list.setMovement(QListView.Movement.Static) + self._item_list.setResizeMode(QListView.ResizeMode.Adjust) + self._item_list.setSpacing(4) + self._item_list.setWordWrap(True) + + def _toggle_view_mode(self): + self._view_mode = "list" if self._view_mode == "icon" else "icon" + self._apply_view_mode() + # 换按钮图标 + icon_name = "fa5s.th-large" if self._view_mode == "list" else "fa5s.th" + self._view_btn.setIcon(qta.icon(icon_name, color="#888")) + self._save_timer.start() + + # ── 文件夹浏览内容 ────────────────────────────────────── + def _build_folder_content(self): + self._fs_model = QFileSystemModel(self) + self._fs_model.setFilter(QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot) + self._fs_model.setReadOnly(True) + self._list_view = FolderListView(self._fs_model, self._folder_path, self._content) + self._content_layout.addWidget(self._list_view) + self._empty_label = QLabel("文件夹不存在") + self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._empty_label.setStyleSheet("color:#555; font-size:11px; background:transparent;") + if not os.path.isdir(self._folder_path): + self._list_view.hide(); self._empty_label.show() + self._content_layout.addWidget(self._empty_label) + + def _setup_folder_model(self): pass + def _refresh_folder(self): + if not self._is_folder_mode or not os.path.isdir(self._folder_path): return + self._fs_model.setRootPath(self._folder_path) + self._list_view.setRootIndex(self._fs_model.index(self._folder_path)) + + def _convert_to_drag_mode(self): + ret = dialog_style.question(self, "确认", "切换为拖入模式?(文件夹路径会被清除)", default_button=QMessageBox.StandardButton.No) + if ret != QMessageBox.StandardButton.Yes: return + database.update_file_box(self._box_id, folder_path="") + self._folder_path = ""; self._is_folder_mode = False + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + w = item.widget() + if w: w.setParent(None); w.deleteLater() + self._build_drag_content() + self._install_drag_filter_recursive() + # 重建标题栏按钮 + while self._title_bar.layout().count(): + item = self._title_bar.layout().takeAt(0) + w = item.widget() + if w: w.setParent(None); w.deleteLater() + self._build_title_bar_after_convert() + + def _build_title_bar_after_convert(self): + h = self._title_bar.layout() + self._title_label.setParent(None) + # 重新构建:label + stretch + rename + view + opacity + minimize + close + h.addWidget(self._title_label); h.addStretch() + h.addWidget(self._mk_title_btn("fa5s.pen", "重命名", self._rename_box_title)) + self._view_btn = self._mk_title_btn( + "fa5s.th-large" if self._view_mode == "list" else "fa5s.th", + "切换列表/图标视图", self._toggle_view_mode, + ) + h.addWidget(self._view_btn) + h.addWidget(self._mk_title_btn("fa5s.adjust", "透明度(所有框)", self._open_opacity_dialog)) + h.addWidget(self._mk_title_btn("fa5s.minus", "最小化", self._toggle_collapse)) + h.addWidget(self._close_btn) + + def _choose_folder(self): + path = QFileDialog.getExistingDirectory(self, "选择文件夹", self._folder_path or os.path.expanduser("~")) + if not path: return + self._folder_path = path + self._box_data["title"] = os.path.basename(path) or path + self._title_label.setText(self._box_data["title"]) + database.update_file_box(self._box_id, folder_path=path, title=self._box_data["title"]) + self._refresh_folder() + + def _mk_title_btn(self, icon_name, tip, callback): + btn = QPushButton(); btn.setFixedSize(22, 22); btn.setAcceptDrops(True) + btn.setStyleSheet( + "QPushButton { border:none; background:transparent; border-radius:4px; }" + "QPushButton:hover { background:rgba(255,255,255,30); }" + ) + btn.setIcon(qta.icon(icon_name, color="#888")) + btn.setIconSize(QSize(11, 11)); btn.setToolTip(tip) + btn.clicked.connect(callback) + return btn + + # ── FileBox 右键菜单(CustomContextMenu 信号 —— 覆盖所有子控件) ── + def _install_drag_filter_recursive(self): + # 统一用 CustomContextMenu 策略:Qt 会调用 customContextMenuRequested(global_pos) + # 对 FileBox 和所有子控件设 CustomContextMenuPolicy,然后统一转发到 FileBox._on_box_context_menu + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_box_context_menu) + + for child in self.findChildren(QWidget): + child.setAcceptDrops(True) + child.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + # lambda 默认参数 trick 绑定当前 child(避免循环变量 late-binding bug) + child.customContextMenuRequested.connect( + lambda pos, c=child: self.customContextMenuRequested.emit(c.mapToGlobal(pos)) + ) + + # 顶层容器装拖放 filter(只拦 DragEnter/Drop,不拦鼠标) + for w in (self._content, self._title_bar): + if w: w.installEventFilter(self) + + self.installEventFilter(self) + + # ── 主题 ──────────────────────────────────────────── + @staticmethod + def _with_opacity(rgba_str: str, opacity: float) -> str: + """给 rgba(...) 字符串替换 alpha 值。opacity 是 0~1 的浮点数。 + 如果输入是 #rrggbb 也先转换成 rgba(r,g,b,a)。""" + import re + s = rgba_str.strip().lower() + nums = [int(x) for x in re.findall(r"\d+", s)] + if len(nums) >= 3: + a = max(0, min(255, int(opacity * 255))) + return f"rgba({nums[0]},{nums[1]},{nums[2]},{a})" + return rgba_str + + def _apply_theme(self): + t = theme.current(); is_dark = theme.name() == "dark" + # ── container:圆角 + 半透明背景 + border(跟 dock.py 同款) ── + # panel_bg 的 alpha 由 _opacity 控制(覆盖 theme 自带 alpha) + panel_bg_rgba = self._with_opacity(t.get("panel_bg", "#1e1e1e"), self._opacity) + # panel_border 保留 theme 自带 alpha(装饰线,不二次乘) + panel_border_rgba = t.get("panel_border", "#888") + self._container.setStyleSheet( + f""" + QWidget#container {{ + background: {panel_bg_rgba}; + border: 1px solid {panel_border_rgba}; + border-radius: {RADIUS}px; + }} + """ + ) + # ── title_bar:比 container 稍深/稍浅,顶部圆角对齐 ── + # 自己造 header 颜色(不用 theme.header_bg,它是叠加色语义怪) + if is_dark: + header_rgb = (50, 50, 50) # 比 dark panel_bg 稍亮 + else: + header_rgb = (230, 230, 230) # 比 light panel_bg 稍深 + header_a = max(0, min(255, int(self._opacity * 255))) + title_fg = t.get("search_color", "#ddd") + self._title_bar.setStyleSheet( + f""" + QWidget#title_bar {{ + background: rgba({header_rgb[0]},{header_rgb[1]},{header_rgb[2]},{header_a}); + border-top-left-radius: {RADIUS}px; + border-top-right-radius: {RADIUS}px; + }} + """ + ) + self._title_label.setStyleSheet( + f"color:{title_fg}; font-size:12px; font-weight:bold; background:transparent;" + ) + # item 卡片文字色跟随主题 + if hasattr(self, "_item_list"): + item_fg = t.get("item_name_color", "#ddd") + self._item_list.setStyleSheet( + f""" + QListView {{ background:transparent; color:{item_fg}; border:none; + font-size:11px; outline:none; }} + QListView::item {{ padding:3px; border-radius:4px; }} + QListView::item:hover {{ background:rgba(74,158,255,30); }} + QListView::item:selected {{ background:rgba(74,158,255,50); }} + """ + ) + # ── 关键:Win32 磨砂合成(跟 dock.py 完全同款) ── + # dock 的半透明+圆角从来不靠 Qt 的 WA_TranslucentBackground, + # 而是靠这个 accent API:DWM 把窗口变成磨砂材质 + Win11 自动圆角。 + # 没有它,FileBox 就是黑色不透明直角窗口(Qt 透明路径在本机不生效)。 + self._apply_win32_blur(is_dark) + + def _apply_win32_blur(self, is_dark: bool): + """给窗口应用 Win32 磨砂合成(ACCENT_ENABLE_ACRYLICBLURBEHIND)。 + 复刻 dock.py 的做法:窗口获得 DWM 半透明磨砂材质,Win11 自动圆角。""" + if sys.platform != "win32": + return + try: + import ctypes + from ctypes import windll, sizeof, byref, c_int, Structure + + class ACCENT_POLICY(Structure): + _fields_ = [ + ("AccentState", c_int), + ("AccentFlags", c_int), + ("GradientColor", c_int), + ("AnimationId", c_int), + ] + + class WINDOWCOMPOSITIONATTRIBDATA(Structure): + _fields_ = [ + ("Attribute", c_int), + ("Data", ctypes.c_void_p), + ("SizeOfData", c_int), + ] + + hwnd = int(self.winId()) + accent = ACCENT_POLICY() + accent.AccentState = 4 # ACCENT_ENABLE_ACRYLICBLURBEHIND + # 磨砂底色:主题色 + 固定 alpha(跟 dock 一致用 0x90); + # 精细透明度仍由 QSS container 层的 _opacity 控制(两层叠加) + if is_dark: + accent.GradientColor = 0x901E1E1E # ABGR: 56% + RGB(30,30,30) + else: + accent.GradientColor = 0x90FFFFFF # ABGR: 56% + RGB(255,255,255) + accent.AccentFlags = 2 + + data = WINDOWCOMPOSITIONATTRIBDATA() + data.Attribute = 19 # WCA_ACCENT_POLICY + data.Data = ctypes.cast(byref(accent), ctypes.c_void_p) + data.SizeOfData = sizeof(accent) + + windll.user32.SetWindowCompositionAttribute(hwnd, byref(data)) + except Exception: + pass + + # ── 事件过滤器 ────────────────────────────────────── + # 只拦两类事件:1) 拖放(所有子控件);2) 边缘 resize(仅 FileBox 自身) + # 中间区域的鼠标事件 → 完全放行给 QListView 处理框选/选择 + def eventFilter(self, obj, event): + et = event.type() + + # ✅ 所有子控件的拖放事件 → 统一转给 FileBox + if et == event.Type.DragEnter: self.dragEnterEvent(event); return True + if et == event.Type.DragMove: self.dragMoveEvent(event); return True + if et == event.Type.Drop: self.dropEvent(event); return True + + # ✅ FileBox 自身边缘区域的鼠标 → resize / 全局 cursor + if obj is self: + if et == event.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton: + local = event.pos() + edge = self._edge_at(local) + if edge or self._resize_edge: + self.mousePressEvent(event); return True + if et == event.Type.MouseMove: + if self._resize_edge: + self.mouseMoveEvent(event); return True + edge = self._edge_at(event.pos()) + CUR = { + "r": Qt.CursorShape.SizeHorCursor, "l": Qt.CursorShape.SizeHorCursor, + "b": Qt.CursorShape.SizeVerCursor, "t": Qt.CursorShape.SizeVerCursor, + "br": Qt.CursorShape.SizeFDiagCursor, "tl": Qt.CursorShape.SizeFDiagCursor, + "bl": Qt.CursorShape.SizeBDiagCursor, "tr": Qt.CursorShape.SizeBDiagCursor, + } + self.setCursor(CUR.get(edge, Qt.CursorShape.ArrowCursor)) + if et == event.Type.MouseButtonRelease and self._resize_edge: + self.mouseReleaseEvent(event); return True + + # ❌ 子控件的所有鼠标事件 → 放行!QListView 需要处理框选/选择 + # ❌ FileBox 中间区域的鼠标 → 放行!(标题栏/子控件自己处理) + return super().eventFilter(obj, event) + + def _handle_drop(self, event): + if self._is_folder_mode: + target = self._folder_path + added = False + for url in event.mimeData().urls(): + src = url.toLocalFile() + if not src: continue + try: + dest = os.path.join(target, os.path.basename(src)) + os.rename(src, dest); added = True + except Exception: + try: + import shutil; shutil.move(src, dest); added = True + except Exception: pass + if added: self._refresh_folder() + else: + added = False + for url in event.mimeData().urls(): + path = url.toLocalFile() + if not path: continue + store = shortcut_target.path_for_storage(path) + name = shortcut_target.item_name_from_sources(path, store) + if self._is_group_linked: + database.add_item(self._group_id, name, store); added = True + else: + database.add_file_box_item(self._box_id, name, store); added = True + if added: + self.refresh_items() + self._notify_panel_refresh() + event.acceptProposedAction() + + def _notify_panel_refresh(self): + """通知主程序刷新分组(分组联动模式下 FileBox 改动需要同步到主面板)。""" + if FileBox._panel_ref and hasattr(FileBox._panel_ref, "refresh_groups"): + try: FileBox._panel_ref.refresh_groups() + except Exception: pass + + # ── 拖入 / 分组模式:刷新卡片到 QStandardItemModel ─────── + def refresh_items(self): + if self._is_folder_mode or not hasattr(self, "_item_model"): return + if self._is_group_linked: + items = database.get_items(self._group_id) + else: + items = database.get_file_box_items(self._box_id) + self._item_model.clear() + for it in items: + icon = extract_icon(it["path"]) + item = QStandardItem(icon, it["name"]) + item.setData(it["path"], Qt.ItemDataRole.UserRole) + item.setData(it["id"], Qt.ItemDataRole.UserRole + 1) + self._item_model.appendRow(item) + count = len(items) + self._empty_label.setVisible(count == 0) + self._item_list.setVisible(count > 0) + + def _open_item(self, idx: QModelIndex): + """打开 QListView 里的某个 item(双击 or 右键)。""" + path = idx.data(Qt.ItemDataRole.UserRole) + if not path or not os.path.exists(path): + dialog_style.warning(self, "打不开", f"文件不存在:\n{path}"); return + try: os.startfile(path) + except Exception: + try: subprocess.Popen([path], creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as e: dialog_style.warning(self, "启动失败", str(e)) + + def _on_item_double_clicked(self, idx: QModelIndex): + self._open_item(idx) + + def _toggle_collapse(self): + self._collapsed = not self._collapsed + if self._collapsed: self._content.hide(); self.setFixedHeight(36) + else: + self._content.show(); self.setMinimumHeight(self.MIN_H); self.setMaximumHeight(16777215) + self.resize(self.width(), max(self.MIN_H, int(self._box_data.get("h", 320)))) + self._save_timer.start() + + def _rename_box_title(self, _event=None): + title, ok = dialog_style.get_text(self, "重命名文件框", "新标题:", text=self._box_data.get("title", "文件框")) + if not ok or not title.strip(): return + title = title.strip(); self._box_data["title"] = title; self._title_label.setText(title) + database.update_file_box(self._box_id, title=title) + + # ── 透明度(所有框联动) ────────────────────────────── + def _open_opacity_dialog(self, _event=None): + all_boxes = [b for b in FileBox._instances if b.isVisible()] + if not all_boxes: return + dlg = QDialog(self); dlg.setWindowTitle("所有文件框透明度") + dlg.setStyleSheet( + """ + QDialog { background:#2b2b2b; color:#ddd; border:1px solid #444; } + QLabel { color:#ddd; } + QSlider::groove:horizontal { height:6px; background:#444; border-radius:3px; } + QSlider::handle:horizontal { background:#4a9eff; width:14px; height:14px; margin:-4px 0; border-radius:7px; } + QSpinBox { background:#3a3a3a; color:#ddd; border:1px solid #555; border-radius:4px; padding:3px; width:60px; } + QPushButton { background:#3a3a3a; color:#ddd; border:1px solid #555; border-radius:4px; padding:5px 16px; } + QPushButton:hover { background:#4a4a4a; } + """ + ) + form = QFormLayout(dlg) + slider = QSlider(Qt.Orientation.Horizontal); slider.setRange(20, 100); slider.setValue(int(self._opacity * 100)) + spin = QSpinBox(); spin.setRange(20, 100); spin.setSuffix(" %"); spin.setValue(int(self._opacity * 100)) + def apply(v: int): + """改 _opacity + 重设 QSS(透明度现在在 QSS rgba 里)。""" + for b in FileBox._instances: + b._opacity = v / 100.0 + b._apply_theme() + slider.valueChanged.connect(lambda v: (spin.setValue(v), apply(v))) + spin.valueChanged.connect(lambda v: (slider.setValue(v), apply(v))) + form.addRow("背景不透明度:", slider); form.addRow("数值:", spin) + hint = QLabel("只影响文件框背景色,标题和图标始终清晰"); hint.setStyleSheet("color:#888; font-size:10px;") + form.addRow("", hint) + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + btns.button(QDialogButtonBox.StandardButton.Ok).setText("确定") + btns.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") + btns.accepted.connect(dlg.accept); btns.rejected.connect(dlg.reject); form.addRow(btns) + original = {b._box_id: b._opacity for b in FileBox._instances} + if dlg.exec() == QDialog.DialogCode.Accepted: + opacity = spin.value() / 100.0 + for b in FileBox._instances: + b._opacity = opacity + database.update_file_box(b._box_id, opacity=opacity) + else: + for b in FileBox._instances: + b._opacity = original.get(b._box_id, 1.0) + b._apply_theme() + + def _close_box(self): + ret = dialog_style.question( + self, "确认", + f"关闭文件框「{self._box_data.get('title', '')}」?\n文件框会保存,下次启动仍然显示。", + default_button=QMessageBox.StandardButton.No, + ) + if ret != QMessageBox.StandardButton.Yes: return + database.update_file_box(self._box_id, collapsed=0); self.close() + + def _delete_box(self): + """彻底删除文件框:DB 里删掉所有记录 + 从 _instances 移除 + 关闭窗口。""" + ret = dialog_style.question( + self, "确认删除", + f"确定删除文件框「{self._box_data.get('title', '')}」?\n⚠️ 不可恢复,里面的条目也会一并删除。", + default_button=QMessageBox.StandardButton.No, + ) + if ret != QMessageBox.StandardButton.Yes: return + database.delete_file_box(self._box_id) + if self in FileBox._instances: FileBox._instances.remove(self) + self.close() # 触发 closeEvent → _save_all + remove from instances(重复 remove 安全) + + # ── 鼠标事件 ────────────────────────────────────────── + def _edge_at(self, local: QPoint) -> str | None: + x, y = local.x(), local.y(); w, h = self.width(), self.height(); m = self._DRAG_MARGIN + l, r, t, b = x <= m, x >= w - m, y <= m, y >= h - m + if t and l: return "tl" + if t and r: return "tr" + if b and l: return "bl" + if b and r: return "br" + if t: return "t" + if b: return "b" + if l: return "l" + if r: return "r" + return None + + def mousePressEvent(self, event): + if event.button() != Qt.MouseButton.LeftButton: return super().mousePressEvent(event) + local = event.pos() + if self._title_bar.geometry().contains(local): + self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft(); return + edge = self._edge_at(local) + if edge: + self._resize_edge = edge + self._resize_start_global = event.globalPosition().toPoint() + self._resize_start_geo = QRect(self.geometry()); return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + gp = event.globalPosition().toPoint() + if self._resize_edge and event.buttons() & Qt.MouseButton.LeftButton: self._do_resize(gp); return + if self._drag_pos and event.buttons() & Qt.MouseButton.LeftButton: self.move(gp - self._drag_pos); return + edge = self._edge_at(event.pos()) + CUR = { + "r": Qt.CursorShape.SizeHorCursor, "l": Qt.CursorShape.SizeHorCursor, + "b": Qt.CursorShape.SizeVerCursor, "t": Qt.CursorShape.SizeVerCursor, + "br": Qt.CursorShape.SizeFDiagCursor, "tl": Qt.CursorShape.SizeFDiagCursor, + "bl": Qt.CursorShape.SizeBDiagCursor, "tr": Qt.CursorShape.SizeBDiagCursor, + } + self.setCursor(CUR.get(edge, Qt.CursorShape.ArrowCursor)) + + def mouseReleaseEvent(self, event): + self._drag_pos = None; self._resize_edge = None + self._resize_start_global = None; self._resize_start_geo = None + self._save_timer.start(); self._do_snap() + super().mouseReleaseEvent(event) + + # ── FileBox 右键菜单(统一入口:所有子控件都转发到这里) ── + def _on_box_context_menu(self, global_pos): + """global_pos 是屏幕坐标。根据点到的位置决定弹哪个菜单: + - QListView item 上 → item 级菜单(打开/重命名/删除) + - 空白处 → FileBox 级菜单(刷新/新增/关闭) + """ + # 先检测:点在哪个 QListView 上? + for lv, is_folder_mode_lv in ( + (getattr(self, "_item_list", None), False), + (getattr(self, "_list_view", None), True), + ): + if lv is None: continue + local_lv = lv.mapFromGlobal(global_pos) + if not lv.rect().contains(local_lv): continue + + # 在 QListView 上 → 判断是 item 还是空白 + idx = lv.indexAt(local_lv) + if idx.isValid(): + # ✅ item 上右键 → 弹 item 级菜单 + if is_folder_mode_lv: + self._show_folder_lv_item_menu(lv, idx, global_pos) + else: + self._show_item_list_menu(idx, global_pos) + return + # QListView 空白处也弹 FileBox 菜单(让用户方便新增) + break # 跳出循环,继续往下弹 FileBox 菜单 + + # ✅ 标题栏按钮 / 标题栏空白 / FileBox 背景 / QListView 空白 + menu = QMenu(self); menu.setStyleSheet(_MENU_QSS) + if self._is_folder_mode: + menu.addAction(qta.icon("fa5s.sync", color="#4a9eff"), "刷新").triggered.connect(self._refresh_folder) + menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "选择文件夹").triggered.connect(self._choose_folder) + else: + menu.addAction(qta.icon("fa5s.sync", color="#4a9eff"), "刷新").triggered.connect(self.refresh_items) + menu.addAction(qta.icon("fa5s.file", color="#aaa"), "新增文件...").triggered.connect(self._pick_files) + menu.addAction(qta.icon("fa5s.folder", color="#aaa"), "新增文件夹...").triggered.connect(self._pick_folders) + menu.addSeparator() + menu.addAction(qta.icon("fa5s.plus-square", color="#aaa"), "新建文件夹").triggered.connect(self._create_new_folder_in_box) + menu.addSeparator() + menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名文件框").triggered.connect(self._rename_box_title) + menu.addSeparator() + menu.addAction(qta.icon("fa5s.window-close", color="#888"), "关闭文件框(保存)").triggered.connect(self._close_box) + menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除文件框(不可恢复)").triggered.connect(self._delete_box) + menu.exec(global_pos) + + def _show_item_list_menu(self, idx: QModelIndex, global_pos): + """拖入 / 分组模式:QListView 卡片级右键菜单。""" + path = idx.data(Qt.ItemDataRole.UserRole) + item_id = idx.data(Qt.ItemDataRole.UserRole + 1) + name = idx.data() + if not path: return + is_linked = self._is_group_linked + menu = QMenu(self); menu.setStyleSheet(_MENU_QSS) + + def open_folder(): + import ctypes + ctypes.windll.shell32.ShellExecuteW(None, "open", "explorer.exe", f'/select,"{path}"', None, 1) + + def rename(): + nonlocal idx, name + new_name, ok = dialog_style.get_text(self, "重命名", "新名称:", text=name) + if not ok or not new_name.strip(): return + new_name = new_name.strip() + if is_linked: database.update_item(item_id, name=new_name) + else: database.update_file_box_item(item_id, name=new_name) + self._item_model.itemFromIndex(idx).setText(new_name) + self._notify_panel_refresh() + + def delete(): + nonlocal idx + ret = dialog_style.question(self, "确认", f"删除「{name}」?", default_button=QMessageBox.StandardButton.No) + if ret != QMessageBox.StandardButton.Yes: return + if is_linked: database.delete_item(item_id) + else: database.delete_file_box_item(item_id) + self._item_model.removeRow(idx.row()) + if self._item_model.rowCount() == 0: + self._empty_label.show(); self._item_list.hide() + self._notify_panel_refresh() + + menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开").triggered.connect(lambda: self._open_item(idx)) + menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置").triggered.connect(open_folder) + menu.addAction(qta.icon("fa5s.pen", color="#aaa"), "重命名").triggered.connect(rename) + menu.addSeparator() + menu.addAction(qta.icon("fa5s.trash", color="#e55"), "删除").triggered.connect(delete) + menu.exec(global_pos) + + def _show_folder_lv_item_menu(self, lv, idx: QModelIndex, global_pos): + """文件夹浏览模式:QListView 条目级右键菜单。""" + path = lv.model().filePath(idx) + if not path or not os.path.exists(path): return + menu = QMenu(self); menu.setStyleSheet(_MENU_QSS) + menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开").triggered.connect(lambda: os.startfile(path)) + def open_folder(): + import ctypes + ctypes.windll.shell32.ShellExecuteW(None, "open", "explorer.exe", f'/select,"{path}"', None, 1) + menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置").triggered.connect(open_folder) + menu.exec(global_pos) + + def _pick_files(self): + files, _ = QFileDialog.getOpenFileNames(self, "选择文件") + if not files: return + added = 0 + for p in files: + store = shortcut_target.path_for_storage(p) + name = shortcut_target.item_name_from_sources(p, store) + database.add_file_box_item(self._box_id, name, store); added += 1 + if added: self.refresh_items() + + def _pick_folders(self): + folder = QFileDialog.getExistingDirectory(self, "选择文件夹") + if not folder: return + store = shortcut_target.path_for_storage(folder) + name = os.path.basename(folder) or folder + database.add_file_box_item(self._box_id, name, store) + self.refresh_items() + + def _create_new_folder_in_box(self): + """在拖入模式下创建一个真实文件夹并添加到文件框。""" + name, ok = dialog_style.get_text(self, "新建文件夹", "文件夹名称:", text="新建文件夹") + if not ok or not name.strip(): return + # 先选父目录 + parent = QFileDialog.getExistingDirectory(self, "选择创建位置") + if not parent: return + new_path = os.path.join(parent, name.strip()) + try: + os.makedirs(new_path, exist_ok=False) + except Exception as e: + dialog_style.warning(self, "失败", str(e)); return + store = shortcut_target.path_for_storage(new_path) + database.add_file_box_item(self._box_id, name.strip(), store) + self.refresh_items() + + def _do_resize(self, gp: QPoint): + if not self._resize_start_geo or not self._resize_start_global: return + d = gp - self._resize_start_global; g = self._resize_start_geo + x, y, w, h = g.x(), g.y(), g.width(), g.height(); e = self._resize_edge + min_w, min_h = self.MIN_W, 28 if self._collapsed else self.MIN_H + if "r" in e: w = max(min_w, w + d.x()) + if "l" in e: nw = max(min_w, w - d.x()); x += w - nw; w = nw + if "b" in e: h = max(min_h, h + d.y()) + if "t" in e: nh = max(min_h, h - d.y()); y += h - nh; h = nh + self.setGeometry(x, y, w, h) + + # ── 磁吸 ────────────────────────────────────────────── + def _do_snap(self): + t = self._SNAP_THRESHOLD; gap = self._BOX_GAP + sx, sy, sw, sh = self.x(), self.y(), self.width(), self.height(); changed = False + screen = QApplication.screenAt(self.geometry().center()) + if not screen: screen = QApplication.primaryScreen() + geom = screen.availableGeometry() + candidates = [] + if abs(sy - geom.top()) <= t: candidates.append(("top", geom.top())) + if abs((sy + sh) - geom.bottom()) <= t: candidates.append(("bottom", geom.bottom() - sh)) + if abs(sx - geom.left()) <= t: candidates.append(("left", geom.left())) + if abs((sx + sw) - geom.right()) <= t: candidates.append(("right", geom.right() - sw)) + if candidates: + edge, pos = min(candidates, key=lambda c: (abs(c[1] - sy) if c[0] in ("top", "bottom") else abs(c[1] - sx))) + if edge in ("top", "bottom"): sy = pos + else: sx = pos + changed = True + for other in FileBox._instances: + if other is self: continue + ox, oy, ow, oh = other.x(), other.y(), other.width(), other.height() + x_c = [("ll", abs(sx - ox), ox), ("lr", abs(sx - (ox + ow)), ox + ow + gap), + ("rl", abs((sx + sw) - ox), ox - sw - gap), ("rr", abs((sx + sw) - (ox + ow)), ox + ow - sw), + ("cx", abs((sx + sw/2) - (ox + ow/2)), ox + ow/2 - sw/2)] + y_c = [("tt", abs(sy - oy), oy), ("tb", abs(sy - (oy + oh)), oy + oh + gap), + ("bt", abs((sy + sh) - oy), oy - sh - gap), ("bb", abs((sy + sh) - (oy + oh)), oy + oh - sh), + ("cy", abs((sy + sh/2) - (oy + oh/2)), oy + oh/2 - sh/2)] + x_best = min(x_c, key=lambda c: c[1]) + if x_best[1] <= t: + sx = int(x_best[2]); changed = True + if x_best[0] not in ("lr", "rl"): sw = max(sw, ow) + y_best = min(y_c, key=lambda c: c[1]) + if y_best[1] <= t: + sy = int(y_best[2]); changed = True + if y_best[0] not in ("tb", "bt"): sh = max(sh, oh) + sx = max(geom.left(), min(sx, geom.right() - sw)) + sy = max(geom.top(), min(sy, geom.bottom() - sh)) + if changed or sx != self.x() or sy != self.y() or sw != self.width() or sh != self.height(): + self._snap_anim = QPropertyAnimation(self, b"geometry") + self._snap_anim.setDuration(150); self._snap_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._snap_anim.setStartValue(self.geometry()) + self._snap_anim.setEndValue(QRect(sx, sy, sw, sh)) + self._snap_anim.start() + + # ── 持久化 ──────────────────────────────────────────── + def _save_all(self): + database.update_file_box( + self._box_id, + x=self.x(), y=self.y(), w=self.width(), h=self.height(), + collapsed=1 if self._collapsed else 0, + opacity=self._opacity, + folder_path=self._folder_path if self._is_folder_mode else "", + view_mode=self._view_mode if not self._is_folder_mode else "icon", + title=self._box_data.get("title", ""), + ) + + def closeEvent(self, event): + if self in FileBox._instances: FileBox._instances.remove(self) + self._save_all(); event.accept() + + # ── 顶层窗口兜底拖放 ───────────────────────────────── + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): event.acceptProposedAction() + else: event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): event.acceptProposedAction() + + def dropEvent(self, event): + self._handle_drop(event) diff --git a/ui/dock.py b/ui/dock.py index c96212b..364570c 100644 --- a/ui/dock.py +++ b/ui/dock.py @@ -432,6 +432,8 @@ class PanelWindow(QWidget): def showEvent(self, event): super().showEvent(event) self._apply_win32_blur(theme.name() == "dark") + # 亚克力方形底靠 DWM 圆角裁剪盖掉;窗口重建后属性丢失,每次 show 重应用 + self._apply_win11_round_corners() if not self._screen_hooked: wh = self.windowHandle() if wh is not None: @@ -571,6 +573,8 @@ class PanelWindow(QWidget): 每项: (tooltip, qtawesome_icon, callback) 排除重启/退出。""" return [ + ("新建桌面文件框", "fa5s.window-restore", self._create_file_box), + ("新建文件夹框", "fa5s.folder-open", self._create_folder_box), ("微信多开", "fa5b.weixin", self._open_wechat_multi), ("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd), ("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell), @@ -840,7 +844,7 @@ class PanelWindow(QWidget): f""" QWidget#container {{ background: {t['panel_bg']}; - border-radius: 12px; + border-radius: 8px; border: 1px solid {t['panel_border']}; }} """ @@ -964,7 +968,7 @@ class PanelWindow(QWidget): f""" QWidget#bottom_bar {{ background: {bar_bg2}; - border-radius: 0 0 12px 12px; + border-radius: 0 0 8px 8px; border-top: 1px solid {t['panel_border']}; }} QPushButton {{ border:none; background:transparent; border-radius:4px; }} @@ -993,6 +997,14 @@ class PanelWindow(QWidget): if item and item.widget(): item.widget()._apply_theme() + # ✅ 主题变化时通知所有 FileBox 重绘 + try: + from ui.box import FileBox + for b in FileBox._instances: + b._apply_theme() # 重设 QSS(container + title_bar 都会刷新) + except Exception: + pass + def _apply_tooltip_theme(self, t: dict, is_dark: bool): """让所有控件 tooltip 跟随主题(含快捷栏按钮)。""" from PyQt6.QtGui import QPalette, QColor @@ -1093,6 +1105,54 @@ class PanelWindow(QWidget): dlg = WechatMultiDialog(self) dlg.exec() + # ── FileBoxes ───────────────────────────────────────── + _file_boxes: list = [] + + def _create_file_box(self): + """新建一个空的拖入卡片文件框。""" + self._create_box_common(title="文件框", folder_path="") + + def _create_folder_box(self): + """新建文件夹框 — 先让用户选文件夹。""" + from PyQt6.QtWidgets import QFileDialog + folder = QFileDialog.getExistingDirectory(self, "选择文件夹", os.path.expanduser("~")) + if not folder: return + title = os.path.basename(folder) or folder + self._create_box_common(title=title, folder_path=folder) + + def _create_box_common(self, title: str, folder_path: str): + from ui.box import FileBox + # 注册 panel 引用 —— FileBox 在 mousePress 时会 raise_ 主面板 + FileBox._panel_ref = self + screen = QApplication.primaryScreen().availableGeometry() + idx = len(self._file_boxes) + x = screen.center().x() - 120 + idx * 20 + y = screen.center().y() - 160 + idx * 20 + new_id = database.add_file_box(title, x=x, y=y, w=240, h=320) + database.update_file_box(new_id, folder_path=folder_path) # 文件夹框模式 + box = FileBox({ + "id": new_id, "title": title, "x": x, "y": y, + "w": 240, "h": 320, "folder_path": folder_path, + }) + self._file_boxes.append(box) + box.show(); box.raise_(); box.activateWindow() + # 主面板始终在 FileBox 之上 + self.raise_() + + def _load_file_boxes(self): + """启动时从 DB 恢复所有文件框。""" + from ui.box import FileBox + FileBox._panel_ref = self + for d in database.get_file_boxes(): + box = FileBox(d) + self._file_boxes.append(box) + box.show() + + def _close_all_file_boxes(self): + for b in list(self._file_boxes): + b.close() + self._file_boxes.clear() + def _toggle_theme(self): theme.set_theme("light" if theme.name() == "dark" else "dark") self._apply_theme() @@ -1210,6 +1270,18 @@ class PanelWindow(QWidget): self._open_url_in_browser("https://www.doubao.com/chat/", "豆包") def refresh_groups(self): + # 同步刷新所有分组联动的 FileBox(双向联动) + try: + from ui.box import FileBox + for b in FileBox._instances: + if b._is_group_linked: + b.refresh_items() + # 如果分组被删了 → 自动解除关联 + if not database.get_items(b._group_id): + pass # 分组可能空了,正常 + except Exception: + pass + # 记录当前折叠状态 collapsed_ids = set() for i in range(self.groups_layout.count() - 1): @@ -1308,6 +1380,8 @@ class PanelWindow(QWidget): self.setGeometry(geo) self.show() self._apply_win32_blur(theme.name() == "dark") + # setWindowFlags 重建了原生窗口,DWM 圆角属性丢失,重新应用 + self._apply_win11_round_corners() ball = getattr(self, "_ball_ref", None) if ball is not None: ball.set_stays_on_top(on_top) @@ -1653,7 +1727,8 @@ class PanelWindow(QWidget): if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos: self._dock_state = "none" self._is_collapsed = False - self.move(gp - self._win_drag_pos) + raw_pos = gp - self._win_drag_pos + self.move(self._magnetic_snap(raw_pos)) return True # 悬停时更新光标 self._update_cursor(gp) @@ -1676,7 +1751,8 @@ class PanelWindow(QWidget): if event.buttons() & Qt.MouseButton.LeftButton and self._win_drag_pos: self._dock_state = "none" self._is_collapsed = False - self.move(event.globalPosition().toPoint() - self._win_drag_pos) + raw_pos = event.globalPosition().toPoint() - self._win_drag_pos + self.move(self._magnetic_snap(raw_pos)) return self._update_cursor(event.globalPosition().toPoint()) @@ -1764,6 +1840,39 @@ class PanelWindow(QWidget): self.show_near(self._ball_ref.pos(), self._ball_ref.width()) # ── 边缘吸附与收起 ───────────────────────────────────── + _SNAP_THRESHOLD = 30 # 拖拽中磁吸触发阈值(像素) + + def _magnetic_snap(self, pos: QPoint) -> QPoint: + """拖拽中实时磁吸:窗口接近边缘时自动吸附到边缘,给用户"磁性"手感。""" + screen = QApplication.screenAt(pos) + if not screen: + screen = QApplication.primaryScreen() + geom = screen.availableGeometry() + x, y, w, h = pos.x(), pos.y(), self.width(), self.height() + t = self._SNAP_THRESHOLD + + # 优先吸附距离最近的边缘 + top_dist = abs(y - geom.top()) + bottom_dist = abs((y + h) - geom.bottom()) + left_dist = abs(x - geom.left()) + right_dist = abs((x + w) - geom.right()) + min_dist = min(top_dist, bottom_dist, left_dist, right_dist) + + if min_dist <= t: + if min_dist == top_dist: + y = geom.top() + elif min_dist == bottom_dist: + y = geom.bottom() - h + elif min_dist == left_dist: + x = geom.left() + else: + x = geom.right() - w + + # 限制在屏幕范围内 + x = max(geom.left(), min(x, geom.right() - w)) + y = max(geom.top(), min(y, geom.bottom() - h)) + return QPoint(x, y) + def _check_dock_state(self): if self._pinned: self._dock_state = "none" @@ -1772,10 +1881,11 @@ class PanelWindow(QWidget): screen = QApplication.screenAt(self.geometry().center()) if not screen: screen = QApplication.primaryScreen() - screen_geom = screen.geometry() + # 用 availableGeometry 排除任务栏区域,避免吸附到任务栏上 + screen_geom = screen.availableGeometry() x, y, w, h = self.x(), self.y(), self.width(), self.height() - threshold = 20 + threshold = 30 # 吸附阈值(像素) # 检测顶部吸附 if abs(y - screen_geom.top()) <= threshold: @@ -1803,7 +1913,7 @@ class PanelWindow(QWidget): if not is_hovered: screen = QApplication.screenAt(cursor_pos) if screen: - screen_geom = screen.geometry() + screen_geom = screen.availableGeometry() tolerance = 3 if self._dock_state == "top" and cursor_pos.y() <= screen_geom.top() + tolerance: if geom.left() <= cursor_pos.x() <= geom.right(): @@ -1831,7 +1941,7 @@ class PanelWindow(QWidget): screen = QApplication.screenAt(self.geometry().center()) if not screen: screen = QApplication.primaryScreen() - screen_geom = screen.geometry() + screen_geom = screen.availableGeometry() x, y, w, h = self.x(), self.y(), self.width(), self.height() target_x, target_y = x, y @@ -1854,7 +1964,7 @@ class PanelWindow(QWidget): screen = QApplication.screenAt(self.geometry().center()) if not screen: screen = QApplication.primaryScreen() - screen_geom = screen.geometry() + screen_geom = screen.availableGeometry() x, y, w, h = self.x(), self.y(), self.width(), self.height() target_x, target_y = x, y @@ -1881,6 +1991,26 @@ class PanelWindow(QWidget): self._dock_anim.finished.connect(self.raise_) self._dock_anim.start() + def _apply_win11_round_corners(self): + """Windows 11: DWMWA_WINDOW_CORNER_PREFERENCE(33) = DWMWCP_ROUND(2)。 + + 必须调用:亚克力合成层(SetWindowCompositionAttribute)作用于**整个方形窗口**, + QSS border-radius 只能裁 Qt 内容层,裁不掉 OS 层的方形亚克力 → + 四角圆弧外露出方形底座("圆角下面垫着直角"的根因)。 + DWMWCP_ROUND 让 DWM 在 OS 层物理裁剪窗口四角,亚克力方形一起被裁掉。 + setWindowFlags 重建窗口后 DWM 属性会丢,需在 showEvent / 置顶切换后重应用。""" + if sys.platform != "win32": + return + try: + import ctypes + hwnd = int(self.winId()) + pref = ctypes.c_int(2) # DWMWCP_ROUND + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, 33, ctypes.byref(pref), ctypes.sizeof(pref) + ) + except Exception: + pass + def _apply_win32_blur(self, is_dark: bool): if sys.platform != "win32": return diff --git a/ui/flow_layout.py b/ui/flow_layout.py index bdbb9d8..7469905 100644 --- a/ui/flow_layout.py +++ b/ui/flow_layout.py @@ -25,6 +25,7 @@ class FlowContainer(QWidget): def add_widget(self, w: QWidget): w.setParent(self) + w.show() # ⚠️ 关键:必须显式 show,否则从 clear_widgets 里 hide() 后不会自动显示 self._widgets.append(w) if self.width() > 0: self._relayout() diff --git a/ui/group.py b/ui/group.py index 4681f8b..cea332a 100644 --- a/ui/group.py +++ b/ui/group.py @@ -486,6 +486,13 @@ class GroupWidget(QWidget): self._add_btn.click() if e.button() == Qt.MouseButton.LeftButton else None ) + # 🖥️ 变成桌面文件框按钮(放在加号左边) + self._dock_btn = QPushButton() + self._dock_btn.setFixedSize(22, 22) + self._dock_btn.setToolTip("变成桌面文件框") + self._dock_btn.setStyleSheet("border:none; background:transparent;") + self._dock_btn.clicked.connect(self._toggle_dock_box) + self._more_btn = QPushButton() self._more_btn.setFixedSize(22, 22) self._more_btn.setStyleSheet("border:none; background:transparent;") @@ -496,6 +503,7 @@ class GroupWidget(QWidget): h_layout.addWidget(self.title_label) h_layout.addStretch() h_layout.addWidget(self.path_label) + h_layout.addWidget(self._dock_btn) h_layout.addWidget(self._add_btn) h_layout.addWidget(self._more_btn) @@ -511,10 +519,65 @@ class GroupWidget(QWidget): n = item_count_for_group(self.group_data["id"]) self.title_label.setText(f"{self.group_data['name']} ({n})") + def _has_docked_box(self) -> bool: + """检查本分组是否已有关联的桌面文件框。""" + conn = database.get_conn() + row = conn.execute("SELECT id FROM file_boxes WHERE group_id=?", + (self.group_data["id"],)).fetchone() + conn.close() + return row is not None + + def _toggle_dock_box(self): + """点击 🖥️ 按钮:创建或恢复/聚焦已关联的桌面文件框。""" + from db import database as db_mod + from ui.box import FileBox + + gid = self.group_data["id"] + box_id = db_mod.create_file_box_from_group(gid) + if box_id is None: + # 已存在 → 找出来 show + raise + conn = db_mod.get_conn() + row = conn.execute("SELECT id FROM file_boxes WHERE group_id=?", (gid,)).fetchone() + conn.close() + if row: + box_id = row["id"] + + # 拿到 PanelWindow 引用 + main = self.window() if hasattr(self, "window") else None + from ui.dock import PanelWindow + if isinstance(main, PanelWindow): + main._load_file_boxes() + else: + # 直接构造一个 FileBox 并 show + conn = db_mod.get_conn() + box_row = conn.execute("SELECT * FROM file_boxes WHERE id=?", (box_id,)).fetchone() + conn.close() + if box_row: + box_data = dict(box_row) + # 如果 FileBox._panel_ref 还没设,尝试找一个 + if FileBox._panel_ref is None: + # 扫描 QApplication topLevelWidgets 找 PanelWindow + from PyQt6.QtWidgets import QApplication + for w in QApplication.topLevelWidgets(): + if isinstance(w, PanelWindow): + FileBox._panel_ref = w; break + box = FileBox(box_data) + box.show() + + self._apply_theme() # 刷新按钮状态色 + def _apply_theme(self): t = theme.current() ic = "#cccccc" if theme.name() == "dark" else "#555555" + # 已关联文件框 → 用蓝色高亮按钮 + if self._has_docked_box(): + dock_color = "#4a9eff" + self._dock_btn.setToolTip("已在桌面文件框(点击聚焦)") + else: + dock_color = ic + self._dock_btn.setToolTip("变成桌面文件框") + self.header.setStyleSheet(f""" QWidget {{ background:{t['header_bg']}; border-radius:5px; }} QWidget:hover {{ background:{t['header_hover']}; }} @@ -526,6 +589,8 @@ class GroupWidget(QWidget): arrow = "fa5s.chevron-down" if not self.collapsed else "fa5s.chevron-right" self.toggle_icon.setPixmap(qta.icon(arrow, color=ic).pixmap(14, 14)) + self._dock_btn.setIcon(qta.icon("fa5s.desktop", color=dock_color)) + self._dock_btn.setIconSize(QSize(11, 11)) self._add_btn.setIcon(qta.icon("fa5s.plus", color=ic)) self._add_btn.setIconSize(QSize(11, 11)) self._more_btn.setIcon(qta.icon("fa5s.ellipsis-h", color=ic)) @@ -573,6 +638,7 @@ class GroupWidget(QWidget): QMenu::item:selected {{ background:{t['menu_selected']}; }} """) rename_act = menu.addAction("重命名") + dock_act = menu.addAction("🖥️ 变成桌面文件框") del_act = menu.addAction("删除分组") action = menu.exec(QCursor.pos()) if action == rename_act: @@ -583,6 +649,28 @@ class GroupWidget(QWidget): database.rename_group(self.group_data["id"], name.strip()) self.group_data["name"] = name.strip() self._update_title_count() + elif action == dock_act: + from db import database as db_mod + from ui.dock import PanelWindow # 延迟 import + box_id = db_mod.create_file_box_from_group(self.group_data["id"]) + if box_id is None: + dialog_style.information(self, "提示", "该分组已关联桌面文件框") + else: + # 通知主面板立即加载这个新 FileBox + main_window = self.window() + if isinstance(main_window, PanelWindow): + main_window._load_file_boxes() + # 找到刚创建的 FileBox 并 show + from ui.box import FileBox + for b in FileBox._instances: + if b._group_id == self.group_data["id"] and not b.isVisible(): + b.show() + break + dialog_style.information( + self, "成功", + f"分组「{self.group_data['name']}」已变成桌面文件框 ✅\n" + f"两者数据实时联动" + ) elif action == del_act: ret = dialog_style.question( self, diff --git a/ui/item.py b/ui/item.py index 3c47f51..54184c7 100644 --- a/ui/item.py +++ b/ui/item.py @@ -265,6 +265,7 @@ class ItemWidget(QWidget): menu.setStyleSheet(menu_style) open_act = menu.addAction(qta.icon("fa5s.play", color="#4a9eff"), "打开") + open_folder_act = menu.addAction(qta.icon("fa5s.folder-open", color="#aaa"), "打开文件夹位置") menu.addSeparator() item_id = self.item_data.get("id") @@ -291,6 +292,8 @@ class ItemWidget(QWidget): if action == open_act: self._launch() + elif action == open_folder_act: + self._open_folder() elif action == rename_act: self._rename() elif repath_act and action == repath_act: @@ -423,13 +426,40 @@ class ItemWidget(QWidget): except OSError: try: import subprocess + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32": - # shell=True 无法直接“运行” .lnk,需交给 start 做关联打开 - subprocess.Popen( - ["cmd", "/c", "start", "", path], - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) + # 尝试直接启动(适用于 .exe / .bat / .cmd) + ext = os.path.splitext(path)[1].lower() + if ext in (".exe", ".bat", ".cmd"): + subprocess.Popen([path], creationflags=creationflags) + else: + # .lnk / .url 等走 start 关联打开 + subprocess.Popen( + ["cmd", "/c", "start", "", path], + creationflags=creationflags, + ) else: subprocess.Popen(["xdg-open", path]) except Exception as e: print(f"启动失败: {path!r} -> {e}") + + def _open_folder(self): + """在系统资源管理器中打开文件所在文件夹并选中该文件。""" + path = (self.item_data.get("path") or "").strip() + if not path: + return + path = os.path.normpath(path) + try: + if sys.platform == "win32": + # explorer /select,"路径" —— /select,"..." 必须作为一个整体参数 + import ctypes + ctypes.windll.shell32.ShellExecuteW( + None, "open", "explorer.exe", + '/select,"{}"'.format(path), + None, 1, # SW_SHOWNORMAL + ) + else: + folder = os.path.dirname(path) + subprocess.Popen(["xdg-open", folder]) + except Exception as e: + print(f"打开文件夹失败: {path!r} -> {e}") diff --git a/ui/updater.py b/ui/updater.py index d3c11c3..a122bc0 100644 --- a/ui/updater.py +++ b/ui/updater.py @@ -1,16 +1,20 @@ # -*- coding: utf-8 -*- """ -软件自动更新模块 -- 检查版本:GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware -- 下载新版 exe,替换当前程序后重启 +软件自动更新模块 —— 主程序负责"下载 + 启动外部更新器 + 退出", +真正的替换/重启由 update_helper.py 完成(独立进程,不随主程序死)。 + +更新包格式(自动识别): + .exe → onefile 直接替换启动器,清理遗留 _internal/ 死目录 + .zip → 解压整包,备份旧目录 → 原子替换 → 清理 + +检查接口: GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware """ import os import sys import subprocess import tempfile import json -import ctypes -import base64 +import argparse # noqa: F401 # update_helper 用 PyQt6 环境也行 from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication import urllib.request @@ -77,57 +81,152 @@ class _DownloadWorker(QThread): self.error.emit(str(e)) -def _replace_and_restart(new_exe: str): - if not getattr(sys, "frozen", False): - subprocess.Popen([new_exe]) +# ────────── 启动外部更新器 ───────────────────────────────── +def _launch_update_helper(downloaded_path: str) -> None: + """下载完成 → 启动 update_helper 独立进程 → 主程序退出。 + update_helper 负责:等待主进程退出 → 替换文件/目录 → 启动新版本 → 清理。""" + is_frozen = getattr(sys, "frozen", False) + + if not is_frozen: + # 开发态:无法自更新(运行时不能替换自己),只提示用户下载 + dialog_style.information( + None, "开发模式", + "开发环境无法自动更新。\n请手动下载新版本,解压后替换原目录。", + ) QApplication.quit() return - current_exe = sys.executable + # ─── onefile vs onedir 路径差异 ──────────────────────── + # onefile 模式:sys.executable = _MEIPASS 里的临时副本,需要用 GetModuleFileNameW 找原始 exe + # onedir 模式:sys.executable 就是真实路径,直接用 + if hasattr(sys, "_MEIPASS"): + # onefile:原始 exe 路径 + import ctypes + from ctypes import wintypes + buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH) + ctypes.windll.kernel32.GetModuleFileNameW(None, buf, wintypes.MAX_PATH) + dst_exe = buf.value + else: + dst_exe = sys.executable + + # ─── 包类型匹配检查 ─────────────────────────────────── + # 防止 onefile(100MB) ↔ onedir 启动器(几MB) 混更导致更新后跑不起来 + try: + cur_size = os.path.getsize(dst_exe) + new_size = os.path.getsize(downloaded_path) + size_diff_abs = abs(cur_size - new_size) + # onefile 通常 > 50MB,onedir 启动器通常 < 10MB,差 > 30MB 就报警 + if size_diff_abs > 30 * 1024 * 1024: + ret = dialog_style.warning( + None, "更新包格式异常", + f"下载的更新包大小({new_size / 1024 / 1024:.1f} MB)" + f"与当前程序({cur_size / 1024 / 1024:.1f} MB)差异较大," + f"可能是包格式不匹配(如 onefile vs onedir 混更)。\n\n" + f"继续更新可能导致程序无法启动。是否仍要继续?", + ) + if ret != QMessageBox.StandardButton.Yes: + try: + os.remove(downloaded_path) + except Exception: + pass + return + except Exception: + pass + + # ─── 找 update_helper.exe ───────────────────────────── + # onedir: 与主 exe 同级 + # onefile: 打包进 onefile exe 解压到 _MEIPASS,需复制到系统 temp 防止清理 + app_dir = os.path.dirname(os.path.abspath(dst_exe)) + helper_exe = os.path.join(app_dir, "update_helper.exe") + helper_py = os.path.join(app_dir, "_internal", "update_helper.py") + + # onefile 解压目录里的 helper + meipass_helper = None + if hasattr(sys, "_MEIPASS"): + meipass_helper = os.path.join(sys._MEIPASS, "update_helper.exe") + if not os.path.exists(helper_exe) and os.path.exists(meipass_helper): + # 从 _MEIPASS 复制到系统 temp(_MEIPASS 在主进程退出后可能被清理) + import tempfile + tmp_helper = os.path.join(tempfile.gettempdir(), "niumasoft_update_helper.exe") + try: + import shutil + shutil.copy2(meipass_helper, tmp_helper) + helper_exe = tmp_helper + print(f"[updater] copied helper from _MEIPASS to {tmp_helper}") + except Exception as e: + print(f"[updater] copy helper failed: {e}") + helper_exe = meipass_helper # 退而求其次,直接用 _MEIPASS 里的 + + # 如果 exe 不存在,尝试用 python 跑 py 脚本(开发态兜底) + if not os.path.exists(helper_exe) and not os.path.exists(helper_py): + # 最后兜底:找项目根目录的 update_helper.py + fallback = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "update_helper.py") + if os.path.exists(fallback): + helper_py = fallback + pid = os.getpid() - ps_script = f""" -$pid_target = {pid} -$src = '{new_exe.replace(chr(92), chr(92)*2)}' -$dst = '{current_exe.replace(chr(92), chr(92)*2)}' + if os.path.exists(helper_exe): + cmd = [ + helper_exe, + "--src", downloaded_path, + "--dst", dst_exe, + "--pid", str(pid), + "--restart", + "--mode", "auto", + ] + elif os.path.exists(helper_py): + cmd = [ + sys.executable if os.path.exists(sys.executable) and sys.executable.lower().endswith("python.exe") + else "python", + helper_py, + "--src", downloaded_path, + "--dst", dst_exe, + "--pid", str(pid), + "--restart", + "--mode", "auto", + ] + else: + # 什么都找不到,放弃 + dialog_style.warning(None, "更新失败", "找不到 update_helper 更新器组件。") + return -$waited = 0 -while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{ - Start-Sleep -Milliseconds 500 - $waited += 0.5 -}} + _log_update_request(downloaded_path, dst_exe, pid) -$ok = $false -for ($i = 0; $i -lt 10; $i++) {{ - try {{ - Copy-Item -Path $src -Destination $dst -Force - $ok = $true - break - }} catch {{ - Start-Sleep -Milliseconds 800 - }} -}} + try: + # CREATE_NEW_CONSOLE:子进程创建独立控制台(不受父进程生命周期影响) + # CREATE_NO_WINDOW: 控制台窗口不显示(静默运行) + # 不用 DETACHED_PROCESS——它在 Python GUI 程序里不可靠,父进程退出时子进程可能被系统回收 + subprocess.Popen( + cmd, + creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NO_WINDOW, + ) + except Exception as e: + dialog_style.warning(None, "更新失败", f"无法启动更新器:\n{e}") + return -if ($ok) {{ - Remove-Item $src -Force -ErrorAction SilentlyContinue - Start-Process $dst -}} -""" - tmp = tempfile.NamedTemporaryFile( - delete=False, suffix=".ps1", mode="w", encoding="utf-8" - ) - tmp.write(ps_script) - tmp.close() - - subprocess.Popen( - ["powershell", "-WindowStyle", "Hidden", - "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", tmp.name], - creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, - ) + # 给 update_helper 一点时间就位(OpenProcess 还没拿到句柄就退了主进程, + # WaitForSingleObject 会立刻返回 0,这其实正好是我们想要的,但留 1 秒保险) + import time as _t + _t.sleep(0.5) QApplication.quit() +def _log_update_request(src: str, dst: str, pid: int) -> None: + """在 update_helper 同目录写一份请求 JSON,方便计划任务/管理员提权模式读取。""" + try: + app_dir = os.path.dirname(os.path.abspath(sys.executable)) + req = { + "src": src, "dst": dst, "pid": pid, + "restart": True, "ts": __import__("time").time(), + } + with open(os.path.join(app_dir, "update_request.json"), "w", encoding="utf-8") as f: + json.dump(req, f, ensure_ascii=False, indent=2) + except Exception: + pass + + class Updater(QObject): def __init__(self, parent=None): super().__init__(parent) @@ -143,7 +242,7 @@ class Updater(QObject): def _on_check_result(self, data: dict): current = _current_version() latest = data.get("latestVersion", "") - url = data.get("downloadUrl", "") + url = data.get("downloadUrls", {}).get("windows") or data.get("downloadUrl", "") if not _is_newer(latest, current): if not self._silent: @@ -167,11 +266,14 @@ class Updater(QObject): dialog_style.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") def _download(self, url: str): - if getattr(sys, "frozen", False): - dest_dir = os.path.dirname(sys.executable) - else: - dest_dir = tempfile.gettempdir() - dest = os.path.join(dest_dir, "_update_new.exe") + # 统一下载到系统 temp 目录(不污染安装目录、避免文件锁冲突) + tmp = tempfile.gettempdir() + # 根据 URL 后缀决定文件名 + name = os.path.basename(url.split("?")[0]) or "_niumasoft_update" + if not os.path.splitext(name)[1]: + # 不知道是什么,先存成 zip(update_helper 会自动识别) + name += ".zip" + dest = os.path.join(tmp, name) self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget) self._progress.setWindowTitle("下载更新") @@ -187,7 +289,7 @@ class Updater(QObject): def _on_download_done(self, path: str): self._progress.close() - _replace_and_restart(path) + _launch_update_helper(path) def _on_download_error(self, err: str): self._progress.close() diff --git a/ui/wechat_multi.py b/ui/wechat_multi.py index 8c462ce..b12f290 100644 --- a/ui/wechat_multi.py +++ b/ui/wechat_multi.py @@ -3,7 +3,6 @@ """ import os import subprocess -import tempfile from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, @@ -203,22 +202,12 @@ class WechatMultiDialog(QDialog): database.set_setting(_DB_KEY_PATH, exe) database.set_setting(_DB_KEY_COUNT, str(count)) - # 写临时 bat,连续 start 多次 - lines = ["@echo off"] + # 直接多次启动,避免 bat 文件中文路径编码问题 for _ in range(count): - lines.append(f'start "" "{exe}"') - bat_content = "\r\n".join(lines) + "\r\n" - - tmp = tempfile.NamedTemporaryFile( - delete=False, suffix=".bat", mode="w", encoding="gbk" - ) - tmp.write(bat_content) - tmp.close() - - subprocess.Popen( - ["cmd", "/c", tmp.name], - creationflags=subprocess.CREATE_NO_WINDOW - ) + subprocess.Popen( + [exe], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) self.accept() # 无边框拖动 diff --git a/update_helper.py b/update_helper.py index 2d34d21..d85f391 100644 --- a/update_helper.py +++ b/update_helper.py @@ -1,141 +1,381 @@ +# -*- coding: utf-8 -*- """ -以“计划任务(最高权限)”方式执行静默覆盖更新。 +外部独立更新器 —— 主进程退出后完成真正的替换/重启。 -工作流: -- 主程序(普通权限)下载新 exe 到可写目录(建议 ProgramData)并写入 update_request.json -- 计划任务以 SYSTEM/管理员身份运行本程序 -- 本程序读取请求文件 -> 等待主程序退出 -> 覆盖安装目录 exe -> 重启 -> 清理请求文件 +不随主进程死、不被主进程文件锁影响、失败可回滚。 + +用法(主程序 Popen 启动): + update_helper.exe --src <下载包路径> --dst <目标exe路径> --pid <当前PID> [--restart] [--mode auto|onefile|zip] [--keepbackup] + +不依赖计划任务,普通用户权限即可覆盖自己的安装目录。 +如果用户装在 C:\\Program Files 等受限目录,需配合计划任务提权(保留 update_helper.spec 打包为 exe 的能力)。 """ - from __future__ import annotations +import argparse +import ctypes +import ctypes.wintypes as wt import json import os +import shutil import subprocess import sys +import tempfile import time +import traceback +import zipfile -APP_NAME = "CleanDesktopOrganizer" -REQUEST_FILE = "update_request.json" LOG_FILE = "update.log" -def _programdata_dir() -> str: - base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData" - return os.path.join(base, APP_NAME) - - -def _request_path() -> str: - return os.path.join(_programdata_dir(), REQUEST_FILE) - - -def _log(msg: str): +# ────────── 日志 ──────────────────────────────────────────── +def _log(msg: str) -> None: try: - os.makedirs(_programdata_dir(), exist_ok=True) - p = os.path.join(_programdata_dir(), LOG_FILE) + p = os.path.join(tempfile.gettempdir(), LOG_FILE) with open(p, "a", encoding="utf-8") as f: - f.write(msg.rstrip() + "\n") + f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n") except Exception: pass -def _read_request() -> dict: - p = _request_path() - with open(p, "r", encoding="utf-8") as f: - return json.load(f) - - -def _pid_exists(pid: int) -> bool: +# ────────── 等待进程退出(Win32 精准版) ─────────────────── +def _wait_process_exit(pid: int, timeout_sec: float = 90.0) -> bool: + """用 OpenProcess + WaitForSingleObject 等进程退出。 + 比 tasklist 轮询精准:能检测 zombie(已退出但句柄未释放)、 + 能被进程句柄自动释放(不依赖命令行解析)。""" if pid <= 0: - return False + _log("wait: no pid, skip") + return True + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + SYNCHRONIZE = 0x00100000 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + handle = kernel32.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + # OpenProcess 失败通常意味着进程已不存在(访问拒绝) + _log(f"wait: OpenProcess pid={pid} failed, assume gone") + return True try: - # tasklist 不需要管理员权限,SYSTEM 下也可用 - out = subprocess.run( - ["tasklist", "/FI", f"PID eq {pid}"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - creationflags=subprocess.CREATE_NO_WINDOW, - ).stdout - return str(pid) in out - except Exception: - return False + waited = 0.0 + step = 0.5 + while True: + rc = kernel32.WaitForSingleObject(handle, int(step * 1000)) + waited += step + if rc == 0: + _log(f"wait: pid={pid} exited after {waited:.1f}s") + return True + if waited >= timeout_sec: + _log(f"wait: pid={pid} TIMEOUT after {timeout_sec}s") + return False + finally: + kernel32.CloseHandle(handle) -def _copy_with_retries(src: str, dst: str, retries: int = 10) -> bool: - for _ in range(max(1, retries)): +# ────────── 文件操作(带重试 + 原子替换) ──────────────────── +def _atomic_replace(src: str, dst: str, backup: str | None = None, retries: int = 12) -> bool: + """原子替换:备份旧文件 → os.replace 覆盖 → 失败则恢复备份。""" + # 1. 备份旧文件 + old_exists = os.path.exists(dst) + if old_exists and backup: try: - os.makedirs(os.path.dirname(dst), exist_ok=True) - if os.path.exists(dst): - try: - os.chmod(dst, 0o666) - except Exception: - pass - # 用二进制流复制,避免权限/锁问题时直接抛错 - with open(src, "rb") as rf: - data = rf.read() - with open(dst, "wb") as wf: - wf.write(data) + os.replace(dst, backup) + _log(f"backup: {dst} -> {backup}") + except Exception as e: + _log(f"backup failed: {e}") + return False # 连备份都做不了 = 没权限 + + # 2. 尝试替换(os.replace 在 Windows 上对目标进程已退出的文件是原子的) + for i in range(max(1, retries)): + try: + os.replace(src, dst) + _log(f"replace: ok ({i} attempts)") return True - except Exception: - time.sleep(0.8) + except Exception as e: + _log(f"replace attempt {i + 1}/{retries} failed: {e}") + time.sleep(0.7) + + # 3. 全部失败 → 恢复备份 + if backup and os.path.exists(backup) and not os.path.exists(dst): + try: + os.replace(backup, dst) + _log("rollback: restored backup") + except Exception as e: + _log(f"rollback failed: {e}") return False -def main() -> int: +def _clean_onedir_dead_sibling(target_exe: str) -> None: + """onefile 覆盖 onedir 启动器后,同级遗留的 _internal/ 等死目录清理。""" + exe_dir = os.path.dirname(os.path.abspath(target_exe)) + if not exe_dir: + return + for name in ("_internal", "__pycache__"): + dead = os.path.join(exe_dir, name) + if os.path.isdir(dead): + try: + shutil.rmtree(dead, ignore_errors=True) + _log(f"cleaned dead dir: {dead}") + except Exception as e: + _log(f"clean dead dir failed: {e}") + + +# ────────── zip 解压 + 路径归一 ───────────────────────────── +def _extract_zip_normalized(zip_path: str, dest_dir: str) -> str: + """解压 zip 到 dest_dir,自动 strip 单一顶层目录(zip 内嵌套一层目录)。 + 返回实际 payload 根目录(可能是 dest_dir 本身,也可能是 strip 后的子目录)。""" + os.makedirs(dest_dir, exist_ok=True) + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(dest_dir) + # 检测是否所有文件都在同一顶层目录下 + entries = set() + with zipfile.ZipFile(zip_path, "r") as zf: + for name in zf.namelist(): + # strip 开头的 ./ + parts = name.replace("\\", "/").lstrip("./").split("/") + if parts[0]: + entries.add(parts[0]) + if len(entries) == 1: + top = next(iter(entries)) + candidate = os.path.join(dest_dir, top) + if os.path.isdir(candidate): + _log(f"zip: strip top folder '{top}', payload={candidate}") + return candidate + _log(f"zip: no single top folder, payload={dest_dir}") + return dest_dir + + +# ────────── 目录整体替换(zip 模式) ────────────────────────── +def _replace_directory(old_dir: str, new_payload: str, keep_backup: bool = False) -> bool: + """备份旧目录为 .bak.时间戳 → 移动新 payload 到旧目录位置 → 清理备份(默认保留最近 3 个)。""" + old_dir = os.path.abspath(old_dir) + if not os.path.isdir(old_dir): + _log(f"replace dir: old_dir {old_dir} not found, treat as fresh install") + os.makedirs(old_dir, exist_ok=True) + _copy_tree(new_payload, old_dir) + return True + + parent = os.path.dirname(old_dir) + base = os.path.basename(old_dir) + ts = time.strftime("%Y%m%d-%H%M%S") + backup_dir = os.path.join(parent, f"{base}.bak.{ts}") + + # 1. 备份旧目录 try: - req = _read_request() - except FileNotFoundError: - return 0 + os.rename(old_dir, backup_dir) + _log(f"backup dir: {old_dir} -> {backup_dir}") + except Exception as e: + _log(f"backup dir failed: {e}") + return False + + # 2. 移动新 payload 到目标位置 + try: + if os.path.isdir(new_payload): + # 直接 rename 最快,但可能跨盘符 → 用 shutil.move + moved = shutil.move(new_payload, old_dir) + _log(f"move: {new_payload} -> {moved}") + else: + _log(f"new_payload {new_payload} is not a dir") + os.rename(backup_dir, old_dir) # 恢复 + return False + except Exception as e: + _log(f"move failed: {e}") + try: + os.rename(backup_dir, old_dir) + except Exception: + pass + return False + + # 3. 清理旧备份(保留最近 3 个) + if not keep_backup: + _prune_old_backups(parent, base, keep=3) + return True + + +def _copy_tree(src: str, dst: str) -> None: + """递归复制目录树,覆盖目标文件。""" + os.makedirs(dst, exist_ok=True) + for item in os.listdir(src): + s = os.path.join(src, item) + d = os.path.join(dst, item) + if os.path.isdir(s): + _copy_tree(s, d) + else: + shutil.copy2(s, d) + + +def _prune_old_backups(parent: str, base: str, keep: int = 3) -> None: + """清理旧的 .bak.* 目录,只保留最近 keep 个。""" + try: + backups = sorted( + [d for d in os.listdir(parent) if d.startswith(f"{base}.bak.")], + reverse=True, + ) + for old in backups[keep:]: + old_path = os.path.join(parent, old) + try: + shutil.rmtree(old_path, ignore_errors=True) + _log(f"prune backup: {old_path}") + except Exception as e: + _log(f"prune failed: {e}") except Exception: - _log("ERROR: cannot read request file") + pass + + +# ────────── 启动新进程 ────────────────────────────────────── +def _launch_new_exe(exe_path: str) -> bool: + try: + # --from-update: 让新进程跳过单实例检测(旧 mutex 可能还没完全释放) + # CREATE_NEW_CONSOLE:新进程创建独立控制台(真正脱离 update_helper) + # CREATE_NO_WINDOW: 控制台窗口不显示 + subprocess.Popen( + [exe_path, "--from-update"], + creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NO_WINDOW, + ) + _log(f"launched: {exe_path} --from-update") + return True + except Exception as e: + _log(f"launch failed: {e}") + return False + + +# ────────── 包类型判断 ────────────────────────────────────── +def _detect_mode(src: str) -> str: + ext = os.path.splitext(src)[1].lower() + if ext == ".zip": + return "zip" + if ext in (".exe",): + return "onefile" + # 尝试打开 zip 看看 + try: + with zipfile.ZipFile(src, "r"): + return "zip" + except Exception: + pass + return "onefile" + + +# ────────── 主流程 ─────────────────────────────────────────── +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="外部独立更新器") + parser.add_argument("--src", required=True, help="下载包路径(onefile exe 或 zip)") + parser.add_argument("--dst", required=True, help="目标 exe 路径") + parser.add_argument("--pid", type=int, default=0, help="当前运行进程 PID,用于等待退出") + parser.add_argument("--restart", action="store_true", help="替换完成后启动新进程") + parser.add_argument("--mode", choices=["auto", "onefile", "zip"], default="auto") + parser.add_argument("--keepbackup", action="store_true", help="保留备份不清理") + parser.add_argument("--zip-root", default=None, + help="zip 包内的 exe 相对路径(如 niumasoftware/niumasoftware.exe)," + "不传则自动在解压结果中查找同名 exe") + args = parser.parse_args(argv) + + _log(f"=== START args={json.dumps(vars(args))}") + + src = os.path.abspath(args.src) + dst = os.path.abspath(args.dst) + mode = args.mode if args.mode != "auto" else _detect_mode(src) + + if not os.path.exists(src): + _log(f"ERROR: src not found: {src}") return 2 - src = str(req.get("src") or "") - dst = str(req.get("dst") or "") - pid = int(req.get("pid") or 0) - restart = bool(req.get("restart", True)) + # 1. 等待主进程退出 + if args.pid > 0: + if not _wait_process_exit(args.pid, timeout_sec=90): + _log("WARN: main process still alive after timeout, attempt replacement anyway") - if not src or not dst: - _log(f"ERROR: bad request src={src!r} dst={dst!r} pid={pid}") - return 3 + if mode == "onefile": + # ─── onefile 模式 ──────────────────────────────────── + _log("mode: onefile exe") + dst_dir = os.path.dirname(dst) + backup_exe = os.path.join(dst_dir, os.path.basename(dst) + ".bak") - _log(f"START: pid={pid} src={src} dst={dst}") + # 原子替换 + if not _atomic_replace(src, dst, backup=backup_exe): + _log("FATAL: onefile replace failed, cannot continue") + return 3 - # 等待主程序退出,最多 60 秒 - waited = 0.0 - while _pid_exists(pid) and waited < 60.0: - time.sleep(0.5) - waited += 0.5 + # 清理遗留的 onedir 死目录 + _clean_onedir_dead_sibling(dst) - ok = _copy_with_retries(src, dst, retries=15) - _log(f"COPY: ok={ok}") - try: - os.remove(src) - except Exception: - pass + # 删备份(保留 1 个版本足够回滚) + if os.path.exists(backup_exe): + try: + # 新 exe 启动正常后用户可以手动删,这里只删旧的 .bak + os.remove(backup_exe) + _log("backup cleaned") + except Exception: + pass - # 清理请求文件(成功/失败都尽量清掉,避免重复执行) - try: - os.remove(_request_path()) - except Exception: - pass + if args.restart: + if not _launch_new_exe(dst): + return 4 - if ok and restart: + elif mode == "zip": + # ─── zip 整包模式 ─────────────────────────────────── + _log("mode: zip full-package") + tmp_extract = tempfile.mkdtemp(prefix="niumasoft_update_") try: - subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW) - _log("RESTART: started") - except Exception: - _log("RESTART: failed") - pass - return 0 + payload_root = _extract_zip_normalized(src, tmp_extract) - _log("DONE: failed") - return 4 + # 确定旧目录和目标 exe 在旧目录里 + old_dir = os.path.dirname(dst) + new_exe_in_payload = dst # 默认同名 exe 相对路径 + if args.zip_root: + new_exe_in_payload = os.path.join(payload_root, args.zip_root) + if not os.path.exists(new_exe_in_payload): + _log(f"ERROR: --zip-root exe not found: {new_exe_in_payload}") + return 5 + else: + # 自动在 payload 里找同名 exe + target_name = os.path.basename(dst) + found = None + for root, _, files in os.walk(payload_root): + if target_name in files: + found = os.path.join(root, target_name) + break + if not found: + _log(f"ERROR: cannot find {target_name!r} in payload {payload_root}") + return 5 + new_exe_in_payload = found + _log(f"auto-found exe in payload: {new_exe_in_payload}") + + # payload 根目录 = exe 所在目录的父级(可能就是 payload_root,也可能子级) + payload_exe_dir = os.path.dirname(new_exe_in_payload) + if os.path.abspath(payload_exe_dir) != os.path.abspath(payload_root): + _log(f"payload exe dir != payload root, using exe dir as new dir root") + new_payload = payload_exe_dir + else: + new_payload = payload_root + + # 替换整个旧目录 + ok = _replace_directory(old_dir, new_payload, keep_backup=args.keepbackup) + if not ok: + _log("FATAL: directory replace failed") + return 3 + + new_dst = os.path.join(old_dir, os.path.basename(dst)) + if args.restart: + if not _launch_new_exe(new_dst): + return 4 + finally: + # 清理临时解压目录 + shutil.rmtree(tmp_extract, ignore_errors=True) + _log(f"cleaned temp: {tmp_extract}") + + else: + _log(f"FATAL: unknown mode {mode}") + return 1 + + _log("=== DONE") + return 0 if __name__ == "__main__": - raise SystemExit(main()) - + try: + sys.exit(main()) + except SystemExit: + raise + except Exception: + _log(f"UNCAUGHT: {traceback.format_exc()}") + sys.exit(99)