252 lines
7.3 KiB
Python
252 lines
7.3 KiB
Python
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+`"""
|
||
_HOTKEY_ID = 0x4E4D # 任意唯一 ID
|
||
_MOD_ALT = 0x0001
|
||
_VK_BACKTICK = 0xC0 # VK_OEM_3 = ` 键
|
||
|
||
def __init__(self, panel: "PanelWindow", ball: "FloatBall"):
|
||
super().__init__()
|
||
self._panel = panel
|
||
self._ball = ball
|
||
self._hwnd = None
|
||
|
||
def install(self):
|
||
if sys.platform != "win32":
|
||
return
|
||
# 用一个隐藏窗口的 HWND 来接收 WM_HOTKEY
|
||
self._hwnd = int(self._panel.winId())
|
||
ctypes.windll.user32.RegisterHotKey(
|
||
self._hwnd, self._HOTKEY_ID, self._MOD_ALT, self._VK_BACKTICK
|
||
)
|
||
QApplication.instance().installNativeEventFilter(self)
|
||
|
||
def uninstall(self):
|
||
if self._hwnd:
|
||
ctypes.windll.user32.UnregisterHotKey(self._hwnd, self._HOTKEY_ID)
|
||
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 and msg.wParam == self._HOTKEY_ID:
|
||
self._toggle()
|
||
return False, 0
|
||
|
||
def _toggle(self):
|
||
if self._panel.isVisible():
|
||
self._panel.minimize_to_ball()
|
||
else:
|
||
self._panel.show_near(self._ball.pos(), BALL_SIZE)
|
||
self._panel.raise_()
|
||
self._panel.activateWindow()
|
||
|
||
# ===================== 打包兼容核心函数 =====================
|
||
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():
|
||
if 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()
|
||
|
||
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() |