Compare commits
26 Commits
45c6176dba
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b5c31e4ef | |||
| 276e1b1876 | |||
| 3cfc6f01a2 | |||
| 91fb9b38d4 | |||
| 1e346f8ef2 | |||
| 8c207b17ad | |||
| f794b98d18 | |||
| 06972bee28 | |||
| c279f269da | |||
| c7d044beed | |||
| 76df19f332 | |||
| 22695a2281 | |||
| f1dd9bd184 | |||
| dd2ac058af | |||
| 98c749477c | |||
| 92973da658 | |||
| f82978e2e8 | |||
| 7007478c3b | |||
| fb728d5033 | |||
| 1b78f1904d | |||
| 9779a63a9c | |||
| 2ecd357aca | |||
| fe4e8904b4 | |||
| 473c945563 | |||
| 64ed6d2e87 | |||
| 1aa2402d09 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -174,3 +174,8 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
/individual_images
|
||||||
|
*.xlsx
|
||||||
|
*.csv
|
||||||
|
*.snirf
|
||||||
|
*.json
|
||||||
@@ -41,4 +41,4 @@ There are no conditions for Linux users at this time.
|
|||||||
|
|
||||||
FLARES is distributed under the GPL-3.0 license.
|
FLARES is distributed under the GPL-3.0 license.
|
||||||
|
|
||||||
Copyright (C) 2025 Tyler de Zeeuw
|
Copyright (C) 2025-2026 Tyler de Zeeuw
|
||||||
83
changelog.md
83
changelog.md
@@ -1,7 +1,86 @@
|
|||||||
|
# Version 1.2.2
|
||||||
|
|
||||||
|
- Added 'Update events in snirf file (BLAZES)...' and renamed 'Update events in snirf file...' to 'Update events in snirf file (BORIS)...'
|
||||||
|
- The BLAZES option will assign events that are exported directly from the software [BLAZES](https://git.research.dezeeuw.ca/tyler/blazes)
|
||||||
|
- Moved the updating logic to a seperate file for better reusability and generalization
|
||||||
|
- Fixed 'Toggle Status Bar' having no effect on the visibility of the status bar
|
||||||
|
- Fixed a bug when updating optode positions that would prevent .txt files from being selected. Fixes [Issue 54](https://git.research.dezeeuw.ca/tyler/flares/issues/54)
|
||||||
|
- Fixed a missing dependency in the standalone application when attempting to use an .xlsx file to update optode positions
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.2.1
|
||||||
|
|
||||||
|
- Added a requirements.txt file to ensure compatibility
|
||||||
|
- Added new options 'Missing Events Bypass' and 'Analysis Clearing Bypass' to the Preferences Menu
|
||||||
|
- Missing Events Bypass allows comparing events in the Group Viewers even if not all participants in the group have the event present. Fixes [Issue 28](https://git.research.dezeeuw.ca/tyler/flares/issues/28)
|
||||||
|
- Clicking Process after an analysis has been performed will now clear the existing analysis by default with a popup warning that the analysis will be cleared
|
||||||
|
- Analysis Clearing Bypass will prevent the popup and will not clear the existing analysis data. Fixes [Issue 41](https://git.research.dezeeuw.ca/tyler/flares/issues/41)
|
||||||
|
- Clicking 'Clear' should now actually properly clear all data. Hopefully fixes [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9) for good
|
||||||
|
- Setting SHORT_CHANNEL to False will now grey out SHORT_CHANNEL_REGRESSION, as it is impossible to regress what does not exist. Sets SHORT_CHANNEL_REGRESSION to False under the hood when it is greyed out regardless of what is displayed. Fixes [Issue 47](https://git.research.dezeeuw.ca/tyler/flares/issues/47)
|
||||||
|
- Projects can now be saves if files have different parent folders. Fixes [Issue 48](https://git.research.dezeeuw.ca/tyler/flares/issues/48)
|
||||||
|
- It is no longer possible to attempt a save before any data has been processed. A popup will now display if a save is attempted with nothing to save
|
||||||
|
- Fixed a bug where LONG_CHANNEL_THRESH was not being applied in the processing steps
|
||||||
|
- Added a new option in the Analysis window for Group Functional Connectivity. Implements [Issue 50](https://git.research.dezeeuw.ca/tyler/flares/issues/50)
|
||||||
|
- Group Functional connectivity is still in development and the results should currently be taken with a grain of salt
|
||||||
|
- A warning is displayed when entering the Group Functional Connectivity Viewer disclosing this
|
||||||
|
- Fixed a bug when updating optode positions that would prevent .txt files from being selected. Fixes [Issue 54](https://git.research.dezeeuw.ca/tyler/flares/issues/54)
|
||||||
|
- Fixed a bug where the secondary download server would never get contacted if the primary failed
|
||||||
|
- Automatic downloads will now ignore prerelease versions. Fixes [Issue 52](https://git.research.dezeeuw.ca/tyler/flares/issues/52)
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.2.0
|
||||||
|
|
||||||
|
- This is a save-breaking release due to a new save file format. Please update your project files to ensure compatibility. Fixes [Issue 30](https://git.research.dezeeuw.ca/tyler/flares/issues/30)
|
||||||
|
- Added new parameters to the right side of the screen
|
||||||
|
- These parameters include SHOW_OPTODE_NAMES, SECONDS_TO_STRIP_HR, MAX_LOW_HR, MAX_HIGH_HR, SMOOTHING_WINDOW_HR, HEART_RATE_WINDOW, BAD_CHANNELS_HANDLING, MAX_DIST, MIN_NEIGHBORS, L_TRANS_BANDWIDTH, H_TRANS_BANDWIDTH, RESAMPLE, RESAMPLE_FREQ, STIM_DUR, HRF_MODEL, HIGH_PASS, DRIFT_ORDER, FIR_DELAYS, MIN_ONSET, OVERSAMPLING, SHORT_CHANNEL_REGRESSION, NOISE_MODEL, BINS, and VERBOSITY.
|
||||||
|
- Certain parameters now have dependencies on other parameters and will now grey out if they are not used
|
||||||
|
- All the new parameters have default values matching the underlying values in version 1.1.7
|
||||||
|
- The order of the parameters have changed to match the order that the code runs when the Process button is clicked
|
||||||
|
- Moved TIME_WINDOW_START and TIME_WINDOW_END to the 'Other' category
|
||||||
|
- Fixed a bug causing SCI to not work when HEART_RATE was set to False
|
||||||
|
- Bad channels can now be dealt with by taking no action, removing them completely, or interpolating them based on their neighbours. Interpolation remains the default option
|
||||||
|
- Fixed an underlying deprecation warning
|
||||||
|
- Fixed an issue causing some overlay elements to not render on the brain for certain devices
|
||||||
|
- Fixed a crash when rendering some Inter-Group images with only one participant in a group
|
||||||
|
- Fixed a crash when attempting to fOLD channels without the fOLD dataset installed
|
||||||
|
- Lowered the number of rectangles in the progress bar to 24 after combining some actions
|
||||||
|
- Fixed the User Guide window to properly display information about the 24 stages and added a link to the Git wiki page
|
||||||
|
- MAX_WORKERS should now properly repect the value set
|
||||||
|
- Added a new CSV export option to be used by other applications
|
||||||
|
- Added support for updating optode positions directly from an .xlsx file from a Polhemius system
|
||||||
|
- Fixed an issue where the dropdowns in the Viewer windows would immediately open and close when using a trackpad
|
||||||
|
- glover and spm hrf models now function as intended without crashing. Currently, group analysis is still only supported by fir. Fixes [Issue 8](https://git.research.dezeeuw.ca/tyler/flares/issues/8)
|
||||||
|
- Clicking 'Clear' should now properly clear all data. Fixes [Issue 9](https://git.research.dezeeuw.ca/tyler/flares/issues/9)
|
||||||
|
- Revamped the fold channels viewer to not hang the application and to better process multiple participants at once. Fixes [Issue 34](https://git.research.dezeeuw.ca/tyler/flares/issues/34), [Issue 31](https://git.research.dezeeuw.ca/tyler/flares/issues/31)
|
||||||
|
- Added a Preferences menu to the navigation bar
|
||||||
|
- Two preferences have been added allowing to bypass the warning of 2D data detected and save files being from previous, potentially breaking versions
|
||||||
|
- Fixed a typo when saving a CSV that stated a SNIRF was being saved
|
||||||
|
- Loading a save file now properly restores AGE, GENDER, and GROUP. Fixes [Issue 40](https://git.research.dezeeuw.ca/tyler/flares/issues/40)
|
||||||
|
- Saving a project now no longer makes the main window go not responding. Fixes [Issue 43](https://git.research.dezeeuw.ca/tyler/flares/issues/43)
|
||||||
|
- Memory usage should no longer grow when generating lots of images multiple times. Fixes [Issue 36](https://git.research.dezeeuw.ca/tyler/flares/issues/36)
|
||||||
|
- Added a new option in the Analysis window for Functional Connectivity
|
||||||
|
- Functional connectivity is still in development and the results should currently be taken with a grain of salt
|
||||||
|
- A warning is displayed when entering the Functional Connectivity Viewer disclosing this
|
||||||
|
|
||||||
|
|
||||||
|
# Version 1.1.7
|
||||||
|
|
||||||
|
- Fixed a bug where having both a L_FREQ and H_FREQ would cause only the L_FREQ to be used
|
||||||
|
- Changed the default H_FREQ from 0.7 to 0.3
|
||||||
|
- Added a PSD graph, along with 2 heart rate images to the individual participant viewer
|
||||||
|
- The PSD graph is used to help calculate the heart rate, whereas the other 2 are currently just for show
|
||||||
|
- SCI is now done using a .6hz window around the calculated heart rate compared to a window around an average heart rate
|
||||||
|
- Fixed an issue with some epochs figures not showing under the participant analysis
|
||||||
|
- Removed SECONDS_TO_STRIP from the preprocessing options
|
||||||
|
- Added new parameters to the right side of the screen
|
||||||
|
- These parameters include TRIM, SECONDS_TO_KEEP, OPTODE_PLACEMENT, HEART_RATE, WAVELET, IQR, WAVELET_TYPE, WAVELET_LEVEL, ENHANCE_NEGATIVE_CORRELATION, SHORT_CHANNEL_THRESH, LONG_CHANNEL_THRESH, and DRIFT_MODEL
|
||||||
|
- Changed number of rectangles in the progress bar to 25 to account for the new options
|
||||||
|
|
||||||
|
|
||||||
# Version 1.1.6
|
# Version 1.1.6
|
||||||
|
|
||||||
- Fixed Process button from appearing when no files are selected
|
- Fixed Process button from appearing when no files are selected
|
||||||
- Fix for instand child process crash on Windows
|
- Fixed a bug that would cause an instant child process crash on Windows
|
||||||
- Added L_FREQ and H_FREQ parameters for more user control over low and high pass filtering
|
- Added L_FREQ and H_FREQ parameters for more user control over low and high pass filtering
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +151,7 @@
|
|||||||
- Added a group option when clicking on a participant's file
|
- Added a group option when clicking on a participant's file
|
||||||
- If no group is specified, the participant will be added to the "Default" group
|
- If no group is specified, the participant will be added to the "Default" group
|
||||||
- Added option to update the optode positions in a snirf file from the Options menu (F6)
|
- Added option to update the optode positions in a snirf file from the Options menu (F6)
|
||||||
- Fixed [Issue 3](https://git.research.dezeeuw.ca/tyler/flares/issues/3), [Issue 4](https://git.research.dezeeuw.ca/tyler/flares/issues/4), [Issue 17](https://git.research.dezeeuw.ca/tyler/flares/issues/17), [Issue 21](https://git.research.dezeeuw.ca/tyler/flares/issues/21), [Issue 22](https://git.research.dezeeuw.ca/tyler/flares/issues/22)
|
- Fixed [Issue 3](https://git.research.dezeeuw.ca/tyler/flares/issues/3), [Issue 5](https://git.research.dezeeuw.ca/tyler/flares/issues/5), [Issue 17](https://git.research.dezeeuw.ca/tyler/flares/issues/17), [Issue 21](https://git.research.dezeeuw.ca/tyler/flares/issues/21), [Issue 22](https://git.research.dezeeuw.ca/tyler/flares/issues/22)
|
||||||
|
|
||||||
|
|
||||||
# Version 1.0.1
|
# Version 1.0.1
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import subprocess
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
PLATFORM_NAME = platform.system().lower()
|
PLATFORM_NAME = platform.system().lower()
|
||||||
|
APP_NAME = "flares"
|
||||||
|
|
||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin':
|
||||||
LOG_FILE = os.path.join(os.path.dirname(sys.executable), "../../../flares_updater.log")
|
LOG_FILE = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log")
|
||||||
else:
|
else:
|
||||||
LOG_FILE = os.path.join(os.getcwd(), "flares_updater.log")
|
LOG_FILE = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log")
|
||||||
|
|
||||||
|
|
||||||
def log(msg):
|
def log(msg):
|
||||||
@@ -147,7 +148,7 @@ def copy_update_files_darwin(src_folder, dest_folder, updater_name):
|
|||||||
|
|
||||||
def remove_quarantine(app_path):
|
def remove_quarantine(app_path):
|
||||||
script = f'''
|
script = f'''
|
||||||
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "FLARES needs privileges to finish the update. (1/2)"
|
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)"
|
||||||
'''
|
'''
|
||||||
try:
|
try:
|
||||||
subprocess.run(['osascript', '-e', script], check=True)
|
subprocess.run(['osascript', '-e', script], check=True)
|
||||||
@@ -162,7 +163,7 @@ def main():
|
|||||||
log(f"[Updater] sys.argv: {sys.argv}")
|
log(f"[Updater] sys.argv: {sys.argv}")
|
||||||
|
|
||||||
if len(sys.argv) != 3:
|
if len(sys.argv) != 3:
|
||||||
log("Invalid arguments. Usage: flares_updater <update_folder> <main_app_executable>")
|
log(f"Invalid arguments. Usage: {APP_NAME}_updater <update_folder> <main_app_executable>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
update_folder = sys.argv[1]
|
update_folder = sys.argv[1]
|
||||||
@@ -215,7 +216,7 @@ def main():
|
|||||||
if PLATFORM_NAME == 'darwin':
|
if PLATFORM_NAME == 'darwin':
|
||||||
log(f'Attempting to delete {ppparent_dir}')
|
log(f'Attempting to delete {ppparent_dir}')
|
||||||
delete_path(ppparent_dir)
|
delete_path(ppparent_dir)
|
||||||
update_folder = os.path.join(sys.argv[1], "flares-darwin")
|
update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin")
|
||||||
copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
|
copy_update_files_darwin(update_folder, pppparent_dir, updater_name)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy import linalg
|
from scipy import linalg
|
||||||
from scipy.spatial.distance import cdist
|
from scipy.spatial.distance import cdist
|
||||||
from scipy.special import sph_harm
|
from scipy.special import sph_harm_y
|
||||||
|
|
||||||
from ._fiff.constants import FIFF
|
from ._fiff.constants import FIFF
|
||||||
from ._fiff.open import fiff_open
|
from ._fiff.open import fiff_open
|
||||||
|
|||||||
@@ -1025,7 +1025,7 @@ def _handle_sensor_types(meg, eeg, fnirs):
|
|||||||
fnirs=dict(channels="fnirs", pairs="fnirs_pairs"),
|
fnirs=dict(channels="fnirs", pairs="fnirs_pairs"),
|
||||||
)
|
)
|
||||||
sensor_alpha = {
|
sensor_alpha = {
|
||||||
key: dict(meg_helmet=0.25, meg=0.25).get(key, 0.8)
|
key: dict(meg_helmet=0.25, meg=0.25).get(key, 1.0)
|
||||||
for ch_dict in alpha_map.values()
|
for ch_dict in alpha_map.values()
|
||||||
for key in ch_dict.values()
|
for key in ch_dict.values()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ class _PyVistaRenderer(_AbstractRenderer):
|
|||||||
color = None
|
color = None
|
||||||
else:
|
else:
|
||||||
scalars = None
|
scalars = None
|
||||||
tube = line.tube(radius, n_sides=self.tube_n_sides)
|
tube = line.tube(radius=radius, n_sides=self.tube_n_sides)
|
||||||
actor = _add_mesh(
|
actor = _add_mesh(
|
||||||
plotter=self.plotter,
|
plotter=self.plotter,
|
||||||
mesh=tube,
|
mesh=tube,
|
||||||
|
|||||||
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
539
updater.py
Normal file
539
updater.py
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
"""
|
||||||
|
Filename: updater.py
|
||||||
|
Description: Generic updater file
|
||||||
|
|
||||||
|
Author: Tyler de Zeeuw
|
||||||
|
License: GPL-3.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Built-in imports
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import traceback
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# External library imports
|
||||||
|
import psutil
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from PySide6.QtWidgets import QMessageBox
|
||||||
|
from PySide6.QtCore import QThread, Signal, QObject
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateDownloadThread(QThread):
|
||||||
|
"""
|
||||||
|
Thread that downloads and extracts an update package and emits a signal on completion or error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_url (str): URL of the update zip file to download.
|
||||||
|
latest_version (str): Version string of the latest update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
update_ready = Signal(str, str)
|
||||||
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, download_url, latest_version, platform_name, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.download_url = download_url
|
||||||
|
self.latest_version = latest_version
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
local_filename = os.path.basename(self.download_url)
|
||||||
|
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
tmp_dir = f'/tmp/{self.app_name}tempupdate'
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
local_path = os.path.join(tmp_dir, local_filename)
|
||||||
|
else:
|
||||||
|
local_path = os.path.join(os.getcwd(), local_filename)
|
||||||
|
|
||||||
|
# Download the file
|
||||||
|
with requests.get(self.download_url, stream=True, timeout=15) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
if chunk:
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
# Extract folder name (remove .zip)
|
||||||
|
if 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)
|
||||||
|
|
||||||
|
# Create the folder if not exists
|
||||||
|
os.makedirs(extract_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Extract the zip file contents
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
subprocess.run(['ditto', '-xk', local_path, extract_path], check=True)
|
||||||
|
else:
|
||||||
|
with zipfile.ZipFile(local_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(extract_path)
|
||||||
|
|
||||||
|
# Remove the zip once extracted and emit a signal
|
||||||
|
os.remove(local_path)
|
||||||
|
self.update_ready.emit(self.latest_version, extract_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Emit a signal signifying failure
|
||||||
|
self.error_occurred.emit(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateCheckThread(QThread):
|
||||||
|
"""
|
||||||
|
Thread that checks for updates by querying the API and emits a signal based on the result.
|
||||||
|
|
||||||
|
Signals:
|
||||||
|
download_requested(str, str): Emitted with (download_url, latest_version) when an update is available.
|
||||||
|
no_update_available(): Emitted when no update is found or current version is up to date.
|
||||||
|
error_occurred(str): Emitted with an error message if the update check fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
download_requested = Signal(str, str)
|
||||||
|
no_update_available = Signal()
|
||||||
|
error_occurred = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, api_url, api_url_sec, current_version, platform_name, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.api_url = api_url
|
||||||
|
self.api_url_sec = api_url_sec
|
||||||
|
self.current_version = current_version
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
latest_version, download_url = self.get_latest_release_for_platform()
|
||||||
|
if not latest_version:
|
||||||
|
self.no_update_available.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not download_url:
|
||||||
|
self.error_occurred.emit(f"No download available for platform '{self.platform_name}'")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.version_compare(latest_version, self.current_version) > 0:
|
||||||
|
self.download_requested.emit(download_url, latest_version)
|
||||||
|
else:
|
||||||
|
self.no_update_available.emit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.error_occurred.emit(f"Update check failed: {e}")
|
||||||
|
|
||||||
|
def version_compare(self, v1, v2):
|
||||||
|
def normalize(v): return [int(x) for x in v.split(".")]
|
||||||
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
def get_latest_release_for_platform(self):
|
||||||
|
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()
|
||||||
|
|
||||||
|
if not releases:
|
||||||
|
continue
|
||||||
|
|
||||||
|
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
|
||||||
|
|
||||||
|
if not latest:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag = latest["tag_name"].lstrip("v")
|
||||||
|
|
||||||
|
for asset in latest.get("assets", []):
|
||||||
|
if self.platform_name in asset["name"].lower():
|
||||||
|
return tag, asset["browser_download_url"]
|
||||||
|
|
||||||
|
return tag, None
|
||||||
|
except (requests.RequestException, ValueError) as e:
|
||||||
|
continue
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPendingUpdateCheckThread(QThread):
|
||||||
|
"""
|
||||||
|
Thread that checks for locally pending updates by scanning the download directory and emits a signal accordingly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_version (str): Current application version.
|
||||||
|
platform_suffix (str): Platform-specific suffix to identify update folders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pending_update_found = Signal(str, str)
|
||||||
|
no_pending_update = Signal()
|
||||||
|
|
||||||
|
def __init__(self, current_version, platform_suffix, platform_name, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.current_version = current_version
|
||||||
|
self.platform_suffix = platform_suffix
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
def version_compare(self, v1, v2):
|
||||||
|
def normalize(v): return [int(x) for x in v.split(".")]
|
||||||
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
cwd = f'/tmp/{self.app_name}tempupdate'
|
||||||
|
else:
|
||||||
|
cwd = os.getcwd()
|
||||||
|
|
||||||
|
pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$")
|
||||||
|
found = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in os.listdir(cwd):
|
||||||
|
folder_path = os.path.join(cwd, item)
|
||||||
|
if os.path.isdir(folder_path) and item.endswith(self.platform_suffix):
|
||||||
|
match = pattern.match(item)
|
||||||
|
if match:
|
||||||
|
folder_version = match.group(1)
|
||||||
|
if self.version_compare(folder_version, self.current_version) > 0:
|
||||||
|
self.pending_update_found.emit(folder_version, folder_path)
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
self.no_pending_update.emit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateManager(QObject):
|
||||||
|
"""
|
||||||
|
Orchestrates the update process.
|
||||||
|
Main apps should instantiate this and call check_for_updates().
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, main_window, api_url, api_url_sec, current_version, platform_name, platform_suffix, app_name):
|
||||||
|
super().__init__()
|
||||||
|
self.parent = main_window
|
||||||
|
self.api_url = api_url
|
||||||
|
self.api_url_sec = api_url_sec
|
||||||
|
self.current_version = current_version
|
||||||
|
self.platform_name = platform_name
|
||||||
|
self.platform_suffix = platform_suffix
|
||||||
|
self.app_name = app_name
|
||||||
|
|
||||||
|
self.pending_update_version = None
|
||||||
|
self.pending_update_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def manual_check_for_updates(self):
|
||||||
|
self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name)
|
||||||
|
self.local_check_thread.pending_update_found.connect(self.on_pending_update_found)
|
||||||
|
self.local_check_thread.no_pending_update.connect(self.on_no_pending_update)
|
||||||
|
self.local_check_thread.start()
|
||||||
|
|
||||||
|
def on_pending_update_found(self, version, folder_path):
|
||||||
|
self.parent.statusBar().showMessage(f"Pending update found: version {version}")
|
||||||
|
self.pending_update_version = version
|
||||||
|
self.pending_update_path = folder_path
|
||||||
|
self.show_pending_update_popup()
|
||||||
|
|
||||||
|
def on_no_pending_update(self):
|
||||||
|
# No pending update found locally, start server check directly
|
||||||
|
self.parent.statusBar().showMessage("No pending local update found. Checking server...")
|
||||||
|
self.start_update_check_thread()
|
||||||
|
|
||||||
|
def show_pending_update_popup(self):
|
||||||
|
msg_box = QMessageBox(self.parent)
|
||||||
|
msg_box.setWindowTitle("Pending Update Found")
|
||||||
|
msg_box.setText(f"A previously downloaded update for {self.app_name.upper()} (version {self.pending_update_version}) is available at:\n{self.pending_update_path}\nWould you like to install it now?")
|
||||||
|
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
||||||
|
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
||||||
|
msg_box.exec()
|
||||||
|
|
||||||
|
if msg_box.clickedButton() == install_now_button:
|
||||||
|
self.install_update(self.pending_update_path)
|
||||||
|
else:
|
||||||
|
self.parent.statusBar().showMessage("Pending update available. Install later.")
|
||||||
|
# After user dismisses, still check the server for new updates
|
||||||
|
self.start_update_check_thread()
|
||||||
|
|
||||||
|
def start_update_check_thread(self):
|
||||||
|
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name)
|
||||||
|
self.check_thread.download_requested.connect(self.on_server_update_requested)
|
||||||
|
self.check_thread.no_update_available.connect(self.on_server_no_update)
|
||||||
|
self.check_thread.error_occurred.connect(self.on_error)
|
||||||
|
self.check_thread.start()
|
||||||
|
|
||||||
|
def on_server_no_update(self):
|
||||||
|
self.parent.statusBar().showMessage("No new updates found on server.", 5000)
|
||||||
|
|
||||||
|
def on_server_update_requested(self, download_url, latest_version):
|
||||||
|
if self.pending_update_version:
|
||||||
|
cmp = self.version_compare(latest_version, self.pending_update_version)
|
||||||
|
if cmp > 0:
|
||||||
|
# Server version is newer than pending update
|
||||||
|
self.parent.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(self.pending_update_path)
|
||||||
|
self.parent.statusBar().showMessage(f"Deleted old update folder: {self.pending_update_path}")
|
||||||
|
except Exception as e:
|
||||||
|
self.parent.statusBar().showMessage(f"Failed to delete old update folder: {e}")
|
||||||
|
|
||||||
|
# Clear pending update info so new download proceeds
|
||||||
|
self.pending_update_version = None
|
||||||
|
self.pending_update_path = None
|
||||||
|
|
||||||
|
# Download the new update
|
||||||
|
self.download_update(download_url, latest_version)
|
||||||
|
elif cmp == 0:
|
||||||
|
# Versions equal, no download needed
|
||||||
|
self.parent.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.parent.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.")
|
||||||
|
else:
|
||||||
|
# No pending update, just download
|
||||||
|
self.download_update(download_url, latest_version)
|
||||||
|
|
||||||
|
def download_update(self, download_url, latest_version):
|
||||||
|
self.parent.statusBar().showMessage("Downloading update...")
|
||||||
|
self.download_thread = UpdateDownloadThread(download_url, latest_version, self.platform_name, self.app_name)
|
||||||
|
self.download_thread.update_ready.connect(self.on_update_ready)
|
||||||
|
self.download_thread.error_occurred.connect(self.on_error)
|
||||||
|
self.download_thread.start()
|
||||||
|
|
||||||
|
def on_update_ready(self, latest_version, extract_folder):
|
||||||
|
self.parent.statusBar().showMessage("Update downloaded and extracted.")
|
||||||
|
|
||||||
|
msg_box = QMessageBox(self.parent)
|
||||||
|
msg_box.setWindowTitle("Update Ready")
|
||||||
|
msg_box.setText(f"Version {latest_version} has been downloaded and extracted to:\n{extract_folder}\nWould you like to install it now?")
|
||||||
|
install_now_button = msg_box.addButton("Install Now", QMessageBox.ButtonRole.AcceptRole)
|
||||||
|
install_later_button = msg_box.addButton("Install Later", QMessageBox.ButtonRole.RejectRole)
|
||||||
|
|
||||||
|
msg_box.exec()
|
||||||
|
|
||||||
|
if msg_box.clickedButton() == install_now_button:
|
||||||
|
self.install_update(extract_folder)
|
||||||
|
else:
|
||||||
|
self.parent.statusBar().showMessage("Update ready. Install later.")
|
||||||
|
|
||||||
|
|
||||||
|
def install_update(self, extract_folder):
|
||||||
|
# Path to updater executable
|
||||||
|
|
||||||
|
if self.platform_name == 'windows':
|
||||||
|
updater_path = os.path.join(os.getcwd(), 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")
|
||||||
|
else:
|
||||||
|
updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app")
|
||||||
|
|
||||||
|
elif self.platform_name == 'linux':
|
||||||
|
updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater")
|
||||||
|
else:
|
||||||
|
updater_path = os.getcwd()
|
||||||
|
|
||||||
|
if not os.path.exists(updater_path):
|
||||||
|
QMessageBox.critical(self.parent, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Launch updater with extracted folder path as argument
|
||||||
|
try:
|
||||||
|
# Pass current app's executable path for updater to relaunch
|
||||||
|
main_app_executable = os.path.abspath(sys.argv[0])
|
||||||
|
|
||||||
|
print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"')
|
||||||
|
|
||||||
|
if self.platform_name == 'darwin':
|
||||||
|
subprocess.Popen(['open', updater_path, '--args', extract_folder, main_app_executable])
|
||||||
|
else:
|
||||||
|
subprocess.Popen([updater_path, f'{extract_folder}', f'{main_app_executable}'], cwd=os.path.dirname(updater_path))
|
||||||
|
|
||||||
|
# Close the current app so updater can replace files
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self.parent, "Error", f"[Updater Launch Failed]\n{str(e)}\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
def on_error(self, message):
|
||||||
|
# print(f"Error: {message}")
|
||||||
|
self.parent.statusBar().showMessage(f"Error occurred during update process. {message}")
|
||||||
|
|
||||||
|
def version_compare(self, v1, v2):
|
||||||
|
def normalize(v): return [int(x) for x in v.split(".")]
|
||||||
|
return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2))
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_process_to_exit(process_name, timeout=10):
|
||||||
|
"""
|
||||||
|
Waits for a process with the specified name to exit within a timeout period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
process_name (str): Name (or part of the name) of the process to wait for.
|
||||||
|
timeout (int, optional): Maximum time to wait in seconds. Defaults to 10.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the process exited before the timeout, False otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
print(f"Waiting for {process_name} to exit...")
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
still_running = False
|
||||||
|
for proc in psutil.process_iter(['name']):
|
||||||
|
try:
|
||||||
|
if proc.info['name'] and process_name.lower() in proc.info['name'].lower():
|
||||||
|
still_running = True
|
||||||
|
print(f"Still running: {proc.info['name']} (PID: {proc.pid})")
|
||||||
|
break
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
if not still_running:
|
||||||
|
print(f"{process_name} has exited.")
|
||||||
|
return True
|
||||||
|
time.sleep(0.5)
|
||||||
|
print(f"{process_name} did not exit in time.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def finish_update_if_needed(platform_name, app_name):
|
||||||
|
"""
|
||||||
|
Completes a pending application update if '--finish-update' is present in the command-line arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if "--finish-update" in sys.argv:
|
||||||
|
print("Finishing update...")
|
||||||
|
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
app_dir = f'/tmp/{app_name}tempupdate'
|
||||||
|
else:
|
||||||
|
app_dir = os.getcwd()
|
||||||
|
|
||||||
|
# 1. Find update folder
|
||||||
|
update_folder = None
|
||||||
|
for entry in os.listdir(app_dir):
|
||||||
|
entry_path = os.path.join(app_dir, entry)
|
||||||
|
if os.path.isdir(entry_path) and entry.startswith(f"{app_name}-") and entry.endswith("-" + platform_name):
|
||||||
|
update_folder = os.path.join(app_dir, entry)
|
||||||
|
break
|
||||||
|
|
||||||
|
if update_folder is None:
|
||||||
|
print("No update folder found. Skipping update steps.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
update_folder = os.path.join(update_folder, f"{app_name}-darwin")
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
try:
|
||||||
|
if platform_name == 'darwin':
|
||||||
|
shutil.rmtree(app_dir)
|
||||||
|
else:
|
||||||
|
shutil.rmtree(update_folder)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to delete update folder: {e}")
|
||||||
|
|
||||||
|
QMessageBox.information(None, "Update Complete", "The application has been successfully updated.")
|
||||||
|
sys.argv.remove("--finish-update")
|
||||||
|
|
||||||
|
|
||||||
|
def remove_quarantine(app_path, app_name):
|
||||||
|
"""
|
||||||
|
Removes the macOS quarantine attribute from the specified application path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
script = f'''
|
||||||
|
do shell script "xattr -d -r com.apple.quarantine {shlex.quote(app_path)}" with administrator privileges with prompt "{app_name.upper()} needs privileges to finish the update. (2/2)"
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
subprocess.run(['osascript', '-e', script], check=True)
|
||||||
|
print("✅ Quarantine attribute removed.")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print("❌ Failed to remove quarantine attribute.")
|
||||||
|
print(e)
|
||||||
@@ -18,7 +18,7 @@ VSVersionInfo(
|
|||||||
StringStruct('FileDescription', 'FLARES main application'),
|
StringStruct('FileDescription', 'FLARES main application'),
|
||||||
StringStruct('FileVersion', '1.0.0.0'),
|
StringStruct('FileVersion', '1.0.0.0'),
|
||||||
StringStruct('InternalName', 'flares.exe'),
|
StringStruct('InternalName', 'flares.exe'),
|
||||||
StringStruct('LegalCopyright', '© 2025 Tyler de Zeeuw'),
|
StringStruct('LegalCopyright', '© 2025-2026 Tyler de Zeeuw'),
|
||||||
StringStruct('OriginalFilename', 'flares.exe'),
|
StringStruct('OriginalFilename', 'flares.exe'),
|
||||||
StringStruct('ProductName', 'FLARES'),
|
StringStruct('ProductName', 'FLARES'),
|
||||||
StringStruct('ProductVersion', '1.0.0.0')])
|
StringStruct('ProductVersion', '1.0.0.0')])
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ VSVersionInfo(
|
|||||||
StringStruct('FileDescription', 'FLARES updater application'),
|
StringStruct('FileDescription', 'FLARES updater application'),
|
||||||
StringStruct('FileVersion', '1.0.0.0'),
|
StringStruct('FileVersion', '1.0.0.0'),
|
||||||
StringStruct('InternalName', 'main.exe'),
|
StringStruct('InternalName', 'main.exe'),
|
||||||
StringStruct('LegalCopyright', '© 2025 Tyler de Zeeuw'),
|
StringStruct('LegalCopyright', '© 2025-2026 Tyler de Zeeuw'),
|
||||||
StringStruct('OriginalFilename', 'flares_updater.exe'),
|
StringStruct('OriginalFilename', 'flares_updater.exe'),
|
||||||
StringStruct('ProductName', 'FLARES Updater'),
|
StringStruct('ProductName', 'FLARES Updater'),
|
||||||
StringStruct('ProductVersion', '1.0.0.0')])
|
StringStruct('ProductVersion', '1.0.0.0')])
|
||||||
|
|||||||
Reference in New Issue
Block a user