""" Filename: file_ext_registration.py Description: Registers the extension of project files with the application Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports import os import sys import plistlib import subprocess from typing import Optional, Tuple # External library imports from src.shared.shareddata import APP_NAME, PLATFORM_NAME ELEVATION_FLAG = "--register_file_association_elevated" def register_file_association(ext: Optional[str] = None, prog_id: Optional[str] = None, app_name: Optional[str] = None, bundle_id: Optional[str] = None, force_admin: bool = False, ) -> Tuple[bool, str]: """ Registers a custom file extension across Windows, Linux, and macOS. Handles non-admin Windows users by falling back to local user registry. """ clean_app = APP_NAME.replace(" ", "").lower()[:-1] if ext is None: ext = f".{clean_app}" if prog_id is None: prog_id = f"{APP_NAME.upper().replace(' ', '')}.ProjectFile" if app_name is None: app_name = APP_NAME # Ensure extension starts with dot and contains no spaces ext = f".{ext.lstrip('.').replace(' ', '').lower()}" if PLATFORM_NAME == "windows": return _register_windows(ext, prog_id, app_name, force_admin=force_admin) elif PLATFORM_NAME == "linux": return _register_linux(ext, prog_id, app_name) elif PLATFORM_NAME == "darwin": # macOS if bundle_id is None: bundle_id = f"com.{clean_app}.app" return _register_macos(ext, app_name, bundle_id) else: return False, f"Unsupported OS: {PLATFORM_NAME}" def _dev_windowless_executable() -> str: """ Windows dev-mode only: returns pythonw.exe alongside the current interpreter if it exists, otherwise falls back to sys.executable. """ if getattr(sys, 'frozen', False): return sys.executable exe_dir = os.path.dirname(sys.executable) windowless = os.path.join(exe_dir, "pythonw.exe") return windowless if os.path.exists(windowless) else sys.executable def is_windows_admin() -> bool: """ Returns True if currently running elevated on Windows. Always False on macOS/Linux, and False (rather than raising) if the check itself fails. """ if PLATFORM_NAME != "windows": return False import ctypes try: return ctypes.windll.shell32.IsUserAnAdmin() != 0 except Exception: return False def _relaunch_elevated(ext: str, prog_id: str, app_name: str) -> Optional[int]: """ Triggers a UAC prompt and re-launches this process elevated with the registration args. """ import ctypes from ctypes import wintypes if getattr(sys, 'frozen', False): exe = sys.executable params = [ELEVATION_FLAG, ext, prog_id, app_name] else: exe = _dev_windowless_executable() script_path = os.path.abspath(sys.argv[0]) params = [script_path, ELEVATION_FLAG, ext, prog_id, app_name] param_str = " ".join(f'"{p}"' for p in params) SEE_MASK_NOCLOSEPROCESS = 0x00000040 SW_SHOWNORMAL = 1 class SHELLEXECUTEINFO(ctypes.Structure): _fields_ = [ ("cbSize", wintypes.DWORD), ("fMask", ctypes.c_ulong), ("hwnd", wintypes.HWND), ("lpVerb", wintypes.LPCWSTR), ("lpFile", wintypes.LPCWSTR), ("lpParameters", wintypes.LPCWSTR), ("lpDirectory", wintypes.LPCWSTR), ("nShow", ctypes.c_int), ("hInstApp", wintypes.HINSTANCE), ("lpIDList", ctypes.c_void_p), ("lpClass", wintypes.LPCWSTR), ("hKeyClass", wintypes.HKEY), ("dwHotKey", wintypes.DWORD), ("hIconOrMonitor", wintypes.HANDLE), ("hProcess", wintypes.HANDLE), ] sei = SHELLEXECUTEINFO() sei.cbSize = ctypes.sizeof(sei) sei.fMask = SEE_MASK_NOCLOSEPROCESS sei.lpVerb = "runas" sei.lpFile = exe sei.lpParameters = param_str sei.nShow = SW_SHOWNORMAL if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei)): # User clicked "No" on the UAC prompt, or elevation failed outright. return None WAIT_INFINITE = 0xFFFFFFFF ctypes.windll.kernel32.WaitForSingleObject(sei.hProcess, WAIT_INFINITE) exit_code = wintypes.DWORD() ctypes.windll.kernel32.GetExitCodeProcess(sei.hProcess, ctypes.byref(exit_code)) ctypes.windll.kernel32.CloseHandle(sei.hProcess) return exit_code.value def _delete_key_recursive(root_key: int, path: str) -> None: """Recursively deletes a registry key and all its subkeys, if present.""" import winreg try: with winreg.OpenKey(root_key, path, 0, winreg.KEY_ALL_ACCESS) as key: while True: try: subkey_name = winreg.EnumKey(key, 0) except OSError: break _delete_key_recursive(root_key, f"{path}\\{subkey_name}") winreg.DeleteKey(root_key, path) except FileNotFoundError: pass def _cleanup_stale_hkcu_entries(ext: str, prog_id: str) -> None: """ Removes any leftover per-user (HKCU) association for this ext/prog_id. Windows prefers HKCU\\Software\\Classes over HKEY_CLASSES_ROOT for a given user, so a stale non-admin dev-time registration can silently keep shadowing a correct system-wide (admin) registration. """ import winreg _delete_key_recursive(winreg.HKEY_CURRENT_USER, f"Software\\Classes\\{ext}") _delete_key_recursive(winreg.HKEY_CURRENT_USER, f"Software\\Classes\\{prog_id}") def _register_windows(ext: str, prog_id: str, app_name: str, force_admin: bool = False) -> Tuple[bool, str]: """ Registers the file extension with the application on Windows. """ import winreg import ctypes is_admin = is_windows_admin() if force_admin and not is_admin: exit_code = _relaunch_elevated(ext, prog_id, app_name) if exit_code is None: return False, "Elevation was cancelled or the UAC prompt could not be shown." if exit_code != 0: return False, f"Elevated registration process exited with code {exit_code}." return True, f"Successfully registered {ext} on Windows (System-wide, via elevated relaunch)!" # Windows is the one platform where unfrozen invocation is supported if getattr(sys, 'frozen', False): command_str = f'"{sys.executable}" "%1"' else: script_path = os.path.abspath(sys.argv[0]) command_str = f'"{_dev_windowless_executable()}" "{script_path}" "%1"' root_key = winreg.HKEY_CLASSES_ROOT if is_admin else winreg.HKEY_CURRENT_USER base_path = "" if is_admin else "Software\\Classes\\" try: # 1. Map extension -> ProgID ext_path = f"{base_path}{ext}" with winreg.CreateKey(root_key, ext_path) as key: winreg.SetValue(key, "", winreg.REG_SZ, prog_id) # 2. Add to OpenWithProgids (Forces Windows 10/11 to show in 'Open With') open_with_path = f"{base_path}{ext}\\OpenWithProgids" with winreg.CreateKey(root_key, open_with_path) as key: winreg.SetValueEx(key, prog_id, 0, winreg.REG_SZ, "") # 3. Set friendly type description (shown as "Type" in File Explorer) type_description = f"{app_name.upper()} Project File" prog_path = f"{base_path}{prog_id}" with winreg.CreateKey(root_key, prog_path) as key: winreg.SetValue(key, "", winreg.REG_SZ, type_description) # 4. Set launch command cmd_path = f"{base_path}{prog_id}\\shell\\open\\command" with winreg.CreateKey(root_key, cmd_path) as key: winreg.SetValue(key, "", winreg.REG_SZ, command_str) # 5. Notify Shell (SHCNE_ASSOCCHANGED = 0x08000000, SHCNF_IDLIST = 0x0000) try: ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, 0, 0) except Exception: pass # 6. If we just wrote a system-wide entry, clean up any stale per-user entry that would otherwise shadow it. if is_admin: try: _cleanup_stale_hkcu_entries(ext, prog_id) except Exception: # Non-fatal: the system-wide write above already succeeded. pass scope = "System-wide" if is_admin else "Local User (Non-Admin)" return True, f"Successfully registered {ext} on Windows ({scope})!" except Exception as e: return False, f"Windows Registration failed: {str(e)}" # TODO: Validate def _register_linux(ext: str, prog_id: str, app_name: str) -> Tuple[bool, str]: """ Registers the file extension with the application on Linux based platforms. """ clean_ext = ext.lstrip('.') mime_type = f"application/x-{clean_ext}" desktop_file_name = f"{prog_id.lower()}.desktop" # Linux build is always a frozen PyInstaller executable exe_path = f'"{sys.executable}"' apps_dir = os.path.expanduser("~/.local/share/applications") mime_dir = os.path.expanduser("~/.local/share/mime/packages") os.makedirs(apps_dir, exist_ok=True) os.makedirs(mime_dir, exist_ok=True) try: # 1. Create XML MIME definition mime_xml_path = os.path.join(mime_dir, f"{prog_id.lower()}.xml") xml_content = f""" {app_name} File """ with open(mime_xml_path, "w") as f: f.write(xml_content) # 2. Create .desktop file desktop_path = os.path.join(apps_dir, desktop_file_name) desktop_content = f"""[Desktop Entry] Name={app_name} Exec={exe_path} %f Type=Application MimeType={mime_type}; Terminal=false """ with open(desktop_path, "w") as f: f.write(desktop_content) # 3. Update the shared-mime-info database and check it actually worked mime_result = subprocess.run( ["update-mime-database", os.path.expanduser("~/.local/share/mime")], capture_output=True, text=True, ) if mime_result.returncode != 0: return False, f"update-mime-database failed: {mime_result.stderr.strip()}" # 4. Set as default handler and check it actually worked xdg_result = subprocess.run( ["xdg-mime", "default", desktop_file_name, mime_type], capture_output=True, text=True, ) if xdg_result.returncode != 0: return False, f"xdg-mime default failed: {xdg_result.stderr.strip()}" return True, f"Successfully registered {ext} on Linux (Local User)!" except FileNotFoundError as e: return False, ( f"Required tool not found ({str(e)}). Is shared-mime-info / " f"xdg-utils installed on this system?" ) except Exception as e: return False, f"Linux Registration failed: {str(e)}" # TODO: Validate def _register_macos(ext: str, app_name: str, bundle_id: str) -> Tuple[bool, str]: """ Registers the file extension with the application on macOS. """ if not getattr(sys, 'frozen', False): return False, "macOS file association requires app to be packaged as a .app bundle." exe_path = sys.executable app_bundle_path = os.path.abspath(os.path.join(exe_path, "../../../")) if not app_bundle_path.endswith(".app"): return False, "Could not locate outer .app bundle." info_plist_path = os.path.join(app_bundle_path, "Contents", "Info.plist") if not os.path.exists(info_plist_path): return False, f"Info.plist not found at {info_plist_path}" clean_ext = ext.lstrip('.') uti = f"{bundle_id}.{clean_ext}" try: with open(info_plist_path, "rb") as f: plist = plistlib.load(f) # Tell macOS this app can open files with our UTI doc_types = plist.get("CFBundleDocumentTypes", []) if not any(uti in dt.get("LSItemContentTypes", []) for dt in doc_types): doc_types.append({ "CFBundleTypeName": f"{app_name} Project File", "CFBundleTypeRole": "Editor", "LSHandlerRank": "Owner", "LSItemContentTypes": [uti], }) plist["CFBundleDocumentTypes"] = doc_types # Declare the UTI itself (required — without this the type is unknown to LS) exported_types = plist.get("UTExportedTypeDeclarations", []) if not any(t.get("UTTypeIdentifier") == uti for t in exported_types): exported_types.append({ "UTTypeIdentifier": uti, "UTTypeDescription": f"{app_name} File", "UTTypeConformsTo": ["public.data"], "UTTypeTagSpecification": {"public.filename-extension": [clean_ext]}, }) plist["UTExportedTypeDeclarations"] = exported_types with open(info_plist_path, "wb") as f: plistlib.dump(plist, f) except Exception as e: return False, f"Failed to update Info.plist: {str(e)}" try: lsregister_path = ( "/System/Library/Frameworks/CoreServices.framework/Frameworks/" "LaunchServices.framework/Support/lsregister" ) subprocess.run( [lsregister_path, "-f", app_bundle_path], check=True, capture_output=True, ) return True, f"Registered {ext} with {app_bundle_path} via macOS Launch Services!" except subprocess.CalledProcessError as e: stderr = e.stderr.decode(errors="ignore") if e.stderr else str(e) return False, f"macOS Launch Services registration failed: {stderr}" except Exception as e: return False, f"macOS Registration failed: {str(e)}"