250 lines
7.3 KiB
Python
250 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
|
|
|
|
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
|
|
)
|
|
QApplication.instance().installNativeEventFilter(self)
|
|
|
|
def uninstall(self):
|
|
if sys.platform == "win32":
|
|
ctypes.windll.user32.UnregisterHotKey(None, 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.is_really_visible():
|
|
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() |