modlink_studio.startup

One-shot helpers that run during desktop startup.

These exist as their own module so app.py can stay focused on the top-to-bottom launch sequence. Everything here is called once per process: icon loading, the Windows AUMID tag, the splash screen, and the deferred heavy import chain that lives behind the splash.

  1"""One-shot helpers that run during desktop startup.
  2
  3These exist as their own module so ``app.py`` can stay focused on the
  4top-to-bottom launch sequence. Everything here is called once per
  5process: icon loading, the Windows AUMID tag, the splash screen, and
  6the deferred heavy import chain that lives behind the splash.
  7"""
  8
  9from __future__ import annotations
 10
 11import sys
 12import time
 13from importlib.resources import files
 14from pathlib import Path
 15from threading import Thread
 16
 17from PyQt6.QtCore import QSize, Qt
 18from PyQt6.QtGui import QIcon, QPixmap
 19from PyQt6.QtWidgets import QApplication
 20
 21WINDOWS_APP_USER_MODEL_ID = "ModLink.Studio.Desktop"
 22SPLASH_WINDOW_SIZE = QSize(420, 280)
 23SPLASH_ICON_SIZE = QSize(112, 112)
 24SPLASH_STATUS_TEXT = "正在启动 ModLink Studio..."
 25
 26
 27def load_app_icon() -> QIcon:
 28    """Return the packaged ``app_icon.png`` as a :class:`QIcon`.
 29
 30    Falls back to the repo-local copy under ``assets/`` so editable
 31    installs without ``setup.py`` package data still find it.
 32    """
 33    try:
 34        icon_bytes = files("modlink_studio").joinpath("app_icon.png").read_bytes()
 35    except (FileNotFoundError, ModuleNotFoundError, OSError):
 36        icon_bytes = None
 37
 38    if icon_bytes is not None:
 39        pixmap = QPixmap()
 40        if pixmap.loadFromData(icon_bytes):
 41            icon = QIcon()
 42            icon.addPixmap(pixmap)
 43            return icon
 44
 45    repo_icon_path = Path(__file__).resolve().parents[3] / "assets" / "app_icon.png"
 46    if repo_icon_path.is_file():
 47        return QIcon(str(repo_icon_path))
 48    return QIcon()
 49
 50
 51def set_windows_app_user_model_id() -> None:
 52    """Bind the process to our own AUMID so the Windows taskbar uses our icon
 53    instead of the cached ``python.exe`` default."""
 54    if sys.platform != "win32":
 55        return
 56    import ctypes
 57
 58    ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(WINDOWS_APP_USER_MODEL_ID)
 59
 60
 61def show_splash_screen(icon: QIcon):
 62    """Display a borderless splash with our icon, a top-left version badge,
 63    a status line, and an indeterminate progress bar at the bottom edge."""
 64    from qfluentwidgets import BodyLabel, IndeterminateProgressBar, SplashScreen, Theme, setTheme
 65
 66    from . import __version__
 67
 68    # Apply theme on the main thread before any styled widget is created.
 69    # qfluentwidgets installs an application-wide event filter that polishes
 70    # styled widgets on the GUI thread; doing setTheme off-thread races with
 71    # widget construction and can wipe inline stylesheets.
 72    setTheme(Theme.AUTO)
 73
 74    splash = SplashScreen(icon, parent=None)
 75    splash.setWindowFlags(Qt.WindowType.SplashScreen | Qt.WindowType.WindowStaysOnTopHint)
 76    splash.titleBar.maxBtn.hide()
 77    splash.titleBar.closeBtn.clicked.connect(lambda: sys.exit(0))
 78    splash.setIconSize(SPLASH_ICON_SIZE)
 79    splash.resize(SPLASH_WINDOW_SIZE)
 80
 81    width = splash.width()
 82    height = splash.height()
 83    side_margin = 12
 84
 85    # Top-left version badge, mimicking the "Office 2021" pill in Word splash.
 86    badge = BodyLabel(f"ModLink Studio {__version__}", splash)
 87    badge.setStyleSheet(
 88        "QLabel {"
 89        " background-color: rgba(0, 120, 212, 0.12);"
 90        " color: rgb(0, 90, 158);"
 91        " border-radius: 6px;"
 92        " padding: 4px 10px;"
 93        " font-weight: 600;"
 94        "}"
 95    )
 96    badge.adjustSize()
 97    badge.move(side_margin, side_margin)
 98
 99    # Status line just above the bottom progress bar.
