summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-17 10:01:59 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-17 10:01:59 -0600
commite749cca30da2d02839aca736873e7e5ad45e191c (patch)
treeca57d9ce2ff6a6f33193a59216be17b3b159c1af
parent34c12b2b5b280206890cd03153b1c95116acc85e (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.py199
-rw-r--r--labui.spec21
-rw-r--r--main.py33
-rw-r--r--ui/main_window.py5
-rw-r--r--ui/profile_manager_ui.py8
-rw-r--r--ui/windows/settings_window.py31
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
diff --git a/labui.spec b/labui.spec
index 71d50dd..0fdbedc 100644
--- a/labui.spec
+++ b/labui.spec
@@ -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,
+ },
+ )
diff --git a/main.py b/main.py
index 26fcaaf..414d06f 100644
--- a/main.py
+++ b/main.py
@@ -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",