typos, standardization, and file association for project extensions
This commit is contained in:
@@ -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).
|
||||
|
||||
[](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:
|
||||
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||
<mime-type type="{mime_type}">
|
||||
<comment>{app_name} File</comment>
|
||||
<glob pattern="*.{clean_ext}"/>
|
||||
</mime-type>
|
||||
</mime-info>"""
|
||||
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)}"
|
||||
@@ -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,6 +604,8 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -2367,7 +2369,6 @@ class MainApplication(QMainWindow):
|
||||
|
||||
|
||||
|
||||
|
||||
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!
|
||||
# Not 2600 lines yay!
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,6 @@ PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
class InterGroupFunctionalConnectivityWidget(InterGroupUIMixin, FlaresBaseWidget):
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ class ParticipantImageViewerWidget(FlaresBaseWidget):
|
||||
self.showMaximized()
|
||||
|
||||
|
||||
|
||||
def show_selected_images(self):
|
||||
# Clear previous images
|
||||
while self.grid_layout.count():
|
||||
|
||||
@@ -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.
|
||||
|
||||
+63
-1
@@ -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
|
||||
@@ -102,3 +134,33 @@ class TerminalWindow(QWidget):
|
||||
|
||||
self.updater.manual_check_for_updates()
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"<h2>Welcome to {APP_NAME.upper()}!</h2>", self)
|
||||
elif direct:
|
||||
|
||||
@@ -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
|
||||
<path> 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)
|
||||
+2
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user