From e749cca30da2d02839aca736873e7e5ad45e191c Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Mon, 17 Aug 2026 10:01:59 -0600 Subject: Add .labui file association — open by double-click, cross-platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/file_association.py | 199 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 core/file_association.py (limited to 'core') 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( + '\n' + '\n' + f' \n' + ' LabUI Profile\n' + f' \n' + ' \n' + '\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 -- cgit v1.2.3