niumasoftware/main.py

252 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()