Files
flares/flares_updater.py
T
2026-08-23 00:12:22 -07:00

274 lines
9.2 KiB
Python

"""
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 <update_folder> <main_app_executable>")
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()