233 lines
9.6 KiB
Python
233 lines
9.6 KiB
Python
import hashlib
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import urllib.request
|
|
import zipfile
|
|
|
|
|
|
class AutoUpdateService:
|
|
@staticmethod
|
|
def _safe_extract_zip(archive, destination_dir):
|
|
destination_real = os.path.realpath(destination_dir)
|
|
for member in archive.infolist():
|
|
member_name = member.filename or ""
|
|
if not member_name:
|
|
continue
|
|
target_path = os.path.realpath(os.path.join(destination_dir, member_name))
|
|
if target_path != destination_real and not target_path.startswith(destination_real + os.sep):
|
|
raise RuntimeError(f"Unsafe path in update archive: {member_name}")
|
|
archive.extractall(destination_dir)
|
|
|
|
@staticmethod
|
|
def download_update_archive(download_url, destination_path):
|
|
request = urllib.request.Request(
|
|
download_url,
|
|
headers={"User-Agent": "AnabasisManager-Updater"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=60) as response:
|
|
with open(destination_path, "wb") as f:
|
|
shutil.copyfileobj(response, f)
|
|
|
|
@staticmethod
|
|
def download_update_text(url):
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "AnabasisManager-Updater"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return response.read().decode("utf-8", errors="replace")
|
|
|
|
@staticmethod
|
|
def sha256_file(path):
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest().lower()
|
|
|
|
@staticmethod
|
|
def extract_sha256_from_text(checksum_text, target_file_name):
|
|
target = (target_file_name or "").strip().lower()
|
|
for raw_line in checksum_text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
match = re.search(r"\b([A-Fa-f0-9]{64})\b", line)
|
|
if not match:
|
|
continue
|
|
checksum = match.group(1).lower()
|
|
if not target:
|
|
return checksum
|
|
line_lower = line.lower()
|
|
if target in line_lower:
|
|
return checksum
|
|
if os.path.basename(target) in line_lower:
|
|
return checksum
|
|
return ""
|
|
|
|
@classmethod
|
|
def verify_update_checksum(cls, zip_path, checksum_url, download_name):
|
|
if not checksum_url:
|
|
raise RuntimeError("В релизе нет файла SHA256. Автообновление остановлено.")
|
|
checksum_text = cls.download_update_text(checksum_url)
|
|
expected_hash = cls.extract_sha256_from_text(checksum_text, download_name or os.path.basename(zip_path))
|
|
if not expected_hash:
|
|
raise RuntimeError("Не удалось найти SHA256 для архива обновления.")
|
|
actual_hash = cls.sha256_file(zip_path)
|
|
if actual_hash != expected_hash:
|
|
raise RuntimeError("SHA256 не совпадает, обновление отменено.")
|
|
|
|
@staticmethod
|
|
def locate_extracted_root(extracted_dir):
|
|
entries = []
|
|
for name in os.listdir(extracted_dir):
|
|
full_path = os.path.join(extracted_dir, name)
|
|
if os.path.isdir(full_path):
|
|
entries.append(full_path)
|
|
if len(entries) == 1:
|
|
candidate = entries[0]
|
|
if os.path.exists(os.path.join(candidate, "AnabasisManager.exe")):
|
|
return candidate
|
|
return extracted_dir
|
|
|
|
@staticmethod
|
|
def build_update_script(app_dir, source_dir, exe_name, target_pid):
|
|
script_path = os.path.join(tempfile.gettempdir(), "anabasis_apply_update.cmd")
|
|
script_lines = [
|
|
"@echo off",
|
|
"setlocal EnableExtensions",
|
|
f"set \"APP_DIR={app_dir}\"",
|
|
f"set \"SRC_DIR={source_dir}\"",
|
|
f"set \"EXE_NAME={exe_name}\"",
|
|
f"set \"TARGET_PID={target_pid}\"",
|
|
"set \"BACKUP_DIR=%TEMP%\\anabasis_backup_%RANDOM%%RANDOM%\"",
|
|
"set \"UPDATE_LOG=%APP_DIR%\\update_error.log\"",
|
|
"echo [%DATE% %TIME%] Update start > \"%UPDATE_LOG%\"",
|
|
"if not exist \"%SRC_DIR%\\%EXE_NAME%\" (",
|
|
" echo Source executable not found: \"%SRC_DIR%\\%EXE_NAME%\" >> \"%UPDATE_LOG%\"",
|
|
" exit /b 3",
|
|
")",
|
|
"set /a WAIT_LOOPS=0",
|
|
":wait_for_exit",
|
|
"tasklist /FI \"PID eq %TARGET_PID%\" | find \"%TARGET_PID%\" >nul",
|
|
"if %ERRORLEVEL% EQU 0 (",
|
|
" set /a WAIT_LOOPS+=1",
|
|
" if %WAIT_LOOPS% GEQ 180 (",
|
|
" echo Timeout waiting for process %TARGET_PID%, attempting force stop >> \"%UPDATE_LOG%\"",
|
|
" taskkill /PID %TARGET_PID% /T /F >nul 2>&1",
|
|
" timeout /t 2 /nobreak >nul",
|
|
" tasklist /FI \"PID eq %TARGET_PID%\" | find \"%TARGET_PID%\" >nul",
|
|
" if %ERRORLEVEL% EQU 0 goto :pid_still_running",
|
|
" goto :wait_image_unlock",
|
|
" )",
|
|
" timeout /t 1 /nobreak >nul",
|
|
" goto :wait_for_exit",
|
|
")",
|
|
":wait_image_unlock",
|
|
"set /a IMG_LOOPS=0",
|
|
":check_image",
|
|
"tasklist /FI \"IMAGENAME eq %EXE_NAME%\" | find /I \"%EXE_NAME%\" >nul",
|
|
"if %ERRORLEVEL% EQU 0 (",
|
|
" set /a IMG_LOOPS+=1",
|
|
" if %IMG_LOOPS% GEQ 60 goto :image_still_running",
|
|
" timeout /t 1 /nobreak >nul",
|
|
" goto :check_image",
|
|
")",
|
|
":backup",
|
|
"timeout /t 1 /nobreak >nul",
|
|
"mkdir \"%BACKUP_DIR%\" >nul 2>&1",
|
|
"robocopy \"%APP_DIR%\" \"%BACKUP_DIR%\" /E /NFL /NDL /NJH /NJS /NP /R:6 /W:2 >nul",
|
|
"set \"RC=%ERRORLEVEL%\"",
|
|
"if %RC% GEQ 8 goto :backup_error",
|
|
"robocopy \"%SRC_DIR%\" \"%APP_DIR%\" /E /NFL /NDL /NJH /NJS /NP /R:12 /W:2 >nul",
|
|
"set \"RC=%ERRORLEVEL%\"",
|
|
"if %RC% GEQ 8 goto :rollback",
|
|
"start \"\" \"%APP_DIR%\\%EXE_NAME%\"",
|
|
"timeout /t 2 /nobreak >nul",
|
|
"tasklist /FI \"IMAGENAME eq %EXE_NAME%\" | find /I \"%EXE_NAME%\" >nul",
|
|
"if %ERRORLEVEL% NEQ 0 goto :rollback",
|
|
"echo Update success >> \"%UPDATE_LOG%\"",
|
|
"rmdir /S /Q \"%BACKUP_DIR%\" >nul 2>&1",
|
|
"exit /b 0",
|
|
":rollback",
|
|
"robocopy \"%BACKUP_DIR%\" \"%APP_DIR%\" /E /NFL /NDL /NJH /NJS /NP /R:6 /W:2 >nul",
|
|
"start \"\" \"%APP_DIR%\\%EXE_NAME%\"",
|
|
"echo Auto-update failed. Rollback executed. >> \"%UPDATE_LOG%\"",
|
|
"exit /b 2",
|
|
":backup_error",
|
|
"echo Auto-update failed during backup. Code %RC% >> \"%UPDATE_LOG%\"",
|
|
"exit /b %RC%",
|
|
":pid_still_running",
|
|
"echo Auto-update aborted: process %TARGET_PID% is still running after force stop. >> \"%UPDATE_LOG%\"",
|
|
"exit /b 4",
|
|
":image_still_running",
|
|
"echo Auto-update aborted: %EXE_NAME% still running and file lock may remain. >> \"%UPDATE_LOG%\"",
|
|
"exit /b 5",
|
|
]
|
|
with open(script_path, "w", encoding="utf-8", newline="\r\n") as f:
|
|
f.write("\r\n".join(script_lines) + "\r\n")
|
|
return script_path
|
|
|
|
@staticmethod
|
|
def launch_update_script(script_path, work_dir):
|
|
creation_flags = 0
|
|
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
|
creation_flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
|
if hasattr(subprocess, "DETACHED_PROCESS"):
|
|
creation_flags |= subprocess.DETACHED_PROCESS
|
|
subprocess.Popen(
|
|
["cmd.exe", "/c", script_path],
|
|
cwd=work_dir,
|
|
creationflags=creation_flags,
|
|
)
|
|
|
|
@staticmethod
|
|
def launch_gui_updater(app_exe, source_dir, work_dir, target_pid, version=""):
|
|
app_dir = os.path.dirname(app_exe)
|
|
exe_name = os.path.basename(app_exe)
|
|
updater_exe = os.path.join(app_dir, "AnabasisUpdater.exe")
|
|
if not os.path.exists(updater_exe):
|
|
raise RuntimeError("Файл AnabasisUpdater.exe не найден в папке приложения.")
|
|
|
|
creation_flags = 0
|
|
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
|
creation_flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
|
if hasattr(subprocess, "DETACHED_PROCESS"):
|
|
creation_flags |= subprocess.DETACHED_PROCESS
|
|
|
|
subprocess.Popen(
|
|
[
|
|
updater_exe,
|
|
"--app-dir",
|
|
app_dir,
|
|
"--source-dir",
|
|
source_dir,
|
|
"--exe-name",
|
|
exe_name,
|
|
"--target-pid",
|
|
str(target_pid),
|
|
"--version",
|
|
str(version or ""),
|
|
"--work-dir",
|
|
str(work_dir or ""),
|
|
],
|
|
cwd=work_dir,
|
|
creationflags=creation_flags,
|
|
)
|
|
|
|
@classmethod
|
|
def prepare_update(cls, download_url, checksum_url, download_name):
|
|
work_dir = tempfile.mkdtemp(prefix="anabasis_update_")
|
|
zip_path = os.path.join(work_dir, "update.zip")
|
|
unpack_dir = os.path.join(work_dir, "extracted")
|
|
cls.download_update_archive(download_url, zip_path)
|
|
cls.verify_update_checksum(zip_path, checksum_url, download_name)
|
|
os.makedirs(unpack_dir, exist_ok=True)
|
|
with zipfile.ZipFile(zip_path, "r") as archive:
|
|
cls._safe_extract_zip(archive, unpack_dir)
|
|
source_dir = cls.locate_extracted_root(unpack_dir)
|
|
return work_dir, source_dir
|