From 6df29c92547d284d44f7133e55e074028df7eff6 Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Mon, 12 Oct 2026 14:57:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=A8=8B=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codegraph/.gitignore | 5 + CleanDesktopOrganizer.spec | 3 +- Readme.md | 14 +- __pycache__/shortcut_target.cpython-314.pyc | Bin 7575 -> 8665 bytes app_info.py | 2 +- db/__pycache__/__init__.cpython-314.pyc | Bin 142 -> 152 bytes db/__pycache__/database.cpython-314.pyc | Bin 15730 -> 26169 bytes db/database.py | 181 +++- main.py | 54 +- shortcut_target.py | 28 +- ui/__pycache__/__init__.cpython-314.pyc | Bin 142 -> 152 bytes ui/__pycache__/ball.cpython-314.pyc | Bin 27317 -> 27327 bytes ui/__pycache__/dialog_style.cpython-314.pyc | Bin 13296 -> 15292 bytes ui/__pycache__/dock.cpython-314.pyc | Bin 103143 -> 127372 bytes ui/__pycache__/flow_layout.cpython-314.pyc | Bin 12819 -> 12876 bytes ui/__pycache__/group.cpython-314.pyc | Bin 39452 -> 47423 bytes ui/__pycache__/item.cpython-314.pyc | Bin 25850 -> 30751 bytes .../settings_window.cpython-314.pyc | Bin 37146 -> 37998 bytes ui/__pycache__/theme.cpython-314.pyc | Bin 2520 -> 2678 bytes ui/box.py | 954 ++++++++++++++++++ ui/dock.py | 148 ++- ui/flow_layout.py | 1 + ui/group.py | 88 ++ ui/item.py | 40 +- ui/updater.py | 206 +++- ui/wechat_multi.py | 21 +- update_helper.py | 432 ++++++-- 27 files changed, 1974 insertions(+), 203 deletions(-) create mode 100644 .codegraph/.gitignore create mode 100644 ui/box.py 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 ee1076ce9a5d8aa3a000a2ce393f4b75e1564aca..b331ab86f639d7158e6fd0b6fe65d9312f570cfc 100644 GIT binary patch delta 2357 zcmY*beN0=|6~EW-*?#_dHpJ%BfPs=?2!s*ZB&Ot(1Z7l?`%)ZDLvfA$Aa0HA-S;e( zZWHO+v`LD#q_;}4Zc@?(X=EyCR<~2Suy)0cpAP^)U1cJ2cg z?F#+w@1Aq+x%a;FVNQ46+$lDT4l9B|ANlm@>Ee@O89sjU;mzR#NWy!}s#!9WB8e+S zNtY@}JaD7VEs_biS?5+s0B+H_O|k;F>D(^afje~WkZ?Wnizk2mb{9vYBbtfGYL=w@ z2F*BdNR<_hQ&i%|nrSdihNG&2;YM}ZW;xK`Ef1#^9ruo;6nYGI_S-hP`=CWJ=A|%`%NG{jKb*Q70nPxz3|$4)QU2ghJJG?YCse+7YX8Li>k7}P#7Vql+-O*KR*!tBUyl-7_uC6cY%rKm93JLHHK+>pZbK-6I=L#8 zzb2WbXXcL_&A&R4&t1u1Is4_6 zGYc2T^HZn5`0mu${OFOX`HSz~zC3y7)JwkoiBvp2qWErKKJE*pM`W_^pqxzl@~^$% z>)FxkEBG9<&Yu{&bNctU-#xqV{2%X3p3lF2aen-T{D~KReNXOg+2qSl9=r4Wk@*+j z`S;_=@5<^6UmuTG3z%zccW=f`G z(WI=ohh#NFkUx59DzDP8lQ=(2MH@Czk#rv*Z6^SI!O=kUR+B* z#C7y_zWgo27L2RuR<6mo7EEesiu+uw2BvQeUckgmzt7j;rreMCpJAgOVCpitw-Of^ z1%h9)&m4S~?~@+y3Xm4I2nHcWb-*+ubc#eqGiD*wgggWy(CE$^=%8tnu^l8$x$~x7 z7&o$0x*r9B!VFluXv6M%48}JsvgwTtm)w&9T|!v6{;y>)0ekfQ{}}0iE9!D7p%K$x zS;`x12T%u7z>w@4*~fs9;rgqV8A4$$R86NX4V&vaRCQtwb+cs+;_!tT<_W`?b`n2u1IiiWdELE_ul<}UK3-{ z40L*Vr7q}MXO&0)(pmNa?_POd!zytAr5`yDLe8|i9_?na(u5TJ60l^0VrJznlM*Jj zu9lj{k!(scLnDZ)D$y%WGbw5uDv-3&-@1NK+5tPrV{DrtqmU-HVwpwTi-Oh|5Ir-R z>G7gr{1E-RsD8U^%?)?St%}<5rs>vgf2y6S=zdUYcUX_&2NlTKIDy|3E(mWrC!Cjd z&WM|4#VyywEi>Y_S+VDu*mG5w5ueCCRUF4pn|jxwzqp$L|7vJq*wzU6VR;$Dj#O*uxkLwtoK2}%9XHB+E#M7}zg!C|dhym+RMEBIkSTBPe3|LcLzM_76c2C0YN0`G4 zNYAU~&MZ7OoQ`Lc@)j}zD%RJOE&ynO7~ez#bI37=Y;&j`CX4=8G>5E9Cin+pX`Kms amWnOdwNwTTVrdZjaLHG@F^Ai9|NjFQ#9_n$ delta 1391 zcmYLITWlLe6y2NkuI+WaUVH7>Nu1bm(j-pe1aLz_nnpB@sgRKytwS7kIJz( zvulVzD*Y@UP@)lqAB2!v@eq}eT$K+bK%zXFf<%c>(+Q#<{D6=ks6>@ONX%>q9qpbu zbLY<7J2Us(KKj}rxnK559BtQx@0Wk`t;-R3X1(W;Nv$EwX?&O~E0$ApP%hZqrHPa~ zZSK}wl)G&%X%gign|n0q;+Miv3i9mO8`054C+@T#u`zomrm*RmU%a@dpD^WwPaF8r$82|ak-r-FiG zzSjTzTLp1en6~Y>-51@r;yGUgl61cq^wTvd8&&+BFLGeu|2+ry+5NtQ(}J_klLPoy zUljK%Dx~qMqGC;Hx7m=rKg|JORVH8Al%YT+K2t@HEzaK_ehIqq&0Y-5l&=2AI zOMzw>cOu;=7m7Mjj%t}BPiFGD9kCABAK;$167N< zX%ZgQD#W>{TLp_&u4q~%8V^PBkHOV2yJ*C2hBO!^YGQ;(g=g_XC@0O*kzEx-_~XzD zeS?9f&Wt~_6^d@D(Je*Y3O4=F(s8lx+rdXa?bvLY`m@dFmCnIVoKyVg*IryZf9Bko z*B`nicioh`Hs$VHa_Xj>dS_`<9;v_5RD#ovY&Z8=poigjisH4tUW%Wq5r%`^6u$^H zF&sF;(eBF>oA^o$GfX5XUU$bC_A%EDKXcvaZKrs{X=Ar1JIvoW2-#tLDpJ}jbC{|B z6nTNq4zqLHau{PQ$`0-_jqLJo>pQrD`#sN26?hl7YR+F~$xvNHee#i!;WuLo6$_h>ebxW2Y%)sx{fN v9UcH~bMxDrcboHUb8h?dZ*$VFC_-pA;09$kLWRFuhB!3cdklDZz*hdhAuC str: diff --git a/db/__pycache__/__init__.cpython-314.pyc b/db/__pycache__/__init__.cpython-314.pyc index 788c4ac9f79541435de0540319c9ea0901acc80e..a3e0171bbf0db64fb36de9c17b03f319c7a7159c 100644 GIT binary patch delta 40 qcmeBUoWaPY&Bx2d00gI&P2@5YGIh0zaY@b1F9wtT<$1*uJ=_4qdkcjC delta 30 kcmbQi*vH7F&Bx2d00c)^Cvur_XuDd)xTNOhPYiMc09WD$*#H0l diff --git a/db/__pycache__/database.cpython-314.pyc b/db/__pycache__/database.cpython-314.pyc index 5c3f1a06889d39c65e8bf29959f6182cc0fb712d..90afefa96e51c5df86d1648b628b2943d3f53a77 100644 GIT binary patch delta 8338 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 delta 1296 zcmZuxO>7%g5Pq|NaqQL(Y4cYlG5$-u{c%ZblGLP0;}R-`64Y(jaB%8awi6p@Z9O|6 zqM{~hD4;5>!ti$}2vrDy#HHFp#DPjZQ_&I$fubs>a^R4vX{g|U5cAe<;lQ(Y-nZY( zym|BE{q`n2n0N1U?{qR+{obDna`%e6Rr*F+35Ni#jW*f$zaKASsTw2mnb&$%{=w7~vg zacJBU;>xj~+ao`=N9-5wCT#}9ZECBIc9}6{dk?W`@-$f#44^QM5}X1P4nj#vKK3}t zVl`ZnO*tt&I%c4evFs#c?Yo#MpMw35c-l(}F7!A1P35ttYN|6HvSmpT-Cl-lDtLBx zXjt{JSdH_tWPJ^?lZ^ap(Y(x%#4kqo7R$>fl#={>nh}rJx_$M{&&VCD0J>s1n=Yt0 z9emlLaUWUo3ET*Fsv5x=tP6E^PZ1iYr~Dp?KNr|TcR)4a{&8US&Ubt7%TlAZF!s9x zmqQiTjn>^GarH*rm2g@+x=u?lh4bN@njz1N*4^-?a$ts_?&f~lxL1gSLQIob_muNR zUdhZ=Qk68H%~g0F|32^>1reCo1U;m6;=^ii+0#&D{4t+6PWX%f0zbx7+W2~&(E z+IWIuG-JbnTYHCI?=jpQZo|U^cO15n#Fx-_D4>bLKeTmL=1>T<>Q(VlybYG{tGHjg zOa^~3>vr6PhI524K|+%>c9b!{M-B|9@l2|euH>tp_UM$bFyvJ~B)!{O9@-D8od#YX*40ml z?7$PLJ*XYouBNB(j}boo!x=gq_nwJDB;pK#1{fuA) zZ^gUtPT~vbvlfqrC3lL*abBz4=Ty5UygAycUM0E@e;b|f=)lF_Nr!PACiE^`&3HW~ z5!IZOSBix*csJvRNvzBE9~YC)#q8RH$HfeBF%MicoQs}v(Kasn!$l9cs52M&bCE9> qnQ?Jbeic`<9nP-_QlhN97dNuMX!PZIFc0`$Zp^vGcEA(%m**dn1R3@K 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 efb2ce1a8842b4ea8ef599355add92801cb04d1f..a31195e9010f2b5f422d895b549a7efc2b4e6e39 100644 GIT binary patch delta 40 qcmeBUoWaPY&Bx2d00gI&P2@5YGIh0zaY@b1F9wtT<$1*uJ=_4qdkcjC delta 30 kcmbQi*vH7F&Bx2d00c)^Cvur_XuDd)xTNOhPYiMc09WD$*#H0l diff --git a/ui/__pycache__/ball.cpython-314.pyc b/ui/__pycache__/ball.cpython-314.pyc index a5e1fee6c237790bd4882054403edb372be90ab7..b3009004907f498f7f8744fbd0e9c2f128ba1113 100644 GIT binary patch delta 45 wcmdmbm2v-7MlNkWUM>b8IJImeR}_b8IKsM-D~gFj-PJ0_B{esHa~YFo1^}>*3B~{b diff --git a/ui/__pycache__/dialog_style.cpython-314.pyc b/ui/__pycache__/dialog_style.cpython-314.pyc index 6a397a050dd6147794d7bfb3ae97654096ebf4e5..b9d22df19f5d328a612e5d178f4fae93719d7b9f 100644 GIT binary patch delta 5887 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 DXcFJv diff --git a/ui/__pycache__/dock.cpython-314.pyc b/ui/__pycache__/dock.cpython-314.pyc index 4b5584dba17b23a2346978621b904348391ddaf7..f79ecd06609d9a4bfb6bbc34877c1806ede46625 100644 GIT binary patch delta 42144 zcmeFad0bOh+Blw*jckyFK-eMdgdIdhaYGar1Ti91MKM4Gl+Bw^(JsN(w%A&Qp0-!J zVwYEJr!5`Y+SV?1rn7e5DG@X@wNu-1X=k;yoo+MJ@AI6SgrJ?}{rvv@UFFHWXS-*4 z&Nu3GmXOV|9LM26kk*nJ#3F`L6tyC_li(fHRc4V!h#Yc@%n?*e91vGR97=eH z+!NAJ(`xImHP>`?wL99ndzL`)pB#Vm(+3WofBD3xj~)8-v4=j{_v1?s?EU19r!GAE z!1;_<_L&?)+P}WG|KfvhozCgSE4>%VBlc8X7z?(`%9&7?}s9*uwyP-6#4= zq>v?khT!m8_H#wVAL%DiVyT$ERToJtbdS_T`_35n?%gDTo;+iq@9q!g-?0%Tzom&7 zF<8x;catK%sfn;u>@ke)TVU2e7`jE^_<0=hw(~TN&eGE-p8bN7> zfqr;nI1OE3p!o_T-HGfIJevEb|H3oTc?Bm!7``F6IoZ`v-s))UY~eImnzW(4vE37Fw>effZnHHxst{eC)BAw&oU_!z}ek?Y8!< zTsmfuv|D$1lno6{?N+s*T2*PTwdZbGr1f8z#{!2k3H5}t52Ec#H< zcvxD$biZ;^p*g7Br*tb4T#AGt^@Jk(Ed4M%F(i0j@IW))HrRGXVdC=mxF8tXe~S1% z4augne_RnI#zx@P46`y;WRX-7i?kaspAL3~g;-=3c@ZKVT-`GIZbSs_yD`ieR4dVl zEQ(qwKxM5ApsFyahzRKtL>4s+p2j0>wsPCQ4N5L-#(!|Lm0fUSV;9^MunW7}$~Cn% zG#Htt(#a&d9-?zlCpaNLvq7+|`g(b}}Vh3j(mbT-f3ttc@yb+vbKb9Wm`OhV4N z1>L;W%5T!z1kk9PmhBsBM5dYWVkqGW=4@Lzo4vKcjzxH4uR@M#v2!4=y$1jwh(>>~ z^3KY;R`(Nn_phQheHfj%Z~f4&e(_{*$ia2{)(tEiDjyHdg6BX?xGW~x=*j5VLt%%m z8)_V?88gp&+4Ay|%OX-rmXOO5aqv>|H;GiK;t(0lgtgGDqL*_Q$`NrOYWLUIFK<*R_MUaCru>iig18Vx7wCXD{EGSoSRrxARB~yJa0>IwDrSR5OB#Puy zZD*b{PYj46;nEvDN0B{kDCXt^Z zcp~O9{=87ep4cm!y!Z<#MIzjK;7@u*0Yyr!GOL_VoKGTgHOj8{?n@$3E%709m%Lua zG6E>_-gL><%5Zk@)onzTnINi_`}T%zajgvQVRvX(bW3Vw5F@t8*pn3UxFW*oxwtU8 z;F8)|m*0tq!LGVRo!DY_t(Dh`Iu-sW89XU{PZl|JS5VIac)oPv@WuW6KE3DJ3(xNR z(o=1haC2m5f)h7m@uwk>xXP^L*1@kwWo_QpV|T2DX~@N4fTX+2?vZwMU2pS{ zUJuy?>n*I<9yw=iv)dW~L0VWUSQ!{TdKX)dtbrv@uO(Mp$pXDfX-IMzv3MKqH_Z;;!$Kh;+V=h9F-@ z8|fRfl}=bUg}xbp993ol+nvM8Im}{@xWNXCQd@_OW6ezy_7;!40ahp257Yl$dxhq* zgxJ@1+%j2vgAc0rsRzo=C?bVw#s}6Drwqdfq>tu_r{jYd@!|d!65>2Ys-SvOyQ8by zBW-PKw!vh!Lk`Y?paXy%=TusszsBu0D-7y?QDc@62gi=&gSW=etNS(HemgkD?6(Ry=!^j(n+b(EsbzVBMwO?q;X$L<+AWEbwn2R zH@R3g$8cF8lBw>f+*9e6M!BR>hf*h`rpfS_y;TCxhxL{1T|OBa(N}hR1smozDGW2W zgU{(DQFN`uK(^5q$(Q8R=qBm7iZ0L=NjX>;s9pP0bs|@Y?VJICF4winxcPYZwr-=0 zTa9L=h0%izw zlA+FKg(=G|?&<}4RiKd=5}hAi8hTzLJEJgjc{DL){tQ?G(x4cRf>8hsTU)pcc!W^g zEv7cAByZ`sUC=j-QRD?WX^bOzd_yUT8r4R;MRJBP1Ga~I7{TKRo&W%4;jYd-1wfc} zX31O_Y=A*I)&to?uZr>(nK9G zRVj}^uvsd+djv~L2LQB0hnKKSGeI>j#$=G6P;KmbZ#5QSHMqMlrJS=ldbm!gKJK4f z1b~0rEvz2Drhko1k>hM?64S&pVWT;5-%FrEIf=3J4`49|5ey)p06cPAZ=2m=XQg}O zZT703_IBKt^2qH?)^=Mn_Xxc`(Isd7co*H2ln*rLfuvdF*P}m6+DHfv<)WFa z2Z2;{SdS4P8QYp*#rrqI$m;tMy+6eiSl_Xf0;wF^&o$8?^W3<*;kE~to}Y}Ad5DSo zG2EK9TotA;)6M3X)*-xo0RgMG5>}Avw%!hFH|NH{R}oCd2u~1a>u$F;+01HTc;1IJ z$c%M#v##ep!aN@%_&tL22rkf%%w}k|Iy0AiG&(o)dqQgYg1IDyKNm-Uf@mcgeq}I; zpbzJV(V^_mHpA{Hf9gj5yzSP34-aB zK1Ntw83Ba|EY8i8i(v_{@1}*hyPangDQsCVD_A#h_hSic4m^r?LkMuhg;H@|aJ>M` zAzU5aqbcveI0V};W(fc< zO}Y6i=~5!g{Hltc%*)owWT~Hv0I=F~cZ`0QcS2&j3RMTOsCy8cL=cOB_0zBLPK)5z z2;M;uiQqQ~{ujY-0eBRE6pOhYdn*@>VNnR)r7=Zmi(GJvY+!p=OP5F7-OHW9I96pT z+`+mA(-dwz+J+A6c3Xpkv+inWgMEj?&R3C9XHhCl0AU$^V`eCST`$qpTT}sJ1~MgP z0AWLrMGjD52?D5OsT4?7I#d>=Lk&U*4HH5H1NNvr+GSi*|CpxU0GgIC0if9FpK3 zc~3XYeH*968P$ORN5qaZ z17Q!R-A(=15<(^>jtmOh20&tM^`EF_JEm@Vm>m3f4PXRvTH zhPR6ynYCJ!24=FrEDWTU71>V93@CYqr3he=WhTIxmRSI2S!M&AZ7Bv=Y?%Xa4l8pm z%U*)n9dj-7AiRWy&u8HaSol24LI|JF!WXgdQWm~|g)C+vWh`W&MYd$qqUu}dk(&%; zFWr%2kS)FHx!nJJJBwd-)$?-SGkxhEgWR%W`r}IfevJ;>1MW)Ho!{S>QPr)J8kWP1hH)!A}Rd&F8Z4amDr*fovgS~78xU| z_akt9voA??GaH!}KG8yA=;1X6dTz5qT)mw=lMZU#6HQO56gj};&-Uf1xBKpDMLsD6 z#LxAI(t(3UvJ(fBKC>o--g6K{tt$=m`+Jca|8QkE3SFJ`H~G^CmV&D|yDULa<|6L{ zNd4fcblTJ6X-{Ror+-m9+^5-9E2{2?8~VgeI;i8Gr3URUeT@J?2}ELt`06W#fII1t zyW=F@2y!=cNvzW%O%|C%_1)ewMRHNI28ENrAuDPEx++ezl&sRXiAp4KqBdey?pEM_ zoD2Ib_czE}Q(lFOI=k9Fk-(}c?dP=Ln;ksIq~jtO^VZ_Za}2~2pFM!PjCZBC4o8QXI_ibg94W}4eTIN#ny&Fl2xh)s4GfIudX zu3i@WnMC&SQ-!??^48r z*!=4+5=6SFFGNI{HJOyzzr?LcxGaH(Z|w$X->(;x8mo1pFpmfj=c9Cc0%zA+?B#m-8WVAOH*eb;0!czZ>{x zx(G~V-c(I+F01E>VRFjhP3(f@&U@9uw6#cm3NedxrKlN*SP-X!=f1wA05gGYU++vsEBzkr1C!#N3F< z!vogyTz?%$AjH`8M|VH4`@tKBuXiQPoKVa-3PF9@6Tg-~^6>+e2TePbNR5AJlIb=YdqCB5Jg0(@{w<&i>u99>><4V(yPL z!a5uH_tnqnLu&1RgR7@*ro!gf>*!xQP6AbYfBkWBli*a2~9~Spff<;inHa$0hs$%L;>ZFg~cROh-Ls`Y|c{0iD)r8|buN z7SZq87E{y4FutLZsOXEcbo6kXkz&g#MMl=D)Fd&$lKV}TU8ENT?nOfhyonr zQXBCOw|TAu0Mrb(Ox8Jm#smWz=!}*WIjh-fx~XLb{jeoL0?a6kM(ZQ^w<^LUgUU7%j>>TGM`nz8Cg5OFq45WJP^ z>cBLx<8*X;6nM+FF^LNHG43Ty_yR3yPb3TJruG6kG5}l$y|+C@{!fUrAE$4$Z;j4& z1!oVhf34VEvdUGm%3V_JDybd|&K?h5LvuPx1Z0n-w{*N)!emBryN$cv#<6XWdmT!I z!D2G6Pw@VS6-SVO`Jv^Nr%0~L+rww zAPrnK1lke(nSd90d-~vx3dlbC$&M=|whS`1KrCF+Mch4D4E9YGOlJ@M%3hSc6rNx$ zz>2Z`ntK@nKy(D%B=DUKN48+bAzJ6iH@Wc^F^>sK*_7)O)L%HUJhQ#6(2I>?dd@MD zc@#pcxMKi80?#lO({We~W9_$^775w+(pP)3#A-3U)RXTO9xlM%CL zCD;cu1bKYjSIi}&Ag z{=`cckKDf7u+=)#o-1rYIj|l&x;R@;=u~o+@WKBx_;z?Y9*c=dmFhqH7j&G-3CW8~lvKO38aW7u)S}X*_&`mc~7EcDpjAfRdEOM8vca>qpWxZ0Tx-3$9HN?|; zUIE2J&nvFnn>6aWp+iFCbfI%`NIlL%cGWqG#G?;7PY|LUt=qFgOjaPSJM$yBz$**g5A@(b7Cm!!9M#?)MQd&~(CUAxsqXejdh_k^wEXsB`r2*r^o_eC z>2Gd{q3_-v5uE9J_gYbP4P(~okZYmQcSMpJI=C-}R^72G97UY$LZ$$(wW7`(`rI9f zG-F>J{qr3WB$r<5_f~Uuzn*^HZ=f6dP2zephN~?hJ|l_*`j`F)R%NwC>tCBOkX3!? z?s%%(7YX@IbostQP?H}7ZDaJEXi1}>Pw%Tt_IH*==gSV2DG;iK6{~KA@^9b)38zJ&C>6$YBH zH-e5GjPnPQT~ymwsIwT+QpUGG*SFKjm9gUL8-bfO@WJII2<(|UaaqQT{^?+3@FI2# z07~x*z`Lr0Aw+laU<`C!XBB;#k7pVX zu=@(d&U^@S@;5}otp9#c;}4-f3mC_zSQ|bJg*Lo@Ur4|*2sxf& zNS5GV25{Vk^lYVr_Y?NW2Ioiq;_Cv6fA-%L|12xsG_80cy}x+&$ci6f<7|3p7G3#3 zQLuk)gBtz#-@M|->=lXAULmCSzk)r&E7HM+1w^GuwClli0)c>4riUWwoev1p7VfdJ zLMVZH!5t3jnL}~(^}C}0+c4xO4|S3kQ5+<;B-7OoSE~Qw7xbjiqYtl>aVw#7sQ8gT zM>N3=+i5TKAIF{iiDO{tvU@g-{`m}=@o0PMIgHl=xH$08r57H%GLN%Ve5y z4t7Ey=aIshhj$jdLjefK0{||0M^78bWE!m;cOM3XdaVN(n_S-34x|A004B$Zau3oI zN6g`W#WIlYw0<4^=h+aFty zdM}3FfdDxWkHN_WuVI*UjU#gs_ml_{2gPGrFMPJmE-VG~foT`858$p|Pi}y}6;MZ6%UAeWUcr zCX#kmlRQ*5p(&WuXx*APmnLp3zHUNu9eXsnG^U}j2~FB0H66V@C{C19PA;29pE-I& ztUC)vlTx>-*kvkyId?*_Vzlb1nPTU>3B`Ogr_=7!x)nJtMb2>Zgkpw2B-y1%b}O=6 zimc%!&#gYTdO|VpTzt}_U5C5e@v~j=v(IX@2bbTud|=aX8dT}5Cfuz_acNTAnj)8` z=!#5|phD493`nu!&BO8Iam9k*3WW>NE=9C^^wl426Nj*>9SuGbJls2>D5mWrGwJr{ z;wK*!aG455ilH`CI#L^u|KJzW7SX?q z{1P-Q$DdElGnR@=mzczFEiomRE)9~swNy^okr%b8H~pBLE-fK3t?%N6lg{8Rjo)3Xa&mWvvCK2Hj6E3 z^zT85bm8}5)bmm_nL%%OIZg@-P%OP$70D80U=>233R5qO!N_UNFv4(}uYRw0a0gZX zE?D?RLXw}@1i@yeZ<|T?_INYg#L_pF`0AyWtgB0e@tD?<`oO}nf3Prq;Y=cwmh*oq zE%%zzVzFu`Ux~n87F+V@oL6Fk^1+O{pj%E&Y8V=W!ctf(M=NRi%Bw4NI#KyDk!UAi zBG(%ustuw?w)@dvo+zrjmGzH-1W}zFBI4-56Y;d=)hI;~6gsn8S<5HR1ko_OE!Hxt zRte?Kh8D=L8rfnPo@Dw4NQeUG2%UMtpqvBc%=O_GCh?)x!4rve+w0SF;Dj{$6GLoZ zV&favCN5!#$s6W?gk(9RECOqS<<9e!E5rw8Sown)=KC|ScwYuUpLjn91L(8Bvd|~q zwgHD13$s9XoAI8D;o|BwKvJO64g3<;`erlWy^r_9$iIIdLVq=1tpp2|Zm`Jk#;r%n zm%n^7QAT&GYt=r)Y++NoA_I(u)zus6^B*VC1Ah++S|mL4sQ4qBkc^=nZz}R*M4LsH zMJw`_=XYyhrPNe6`sy(C3+Q2hHwV`SSF?<|3Xo7JMe!ob!ekMs#p0>{qcG`G`KlXv z>)l{E6#J19pXvjiq^nj5Pd|E7C6iR_6p4J1s!%AzPe_$AnLd5z&3dw&KeC6+qNZQS zY3@&gDR=Uqj9USf8r8k^4M|fnLaDpG$Ig8L8}W2$2MhiRf=3lUT~9*3Rk6e!ZJj;# z-CL>V=cRHmw=Xf>M7R9hB$vR=?nU&WN9NFH-kvSj!7T75Vg~vwkuF%E%XW1(H&bkOFH7Z0{iH_c*2KFs@or6;OOy75{b=uFy(8+Ag%j!J6PjfK(U~qy z=8tBMgpOFAookWQ;M;IvR@WXWK>OPRtLmqxincnS~`+6 z()e`cgl6t#g#_Q51TVfi|47`)#)*s-6PlHmpuhd?x;38#i9%;z*2t9l4}b;^k9CKqPlTrrkV&1M z4;c(OqcdHRh>RIuf6)bI>P+9M_h@ve>ePP+H0m`$H8ktp6z6we)RL>?zO`?HaesT> z;%uu?^b2!=HAD1nLTQ|Ju4p0?1UVYQ;cO71<7?u`67Olmu8gctg5w zQ_;Pt2AK-Bd^tS=P86^msi-v}Ok6AB6G3*z4=YGmbgeWIs0bew<|9v_nCWS6gA77i z#Sf&FBe6k8P6cPFyFr59t*KQ5VG&##1Tdsl0Z_|G4PC7U?)8N#*r4S6U<1(MeXf|e z(C(1BH_+Ij88(erQQdFZeHq((L#TcMR9Q`b>xz8*`r=za4*(l|h(#Y*22k7~wZVZQ-66H|>SWx^^&!4~ z0K*a(CbNP5^j27gTXeA%rVO07RUKTModZ#%;#y zy#~7`T|eGO)=7C7WIvWJ@GMi;bR{uw z$i-lsflutA2AoHk!-Thqbk0Ql65RI8`m5IesVcD=cqG>DZq5QJIi#Gp8U)+1YN*2D zTy*b*DOL}+XlJRXt?ZZ_(W zJ4@cvgkBQ7+o<|wZt8a0WV+QFNTHJVC1s~vuI9#(4+!* zbhPU@6zPu2az$miqh`CJX1`)QDLxTBp;+>tr@0XmT8&0Qa zW$B@pH6op%-!@QqIb22WKApbR5Y87579UzVR4{Hx9kQM_Wb~I!dIQS>0+&o0jHeAL zLypsitbL0wB_s|fJ(qPXYoytoS#m0~WFTq4aliRw=*b&S>n;6@!M#QBL^_moMiB># z&QNc}=!`S#NhI#hsGRp2;F^SPEfCWu+==2?DgA{T`~hg<qK)V zDb%FNXAHs;J%R0zG`F`1MihXNLZ|(i&iY+~c(#<*{qAO(eMVUm%`lRY=fb*95=8YV z>0nol2*mq-i0(7SBvDl3mnC#d)(1HC)kwU4Z*X&DXS^7n6Jn|9gD$6> zgKFclqOSq-E3XPqup#RK6CTk;e(2)peHZu93$OJBDi67bAzu|}3ejrIn+*+Xzym4- zsJehq?ult##660SjC6cbjBZQY;VoP{08a>ts*dhajTt8sz0 zG}kC37vY%q!3#kBD3&S>A83k!gwPrXn#W=n(kLuUFWduZ~|lJQV8%$=0<2~FbF zZgkAyOAv^iQN(`zML6yIU3hWn*wPJS8|%k5Y#J-uJfUg$Kok1iWtmuM_|_f)Z@ooc z8YOxwO1)Smc`GGmu}tz4nGEim1ho6z?{=D>#vU00;L|;`fFlqf=$?I;@+rOf!{n6J z*c=?1KG6q?neT;wuuxd81g}8wMPsHPMv3N1Hno16;Iv45${^TVz?{)6>p@i`N~ce} zdj93U^DjSj>Da4q5WvN|?!R!`lNatja%tZke$6vD{P!qXa<3|y?HI?z=miJ{iXOF~ zXKv^QcPTXD@N0^Jum=OGj*fL;iTF7b;Ik}&TkaB$TI{q@yGr9+hpj};27J;`2+3T@ zx4+DdDH6^o7PF-&O8<8j@27vj7yUCCi2#YeMO-hZeUq`&39kGLgOqVT796}{{iKh; zE<=y-E*Y4-eSaYwykV6<=7dUX#N85pPZu1@P*-S?UDbW^orkC}^f{~Q5;uXIse0TP* zX1gmkxhgh|ZMKeAG>+?=`sE*L!w0WFs|)3W27}x>lS^kBGLGvqz@l4F)tb)MJ?hxR*^!dD8MKPwQpI&qM%@NiO5hE6MCh zcqtIc{!0gU@T|D@!=j**P=5W2z1bP#cd1)J6U@9$E{!J(k~p>m-frghUf=U+@VRX&?M)0 zNHT0?n$Uxl``~m2LF+WGNf=5#tw|l03^zWbIIYR=C;vIcS$&jSpLR;0HqClX!;NcrC z%Onx1DRDO$OSky~{dQ=85qWlQ~HcEz(bV27aDaak!AvQgVL*5mnPQa)+D-enj`WAQyAzBPxW;8LdWN`wiYzmdc%q5A}ZFt6VRCs#cDT@2qji^!dVXC^u& z!luQV4m#}iE@1uO2h^`VhM)mk4Syh)V{^k34rkXuvIaacVQLn85~uG>Kaj+Ut;c;C z?56ek;RDTXW17pDc1EAxUplD?KDcb(vI8qVlZZky7&##r-FuDd8Gc292_@f(J zt@3${foJ$KX45NT@e%YR5MabsQrkJLLE;l{_}KpDMf~qJl4#V1P{<{ZXStYOkq))S z=OXdt`vc**-@F}sN}%>AZHW`D+TxRl)8LPyoRGu!4E_x`x*l0O`mlrPJLtoaAh09Z z%>gaV9C9l#33?C<2Tm}#G8486@?JM2*5mxT2%=T9qbv4e z2mgk?_>t8)p)2@8!32I|z}lj`FMA}+T~O{SD0dfBy9%n^1(s6}AEStTIaX@x=(B(N zgd`(Ea;pINI+YnPLjvEN=j!Myf5{*((U1RfJ$w25JnnOd2eWCIcK-!|_b&a}Urmtn z+4E8K`@gO>{t@5q?_saq?kBYQauRu$-f=mZasAx4bmX$dg3a{E1^Jps-q_O6YU|~` z#R9PHz>k^XN3e_j?<#YO)_gV#`r)q67D{TqhRikQ*#DEKH}`jZBkK3PPTqL;4+Q^2 zFh=9QoHO^&Si?*B{2+pd5V#PqN(KqU%0DpCKM}A7OPI{2kQuzCL@ed^0JIBmoA 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 J76iK0{{lO3C+PqH delta 24981 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 TtDtArMFD?*s24fILyi9rE=qv2 diff --git a/ui/__pycache__/flow_layout.cpython-314.pyc b/ui/__pycache__/flow_layout.cpython-314.pyc index 342c24f074ee72bf9737a112c0ee91120839e620..bca95383dbe84382dbf488a243501add050bd45e 100644 GIT binary patch delta 1072 zcmZXTTWkzr6vy{;_A+}L+m0^n-rBL6UAt|qAZ1;W(u*`*OQ@34^fKCt?($8$4XJ2F zMTKY`(hw2xC|Y4&yo!*Zi5CyDA>~0zB5n`fB)&8IBFw|@n>puy{^y(X&0z0*uRA8W z9VUMHTj!4~K5*X?O6t^&9oyBxtY&cMNJi5i3yrYEW~usN;<(;qESPfo9A9|ln5hlY zfX6K|rYtT2(gJo>g|KIZjL*GG&26w2aMBjU+c7)NStRWC_;5L`k~pjgk0YK^h-PVc4p_lTv2qDpHga;<#Sk1Zz=~+abocBIAN=hi&?2`3XQB z&Xy)X)fY-Xn5}Ij@5E>23LL`E@jW-8& zf<#Ho2DiW&oC|hBA4;J(OkgtfRZQ_@RAi%=sED}h>7j^~Q9&Obu1La!ey8FX@Ttnm zI;+ug98*dqG~+SF2j}skqQE8nzS04QjBa-3rDBn=%&_6eJ2F`|*mZQ^q}t1tQ9CT?Ql&$7xoCJb3D2yy0p;lEZNxz6NGadc^6ZU zs$FEw{r~Dp!_PZ*@2#(I*xlK>BgOVp$taj!BNc#{j)dW+J{;L&S!Nt9Ipzg5(;KxFX4#DItNm~ge^-ZK1_Lz#nAMXt!!*|i zwL5mzkncLeCc-vCBcX*5C!`6+m!;92aiqqc$LaArf#$@JFoP|ta}NFkf2Y6rX9>Ps j{mRo$nFEBqglW8+2*E>qk*F)yioi3~^vgsm3-atA<)jI9 delta 1145 zcmZXTO>7fa5XU{9@3;MiM}+L)MxZ;}uRH?~F8gb;<6wrNv?Ux<-o!}B_P ziy(pmC{jZvLPE6ap_fLAkkB4`Xr-1DNE`rBZKXA@j$|dw`C7XfhPRgTZ@0n3jPrB;V-ogc?dqRe1Z=G^|ZqeZEly%uveHi znB%OZVa7JOwpS#aviz>tzj0o5&hD`K+C^mC%6H+M zJta&J?bk&Rb1XnR{_kjn9;|n!h2;z9ys$iX`XPa`D++1c=xT-z%(|`(nkgi{QDowE z0)~^bO5qGkPm0gs8DXhN0UIYin-I} zs+Q8#b-x1a#Ao##kk=dhPit%kDZ^Lzs!@%9N!pqs8`BJ#7mki=hE!ywxQz!XW{eOa z9LBz;Jwkt_X^R!A_|y8sa1KME^KSE$fS;nEPW&;{0~hhH&?t;ywq+Acqt^0V=@-tp z!q4KdaLm1zHr6-!@%wNWOzVGykAvvL$Le;QS#k`M#1&9K)YR2r55 diff --git a/ui/__pycache__/group.cpython-314.pyc b/ui/__pycache__/group.cpython-314.pyc index cedb468639a65abacc119c3709de2934d4d36372..3bae190b2040e331e7aabdd9f86e5c2d76de5f17 100644 GIT binary patch delta 13894 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 F{{kSahU@?U delta 8275 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 diff --git a/ui/__pycache__/item.cpython-314.pyc b/ui/__pycache__/item.cpython-314.pyc index 67ac67429a338f7f12586715adf2b736e324467c..34ecbb5a48513736e0d2b8e47edaf4127e0445cb 100644 GIT binary patch delta 10181 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 diff --git a/ui/__pycache__/settings_window.cpython-314.pyc b/ui/__pycache__/settings_window.cpython-314.pyc index 6552fac6c74009884f039a43a6d02d833d5f954b..ebc9e9fdefce16e59e4eaa5da7fdc29b78583b3b 100644 GIT binary patch delta 4573 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 AIsgCw delta 4068 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 JzkD|C|1S-c>}>!5 diff --git a/ui/__pycache__/theme.cpython-314.pyc b/ui/__pycache__/theme.cpython-314.pyc index 6e096e4c0263c2ade9d8b678b098cd5278da38c4..7e53ecef2e9ac6d312e9ea12b8c8d35a7254da89 100644 GIT binary patch delta 980 zcmZvYy>HV{6vgedY4RG!`Eow$r1@wHjZFZ@trs>VP)FcxNQfv7CMil{$q6832^%x= z(*J>xKZB)WLK#>Y>xr53o{2@&z9dKIT-|%$@8xIVk0Rfb)B6HW_5As}AIUHB%hjGO z(kzvrIhuzSr~)nWN{N=?EA#-W@=BGm{u(`m)*lLVhc@8ivX?hIJ z&^yp9uWatPi%OB6*kX;AXa}J}HK@v)s(heETYgORq*J3CetV|je-e*Ur-rsBV`uQj z@%(GC^w_ktwG`(5*!|#mOT`=6gN|ug-OcX|J(-JzGwsg?Q>Q<4C$6_lwBPdM{}$^_ z6I;`BXW$LTecSc0d@cQpyxZ@^+Ak9AAwO$kFJ|FoYcd~t?qp&QZoAjQ*2L7Az3;t za_PMwDUdP?goS;9sF#}mI$2(^T87oKXt>mZ9P|0m7noNBqM%-6z7+a0^A&hDe87S# zko=YUAgHoX6G#yghs@UnQes^V7McR7u+U=RScpr3;9n=}V9)_6Zy46(5;)V~ob#Nnu+`9jj-2V&tMFYHJCfF*}^V4-uj${pDI zCq(Mhv3sXVk^hN$>fCee*v&oo(~omMpYtL97VitvGf7NwaDFC!uU;g-Nbe3=Ge&bX z4_cr_&=M_!R%jKpM)9^z&p{i$r|EZERDe$VnxiN50w_T*L6futN~jEaMz26c`UEsX zyP!G06Yjvd75dbSHDC!|*J%&5;hUON0dCQb6OY+xZPb7G3^nIdtaBKQ6qhWSF=O_` zT!! zk9;Eqnx<)jswz6?Iu?AL!ohz(JUr*zo%|AdZ(hEC%{JEES6<>{Kb&uZT0bgYGAEvT z0!J#q1Ubc#`myW$5h{mc*UMlli`QK*hnx?r1>_<}iqJ11mjkXKR|Bpg*Ey1h(dWnw zj+DUPL~e1U4BSS(;7Aobm&jLKBJFWG>GWQ3K9kzPk0?Eg2pmN8&cNlRao4pcB1 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)