fix to app not updating

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