import sys import os import time import ctypes import ctypes.wintypes import subprocess import importlib.util from PyQt6.QtCore import Qt, QAbstractNativeEventFilter from PyQt6.QtWidgets import QApplication from PyQt6.QtGui import QIcon from db.database import init_db from ui.dock import PanelWindow, _set_autostart from ui.ball import FloatBall, BALL_SIZE import ui.theme as theme from db import database from app_info import __VERSION__, app_title def _install_requirements_on_startup() -> None: """ 启动时仅在“检测到缺失依赖”时安装 requirements。 失败时不阻塞主程序启动。 """ if getattr(sys, "frozen", False): # 打包态通常无法通过当前 exe 直接调用 pip,直接跳过 return req_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "requirements.txt") if not os.path.exists(req_path): return module_name_map = { "pyqt6": "PyQt6", "pillow": "PIL", } def _req_name(req_line: str) -> str: s = req_line.split(";", 1)[0].strip() s = s.split("[", 1)[0] for sep in ("==", ">=", "<=", "~=", "!=", ">", "<"): if sep in s: s = s.split(sep, 1)[0].strip() break return s missing_reqs: list[str] = [] try: with open(req_path, "r", encoding="utf-8") as f: for raw in f: line = raw.strip() if not line or line.startswith("#"): continue pkg_name = _req_name(line) if not pkg_name: continue mod_name = module_name_map.get(pkg_name.lower(), pkg_name) if importlib.util.find_spec(mod_name) is None: missing_reqs.append(line) except Exception: return if not missing_reqs: return cmd = [sys.executable, "-m", "pip", "install", *missing_reqs] kwargs = { "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "check": False, } if sys.platform == "win32": kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW try: subprocess.run(cmd, **kwargs) except Exception: pass class GlobalHotkey(QAbstractNativeEventFilter): """ 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__() self._panel = panel self._ball = ball def install(self): if sys.platform != "win32": return 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": 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): if event_type == b"windows_generic_MSG": import ctypes msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents WM_HOTKEY = 0x0312 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_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): """ 打包 EXE 后获取资源路径,开发环境也能用 """ if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.abspath("."), relative_path) # ========================================================== def _set_windows_app_user_model_id(appid: str): """让任务栏图标/分组按 AppID 识别(开发态避免显示 Python 图标)。""" if sys.platform != "win32": return try: import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) except Exception: pass def _wake_existing_or_exit() -> bool: """ Windows 单实例: - 若已有进程在运行:唤醒已有窗口并返回 False(当前进程退出) - 若无已有进程:返回 True(继续正常启动) """ if sys.platform != "win32": return True import ctypes import ctypes.wintypes mutex_name = r"Global\CleanDesktopOrganizerSingleton" title = app_title() kernel32 = ctypes.windll.kernel32 user32 = ctypes.windll.user32 ERROR_ALREADY_EXISTS = 183 h_mutex = kernel32.CreateMutexW(None, False, mutex_name) if not h_mutex: return True last_err = kernel32.GetLastError() if last_err == ERROR_ALREADY_EXISTS: hwnd = None for _ in range(8): hwnd = user32.FindWindowW(None, title) if hwnd: break time.sleep(0.3) if hwnd: user32.ShowWindow(hwnd, 5) user32.SetForegroundWindow(hwnd) return False return True def main(): # 外部更新器启动时带 --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() QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi, True) try: QApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) except Exception: pass app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) _set_windows_app_user_model_id("niumasoftware") # 任务栏/窗口图标:开发态用 ico;打包后也能通过资源路径找到 try: ico_path = get_resource_path("logo.ico") if os.path.exists(ico_path): app.setWindowIcon(QIcon(ico_path)) except Exception: pass init_db() theme.load() _set_autostart(True) panel = PanelWindow() ball = FloatBall() 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)) # 全局快捷键 Alt+` 唤醒/隐藏主界面(Win32 RegisterHotKey,真正全局) _hotkey = GlobalHotkey(panel, ball) _hotkey.install() has_saved = bool(database.get_setting("panel_x", "")) if has_saved: panel.setWindowOpacity(0) panel.show() panel.raise_() if hasattr(panel, "_ball_ref"): panel._ball_ref.hide() from PyQt6.QtCore import QPropertyAnimation anim = QPropertyAnimation(panel, b"windowOpacity") anim.setDuration(180) anim.setStartValue(0.0) anim.setEndValue(1.0) anim.start() panel._anim = anim else: ball._place_default() panel.show_near(ball.pos(), BALL_SIZE) sys.exit(app.exec()) if __name__ == "__main__": main()