summaryrefslogtreecommitdiff
path: root/core/file_association.py
diff options
context:
space:
mode:
Diffstat (limited to 'core/file_association.py')
-rw-r--r--core/file_association.py199
1 files changed, 199 insertions, 0 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