From 8ef352add3185b596b387b70b8a7fae28342d542 Mon Sep 17 00:00:00 2001 From: tyler Date: Mon, 31 Aug 2026 23:37:12 -0700 Subject: [PATCH] fix to app not updating --- .gitignore | 1 + changelog.md | 5 + flares_updater.py | 84 ++++++++------ updater.py | 278 ++++++++++++++++++++++++---------------------- 4 files changed, 201 insertions(+), 167 deletions(-) diff --git a/.gitignore b/.gitignore index fc1282a..44aecc9 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,7 @@ cython_debug/ .pypirc /individual_images +/plugins *.xlsx *.csv *.snirf diff --git a/changelog.md b/changelog.md index e647b2f..2c4588b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,8 @@ +# Version 1.7.1 + +- Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! + + # Version 1.7.0 - This is potentially a save-changing release due to adding more data into the save file. Please update your project files to ensure compatibility diff --git a/flares_updater.py b/flares_updater.py index 88f4783..0647971 100644 --- a/flares_updater.py +++ b/flares_updater.py @@ -20,21 +20,24 @@ from pathlib import Path from datetime import datetime # External library imports -from src.shared.shareddata import APP_NAME, PLATFORM_NAME +from src.shared.shareddata import APP_NAME, PLATFORM_NAME, get_app_dir if PLATFORM_NAME == 'darwin': _log_path = os.path.join(os.path.dirname(sys.executable), f"../../../{APP_NAME}_updater.log") else: - _log_path = os.path.join(os.getcwd(), f"{APP_NAME}_updater.log") + _log_path = os.path.join(get_app_dir(), f"{APP_NAME}_updater.log") LOG_FILE = _log_path def log(msg: str) -> None: - with open(LOG_FILE, "a", encoding="utf-8") as f: - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - f.write(f"{timestamp} - {msg}\n") + try: + with open(LOG_FILE, "a", encoding="utf-8") as f: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write(f"{timestamp} - {msg}\n") + except Exception: + pass def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool: @@ -45,22 +48,28 @@ def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool: for proc in psutil.process_iter(['pid', 'exe']): try: proc_exe = proc.info.get('exe') - if proc_exe and os.path.samefile(os.path.realpath(proc_exe), exe_path): - log(f"Terminating process: PID {proc.pid}") - _terminate_process(proc) - terminated_any = True + if proc_exe and os.path.exists(proc_exe) and os.path.exists(exe_path): + if os.path.samefile(os.path.realpath(proc_exe), exe_path): + log(f"Terminating process: PID {proc.pid}") + _terminate_process(proc) + terminated_any = True + except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e: + continue except Exception as e: log(f"Error terminating process (Windows): {e}") + elif PLATFORM_NAME == 'linux': for proc in psutil.process_iter(['pid', 'cmdline']): try: cmdline = proc.info.get('cmdline', []) - if cmdline: + if cmdline and os.path.exists(cmdline[0]) and os.path.exists(exe_path): proc_cmd = os.path.realpath(cmdline[0]) if os.path.samefile(proc_cmd, exe_path): log(f"Terminating process: PID {proc.pid}") _terminate_process(proc) terminated_any = True + except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError) as e: + continue except Exception as e: log(f"Error terminating process (Linux): {e}") @@ -82,6 +91,9 @@ def _terminate_process(proc: psutil.Process) -> None: def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None: + if not os.path.exists(path): + return + start_time = time.time() while time.time() - start_time < timeout: try: @@ -95,7 +107,7 @@ def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> log(f"Still locked: {path} - {e}") time.sleep(1) log(f"Failed to delete after wait: {path}") - + def delete_path(path: Union[str, Path]) -> None: if os.path.exists(path): @@ -130,8 +142,8 @@ def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None: - - updater_name = updater_name + ".app" + if not updater_name.endswith(".app"): + updater_name = updater_name + ".app" for item in os.listdir(src_folder): if item.lower() == updater_name.lower(): @@ -152,10 +164,7 @@ def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[st def remove_quarantine(app_path: Union[str, Path]) -> bool: - """Removes the macOS quarantine extended attribute from an application bundle using osascript. - Returns True on success, False on error or cancellation. - """ - + """Removes the macOS quarantine extended attribute from an application bundle using osascript.""" clean_path: str = str(app_path) escaped_path: str = shlex.quote(clean_path) @@ -172,7 +181,7 @@ def remove_quarantine(app_path: Union[str, Path]) -> bool: return False -def main(): +def main() -> None: main_exe: str = "" app_dir: Path = Path() bundle_dir: Path = Path() @@ -189,17 +198,24 @@ def main(): main_exe = sys.argv[2] main_exe_path = Path(main_exe).resolve() - app_dir = main_exe_path.parent - bundle_dir = main_exe_path.parents[2] - parent_bundle_dir = main_exe_path.parents[3] + app_dir = main_exe_path.parent - updater_name = os.path.basename(sys.argv[0]) + if PLATFORM_NAME == 'darwin': + # Bundle hierarchy on macOS: App.app/Contents/MacOS/executable + if len(main_exe_path.parents) >= 4: + bundle_dir = main_exe_path.parents[2] + parent_bundle_dir = main_exe_path.parents[3] + else: + bundle_dir = app_dir + parent_bundle_dir = app_dir.parent + + updater_name = os.path.basename(sys.executable if getattr(sys, 'frozen', False) else sys.argv[0]) log("Updater started.") log(f"Update folder: {update_folder}") log(f"Main EXE: {main_exe}") log(f"Updater EXE: {updater_name}") - if PLATFORM_NAME == 'darwin': + if PLATFORM_NAME == 'darwin' and bundle_dir: log(f"Main App Folder: {bundle_dir}") # Kill all instances of main app @@ -212,18 +228,22 @@ def main(): try: if PLATFORM_NAME == 'windows': proc_exe = proc.info.get('exe') - if proc_exe and os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)): - running = True - break + if proc_exe and os.path.exists(proc_exe) and os.path.exists(main_exe): + if os.path.samefile(os.path.realpath(proc_exe), os.path.realpath(main_exe)): + running = True + break elif PLATFORM_NAME == 'linux': cmdline = proc.info.get('cmdline', []) - if cmdline: + if cmdline and os.path.exists(cmdline[0]) and os.path.exists(main_exe): proc_cmd = os.path.realpath(cmdline[0]) if os.path.samefile(proc_cmd, os.path.realpath(main_exe)): running = True break + except (psutil.NoSuchProcess, psutil.AccessDenied, FileNotFoundError): + continue except Exception as e: log(f"Polling error: {e}") + if not running: break time.sleep(0.5) @@ -231,11 +251,11 @@ def main(): log("Warning: main executable still running after wait timeout.") # Delete old version files - if PLATFORM_NAME == 'darwin': + if PLATFORM_NAME == 'darwin' and bundle_dir and parent_bundle_dir: log(f'Attempting to delete {bundle_dir}') delete_path(str(bundle_dir)) - update_folder = os.path.join(sys.argv[1], f"{APP_NAME}-darwin") - copy_update_files_darwin(update_folder, str(parent_bundle_dir), updater_name) + extracted_update_folder = os.path.join(update_folder, f"{APP_NAME}-darwin") + copy_update_files_darwin(extracted_update_folder, str(parent_bundle_dir), updater_name) else: delete_path(main_exe) @@ -252,8 +272,8 @@ def main(): if PLATFORM_NAME == 'linux': os.chmod(main_exe, 0o755) log("Added executable bit") - - if PLATFORM_NAME == 'darwin': + + if PLATFORM_NAME == 'darwin' and bundle_dir: os.chmod(str(bundle_dir), 0o755) log("Added executable bit") remove_quarantine(str(bundle_dir)) diff --git a/updater.py b/updater.py index 3778781..73aed27 100644 --- a/updater.py +++ b/updater.py @@ -18,7 +18,8 @@ import zipfile import traceback import subprocess import configparser -from typing import List +from typing import List, Tuple, Optional, Callable, Any + # External library imports import psutil @@ -26,7 +27,7 @@ import requests from PySide6.QtCore import QThread, Signal, QObject from PySide6.QtWidgets import QMainWindow, QMessageBox - +from src.shared.shareddata import get_app_dir class UpdateDownloadThread(QThread): """ @@ -54,7 +55,7 @@ class UpdateDownloadThread(QThread): self.platform_name = platform_name self.app_name = app_name - def run(self): + def run(self) -> None: try: local_filename = os.path.basename(self.download_url) @@ -63,8 +64,8 @@ class UpdateDownloadThread(QThread): os.makedirs(tmp_dir, exist_ok=True) local_path = os.path.join(tmp_dir, local_filename) else: - tmp_dir = os.getcwd() - local_path = os.path.join(os.getcwd(), local_filename) + tmp_dir = get_app_dir() + local_path = os.path.join(tmp_dir, local_filename) # Download the file with requests.get(self.download_url, stream=True, timeout=15) as r: @@ -78,10 +79,9 @@ class UpdateDownloadThread(QThread): if self.platform_name == 'darwin': extract_folder = os.path.splitext(local_filename)[0] extract_path = os.path.join(tmp_dir, extract_folder) - else: extract_folder = os.path.splitext(local_filename)[0] - extract_path = os.path.join(os.getcwd(), extract_folder) + extract_path = os.path.join(get_app_dir(), extract_folder) # Create the folder if not exists os.makedirs(extract_path, exist_ok=True) @@ -132,10 +132,7 @@ class UpdateCheckThread(QThread): self.platform_name = platform_name self.app_name = app_name - def run(self): - # if not getattr(sys, 'frozen', False): - # self.error_occurred.emit("Application is not frozen (Development mode).") - # return + def run(self) -> None: try: latest_version, download_url = self.get_latest_release_for_platform() if not latest_version: @@ -159,11 +156,10 @@ class UpdateCheckThread(QThread): return [int(x) for x in v.split(".")] return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2)) - def get_latest_release_for_platform(self): + def get_latest_release_for_platform(self) -> Tuple[Optional[str], Optional[str]]: urls = [self.api_url, self.api_url_sec] for url in urls: try: - response = requests.get(url, timeout=5) response.raise_for_status() releases = response.json() @@ -219,11 +215,11 @@ class LocalPendingUpdateCheckThread(QThread): return [int(x) for x in v.split(".")] return (normalize(v1) > normalize(v2)) - (normalize(v1) < normalize(v2)) - def run(self): + def run(self) -> None: if self.platform_name == 'darwin': cwd = f'/tmp/{self.app_name}tempupdate' else: - cwd = os.getcwd() + cwd = get_app_dir() pattern = re.compile(r".*-(\d+\.\d+\.\d+)" + re.escape(self.platform_suffix) + r"$") found = False @@ -239,7 +235,7 @@ class LocalPendingUpdateCheckThread(QThread): self.pending_update_found.emit(folder_version, folder_path) found = True break - except: + except Exception: pass if not found: @@ -272,9 +268,8 @@ class UpdateManager(QObject): self.platform_suffix = platform_suffix self.app_name = app_name - self.pending_update_version = None - self.pending_update_path = None - + self.pending_update_version: Optional[str] = None + self.pending_update_path: Optional[str] = None def manual_check_for_updates(self) -> None: self.local_check_thread = LocalPendingUpdateCheckThread(self.current_version, self.platform_suffix, self.platform_name, self.app_name) @@ -283,14 +278,15 @@ class UpdateManager(QObject): self.local_check_thread.start() def on_pending_update_found(self, version: str, folder_path: str) -> None: - self.main_window.statusBar().showMessage(f"Pending update found: version {version}") + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage(f"Pending update found: version {version}") self.pending_update_version = version self.pending_update_path = folder_path self.show_pending_update_popup() def on_no_pending_update(self) -> None: - # No pending update found locally, start server check directly - self.main_window.statusBar().showMessage("No pending local update found. Checking server...") + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage("No pending local update found. Checking server...") self.start_update_check_thread() def show_pending_update_popup(self) -> None: @@ -306,7 +302,6 @@ class UpdateManager(QObject): else: if self.main_window.statusBar(): self.main_window.statusBar().showMessage("Pending update available. Install later.") - # After user dismisses, still check the server for new updates self.start_update_check_thread() def start_update_check_thread(self) -> None: @@ -327,13 +322,15 @@ class UpdateManager(QObject): if pending_version and pending_path: cmp = self.version_compare(latest_version, pending_version) if cmp > 0: - # Server version is newer than pending update - self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...") + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage(f"Newer version {latest_version} available on server. Removing old pending update...") try: shutil.rmtree(pending_path) - self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}") + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage(f"Deleted old update folder: {pending_path}") except Exception as e: - self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}") + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage(f"Failed to delete old update folder: {e}") # Clear pending update info so new download proceeds self.pending_update_version = None @@ -342,8 +339,8 @@ class UpdateManager(QObject): # Download the new update self.download_update(download_url, latest_version) elif cmp == 0: - # Versions equal, no download needed - self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.") + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is already latest. No download needed.") else: # Server version older than pending? Unlikely but just keep pending update self.main_window.statusBar().showMessage(f"Pending update version {self.pending_update_version} is newer than server version. No action.") @@ -374,24 +371,26 @@ class UpdateManager(QObject): if msg_box.clickedButton() == install_now_button: self.install_update(extract_folder) else: - self.main_window.statusBar().showMessage("Update ready. Install later.") - + if self.main_window.statusBar(): + self.main_window.statusBar().showMessage("Update ready. Install later.") def install_update(self, extract_folder: str) -> None: - # Path to updater executable + base_dir = get_app_dir() + # Path to updater executable if self.platform_name == 'windows': - updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater.exe") + updater_path = os.path.join(base_dir, f"{self.app_name}_updater.exe") elif self.platform_name == 'darwin': if getattr(sys, 'frozen', False): - updater_path = os.path.join(os.path.dirname(sys.executable), f"../../../{self.app_name}_updater.app") + updater_path = os.path.join(base_dir, f"../../../{self.app_name}_updater.app") else: - updater_path = os.path.join(os.getcwd(), f"../{self.app_name}_updater.app") - + updater_path = os.path.join(base_dir, f"../{self.app_name}_updater.app") elif self.platform_name == 'linux': - updater_path = os.path.join(os.getcwd(), f"{self.app_name}_updater") + updater_path = os.path.join(base_dir, f"{self.app_name}_updater") else: - updater_path = os.getcwd() + updater_path = base_dir + + updater_path = os.path.abspath(updater_path) if not os.path.exists(updater_path): QMessageBox.critical(self.main_window, "Error", f"Updater not found at:\n{updater_path}. The absolute path was {os.path.abspath(updater_path)}") @@ -399,8 +398,7 @@ class UpdateManager(QObject): # Launch updater with extracted folder path as argument try: - # Pass current app's executable path for updater to relaunch - main_app_executable = os.path.abspath(sys.argv[0]) + main_app_executable = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(sys.argv[0]) print(f'Launching updater with: "{updater_path}" "{extract_folder}" "{main_app_executable}"') @@ -457,121 +455,131 @@ def wait_for_process_to_exit(process_name: str, timeout: int = 10) -> bool: return False +def _remove_readonly(func: Callable[[str], Any], path: str, exc: Any) -> None: + """Error handler for shutil.rmtree to clear read-only files on Windows.""" + import stat + os.chmod(path, stat.S_IWRITE) + func(path) + + def finish_update_if_needed(platform_name: str, app_name: str, cfg_path: str, finish_update: bool) -> None: """ Completes a pending application update if '--finish-update' is present in the command-line arguments. """ - if finish_update: - print("Finishing update...") - - update_cfg = configparser.ConfigParser() - try: - update_cfg.read(cfg_path) + if not finish_update: + return + + print("Finishing update...") + + # 1. Reset welcome dialog config flag + update_cfg = configparser.ConfigParser() + try: + if os.path.exists(cfg_path): + update_cfg.read(cfg_path) + if not update_cfg.has_section("Options"): + update_cfg.add_section("Options") update_cfg.set("Options", "show_welcome_dialog", "true") with open(cfg_path, "w") as f: update_cfg.write(f) print("Welcome dialog flag successfully reset to 'true' for next run.") - except Exception as e: - print(f"Warning: Could not update welcome dialog preference flag: {e}") + except Exception as e: + print(f"Warning: Could not update welcome dialog preference flag: {e}") - if platform_name == 'darwin': - app_dir = f'/tmp/{app_name}tempupdate' - else: - app_dir = os.getcwd() + app_dir = f'/tmp/{app_name}tempupdate' if platform_name == 'darwin' else get_app_dir() - # 1. Find update folder - update_folder = None + if not os.path.exists(app_dir): + print(f"App directory does not exist: {app_dir}. Skipping cleanup.") + return + + # 2. Find update folder(s) + update_folders: List[str] = [] + try: for entry in os.listdir(app_dir): entry_path = os.path.join(app_dir, entry) if os.path.isdir(entry_path) and entry.startswith(f"{app_name}-") and entry.endswith("-" + platform_name): - update_folder = os.path.join(app_dir, entry) - break + update_folders.append(entry_path) + except Exception as e: + print(f"Error scanning app directory for update folders: {e}") - if update_folder is None: - print("No update folder found. Skipping update steps.") - return - - if platform_name == 'darwin': - update_folder = os.path.join(update_folder, f"{app_name}-darwin") + if not update_folders: + print("No update folder found. Skipping update steps.") + return - # 2. Wait for updater to exit - print(f"Waiting for {app_name}_updater to exit...") - for proc in psutil.process_iter(['pid', 'name']): - if proc.info['name'] and f"{app_name}_updater" in proc.info['name'].lower(): - try: - proc.wait(timeout=5) - except psutil.TimeoutExpired: - print(f"Force killing lingering {app_name}_updater") - proc.kill() + primary_update_folder = update_folders[0] + target_updater_folder = os.path.join(primary_update_folder, f"{app_name}-darwin") if platform_name == 'darwin' else primary_update_folder - # 3. Replace the updater - if platform_name == 'windows': - new_updater = os.path.join(update_folder, f"{app_name}_updater.exe") - dest_updater = os.path.join(app_dir, f"{app_name}_updater.exe") - - elif platform_name == 'darwin': - new_updater = os.path.join(update_folder, f"{app_name}_updater.app") - dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app")) - - elif platform_name == 'linux': - new_updater = os.path.join(update_folder, f"{app_name}_updater") - dest_updater = os.path.join(app_dir, f"{app_name}_updater") - - else: - print("Unknown Platform") - new_updater = os.getcwd() - dest_updater = os.getcwd() - - print(f"New updater is {new_updater}") - print(f"Dest updater is {dest_updater}") - - print("Writable?", os.access(dest_updater, os.W_OK)) - print("Executable path:", sys.executable) - print("Trying to copy:", new_updater, "->", dest_updater) - - if os.path.exists(new_updater): - try: - if os.path.exists(dest_updater): - if platform_name == 'darwin': - try: - if os.path.isdir(dest_updater): - shutil.rmtree(dest_updater) - print(f"Deleted directory: {dest_updater}") - else: - os.remove(dest_updater) - print(f"Deleted file: {dest_updater}") - except Exception as e: - print(f"Error deleting {dest_updater}: {e}") - else: - os.remove(dest_updater) - - if platform_name == 'darwin': - wait_for_process_to_exit(f"{app_name}_updater", timeout=10) - subprocess.check_call(["ditto", new_updater, dest_updater]) - else: - shutil.copy2(new_updater, dest_updater) - - if platform_name in ('linux', 'darwin'): - os.chmod(dest_updater, 0o755) - - if platform_name == 'darwin': - remove_quarantine(dest_updater, app_name) - - print(f"{app_name}_updater replaced.") - except Exception as e: - print(f"Failed to replace {app_name}_updater: {e}") - - # 4. Delete the update folder + # 3. Wait for updater process to exit + print(f"Waiting for {app_name}_updater to exit...") + updater_bin_name = f"{app_name}_updater" + for proc in psutil.process_iter(['pid', 'name']): try: - if platform_name == 'darwin': - shutil.rmtree(app_dir) - else: - shutil.rmtree(update_folder) - except Exception as e: - print(f"Failed to delete update folder: {e}") + if proc.info['name'] and updater_bin_name.lower() in proc.info['name'].lower(): + proc.wait(timeout=5) + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + except psutil.TimeoutExpired: + print(f"Force killing lingering {app_name}_updater") + try: + proc.kill() + except Exception: + pass + # 4. Replace the updater executable + target_base = get_app_dir() + if platform_name == 'windows': + new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.exe") + dest_updater = os.path.join(target_base, f"{app_name}_updater.exe") + elif platform_name == 'darwin': + new_updater = os.path.join(target_updater_folder, f"{app_name}_updater.app") + dest_updater = os.path.abspath(os.path.join(sys.executable, f"../../../../{app_name}_updater.app")) + elif platform_name == 'linux': + new_updater = os.path.join(target_updater_folder, f"{app_name}_updater") + dest_updater = os.path.join(target_base, f"{app_name}_updater") + else: + new_updater = target_base + dest_updater = target_base + + if os.path.exists(new_updater): + try: + if os.path.exists(dest_updater): + if platform_name == 'darwin' and os.path.isdir(dest_updater): + shutil.rmtree(dest_updater, onexc=_remove_readonly) + else: + os.remove(dest_updater) + + if platform_name == 'darwin': + wait_for_process_to_exit(f"{app_name}_updater", timeout=10) + subprocess.check_call(["ditto", new_updater, dest_updater]) + else: + shutil.copy2(new_updater, dest_updater) + + if platform_name in ('linux', 'darwin'): + os.chmod(dest_updater, 0o755) + + if platform_name == 'darwin': + remove_quarantine(dest_updater, app_name) + + print(f"{app_name}_updater replaced successfully.") + except Exception as e: + print(f"Failed to replace {app_name}_updater: {e}") + + # 5. Clean up all temporary update folders (with retries for Windows file locks) + for folder in update_folders: + for attempt in range(3): + try: + if os.path.exists(folder): + shutil.rmtree(folder, onexc=_remove_readonly) + print(f"Successfully deleted update folder: {folder}") + break + except Exception as e: + if attempt < 2: + time.sleep(1.0) + else: + print(f"Failed to delete update folder '{folder}' after 3 attempts: {e}") + + if "--finish-update" in sys.argv: sys.argv.remove("--finish-update")