summaryrefslogtreecommitdiff
path: root/core/updater.py
blob: 471ef0ba3eeebfdb00e19b93b959494567469f56 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
"""
core/updater.py

Thin wrapper around tufup.client.Client — checks for and applies app
updates. Only meaningful inside a PyInstaller-frozen build (see
is_frozen()); calling check_for_update()/apply_update() from a
`python main.py` dev run raises, since there's no installed bundle for
tufup to update.

Update metadata/targets are hosted on this repo's "updates" GitHub Release
— a fixed tag that never changes between app versions, unlike a normal
per-version release tag, because TUF's top-level metadata (timestamp.json,
snapshot.json, ...) must live at a stable URL the client always checks.
See scripts/release/README.md for the publishing side.
"""

import sys
from pathlib import Path
from typing import Optional

from core.app_settings import app_data_dir
from core.version import __version__ as CURRENT_VERSION

APP_NAME = "labui"

_RELEASE_BASE_URL  = "https://github.com/c-kolset/labUI-python/releases/download/updates/"
METADATA_BASE_URL  = _RELEASE_BASE_URL
TARGET_BASE_URL    = _RELEASE_BASE_URL

_client = None  # lazily-created tufup.client.Client, reused across calls so
                # apply_update() can act on what check_for_update() found


def is_frozen() -> bool:
    """True only inside a PyInstaller-built exe — never for `python main.py`."""
    return bool(getattr(sys, "frozen", False))


def _require_frozen():
    if not is_frozen():
        raise RuntimeError(
            "Updates are only available in the packaged app, not `python main.py`."
        )


def _bootstrap_root_json(metadata_dir: Path) -> None:
    """
    tuf.ngclient.Updater (tufup.client.Client's base class) loads root.json
    from metadata_dir automatically on construction — it does NOT fetch it
    remotely on first use, by design (TUF's root of trust has to come from
    somewhere already trusted, not from the same server being verified).

    So the copy bundled into the app (labui.spec bundles
    scripts/release/repository/metadata/root.json to "repository/metadata")
    has to be seeded into metadata_dir before the first Client is ever
    constructed on a given machine. sys._MEIPASS is where PyInstaller puts
    bundled data files at runtime, for both onefile and onedir builds.
    """
    dest = metadata_dir / "root.json"
    if dest.exists():
        return
    bundled = Path(getattr(sys, "_MEIPASS", "")) / "repository" / "metadata" / "root.json"
    if not bundled.is_file():
        raise RuntimeError(f"Bundled root.json not found at {bundled} — was the app built "
                            f"before scripts/release/repo_init.py had been run?")
    metadata_dir.mkdir(parents=True, exist_ok=True)
    dest.write_bytes(bundled.read_bytes())


def _get_client():
    global _client
    if _client is None:
        from tufup.client import Client

        cache_dir = app_data_dir() / "tufup"
        metadata_dir = cache_dir / "metadata"
        _bootstrap_root_json(metadata_dir)
        _client = Client(
            app_name=APP_NAME,
            app_install_dir=Path(sys.executable).resolve().parent,
            current_version=CURRENT_VERSION,
            metadata_dir=metadata_dir,
            metadata_base_url=METADATA_BASE_URL,
            target_dir=cache_dir / "targets",
            target_base_url=TARGET_BASE_URL,
        )
    return _client


def check_for_update() -> Optional[str]:
    """Return the new version string if an update is available, else None."""
    _require_frozen()
    new_target = _get_client().check_for_updates()
    return str(new_target.version) if new_target else None


def apply_update() -> None:
    """
    Download, TUF-verify, and install the update found by the most recent
    check_for_update() call on this process. Only call after
    check_for_update() returned a version — tufup raises otherwise.

    tufup's default install step replaces the app's files and relaunches it;
    the calling UI should assume the process may exit during this call.
    """
    _require_frozen()
    _get_client().download_and_apply_update(skip_confirmation=True)