Files
flares/main.py
2025-08-19 09:13:22 -07:00

1725 lines
65 KiB
Python

"""
Filename: main.py
Description: FLARES main executable
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import os
import re
import sys
import time
import shlex
import pickle
import shutil
import zipfile
import platform
import traceback
import subprocess
from pathlib import Path
from datetime import datetime
from multiprocessing import Process, current_process, freeze_support, Manager
# External library imports
import psutil
import requests
from PySide6.QtWidgets import (
QApplication, QWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QTextEdit, QScrollArea, QComboBox, QGridLayout,
QPushButton, QMainWindow, QFileDialog, QLabel, QLineEdit, QFrame, QSizePolicy
)
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtGui import QAction, QKeySequence, QIcon, QIntValidator, QDoubleValidator
CURRENT_VERSION = "1.0.0"
API_URL = "https://git.research.dezeeuw.ca/api/v1/repos/tyler/flares/releases"
PLATFORM_NAME = platform.system().lower()
# Selectable parameters on the right side of the window
SECTIONS = [
{
"title": "Preprocessing",
"params": [
{"name": "SECONDS_TO_STRIP", "default": 0, "type": int, "help": "Seconds to remove from beginning of file. Setting this to 0 will remove nothing from the file."},
{"name": "DOWNSAMPLE", "default": True, "type": bool, "help": "Downsample snirf files."},
{"name": "DOWNSAMPLE_FREQUENCY", "default": 25, "type": int, "help": "Frequency (Hz) to downsample to. If this is set higher than the input data, new data will be interpolated."},
{"name": "FORCE_DROP_CHANNELS", "default": "", "type": str, "help": "Channels to forcibly drop (comma separated)."},
{"name": "SOURCE_DETECTOR_SEPARATOR", "default": "_", "type": str, "help": "Separator between source and detector."},
]
},
{
"title": "Update Optode Positions",
"params": [
{"name": "OPTODE_FILE", "default": True, "type": bool, "help": "Use optode file to update positions."},
{"name": "OPTODE_FILE_PATH", "default": "", "type": str, "help": "Optode file location."},
{"name": "OPTODE_FILE_SEPARATOR", "default": ":", "type": str, "help": "Separator in optode file."},
]
},
{
"title": "Temporal Derivative Distribution Repair filtering",
"params": [
{"name": "TDDR", "default": True, "type": bool, "help": "Apply TDDR filtering."},
]
},
{
"title": "Wavelet filtering",
"params": [
{"name": "WAVELET", "default": True, "type": bool, "help": "Apply Wavelet filtering."},
{"name": "IQR", "default": 1.5, "type": float, "help": "Interquartile Range for Wavelet filter."},
]
},
{
"title": "Heart rate",
"params": [
{"name": "HEART_RATE", "default": True, "type": bool, "help": "Calculate heart rate."},
{"name": "SECONDS_TO_STRIP_HR", "default": 5, "type": int, "help": "Seconds to strip for HR calculation."},
{"name": "MAX_LOW_HR", "default": 40, "type": int, "help": "Minimum heart rate value."},
{"name": "MAX_HIGH_HR", "default": 200, "type": int, "help": "Maximum heart rate value."},
{"name": "SMOOTHING_WINDOW_HR", "default": 100, "type": int, "help": "Rolling average window for HR."},
{"name": "HEART_RATE_WINDOW", "default": 25, "type": int, "help": "Range of BPM around average."},
{"name": "SHORT_CHANNEL", "default": True, "type": bool, "help": "Indicates if data has short channel."},
{"name": "SHORT_CHANNEL_THRESH", "default": 0.013, "type": float, "help": "Threshold for short channel (m)."},
]
},
{
"title": "Scalp Coupling Index / Peak Spectral Power / Signal to Noise Ratio",
"params": [
{"name": "SCI", "default": True, "type": bool, "help": "Calculate Scalp Coupling Index."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "help": "SCI time window."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "help": "SCI threshold (0-1)."},
{"name": "PSP", "default": True, "type": bool, "help": "Calculate Peak Spectral Power."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "help": "PSP time window."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "help": "PSP threshold."},
{"name": "SNR", "default": True, "type": bool, "help": "Calculate Signal to Noise Ratio."},
{"name": "SNR_TIME_WINDOW", "default": -1, "type": int, "help": "SNR time window."},
{"name": "SNR_THRESHOLD", "default": 2.0, "type": float, "help": "SNR threshold (dB)."},
]
},
{
"title": "Drop bad channels",
"params": [
{"name": "EXCLUDE_CHANNELS", "default": True, "type": bool, "help": "Drop channels failing metrics."},
{"name": "MAX_BAD_CHANNELS", "default": 15, "type": int, "help": "Max bad channels allowed."},
{"name": "LONG_CHANNEL_THRESH", "default": 0.045, "type": float, "help": "Max distance (m) for channel."},
]
},
{
"title": "Optical Density",
"params": [
# Intentionally empty (TODO)
]
},
{
"title": "Haemoglobin Concentration",
"params": [
{"name": "PPF", "default": 0.1, "type": float, "help": "Partial Pathlength Factor."},
]
},
{
"title": "Design Matrix",
"params": [
{"name": "DRIFT_MODEL", "default": "cosine", "type": str, "help": "Drift model for GLM."},
{"name": "DURATION_BETWEEN_ACTIVITIES", "default": 35, "type": int, "help": "Time between activities (s)."},
{"name": "SHORT_CHANNEL_REGRESSION", "default": True, "type": bool, "help": "Use short channel regression."},
]
},
{
"title": "General Linear Model",
"params": [
{"name": "N_JOBS", "default": 1, "type": int, "help": "Number of jobs for processing."},
]
},
{
"title": "Finishing Touches",
"params": [
# Intentionally empty (TODO)
]
},
]
class UpdateDownloadThread(QThread):
"""
Thread that downloads and extracts an update package and emits a signal on completion or error.
Args:
download_url (str): URL of the update zip file to download.
latest_version (str): Version string of the latest update.
"""
update_ready = Signal(str, str)
error_occurred = Signal(str)
def __init__(self, download_url, latest_version):
super().__init__()
self.download_url = download_url
self.latest_version = latest_version
def run(self):
try:
local_filename = os.path.basename(self.download_url)
if PLATFORM_NAME == 'darwin':
tmp_dir = '/tmp/flarestempupdate'
os.makedirs(tmp_dir, exist_ok=True)
local_path = os.path.join(tmp_dir, local_filename)
else:
local_path = os.path.join(os.getcwd(), local_filename)
# Download the file
with requests.get(self.download_url, stream=True, timeout=15) as r:
r.raise_for_status()
with open(local_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
# Extract folder name (remove .zip)
if PLATFORM_NAME == 'darwin':
extract_folder = os.path.splitext(local_filename)[0]
extract_path = os.path.join(tmp_dir, extract_folder)
else:
extract_folder = os.path.splitext(local_filename)[0]
extract_path = os.path.join(os.getcwd(), extract_folder)
# Create the folder if not exists
os.makedirs(extract_path, exist_ok=True)
# Extract the zip file contents
if PLATFORM_NAME == 'darwin':
subprocess.run(['ditto', '-xk', local_path, extract_path], check=True)
else:
with zipfile.ZipFile(local_path, 'r') as zip_ref:
zip_ref.extractall(extract_path)
# Remove the zip once extracted and emit a signal
os.remove(local_path)
self.update_ready.emit(self.latest_version, extract_path)
except Exception as e:
# Emit a signal signifying failure
self.error_occurred.emit(str(e))
class UpdateCheckThread(QThread):
"""
Thread that checks for updates by querying the API and emits a signal based on the result.
Signals:
download_requested(str, str): Emitted with (download_url, latest_version) when an update is available.
no_update_available(): Emitted when no update is found or current version is up to date.
error_occurred(str): Emitted with an error message if the update check fails.
"""
download_requested = Signal(str, str)
no_update_available = Signal()
error_occurred = Signal(str)
def run(self):
try:
latest_version, download_url = self.get_latest_release_for_platform()
if not latest_version:
self.no_update_available.emit()
return
if not download_url:
self.error_occurred.emit(f"No download available for platform '{PLATFORM_NAME}'")
return
if self.version_compare(latest_version, CURRENT_VERSION) > 0:
self.download_requested.emit(download_url, latest_version)
else:
self.no_update_available.emit()
except Exception as e:
self.error_occurred.emit(f"Update check failed: {e}")
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def get_latest_release_for_platform(self):
response = requests.get(API_URL, timeout=5)
response.raise_for_status()
releases = response.json()
if not releases:
return None, None
latest = releases[0]
tag = latest["tag_name"].lstrip("v")
for asset in latest.get("assets", []):
if PLATFORM_NAME in asset["name"].lower():
return tag, asset["browser_download_url"]
return tag, None
class LocalPendingUpdateCheckThread(QThread):
"""
Thread that checks for locally pending updates by scanning the download directory and emits a signal accordingly.
Args:
current_version (str): Current application version.
platform_suffix (str): Platform-specific suffix to identify update folders.
"""
pending_update_found = Signal(str, str)
no_pending_update = Signal()
def __init__(self, current_version, platform_suffix):
super().__init__()
self.current_version = current_version
self.platform_suffix = platform_suffix
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def run(self):
if PLATFORM_NAME == 'darwin':
cwd = '/tmp/flarestempupdate'
else:
cwd = os.getcwd()
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
found = False
try:
for item in os.listdir(cwd):
folder_path = os.path.join(cwd, item)
if os.path.isdir(folder_path) and item.endswith(self.platform_suffix):
match = pattern.match(item)
if match:
folder_version = match.group(1)
if self.version_compare(folder_version, self.current_version) > 0:
self.pending_update_found.emit(folder_version, folder_path)
found = True
break
except:
pass
if not found:
self.no_pending_update.emit()
class AboutWindow(QWidget):
"""
Simple About window displaying basic application information.
Args:
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("About FLARES")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel("About FLARES", self)
label2 = QLabel("fNIRS Lightweight Analysis, Research, & Evaluation Suite", self)
label3 = QLabel("FLARES is licensed under the GPL-3.0 licence. For more information, visit https://www.gnu.org/licenses/gpl-3.0.en.html", self)
label4 = QLabel(f"Version v{CURRENT_VERSION}")
layout.addWidget(label)
layout.addWidget(label2)
layout.addWidget(label3)
layout.addWidget(label4)
self.setLayout(layout)
class UserGuideWindow(QWidget):
"""
Simple User Guide window displaying basic information on how to use the software.
Args:
parent (QWidget, optional): Parent widget of this window. Defaults to None.
"""
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("User Guide for FLARES")
self.resize(250, 100)
layout = QVBoxLayout()
label = QLabel("No user guide available yet!", self)
layout.addWidget(label)
self.setLayout(layout)
class ProgressBubble(QWidget):
"""
A clickable widget displaying a progress bar made of colored rectangles and a label.
Args:
display_name (str): Text to display above the progress bar.
file_path (str): Associated file path stored with the bubble.
"""
clicked = Signal(object)
def __init__(self, display_name, file_path):
super().__init__()
self.layout = QVBoxLayout()
self.label = QLabel(display_name)
self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label.setStyleSheet("""
QLabel {
border: 1px solid #888;
border-radius: 10px;
padding: 8px 12px;
background-color: #e0f0ff;
}
""")
self.progress_layout = QHBoxLayout()
self.rects = []
for _ in range(12):
rect = QFrame()
rect.setFixedSize(10, 20)
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
self.progress_layout.addWidget(rect)
self.rects.append(rect)
self.layout.addWidget(self.label)
self.layout.addLayout(self.progress_layout)
self.setLayout(self.layout)
# Store the file path
self.file_path = file_path
self.current_step = 0
# Make the bubble clickable
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Resize policy to make bubbles responsive
# TODO: Not only do this once but when window is resized too
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
def update_progress(self, step_index):
self.current_step = step_index
for i, rect in enumerate(self.rects):
if i < step_index:
rect.setStyleSheet("background-color: green; border: 1px solid gray;")
elif i == step_index:
rect.setStyleSheet("background-color: yellow; border: 1px solid gray;")
else:
rect.setStyleSheet("background-color: white; border: 1px solid gray;")
def mousePressEvent(self, event):
self.clicked.emit(self)
super().mousePressEvent(event)
class ParamSection(QWidget):
"""
A widget section that dynamically creates labeled input fields from parameter metadata.
Args:
section_data (dict): Dictionary containing section title and list of parameter info.
Expected format:
{
"title": str,
"params": [
{
"name": str,
"type": type,
"default": any,
"help": str (optional)
},
...
]
}
"""
def __init__(self, section_data):
super().__init__()
layout = QVBoxLayout()
self.setLayout(layout)
self.widgets = {}
self.selected_path = None
# Title label
title_label = QLabel(section_data["title"])
title_label.setStyleSheet("font-weight: bold; font-size: 14px; margin-top: 10px; margin-bottom: 5px;")
layout.addWidget(title_label)
# Horizontal line
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
for param in section_data["params"]:
h_layout = QHBoxLayout()
label = QLabel(param["name"])
label.setFixedWidth(180)
label.setToolTip(param.get("help", ""))
help_text = param.get("help", "")
help_btn = QPushButton("?")
help_btn.setFixedWidth(25)
help_btn.setToolTip(help_text)
help_btn.clicked.connect(lambda _, text=help_text: self.show_help_popup(text))
h_layout.addWidget(help_btn)
h_layout.setStretch(0, 1) # Set stretch factor for button (10%)
h_layout.addWidget(label)
h_layout.setStretch(1, 3) # Set the stretch factor for label (40%)
# Create input widget based on type
if param["type"] == bool:
widget = QComboBox()
widget.addItems(["True", "False"])
widget.setCurrentText(str(param["default"]))
elif param["type"] == int:
widget = QLineEdit()
widget.setValidator(QIntValidator())
widget.setText(str(param["default"]))
elif param["type"] == float:
widget = QLineEdit()
widget.setValidator(QDoubleValidator())
widget.setText(str(param["default"]))
else: # str or list, treat as text for now
widget = QLineEdit()
widget.setText(str(param["default"]))
widget.setToolTip(help_text)
h_layout.addWidget(widget)
h_layout.setStretch(2, 5) # Set stretch factor for input field (50%)
layout.addLayout(h_layout)
self.widgets[param["name"]] = widget
def show_help_popup(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Parameter Info")
msg.setText(text)
msg.exec()
class ViewerWindow(QWidget):
"""
Window displaying various fNIRS data visualization and analysis options via buttons.
Args:
all_results (dict): Analysis results data.
all_haemo (dict): Haemodynamic data per subject.
all_figures (dict): Figures generated from the data.
config_snapshot (dict): Configuration snapshot used for analysis.
parent (QWidget, optional): Parent widget. Defaults to None.
"""
def __init__(self, all_results, all_haemo, all_figures, config_snapshot, parent=None):
try:
super().__init__(parent, Qt.WindowType.Window)
self.all_results = all_results
self.all_haemo = all_haemo
self.all_figures = all_figures
self.config_snapshot = config_snapshot
if not self.all_haemo:
QMessageBox.critical(self, "Data Error", "No haemodynamic data available!")
return
subjects_dir = resource_path("mne_data/MNE-sample-data/subjects")
os.environ["SUBJECTS_DIR"] = subjects_dir
# TODO: Thread all of this to not freeze main window
import fNIRS_module
fNIRS_module.set_config(self.config_snapshot, True) # Set globals in this process
layout = QVBoxLayout()
button_actions = [
("Show all Images", lambda: fNIRS_module.show_all_images(self.all_figures)),
("save_all_images", lambda: fNIRS_module.save_all_images(self.all_figures)),
("data_to_csv", lambda: fNIRS_module.data_to_csv(self.all_results)),
("plot_2d_theta_graph", lambda: fNIRS_module.plot_2d_theta_graph(self.all_results)),
("verify_channel_positions", lambda: fNIRS_module.verify_channel_positions(self.all_haemo[list(self.all_haemo.keys())[0]]["full_layout"])),
("brain_landmarks_3d", lambda: fNIRS_module.brain_landmarks_3d(self.all_haemo[list(self.all_haemo.keys())[0]]["full_layout"], 'all')),
("plot_2d_3d_contrasts_between_groups", lambda: fNIRS_module.plot_2d_3d_contrasts_between_groups(self.all_results, self.all_haemo, 'theta', 'all', True)),
("brain_3d_visualization", lambda: fNIRS_module.brain_3d_visualization(self.all_results, self.all_haemo, 0, 't', 'all', True)),
("plot_fir_model_results", lambda: fNIRS_module.plot_fir_model_results(self.all_results, self.all_haemo, 0, 'theta')),
("plot_individual_theta_averages", lambda: fNIRS_module.plot_individual_theta_averages(self.all_results)),
("plot_group_theta_averages", lambda: fNIRS_module.plot_group_theta_averages(self.all_results)),
("plot_avg_significant_activity", lambda: fNIRS_module.plot_avg_significant_activity(self.all_haemo[list(self.all_haemo.keys())[0]]["full_layout"], self.all_results, 'theta')),
("fold_channels", lambda: fNIRS_module.fold_channels(self.all_haemo[list(self.all_haemo.keys())[0]]["full_layout"].copy(), self.all_results, resource_path("mne_data/fOLD/fOLD-public-master/Supplementary"))),
]
for text, func in button_actions:
btn = QPushButton(text)
btn.clicked.connect(self.make_safe_callback(func))
layout.addWidget(btn)
self.setLayout(layout)
except Exception as e:
QMessageBox.critical(None, "Startup Error", f"ViewerWindow failed:\n{str(e)}")
def make_safe_callback(self, func):
def safe_func():
try:
func()
except Exception as e:
QMessageBox.critical(self, "Error", f"An error occurred:\n{str(e)}")
return safe_func
class MainApplication(QMainWindow):
"""
Main application window that creates and sets up the UI.
"""
progress_update_signal = Signal(str, int)
def __init__(self):
super().__init__()
self.setWindowTitle("FLARES")
self.setGeometry(100, 100, 1280, 720)
self.about = None
self.help = None
self.bubble_widgets = {}
self.param_sections = []
self.folder_paths = []
self.init_ui()
self.create_menu_bar()
self.platform_suffix = "-" + PLATFORM_NAME
self.pending_update_version = None
self.pending_update_path = None
# Start local pending update check thread
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix)
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
self.local_check_thread.start()
def init_ui(self):
# Central widget and main horizontal layout
central = QWidget()
self.setCentralWidget(central)
main_layout = QHBoxLayout()
central.setLayout(main_layout)
# Left container with vertical layout: top left + bottom left
left_container = QWidget()
left_layout = QVBoxLayout()
left_container.setLayout(left_layout)
left_container.setMinimumWidth(300)
# Top left widget (for demo, use QTextEdit)
self.top_left_widget = QTextEdit()
self.top_left_widget.setReadOnly(True)
self.top_left_widget.setPlaceholderText("Click a file below to get started! No files below? Open one using File > Open!")
self.top_left_widget.setFixedHeight(250)
# Bottom left: the bubbles inside a scroll area
self.bubble_container = QWidget()
self.bubble_layout = QGridLayout()
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.bubble_container.setLayout(self.bubble_layout)
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(self.bubble_container)
self.scroll_area.setMinimumHeight(300)
# Add top left and bottom left to left layout
left_layout.addWidget(self.top_left_widget)
left_layout.addWidget(self.scroll_area)
self.progress_update_signal.connect(self.update_file_progress)
# Right widget (full height on right side) — example QTextEdit
self.right_container = QWidget()
right_container_layout = QVBoxLayout()
self.right_container.setLayout(right_container_layout)
# Content widget inside scroll area
self.right_content_widget = QWidget()
right_content_layout = QVBoxLayout()
self.right_content_widget.setLayout(right_content_layout)
# Option selector dropdown
self.option_selector = QComboBox()
self.option_selector.addItems(["FIR"])
right_content_layout.addWidget(self.option_selector)
# Container for the sections
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout()
self.rows_layout.setSpacing(10)
self.rows_container.setLayout(self.rows_layout)
right_content_layout.addWidget(self.rows_container)
# Spacer at bottom inside scroll area content to push content up
right_content_layout.addStretch()
# Scroll area for the right side content
self.right_scroll_area = QScrollArea()
self.right_scroll_area.setWidgetResizable(True)
self.right_scroll_area.setWidget(self.right_content_widget)
# Buttons widget (fixed below the scroll area)
buttons_widget = QWidget()
buttons_layout = QHBoxLayout()
buttons_widget.setLayout(buttons_layout)
buttons_layout.addStretch()
self.button1 = QPushButton("Process")
self.button2 = QPushButton("Clear")
self.button3 = QPushButton("Analysis")
buttons_layout.addWidget(self.button1)
buttons_layout.addWidget(self.button2)
buttons_layout.addWidget(self.button3)
self.button1.setMinimumSize(100, 40)
self.button2.setMinimumSize(100, 40)
self.button3.setMinimumSize(100, 40)
self.button3.setVisible(False)
self.button1.clicked.connect(self.on_run_task)
self.button2.clicked.connect(self.clear_all)
self.button3.clicked.connect(self.open_viewer_window)
# Add scroll area and buttons widget to right container layout
right_container_layout.addWidget(self.right_scroll_area)
right_container_layout.addWidget(buttons_widget)
# Add left and right containers to main layout
main_layout.addWidget(left_container, stretch=55)
main_layout.addWidget(self.right_container, stretch=45)
# Set size policy to expand
self.right_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.right_scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# Store ParamSection widgets
self.option_selector.currentIndexChanged.connect(self.update_sections)
# Initial build
self.update_sections(0)
def create_menu_bar(self):
'''Menu Bar at the top of the screen'''
menu_bar = self.menuBar()
def make_action(name, shortcut=None, slot=None, checkable=False, checked=False, icon=None):
action = QAction(name, self)
if shortcut:
action.setShortcut(QKeySequence(shortcut))
if slot:
action.triggered.connect(slot)
if checkable:
action.setCheckable(True)
action.setChecked(checked)
if icon:
action.setIcon(QIcon(icon))
return action
# File menu and actions
file_menu = menu_bar.addMenu("File")
file_actions = [
("Open File...", "Ctrl+O", self.open_file_dialog, resource_path("icons/file_open_24dp_1F1F1F.svg")),
("Open Folder...", "Ctrl+Alt+O", self.open_folder_dialog, resource_path("icons/folder_24dp_1F1F1F.svg")),
("Open Folders...", "Ctrl+Shift+O", self.open_multiple_folders_dialog, resource_path("icons/folder_copy_24dp_1F1F1F.svg")),
("Load Project...", "Ctrl+L", self.load_project, resource_path("icons/article_24dp_1F1F1F.svg")),
("Save Project...", "Ctrl+S", self.save_project, resource_path("icons/save_24dp_1F1F1F.svg")),
("Save Project As...", "Ctrl+Shift+S", self.save_project, resource_path("icons/save_as_24dp_1F1F1F.svg")), # Maybe connect a separate method if different
]
for i, (name, shortcut, slot, icon) in enumerate(file_actions):
file_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 2: # after the first 3 actions (0,1,2)
file_menu.addSeparator()
file_menu.addSeparator()
file_menu.addAction(make_action("Exit", "Ctrl+Q", QApplication.instance().quit, icon=resource_path("icons/exit_to_app_24dp_1F1F1F.svg")))
# Edit menu
edit_menu = menu_bar.addMenu("Edit")
edit_actions = [
("Cut", "Ctrl+X", self.cut_text, resource_path("icons/content_cut_24dp_1F1F1F.svg")),
("Copy", "Ctrl+C", self.copy_text, resource_path("icons/content_copy_24dp_1F1F1F.svg")),
("Paste", "Ctrl+V", self.paste_text, resource_path("icons/content_paste_24dp_1F1F1F.svg"))
]
for name, shortcut, slot, icon in edit_actions:
edit_menu.addAction(make_action(name, shortcut, slot, icon=icon))
# View menu
view_menu = menu_bar.addMenu("View")
toggle_statusbar_action = make_action("Toggle Status Bar", checkable=True, checked=True, slot=None)
view_menu.addAction(toggle_statusbar_action)
# Options menu (Help & About)
options_menu = menu_bar.addMenu("Options")
options_actions = [
("User Guide", "F1", self.user_guide, resource_path("icons/help_24dp_1F1F1F.svg")),
("Check for Updates", "F5", self.manual_check_for_updates, resource_path("icons/update_24dp_1F1F1F.svg")),
("About", "F12", self.about_window, resource_path("icons/info_24dp_1F1F1F.svg"))
]
for i, (name, shortcut, slot, icon) in enumerate(options_actions):
options_menu.addAction(make_action(name, shortcut, slot, icon=icon))
if i == 1: # after the first 2 actions (0,1)
options_menu.addSeparator()
# Optional: status bar
self.statusbar = self.statusBar()
self.statusbar.showMessage("Ready")
def update_sections(self, index):
# Clear previous sections
for i in reversed(range(self.rows_layout.count())):
widget = self.rows_layout.itemAt(i).widget()
if widget is not None:
widget.deleteLater()
self.param_sections.clear()
# Add ParamSection widgets from SECTIONS
for section in SECTIONS:
section_widget = ParamSection(section)
self.rows_layout.addWidget(section_widget)
self.param_sections.append(section_widget)
def clear_all(self):
# Clear the bubble layout
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
# Clear file data
self.bubble_widgets.clear()
self.statusBar().clearMessage()
# Reset any other data variables
self.collected_data_snapshot = None
self.all_results = None
self.all_haemo = None
self.all_figures = None
# Reset any visible UI elements
self.button3.setVisible(False)
self.top_left_widget.clear()
def open_viewer_window(self):
if not hasattr(self, "all_figures") or self.all_figures is None:
QMessageBox.warning(self, "No Data", "No figures to show yet!")
return
self.viewer_window = ViewerWindow(self.all_results, self.all_haemo, self.all_figures, self.collected_data_snapshot, self)
self.viewer_window.show()
def copy_text(self):
self.top_left_widget.copy() # Trigger copy
self.statusbar.showMessage("Copied to clipboard") # Show status message
def cut_text(self):
self.top_left_widget.cut() # Trigger cut
self.statusbar.showMessage("Cut to clipboard") # Show status message
def paste_text(self):
self.top_left_widget.paste() # Trigger paste
self.statusbar.showMessage("Pasted from clipboard") # Show status message
def about_window(self):
if self.about is None or not self.about.isVisible():
self.about = AboutWindow(self)
self.about.show()
def user_guide(self):
if self.help is None or not self.help.isVisible():
self.help = UserGuideWindow(self)
self.help.show()
def open_file_dialog(self):
file_path, _ = QFileDialog.getOpenFileName(
self, "Open File", "", "All Files (*);;Text Files (*.txt)"
)
if file_path:
self.selected_path = file_path # store the file path
self.show_files_as_bubbles(file_path)
def open_folder_dialog(self):
folder_path = QFileDialog.getExistingDirectory(
self, "Select Folder", ""
)
if folder_path:
self.selected_path = folder_path # store the folder path
self.show_files_as_bubbles(folder_path)
def open_multiple_folders_dialog(self):
while True:
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
if not folder:
break
if not hasattr(self, 'selected_paths'):
self.selected_paths = []
if folder not in self.selected_paths:
self.selected_paths.append(folder)
self.show_files_as_bubbles(self.selected_paths)
# Ask if the user wants to add another
more = QMessageBox.question(
self,
"Add Another?",
"Do you want to select another folder?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if more == QMessageBox.StandardButton.No:
break
def save_project(self):
filename, _ = QFileDialog.getSaveFileName(
self, "Save Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
try:
project_data = {
"file_list": [bubble.file_path for bubble in self.bubble_widgets.values()],
"progress_states": {
bubble.file_path: bubble.current_step for bubble in self.bubble_widgets.values()
},
"all_results": self.all_results,
"all_haemo": self.all_haemo,
"all_figures": self.all_figures,
"config_snapshot": self.collected_data_snapshot,
}
with open(filename, "wb") as f:
pickle.dump(project_data, f)
QMessageBox.information(self, "Success", f"Project saved to:\n{filename}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save project:\n{e}")
def load_project(self):
filename, _ = QFileDialog.getOpenFileName(
self, "Load Project", "", "FLARE Project (*.flare)"
)
if not filename:
return
try:
with open(filename, "rb") as f:
data = pickle.load(f)
self.collected_data_snapshot = data["config_snapshot"]
self.all_results = data["all_results"]
self.all_haemo = data["all_haemo"]
self.all_figures = data["all_figures"]
for section_widget in self.param_sections:
for name, widget in section_widget.widgets.items():
if name not in self.collected_data_snapshot:
continue
value = self.collected_data_snapshot[name]
if isinstance(widget, QComboBox):
widget.setCurrentText("True" if value else "False")
elif isinstance(widget, QLineEdit):
validator = widget.validator()
if isinstance(validator, QIntValidator):
widget.setText(str(int(value)))
elif isinstance(validator, QDoubleValidator):
widget.setText(str(float(value)))
else:
widget.setText(str(value))
self.show_files_as_bubbles_from_list(data["file_list"], data.get("progress_states", {}), filename)
# Re-enable the "Viewer" button
self.button3.setVisible(True)
QMessageBox.information(self, "Loaded", f"Project loaded from:\n{filename}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load project:\n{e}")
def show_files_as_bubbles(self, folder_paths):
if isinstance(folder_paths, str):
folder_paths = [folder_paths]
# Clear previous bubbles
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement
temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy. Preferred)
# temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improve rendering?
temp_bubble.adjustSize() # Adjust size after the widget is created
bubble_width = temp_bubble.width() # Get the actual width of a bubble
available_width = self.bubble_container.width()
cols = max(1, available_width // bubble_width) # Ensure at least 1 column
index = 0
for folder_path in folder_paths:
if not os.path.isdir(folder_path):
continue
files = os.listdir(folder_path)
files = [f for f in files if os.path.isfile(os.path.join(folder_path, f))]
for filename in files:
full_path = os.path.join(folder_path, filename)
display_name = f"{os.path.basename(folder_path)} / {filename}"
bubble = ProgressBubble(display_name, full_path)
bubble.clicked.connect(self.on_bubble_clicked)
self.bubble_widgets[filename] = bubble
row = index // cols
col = index % cols
self.bubble_layout.addWidget(bubble, row, col)
index += 1
self.statusBar().showMessage(f"{index} file(s) loaded from: {', '.join(folder_paths)}")
def show_files_as_bubbles_from_list(self, file_list, progress_states=None, filenames=None):
progress_states = progress_states or {}
# Clear old
while self.bubble_layout.count():
item = self.bubble_layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self.bubble_widgets = {}
temp_bubble = ProgressBubble("Test Bubble", "") # A dummy bubble for measurement
temp_bubble.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
# temp_bubble.setAttribute(Qt.WA_OpaquePaintEvent) # Improves rendering?
temp_bubble.adjustSize() # Adjust size after the widget is created
bubble_width = temp_bubble.width() # Get the actual width of a bubble
available_width = self.bubble_container.width()
cols = max(1, available_width // bubble_width) # Ensure at least 1 column
index = 0
for index, file_path in enumerate(file_list):
filename = os.path.basename(file_path)
display_name = f"{os.path.basename(os.path.dirname(file_path))} / {filename}"
# Create bubble with full path
bubble = ProgressBubble(display_name, file_path)
bubble.clicked.connect(self.on_bubble_clicked)
self.bubble_widgets[file_path] = bubble
step = progress_states.get(file_path, 0)
bubble.update_progress(step)
row = index // cols
col = index % cols
self.bubble_layout.addWidget(bubble, row, col)
self.statusBar().showMessage(f"{len(file_list)} files loaded from from {os.path.abspath(filenames)}.")
def on_bubble_clicked(self, bubble):
file_path = bubble.file_path
if not os.path.exists(file_path):
self.top_left_widget.setText("File not found.")
return
size = os.path.getsize(file_path)
created = time.ctime(os.path.getctime(file_path))
modified = time.ctime(os.path.getmtime(file_path))
# snirf_info = self.get_snirf_metadata_mne(file_path)
info = f"""\
File: {os.path.basename(file_path)}
Size: {size:,} bytes
Created: {created}
Modified: {modified}
Full Path: {file_path}
"""
# if "Error" in snirf_info:
# info += f"\nSNIRF Metadata could not be loaded: {snirf_info['Error']}"
# else:
# info += "\nSNIRF Metadata:\n"
# for k, v in snirf_info.items():
# if isinstance(v, list):
# info += f" {k}:\n"
# for item in v:
# info += f" - {item}\n"
# else:
# info += f" {k}: {v}\n"
self.top_left_widget.setText(info)
def placeholder(self):
QMessageBox.information(self, "Placeholder", "This feature is not implemented yet.")
'''MODULE FILE'''
def on_run_task(self):
collected_data = {}
# Add all parameter key-value pairs
for section_widget in self.param_sections:
for name, widget in section_widget.widgets.items():
if isinstance(widget, QComboBox):
val = widget.currentText() == "True"
elif isinstance(widget, QLineEdit):
text = widget.text()
validator = widget.validator()
if isinstance(validator, QIntValidator):
val = int(text or 0)
elif isinstance(validator, QDoubleValidator):
val = float(text or 0.0)
else:
val = text
else:
val = None
collected_data[name] = val # Flattened!
if hasattr(self, "selected_paths") and self.selected_paths:
# Handle multiple folders
parents = [Path(p).parent for p in self.selected_paths]
base_parents = set(str(p) for p in parents)
if len(base_parents) > 1:
raise ValueError("Selected folders must have the same parent directory")
collected_data["BASE_SNIRF_FOLDER"] = base_parents.pop()
collected_data["SNIRF_SUBFOLDERS"] = [Path(p).name for p in self.selected_paths]
collected_data["STIM_DURATION"] = [0 for _ in self.selected_paths]
elif hasattr(self, "selected_path") and self.selected_path:
# Handle single folder
selected_path = Path(self.selected_path)
collected_data["BASE_SNIRF_FOLDER"] = str(selected_path.parent)
collected_data["SNIRF_SUBFOLDERS"] = [selected_path.name]
collected_data["STIM_DURATION"] = [0]
else:
# No folder selected - handle gracefully or raise error
raise ValueError("No folder(s) selected")
collected_data["HRF_MODEL"] = 'fir'
collected_data["MAX_WORKERS"] = 12
collected_data["FORCE_DROP_CHANNELS"] = []
collected_data["TARGET_ACTIVITY"] = "Reach"
collected_data["TARGET_CONTROL"] = "Start of Rest"
collected_data["ROI_GROUP_1"] = [[1, 1], [1, 2], [2, 1], [2, 4], [3, 1], # Channel pairings for a region of interest.
[2, 2], [4, 3], [4, 4], [5, 5], [6, 4]]
collected_data["ROI_GROUP_2"] = [[6, 5], [6, 8], [7, 7], [7, 8], [8, 5], # Channel pairings for another region of interest.
[8, 6], [9, 6], [9, 7], [10, 7], [10, 8]]
collected_data["ROI_GROUP_1_NAME"] = "Parieto-Ocipital" # Friendly name for the first region of interest group.
collected_data["ROI_GROUP_2_NAME"] = "Fronto-Parietal"
collected_data["P_THRESHOLD"] = 0.05
collected_data["SEE_BAD_IMAGES"] = True
collected_data["ABS_T_VALUE"] = 6
collected_data["ABS_THETA_VALUE"] = 10
collected_data["ABS_CONTRAST_T_VALUE"] = 6
collected_data["ABS_CONTRAST_THETA_VALUE"] = 10
collected_data["ABS_SIGNIFICANCE_T_VALUE"] = 6
collected_data["ABS_SIGNIFICANCE_THETA_VALUE"] = 10
collected_data["BRAIN_DISTANCE"] = 0.02
collected_data["BRAIN_MODE"] = "weighted"
collected_data["EPOCH_REJECT_CRITERIA_THRESH"] = 20e-2
collected_data["TIME_MIN_THRESH"] = -5
collected_data["TIME_MAX_THRESH"] = 15
collected_data["VERBOSITY"] = True
self.collected_data_snapshot = collected_data.copy()
if current_process().name == 'MainProcess':
self.manager = Manager()
self.result_queue = self.manager.Queue()
self.progress_queue = self.manager.Queue()
self.result_process = Process(
target=run_gui_entry_wrapper,
args=(collected_data, self.result_queue, self.progress_queue)
)
self.result_process.daemon = False
self.result_process.start()
print("start was called")
self.statusbar.showMessage("Running processing in background...")
# Poll the queue periodically
self.result_timer = QTimer()
self.result_timer.timeout.connect(self.check_for_pipeline_results)
self.result_timer.start()
self.statusbar.showMessage("Task started in separate process.")
def check_for_pipeline_results(self):
while not self.result_queue.empty():
msg = self.result_queue.get()
if isinstance(msg, dict):
if msg.get("success"):
all_results, all_haemo, all_figures, all_processes, elapsed_time = msg["result"]
self.all_results = all_results
self.all_haemo = all_haemo
self.all_figures = all_figures
self.all_processes = all_processes
self.elapsed_time = elapsed_time
self.statusbar.showMessage(f"Processing complete! Time elapsed: {elapsed_time:.2f} seconds")
self.button3.setVisible(True)
else:
error_msg = msg.get("error", "Unknown error")
print("Error during processing:", error_msg)
self.statusbar.showMessage(f"Processing failed! {error_msg}")
self.result_timer.stop()
self.cleanup_after_process()
return
elif isinstance(msg, tuple) and msg[0] == 'progress':
_, file_name, step_index = msg
self.progress_update_signal.emit(file_name, step_index)
def cleanup_after_process(self):
if hasattr(self, 'result_process'):
self.result_process.join(timeout=0)
if self.result_process.is_alive():
self.result_process.terminate()
self.result_process.join()
if hasattr(self, 'result_queue'):
if 'AutoProxy' in repr(self.result_queue):
pass
else:
self.result_queue.close()
self.result_queue.join_thread()
if hasattr(self, 'progress_queue'):
if 'AutoProxy' in repr(self.progress_queue):
pass
else:
self.progress_queue.close()
self.progress_queue.join_thread()
# Shutdown manager to kill its server process and clean up
if hasattr(self, 'manager'):
self.manager.shutdown()
def update_file_progress(self, filename, step_index):
bubble = self.bubble_widgets.get(filename)
if bubble:
bubble.update_progress(step_index)
'''UPDATER'''
def manual_check_for_updates(self):
self.local_check_thread = LocalPendingUpdateCheckThread(CURRENT_VERSION, self.platform_suffix)
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
self.local_check_thread.start()
def on_pending_update_found(self, version, folder_path):
self.statusBar().showMessage(f"Pending update found: version {version}")
self.pending_update_version = version
self.pending_update_path = folder_path
self.show_pending_update_popup()
def on_no_pending_update(self):
# No pending update found locally, start server check directly
self.statusBar().showMessage("No pending local update found. Checking server...")
self.start_update_check_thread()
def show_pending_update_popup(self):
msg_box = QMessageBox(self)
msg_box.setWindowTitle("Pending Update Found")
msg_box.setText(f"A previously downloaded update (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == install_now_button:
self.install_update(self.pending_update_path)
else:
self.statusBar().showMessage("Pending update available. Install later.")
# After user dismisses, still check the server for new updates
self.start_update_check_thread()
def start_update_check_thread(self):
self.check_thread = UpdateCheckThread()
self.check_thread.download_requested.connect(self.on_server_update_requested)
self.check_thread.no_update_available.connect(self.on_server_no_update)
self.check_thread.error_occurred.connect(self.on_error)
self.check_thread.start()
def on_server_no_update(self):
self.statusBar().showMessage("No new updates found on server.", 5000)
def on_server_update_requested(self, download_url, latest_version):
if self.pending_update_version:
cmp = self.version_compare(latest_version, self.pending_update_version)
if cmp > 0:
# Server version is newer than pending update
self.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
try:
shutil.rmtree(self.pending_update_path)
self.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
except Exception as e:
self.statusBar().showMessage(f"Failed to delete old update folder: {e}")
# Clear pending update info so new download proceeds
self.pending_update_version = None
self.pending_update_path = None
# Download the new update
self.download_update(download_url, latest_version)
elif cmp == 0:
# Versions equal, no download needed
self.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
else:
# Server version older than pending? Unlikely but just keep pending update
self.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
else:
# No pending update, just download
self.download_update(download_url, latest_version)
def download_update(self, download_url, latest_version):
self.statusBar().showMessage("Downloading update...")
self.download_thread = UpdateDownloadThread(download_url, latest_version)
self.download_thread.update_ready.connect(self.on_update_ready)
self.download_thread.error_occurred.connect(self.on_error)
self.download_thread.start()
def on_update_ready(self, latest_version, extract_folder):
self.statusBar().showMessage("Update downloaded and extracted.")
msg_box = QMessageBox(self)
msg_box.setWindowTitle("Update Ready")
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
msg_box.exec()
if msg_box.clickedButton() == install_now_button:
self.install_update(extract_folder)
else:
self.statusBar().showMessage("Update ready. Install later.")
def install_update(self, extract_folder):
# Path to updater executable
if PLATFORM_NAME == 'windows':
updater_path = os.path.join(os.getcwd(), "flares_updater.exe")
elif PLATFORM_NAME == 'darwin':
if getattr(sys, 'frozen', False):
updater_path = os.path.join(os.path.dirname(sys.executable), "../../../flares_updater.app")
else:
updater_path = os.path.join(os.getcwd(), "../flares_updater.app")
elif PLATFORM_NAME == 'linux':
updater_path = os.path.join(os.getcwd(), "flares_updater")
else:
updater_path = os.getcwd()
if not os.path.exists(updater_path):
QMessageBox.critical(self, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
return
# Launch updater with extracted folder path as argument
try:
# Pass current app's executable path for updater to relaunch
main_app_executable = os.path.abspath(sys.argv[0])
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
if PLATFORM_NAME == 'darwin':
subprocess.Popen(['open', updater_path, '--args', extract_folder, main_app_executable])
else:
subprocess.Popen([updater_path, f'{extract_folder}', f'{main_app_executable}'], cwd=os.path.dirname(updater_path))
# Close the current app so updater can replace files
sys.exit(0)
except Exception as e:
QMessageBox.critical(self, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
def on_error(self, message):
# print(f"Error: {message}")
self.statusBar().showMessage(f"Error occurred during update process. {message}")
def version_compare(self, v1, v2):
def normalize(v): return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def closeEvent(self, event):
# Gracefully shut down multiprocessing children
print("Window is closing. Cleaning up...")
if hasattr(self, 'manager'):
self.manager.shutdown()
for child in self.findChildren(QWidget):
if child is not self and child.isVisible():
child.close()
kill_child_processes()
event.accept()
def wait_for_process_to_exit(process_name, timeout=10):
"""
Waits for a process with the specified name to exit within a timeout period.
Args:
process_name (str): Name (or part of the name) of the process to wait for.
timeout (int, optional): Maximum time to wait in seconds. Defaults to 10.
Returns:
bool: True if the process exited before the timeout, False otherwise.
"""
print(f"Waiting for {process_name} to exit...")
deadline = time.time() + timeout
while time.time() < deadline:
still_running = False
for proc in psutil.process_iter(['name']):
try:
if proc.info['name'] and process_name.lower() in proc.info['name'].lower():
still_running = True
print(f"Still running: {proc.info['name']} (PID: {proc.pid})")
break
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if not still_running:
print(f"{process_name} has exited.")
return True
time.sleep(0.5)
print(f"{process_name} did not exit in time.")
return False
def finish_update_if_needed():
"""
Completes a pending application update if '--finish-update' is present in the command-line arguments.
"""
if "--finish-update" in sys.argv:
print("Finishing update...")
if PLATFORM_NAME == 'darwin':
app_dir = '/tmp/flarestempupdate'
else:
app_dir = os.getcwd()
# 1. Find update folder
update_folder = None
for entry in os.listdir(app_dir):
entry_path = os.path.join(app_dir, entry)
if os.path.isdir(entry_path) and entry.startswith("flares-") and entry.endswith("-" + PLATFORM_NAME):
update_folder = os.path.join(app_dir, entry)
break
if update_folder is None:
print("No update folder found. Skipping update steps.")
return
if PLATFORM_NAME == 'darwin':
update_folder = os.path.join(update_folder, "flares-darwin")
# 2. Wait for flares_updater to exit
print("Waiting for flares_updater to exit...")
for proc in psutil.process_iter(['pid', 'name']):
if proc.info['name'] and "flares_updater" in proc.info['name'].lower():
try:
proc.wait(timeout=5)
except psutil.TimeoutExpired:
print("Force killing lingering flares_updater")
proc.kill()
# 3. Replace the updater
if PLATFORM_NAME == 'windows':
new_updater = os.path.join(update_folder, "flares_updater.exe")
dest_updater = os.path.join(app_dir, "flares_updater.exe")
elif PLATFORM_NAME == 'darwin':
new_updater = os.path.join(update_folder, "flares_updater.app")
dest_updater = os.path.abspath(os.path.join(sys.executable, "../../../../flares_updater.app"))
elif PLATFORM_NAME == 'linux':
new_updater = os.path.join(update_folder, "flares_updater")
dest_updater = os.path.join(app_dir, "flares_updater")
else:
print("No platform??")
new_updater = os.getcwd()
dest_updater = os.getcwd()
print(f"New updater is {new_updater}")
print(f"Dest updater is {dest_updater}")
print("Writable?", os.access(dest_updater, os.W_OK))
print("Executable path:", sys.executable)
print("Trying to copy:", new_updater, "->", dest_updater)
if os.path.exists(new_updater):
try:
if os.path.exists(dest_updater):
if PLATFORM_NAME == 'darwin':
try:
if os.path.isdir(dest_updater):
shutil.rmtree(dest_updater)
print(f"Deleted directory: {dest_updater}")
else:
os.remove(dest_updater)
print(f"Deleted file: {dest_updater}")
except Exception as e:
print(f"Error deleting {dest_updater}: {e}")
else:
os.remove(dest_updater)
if PLATFORM_NAME == 'darwin':
wait_for_process_to_exit("flares_updater", timeout=10)
subprocess.check_call(["ditto", new_updater, dest_updater])
else:
shutil.copy2(new_updater, dest_updater)
if PLATFORM_NAME in ('linux', 'darwin'):
os.chmod(dest_updater, 0o755)
if PLATFORM_NAME == 'darwin':
remove_quarantine(dest_updater)
print("flares_updater replaced.")
except Exception as e:
print(f"Failed to replace flares_updater: {e}")
# 4. Delete the update folder
try:
if PLATFORM_NAME == 'darwin':
shutil.rmtree(app_dir)
else:
shutil.rmtree(update_folder)
except Exception as e:
print(f"Failed to delete update folder: {e}")
QMessageBox.information(None, "Update Complete", "The application has been successfully updated.")
sys.argv.remove("--finish-update")
def remove_quarantine(app_path):
"""
Removes the macOS quarantine attribute from the specified application path.
"""
script = f'''
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "FLARES needs privileges to finish the update. (2/2)"
'''
try:
subprocess.run(['osascript', '-e', script], check=True)
print("✅ Quarantine attribute removed.")
except subprocess.CalledProcessError as e:
print("❌ Failed to remove quarantine attribute.")
print(e)
def run_gui_entry_wrapper(config, gui_queue, progress_queue):
"""
Where the processing happens
"""
try:
import fNIRS_module
fNIRS_module.gui_entry(config, gui_queue, progress_queue)
sys.exit(0)
except Exception as e:
tb_str = traceback.format_exc()
gui_queue.put({
"success": False,
"error": f"Child process crashed: {str(e)}\nTraceback:\n{tb_str}"
})
def resource_path(relative_path):
"""
Get absolute path to resource regardless of running directly or packaged using PyInstaller
"""
if hasattr(sys, '_MEIPASS'):
# PyInstaller bundle path
base_path = sys._MEIPASS
else:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
def kill_child_processes():
"""
Goodbye children
"""
try:
parent = psutil.Process(os.getpid())
children = parent.children(recursive=True)
for child in children:
try:
child.kill()
except psutil.NoSuchProcess:
pass
psutil.wait_procs(children, timeout=5)
except Exception as e:
print(f"Error killing child processes: {e}")
def exception_hook(exc_type, exc_value, exc_traceback):
"""
Method that will display a popup when the program hard crashes containg what went wrong
"""
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print(error_msg) # also print to console
kill_child_processes()
# Show error message box
# Make sure QApplication exists (or create a minimal one)
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
QMessageBox.critical(None, "Unexpected Error", f"An unhandled exception occurred:\n\n{error_msg}")
# Exit the app after user acknowledges
sys.exit(1)
if __name__ == "__main__":
# Redirect exceptions to the popup window
sys.excepthook = exception_hook
# Set up application logging
if PLATFORM_NAME == "darwin":
log_path = os.path.join(os.path.dirname(sys.executable), "../../../flares.log")
else:
log_path = os.path.join(os.getcwd(), "flares.log")
os.remove(log_path)
sys.stdout = open(log_path, "a", buffering=1)
sys.stderr = sys.stdout
print(f"\n=== App started at {datetime.now()} ===\n")
freeze_support() # Required for PyInstaller + multiprocessing
# Only run GUI in the main process
if current_process().name == 'MainProcess':
app = QApplication(sys.argv)
finish_update_if_needed()
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")))
window.show()
sys.exit(app.exec())