Compare commits
4
Commits
8c432f3da4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ba3b30575 | ||
|
|
e2cb49ceb9 | ||
|
|
2d1133e099 | ||
|
|
6f4095933a |
No files matched your search
@@ -6851,6 +6851,8 @@ 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)
|
||||||
@@ -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(
|
||||||
@@ -7167,7 +7188,23 @@ def write_qc_excel_summary(
|
|||||||
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
param_values, self.haemo_dict, self.epochs_dict
|
||||||
|
)
|
||||||
|
|
||||||
if selected_event:
|
# Connect signals
|
||||||
raw_annotations = getattr(haemo_obj, "annotations", None)
|
self.worker.progress.connect(lambda msg: progress_dialog.setLabelText(msg))
|
||||||
|
self.worker.finished.connect(self._on_fc_analysis_complete)
|
||||||
|
self.worker.finished.connect(progress_dialog.close)
|
||||||
|
|
||||||
if raw_annotations is not None:
|
# Start processing
|
||||||
annotations = cast(Annotations, raw_annotations)
|
self.worker.start()
|
||||||
descriptions = cast(list[str], list(annotations.description))
|
|
||||||
participant_events: set[str] = set(descriptions)
|
def _on_fc_analysis_complete(self, success: bool, error_msg: str) -> None:
|
||||||
|
"""Handle completion of functional connectivity analysis."""
|
||||||
|
if success:
|
||||||
|
QMessageBox.information(
|
||||||
|
self, f"Success - {APP_NAME.upper()}",
|
||||||
|
"Functional connectivity analysis completed!\n\n"
|
||||||
|
"Check the output files in your results directory."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
participant_events: set[str] = set()
|
QMessageBox.critical(
|
||||||
|
self, f"Error - {APP_NAME.upper()}",
|
||||||
if selected_event not in participant_events:
|
f"Analysis failed:\n{error_msg}"
|
||||||
print(f"Skipping {self.participant_map[file_path]}: Event '{selected_event}' not found.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
for idx in selected_indexes:
|
|
||||||
params = param_values.get(idx, {})
|
|
||||||
if idx == 0:
|
|
||||||
method = params.get("method", "wpli2_debiased")
|
|
||||||
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)
|
|
||||||
|
|
||||||
functional_connectivity_spectral_epochs(epochs=epochs_obj, n_lines=n_lines, vmin=vmin, fmin=fmin, fmax=fmax, method=method)
|
|
||||||
|
|
||||||
elif idx == 1:
|
|
||||||
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:
|
self.worker.deleteLater() # Clean up worker thread
|
||||||
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}")
|
|
||||||
+17
-211
@@ -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,6 +200,12 @@ class UpdateCheckThread(QThread):
|
|||||||
if not releases:
|
if not releases:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 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)
|
latest = next((r for r in releases if not r.get("prerelease") and not r.get("draft")), None)
|
||||||
|
|
||||||
if not latest:
|
if not latest:
|
||||||
@@ -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:
|
||||||
@@ -749,12 +563,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...")
|
||||||
updater_bin_name = f"{app_name}_updater"
|
updater_bin_name = f"{app_name}_updater"
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user