From 074a0681b90a222ee60ff6fef8bce2c950f1e239 Mon Sep 17 00:00:00 2001 From: tyler Date: Thu, 6 Aug 2026 02:00:36 -0700 Subject: [PATCH] typos, standardization, and file association for project extensions --- README.md | 4 +- changelog.md | 6 +- file_ext_registration.py | 393 ++++++++++++++++++ main.py | 39 +- src/analysis/intergroupbrainimage.py | 3 +- .../intergroupfunctionalconnectivity.py | 1 - src/analysis/intergroupstats.py | 1 - .../participantfunctionalconnectivity.py | 3 - src/analysis/participantimage.py | 1 - src/window/about.py | 2 + src/window/terminal.py | 66 ++- src/window/userguide.py | 4 +- src/window/welcome.py | 9 +- startup_args.py | 41 ++ updater.py | 4 +- 15 files changed, 539 insertions(+), 38 deletions(-) create mode 100644 file_ext_registration.py create mode 100644 startup_args.py diff --git a/README.md b/README.md index e1ff3b1..36e26fb 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ FLARES (fNIRS Lightweight Analysis, Research, & Evaluation Suite) FLARES is a lightweight standalone application to extract meaningful data out of .snirf files. -FLARES is free and open-source software that runs on Windows, MacOS, and Linux. Please read the information regarding each operating system below. +FLARES is free and open-source software that runs on Windows, macOS, and Linux. Please read the information regarding each operating system below. Visit the official [FLARES web site](https://research.dezeeuw.ca/flares). [![Python web site](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org) -# For MacOS Users +# For macOS Users Due to the cost of an Apple Developer account, the application is not certified by Apple. Once the application is extracted and attempted to be launched for the first time you will get a popup stating: diff --git a/changelog.md b/changelog.md index f0e75b1..12d8cef 100644 --- a/changelog.md +++ b/changelog.md @@ -24,6 +24,8 @@ - Fixed an issue where when loading a save file some list dropdowns could go blue and bold even if the value was default - Fixed an issue where the Analysis button would not be clickable if the user had previously pushed Clear. Fixes [Issue 83](https://git.research.dezeeuw.ca/tyler/flares/issues/83) - Fixed an issue where some popup windows would not properly display the application name when they would open +- Fixed instances of the word MacOS to now read macOS to match Apple branding +- Fixed welcom dialog application image not displaying correctly on macOS # Version 1.5.2 @@ -133,7 +135,7 @@ # Version 1.4.1 -- Hotfix to fix a recursive child loop that would cause the MacOS version to not open +- Hotfix to fix a recursive child loop that would cause the macOS version to not open # Version 1.4.0 @@ -151,7 +153,7 @@ - Added feedback when clicking an analysis option that opens up a new window. Fixes [Issue 20](https://git.research.dezeeuw.ca/tyler/flares/issues/20) - Fixed an issue where projects can not be saved to a different drive letter on windows. Fixes [Issue 71](https://git.research.dezeeuw.ca/tyler/flares/issues/71) - Fixed an issue where the fOLD files were not included in the Windows version. Fixes [Issue 60](https://git.research.dezeeuw.ca/tyler/flares/issues/60) -- Fixed an issue where the MacOS version would fail to perform some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63) +- Fixed an issue where the macOS version would fail to perform some analysis options. Fixes [Issue 63](https://git.research.dezeeuw.ca/tyler/flares/issues/63) - Fixed an issue where processing too many participants would cause the analysis button to not appear. Fixes [Issue 61](https://git.research.dezeeuw.ca/tyler/flares/issues/61) - Fixed an issue where the error message when a participant fails would not appear. Fixes [Issue 68](https://git.research.dezeeuw.ca/tyler/flares/issues/68) - Fixed an issue where changes would not be saved if a project was originally loaded from a save. Fixes [Issue 44](https://git.research.dezeeuw.ca/tyler/flares/issues/44) diff --git a/file_ext_registration.py b/file_ext_registration.py new file mode 100644 index 0000000..36085dc --- /dev/null +++ b/file_ext_registration.py @@ -0,0 +1,393 @@ +""" +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)}" \ No newline at end of file diff --git a/main.py b/main.py index 0e4add5..d0a8701 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,7 @@ from PySide6.QtCore import Signal, Qt, QTimer from PySide6.QtGui import QAction, QFontMetrics, QKeySequence, QIcon from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen +from file_ext_registration import register_file_association, ELEVATION_FLAG from project_manager import ProjectManager from src.window.about import AboutWindow from src.window.terminal import TerminalWindow @@ -42,6 +43,7 @@ from src.window.viewerlauncher import ViewerLauncherWidget from src.window.welcome import WelcomeDialog from src.shared.flaresbasewidget import FilePickerWidget, ParamSection, ProgressBubble from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA +from startup_args import parse_startup_args from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread @@ -498,7 +500,7 @@ class MainApplication(QMainWindow): metadata_processed = Signal(str, int) metadata_ui_signal = Signal(dict, str, int) - def __init__(self): + def __init__(self, file_to_open=None): super().__init__() self.setWindowTitle(f"{APP_NAME.upper()}") self.setGeometry(100, 100, 1280, 720) @@ -602,8 +604,10 @@ class MainApplication(QMainWindow): welcome = WelcomeDialog(self, direct=True, first=False) welcome.show() + if file_to_open and os.path.exists(file_to_open): + self.project_manager.load_project(file_to_open) - + def init_ui(self): central = QWidget() @@ -1362,8 +1366,6 @@ class MainApplication(QMainWindow): self.sync_file_baselines() - #TODO: Update blue bold text too - # def show_files_as_bubbles(self, folder_paths): # if isinstance(folder_paths, str): @@ -2365,9 +2367,8 @@ class MainApplication(QMainWindow): self.update_window_title() print("[State] All changes saved to disk.") - - + def run_gui_entry_wrapper(config, gui_queue, progress_queue, ack_queue): """ Where the processing happens @@ -2450,6 +2451,7 @@ def exception_hook(exc_type, exc_value, exc_traceback): # Exit the app after user acknowledges sys.exit(1) + def show_critical_error(error_msg): msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Icon.Critical) @@ -2548,8 +2550,15 @@ def config_init(): - if __name__ == "__main__": + + if len(sys.argv) > 1 and sys.argv[1] == ELEVATION_FLAG: + _, _ext, _prog_id, _app_name = sys.argv[1:5] + _ok, _msg = register_file_association(ext=_ext, prog_id=_prog_id, app_name=_app_name) + sys.exit(0 if _ok else 1) + + startup_args = parse_startup_args(sys.argv) + # Redirect exceptions to the popup window sys.excepthook = exception_hook @@ -2578,16 +2587,12 @@ if __name__ == "__main__": # Only run GUI in the main process if current_process().name == 'MainProcess': app = QApplication(sys.argv) - finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path) - window = MainApplication() - - if PLATFORM_NAME == "darwin": - app.setWindowIcon(QIcon(resource_path("icons/main.icns"))) - window.setWindowIcon(QIcon(resource_path("icons/main.icns"))) - else: - app.setWindowIcon(QIcon(resource_path("icons/main.ico"))) - window.setWindowIcon(QIcon(resource_path("icons/main.ico"))) + finish_update_if_needed(PLATFORM_NAME, APP_NAME, cfg_path, startup_args.finish_update) + icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico" + app.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}"))) + window = MainApplication(file_to_open=startup_args.initial_file) + window.setWindowIcon(QIcon(resource_path(f"icons/main.{icon_ext}"))) window.show() sys.exit(app.exec()) -# Not 6000 lines yay! \ No newline at end of file +# Not 2600 lines yay! \ No newline at end of file diff --git a/src/analysis/intergroupbrainimage.py b/src/analysis/intergroupbrainimage.py index 0b6c567..10e94b5 100644 --- a/src/analysis/intergroupbrainimage.py +++ b/src/analysis/intergroupbrainimage.py @@ -21,7 +21,7 @@ from mne.io.base import BaseRaw from flares import aggregate_fnirs_group_geometry, plot_fir_model_results, brain_3d_visualization from src.shared.flaresbasewidget import InterGroupUIMixin, FlaresBaseWidget from src.shared.shareddata import APP_NAME -from mne.io import BaseRaw + PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { 0: [ @@ -81,7 +81,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { } - class InterGroupBrainImageWidget(InterGroupUIMixin, FlaresBaseWidget): def __init__( self, diff --git a/src/analysis/intergroupfunctionalconnectivity.py b/src/analysis/intergroupfunctionalconnectivity.py index 1567e03..334baf5 100644 --- a/src/analysis/intergroupfunctionalconnectivity.py +++ b/src/analysis/intergroupfunctionalconnectivity.py @@ -39,7 +39,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { } - class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget): def __init__( self, diff --git a/src/analysis/intergroupstats.py b/src/analysis/intergroupstats.py index f29aca9..6bbc06e 100644 --- a/src/analysis/intergroupstats.py +++ b/src/analysis/intergroupstats.py @@ -157,7 +157,6 @@ DESCRIPTION = """0. ROI vs. Zero (run_roi_second_level_analysis) class InterGroupStatsWidget(InterGroupUIMixin, FlaresBaseWidget): - def __init__( self, haemo_dict: dict[str | Path, BaseRaw], diff --git a/src/analysis/participantfunctionalconnectivity.py b/src/analysis/participantfunctionalconnectivity.py index 8313387..6461173 100644 --- a/src/analysis/participantfunctionalconnectivity.py +++ b/src/analysis/participantfunctionalconnectivity.py @@ -87,7 +87,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { } - class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidget): def __init__( self, @@ -106,7 +105,6 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg self.setup_participant_ui(["0 (Spectral Connectivity Epochs)", "1 (Envelope Correlation)", "2 (Betas)", "3 (Spectral Connectivity Epochs)",]) - def process_request(self): request = self.get_common_request_data(PARAMETERIZED_INDEXES) if request is None: @@ -138,7 +136,6 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") continue - for idx in selected_indexes: if idx == 0: diff --git a/src/analysis/participantimage.py b/src/analysis/participantimage.py index 682d80d..d4e41bd 100644 --- a/src/analysis/participantimage.py +++ b/src/analysis/participantimage.py @@ -85,7 +85,6 @@ class ParticipantImageViewerWidget(FlaresBaseWidget): self.showMaximized() - def show_selected_images(self): # Clear previous images while self.grid_layout.count(): diff --git a/src/window/about.py b/src/window/about.py index 8c1fad6..a2144b6 100644 --- a/src/window/about.py +++ b/src/window/about.py @@ -7,11 +7,13 @@ Author: Tyler de Zeeuw License: GPL-3.0 """ +# External library imports from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel from PySide6.QtCore import Qt from src.shared.shareddata import APP_NAME, APP_NAME_EXPANDED, CURRENT_VERSION + class AboutWindow(QWidget): """ Simple About window displaying basic application information. diff --git a/src/window/terminal.py b/src/window/terminal.py index 9bdf8e3..65b7ad5 100644 --- a/src/window/terminal.py +++ b/src/window/terminal.py @@ -7,16 +7,38 @@ Author: Tyler de Zeeuw License: GPL-3.0 """ +# Built-in imports from typing import Any, Callable +# External library imports from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QThread, Signal +from file_ext_registration import register_file_association, is_windows_admin from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME from src.window.about import AboutWindow from updater import UpdateManager +class _AssocWorker(QThread): + """ + Runs register_file_association() off the UI thread. Only actually + needed for the force_admin=True path, since that blocks on + WaitForSingleObject while the UAC prompt is up and the elevated + child process runs — which would otherwise freeze the terminal + window. Used for the non-elevated path too for consistency. + """ + result_ready = Signal(bool, str) + + def __init__(self, force_admin: bool, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._force_admin = force_admin + + def run(self) -> None: + ok, msg = register_file_association(force_admin=self._force_admin) + self.result_ready.emit(ok, msg) + + class TerminalWindow(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent, Qt.WindowType.Window) @@ -38,9 +60,13 @@ class TerminalWindow(QWidget): "help": self.cmd_help, "version": self.cmd_version, "about": self.cmd_about, + "assoc": self.cmd_assoc, "update": self.cmd_update, } + self._pending_assoc_confirmation: bool = False + self._assoc_worker: _AssocWorker | None = None + self.output_area.append(f"Welcome to {APP_NAME.upper()}. You are running version {CURRENT_VERSION}.") self.output_area.append("Type 'help' for a list of available commands.\n") @@ -52,6 +78,12 @@ class TerminalWindow(QWidget): self.input_line.clear() self.output_area.append(f"> {command_text}") + + if self._pending_assoc_confirmation: + self._pending_assoc_confirmation = False + self._handle_assoc_confirmation(command_text.strip().lower()) + return + parts = command_text.strip().split() if not parts: return @@ -101,4 +133,34 @@ class TerminalWindow(QWidget): self.output_area.append("Checking for updates...") self.updater.manual_check_for_updates() - return "See status bar for update information." \ No newline at end of file + return "See status bar for update information." + + def cmd_assoc(self, *args: Any) -> str | None: + # Non-Windows platforms don't have the admin/non-admin split — + # just register directly. + if PLATFORM_NAME != "windows" or is_windows_admin(): + self._run_assoc(force_admin=False) + return None + + self._pending_assoc_confirmation = True + return "Not running as admin. Register system-wide via UAC elevation? (y/n)" + + def _handle_assoc_confirmation(self, answer: str) -> None: + if answer in ("y", "yes"): + self._run_assoc(force_admin=True) + elif answer in ("n", "no"): + self._run_assoc(force_admin=False) + else: + self.output_area.append("Please answer 'y' or 'n'. Run 'assoc' again to retry.") + + def _run_assoc(self, force_admin: bool) -> None: + if force_admin: + self.output_area.append("Requesting elevation. Check for a UAC prompt...") + + self._assoc_worker = _AssocWorker(force_admin=force_admin, parent=self) + self._assoc_worker.result_ready.connect(self._on_assoc_result) + self._assoc_worker.start() + + def _on_assoc_result(self, ok: bool, msg: str) -> None: + self.output_area.append(msg) + self._assoc_worker = None \ No newline at end of file diff --git a/src/window/userguide.py b/src/window/userguide.py index 4c258aa..9189f61 100644 --- a/src/window/userguide.py +++ b/src/window/userguide.py @@ -1,11 +1,13 @@ """ Filename: userguide.py -Description: User guide for FLARES +Description: User guide window +Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ +# External library imports from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel from PySide6.QtCore import Qt diff --git a/src/window/welcome.py b/src/window/welcome.py index f3d28ac..99eb390 100644 --- a/src/window/welcome.py +++ b/src/window/welcome.py @@ -1,18 +1,19 @@ """ Filename: welcome.py -Description: Welcome dialog for FLARES +Description: Welcome dialog window Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ +# External library imports from PySide6.QtWidgets import QTextBrowser, QVBoxLayout, QLabel, QDialog, QHBoxLayout, QPushButton from PySide6.QtGui import QDesktopServices, QIcon from PySide6.QtCore import QUrl from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply -from src.shared.shareddata import APP_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path +from src.shared.shareddata import APP_NAME, PLATFORM_NAME, CURRENT_VERSION, CHANGELOG_URL, resource_path class WelcomeDialog(QDialog): @@ -27,8 +28,8 @@ class WelcomeDialog(QDialog): header_layout = QHBoxLayout() logo_label = QLabel(self) - # NOTE: might not work on mac and need the icns file - logo_label.setPixmap(QIcon(resource_path("icons/main.ico")).pixmap(48, 48)) + icon_ext = "icns" if PLATFORM_NAME == "darwin" else "ico" + logo_label.setPixmap(QIcon(resource_path(f"icons/main.{icon_ext}")).pixmap(48, 48)) if first: title_label = QLabel(f"

Welcome to {APP_NAME.upper()}!

", self) elif direct: diff --git a/startup_args.py b/startup_args.py new file mode 100644 index 0000000..22a694f --- /dev/null +++ b/startup_args.py @@ -0,0 +1,41 @@ +""" +Filename: startup_args.py +Description: Parses the startup arguments for the application +Note: Compliant with pylance strict type checking + +Author: Tyler de Zeeuw +License: GPL-3.0 +""" + +import os +from dataclasses import dataclass + + +@dataclass +class StartupArgs: + finish_update: bool + initial_file: str | None + + +def parse_startup_args(argv: list[str]) -> StartupArgs: + """ + Parses the command-line flags. + + Recognizes: + --finish-update set by the updater when relaunching post-update + a project file to open on startup — the first + argument that doesn't start with '-', found + anywhere in argv rather than assumed to be at + a fixed position, so it survives regardless of + whether --finish-update precedes it. + """ + + finish_update = "--finish-update" in argv[1:] + + initial_file = None + for arg in argv[1:]: + if not arg.startswith("-"): + initial_file = os.path.abspath(arg) + break + + return StartupArgs(finish_update=finish_update, initial_file=initial_file) \ No newline at end of file diff --git a/updater.py b/updater.py index 9c5819d..b021476 100644 --- a/updater.py +++ b/updater.py @@ -416,12 +416,12 @@ def wait_for_process_to_exit(process_name, timeout=10): return False -def finish_update_if_needed(platform_name, app_name, cfg_path): +def finish_update_if_needed(platform_name, app_name, cfg_path, finish_update): """ Completes a pending application update if '--finish-update' is present in the command-line arguments. """ - if "--finish-update" in sys.argv: + if finish_update: print("Finishing update...") update_cfg = configparser.ConfigParser()