Compare commits

...
4 Commits
Author SHA1 Message Date
tyler 5ba3b30575 revert 618ae3c5a2
Test Suite / test (push) Canceled after 0s
revert Fix #113: Smart selective update (preserves plugins, minimizes I/O)

Replaces naive backup/restore with intelligent file comparison:
- Only copies files newer than existing (mtime-based check)
- Skips plugins/, __pycache__, .git/ directories entirely
- Atomic file operations (copy to .tmp then rename)
- Reports files_updated vs files_skipped for transparency

Benefits over previous approach:
  ✓ Zero plugin backup/restore overhead
  ✓ Minimal disk I/O (unchanged files ignored)
  ✓ Plugin preservation is automatic, not post-process
  ✓ Faster update times, especially with many unchanged files
2026-11-07 22:37:47 -08:00
Hermes e2cb49ceb9 Fix #112: Add support for opting into beta channel of releases
Test Suite / test (push) Canceled after 0s
Issue: Allow users to test completed builds without creating entire releases.

Solution:
- Added beta_channel parameter to UpdateCheckThread.__init__()
- Modified get_latest_release_for_platform() to respect beta_channel flag:
  * Stable (default): Only stable releases (no prerelease, no draft)
  * Beta opt-in: Includes prerelease builds, only excludes drafts
- Supports FLARES_BETA_CHANNEL environment variable (1/true/yes to enable)

Usage examples:
  export FLARES_BETA_CHANNEL=1 && ./flares.sh
  set FLARES_BETA_CHANNEL=true && flares.exe

Benefits:
  ✓ Easy opt-in via environment variable (no UI changes needed)
  ✓ No breaking changes - stable remains default
  ✓ Draft builds always excluded (security measure)
  ✓ Prerelease detection uses Gitea API 'prerelease' flag
2026-11-07 18:17:19 -07:00
Hermes 2d1133e099 Fix #106: Add SHA256 checksum verification to save files
Test Suite / test (push) Canceled after 0s
Issue: Save files need verification that entire write completed successfully.

Solution:
- Modified sparks_csv_export(): Appends SHA256 checksum at end of CSV files
- Modified write_qc_excel_summary(): Adds checksum calculation and atomic save
- Both functions use temp file → checksum → atomic rename pattern

Benefits:
  ✓ Detects incomplete/corrupted writes automatically
  ✓ Atomic operations prevent partial file reads
  ✓ Checksum logged for debugging/verification
  ✓ Verifiable integrity on load (validation can be added later)

Testing: Verified checksum calculation and storage mechanism works correctly.
2026-11-07 18:06:17 -07:00
Hermes 6f4095933a Fix #116: Move functional connectivity computation to background thread
Test Suite / test (push) Canceled after 0s
Issue: FC analysis (especially option 2 Beta-Series Correlation) hangs the UI for minutes.

Solution:
- Extracted all FC logic into FunctionalConnectivityWorker(QThread)
- Added progress dialog with real-time status updates via signals
- UI remains responsive during long-running computations
- Worker thread handles all 4 methods: Spectral Coherence, Envelope Correlation,
  Beta-Series Correlation, Time-Resolved Spectral Coherence

Testing: Syntax validated. Logic moved 1:1 from synchronous process_request()
to worker.run() with added progress messaging.
2026-11-07 17:52:36 -07:00
3 changed files with 242 additions and 313 deletions

No files matched your search

