""" Filename: flares_updater.py Description: FLARES updater executable Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports import os import sys import time import shlex import psutil import shutil import subprocess from typing import Union from pathlib import Path from datetime import datetime # External library imports from src.shared.shareddata import APP_NAME, PLATFORM_NAME 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_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") def kill_all_processes_by_executable(exe_path: Union[str, Path]) -> bool: terminated_any = False exe_path = os.path.realpath(exe_path) if PLATFORM_NAME == 'windows': 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 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: 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 Exception as e: log(f"Error terminating process (Linux): {e}") if not terminated_any: log(f"No running processes found for {exe_path}") return terminated_any def _terminate_process(proc: psutil.Process) -> None: try: proc.terminate() proc.wait(timeout=10) log(f"Process {proc.pid} terminated gracefully.") except psutil.TimeoutExpired: log(f"Process {proc.pid} did not terminate in time. Killing forcefully.") proc.kill() proc.wait(timeout=5) log(f"Process {proc.pid} killed.") def wait_for_unlock(path: Union[str, Path], timeout: Union[int, float] = 100) -> None: start_time = time.time() while time.time() - start_time < timeout: try: if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) log(f"Deleted (after wait): {path}") return except Exception as e: 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): try: if os.path.isdir(path): shutil.rmtree(path) log(f"Deleted directory: {path}") else: os.remove(path) log(f"Deleted file: {path}") except Exception as e: log(f"Error deleting {path}: {e}") def copy_update_files(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None: for item in os.listdir(src_folder): if item.lower() == updater_name.lower(): log(f"Skipping updater executable: {item}") continue s = os.path.join(src_folder, item) d = os.path.join(dest_folder, item) delete_path(d) try: if os.path.isdir(s): shutil.copytree(s, d) log(f"Copied folder: {s} -> {d}") else: shutil.copy2(s, d) log(f"Copied file: {s} -> {d}") except Exception as e: log(f"Error copying {s} -> {d}: {e}") def copy_update_files_darwin(src_folder: Union[str, Path], dest_folder: Union[str, Path], updater_name: str) -> None: updater_name = updater_name + ".app" for item in os.listdir(src_folder): if item.lower() == updater_name.lower(): log(f"Skipping updater executable: {item}") continue s = os.path.join(src_folder, item) d = os.path.join(dest_folder, item) delete_path(d) try: if os.path.isdir(s): subprocess.check_call(["ditto", s, d]) log(f"Copied folder with ditto: {s} -> {d}") else: shutil.copy2(s, d) log(f"Copied file: {s} -> {d}") except Exception as e: log(f"Error copying {s} -> {d}: {e}") 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. """ clean_path: str = str(app_path) escaped_path: str = shlex.quote(clean_path) script = f''' do shell script "xattr -d -r com.apple.quarantine {escaped_path}" with administrator privileges with prompt "{APP_NAME} needs privileges to finish the update. (1/2)" ''' try: subprocess.run(["osascript", "-e", script], check=True) print("✅ Quarantine attribute removed.") return True except subprocess.CalledProcessError as e: print("❌ Failed to remove quarantine attribute.") print(e) return False def main(): main_exe: str = "" app_dir: Path = Path() bundle_dir: Path = Path() parent_bundle_dir: Path = Path() try: log(f"[Updater] sys.argv: {sys.argv}") if len(sys.argv) != 3: log(f"Invalid arguments. Usage: {APP_NAME}_updater ") sys.exit(1) update_folder = sys.argv[1] 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] updater_name = os.path.basename(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': log(f"Main App Folder: {bundle_dir}") # Kill all instances of main app kill_all_processes_by_executable(main_exe) # Wait until main_exe process is fully gone (polling) for _ in range(10): # wait max 10 seconds running = False for proc in psutil.process_iter(['exe', 'cmdline']): 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 elif PLATFORM_NAME == 'linux': cmdline = proc.info.get('cmdline', []) if cmdline: proc_cmd = os.path.realpath(cmdline[0]) if os.path.samefile(proc_cmd, os.path.realpath(main_exe)): running = True break except Exception as e: log(f"Polling error: {e}") if not running: break time.sleep(0.5) else: log("Warning: main executable still running after wait timeout.") # Delete old version files if PLATFORM_NAME == 'darwin': 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) else: delete_path(main_exe) wait_for_unlock(os.path.join(str(app_dir), "_internal")) # Copy new files excluding the updater itself copy_update_files(update_folder, str(app_dir), updater_name) except Exception as e: log(f"Something went wrong: {e}") # Relaunch main app try: if PLATFORM_NAME == 'linux': os.chmod(main_exe, 0o755) log("Added executable bit") if PLATFORM_NAME == 'darwin': os.chmod(str(bundle_dir), 0o755) log("Added executable bit") remove_quarantine(str(bundle_dir)) log(f"Removed the quarantine flag on {bundle_dir}") subprocess.Popen(['open', str(bundle_dir), "--args", "--finish-update"]) else: subprocess.Popen([main_exe, "--finish-update"], cwd=str(app_dir)) log("Relaunched main app.") except Exception as e: log(f"Failed to relaunch main app: {e}") log("Updater completed. Exiting.") sys.exit(0) if __name__ == "__main__": main()