13 Commits
Author SHA1 Message Date
tyler 4059a3ef93 v2 2026-09-10 13:56:07 -07:00
tyler 66242c1a5c changelog for hotfix 2026-09-10 13:55:58 -07:00
tyler 2a5e3f5606 fix plugins on macOS 2026-09-10 13:32:46 -07:00
tyler 613d2b9103 drag+drop support 2026-09-10 11:18:00 -07:00
tyler cd0b55df34 roi from user provided file 2026-09-09 16:26:14 -07:00
tyler 5d46d3b3e1 pylance fixes and terminal plugin launching 2026-09-01 21:09:45 -07:00
tyler 05dc9203ba bug fixes 2026-09-01 20:42:16 -07:00
tyler 4d00ef86e4 package dependency updates 2026-09-01 18:02:31 -07:00
tyler e9ef78c872 continuation of plugin support 2026-09-01 17:20:16 -07:00
tyler ca203fcb56 start of plugin support? 2026-09-01 02:14:47 -07:00
tyler 815f342ead fix to update part 2 2026-09-01 01:11:44 -07:00
tyler 57492a479b Merge branch 'main' of https://git.research.dezeeuw.ca/tyler/flares 2026-08-31 23:37:28 -07:00
tyler 8ef352add3 fix to app not updating 2026-08-31 23:37:12 -07:00
18 changed files with 1584 additions and 525 deletions
+1
View File
@@ -175,6 +175,7 @@ cython_debug/
.pypirc
/individual_images
/plugins
*.xlsx
*.csv
*.snirf
+21
View File
@@ -1,3 +1,22 @@
# Version 1.7.2
- Fixed the Linux build not starting if shared library files were missing from the host machine
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
# Version 1.7.1
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
- Fixed an issue where updating events in a snirf file would not release memory properly
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
# Version 1.7.0
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
@@ -12,6 +31,7 @@
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
- Added a new Preference option of Theme. Allows from selecting Auto (System default), Light, or Dark. Fixes [Issue 7](https://git.research.dezeeuw.ca/tyler/flares/issues/7)
- Optode updater can now update optode locations in multiple snirf files at once
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
- Modified the help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
@@ -32,6 +52,7 @@
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
- Fixed an issue where progress bar colors would not reset if the data was reprocessed
# Version 1.6.0
+54 -34
View File
@@ -1,35 +1,55 @@
# Version 1.6.0
# Version 1.7.2
- This is potentially a save-changing release due to adding more data into the save file, renaming existing data, and changing what part of the code saves data. Please update your project files to ensure compatibility
- It is still possible to load older saves by enabling 'Incompatible Save Bypass' from the Preferences menu, but your mileage may vary
- Optimized calculations being performed when calculating the Heart Rate to speed up step 5 by up to ~35% on a per-file basis
- Optimized calculations being performed when running the General Linear Model to speed up step 5 by up to ~35% on a per-file basis
- The Group Stats Viewer windows will now properly load the Right/Left or Front/Back fallback ROIs if JSON_LOCATION is not set
- Changed the warning message for lots of short channels and changed when it shows to be percentage based (35%) instead of numerical based (6)
- Renames GENDER to SEX to better represent the values that the field expects and to be more BIDS compliant
- Changed the Assign Groups by AGE window to now be the Assign Groups by Metadata window and added support for grouping by other metadata values
- Added a new metadata value of HAND to allow for grouping participants based on their handiness
- Updated the layout of the participant metadata area to better accommodate current and future metadata values
- Updated the message displayed when "Why are these useful?" is clicked to better represent what the values are used for and to accommodate the new metadata values
- Changed the application title to now show what project you are working on. If you are not working on a project, it will display 'Untitled'
- Tracking of current save states is now present and an asterisk will now appear in the title bar when a project has not been saved
- Currently tracking is only supported on loaded files, parameters on the right side of the screen, and when processing has completed. Per-file metadata or processing stages are not currently implemented
- Clicking close on the Main Window will now properly close all sub windows when closing the application and closing the application with unsaved changes will now prompt to save a project or discard changes. Fixes [Issue 87](https://git.research.dezeeuw.ca/tyler/flares/issues/87)
- The Save button under the File menu no longer asks for a destination if a saved project was already loaded. Fixes [Issue 72](https://git.research.dezeeuw.ca/tyler/flares/issues/72)
- Added an icon to the 'Toggle Status Bar' view option
- Status bar visibility and window layout sizes are now stored between application sessions. Fixes [Issue 88](https://git.research.dezeeuw.ca/tyler/flares/issues/88)
- The popup that appeared when a project was saved has now been moved to a message displayed from the status bar
- Moved all loading of files, loading of folders, loading of projects, and saving of projects to a new file for easier future development
- Changed the working of some messages on the status bar to better reflect what has occurred
- Added a new terminal command 'assoc' to associate .flare files to the application. This is undergoing testing and may not function correctly.
- Removed the debug flag from the application package, which may speed up the application. Fixes [Issue 11](https://git.research.dezeeuw.ca/tyler/flares/issues/11)
- Fixed an issue where the two Group Stats Viewer windows would crash the application once attempted to be opened if JSON_LOCATION was not set
- Fixed an issue where participants could be skipped when processing multiple participants at once which could prevent overall processing from completing
- 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 removing all files via their right click feature would cause the Process button to still be visible. Fixes [Issue 91](https://git.research.dezeeuw.ca/tyler/flares/issues/91)
- Fixed an issue where some popup windows would not properly display the application name when they would open
- Fixed an issue where the welcome dialog image would not display correctly on macOS
- Fixed an issue where the Linux version would fail at 0 seconds when attempting to process files
- Fixed an issue where fOLDing channels could cause the same channels to be repeated misaligning the labels
- Fixed all instances of the word MacOS to now read macOS to match Apple branding
- Fixed the Linux build not starting if shared library files were missing from the host machine
- Fixed the macOS version not loading plugins due to not having an HTTPS certificate
# Version 1.7.1
- Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki
- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu
- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92)
- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust
- Fixed an issue where some log files would not generate, or would generate in an incorrect location
- Fixed an issue where updating events in a snirf file would not release memory properly
- Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading
- Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area
- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80)
# Version 1.7.0
- This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility
- Renamed all instances of "Inter" to properly read as "Intra" and changed "Cross" to now read as "Inter"
- Changed RESAMPLE to only apply where it is required to avoid having Functional Connectivity analysis methods running on data that has been resampled
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
- Updated the names of the methods provided for the Participant Functional Connectivity Viewer to better match the actions they perform
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
- Added a new Preference option of Theme. Allows from selecting Auto (System default), Light, or Dark. Fixes [Issue 7](https://git.research.dezeeuw.ca/tyler/flares/issues/7)
- Optode updater can now update optode locations in multiple snirf files at once
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
- Modified the help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
- Modified the heart rate calculation to be more precise and correct when dealing with good data and not messy data
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
- Fixed an issue where loading a project from a saved project file would not allow the data to be reprocessed with new parameters
- Fixed an issue where the "Hand" BIDS metadata value would not populate on bubbles correctly
- Fixed an issue where performing PSP could ignore the bad channels that were marked by SCI and SNR
- Fixed an issue where the heart rate calculation would disregard all calculations and fall back to an extremely rudimentary calculation
- Fixed an issue that could prevent log file generation while the application was in the middle of an update
- Fixed an issue where a rare crash could occur while the application was in the middle of an update
- Fixed an issue that could have passed multiple conditions when generating an Intra-Group Stats image
- Fixed an issue that could pass NaN values when attempting to collapse channels
- Fixed an issue that was causing the OLS model to always be used for brain images with multiple participants, and not the MixedLM model
- Fixed an issue that could cause the Wavelet filtering step to crash
- Fixed an issue where file associations appeared to work as intended but would not load the project on macOS and only open the application
- Fixed an issue where file associations would refuse to associate on macOS once they have attempted to be associated
- Fixed an issue where certain parameters would not enable or disable depending on other parameters when they should've
- Fixed an issue where not all widgets would close when attempting to close the application causing the application to crash
- Fixed an issue where events were not created correctly after the data had been resampled by the design matrix
- Fixed an issue where progress bar colors would not reset if the data was reprocessed
+97 -88
View File
@@ -105,7 +105,7 @@ from mne_nirs.statistics._glm_level_first import RegressionResults # type: igno
from mne_connectivity.viz import plot_connectivity_circle # type: ignore
from mne_connectivity import envelope_correlation, spectral_connectivity_epochs, spectral_connectivity_time # type: ignore
from src.shared.shareddata import PLATFORM_NAME, resource_path
from src.shared.shareddata import PLATFORM_NAME, resource_path, get_app_dir
@@ -162,43 +162,6 @@ QC_METRIC_LABELS = {
"total_processing_seconds": "Processing Time (s)",
}
ROI_MAP = {
'ROI_OccipitoParietal_BA18_19_7': [
'S1_D1', 'S1_D2', 'S4_D3'
],
'ROI_SuperiorParietal_BA7_5': [
'S2_D1', 'S3_D1', 'S2_D2', 'S2_D4', 'S4_D4'
],
'ROI_TPJ_AngularGyrus_BA39_19_40': [
'S4_D2', 'S5_D3', 'S4_D5', 'S5_D5'
],
# --- SPLIT BA40 REGIONS ---
'ROI_Posterior_BA40_Parietal': [
'S6_D4', 'S6_D5'
],
'ROI_Anterior_BA40_Sensorimotor': [
'S10_D5', 'S8_D5'
],
# --------------------------
'ROI_Supramarginal_Inferior_BA40_6': [
'S6_D6'
],
'ROI_VentralSomatosensory_BA1_2_3_43_48': [
'S8_D6', 'S9_D6'
],
'ROI_Sensorimotor_BA1_2_3_4_6': [
'S6_D8', 'S10_D8'
],
'ROI_DLPFC_FEF_BA6_8_9': [
'S7_D7', 'S7_D8'
],
'ROI_Broca_VLPFC_BA6_44_45_4': [
'S9_D7', 'S10_D7'
]
}
DOWNSAMPLE: bool
DOWNSAMPLE_FREQUENCY: int
@@ -345,24 +308,20 @@ FEATURE_2: bool = False
script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir)
# Configure logging to file with timestamps and realtime flush
if PLATFORM_NAME == 'darwin':
logging.basicConfig(
filename=os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"), # Needed to get out of the bundled application
level=logging.INFO,
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
filemode='a'
)
log_path = os.path.abspath(os.path.join(os.path.dirname(sys.executable), "../../../fnirs_analysis.log"))
else:
logging.basicConfig(
filename='fnirs_analysis.log',
level=logging.INFO,
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
filemode='a'
)
log_path = os.path.join(get_app_dir(), "fnirs_analysis.log")
logging.basicConfig(
filename=log_path,
level=logging.INFO,
format='%(asctime)s - %(processName)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
filemode='a'
)
logger = logging.getLogger()
@@ -514,7 +473,8 @@ def process_multiple_participants(file_paths, file_params, file_metadata,
success_count = {"value": 0}
failed_stages = {"value": []}
qc_rows: list[dict[str, Any]] = []
qc_summary_path="qc_summary.xlsx"
if qc_summary_path is None:
qc_summary_path = os.path.join(get_app_dir(), "qc_summary.xlsx")
def elapsed_heartbeat():
# Ticks once a second so the GUI can show a live-updating timer,
@@ -1155,10 +1115,10 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
is_bad = (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads'])
is_short = pair_distances[i] < short_channels_threshold
if is_bad:
bad_pairs.append(i)
elif is_short:
if is_short:
n_short_excluded += 1
elif is_bad:
bad_pairs.append(i)
else:
good_pairs.append(i)
@@ -1222,7 +1182,7 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
constrained_layout=True)
if n_bad == 1: axes = [axes] # Handle single subplot case
axes_flat = np.asarray(axes).get_data().flatten() if hasattr(axes, 'get_data') else np.asarray(axes).ravel()
axes_flat = np.asarray(axes).ravel()
for j in range(n_bad, len(axes_flat)):
if j >= n_bad:
axes_flat[j].axis('off')
@@ -1262,9 +1222,6 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2,
print("Bads cleared:", raw.info['bads'])
raw.info['bads'] = []
for ch in raw.info['bads']:
print(f"Channel {ch} still marked as bad.")
fig_raw_after = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After interpolation", show=False)
return raw, fig_raw_after, fig_compare
@@ -6097,21 +6054,22 @@ def process_participant(file_path, file_start, progress_callback=None):
# Step 27.5: Extract FIR Waveform Features & Enqueue Metric Plots
fir_feature_dict = {'features': np.array([]), 'feature_names': [], 'feature_channels': []}
# if HRF_MODEL.lower() == "fir":
# try:
# fir_feature_dict = extract_fir_features_real_data(
# raw=raw_haemo,
# target_condition=None, # e.g., 'reach'
# fir_delays=FIR_DELAYS, # e.g., np.arange(0, 15)
# selected_metrics=tuple(METRIC_REGISTRY.keys()), # e.g., ('Peak_Amp', 'TTP', 'AUC')
# roi_map=ROI_MAP,
# glm_est=glm_est,
# df_design_matrix=df_design_matrix,
# png_queue=png_queue
# )
# logger.info("Step 27.5: FIR features successfully extracted and metric images enqueued.")
# except Exception as e:
# logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
if HRF_MODEL.lower() == "fir":
try:
fir_feature_dict = extract_fir_features_real_data(
raw=raw_haemo,
target_condition=None, # e.g., 'reach'
fir_delays=FIR_DELAYS, # e.g., np.arange(0, 15)
selected_metrics=tuple(METRIC_REGISTRY.keys()), # e.g., ('Peak_Amp', 'TTP', 'AUC')
roi_map=JSON_LOCATION,
chromophores=('hbo', 'hbr', 'hbt'),
glm_est=glm_est,
df_design_matrix=df_design_matrix,
png_queue=png_queue
)
logger.info("Step 27.5: FIR features successfully extracted and metric images enqueued.")
except Exception as e:
logger.warning(f"Step 27.5 Failed to extract FIR features: {e}")
# Step 28: Finishing Up
@@ -7235,8 +7193,8 @@ def _compute_roi_fir_curves(
raw=None,
target_condition='reach',
fir_delays=np.arange(0, 15),
roi_map=ROI_MAP,
chromophores=('hbr',),
roi_map={},
chromophores=('hbo', 'hbr', 'hbt'),
glm_est=None,
df_design_matrix=None
):
@@ -7245,6 +7203,7 @@ def _compute_roi_fir_curves(
it reuses pre-calculated GLM results directly to avoid duplicate processing.
"""
roi_curves = {}
active_roi_map = normalize_roi_map(roi_map)
# --- SHORT-CIRCUIT: Reuse pre-calculated GLM estimation if available ---
if glm_est is not None and df_design_matrix is not None:
@@ -7263,25 +7222,36 @@ def _compute_roi_fir_curves(
fir_df = glm_df[glm_df[cond_col].astype(str).str.lower().str.contains(target_condition.lower())].copy()
print(f"Matched rows for '{target_condition}': {len(fir_df)}")
active_roi_map = normalize_roi_map(roi_map)
if fir_df.empty:
logger.warning(f"Condition '{target_condition}' not found in precalculated GLM estimates.")
return roi_curves
for chromo in chromophores:
chromo_df = fir_df[fir_df[ch_col].str.lower().str.contains(chromo.lower())] if ch_col in fir_df.columns else fir_df
# 1. Extract base measured chromophores (e.g., hbo, hbr) directly from GLM data
base_chromos = [c.lower() for c in chromophores if c.lower() != 'hbt']
for chromo in base_chromos:
chromo_df = fir_df[fir_df[ch_col].str.lower().str.contains(chromo)] if ch_col in fir_df.columns else fir_df
ch_curves = {}
for ch_name, ch_group in chromo_df.groupby(ch_col):
pair = ch_name.split(' ')[0]
ch_curves[pair] = ch_group['theta'].values if 'theta' in ch_group.columns else ch_group['beta'].values
print("Extracted channel keys:", list(ch_curves.keys())[:5])
for roi_name, channels in roi_map.items():
for roi_name, channels in active_roi_map.items():
matching_curves = [ch_curves[ch] for ch in channels if ch in ch_curves]
if matching_curves:
roi_curves[(chromo, roi_name)] = np.mean(matching_curves, axis=0)
# 2. Derive HbT (HbO + HbR) dynamically if requested
if 'hbt' in [c.lower() for c in chromophores]:
for roi_name in active_roi_map.keys():
hbo_key = ('hbo', roi_name)
hbr_key = ('hbr', roi_name)
if hbo_key in roi_curves and hbr_key in roi_curves:
roi_curves[('hbt', roi_name)] = roi_curves[hbo_key] + roi_curves[hbr_key]
return roi_curves
@@ -7290,7 +7260,8 @@ def extract_fir_features_real_data(
target_condition=None,
fir_delays=np.arange(0, 15),
selected_metrics=('Peak_Amp',),
roi_map=ROI_MAP,
roi_map={},
chromophores=('hbo', 'hbr', 'hbt'),
glm_est=None,
df_design_matrix=None,
png_queue=None
@@ -7323,7 +7294,7 @@ def extract_fir_features_real_data(
target_condition=cond,
fir_delays=fir_delays,
roi_map=roi_map,
chromophores=('hbr',),
chromophores=chromophores,
glm_est=glm_est,
df_design_matrix=df_design_matrix
)
@@ -7338,9 +7309,10 @@ def extract_fir_features_real_data(
for (chromo, roi_name), roi_fir_curve in roi_curves.items():
metrics = compute_waveform_metrics(roi_fir_curve, fir_delays=fir_delays, selected_metrics=selected_metrics)
collapsed_features.extend(metrics)
# Prefix feature names with condition
feature_names.extend([f"{cond}_{roi_name}_{m}" for m in metric_labels])
feature_channels.extend([roi_name] * len(metric_labels))
# Prefix feature names with condition AND chromophore
feature_names.extend([f"{cond}_{chromo.upper()}_{roi_name}_{m}" for m in metric_labels])
feature_channels.extend([f"{roi_name} ({chromo.upper()})"] * len(metric_labels))
# Enqueue plots for this specific condition
plot_and_enqueue_waveform_metrics(
@@ -7404,6 +7376,43 @@ def compute_waveform_metrics(fir_curve, fir_delays, selected_metrics=('Peak_Amp'
return [calculated_metrics[m] for m in selected_metrics]
def normalize_roi_map(roi_map):
"""
Normalizes ROI mapping inputs into a flat {roi_name: [channel_list]} dict.
Accepts:
1. String or Path pointing to a JSON file (JSON_LOCATION).
2. Dict with 'regions_of_interest' list (loaded JSON).
3. Standard flat dict {roi_name: [channels]}.
"""
# 1. If roi_map is a file path string or Path, load JSON from disk
if isinstance(roi_map, (str, Path)):
json_path = Path(roi_map)
if json_path.is_file():
try:
with open(json_path, 'r', encoding='utf-8') as f:
roi_map = json.load(f)
except Exception as e:
logger.error(f"Failed to load ROI JSON file from {json_path}: {e}")
return {}
else:
logger.error(f"ROI JSON file path does not exist: {json_path}")
return {}
# 2. Handle nested JSON structure with 'regions_of_interest'
if isinstance(roi_map, dict) and 'regions_of_interest' in roi_map:
return {
roi['name']: roi['channels']
for roi in roi_map['regions_of_interest']
}
# 3. Fallback for flat dictionary {roi_name: [channels]}
if isinstance(roi_map, dict):
return roi_map
return {}
if __name__ == "__main__":
print("This file has no functionality when not used in tandem with the FLARES application.")
+52 -32
View File
@@ -20,21 +20,24 @@ from pathlib import Path
from datetime import datetime
# External library imports
from src.shared.shareddata import APP_NAME, PLATFORM_NAME
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, get_app_dir
if PLATFORM_NAME == 'darwin':
_log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
else:
_log_path = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log")
_log_path = os.path.join(get_app_dir(), f"{APP_NAME}_updater.log")
LOG_FILE = _log_path
def log(msg: str) -> None:
with open(LOG_FILE, "a", encoding="utf-8") as f:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"{timestamp} - {msg}\n")
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"{timestamp} - {msg}\n")
except Exception:
pass
def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
@@ -45,22 +48,28 @@ def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool:
for proc in psutil.process_iter(['pid', 'exe']):
try:
proc_exe = proc.info.get('exe')
if proc_exe and os.path.samefile(os.path.realpath(proc_exe), exe_path):
log(f"Terminating process: PID {proc.pid}")
_terminate_process(proc)
terminated_any = True
if proc_exe and os.path.exists(proc_exe) and os.path.exists(exe_path):
if os.path.samefile(os.path.realpath(proc_exe), exe_path):
log(f"Terminating process: PID {proc.pid}")
_terminate_process(proc)
terminated_any = True
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e:
continue
except Exception as e:
log(f"Error terminating process (Windows): {e}")
elif PLATFORM_NAME == 'linux':
for proc in psutil.process_iter(['pid', 'cmdline']):
try:
cmdline = proc.info.get('cmdline', [])
if cmdline:
if cmdline and os.path.exists(cmdline[0]) and os.path.exists(exe_path):
proc_cmd = os.path.realpath(cmdline[0])
if os.path.samefile(proc_cmd, exe_path):
log(f"Terminating process: PID {proc.pid}")
_terminate_process(proc)
terminated_any = True
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e:
continue
except Exception as e:
log(f"Error terminating process (Linux): {e}")
@@ -82,6 +91,9 @@ def _terminate_process(proc: psutil.Process) -> None:
def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None:
if not os.path.exists(path):
return
start_time = time.time()
while time.time() - start_time < timeout:
try:
@@ -95,7 +107,7 @@ def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) ->
log(f"Still locked: {path} - {e}")
time.sleep(1)
log(f"Failed to delete after wait: {path}")
def delete_path(path: Union[str, Path]) -> None:
if os.path.exists(path):
@@ -130,8 +142,8 @@ def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path
def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None:
updater_name = updater_name + ".app"
if not updater_name.endswith(".app"):
updater_name = updater_name + ".app"
for item in os.listdir(src_folder):
if item.lower() == updater_name.lower():
@@ -152,10 +164,7 @@ def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[st
def remove_quarantine(app_path: Union[str, Path]) -> bool:
"""Removes the macOS quarantine extended attribute from an application bundle using osascript.
Returns True on success, False on error or cancellation.
"""
"""Removes the macOS quarantine extended attribute from an application bundle using osascript."""
clean_path: str = str(app_path)
escaped_path: str = shlex.quote(clean_path)
@@ -172,7 +181,7 @@ def remove_quarantine(app_path: Union[str, Path]) -> bool:
return False
def main():
def main() -> None:
main_exe: str = ""
app_dir: Path = Path()
bundle_dir: Path = Path()
@@ -189,17 +198,24 @@ def main():
main_exe = sys.argv[2]
main_exe_path = Path(main_exe).resolve()
app_dir = main_exe_path.parent
bundle_dir = main_exe_path.parents[2]
parent_bundle_dir = main_exe_path.parents[3]
app_dir = main_exe_path.parent
updater_name = os.path.basename(sys.argv[0])
if PLATFORM_NAME == 'darwin':
# Bundle hierarchy on macOS: App.app/Contents/MacOS/executable
if len(main_exe_path.parents) >= 4:
bundle_dir = main_exe_path.parents[2]
parent_bundle_dir = main_exe_path.parents[3]
else:
bundle_dir = app_dir
parent_bundle_dir = app_dir.parent
updater_name = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else sys.argv[0])
log("Updater started.")
log(f"Update folder: {update_folder}")
log(f"Main EXE: {main_exe}")
log(f"Updater EXE: {updater_name}")
if PLATFORM_NAME == 'darwin':
if PLATFORM_NAME == 'darwin' and bundle_dir:
log(f"Main App Folder: {bundle_dir}")
# Kill all instances of main app
@@ -212,18 +228,22 @@ def main():
try:
if PLATFORM_NAME == 'windows':
proc_exe = proc.info.get('exe')
if proc_exe and os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)):
running = True
break
if proc_exe and os.path.exists(proc_exe) and os.path.exists(main_exe):
if os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)):
running = True
break
elif PLATFORM_NAME == 'linux':
cmdline = proc.info.get('cmdline', [])
if cmdline:
if cmdline and os.path.exists(cmdline[0]) and os.path.exists(main_exe):
proc_cmd = os.path.realpath(cmdline[0])
if os.path.samefile(proc_cmd, os.path.realpath(main_exe)):
running = True
break
except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError):
continue
except Exception as e:
log(f"Polling error: {e}")
if not running:
break
time.sleep(0.5)
@@ -231,11 +251,11 @@ def main():
log("Warning: main executable still running after wait timeout.")
# Delete old version files
if PLATFORM_NAME == 'darwin':
if PLATFORM_NAME == 'darwin' and bundle_dir and parent_bundle_dir:
log(f'Attempting to delete {bundle_dir}')
delete_path(str(bundle_dir))
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
copy_update_files_darwin(update_folder, str(parent_bundle_dir), updater_name)
extracted_update_folder = os.path.join(update_folder, f"{APP_NAME}-darwin")
copy_update_files_darwin(extracted_update_folder, str(parent_bundle_dir), updater_name)
else:
delete_path(main_exe)
@@ -252,8 +272,8 @@ def main():
if PLATFORM_NAME == 'linux':
os.chmod(main_exe, 0o755)
log("Added executable bit")
if PLATFORM_NAME == 'darwin':
if PLATFORM_NAME == 'darwin' and bundle_dir:
os.chmod(str(bundle_dir), 0o755)
log("Added executable bit")
remove_quarantine(str(bundle_dir))
+82 -13
View File
@@ -22,6 +22,10 @@ from datetime import datetime
from functools import partial
from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method
# Fix for plotting on linux
if sys.platform.startswith("linux"):
os.environ["QT_QPA_PLATFORM"] = "xcb"
# External library imports
import psutil
@@ -34,8 +38,10 @@ from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIc
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 plugin_manager import PluginManager
from project_manager import ProjectManager
from src.window.about import AboutWindow
from src.window.plugins import PluginsWindow
from src.window.terminal import TerminalWindow
from src.window.updateevents import EventUpdateMode, UpdateEventsBlazesWindow, UpdateEventsWindow
from src.window.updateoptodes import UpdateOptodesWindow
@@ -43,7 +49,7 @@ from src.window.userguide import UserGuideWindow
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 src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA, get_app_dir
from startup_args import parse_startup_args
from updater import finish_update_if_needed, UpdateManager, LocalPendingUpdateCheckThread
@@ -533,6 +539,49 @@ class ThemeChangeWatcher(QObject):
self.main_window.update_theme()
class DropScrollArea(QScrollArea):
"""QScrollArea that accepts .snirf file and folder drops."""
def __init__(self, on_files_dropped_callback, parent=None):
super().__init__(parent)
self.on_files_dropped_callback = on_files_dropped_callback
self.setAcceptDrops(True)
# ScrollAreas require setting acceptDrops on their viewport as well
self.viewport().setAcceptDrops(True)
def dragEnterEvent(self, event) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event) -> None:
if not event.mimeData().hasUrls():
return
dropped_paths = [
url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile()
]
files_to_load = []
for path_str in dropped_paths:
p = Path(path_str)
if p.is_file() and p.suffix.lower() == ".snirf":
files_to_load.append(os.path.normpath(str(p)))
elif p.is_dir():
files_to_load.extend(
os.path.normpath(str(f)) for f in p.rglob("*.snirf")
)
if files_to_load:
self.on_files_dropped_callback(files_to_load)
else:
QMessageBox.information(
self,
"No Valid Files",
"None of the dropped items were .snirf files or directories containing .snirf files.",
)
class MainApplication(QMainWindow):
"""
Main application window that creates and sets up the UI.
@@ -556,6 +605,7 @@ class MainApplication(QMainWindow):
self.optodes = None
self.events = None
self.events_blazes = None
self.plugins = None
self.terminal = None
self.bubble_widgets = {}
self.param_sections = []
@@ -577,6 +627,7 @@ class MainApplication(QMainWindow):
self.files_are_dirty = False
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
self.plugin_manager = PluginManager(self)
# Initialization to ensure that saving can occur
for item in DATA_SCHEMA:
@@ -618,7 +669,9 @@ class MainApplication(QMainWindow):
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
self.local_check_thread.start()
self.plugin_manager.reload_plugins()
self.show()
# Check if we should pop up the welcome screen
@@ -709,7 +762,9 @@ class MainApplication(QMainWindow):
self.bubble_layout = QGridLayout(self.bubble_container)
self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area = QScrollArea()
self.scroll_area = DropScrollArea(
on_files_dropped_callback=self.project_manager.load_dropped_files
)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(self.bubble_container)
self.scroll_area.setMinimumHeight(200)
@@ -910,6 +965,13 @@ class MainApplication(QMainWindow):
preferences_menu.addAction(action)
self.pref_actions[config_key] = action
plugins_menu = menu_bar.addMenu("Plugins")
plugins_actions = [
("Plugin Manager", "Ctrl+Alt+P", self.plugins_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
]
for name, shortcut, slot, icon in plugins_actions:
plugins_menu.addAction(make_action(name, shortcut, slot, icon=icon))
terminal_menu = menu_bar.addMenu("Terminal")
terminal_actions = [
("New Terminal", "Ctrl+Alt+T", self.terminal_gui, resource_path("icons/terminal_24dp_1F1F1F.svg")),
@@ -1171,6 +1233,7 @@ class MainApplication(QMainWindow):
# for stat in stats[:10]:
# print(stat)
# print("Top 10 growing object types in RAM:")
# objgraph has been removed so it will need to be reinstalled for this to work
# objgraph.show_most_common_types(limit=10)
def check_if_app_is_dirty(self):
@@ -1315,10 +1378,15 @@ class MainApplication(QMainWindow):
"This action is not available at this time.",
QMessageBox.Ok
)
def plugins_gui(self):
if self.plugins is None or not self.plugins.isVisible():
self.plugins = PluginsWindow(self, self.plugin_manager)
self.plugins.show()
def terminal_gui(self):
if self.terminal is None or not self.terminal.isVisible():
self.terminal = TerminalWindow(self)
self.terminal = TerminalWindow(self, self.plugin_manager)
self.terminal.show()
def update_optode_positions(self):
@@ -1642,7 +1710,7 @@ class MainApplication(QMainWindow):
bubble.update_progress(step, active=False)
# Add to layout
self.bubble_layout.addWidget(bubble, index, 1)
self.bubble_layout.addWidget(bubble, index, 0)
# 4. Status Bar
msg = f"Project loaded: {len(file_list)} files."
@@ -2746,13 +2814,14 @@ if __name__ == "__main__":
log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.log")
cfg_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}.cfg")
else:
log_path = os.path.join(os.getcwd(), f"{APP_NAME}.log")
cfg_path = os.path.join(os.getcwd(), f"{APP_NAME}.cfg")
log_path = os.path.join(get_app_dir(), f"{APP_NAME}.log")
cfg_path = os.path.join(get_app_dir(), f"{APP_NAME}.cfg")
try:
os.remove(log_path)
except:
pass
if os.path.exists(log_path):
os.remove(log_path)
except Exception as e:
print(f"Warning: Could not remove old log file: {e}")
sys.stdout = open(log_path, "a", buffering=1)
sys.stderr = sys.stdout
print(f"\n=== App started at {datetime.now()} ===\n")
@@ -2778,4 +2847,4 @@ if __name__ == "__main__":
window.show()
sys.exit(app.exec())
# Not 2600 lines yay!
# Not 2800 lines yay!
+49
View File
@@ -0,0 +1,49 @@
Package Version Latest Type
------------------------- -------- ------------ -----
certifi 2026.1.4 2026.7.22 wheel
charset-normalizer 3.4.4 3.5.1 wheel
decorator 5.2.1 5.3.1 wheel
fonttools 4.61.1 4.64.0 wheel
h5py 3.15.1 3.16.0 wheel
idna 3.11 3.19 wheel
joblib 1.5.3 1.6.0 wheel
kiwisolver 1.4.9 1.5.1 wheel
lazy_loader 0.4 0.5 wheel
lxml 6.0.2 6.1.2 wheel
matplotlib 3.10.8 3.11.1 wheel
mne 1.11.0 1.12.1 wheel
mne-connectivity 0.7.0 0.9.0 wheel
neurokit2 0.2.12 0.2.13 wheel
nibabel 5.3.3 5.4.2 wheel
nilearn 0.13.0 0.14.0 wheel
numpy 2.4.1 2.5.2 wheel
packaging 25.0 26.3 wheel
pandas 2.3.3 3.0.5 wheel
patsy 1.0.2 1.0.3 wheel
pillow 12.1.0 12.3.0 wheel
pip 25.3 26.2.1 wheel
platformdirs 4.5.1 4.11.7 wheel
pooch 1.8.2 1.9.0 wheel
psutil 7.2.1 7.2.2 wheel
pyinstaller 6.19.0 6.22.2 wheel
pyinstaller-hooks-contrib 2026.3 2026.7 wheel
pyparsing 3.3.1 3.3.2 wheel
PySide6 6.10.1 6.11.2 wheel
PySide6_Addons 6.10.1 6.11.2 wheel
PySide6_Essentials 6.10.1 6.11.2 wheel
pytz 2025.2 2026.3.post1 wheel
pyvista 0.46.5 0.48.4 wheel
pyvistaqt 0.11.3 0.12.0 wheel
requests 2.32.5 2.34.2 wheel
scikit-learn 1.8.0 1.9.0 wheel
scipy 1.17.0 1.18.1 wheel
scooby 0.11.0 0.11.2 wheel
setuptools 82.0.1 84.0.0 wheel
shiboken6 6.10.1 6.11.2 wheel
statsmodels 0.14.6 0.15.0 wheel
tqdm 4.67.1 4.70.0 wheel
typing_extensions 4.15.0 4.16.0 wheel
tzdata 2025.3 2026.3 wheel
urllib3 2.6.3 2.7.0 wheel
vtk 9.5.2 9.7.0 wheel
xarray 2026.1.0 2026.7.0 wheel
+331
View File
@@ -0,0 +1,331 @@
"""
Filename: plugin_manager.py
Description: Manager file for anything plugin related
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
import io
import ssl
import sys
import json
import shutil
import zipfile
import platform
import urllib.request
import importlib.util
from pathlib import Path
from typing import Any, cast
# External library imports
import certifi
from PySide6.QtCore import QObject, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME
def get_ssl_context() -> ssl.SSLContext:
"""Returns a cross-platform SSL context configured with certifi's CA bundle."""
return ssl.create_default_context(cafile=certifi.where())
def get_current_platform_id() -> str:
"""Returns standardized platform identifier (win_x64, darwin_arm64, etc.)."""
sys_name = sys.platform
arch = platform.machine().lower()
if sys_name == "win32":
return "win_x64" if "64" in arch or "amd64" in arch else "win_x86"
elif sys_name == "darwin":
return "darwin_arm64" if "arm" in arch or "aarch64" in arch else "darwin_x64"
elif sys_name.startswith("linux"):
return "linux_x64" if "64" in arch else "linux_x86"
return sys_name
def parse_version(v_str: str) -> tuple[int, ...]:
"""Parses a version string into a comparable integer tuple."""
try:
return tuple(int(x) for x in v_str.strip().lstrip("v").split("."))
except ValueError:
return (0, 0, 0)
class PluginManager(QObject):
"""
Handles plugin discovery, dynamic loading, menu building,
and plugin lifecycle (install, toggle, uninstall).
"""
plugins_changed = Signal()
def __init__(self, main_window: QMainWindow) -> None:
super().__init__()
self.main_window: QMainWindow = main_window
self.plugins_dir: Path = self._resolve_plugins_dir()
self.loaded_plugins: list[Any] = []
self.current_platform: str = get_current_platform_id()
self.current_app_version: tuple[int, ...] = parse_version(str(CURRENT_VERSION))
def _resolve_plugins_dir(self) -> Path:
"""Determines the local plugins directory based on execution context."""
if PLATFORM_NAME == "darwin":
base_dir = Path(sys.executable).parent / "../../.."
else:
base_dir = Path.cwd()
plugins_path = (base_dir / "plugins").resolve()
plugins_path.mkdir(parents=True, exist_ok=True)
return plugins_path
def reload_plugins(self) -> list[Any]:
"""Scans directory, imports enabled plugins, and updates the main application menu."""
self.unload_plugins()
plugins_dir_str = str(self.plugins_dir)
if plugins_dir_str not in sys.path:
sys.path.insert(0, plugins_dir_str)
failed_plugins: list[tuple[str, str]] = []
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
if entry.name.startswith((".", "__")) or entry.name.endswith(".disabled"):
continue
module_name = ""
init_file: Path | None = None
if entry.is_dir():
init_file = entry / "__init__.py"
module_name = entry.name
elif entry.is_file() and entry.suffix == ".py":
init_file = entry
module_name = entry.stem
if not init_file or not init_file.exists():
continue
try:
instance = self._import_and_instantiate(module_name, init_file)
if instance is not None:
self.loaded_plugins.append(instance)
except Exception as e:
failed_plugins.append((module_name, str(e)))
# Report failures after all plugins have attempted to load
if failed_plugins:
error_details = "\n".join(f"• {name}: {err}" for name, err in failed_plugins)
summary_msg = f"One or more plugins failed to load:\n\n{error_details}"
print(f"[PluginManager] {summary_msg}")
parent_widget = getattr(self, "main_window", None)
QMessageBox.warning(
parent_widget,
"Plugin Load Failures",
summary_msg
)
self.build_plugins_menu()
self.plugins_changed.emit()
return self.loaded_plugins
def _import_and_instantiate(self, module_name: str, file_path: Path) -> Any | None:
"""Dynamically imports a plugin module, attaches its metadata, and returns the Plugin instance."""
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Invalid or missing spec for file: {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
if hasattr(module, "Plugin") and isinstance(module.Plugin, type):
instance = module.Plugin(self.main_window)
# Attach manifest metadata directly to the plugin instance if plugin.json exists
manifest_path = file_path.parent / "plugin.json"
if manifest_path.exists():
try:
with open(manifest_path, "r", encoding="utf-8") as f:
instance.metadata = json.load(f)
# Override class name property if specified in manifest
if "name" in instance.metadata:
instance.name = instance.metadata["name"]
except Exception as e:
print(f"[PluginManager] Could not attach metadata to '{module_name}': {e}")
return instance
return None
def build_plugins_menu(self) -> None:
"""Constructs or refreshes submenus under the top-level 'Plugins' menu item."""
menubar = self.main_window.menuBar()
# Dynamically locate the existing 'Plugins' menu on the menubar
plugins_menu: QMenu | None = None
for action in menubar.actions():
clean_text = action.text().replace("&", "").strip().lower()
if clean_text == "plugins":
menu = action.menu()
if isinstance(menu, QMenu):
plugins_menu = menu
break
# If it doesn't exist yet, create it
if plugins_menu is None:
plugins_menu = menubar.addMenu("&Plugins")
plugins_menu.clear()
# 1. Populate loaded plugin submenus
if not self.loaded_plugins:
disabled_action = plugins_menu.addAction("No active plugins")
disabled_action.setEnabled(False)
else:
for plugin in self.loaded_plugins:
plugin_name = getattr(plugin, "name", "Unnamed Plugin")
plugin_submenu = plugins_menu.addMenu(plugin_name)
if hasattr(plugin, "register_menu") and callable(plugin.register_menu):
plugin.register_menu(plugin_submenu)
# 2. Append entry to open Plugin Manager GUI
plugins_menu.addSeparator()
manager_action = plugins_menu.addAction("Manage Plugins...")
plugins_gui_func = getattr(self.main_window, "plugins_gui", None)
if callable(plugins_gui_func):
manager_action.triggered.connect(plugins_gui_func)
def unload_plugins(self) -> None:
"""Clears current active plugin instances."""
self.loaded_plugins.clear()
def get_installed_plugins_info(self) -> list[dict[str, Any]]:
"""Returns metadata for all local plugins in the plugins folder by reading their plugin.json."""
results: list[dict[str, Any]] = []
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
if entry.name.startswith((".", "__")):
continue
is_disabled = entry.name.endswith(".disabled")
clean_name = entry.name[:-9] if is_disabled else entry.name
if entry.is_dir() or (entry.is_file() and (entry.suffix == ".py" or entry.name.endswith(".py.disabled"))):
if entry.is_file() and entry.stem in ("__init__", "__init__.py"):
continue
# Load local plugin.json if present
manifest: dict[str, Any] = {}
manifest_path = (entry / "plugin.json") if entry.is_dir() else None
if manifest_path and manifest_path.exists():
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except Exception as e:
print(f"[PluginManager] Warning: Failed to read manifest for '{entry.name}': {e}")
display_name = manifest.get("name", clean_name.removesuffix(".py"))
version = manifest.get("version", "1.0.0")
description = manifest.get("description", "")
author = manifest.get("author", "Unknown")
results.append({
"id": manifest.get("id", clean_name),
"name": display_name,
"version": version,
"description": description,
"author": author,
"path": entry,
"is_disabled": is_disabled,
"manifest": manifest,
})
return results
def toggle_plugin_state(self, plugin_path: Path) -> None:
"""Swaps a plugin between enabled and disabled by renaming with/without '.disabled'."""
if not plugin_path.exists():
return
if plugin_path.name.endswith(".disabled"):
new_path = plugin_path.with_name(plugin_path.name.removesuffix(".disabled"))
else:
new_path = plugin_path.with_name(f"{plugin_path.name}.disabled")
plugin_path.rename(new_path)
self.reload_plugins()
def uninstall_plugin(self, plugin_path: Path) -> None:
"""Deletes a plugin directory or file from disk."""
if not plugin_path.exists():
return
if plugin_path.is_dir():
shutil.rmtree(plugin_path)
else:
plugin_path.unlink()
self.reload_plugins()
def fetch_remote_repositories(self, repo_urls: list[str]) -> tuple[list[dict[str, Any]], bool]:
"""Fetches remote plugin metadata from repository URLs."""
aggregated: list[dict[str, Any]] = []
failed_count = 0
for url in repo_urls:
try:
req = urllib.request.Request(
url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=5, context=get_ssl_context()) as response:
if response.status == 200:
raw_data = response.read().decode("utf-8")
data = json.loads(raw_data)
if isinstance(data, list):
aggregated.extend(cast(list[Any], data))
else:
failed_count += 1
except Exception:
failed_count += 1
all_failed = failed_count == len(repo_urls) and len(repo_urls) > 0
return aggregated, all_failed
def install_plugin_from_url(self, download_url: str, plugin_id: str = "") -> Path:
"""
Downloads a single-folder ZIP archive and extracts it directly into plugins/.
"""
req = urllib.request.Request(
download_url,
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
)
with urllib.request.urlopen(req, timeout=15, context=get_ssl_context()) as response:
if response.status != 200:
raise RuntimeError(f"Download failed with HTTP status code {response.status}")
zip_bytes = response.read()
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
zip_ref.extractall(self.plugins_dir)
self.reload_plugins()
return self.plugins_dir
def open_plugins_directory(self) -> bool:
"""Opens the local plugins folder in the operating system's file manager."""
if self.plugins_dir.exists():
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
return False
+107 -9
View File
@@ -15,6 +15,7 @@ import copy
import pickle
import concurrent
import configparser
import concurrent.futures
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, List, Optional, Union
@@ -107,21 +108,116 @@ class ProjectManager:
# =========================================================================
# File & Folder Opening Dialogs
# =========================================================================
def is_valid_snirf(self, path: str) -> bool:
"""Fast header check to verify HDF5/SNIRF signature and ignore corrupt/shadow files."""
try:
if os.path.getsize(path) < 8:
return False
with open(path, "rb") as f:
return f.read(8) == b"\x89HDF\r\n\x1a\n"
except Exception:
return False
def open_file_dialog(self) -> None:
"""Opens dialog to pick a single .snirf file."""
file_path, _ = QFileDialog.getOpenFileName(
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
)
if file_path:
self._load_files_into_pipeline([os.path.normpath(file_path)])
if not file_path:
return
norm_path = os.path.normpath(file_path)
filename = os.path.basename(norm_path)
# 1. Check specifically for macOS shadow files
if filename.startswith("._"):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not a actual data file."
)
return
# 2. Check for general header validity / corruption
if not self.is_valid_snirf(norm_path):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
)
return
self._load_files_into_pipeline([norm_path])
def open_folder_dialog(self)-> None:
"""Recursively finds all .snirf files in a selected directory."""
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
if folder_path:
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
self._load_files_into_pipeline(snirf_files)
if not folder_path:
return
# Automatically filter out shadow files and invalid headers in batch mode
snirf_files = [
os.path.normpath(str(f))
for f in Path(folder_path).rglob("*.snirf")
if self.is_valid_snirf(str(f))
]
if not snirf_files:
QMessageBox.information(
self.app,
"No Valid Files",
"No valid .snirf files were found in the selected directory."
)
return
def load_dropped_files(self, file_paths: List[str]) -> None:
"""Public entry point for handling files or folders dropped onto the UI."""
if not file_paths:
return
# 1. Single file drop: Give explicit feedback like open_file_dialog
if len(file_paths) == 1:
norm_path = os.path.normpath(file_paths[0])
filename = os.path.basename(norm_path)
if filename.startswith("._"):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not an actual data file."
)
return
if not self.is_valid_snirf(norm_path):
QMessageBox.warning(
self.app,
"Invalid SNIRF File",
f"'{filename}' is not a valid SNIRF file or has a corrupted header."
)
return
valid_files = [norm_path]
# 2. Batch drop: Silently filter out shadow/corrupt files like open_folder_dialog
else:
valid_files = [
os.path.normpath(p)
for p in file_paths
if self.is_valid_snirf(os.path.normpath(p))
]
if not valid_files:
QMessageBox.information(
self.app,
"No Valid Files",
"None of the dropped items were valid .snirf files."
)
return
# Delegate to internal pipeline
self._load_files_into_pipeline(valid_files)
def _load_files_into_pipeline(self, file_paths: List[str]) -> None:
"""Loads .snirf files into UI using chunked batches and background workers."""
app = self.app
@@ -135,7 +231,6 @@ class ProjectManager:
# 2. Track this session to prevent ghost updates
if not hasattr(app, "loading_session_id"):
app.loading_session_id = 0
app.loading_session_id += 1
current_session = app.loading_session_id
# 3. Setup internal tracking if not exists
@@ -146,8 +241,11 @@ class ProjectManager:
if not hasattr(app, "metadata_cache"):
app.metadata_cache = {}
# Filter out files already in the UI to avoid duplicates
new_files = [p for p in file_paths if p not in app.selected_paths]
# Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files)
new_files = [
p for p in file_paths
if p not in app.selected_paths and self.is_valid_snirf(p)
]
if not new_files:
return
@@ -719,7 +817,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
return {}
def _row_to_dict(row: pd.Series) -> dict[str, str]:
result = {}
result: dict[str, str] = {}
for field in fields:
if field not in row:
continue
+6 -7
View File
@@ -1,7 +1,6 @@
src\analysis\participantfoldchannels.py 158
src\shared\flaresbasewidget.py 1001+
src\window\updateevents.py 83
flares.py 1001+
main_unit_tests.py 153
main.py 691
project_manager.py 113
src\analysis\participantfoldchannels.py 84
src\shared\flaresbasewidget.py 1155
flares.py 2900
main_unit_tests.py 152
main.py 732
project_manager.py 109
BIN
View File
Binary file not shown.
+28 -28
View File
@@ -8,29 +8,31 @@ License: GPL-3.0
# Built-in Imports
import os
from pathlib import Path
import time
import traceback
from pathlib import Path
from multiprocessing import Process, current_process, Manager
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
# External library imports
from matplotlib.backend_bases import Event
import numpy as np
from pandas import DataFrame
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
from matplotlib.figure import Figure
from matplotlib.backend_bases import Event
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from PySide6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QLabel, QLayout, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QWidget, QDialog, QVBoxLayout
from PySide6.QtCore import QThread, Qt, QSize, QTimer, QObject, Signal
from PySide6.QtGui import QCloseEvent, QMouseEvent, QPixmap, QImage
from pandas import DataFrame
from mne.io.base import BaseRaw
from src.shared.flaresbasewidget import FlaresBaseWidget
from src.shared.shareddata import APP_NAME, resource_path
from flares import fold_channels
class MultiProgressDialog(QDialog):
@@ -82,7 +84,6 @@ def single_participant_worker(
""" Runs inside its own dedicated process """
p_name = os.path.basename(file_path)
try:
from flares import fold_channels
# Perform the heavy fold_channels logic
channel_results = fold_channels(raw=raw_data, p_name=p_name, progress_queue=progress_queue)
@@ -284,7 +285,7 @@ class StaticChannelCanvas(FigureCanvas):
class StandaloneLegendDialog(QWidget):
def __init__(self, canvas_engine, title_prefix, parent=None):
def __init__(self, canvas_engine: Any, title_prefix: str, parent: QWidget | None = None) -> None:
super().__init__(None)
self.setWindowTitle("Full View - Brodmann Legend")
self.setMinimumSize(500, 600)
@@ -302,7 +303,14 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
"""The Big Grid Canvas.
Dynamically scales row and column configurations to maintain a crisp 16:9 layout orientation.
"""
def __init__(self, channels_data, color_map, is_fullscreen_copy=False, parent=None):
def __init__(
self,
channels_data: dict[str, list[dict[str, Any]]],
color_map: dict[str, str],
is_fullscreen_copy: bool = False,
parent: QWidget | None = None
) -> None:
self.channels_data = channels_data
self.color_map = color_map
self.is_fullscreen_copy = is_fullscreen_copy
@@ -494,7 +502,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
self._fullscreen_refs.append(fullscreen_window)
def _calculate_total_brodmann_profile(self, channels_data: Dict[str, Any]):
def _calculate_total_brodmann_profile(self, channels_data: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
"""Sums and normalizes the specificity profile across all channels."""
totals = {}
num_channels = len(channels_data)
@@ -503,7 +511,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
return []
# Sum up specificities across all channels
for channel_name, data_list in channels_data.items():
for _, data_list in channels_data.items():
for entry in data_list:
landmark = entry['Landmark']
specificity = entry['Specificity']
@@ -532,7 +540,7 @@ class InteractiveParticipantGridCanvas(FigureCanvas):
return normalized_data_list
def _open_expanded_view(self, channel_name, data_list):
def _open_expanded_view(self, channel_name: str, data_list: list[dict[str, Any]]) -> None:
# 1. Create a plain QWidget with NO parent (None)
# This instantly makes it a top-level desktop window
popup = QWidget(None)
@@ -746,11 +754,12 @@ class ProcessOrchestrator(QObject):
setup_finished = Signal(object, object, object, list)
setup_failed = Signal(str)
def __init__(self,
selected_files,
haemo_dict: dict[str, BaseRaw],
worker_func
):
def __init__(
self,
selected_files: list[str],
haemo_dict: dict[str, Any],
worker_func: Callable[..., Any]
) -> None:
super().__init__()
self.selected_files = selected_files
@@ -810,9 +819,6 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.index_texts = [
"0 (Fold Channels)",
# "1 (second image)",
# "2 (third image)",
# "3 (fourth image)",
]
self.image_index_dropdown = self._create_multiselect_dropdown(self.index_texts)
@@ -888,13 +894,8 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.multi_progress.add_participant(os.path.basename(file_path), total_channels)
from datetime import datetime
print(f"Before: {datetime.now()}")
self.multi_progress.show()
print(f"After 1: {datetime.now()}")
if current_process().name == 'MainProcess':
# Create a clean background thread worker execution channel
self.orchestrator_thread = QThread()
@@ -914,7 +915,6 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
self.orchestrator.setup_failed.connect(self.orchestrator.deleteLater)
self.orchestrator_thread.start()
print(f"After 4: {datetime.now()}")
def on_orchestration_success(
self,
@@ -1102,7 +1102,7 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
vbox = QVBoxLayout(container)
title = QLabel("<b>Brodmann Area Legend</b>")
title.setAlignment(Qt.AlignCenter)
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
vbox.addWidget(title)
pixmap = self._bytes_to_pixmap(legend_bytes)
@@ -1110,10 +1110,10 @@ class ParticipantFoldChannelsWidget(FlaresBaseWidget):
# Legends are usually tall, so we scale it differently or keep it smaller
legend_label.setPixmap(pixmap.scaled(
self.thumb_size,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
legend_label.setAlignment(Qt.AlignCenter)
legend_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
legend_label.mousePressEvent = lambda e, p=pixmap: self._open_full_size(p, "Brodmann Legend")
vbox.addWidget(legend_label)
+11 -6
View File
@@ -13,7 +13,7 @@ import sys
import platform
CURRENT_VERSION = "1.6.1"
CURRENT_VERSION = "1.7.2"
APP_NAME = "flares"
APP_NAME_EXPANDED = "fNIRS Lightweight Analysis, Research, & Evaluation Suite"
API_URL = f"https://git.research.dezeeuw.ca/api/v1/repos/tyler/{APP_NAME}/releases"
@@ -21,6 +21,7 @@ API_URL_SECONDARY = f"https://git.research2.dezeeuw.ca/api/v1/repos/tyler/{APP_N
PLATFORM_NAME = platform.system().lower()
CHANGELOG_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/raw/branch/main/changelog_major.md"
WIKI_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}/wiki"
PLUGINS_URL = f"https://git.research.dezeeuw.ca/tyler/{APP_NAME}-plugins/raw/branch/main/plugins.json"
PIPELINE_STAGES = [
@@ -72,9 +73,13 @@ DATA_SCHEMA = [
def resource_path(relative_path: str) -> str:
"""
Get absolute path to resource regardless of running directly or packaged using PyInstaller
"""
"""Get absolute path to resource regardless of running directly or packaged using PyInstaller"""
base_path = getattr(sys, "_MEIPASS", os.path.abspath("."))
return os.path.join(base_path, relative_path)
return os.path.join(base_path, relative_path)
def get_app_dir() -> str:
"""Returns the root directory of the application executable or working directory."""
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.getcwd()
+391
View File
@@ -0,0 +1,391 @@
"""
Filename: plugins.py
Description: Plugins window
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
"""
# Built-in imports
from pathlib import Path
from typing import Any, cast
# External library imports
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget
)
from plugin_manager import PluginManager, parse_version
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
class RemoteFetchWorker(QThread):
"""Background thread to fetch remote repository data without lagging the main UI."""
fetched = Signal(list, bool) # (plugins_data, all_failed)
def __init__(self, manager: PluginManager, urls: list[str]) -> None:
super().__init__()
self.manager = manager
self.urls = urls
def run(self) -> None:
plugins, all_failed = self.manager.fetch_remote_repositories(self.urls)
self.fetched.emit(plugins, all_failed)
class PluginsWindow(QWidget):
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
super().__init__(None, Qt.WindowType.Window)
self.main_app_window = parent
self.manager: PluginManager = plugin_manager
self.setWindowTitle(f"{APP_NAME.upper()} - Plugins")
self.resize(750, 500)
self.repository_urls: list[str] = [PLUGINS_URL]
self.remote_plugins_data: list[dict[str, Any]] = []
self._has_fetched_remote: bool = False
self._fetch_worker: RemoteFetchWorker | None = None
# Refresh UI automatically if manager updates state
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
main_layout = QVBoxLayout(self)
self.tab_widget = QTabWidget(self)
self.installed_tab = self._create_installed_tab()
self.browser_tab = self._create_browser_tab()
self.tab_widget.addTab(self.installed_tab, "Installed Plugins")
self.tab_widget.addTab(self.browser_tab, "Plugin Browser")
self.tab_widget.currentChanged.connect(self._on_tab_changed)
main_layout.addWidget(self.tab_widget)
self.setLayout(main_layout)
self.refresh_installed_plugins()
def refresh_installed_plugins(self) -> None:
"""Refreshes installed list using metadata from PluginManager."""
self.installed_list.clear()
plugins_info = self.manager.get_installed_plugins_info()
for info in plugins_info:
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
if info["is_disabled"]:
display_text += " [Disabled]"
item = QListWidgetItem(display_text, self.installed_list)
item.setData(Qt.ItemDataRole.UserRole, info)
if info["is_disabled"]:
item.setForeground(QColor("#757575"))
if self.installed_list.count() == 0:
self.installed_list.addItem("No plugins installed.")
self._clear_details_panel()
# Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins
if self._has_fetched_remote and self.remote_plugins_data:
self._populate_browser_list()
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
"""Updates the right-hand details panel when a plugin is selected."""
if not current:
self._clear_details_panel()
return
info = current.data(Qt.ItemDataRole.UserRole)
if not isinstance(info, dict):
self._clear_details_panel()
return
plugin_info = cast(dict[str, Any], info)
name = str(plugin_info.get("name", "Unknown"))
version = str(plugin_info.get("version", "1.0.0"))
author = str(plugin_info.get("author", "Unknown"))
desc = str(plugin_info.get("description", "No description provided."))
is_disabled = bool(plugin_info.get("is_disabled", False))
path = str(plugin_info.get("path", ""))
self.lbl_plugin_title.setText(name)
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} &nbsp;|&nbsp; <b>Author:</b> {author}")
if is_disabled:
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
else:
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
self.txt_plugin_desc.setHtml(desc)
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
def _clear_details_panel(self) -> None:
"""Clears details display when nothing is selected."""
self.lbl_plugin_title.setText("Select a plugin")
self.lbl_plugin_meta.setText("")
self.lbl_plugin_status.setText("")
self.txt_plugin_desc.clear()
self.lbl_plugin_path.setText("")
def toggle_enable_disable(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if isinstance(raw_info, dict):
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
if isinstance(plugin_path, str) and plugin_path:
self.manager.toggle_plugin_state(Path(plugin_path))
def uninstall_plugin(self) -> None:
selected = self.installed_list.currentItem()
if not selected:
return
raw_info = selected.data(Qt.ItemDataRole.UserRole)
if not isinstance(raw_info, dict):
return
info = cast(dict[str, Any], raw_info)
plugin_path = info.get("path")
plugin_name = info.get("name", "this plugin")
if not isinstance(plugin_path, str) or not plugin_path:
return
reply = QMessageBox.question(
self,
"Confirm Uninstall",
f"Are you sure you want to delete '{plugin_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self.manager.uninstall_plugin(Path(plugin_path))
def fetch_remote_plugins(self) -> None:
"""Asynchronously fetches remote plugins on a background thread."""
if self._fetch_worker is not None and self._fetch_worker.isRunning():
return
self.browser_list.clear()
self.browser_list.addItem("Fetching remote repositories...")
self.btn_install.setEnabled(False)
self.btn_fetch.setEnabled(False)
# Defer network call to background worker to avoid UI freeze
self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls)
self._fetch_worker.fetched.connect(self._on_remote_fetched)
self._fetch_worker.start()
def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None:
"""Callback executed on the main UI thread when remote fetching finishes."""
self.remote_plugins_data = plugins
self._has_fetched_remote = True
self.btn_fetch.setEnabled(True)
if plugins:
self._populate_browser_list()
elif all_failed:
self.browser_list.clear()
self.browser_list.addItem("Unable to load plugins from configured repositories.")
else:
self.browser_list.clear()
self.browser_list.addItem("No plugins found across configured repositories.")
def _populate_browser_list(self) -> None:
"""Populates the browser tab list and evaluates compatibility and installation state."""
self.browser_list.clear()
installed_info = self.manager.get_installed_plugins_info()
installed_ids = {p.get("id") for p in installed_info if p.get("id")}
installed_names = {p.get("name") for p in installed_info if p.get("name")}
for plugin in self.remote_plugins_data:
name = plugin.get("name", "Unknown")
p_id = plugin.get("id", "")
version = plugin.get("version", "v0.0")
desc = plugin.get("description", "")
platforms = str(plugin.get("platforms", []))
min_v_str = plugin.get("min_app_version", "0.0.0")
is_platform_ok = not platforms or self.manager.current_platform in platforms
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
is_compatible = is_platform_ok and is_version_ok
# Evaluate reasons for incompatibility
incompat_reasons: list[str] = []
if not is_platform_ok:
plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms)
incompat_reasons.append(f"Requires platform: {plat_str}")
if not is_version_ok:
incompat_reasons.append(f"Requires App v{min_v_str}+")
is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names)
display_text = f"{name} (v{version}) - {desc}"
if is_installed:
display_text += " [Installed]"
elif incompat_reasons:
reason_str = "; ".join(incompat_reasons)
display_text += f" [Incompatible: {reason_str}]"
item = QListWidgetItem(display_text, self.browser_list)
item.setData(Qt.ItemDataRole.UserRole, plugin)
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
item.setData(Qt.ItemDataRole.UserRole + 2, is_installed)
if is_installed:
item.setForeground(QColor("#2e7d32")) # Green for installed
elif not is_compatible:
item.setForeground(QColor("#d32f2f")) # Red for incompatible
def install_selected_plugin(self) -> None:
selected = self.browser_list.currentItem()
if not selected:
return
data = selected.data(Qt.ItemDataRole.UserRole)
if not data:
return
try:
target_path = self.manager.install_plugin_from_url(
data.get("download_url", ""),
data.get("id", "unnamed")
)
QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}")
except Exception as e:
QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}")
def _on_tab_changed(self, index: int) -> None:
if index == 1 and not self._has_fetched_remote:
self.fetch_remote_plugins()
def _on_browser_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
if current:
is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1))
is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2))
# Button lights up ONLY if plugin is compatible and NOT yet installed
self.btn_install.setEnabled(is_compatible and not is_installed)
else:
self.btn_install.setEnabled(False)
def _on_add_repo(self) -> None:
"""Adds a custom repository URL and re-fetches plugins."""
url = self.repo_input.text().strip()
if url and url not in self.repository_urls:
self.repository_urls.append(url)
self.repo_input.clear()
self.fetch_remote_plugins()
def _create_installed_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
path_layout = QHBoxLayout()
path_label = QLabel(f"<b>Plugins Directory:</b> <code>{self.manager.plugins_dir}</code>", tab)
btn_open_folder = QPushButton("Open Folder", tab)
btn_open_folder.setToolTip("Open this directory in Explorer / Finder")
btn_open_folder.clicked.connect(self.manager.open_plugins_directory)
path_layout.addWidget(path_label)
path_layout.addStretch()
path_layout.addWidget(btn_open_folder)
content_layout = QHBoxLayout()
self.installed_list = QListWidget(tab)
self.installed_list.currentItemChanged.connect(self._on_installed_item_changed)
self.details_panel = QWidget(tab)
details_layout = QVBoxLayout(self.details_panel)
details_layout.setContentsMargins(10, 0, 0, 0)
self.lbl_plugin_title = QLabel("Select a plugin", self.details_panel)
self.lbl_plugin_title.setStyleSheet("font-size: 13pt; font-weight: bold;")
self.lbl_plugin_meta = QLabel("", self.details_panel)
self.lbl_plugin_status = QLabel("", self.details_panel)
self.txt_plugin_desc = QTextBrowser(self.details_panel)
self.txt_plugin_desc.setPlaceholderText("Select an installed plugin to view details...")
self.lbl_plugin_path = QLabel("", self.details_panel)
self.lbl_plugin_path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.lbl_plugin_path.setWordWrap(True)
details_layout.addWidget(self.lbl_plugin_title)
details_layout.addWidget(self.lbl_plugin_meta)
details_layout.addWidget(self.lbl_plugin_status)
details_layout.addWidget(QLabel("<b>Description:</b>", self.details_panel))
details_layout.addWidget(self.txt_plugin_desc)
details_layout.addWidget(self.lbl_plugin_path)
content_layout.addWidget(self.installed_list, stretch=1)
content_layout.addWidget(self.details_panel, stretch=1)
btn_layout = QHBoxLayout()
btn_refresh = QPushButton("Refresh List", tab)
btn_enable = QPushButton("Enable / Disable", tab)
btn_uninstall = QPushButton("Uninstall", tab)
btn_refresh.clicked.connect(self.refresh_installed_plugins)
btn_enable.clicked.connect(self.toggle_enable_disable)
btn_uninstall.clicked.connect(self.uninstall_plugin)
btn_layout.addWidget(btn_refresh)
btn_layout.addStretch()
btn_layout.addWidget(btn_enable)
btn_layout.addWidget(btn_uninstall)
layout.addLayout(path_layout)
layout.addLayout(content_layout)
layout.addLayout(btn_layout)
return tab
def _create_browser_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
info = QLabel(
f"<b>Platform:</b> <code>{self.manager.current_platform}</code> | "
f"<b>App Version:</b> <code>v{CURRENT_VERSION}</code>", tab
)
repo_layout = QHBoxLayout()
self.repo_input = QLineEdit(tab)
self.repo_input.setPlaceholderText("Enter custom plugins.json URL...")
btn_add = QPushButton("Add Repo", tab)
btn_add.clicked.connect(self._on_add_repo)
repo_layout.addWidget(self.repo_input)
repo_layout.addWidget(btn_add)
self.browser_list = QListWidget(tab)
self.browser_list.currentItemChanged.connect(self._on_browser_item_changed)
btn_layout = QHBoxLayout()
self.btn_fetch = QPushButton("Fetch Remote Lists", tab)
self.btn_install = QPushButton("Install Plugin", tab)
self.btn_install.setEnabled(False)
self.btn_fetch.clicked.connect(self.fetch_remote_plugins)
self.btn_install.clicked.connect(self.install_selected_plugin)
btn_layout.addWidget(self.btn_fetch)
btn_layout.addStretch()
btn_layout.addWidget(self.btn_install)
layout.addWidget(info)
layout.addLayout(repo_layout)
layout.addWidget(self.browser_list)
layout.addLayout(btn_layout)
return tab
+9 -1
View File
@@ -17,8 +17,10 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QLineEdit, QMainW
from PySide6.QtCore import QProcess, Qt, QThread, Signal
from file_ext_registration import register_file_association, is_windows_admin
from plugin_manager import PluginManager
from src.shared.shareddata import API_URL, API_URL_SECONDARY, APP_NAME, CURRENT_VERSION, PLATFORM_NAME
from src.window.about import AboutWindow
from src.window.plugins import PluginsWindow
from updater import UpdateManager
@@ -42,7 +44,7 @@ class _AssocWorker(QThread):
class TerminalWindow(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle(f"Terminal - {APP_NAME.upper()}")
self.resize(320, 180)
@@ -58,6 +60,7 @@ class TerminalWindow(QWidget):
self.setLayout(layout)
self._process: QProcess | None = None
self.plugin_manager = plugin_manager
self.commands: dict[str, Callable[..., Any]] = {
"hello": self.cmd_hello,
@@ -66,6 +69,7 @@ class TerminalWindow(QWidget):
"about": self.cmd_about,
"assoc": self.cmd_assoc,
"update": self.cmd_update,
"plugins": self.cmd_plugins,
"utest": self.cmd_utest,
}
@@ -122,6 +126,10 @@ class TerminalWindow(QWidget):
self.about = AboutWindow(self)
self.about.show()
def cmd_plugins(self, *args: Any) -> None:
self.about = PluginsWindow(self, self.plugin_manager)
self.about.show()
def cmd_update(self, *args: Any) -> str:
main_win = self.parent()
if not isinstance(main_win, QMainWindow):
+201 -170
View File
@@ -1,6 +1,7 @@
"""
Filename: updateevents.py
Description: Methods to update snirf events for FLARES
Description: Methods to update snirf events
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
License: GPL-3.0
@@ -9,27 +10,184 @@ License: GPL-3.0
# Built-in imports
import os
import json
import concurrent.futures
from enum import Enum, auto
from typing import Any, List, Optional, cast
from typing import Any, List, Optional, Sequence, Union, cast
# External library imports
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QHBoxLayout, QMessageBox, QFileDialog
from PySide6.QtCore import Qt
from mne import Annotations
from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf #type: ignore
from mne.io import read_raw_snirf #type: ignore
from mne_nirs.io import write_raw_snirf #type: ignore
from mne.io.base import BaseRaw
from src.shared.shareddata import APP_NAME
def _load_annotations_worker(file_path: str) -> Annotations:
"""Isolated worker process to load SNIRF annotations without leaving HDF5 memory leaks in main process."""
raw: BaseRaw = read_raw_snirf(file_path, preload=False, verbose=False)
return cast(Annotations, getattr(raw, "annotations"))
def load_snirf_annotations_isolated(file_path: str) -> Annotations:
"""Spawns a short-lived process to extract annotations and cleanly releases C-memory allocations."""
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(_load_annotations_worker, file_path)
return future.result()
def _write_snirf_worker(
file_path: str,
save_path: str,
onsets: list[float],
durations: list[float],
descriptions: list[str]
) -> None:
"""Isolated process worker to read, annotate, write, and immediately free memory/file handles."""
raw = read_raw_snirf(file_path, preload=True, verbose=False)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations) #type: ignore
write_raw_snirf(raw, save_path)
def write_snirf_with_annotations_isolated(
file_path: str,
save_path: str,
onsets: list[float],
durations: list[float],
descriptions: list[str]
) -> None:
"""Executes SNIRF writing in a dedicated worker process to ensure 100% memory/handle cleanup."""
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(
_write_snirf_worker,
file_path,
save_path,
onsets,
durations,
descriptions
)
future.result()
def _align_boris_and_write_snirf_worker(
file_path: str,
save_path: str,
boris_events: list[Any],
time_shift: float
) -> int:
"""
Isolated process worker to read SNIRF, align BORIS events, write updated file,
and cleanly exit releasing all HDF5 C-memory allocations and file locks.
"""
raw = read_raw_snirf(file_path, preload=True, verbose=False)
# Type-safe extraction of sampling frequency from raw.info
info_dict = cast(dict[str, Any], raw.info)
sfreq = float(info_dict.get("sfreq", 10.0))
min_shift = 1.0 / sfreq if sfreq > 0 else 0.1
max_attempts = 10
onsets: list[float] = []
durations: list[float] = []
descriptions: list[str] = []
open_events: dict[str, list[float]] = {}
label_counts: dict[str, int] = {}
used_times: set[float] = set()
for raw_event in boris_events:
if not isinstance(raw_event, (list, tuple)):
continue
event = cast(Sequence[Any], raw_event)
if len(event) < 3:
continue
event_time = float(event[0])
label = str(event[2])
count = label_counts.get(label, 0) + 1
label_counts[label] = count
if label not in open_events:
open_events[label] = []
if count % 2 == 1:
open_events[label].append(event_time)
else:
if open_events[label]:
start_time = open_events[label].pop(0)
duration = event_time - start_time
if duration <= 0:
continue
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(round(duration, 6))
descriptions.append(label)
# Handle unmatched start markers
for label, starts in open_events.items():
for start_time in starts:
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(0.0)
descriptions.append(label)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations) #type: ignore
write_raw_snirf(raw, save_path)
return len(onsets)
def align_boris_and_write_snirf_isolated(
file_path: str,
save_path: str,
boris_events: list[Any],
time_shift: float
) -> int:
"""Executes BORIS alignment and SNIRF writing in a dedicated worker process."""
with concurrent.futures.ProcessPoolExecutor(max_workers=1) as executor:
future = executor.submit(
_align_boris_and_write_snirf_worker,
file_path,
save_path,
boris_events,
time_shift
)
return future.result()
class EventUpdateMode(Enum):
WRITE_SNIRF = auto() # destructive
WRITE_JSON = auto() # non-destructive
class UpdateEventsWindow(QWidget):
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
@@ -210,12 +368,13 @@ class UpdateEventsWindow(QWidget):
if file_path:
self.line_edit_file_a.setText(file_path)
try:
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
raw = read_raw_snirf(file_path, preload=False)
annotations = raw.annotations
# Memory leak safe: Extracted in a separate process that terminates immediately
annotations: Annotations = load_snirf_annotations_isolated(file_path)
print(f"Loaded {len(annotations)} annotations from {file_path}")
# Build individual event entries
event_entries = []
event_entries: list[str] = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
@@ -251,6 +410,7 @@ class UpdateEventsWindow(QWidget):
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
QMessageBox.warning(self, "Error", f"Failed to parse BORIS file:\n{e}")
def extract_boris_observation_keys(self, data: dict[str, Any]) -> List[str]:
if "observations" not in data:
@@ -274,11 +434,16 @@ class UpdateEventsWindow(QWidget):
self.combo_events.setEnabled(False)
return
event_entries = []
for event in events:
if isinstance(event, list) and len(event) >= 3:
timestamp = event[0]
label = event[2]
event_entries: list[str] = []
for raw_event in events:
if not isinstance(raw_event, (list, tuple)):
continue
event = cast(Sequence[Any], raw_event)
if len(event) >= 3:
timestamp = float(event[0])
label = str(event[2])
display = f"{label} @ {timestamp:.3f}"
event_entries.append(display)
@@ -363,71 +528,15 @@ class UpdateEventsWindow(QWidget):
save_path += ".snirf"
try:
raw = read_raw_snirf(file_a, preload=True)
# Memory leak safe: Worker handles alignment calculation, read, write, and process termination
event_count = align_boris_and_write_snirf_isolated(
file_a,
save_path,
boris_events,
time_shift
)
# --- Align BORIS events to SNIRF ---
boris_events = boris_obs.get("events", [])
onsets, durations, descriptions = [], [], []
open_events = {} # label -> list of start times
label_counts = {}
used_times = set()
sfreq = raw.info['sfreq']
min_shift = 1.0 / sfreq
max_attempts = 10
for event in boris_events:
if not isinstance(event, list) or len(event) < 3:
continue
event_time = event[0]
label = event[2]
count = label_counts.get(label, 0) + 1
label_counts[label] = count
if label not in open_events:
open_events[label] = []
if count % 2 == 1:
open_events[label].append(event_time)
else:
if open_events[label]:
start_time = open_events[label].pop(0)
duration = event_time - start_time
if duration <= 0:
continue
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(duration)
descriptions.append(label)
# Handle unmatched starts
for label, starts in open_events.items():
for start_time in starts:
adjusted_time = start_time + time_shift
attempts = 0
while round(adjusted_time, 6) in used_times and attempts < max_attempts:
adjusted_time += min_shift
attempts += 1
if attempts == max_attempts:
continue
adjusted_time = round(adjusted_time, 6)
used_times.add(adjusted_time)
onsets.append(adjusted_time)
durations.append(0.0)
descriptions.append(label)
new_annotations = Annotations(onset=onsets, duration=durations, description=descriptions)
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
QMessageBox.information(self, "Success", "SNIRF file updated with aligned BORIS events.")
QMessageBox.information(self, "Success", f"SNIRF file updated with {event_count} aligned BORIS events.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to update SNIRF file:\n{e}")
@@ -466,79 +575,9 @@ class UpdateEventsWindow(QWidget):
QMessageBox.critical(self, "Error", f"Failed to write JSON:\n{e}")
# def update_optode_positions(self, file_a, file_b, save_path):
# fiducials = {}
# ch_positions = {}
# # Read the lines from the optode file
# with open(file_b, 'r') as f:
# for line in f:
# if line.strip():
# # Split by the semicolon and convert to meters
# ch_name, coords_str = line.split(":")
# coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001
# # The key we have is a fiducial
# if ch_name.lower() in ['lpa', 'nz', 'rpa']:
# fiducials[ch_name.lower()] = coords
# # The key we have is a source or detector
# else:
# ch_positions[ch_name.upper()] = coords
# # Create montage with updated coords in head space
# initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore
# # Read the SNIRF file, set the montage, and write it back
# # TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
# raw = read_raw_snirf(file_a, preload=True)
# raw.set_montage(initial_montage)
# write_raw_snirf(raw, save_path)
# def _apply_events_to_snirf(self, raw, new_annotations, save_path):
# raw.set_annotations(new_annotations)
# write_raw_snirf(raw, save_path)
# def _write_event_mapping_json(
# self,
# file_a,
# file_b,
# selected_obs,
# snirf_anchor,
# boris_anchor,
# time_shift,
# mapped_events,
# save_path
# ):
# payload = {
# "source": {
# "called_from": self.caller,
# "snirf_file": os.path.basename(file_a),
# "boris_file": os.path.basename(file_b),
# "observation": selected_obs
# },
# "alignment": {
# "snirf_anchor": snirf_anchor,
# "boris_anchor": boris_anchor,
# "time_shift_seconds": time_shift
# },
# "events": mapped_events,
# "created_at": datetime.utcnow().isoformat() + "Z"
# }
# with open(save_path, "w", encoding="utf-8") as f:
# json.dump(payload, f, indent=2)
# return save_path
class UpdateEventsBlazesWindow(QWidget):
def __init__(self, parent: Optional[QWidget]=None, mode=EventUpdateMode.WRITE_SNIRF, caller=None):
def __init__(self, parent: Optional[QWidget]=None, mode: EventUpdateMode=EventUpdateMode.WRITE_SNIRF, caller: Optional[Union[str, object]] = None,):
super().__init__(parent, Qt.WindowType.Window)
self.mode = mode
@@ -692,12 +731,13 @@ class UpdateEventsBlazesWindow(QWidget):
if file_path:
self.line_edit_file_a.setText(file_path)
try:
# TODO: Bad! read_raw_snirf doesnt release memory properly! Should be spawned in a seperate process and killed once completed
raw = read_raw_snirf(file_path, preload=False)
annotations = raw.annotations
# Memory leak safe: Extracted in a separate process that terminates immediately
annotations: Annotations = load_snirf_annotations_isolated(file_path)
print(f"Loaded {len(annotations)} annotations from {file_path}")
# Build individual event entries
event_entries = []
event_entries: list[str] = []
for onset, description in zip(annotations.onset, annotations.description):
event_str = f"{description} @ {onset:.3f}s"
event_entries.append(event_str)
@@ -748,12 +788,11 @@ class UpdateEventsBlazesWindow(QWidget):
event_strings: List[str] = []
# The new format is a flat list chronologically ordered
# Flat list chronologically ordered
for event in data["events"]:
track_name = event.get("track_name", "Unknown")
onset = event.get("start_sec", 0.0)
# Formatting to match your SNIRF style: "Event Name @ 0.000s"
display_str = f"{track_name} @ {onset:.3f}s"
event_strings.append(display_str)
@@ -792,7 +831,9 @@ class UpdateEventsBlazesWindow(QWidget):
time_shift = snirf_anchor_time - json_anchor_time
onsets, durations, descriptions = [], [], []
onsets: list[float] = []
durations: list[float] = []
descriptions: list[str] = []
skipped_count = 0
try:
@@ -806,7 +847,7 @@ class UpdateEventsBlazesWindow(QWidget):
original_end = event.get("end_sec", original_start)
duration = original_end - original_start
# FILTER: Minimum 0.1s duration
# Minimum 0.1s duration
if duration < 0.1:
skipped_count += 1
continue
@@ -834,19 +875,9 @@ class UpdateEventsBlazesWindow(QWidget):
if not save_path.lower().endswith(".snirf"): save_path += ".snirf"
try:
raw = read_raw_snirf(file_a, preload=True)
# Create annotations
new_annotations = Annotations(
onset=onsets,
duration=durations,
description=descriptions
)
# Replace existing annotations with the new aligned JSON tracks
raw.set_annotations(new_annotations)
write_raw_snirf(raw, save_path)
# Memory leak safe: Worker handles read, write, and complete memory reclamation upon exit
write_snirf_with_annotations_isolated(file_a, save_path, onsets, durations, descriptions)
QMessageBox.information(self, "Success",
f"Aligned {len(onsets)} events.\n(Filtered out {skipped_count} short events)")
except Exception as e:
+1 -2
View File
@@ -1,6 +1,6 @@
"""
Filename: updateoptodes.py
Description: Methods to update optode locations for FLARES
Description: Methods to update optode locations
Note: Compliant with pylance strict type checking
Author: Tyler de Zeeuw
@@ -8,7 +8,6 @@ License: GPL-3.0
"""
# Built-in imports
import os
from pathlib import Path
from typing import Dict, Optional, Union
+143 -135
View File
@@ -18,7 +18,8 @@ import zipfile
import traceback
import subprocess
import configparser
from typing import List
from typing import List, Tuple, Optional, Callable, Any
# External library imports
import psutil
@@ -26,7 +27,7 @@ import requests
from PySide6.QtCore import QThread, Signal, QObject
from PySide6.QtWidgets import QMainWindow, QMessageBox
from src.shared.shareddata import get_app_dir
class UpdateDownloadThread(QThread):
"""
@@ -54,7 +55,7 @@ class UpdateDownloadThread(QThread):
self.platform_name = platform_name
self.app_name = app_name
def run(self):
def run(self) -> None:
try:
local_filename = os.path.basename(self.download_url)
@@ -63,8 +64,8 @@ class UpdateDownloadThread(QThread):
os.makedirs(tmp_dir, exist_ok=True)
local_path = os.path.join(tmp_dir, local_filename)
else:
tmp_dir = os.getcwd()
local_path = os.path.join(os.getcwd(), local_filename)
tmp_dir = get_app_dir()
local_path = os.path.join(tmp_dir, local_filename)
# Download the file
with requests.get(self.download_url, stream=True, timeout=15) as r:
@@ -78,10 +79,9 @@ class UpdateDownloadThread(QThread):
if self.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)
extract_path = os.path.join(get_app_dir(), extract_folder)
# Create the folder if not exists
os.makedirs(extract_path, exist_ok=True)
@@ -132,10 +132,7 @@ class UpdateCheckThread(QThread):
self.platform_name = platform_name
self.app_name = app_name
def run(self):
# if not getattr(sys, 'frozen', False):
# self.error_occurred.emit("Application is not frozen (Development mode).")
# return
def run(self) -> None:
try:
latest_version, download_url = self.get_latest_release_for_platform()
if not latest_version:
@@ -159,11 +156,10 @@ class UpdateCheckThread(QThread):
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def get_latest_release_for_platform(self):
def get_latest_release_for_platform(self) -> Tuple[Optional[str], Optional[str]]:
urls = [self.api_url, self.api_url_sec]
for url in urls:
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
releases = response.json()
@@ -219,11 +215,11 @@ class LocalPendingUpdateCheckThread(QThread):
return [int(x) for x in v.split(".")]
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
def run(self):
def run(self) -> None:
if self.platform_name == 'darwin':
cwd = f'/tmp/{self.app_name}tempupdate'
else:
cwd = os.getcwd()
cwd = get_app_dir()
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
found = False
@@ -239,7 +235,7 @@ class LocalPendingUpdateCheckThread(QThread):
self.pending_update_found.emit(folder_version, folder_path)
found = True
break
except:
except Exception:
pass
if not found:
@@ -272,9 +268,8 @@ class UpdateManager(QObject):
self.platform_suffix = platform_suffix
self.app_name = app_name
self.pending_update_version = None
self.pending_update_path = None
self.pending_update_version: Optional[str] = None
self.pending_update_path: Optional[str] = None
def manual_check_for_updates(self) -> None:
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
@@ -283,14 +278,15 @@ class UpdateManager(QObject):
self.local_check_thread.start()
def on_pending_update_found(self, version: str, folder_path: str) -> None:
self.main_window.statusBar().showMessage(f"Pending update found: version {version}")
if self.main_window.statusBar():
self.main_window.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) -> None:
# No pending update found locally, start server check directly
self.main_window.statusBar().showMessage("No pending local update found. Checking server...")
if self.main_window.statusBar():
self.main_window.statusBar().showMessage("No pending local update found. Checking server...")
self.start_update_check_thread()
def show_pending_update_popup(self) -> None:
@@ -306,7 +302,6 @@ class UpdateManager(QObject):
else:
if self.main_window.statusBar():
self.main_window.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) -> None:
@@ -327,13 +322,15 @@ class UpdateManager(QObject):
if pending_version and pending_path:
cmp = self.version_compare(latest_version, pending_version)
if cmp > 0:
# Server version is newer than pending update
self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
if self.main_window.statusBar():
self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
try:
shutil.rmtree(pending_path)
self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}")
if self.main_window.statusBar():
self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}")
except Exception as e:
self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
if self.main_window.statusBar():
self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}")
# Clear pending update info so new download proceeds
self.pending_update_version = None
@@ -342,8 +339,8 @@ class UpdateManager(QObject):
# Download the new update
self.download_update(download_url, latest_version)
elif cmp == 0:
# Versions equal, no download needed
self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.")
if self.main_window.statusBar():
self.main_window.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.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
@@ -374,24 +371,26 @@ class UpdateManager(QObject):
if msg_box.clickedButton() == install_now_button:
self.install_update(extract_folder)
else:
self.main_window.statusBar().showMessage("Update ready. Install later.")
if self.main_window.statusBar():
self.main_window.statusBar().showMessage("Update ready. Install later.")
def install_update(self, extract_folder: str) -> None:
# Path to updater executable
base_dir = get_app_dir()
# Path to updater executable
if self.platform_name == 'windows':
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater.exe")
updater_path = os.path.join(base_dir, f"{self.app_name}_updater.exe")
elif self.platform_name == 'darwin':
if getattr(sys, 'frozen', False):
updater_path = os.path.join(os.path.dirname(sys.executable), f"../../../{self.app_name}_updater.app")
updater_path = os.path.join(base_dir, f"../../../{self.app_name}_updater.app")
else:
updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app")
updater_path = os.path.join(base_dir, f"../{self.app_name}_updater.app")
elif self.platform_name == 'linux':
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater")
updater_path = os.path.join(base_dir, f"{self.app_name}_updater")
else:
updater_path = os.getcwd()
updater_path = base_dir
updater_path = os.path.abspath(updater_path)
if not os.path.exists(updater_path):
QMessageBox.critical(self.main_window, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
@@ -399,8 +398,7 @@ class UpdateManager(QObject):
# 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])
main_app_executable = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(sys.argv[0])
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
@@ -457,121 +455,131 @@ def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool:
return False
def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None:
"""Error handler for shutil.rmtree to clear read-only files on Windows."""
import stat
os.chmod(path, stat.S_IWRITE)
func(path)
def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None:
"""
Completes a pending application update if '--finish-update' is present in the command-line arguments.
"""
if finish_update:
print("Finishing update...")
update_cfg = configparser.ConfigParser()
try:
update_cfg.read(cfg_path)
if not finish_update:
return
print("Finishing update...")
# 1. Reset welcome dialog config flag
update_cfg = configparser.ConfigParser()
try:
if os.path.exists(cfg_path):
update_cfg.read(cfg_path)
if not update_cfg.has_section("Options"):
update_cfg.add_section("Options")
update_cfg.set("Options", "show_welcome_dialog", "true")
with open(cfg_path, "w") as f:
update_cfg.write(f)
print("Welcome dialog flag successfully reset to 'true' for next run.")
except Exception as e:
print(f"Warning: Could not update welcome dialog preference flag: {e}")
except Exception as e:
print(f"Warning: Could not update welcome dialog preference flag: {e}")
if platform_name == 'darwin':
app_dir = f'/tmp/{app_name}tempupdate'
else:
app_dir = os.getcwd()
app_dir = f'/tmp/{app_name}tempupdate' if platform_name == 'darwin' else get_app_dir()
# 1. Find update folder
update_folder = None
if not os.path.exists(app_dir):
print(f"App directory does not exist: {app_dir}. Skipping cleanup.")
return
# 2. Find update folder(s)
update_folders: List[str] = []
try:
for entry in os.listdir(app_dir):
entry_path = os.path.join(app_dir, entry)
if os.path.isdir(entry_path) and entry.startswith(f"{app_name}-") and entry.endswith("-" + platform_name):
update_folder = os.path.join(app_dir, entry)
break
update_folders.append(entry_path)
except Exception as e:
print(f"Error scanning app directory for update folders: {e}")
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, f"{app_name}-darwin")
if not update_folders:
print("No update folder found. Skipping update steps.")
return
# 2. Wait for updater to exit
print(f"Waiting for {app_name}_updater to exit...")
for proc in psutil.process_iter(['pid', 'name']):
if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower():
try:
proc.wait(timeout=5)
except psutil.TimeoutExpired:
print(f"Force killing lingering {app_name}_updater")
proc.kill()
primary_update_folder = update_folders[0]
target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder
# 3. Replace the updater
if platform_name == 'windows':
new_updater = os.path.join(update_folder, f"{app_name}_updater.exe")
dest_updater = os.path.join(app_dir, f"{app_name}_updater.exe")
elif platform_name == 'darwin':
new_updater = os.path.join(update_folder, f"{app_name}_updater.app")
dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app"))
elif platform_name == 'linux':
new_updater = os.path.join(update_folder, f"{app_name}_updater")
dest_updater = os.path.join(app_dir, f"{app_name}_updater")
else:
print("Unknown 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(f"{app_name}_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, app_name)
print(f"{app_name}_updater replaced.")
except Exception as e:
print(f"Failed to replace {app_name}_updater: {e}")
# 4. Delete the update folder
# 3. Wait for updater process to exit
print(f"Waiting for {app_name}_updater to exit...")
updater_bin_name = f"{app_name}_updater"
for proc in psutil.process_iter(['pid', 'name']):
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}")
if proc.info['name'] and updater_bin_name.lower() in proc.info['name'].lower():
proc.wait(timeout=5)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
except psutil.TimeoutExpired:
print(f"Force killing lingering {app_name}_updater")
try:
proc.kill()
except Exception:
pass
# 4. Replace the updater executable
target_base = get_app_dir()
if platform_name == 'windows':
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.exe")
dest_updater = os.path.join(target_base, f"{app_name}_updater.exe")
elif platform_name == 'darwin':
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.app")
dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app"))
elif platform_name == 'linux':
new_updater = os.path.join(target_updater_folder, f"{app_name}_updater")
dest_updater = os.path.join(target_base, f"{app_name}_updater")
else:
new_updater = target_base
dest_updater = target_base
if os.path.exists(new_updater):
try:
if os.path.exists(dest_updater):
if platform_name == 'darwin' and os.path.isdir(dest_updater):
shutil.rmtree(dest_updater, onexc=_remove_readonly)
else:
os.remove(dest_updater)
if platform_name == 'darwin':
wait_for_process_to_exit(f"{app_name}_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, app_name)
print(f"{app_name}_updater replaced successfully.")
except Exception as e:
print(f"Failed to replace {app_name}_updater: {e}")
# 5. Clean up all temporary update folders (with retries for Windows file locks)
for folder in update_folders:
for attempt in range(3):
try:
if os.path.exists(folder):
shutil.rmtree(folder, onexc=_remove_readonly)
print(f"Successfully deleted update folder: {folder}")
break
except Exception as e:
if attempt < 2:
time.sleep(1.0)
else:
print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}")
if "--finish-update" in sys.argv:
sys.argv.remove("--finish-update")