100    status_label = BodyLabel(SPLASH_STATUS_TEXT, splash)
101    status_label.adjustSize()
102    progress_bar = IndeterminateProgressBar(splash)
103    progress_bar.setFixedHeight(4)
104    progress_bar.setGeometry(0, height - 4, width, 4)
105    progress_bar.start()
106    status_label.move(side_margin, height - 4 - status_label.height() - 12)
107
108    splash._badge = badge  # keep references alive
109    splash._status_label = status_label
110    splash._progress_bar = progress_bar
111
112    center = QApplication.primaryScreen().availableGeometry().center()
113    splash.move(center.x() - splash.width() // 2, center.y() - splash.height() // 2)
114    splash.show()
115    _apply_windows_round_corner_preference(splash)
116    QApplication.processEvents()
117    return splash
118
119
120def _apply_windows_round_corner_preference(widget) -> None:
121    """Ask DWM to round the splash corners on Windows 11, matching the main
122    window. Older Windows builds and non-Windows platforms silently fall
123    back to square corners."""
124    if sys.platform != "win32":
125        return
126    try:
127        import ctypes
128
129        DWMWA_WINDOW_CORNER_PREFERENCE = 33
130        DWMWCP_ROUND = 2
131        preference = ctypes.c_int(DWMWCP_ROUND)
132        ctypes.windll.dwmapi.DwmSetWindowAttribute(
133            ctypes.c_void_p(int(widget.winId())),
134            ctypes.c_uint(DWMWA_WINDOW_CORNER_PREFERENCE),
135            ctypes.byref(preference),
136            ctypes.sizeof(preference),
137        )
138    except Exception:
139        return
140
141
142def load_runtime_deps() -> tuple[type, type, type]:
143    """Pull in engine, Qt bridge, main window, plus Qt-wide config knobs.
144
145    Imports happen in a background thread so the splash screen animation
146    stays alive. The main thread pumps Qt events at ~60 fps while waiting.
147    """
148    result: list[object] = []
149
150    def _import_worker() -> None:
151        import pyqtgraph as pg
152
153        from modlink_core import ModLinkEngine
154        from modlink_ui import MainWindow
155        from modlink_ui.bridge import QtModLinkBridge
156
157        pg.setConfigOptions(useOpenGL=True)
158        result.extend([ModLinkEngine, QtModLinkBridge, MainWindow])
159
160    thread = Thread(target=_import_worker, name="modlink.startup_import", daemon=True)
161    thread.start()
162    while thread.is_alive():
163        QApplication.processEvents()
164        time.sleep(0.016)
165    thread.join()
166
167    if len(result) != 3:
168        raise RuntimeError("startup import failed")
169    return result[0], result[1], result[2]
WINDOWS_APP_USER_MODEL_ID = 'ModLink.Studio.Desktop'
SPLASH_WINDOW_SIZE = PyQt6.QtCore.QSize(420, 280)
SPLASH_ICON_SIZE = PyQt6.QtCore.QSize(112, 112)
SPLASH_STATUS_TEXT = '正在启动 ModLink Studio...'
def load_app_icon() -> PyQt6.QtGui.QIcon:
28def load_app_icon() -> QIcon:
29    """Return the packaged ``app_icon.png`` as a :class:`QIcon`.
30
31    Falls back to the repo-local copy under ``assets/`` so editable
32    installs without ``setup.py`` package data still find it.
33    """
34    try:
35        icon_bytes = files("modlink_studio").joinpath("app_icon.png").read_bytes()
36    except (FileNotFoundError, ModuleNotFoundError, OSError):
37        icon_bytes = None
38
39    if icon_bytes is not None:
40        pixmap = QPixmap()
41        if pixmap.loadFromData(icon_bytes):
42            icon = QIcon()
43            icon.addPixmap(pixmap)
44            return icon
45
46    repo_icon_path = Path(__file__).resolve().parents[3] / "assets" / "app_icon.png"
47    if repo_icon_path.is_file():
48        return QIcon(str(repo_icon_path))
49    return QIcon()

Return the packaged app_icon.png as a QIcon.

Falls back to the repo-local copy under assets/ so editable installs without setup.py package data still find it.

def set_windows_app_user_model_id() -> None:
52def set_windows_app_user_model_id() -> None:
53    """Bind the process to our own AUMID so the Windows taskbar uses our icon
54    instead of the cached ``python.exe`` default."""
55    if sys.platform != "win32":
56        return
57    import ctypes
58
59    ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(WINDOWS_APP_USER_MODEL_ID)

Bind the process to our own AUMID so the Windows taskbar uses our icon instead of the cached python.exe default.

def show_splash_screen(icon: PyQt6.QtGui.QIcon):
 62def show_splash_screen(icon: QIcon):
 63    """Display a borderless splash with our icon, a top-left version badge,
 64    a status line, and an indeterminate progress bar at the bottom edge."""
 65    from qfluentwidgets import BodyLabel, IndeterminateProgressBar, SplashScreen, Theme, setTheme
 66
 67    from . import __version__
 68
 69    # Apply theme on the main thread before any styled widget is created.
 70    # qfluentwidgets installs an application-wide event filter that polishes
 71    # styled widgets on the GUI thread; doing setTheme off-thread races with
 72    # widget construction and can wipe inline stylesheets.
 73    setTheme(Theme.AUTO)
 74
 75    splash = SplashScreen(icon, parent=None)
 76    splash.setWindowFlags(Qt.WindowType.SplashScreen | Qt.WindowType.WindowStaysOnTopHint)
 77    splash.titleBar.maxBtn.hide()
 78    splash.titleBar.closeBtn.clicked.connect(lambda: sys.exit(0))
 79    splash.setIconSize(SPLASH_ICON_SIZE)
 80    splash.resize(SPLASH_WINDOW_SIZE)
 81
 82    width = splash.width()
 83    height = splash.height()
 84    side_margin = 12
 85
 86    # Top-left version badge, mimicking the "Office 2021" pill in Word splash.
 87    badge = BodyLabel(f"ModLink Studio {__version__}", splash)
 88    badge.setStyleSheet(
 89        "QLabel {"
 90        " background-color: rgba(0, 120, 212, 0.12);"
 91        " color: rgb(0, 90, 158);"
 92        " border-radius: 6px;"
 93        " padding: 4px 10px;"
 94        " font-weight: 600;"
 95        "}"
 96    )
 97    badge.adjustSize()
 98    badge.move(side_margin, side_margin)
 99
100    # Status line just above the bottom progress bar.
101    status_label = BodyLabel(SPLASH_STATUS_TEXT, splash)
102    status_label.adjustSize()
103    progress_bar = IndeterminateProgressBar(splash)
104    progress_bar.setFixedHeight(4)
105    progress_bar.setGeometry(0, height - 4, width, 4)
106    progress_bar.start()
107    status_label.move(side_margin, height - 4 - status_label.height() - 12)
108
109    splash._badge = badge  # keep references alive
110    splash._status_label = status_label
111    splash._progress_bar = progress_bar
112
113    center = QApplication.primaryScreen().availableGeometry().center()
114    splash.move(center.x() - splash.width() // 2, center.y() - splash.height() // 2)
115    splash.show()
116    _apply_windows_round_corner_preference(splash)
117    QApplication.processEvents()
118    return splash

Display a borderless splash with our icon, a top-left version badge, a status line, and an indeterminate progress bar at the bottom edge.

def load_runtime_deps() -> tuple[type, type, type]:
143def load_runtime_deps() -> tuple[type, type, type]:
144    """Pull in engine, Qt bridge, main window, plus Qt-wide config knobs.
145
146    Imports happen in a background thread so the splash screen animation
147    stays alive. The main thread pumps Qt events at ~60 fps while waiting.
148    """
149    result: list[object] = []
150
151    def _import_worker() -> None:
152        import pyqtgraph as pg
153
154        from modlink_core import ModLinkEngine
155        from modlink_ui import MainWindow
156        from modlink_ui.bridge import QtModLinkBridge
157
158        pg.setConfigOptions(useOpenGL=True)
159        result.extend([ModLinkEngine, QtModLinkBridge, MainWindow])
160
161    thread = Thread(target=_import_worker, name="modlink.startup_import", daemon=True)
162    thread.start()
163    while thread.is_alive():
164        QApplication.processEvents()
165        time.sleep(0.016)
166    thread.join()
167
168    if len(result) != 3:
169        raise RuntimeError("startup import failed")
170    return result[0], result[1], result[2]

Pull in engine, Qt bridge, main window, plus Qt-wide config knobs.

Imports happen in a background thread so the splash screen animation stays alive. The main thread pumps Qt events at ~60 fps while waiting.