diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-08-17 10:01:59 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-08-17 10:01:59 -0600 |
| commit | e749cca30da2d02839aca736873e7e5ad45e191c (patch) | |
| tree | ca57d9ce2ff6a6f33193a59216be17b3b159c1af | |
| parent | 34c12b2b5b280206890cd03153b1c95116acc85e (diff) | |
Add .labui file association — open by double-click, cross-platform
Windows/Linux self-register via core/file_association.py (winreg /
XDG .desktop+mime), triggered from a new Settings button since there's
no installer to do it at install time. macOS gets it at build time via
labui.spec's new BUNDLE() step (CFBundleDocumentTypes in Info.plist) —
that step also makes mac builds a real .app bundle for the first time,
not just a bare onedir folder. main.py gains a LabUIApplication
subclass to catch the macOS QFileOpenEvent (buffered if it arrives
before MainWindow exists) plus argv[1] handling for Windows/Linux.
ProfileButton.load_path() extracted so both paths share the same
load-and-apply logic as the existing file-dialog flow.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| -rw-r--r-- | core/file_association.py | 199 | ||||
| -rw-r--r-- | labui.spec | 21 | ||||
| -rw-r--r-- | main.py | 33 | ||||
| -rw-r--r-- | ui/main_window.py | 5 | ||||
| -rw-r--r-- | ui/profile_manager_ui.py | 8 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 31 |
6 files changed, 295 insertions, 2 deletions
diff --git a/core/file_association.py b/core/file_association.py new file mode 100644 index 0000000..047cdcc --- /dev/null +++ b/core/file_association.py @@ -0,0 +1,199 @@ +""" +core/file_association.py + +Self-registers LabUI as the handler for .labui files with the OS, so +double-clicking one launches the app — Windows (registry) and Linux (XDG). +User-scoped, no admin/root rights needed. + +macOS doesn't need any of this: labui.spec builds a real .app bundle with +CFBundleDocumentTypes baked into Info.plist at build time, which is enough +for Finder to associate .labui files with it automatically. + +Only meaningful for a packaged build (sys.frozen, set by PyInstaller) — +registering the bare Python interpreter as the handler for "python.exe +main.py" isn't useful, so callers should gate the UI action on that. +""" + +from __future__ import annotations +import os +import sys +import subprocess +from typing import Optional + +_PROG_ID = "LabUI.Profile" +_EXT = ".labui" +_MIME_TYPE = "application/x-labui" + + +def _exe_path() -> str: + return sys.executable + + +def is_registered() -> bool: + if sys.platform.startswith("win"): + return _win_is_registered() + if sys.platform.startswith("linux"): + return _linux_is_registered() + return False + + +def register() -> Optional[str]: + """Register .labui with this app. Returns an error string, or None on success.""" + try: + if sys.platform.startswith("win"): + _win_register() + elif sys.platform.startswith("linux"): + _linux_register() + else: + return f"File association isn't needed on this platform ({sys.platform})." + return None + except Exception as e: + return str(e) + + +def unregister() -> Optional[str]: + """Remove the association. Returns an error string, or None on success.""" + try: + if sys.platform.startswith("win"): + _win_unregister() + elif sys.platform.startswith("linux"): + _linux_unregister() + return None + except Exception as e: + return str(e) + + +# ── Windows ────────────────────────────────────────────────────────────────── + +def _win_is_registered() -> bool: + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + f"Software\\Classes\\{_EXT}") as k: + value, _ = winreg.QueryValueEx(k, "") + return value == _PROG_ID + except FileNotFoundError: + return False + + +def _win_register(): + import winreg + exe = _exe_path() + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, + f"Software\\Classes\\{_EXT}") as k: + winreg.SetValueEx(k, "", 0, winreg.REG_SZ, _PROG_ID) + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, + f"Software\\Classes\\{_PROG_ID}") as k: + winreg.SetValueEx(k, "", 0, winreg.REG_SZ, "LabUI Profile") + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, + f"Software\\Classes\\{_PROG_ID}\\DefaultIcon") as k: + winreg.SetValueEx(k, "", 0, winreg.REG_SZ, f'"{exe}",0') + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, + f"Software\\Classes\\{_PROG_ID}\\shell\\open\\command") as k: + winreg.SetValueEx(k, "", 0, winreg.REG_SZ, f'"{exe}" "%1"') + + _win_notify_shell() + + +def _win_unregister(): + import winreg + for path in ( + f"Software\\Classes\\{_PROG_ID}\\shell\\open\\command", + f"Software\\Classes\\{_PROG_ID}\\shell\\open", + f"Software\\Classes\\{_PROG_ID}\\shell", + f"Software\\Classes\\{_PROG_ID}\\DefaultIcon", + f"Software\\Classes\\{_PROG_ID}", + f"Software\\Classes\\{_EXT}", + ): + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path) + except FileNotFoundError: + pass + _win_notify_shell() + + +def _win_notify_shell(): + """Tell Explorer the file-type association changed — otherwise it keeps + showing the old icon/handler until logoff or reboot.""" + try: + import ctypes + SHCNE_ASSOCCHANGED = 0x08000000 + SHCNF_IDLIST = 0x0000 + ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None) + except Exception: + pass + + +# ── Linux (XDG) ────────────────────────────────────────────────────────────── + +def _xdg_data_home() -> str: + return os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share") + + +def _desktop_file_path() -> str: + return os.path.join(_xdg_data_home(), "applications", "labui.desktop") + + +def _mime_package_path() -> str: + return os.path.join(_xdg_data_home(), "mime", "packages", "labui-labui.xml") + + +def _linux_is_registered() -> bool: + return os.path.isfile(_desktop_file_path()) + + +def _linux_register(): + exe = _exe_path() + + desktop_path = _desktop_file_path() + os.makedirs(os.path.dirname(desktop_path), exist_ok=True) + with open(desktop_path, "w") as f: + f.write( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=LabUI\n" + f"Exec={exe} %f\n" + "Terminal=false\n" + f"MimeType={_MIME_TYPE};\n" + "NoDisplay=true\n" + ) + + mime_path = _mime_package_path() + os.makedirs(os.path.dirname(mime_path), exist_ok=True) + with open(mime_path, "w") as f: + f.write( + '<?xml version="1.0" encoding="UTF-8"?>\n' + '<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n' + f' <mime-type type="{_MIME_TYPE}">\n' + ' <comment>LabUI Profile</comment>\n' + f' <glob pattern="*{_EXT}"/>\n' + ' </mime-type>\n' + '</mime-info>\n' + ) + + # Best-effort — desktop-environment dependent, the glob-matched .desktop + # file alone is often enough even if these tools/steps are unavailable. + _run_best_effort(["update-desktop-database", os.path.join(_xdg_data_home(), "applications")]) + _run_best_effort(["update-mime-database", os.path.join(_xdg_data_home(), "mime")]) + _run_best_effort(["xdg-mime", "default", "labui.desktop", _MIME_TYPE]) + + +def _linux_unregister(): + for path in (_desktop_file_path(), _mime_package_path()): + try: + os.remove(path) + except FileNotFoundError: + pass + _run_best_effort(["update-desktop-database", os.path.join(_xdg_data_home(), "applications")]) + _run_best_effort(["update-mime-database", os.path.join(_xdg_data_home(), "mime")]) + + +def _run_best_effort(cmd: list): + try: + subprocess.run(cmd, check=False, capture_output=True, timeout=5) + except Exception: + pass @@ -81,3 +81,24 @@ coll = COLLECT( upx_exclude=[], name="LabUI", ) + +# macOS: wrap the onedir output in a real .app bundle. Required for both a +# double-clickable Finder app AND file association — CFBundleDocumentTypes +# here is what tells Finder/LaunchServices that LabUI opens .labui files +# (core/file_association.py doesn't need to do anything at runtime on this +# platform, unlike Windows/Linux, because of this). +if sys.platform == "darwin": + app = BUNDLE( + coll, + name="LabUI.app", + bundle_identifier="org.labui.LabUI", # placeholder — swap for a real reverse-DNS id + info_plist={ + "CFBundleDocumentTypes": [{ + "CFBundleTypeName": "LabUI Profile", + "CFBundleTypeExtensions": ["labui"], + "CFBundleTypeRole": "Editor", + "LSHandlerRank": "Owner", + }], + "NSHighResolutionCapable": True, + }, + ) @@ -12,10 +12,31 @@ import sys, os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PyQt6.QtWidgets import QApplication -from PyQt6.QtCore import qInstallMessageHandler, QtMsgType +from PyQt6.QtCore import qInstallMessageHandler, QtMsgType, QEvent from ui.main_window import MainWindow +class LabUIApplication(QApplication): + """Catches the macOS "open with" launch event (QFileOpenEvent) for a + double-clicked .labui file — delivered via this event, not argv, and + can arrive before or after MainWindow exists, so it's buffered.""" + + def __init__(self, argv): + super().__init__(argv) + self.file_to_open: str | None = None + self.main_window = None + + def event(self, e): + if e.type() == QEvent.Type.FileOpen: + path = e.file() + if self.main_window is not None: + self.main_window.load_profile_path(path) + else: + self.file_to_open = path + return True + return super().event(e) + + def _qt_message_handler(msg_type, context, message): """Silence one specific, harmless Qt warning: the native windowsvista style's combo-box/menu popup code calls QFont::setPointSize() on a @@ -36,7 +57,7 @@ def _qt_message_handler(msg_type, context, message): def main(): qInstallMessageHandler(_qt_message_handler) - app = QApplication(sys.argv) + app = LabUIApplication(sys.argv) app.setApplicationName("LabUI") qss = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui", "style_dark.qss") @@ -45,7 +66,15 @@ def main(): app.setStyleSheet(f.read()) win = MainWindow() + app.main_window = win win.show() + + # Opened via double-click / CLI argv (Windows, Linux) or the buffered + # macOS FileOpen event (see LabUIApplication.event above). + path = app.file_to_open or (sys.argv[1] if len(sys.argv) > 1 else None) + if path and path.lower().endswith(".labui") and os.path.isfile(path): + win.load_profile_path(path) + sys.exit(app.exec()) diff --git a/ui/main_window.py b/ui/main_window.py index 012d6f5..b632535 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -601,6 +601,11 @@ class MainWindow(QMainWindow): # ── Profile callbacks ───────────────────────────────────────────────── + def load_profile_path(self, path: str) -> bool: + """Load a .labui file from an explicit path — used when one is + opened from the OS (double-click / CLI argv), see main.py.""" + return self._file_btn.load_path(path) + def _profile_new(self): """Reset to a blank slate.""" # Clear devices diff --git a/ui/profile_manager_ui.py b/ui/profile_manager_ui.py index 446e867..79f333e 100644 --- a/ui/profile_manager_ui.py +++ b/ui/profile_manager_ui.py @@ -85,13 +85,21 @@ class ProfileButton(QPushButton): ) if not path: return + self.load_path(path) + + def load_path(self, path: str) -> bool: + """Load a profile from an explicit path — shared by the file dialog + above and by opening a .labui file from the OS (double-click / CLI + argv), see main.py.""" try: profile = Profile.load(path) self._apply_profile(profile) self._current_path = path self._update_title(profile.name) + return True except Exception as e: QMessageBox.critical(self, "Load Failed", str(e)) + return False def _do_save(self): if not self._current_path: diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index cea7e5c..ae44c31 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -137,6 +137,24 @@ class SettingsWindow(QWidget): self._update_status_lbl.setWordWrap(True) lay.addRow("", self._update_status_lbl) + import sys as _sys + if _sys.platform.startswith(("win", "linux")): + assoc_row = QHBoxLayout() + self._assoc_btn = QPushButton("Register .labui Files") + self._assoc_btn.setObjectName("configButton") + self._assoc_btn.setToolTip( + "Associates .labui files with LabUI so double-clicking one\n" + "in the OS file browser opens it here." + ) + self._assoc_btn.clicked.connect(self._register_file_association) + assoc_row.addWidget(self._assoc_btn) + assoc_row.addStretch() + lay.addRow("File Associations:", assoc_row) + self._assoc_status_lbl = QLabel("") + self._assoc_status_lbl.setObjectName("traceSource") + self._assoc_status_lbl.setWordWrap(True) + lay.addRow("", self._assoc_status_lbl) + rst = QPushButton("Reset to Defaults"); rst.setObjectName("configButton") rst.clicked.connect(self._reset_to_defaults) lay.addRow("", rst) @@ -390,6 +408,19 @@ class SettingsWindow(QWidget): except Exception as e: QMessageBox.critical(self, "Update Failed", str(e)) + def _register_file_association(self): + from core.updater import is_frozen + if not is_frozen(): + self._assoc_status_lbl.setText("Not available outside the packaged app (dev mode).") + return + + from core.file_association import register + err = register() + if err: + self._assoc_status_lbl.setText(f"Failed: {err}") + else: + self._assoc_status_lbl.setText("✓ .labui files now open with LabUI.") + def _reset_to_defaults(self): reply = QMessageBox.question( self, "Reset Settings", |
