typos, standardization, and file association for project extensions

This commit is contained in:
2026-08-06 02:00:36 -07:00
parent 68e34484ee
commit 074a0681b9
15 changed files with 539 additions and 38 deletions
+2 -2
View File
@@ -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:
+4 -2
View File
@@ -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)
+393
View File
@@ -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)}"
+20 -15
View File
@@ -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!
+1 -2
View File
@@ -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,
-1
View File
@@ -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:
-1
View File
@@ -85,7 +85,6 @@ class ParticipantImageViewerWidget(FlaresBaseWidget):
self.showMaximized()
def show_selected_images(self):
# Clear previous images
while self.grid_layout.count():
+2
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
+5 -4
View File
@@ -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:
+41
View File
@@ -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
View File
@@ -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()