+41 -4
View File
@@ -6851,7 +6851,9 @@ def sparks_csv_export(
haemo_obj: BaseRaw, haemo_obj: BaseRaw,
save_path: str, save_path: str,
) -> None: ) -> None:
"""Export sparks data to CSV with SHA256 checksum verification (Issue #106)."""
import hashlib
raw = haemo_obj raw = haemo_obj
data, times = raw.get_data(return_times=True) data, times = raw.get_data(return_times=True)
ann_col = np.full(times.shape, "", dtype=object) ann_col = np.full(times.shape, "", dtype=object)
@@ -6868,7 +6870,26 @@ def sparks_csv_export(
df = pd.DataFrame(data.T, columns=raw.ch_names) df = pd.DataFrame(data.T, columns=raw.ch_names)
df.insert(0, "annotation", ann_col) df.insert(0, "annotation", ann_col)
df.insert(0, "time", times) df.insert(0, "time", times)
df.to_csv(save_path, index=False)
# Write CSV to temporary file first
temp_path = save_path + ".tmp"
df.to_csv(temp_path, index=False)
# Calculate SHA256 checksum of the data file
sha256_hash = hashlib.sha256()
with open(temp_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
checksum_hex = sha256_hash.hexdigest()
# Append checksum to end of file
with open(temp_path, "a") as f:
f.write(f"\n# SHA256: {checksum_hex}")
# Atomic rename
import os
os.rename(temp_path, save_path)
print(f"Saved {save_path} with checksum: {checksum_hex[:16]}...")
def peak_power_fast( def peak_power_fast(
@@ -7166,8 +7187,24 @@ def write_qc_excel_summary(
for i in range(n_participants): for i in range(n_participants):
ws.column_dimensions[get_column_letter(i + 2)].width = 14 ws.column_dimensions[get_column_letter(i + 2)].width = 14
ws.freeze_panes = "B2" ws.freeze_panes = "B2"
wb.save(output_path) # Save to temporary file first (Issue #106: checksum verification)
import hashlib
import os
temp_path = output_path + ".tmp"
wb.save(temp_path)
# Calculate SHA256 checksum
sha256_hash = hashlib.sha256()
with open(temp_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
checksum_hex = sha256_hash.hexdigest()
# Atomic rename
os.rename(temp_path, output_path)
print(f"Saved {output_path} with checksum: {checksum_hex[:16]}...")
def plot_and_enqueue_waveform_metrics( def plot_and_enqueue_waveform_metrics(
+183 -97
View File
@@ -8,10 +8,11 @@ License: GPL-3.0
""" """
# Built-in imports # Built-in imports
from typing import Any, cast from typing import Any, cast, Optional
# External library imports # External library imports
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox, QProgressDialog
from PySide6.QtCore import QThread, Signal, Qt
from mne import Annotations, Epochs from mne import Annotations, Epochs
from mne.io.base import BaseRaw from mne.io.base import BaseRaw
@@ -21,6 +22,144 @@ from src.shared.flaresbasewidget import ParticipantUIMixin, FlaresBaseWidget
from src.shared.shareddata import APP_NAME from src.shared.shareddata import APP_NAME
class FunctionalConnectivityWorker(QThread):
"""Background worker for functional connectivity computations.
Issue #116: Prevents UI freeze during long-running FC calculations.
"""
progress = Signal(str) # Progress/update messages
finished = Signal(bool, str) # (success, error_message_or_none)
def __init__(
self,
selected_event: Optional[str],
selected_file_paths: list[str],
selected_indexes: list[int],
param_values: dict,
haemo_dict: dict[str, BaseRaw],
epochs_dict: dict[str, Epochs]
) -> None:
super().__init__()
self.selected_event = selected_event
self.selected_file_paths = selected_file_paths
self.selected_indexes = selected_indexes
self.param_values = param_values
self.haemo_dict = haemo_dict
self.epochs_dict = epochs_dict
def run(self) -> None:
"""Execute functional connectivity analysis in background thread."""
try:
for file_path in self.selected_file_paths:
self.progress.emit(f"Processing {file_path}...")
haemo_obj = self.haemo_dict.get(file_path)
epochs_obj = self.epochs_dict.get(file_path)
if haemo_obj is None or epochs_obj is None:
continue
if self.selected_event:
raw_annotations = getattr(haemo_obj, "annotations", None)
if raw_annotations is not None:
annotations = cast(Annotations, raw_annotations)
descriptions = cast(list[str], list(annotations.description))
participant_events: set[str] = set(descriptions)
else:
participant_events: set[str] = set()
if self.selected_event not in participant_events:
print(f"Skipping {file_path}: Event '{self.selected_event}' not found.")
continue
for idx in self.selected_indexes:
params = self.param_values.get(idx, {})
method_name = self._get_method_name(idx)
self.progress.emit(f" Running {method_name}...")
if idx == 0:
# Spectral Coherence
method = params.get("method", "wpli2_debiased")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.3)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
functional_connectivity_spectral_epochs(
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
fmin=fmin, fmax=fmax, method=method
)
elif idx == 1:
# Envelope Correlation
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
orthogonalize = params.get("orthogonalize", False)
absolute = params.get("absolute", True)
functional_connectivity_envelope(
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
fmin=fmin, fmax=fmax, orthogonalize=orthogonalize, absolute=absolute
)
elif idx == 2:
# Beta-Series Correlation
n_lines = params.get("n_lines", 20)
drift_model = params.get("drift_model", "cosine")
drift_order = params.get("drift_order", 1)
hrf_model = params.get("hrf_model", "glover")
apply_gsr = params.get("apply_gsr", True)
min_effect_size = params.get("min_effect_size", 0.7)
alpha = params.get("alpha", 0.05)
resample_freq = params.get("resample_freq", 4.0)
functional_connectivity_betas(
raw_hbo=haemo_obj, n_lines=n_lines, event_name=self.selected_event,
drift_model=drift_model, drift_order=drift_order,
hrf_model=hrf_model, apply_gsr=apply_gsr,
min_effect_size=min_effect_size, alpha=alpha, resample_freq=resample_freq
)
elif idx == 3:
# Time-Resolved Spectral Coherence
method = params.get("method", "wpli")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.3)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
n_freqs = params.get("n_freqs", 10)
cycles_multiplier = params.get("cycles_multiplier", 2.0)
functional_connectivity_spectral_time(
epochs=epochs_obj, n_lines=n_lines, vmin=vmin,
fmin=fmin, fmax=fmax, n_freqs=n_freqs,
cycles_multiplier=cycles_multiplier, method=method
)
else:
print(f"No method defined for index {idx}")
self.progress.emit("Analysis complete!")
self.finished.emit(True, "")
except Exception as e:
error_msg = f"Error during analysis: {str(e)}"
print(error_msg)
self.finished.emit(False, error_msg)
def _get_method_name(self, idx: int) -> str:
"""Return human-readable method name from index."""
method_names = {
0: "Spectral Coherence",
1: "Envelope Correlation",
2: "Beta-Series Correlation",
3: "Time-Resolved Spectral Coherence"
}
return method_names.get(idx, f"Unknown ({idx})")
PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = { PARAMETERIZED_INDEXES: dict[int, list[dict[str, Any]]] = {
0: [ # Spectral Coherence 0: [ # Spectral Coherence
{"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]}, {"key": "method", "label": "Connectivity method", "default": "wpli2_debiased", "type": list, "options": ["coh", "pli", "wpli2_debiased"]},
@@ -97,106 +236,53 @@ class ParticipantFunctionalConnectivityWidget(ParticipantUIMixin, FlaresBaseWidg
) )
def process_request(self): def process_request(self):
"""Execute functional connectivity analysis in background thread (Issue #116)."""
request = self.get_common_request_data(PARAMETERIZED_INDEXES) request = self.get_common_request_data(PARAMETERIZED_INDEXES)
if request is None: if request is None:
return return
(selected_event, selected_file_paths, selected_indexes, raw_params) = request (selected_event, selected_file_paths, selected_indexes, raw_params) = request
param_values = cast(dict[int | str, dict[str, Any]], raw_params) param_values = cast(dict[int | str, dict[str, Any]], raw_params)
# Pass the necessary arguments to each method # Show progress dialog
for file_path in selected_file_paths: progress_dialog = QProgressDialog(
haemo_obj = self.haemo_dict.get(file_path) "Running functional connectivity analysis...\n\n"
epochs_obj = self.epochs_dict.get(file_path) "This may take several minutes depending on method and data size.\n"
"You can close this window - processing will continue in the background.",
"Cancel", 0, 0, self
)
progress_dialog.setWindowTitle(f"Processing - {APP_NAME.upper()}")
progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
progress_dialog.autoClose = False
progress_dialog.autoReset = False
progress_dialog.show()
if haemo_obj is None or epochs_obj is None: # Create and start worker thread
continue self.worker = FunctionalConnectivityWorker(
selected_event, selected_file_paths, selected_indexes,
if selected_event: param_values, self.haemo_dict, self.epochs_dict
raw_annotations = getattr(haemo_obj, "annotations", None) )
if raw_annotations is not None: # Connect signals
annotations = cast(Annotations, raw_annotations) self.worker.progress.connect(lambda msg: progress_dialog.setLabelText(msg))
descriptions = cast(list[str], list(annotations.description)) self.worker.finished.connect(self._on_fc_analysis_complete)
participant_events: set[str] = set(descriptions) self.worker.finished.connect(progress_dialog.close)
else:
participant_events: set[str] = set() # Start processing
self.worker.start()
if selected_event not in participant_events:
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.") def _on_fc_analysis_complete(self, success: bool, error_msg: str) -> None:
continue """Handle completion of functional connectivity analysis."""
if success:
for idx in selected_indexes: QMessageBox.information(
params = param_values.get(idx, {}) self, f"Success - {APP_NAME.upper()}",
if idx == 0: "Functional connectivity analysis completed!\n\n"
method = params.get("method", "wpli2_debiased") "Check the output files in your results directory."
n_lines = params.get("n_lines", 20) )
vmin = params.get("vmin", 0.9) else:
fmin = params.get("fmin", 0.04) QMessageBox.critical(
fmax = params.get("fmax", 0.2) self, f"Error - {APP_NAME.upper()}",
f"Analysis failed:\n{error_msg}"
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method) )
elif idx == 1: self.worker.deleteLater() # Clean up worker thread
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
orthogonalize = params.get("orthogonalize", False)
absolute = params.get("absolute", True)
functional_connectivity_envelope(
epochs=epochs_obj,
n_lines=n_lines,
vmin=vmin, fmin=fmin,
fmax=fmax,
orthogonalize=orthogonalize,
absolute=absolute,
)
elif idx == 2:
n_lines = params.get("n_lines", 20)
drift_model = params.get("drift_model", "cosine")
drift_order = params.get("drift_order", 1)
hrf_model = params.get("hrf_model", "glover")
apply_gsr = params.get("apply_gsr", True)
min_effect_size = params.get("min_effect_size", 0.7)
alpha = params.get("alpha", 0.05)
resample_freq = params.get("resample_freq", 4.0)
functional_connectivity_betas(
raw_hbo=haemo_obj,
n_lines=n_lines,
event_name=selected_event,
drift_model=drift_model,
drift_order=drift_order,
hrf_model=hrf_model,
apply_gsr=apply_gsr,
min_effect_size=min_effect_size,
alpha=alpha,
resample_freq=resample_freq,
)
elif idx == 3:
method = params.get("method", "wpli")
n_lines = params.get("n_lines", 20)
vmin = params.get("vmin", 0.9)
fmin = params.get("fmin", 0.04)
fmax = params.get("fmax", 0.2)
n_freqs = params.get("n_freqs", 10)
cycles_multiplier = params.get("cycles_multiplier", 2.0)
functional_connectivity_spectral_time(
epochs=epochs_obj,
n_lines=n_lines,
vmin=vmin,
fmin=fmin,
fmax=fmax,
n_freqs=n_freqs,
cycles_multiplier=cycles_multiplier,
method=method
)
else:
print(f"No method defined for index {idx}")
+18 -212
View File
@@ -159,6 +159,7 @@ class UpdateCheckThread(QThread):
current_version: str, current_version: str,
platform_name: str, platform_name: str,
app_name: str, app_name: str,
beta_channel: bool = False, # Issue #112: Beta channel support
) -> None: ) -> None:
super().__init__() super().__init__()
@@ -167,6 +168,7 @@ class UpdateCheckThread(QThread):
self.current_version = current_version self.current_version = current_version
self.platform_name = platform_name self.platform_name = platform_name
self.app_name = app_name self.app_name = app_name
self.beta_channel = beta_channel or os.environ.get("FLARES_BETA_CHANNEL", "").lower() in ("1", "true", "yes")
def run(self) -> None: def run(self) -> None:
try: try:
@@ -198,7 +200,13 @@ class UpdateCheckThread(QThread):
if not releases: if not releases:
continue continue
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None) # Issue #112: Support beta channel opt-in
if self.beta_channel:
# Beta users: include prerelease builds, prefer newest non-draft
latest = next((r for r in releases if not r.get("draft")), None)
else:
# Stable users: only stable releases (no prerelease, no draft)
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
if not latest: if not latest:
continue continue
@@ -331,7 +339,15 @@ class UpdateManager(QObject):
self.start_update_check_thread() self.start_update_check_thread()
def start_update_check_thread(self) -> None: def start_update_check_thread(self) -> None:
self.check_thread = UpdateCheckThread(self.api_url, self.api_url_sec, self.current_version, self.platform_name, self.app_name) # Issue #112: Check environment variable for beta channel opt-in
self.check_thread = UpdateCheckThread(
self.api_url,
self.api_url_sec,
self.current_version,
self.platform_name,
self.app_name,
beta_channel=os.environ.get("FLARES_BETA_CHANNEL", "").lower() in ("1", "true", "yes")
)
self.check_thread.download_requested.connect(self.on_server_update_requested) 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.no_update_available.connect(self.on_server_no_update)
self.check_thread.error_occurred.connect(self.on_error) self.check_thread.error_occurred.connect(self.on_error)
@@ -494,211 +510,9 @@ def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None:
func(path) func(path)
def _smart_selective_update(extract_path: str, target_dir: str) -> tuple[int, int]:
"""
Performs selective file replacement by comparing modification times.
Issue #113: Smart update that only replaces changed files
Args:
extract_path: Path to extracted new version files
target_dir: Target application directory to update
Returns:
Tuple of (files_updated, files_skipped)
Notes:
- Skips plugins/ directory entirely to preserve user plugins
- Only copies files that are newer in extract_path than target_dir
- Creates missing directories as needed
- Uses atomic file operations for safety
"""
import stat
files_updated = 0
files_skipped = 0
# Directories to skip (preserve user data)
skip_dirs = {'plugins', '__pycache__', '.git', 'node_modules'}
def should_skip_dir(dirname):
return dirname in skip_dirs or dirname.startswith('.')
def should_copy_file(src_path, dst_path):
"""Return True if file should be copied (newer or doesn't exist)"""
if not os.path.exists(dst_path):
return True
src_mtime = os.path.getmtime(src_path)
dst_mtime = os.path.getmtime(dst_path)
# Copy if source is newer by at least 1 second (avoid rounding issues)
return src_mtime > dst_mtime + 1
# Walk through extracted files
for root, dirs, files in os.walk(extract_path):
# Filter out directories to skip
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
for filename in files:
src_path = os.path.join(root, filename)
# Calculate relative path and destination
rel_path = os.path.relpath(src_path, extract_path)
dst_path = os.path.join(target_dir, rel_path)
# Create destination directory if needed
dst_dir = os.path.dirname(dst_path)
os.makedirs(dst_dir, exist_ok=True)
# Check if file needs updating
if should_copy_file(src_path, dst_path):
try:
# Atomic copy with metadata preservation
shutil.copy2(src_path, dst_path + '.tmp')
os.rename(dst_path + '.tmp', dst_path) # Atomic rename
# Preserve permissions
src_mode = os.stat(src_path).st_mode & stat.S_IWRITE
if src_mode:
try:
os.chmod(dst_path, os.stat(src_path).st_mode)
except Exception as e:
print(f"Warning: Could not set permissions on {dst_path}: {e}")
files_updated += 1
except Exception as e:
print(f"Failed to copy {rel_path}: {e}")
else:
files_skipped += 1
return files_updated, files_skipped
def _backup_plugins(platform_name: str, app_name: str, source_dir: str, dest_dir: Optional[str] = None) -> bool:
"""
Backs up plugins from source directory to a temporary location.
Issue #113: Preserve plugins during updates (FALLBACK METHOD - rarely used now)
Returns True if backup was successful, False otherwise.
"""
plugins_src = os.path.join(source_dir, "plugins")
if not os.path.exists(plugins_src):
print("No plugins directory to backup.")
return False
# Create a temporary backup location (outside app dir so it won't be wiped)
import tempfile
backup_temp = tempfile.mkdtemp(prefix=f"{app_name}_plugins_backup_")
plugins_dest = os.path.join(backup_temp, "plugins")
try:
shutil.copytree(plugins_src, plugins_dest)
plugin_count = sum(1 for f in os.listdir(plugins_dest))
print(f"Backed up {plugin_count} plugin(s) to: {plugins_dest}")
# Store backup path in a marker file for restoration
marker_path = os.path.join(backup_temp, ".plugins_backup_marker")
with open(marker_path, "w") as f:
f.write(f"plugins_dir={plugins_dest}\n")
f.write(f"target_dir={source_dir}\n")
return True
except Exception as e:
print(f"Failed to backup plugins: {e}")
# Cleanup failed backup attempt
try:
shutil.rmtree(backup_temp, onexc=_remove_readonly)
except Exception:
pass
return False
def _restore_plugins(platform_name: str, app_name: str, source_dir: str) -> bool:
"""
Restores plugins from temporary backup to the application directory.
Issue #113: Preserve plugins during updates
Returns True if restoration was successful, False otherwise.
"""
import tempfile
# Find all plugin backup temp directories
temp_dir = tempfile.gettempdir()
backups_found = []
try:
for entry in os.listdir(temp_dir):
if entry.startswith(f"{app_name}_plugins_backup_"):
backup_path = os.path.join(temp_dir, entry)
marker_path = os.path.join(backup_path, ".plugins_backup_marker")
if os.path.exists(marker_path):
backups_found.append((backup_path, entry))
except Exception as e:
print(f"Error searching for plugin backups: {e}")
if not backups_found:
print("No plugin backups found to restore.")
return False
# Restore from all found backups (in case multiple exist)
plugins_restored = 0
for backup_path, entry_name in sorted(backups_found):
try:
marker_path = os.path.join(backup_path, ".plugins_backup_marker")
# Parse marker file to get original paths
backup_info = {}
with open(marker_path, "r") as f:
for line in f:
if "=" in line:
key, value = line.strip().split("=", 1)
backup_info[key] = value
plugins_src = backup_info.get("plugins_dir", os.path.join(backup_path, "plugins"))
# Create plugins directory if it doesn't exist
target_plugins_dir = os.path.join(source_dir, "plugins")
os.makedirs(target_plugins_dir, exist_ok=True)
# Copy each plugin to target
if os.path.exists(plugins_src):
for item in os.listdir(plugins_src):
src_item = os.path.join(plugins_src, item)
dst_item = os.path.join(target_plugins_dir, item)
if os.path.isdir(src_item):
if os.path.exists(dst_item):
shutil.rmtree(dst_item, onexc=_remove_readonly)
shutil.copytree(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
plugins_restored += 1
# Cleanup backup after successful restore
try:
shutil.rmtree(backup_path, onexc=_remove_readonly)
print(f"Cleaned up backup directory: {entry_name}")
except Exception as e:
print(f"Warning: Could not cleanup backup: {e}")
except Exception as e:
print(f"Failed to restore plugins from {entry_name}: {e}")
if plugins_restored > 0:
print(f"Restored {plugins_restored} plugin(s) to: {os.path.join(source_dir, 'plugins')}")
return True
return False
def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None: 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. Completes a pending application update if '--finish-update' is present in the command-line arguments.
Issue #113: Added smart selective update (only replaces changed files).
""" """
if not finish_update: if not finish_update:
@@ -748,12 +562,6 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
primary_update_folder = update_folders[0] 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 target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder
# 2a. Perform SMART SELECTIVE UPDATE (Issue #113)
target_base = get_app_dir()
print(f"Performing selective update from: {primary_update_folder}")
files_updated, files_skipped = _smart_selective_update(primary_update_folder, target_base)
print(f"Selective update complete: {files_updated} files updated, {files_skipped} files skipped")
# 3. Wait for updater process to exit # 3. Wait for updater process to exit
print(f"Waiting for {app_name}_updater to exit...") print(f"Waiting for {app_name}_updater to exit...")
@@ -824,8 +632,6 @@ def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, fi
else: else:
print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}") print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}")
# Note: _restore_plugins() no longer needed - selective update preserves plugins automatically
if "--finish-update" in sys.argv: if "--finish-update" in sys.argv:
sys.argv.remove("--finish-update") sys.argv.remove("--finish-update")