909 lines
29 KiB
Python
909 lines
29 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
AZA Desktop — gemeinsame Versions- und Update-Logik (Controller, Updater, Office).
|
|
|
|
Phase 1: Manifest lesen, Version vergleichen, SHA256, Backup/Rollback vorbereiten.
|
|
Kein stilles Auto-Update, keine Aenderung von Benutzerdaten in %%APPDATA%%\\AzA.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
try:
|
|
import requests
|
|
except ImportError: # pragma: no cover
|
|
requests = None # type: ignore
|
|
|
|
# Neues Update-Manifest (Ziel) + bestehende Endpunkte (Kompatibilitaet).
|
|
UPDATE_CHANNEL_MANIFEST_URL = "https://api.aza-medwork.ch/downloads/updates/manifest.json"
|
|
UPDATE_MANIFEST_URLS = (
|
|
UPDATE_CHANNEL_MANIFEST_URL,
|
|
"https://api.aza-medwork.ch/download/version.json",
|
|
"https://api.aza-medwork.ch/release/version.json",
|
|
)
|
|
LOCAL_TEST_MANIFEST_NAME = "test_update_manifest.json"
|
|
|
|
_BUILD_STAMP_RE = re.compile(r"^\d{8}_\d{6}$")
|
|
_VERSION_JSON_NAME = "version.json"
|
|
_test_install_dir_override: Path | None = None
|
|
|
|
|
|
def configure_test_install_dir(path: str | Path | None) -> None:
|
|
"""Isolierter Test-Installationsordner — nie den Projektroot ueberschreiben."""
|
|
global _test_install_dir_override
|
|
if path is None or not str(path).strip():
|
|
_test_install_dir_override = None
|
|
return
|
|
_test_install_dir_override = Path(path).resolve()
|
|
|
|
|
|
def get_test_install_dir() -> Path | None:
|
|
if _test_install_dir_override is not None:
|
|
return _test_install_dir_override
|
|
env = (os.environ.get("AZA_UPDATE_TEST_INSTALL_DIR") or "").strip()
|
|
if env:
|
|
return Path(env).resolve()
|
|
return None
|
|
|
|
|
|
def get_install_dir() -> Path:
|
|
test_dir = get_test_install_dir()
|
|
if test_dir is not None:
|
|
return test_dir
|
|
return get_project_root()
|
|
|
|
|
|
def get_project_root() -> Path:
|
|
"""Installations- bzw. Projektroot (EXE-Verzeichnis oder Skriptordner)."""
|
|
if getattr(sys, "frozen", False):
|
|
return Path(sys.executable).resolve().parent
|
|
return Path(__file__).resolve().parent
|
|
|
|
|
|
def get_user_data_root() -> Path:
|
|
"""Benutzerdaten — darf bei Updates nie geloescht werden."""
|
|
appdata = os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") or ""
|
|
if appdata:
|
|
return Path(appdata) / "AzA"
|
|
return Path.home() / "AppData" / "Roaming" / "AzA"
|
|
|
|
|
|
def get_update_backup_root() -> Path:
|
|
"""Rollback-Speicher: im Testmodus unter dem Test-Installationsordner."""
|
|
test_dir = get_test_install_dir()
|
|
if test_dir is not None:
|
|
base = test_dir / "_update_backups"
|
|
try:
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
return base
|
|
program_data = os.environ.get("PROGRAMDATA") or ""
|
|
if program_data:
|
|
base = Path(program_data) / "AzA" / "update_backups"
|
|
else:
|
|
base = get_user_data_root() / "update_backups"
|
|
try:
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
return base
|
|
|
|
|
|
def find_local_version_file() -> Path | None:
|
|
root = get_install_dir()
|
|
direct = root / _VERSION_JSON_NAME
|
|
if direct.is_file():
|
|
return direct
|
|
if get_test_install_dir() is not None:
|
|
return None
|
|
release = get_project_root() / "release" / _VERSION_JSON_NAME
|
|
if release.is_file():
|
|
return release
|
|
return None
|
|
|
|
|
|
def _parse_semver_tuple(v: str) -> tuple[int, ...]:
|
|
v = str(v or "").strip()
|
|
if not v:
|
|
return tuple()
|
|
parts: list[int] = []
|
|
for seg in v.split("."):
|
|
seg = seg.strip()
|
|
m = re.match(r"^(\d+)", seg)
|
|
parts.append(int(m.group(1)) if m else 0)
|
|
return tuple(parts)
|
|
|
|
|
|
def _semver_pad(a: tuple[int, ...], b: tuple[int, ...]) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
n = max(len(a), len(b))
|
|
return a + (0,) * (n - len(a)), b + (0,) * (n - len(b))
|
|
|
|
|
|
def semver_gt(ver_a: str, ver_b: str) -> bool:
|
|
ta, tb = _parse_semver_tuple(ver_a), _parse_semver_tuple(ver_b)
|
|
if not ta or not tb:
|
|
return False
|
|
aa, bb = _semver_pad(ta, tb)
|
|
return aa > bb
|
|
|
|
|
|
def semver_eq(ver_a: str, ver_b: str) -> bool:
|
|
ta, tb = _parse_semver_tuple(ver_a), _parse_semver_tuple(ver_b)
|
|
if not ta and not tb:
|
|
return str(ver_a).strip() == str(ver_b).strip()
|
|
aa, bb = _semver_pad(ta, tb)
|
|
return aa == bb
|
|
|
|
|
|
def build_gt(remote: str, local: str) -> bool:
|
|
r, l = (remote or "").strip(), (local or "").strip()
|
|
if not r or not l:
|
|
return False
|
|
if _BUILD_STAMP_RE.match(r) and _BUILD_STAMP_RE.match(l):
|
|
return r > l
|
|
return r > l
|
|
|
|
|
|
def _fallback_version_from_code() -> dict[str, Any]:
|
|
version = "0.0.0"
|
|
channel = "stable"
|
|
build = ""
|
|
try:
|
|
from aza_version import APP_VERSION, APP_CHANNEL
|
|
|
|
version = str(APP_VERSION).strip() or version
|
|
channel = str(APP_CHANNEL).strip() or channel
|
|
except Exception:
|
|
pass
|
|
if not build:
|
|
try:
|
|
from _build_info import BUILD_TIMESTAMP
|
|
|
|
build = str(BUILD_TIMESTAMP or "").strip()
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"version": version,
|
|
"build": build,
|
|
"channel": channel,
|
|
"app": "AZA Desktop",
|
|
}
|
|
|
|
|
|
def load_local_version() -> dict[str, Any]:
|
|
"""Liest version.json; Fallback aza_version.py + _build_info.py."""
|
|
vf = find_local_version_file()
|
|
if vf is not None:
|
|
try:
|
|
data = json.loads(vf.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict):
|
|
if get_test_install_dir() is not None:
|
|
base = {
|
|
"version": "0.0.0",
|
|
"build": "",
|
|
"channel": "stable",
|
|
"app": "AZA Desktop",
|
|
}
|
|
base.update({k: v for k, v in data.items() if v is not None})
|
|
return base
|
|
out = _fallback_version_from_code()
|
|
out.update({k: v for k, v in data.items() if v is not None})
|
|
return out
|
|
except Exception:
|
|
pass
|
|
if get_test_install_dir() is not None:
|
|
return {
|
|
"version": "0.0.0",
|
|
"build": "",
|
|
"channel": "stable",
|
|
"app": "AZA Desktop",
|
|
}
|
|
return _fallback_version_from_code()
|
|
|
|
|
|
def save_local_version(data: dict[str, Any]) -> Path:
|
|
"""Schreibt version.json neben der Installation."""
|
|
root = get_install_dir()
|
|
target = root / _VERSION_JSON_NAME
|
|
payload = dict(load_local_version())
|
|
payload.update(data)
|
|
target.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
return target
|
|
|
|
|
|
def format_version_label(data: dict[str, Any] | None = None) -> str:
|
|
info = data or load_local_version()
|
|
ver = str(info.get("version") or "?").strip()
|
|
build = str(info.get("build") or "").strip()
|
|
if build:
|
|
return f"v{ver} · {build}"
|
|
return f"v{ver}"
|
|
|
|
|
|
def _normalize_notes(data: dict[str, Any]) -> list[str]:
|
|
raw = data.get("notes_de")
|
|
if raw is None:
|
|
raw = data.get("notes")
|
|
if raw is None:
|
|
raw = data.get("release_notes")
|
|
if isinstance(raw, str):
|
|
return [raw.strip()] if raw.strip() else []
|
|
if isinstance(raw, list):
|
|
return [str(x).strip() for x in raw if str(x).strip()]
|
|
return []
|
|
|
|
|
|
def _remote_version_fields(data: dict[str, Any]) -> tuple[str, str, str]:
|
|
version = str(
|
|
data.get("latest_version") or data.get("version") or ""
|
|
).strip()
|
|
build = str(data.get("latest_build") or data.get("build") or "").strip()
|
|
channel = str(data.get("channel") or "stable").strip()
|
|
return version, build, channel
|
|
|
|
|
|
def _min_required(data: dict[str, Any]) -> str:
|
|
for key in ("min_supported_version", "minimum_supported_version", "min_required_version"):
|
|
v = data.get(key)
|
|
if v is not None and str(v).strip():
|
|
return str(v).strip()
|
|
return ""
|
|
|
|
|
|
UPDATE_CHECK_LOG_NAME = "update_check.log"
|
|
|
|
|
|
def get_update_check_log_path() -> Path:
|
|
log_dir = get_user_data_root() / "logs"
|
|
try:
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
return log_dir / UPDATE_CHECK_LOG_NAME
|
|
|
|
|
|
def _sanitize_log_text(text: str, *, max_len: int = 400) -> str:
|
|
s = str(text or "").replace("\r", " ").replace("\n", " ").strip()
|
|
for needle in ("token", "secret", "password", "authorization", "bearer"):
|
|
if needle in s.lower():
|
|
return "[redacted]"
|
|
if len(s) > max_len:
|
|
return s[: max_len - 3] + "..."
|
|
return s
|
|
|
|
|
|
def log_update_check(event: str, **fields: Any) -> None:
|
|
"""Schreibt strukturierte Zeile nach %APPDATA%\\AzA\\logs\\update_check.log."""
|
|
try:
|
|
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
parts = [f"time={ts}", f"event={event}"]
|
|
for key, value in fields.items():
|
|
if value is None:
|
|
continue
|
|
parts.append(f"{key}={_sanitize_log_text(str(value))}")
|
|
line = " ".join(parts) + "\n"
|
|
get_update_check_log_path().open("a", encoding="utf-8").write(line)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _log_internal(msg: str) -> None:
|
|
print(f"[AZA Update] {msg}")
|
|
log_update_check("internal", message=_sanitize_log_text(msg))
|
|
|
|
|
|
def resolve_manifest_sources(*, cli_manifest: str | None = None) -> list[str]:
|
|
"""
|
|
Manifest-Quellen (Startup, CLI, Updater).
|
|
|
|
Prioritaet:
|
|
1) --manifest (CLI)
|
|
2) AZA_UPDATE_MANIFEST_URL
|
|
3) test_update_manifest.json bei AZA_UPDATE_TEST_MANIFEST=1
|
|
4) oeffentlicher Update-Kanal /downloads/updates/manifest.json
|
|
"""
|
|
sources: list[str] = []
|
|
cli = (cli_manifest or "").strip()
|
|
if cli:
|
|
sources.append(cli)
|
|
|
|
env_url = (os.environ.get("AZA_UPDATE_MANIFEST_URL") or "").strip()
|
|
if env_url and env_url not in sources:
|
|
sources.append(env_url)
|
|
|
|
use_test = (os.environ.get("AZA_UPDATE_TEST_MANIFEST") or "").strip().lower() in (
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
)
|
|
test_path = get_project_root() / LOCAL_TEST_MANIFEST_NAME
|
|
if use_test and test_path.is_file():
|
|
resolved = str(test_path.resolve())
|
|
if resolved not in sources:
|
|
sources.append(resolved)
|
|
|
|
if UPDATE_CHANNEL_MANIFEST_URL not in sources:
|
|
sources.append(UPDATE_CHANNEL_MANIFEST_URL)
|
|
return sources
|
|
|
|
|
|
def resolve_startup_manifest_sources() -> list[str]:
|
|
"""Alias fuer Startpanel-Startup (ohne CLI-Override)."""
|
|
return resolve_manifest_sources()
|
|
|
|
|
|
def _load_manifest_json_text(text: str, *, source: str) -> dict[str, Any] | None:
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError:
|
|
_log_internal(f"invalid_json source={source}")
|
|
return None
|
|
if not isinstance(data, dict):
|
|
_log_internal(f"invalid_manifest_shape source={source}")
|
|
return None
|
|
data["_manifest_url"] = source
|
|
return data
|
|
|
|
|
|
def load_manifest_from_source(source: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
"""Laedt Manifest von HTTP(S)-URL oder lokaler Datei."""
|
|
src = (source or "").strip()
|
|
if not src:
|
|
return None, "empty_source"
|
|
|
|
if src.lower().startswith("file://"):
|
|
src = src[7:]
|
|
if sys.platform == "win32" and src.startswith("/") and len(src) > 2 and src[2] == ":":
|
|
src = src[1:]
|
|
|
|
path = Path(src)
|
|
if path.is_file() or (not src.lower().startswith("http") and path.exists()):
|
|
try:
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
data = _load_manifest_json_text(text, source=str(path.resolve()))
|
|
return (data, None) if data else (None, "invalid_json")
|
|
except OSError as exc:
|
|
return None, f"file_read={type(exc).__name__}"
|
|
|
|
if requests is None:
|
|
return None, "requests_not_installed"
|
|
|
|
try:
|
|
r = requests.get(src, timeout=8.0)
|
|
if r.status_code == 404:
|
|
return None, f"http_status=404 url={src}"
|
|
if r.status_code != 200:
|
|
return None, f"http_status={r.status_code} url={src}"
|
|
text = r.content.decode("utf-8-sig")
|
|
data = _load_manifest_json_text(text, source=src)
|
|
return (data, None) if data else (None, "invalid_json")
|
|
except Exception as exc:
|
|
return None, f"exc={type(exc).__name__} url={src}"
|
|
|
|
|
|
def fetch_startup_manifest(
|
|
*,
|
|
sources: list[str] | None = None,
|
|
) -> tuple[dict[str, Any] | None, str | None]:
|
|
"""Nur Update-Kanal — kein Fallback auf Installer-version.json."""
|
|
last: str | None = None
|
|
for source in sources or resolve_startup_manifest_sources():
|
|
data, err = load_manifest_from_source(source)
|
|
if data:
|
|
return data, None
|
|
last = err
|
|
if err and "404" in err:
|
|
_log_internal(f"Update manifest not available ({err})")
|
|
return None, last or "no_manifest"
|
|
|
|
|
|
def _update_files_from_manifest(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
files = data.get("files")
|
|
if isinstance(files, list) and files:
|
|
out: list[dict[str, Any]] = []
|
|
for item in files:
|
|
if isinstance(item, dict) and item.get("url"):
|
|
out.append(dict(item))
|
|
return out
|
|
download_url = str(data.get("download_url") or "").strip()
|
|
if download_url:
|
|
return [
|
|
{
|
|
"name": Path(download_url).name or "aza_desktop_setup.exe",
|
|
"url": download_url,
|
|
"sha256": str(data.get("sha256") or "").strip(),
|
|
"size_bytes": data.get("size_bytes"),
|
|
}
|
|
]
|
|
return []
|
|
|
|
|
|
def fetch_remote_manifest(
|
|
*,
|
|
timeout: float = 8.0,
|
|
urls: tuple[str, ...] | None = None,
|
|
) -> tuple[dict[str, Any] | None, str | None]:
|
|
"""Laedt Manifest (inkl. Legacy-Installer-Endpunkte)."""
|
|
last: str | None = None
|
|
for url in urls or UPDATE_MANIFEST_URLS:
|
|
data, err = load_manifest_from_source(url)
|
|
if data:
|
|
return data, None
|
|
last = err
|
|
return None, last or "no_manifest"
|
|
|
|
|
|
def check_update_from_manifest(manifest_source: str | Path) -> dict[str, Any]:
|
|
"""Prueft Update anhand eines expliziten Manifests (lokal oder URL)."""
|
|
local = load_local_version()
|
|
remote, err = load_manifest_from_source(str(manifest_source))
|
|
if err or not remote:
|
|
return {
|
|
"status": "error",
|
|
"message": "Manifest nicht lesbar.",
|
|
"detail": err,
|
|
"local": local,
|
|
}
|
|
return evaluate_update(local, remote)
|
|
|
|
|
|
def check_update_for_startup(*, manifest: str | None = None) -> dict[str, Any]:
|
|
"""
|
|
Start-Updatepruefung fuer das schoene Startpanel.
|
|
|
|
status:
|
|
- manifest_unavailable (404/kein Netz — still fuer Benutzer)
|
|
- current
|
|
- update_available
|
|
- channel_mismatch
|
|
- error
|
|
"""
|
|
local = load_local_version()
|
|
sources = resolve_manifest_sources(cli_manifest=manifest)
|
|
log_update_check(
|
|
"check_start",
|
|
local_version=local.get("version"),
|
|
local_build=local.get("build"),
|
|
manifest_url=sources[0] if sources else "",
|
|
)
|
|
remote, err = fetch_startup_manifest(sources=sources)
|
|
if err or not remote:
|
|
silent = bool(
|
|
err
|
|
and (
|
|
"404" in err
|
|
or err in ("no_manifest", "requests_not_installed")
|
|
or err.startswith("exc=")
|
|
)
|
|
)
|
|
status = "manifest_unavailable" if silent else "error"
|
|
log_update_check(
|
|
"check_result",
|
|
status=status,
|
|
detail=err,
|
|
manifest_url=remote.get("_manifest_url") if remote else sources[0],
|
|
)
|
|
return {
|
|
"status": status,
|
|
"message": "Update manifest not available" if silent else "Manifest nicht erreichbar.",
|
|
"detail": err,
|
|
"local": local,
|
|
}
|
|
result = evaluate_update(local, remote)
|
|
result["detail"] = err
|
|
log_update_check(
|
|
"check_result",
|
|
status=result.get("status"),
|
|
latest_version=result.get("latest_version"),
|
|
manifest_url=remote.get("_manifest_url") or sources[0],
|
|
detail=err,
|
|
)
|
|
return result
|
|
|
|
|
|
def validate_update_install_ready(result: dict[str, Any]) -> tuple[bool, str]:
|
|
"""Prueft alle Voraussetzungen vor einer Installation."""
|
|
if result.get("status") != "update_available":
|
|
return False, "Kein Update verfuegbar."
|
|
|
|
files = result.get("files") or []
|
|
if not files:
|
|
return False, "Kein Update-Paket im Manifest."
|
|
|
|
file_info = files[0]
|
|
url = str(file_info.get("url") or "").strip()
|
|
if not url:
|
|
return False, "Download-URL fehlt."
|
|
|
|
sha = str(file_info.get("sha256") or "").strip()
|
|
if not sha:
|
|
return False, "SHA256 fehlt im Manifest."
|
|
|
|
latest = str(result.get("latest_version") or "").strip()
|
|
if not latest:
|
|
return False, "Versionsangabe fehlt."
|
|
|
|
name = str(file_info.get("name") or Path(url).name or "").strip()
|
|
if name.lower().endswith(".zip"):
|
|
return True, "ok"
|
|
if name.lower().endswith(".exe"):
|
|
return True, "ok"
|
|
return False, "Unbekanntes Update-Format."
|
|
|
|
|
|
def evaluate_update(
|
|
local: dict[str, Any] | None = None,
|
|
remote: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Vergleicht lokal vs. remote.
|
|
|
|
status:
|
|
- current
|
|
- update_available
|
|
- below_min_supported
|
|
- channel_mismatch
|
|
- error
|
|
"""
|
|
loc = local or load_local_version()
|
|
if remote is None:
|
|
return {
|
|
"status": "error",
|
|
"message": "Kein Remote-Manifest",
|
|
"local": loc,
|
|
}
|
|
|
|
local_ver = str(loc.get("version") or "").strip()
|
|
local_build = str(loc.get("build") or "").strip()
|
|
local_channel = str(loc.get("channel") or "stable").strip()
|
|
|
|
remote_ver, remote_build, remote_channel = _remote_version_fields(remote)
|
|
if remote_channel != local_channel:
|
|
return {
|
|
"status": "channel_mismatch",
|
|
"message": f"Kanal lokal={local_channel}, remote={remote_channel}",
|
|
"local": loc,
|
|
"remote": remote,
|
|
}
|
|
|
|
min_req = _min_required(remote)
|
|
below_min = bool(min_req and semver_gt(min_req, local_ver))
|
|
newer_semver = semver_gt(remote_ver, local_ver)
|
|
same_semver = semver_eq(remote_ver, local_ver)
|
|
build_bump = bool(
|
|
same_semver and remote_build and local_build and build_gt(remote_build, local_build)
|
|
)
|
|
client_ahead = semver_gt(local_ver, remote_ver)
|
|
|
|
if client_ahead and not below_min:
|
|
return {
|
|
"status": "current",
|
|
"message": "Lokal ist aktuell oder neuer.",
|
|
"local": loc,
|
|
"remote": remote,
|
|
}
|
|
|
|
if below_min or newer_semver or build_bump:
|
|
files = _update_files_from_manifest(remote)
|
|
return {
|
|
"status": "update_available",
|
|
"message": "Update verfuegbar.",
|
|
"local": loc,
|
|
"remote": remote,
|
|
"latest_version": remote_ver,
|
|
"latest_build": remote_build,
|
|
"below_min_supported": below_min,
|
|
"mandatory": bool(remote.get("mandatory")) or below_min,
|
|
"notes": _normalize_notes(remote),
|
|
"files": files,
|
|
"manifest_url": remote.get("_manifest_url"),
|
|
}
|
|
|
|
return {
|
|
"status": "current",
|
|
"message": "Kein Update noetig.",
|
|
"local": loc,
|
|
"remote": remote,
|
|
}
|
|
|
|
|
|
def compute_sha256(path: Path, *, chunk_size: int = 1024 * 1024) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
while True:
|
|
chunk = f.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest().upper()
|
|
|
|
|
|
def verify_sha256(path: Path, expected: str) -> bool:
|
|
exp = str(expected or "").strip().upper()
|
|
if not exp or not path.is_file():
|
|
return False
|
|
return compute_sha256(path) == exp
|
|
|
|
|
|
def _resolve_package_source(url: str) -> Path | None:
|
|
src = (url or "").strip()
|
|
if not src:
|
|
return None
|
|
if src.lower().startswith("file://"):
|
|
src = src[7:]
|
|
if sys.platform == "win32" and src.startswith("/") and len(src) > 2 and src[2] == ":":
|
|
src = src[1:]
|
|
path = Path(src)
|
|
if path.is_file():
|
|
return path.resolve()
|
|
if not src.lower().startswith("http") and path.exists():
|
|
return path.resolve()
|
|
return None
|
|
|
|
|
|
def download_file(
|
|
url: str,
|
|
dest: Path,
|
|
*,
|
|
timeout: float = 120.0,
|
|
progress: Callable[[int, int | None], None] | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""Laedt eine Datei herunter. Keine Installation."""
|
|
if requests is None:
|
|
return False, "requests_not_installed"
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
try:
|
|
with requests.get(url, stream=True, timeout=timeout) as r:
|
|
if r.status_code != 200:
|
|
return False, f"http_status={r.status_code}"
|
|
total = int(r.headers.get("Content-Length") or 0) or None
|
|
done = 0
|
|
with tmp.open("wb") as f:
|
|
for chunk in r.iter_content(chunk_size=1024 * 256):
|
|
if not chunk:
|
|
continue
|
|
f.write(chunk)
|
|
done += len(chunk)
|
|
if progress:
|
|
progress(done, total)
|
|
tmp.replace(dest)
|
|
return True, "ok"
|
|
except Exception as exc:
|
|
try:
|
|
if tmp.is_file():
|
|
tmp.unlink()
|
|
except OSError:
|
|
pass
|
|
return False, f"{type(exc).__name__}: {exc}"
|
|
|
|
|
|
def create_pre_update_backup(
|
|
install_dir: Path | None = None,
|
|
*,
|
|
extra_names: tuple[str, ...] = (
|
|
"aza_desktop.exe",
|
|
"aza_controller.exe",
|
|
"aza_office.exe",
|
|
"aza_praxis_chat.exe",
|
|
"aza_updater.exe",
|
|
"AZA_EmpfangShell.exe",
|
|
"version.json",
|
|
"BUILD_INFO.txt",
|
|
),
|
|
) -> Path:
|
|
"""
|
|
Sichert EXEs und version.json vor einem Update.
|
|
Benutzerdaten in APPDATA werden nicht angetastet.
|
|
"""
|
|
src = install_dir or get_install_dir()
|
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
backup_dir = get_update_backup_root() / stamp
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
copied: list[str] = []
|
|
names = list(extra_names) + ["app_marker.txt"]
|
|
for name in names:
|
|
p = src / name
|
|
if p.is_file():
|
|
shutil.copy2(p, backup_dir / name)
|
|
copied.append(name)
|
|
|
|
runtime = src / "runtime"
|
|
if runtime.is_dir():
|
|
shutil.copytree(runtime, backup_dir / "runtime", dirs_exist_ok=True)
|
|
copied.append("runtime/")
|
|
|
|
meta = {
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"install_dir": str(src),
|
|
"copied": copied,
|
|
}
|
|
(backup_dir / "backup_meta.json").write_text(
|
|
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return backup_dir
|
|
|
|
|
|
def rollback_from_backup(
|
|
backup_dir: Path,
|
|
install_dir: Path | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""Stellt gesicherte Programmdateien wieder her."""
|
|
if not backup_dir.is_dir():
|
|
return False, "Backup-Ordner nicht gefunden."
|
|
dst = install_dir or get_install_dir()
|
|
restored: list[str] = []
|
|
for item in backup_dir.iterdir():
|
|
if item.name == "backup_meta.json":
|
|
continue
|
|
target = dst / item.name
|
|
try:
|
|
if item.is_dir():
|
|
if target.exists():
|
|
shutil.rmtree(target)
|
|
shutil.copytree(item, target)
|
|
elif item.is_file():
|
|
shutil.copy2(item, target)
|
|
restored.append(item.name)
|
|
except Exception as exc:
|
|
return False, f"Rollback fehlgeschlagen bei {item.name}: {exc}"
|
|
return True, f"Wiederhergestellt: {', '.join(restored)}"
|
|
|
|
|
|
def list_available_backups() -> list[Path]:
|
|
root = get_update_backup_root()
|
|
if not root.is_dir():
|
|
return []
|
|
dirs = [p for p in root.iterdir() if p.is_dir()]
|
|
return sorted(dirs, reverse=True)
|
|
|
|
|
|
def extract_update_zip(zip_path: Path, target_dir: Path) -> tuple[bool, str]:
|
|
"""Entpackt ein Update-ZIP in das Installationsverzeichnis (ohne APPDATA)."""
|
|
if not zip_path.is_file():
|
|
return False, "ZIP nicht gefunden."
|
|
try:
|
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
for member in zf.namelist():
|
|
if member.startswith("/") or ".." in Path(member).parts:
|
|
return False, f"Unsicherer ZIP-Eintrag: {member}"
|
|
zf.extractall(target_dir)
|
|
return True, "ok"
|
|
except Exception as exc:
|
|
return False, f"{type(exc).__name__}: {exc}"
|
|
|
|
|
|
def download_update_package(
|
|
file_info: dict[str, Any],
|
|
*,
|
|
work_dir: Path | None = None,
|
|
progress: Callable[[int, int | None], None] | None = None,
|
|
) -> tuple[Path | None, str]:
|
|
"""Laedt ein Update-Paket herunter und prueft SHA256."""
|
|
url = str(file_info.get("url") or "").strip()
|
|
if not url:
|
|
log_update_check("dl_no_url")
|
|
return None, "Keine Download-URL."
|
|
name = str(file_info.get("name") or Path(url).name or "aza_update.zip")
|
|
base = work_dir or Path(tempfile.gettempdir()) / "aza_updates"
|
|
base.mkdir(parents=True, exist_ok=True)
|
|
dest = base / name
|
|
log_update_check("dl_start", url=url[:200], dest=str(dest))
|
|
local_src = _resolve_package_source(url)
|
|
if local_src is not None:
|
|
try:
|
|
shutil.copy2(local_src, dest)
|
|
ok, msg = True, "ok"
|
|
except Exception as exc:
|
|
log_update_check("dl_local_copy_failed", error=f"{type(exc).__name__}: {exc}"[:160])
|
|
return None, f"{type(exc).__name__}: {exc}"
|
|
elif not url.lower().startswith("http"):
|
|
log_update_check("dl_no_pkg")
|
|
return None, "Paket nicht gefunden."
|
|
else:
|
|
ok, msg = download_file(url, dest, progress=progress)
|
|
if not ok:
|
|
log_update_check("dl_failed", msg=str(msg)[:160])
|
|
return None, msg
|
|
expected = str(file_info.get("sha256") or "").strip()
|
|
if not expected:
|
|
log_update_check("dl_no_sha")
|
|
return None, "SHA256 fehlt — Download abgebrochen."
|
|
if not verify_sha256(dest, expected):
|
|
try:
|
|
dest.unlink()
|
|
except OSError:
|
|
pass
|
|
log_update_check("dl_sha_mismatch")
|
|
return None, "SHA256 stimmt nicht ueberein — Download verworfen."
|
|
log_update_check("dl_ok", size=dest.stat().st_size)
|
|
return dest, "ok"
|
|
|
|
|
|
# ── Update-Pending / Auto-Prompt-Prefs ────────────────────────────────────────
|
|
|
|
def _get_update_prefs_path() -> Path:
|
|
return get_user_data_root() / "update_prefs.json"
|
|
|
|
|
|
def _get_update_pending_path() -> Path:
|
|
return get_user_data_root() / "update_pending.json"
|
|
|
|
|
|
def save_update_pending(manifest_url: str, latest_version: str) -> None:
|
|
"""Speichert ein ausstehender Update-Auftrag fuer 'Beim Beenden'."""
|
|
try:
|
|
_get_update_pending_path().write_text(
|
|
json.dumps({"manifest_url": manifest_url, "latest_version": latest_version},
|
|
ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
log_update_check("pending_saved", version=latest_version)
|
|
except Exception as exc:
|
|
log_update_check("pending_save_err", error=str(exc)[:120])
|
|
|
|
|
|
def load_update_pending() -> dict[str, Any] | None:
|
|
"""Laedt ausstehenden Update-Auftrag oder None."""
|
|
try:
|
|
p = _get_update_pending_path()
|
|
if p.is_file():
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
if isinstance(data, dict) and data.get("manifest_url"):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def clear_update_pending() -> None:
|
|
"""Loescht den gespeicherten Update-Auftrag."""
|
|
try:
|
|
_get_update_pending_path().unlink(missing_ok=True)
|
|
log_update_check("pending_cleared")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def get_auto_update_prompt_enabled() -> bool:
|
|
"""Gibt an, ob der automatische Update-Dialog aktiv ist (Default: True)."""
|
|
try:
|
|
p = _get_update_prefs_path()
|
|
if p.is_file():
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
return bool(data.get("auto_update_prompt_enabled", True))
|
|
except Exception:
|
|
pass
|
|
return True
|
|
|
|
|
|
def set_auto_update_prompt_enabled(enabled: bool) -> None:
|
|
"""Aktiviert oder deaktiviert den automatischen Update-Dialog."""
|
|
try:
|
|
p = _get_update_prefs_path()
|
|
data: dict[str, Any] = {}
|
|
if p.is_file():
|
|
try:
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
data = {}
|
|
data["auto_update_prompt_enabled"] = bool(enabled)
|
|
p.write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
log_update_check("auto_prompt_set", enabled=enabled)
|
|
except Exception as exc:
|
|
log_update_check("auto_prompt_set_err", error=str(exc)[:120])
|