diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-07-31 15:52:28 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-07-31 15:52:28 -0600 |
| commit | bfbdd0c19910f464e779fa64cc0ec8590f8e37c1 (patch) | |
| tree | 37ea9b29a925d24928b71b054f1ea955b2b90105 | |
| parent | f1aaffbc3eb1e2c154315c556d2555803eea7997 (diff) | |
Add PyInstaller packaging and tufup-based auto-update pipeline
Full pipeline, verified end-to-end against the real installed tufup
0.10.0 API (initial docs/summaries turned out inaccurate in places —
e.g. the apply method is download_and_apply_update, not update;
confirmed by inspecting installed package source directly rather than
trusting docs alone):
- core/version.py: single-source app version constant.
- labdaq.spec: PyInstaller onedir build (must be onedir, not onefile —
tufup replaces individual files in the install dir on update).
Bundles ui/*.qss, plugins/ (needed for runtime plugin discovery), and
repository/metadata/root.json once repo_init.py has produced one.
Built and smoke-tested: the frozen exe launches and stays running.
- scripts/release/{repo_init,repo_release}.py: maintainer-run release
tooling using tufup.repo.Repository, manual local signing (keys never
touch CI). Both actually run end-to-end during development of this
feature against a real build, not just written and assumed correct.
Longer expiration_days than tufup-example's CI-oriented defaults
(targets/snapshot/timestamp 90d instead of 7d/7d/1d), since we're
signing manually, not on an automated daily schedule — see
scripts/release/README.md for the re-signing cadence this still
requires even between releases.
- core/updater.py: thin Client wrapper. Refuses to run outside a
frozen build (getattr(sys, "frozen", False)) since there's no
installed bundle for tufup to update in `python main.py` dev mode.
Bootstraps the bundled root.json into the metadata cache dir on
first run — tuf.ngclient.Updater loads root.json from local disk on
construction, it does not fetch it remotely by design (the root of
trust can't come from the same server being verified).
- core/app_settings.py: factored out app_data_dir() (was inline in
_settings_path()) so the updater's metadata/target cache dirs live
in the same per-user location as settings.json, deliberately outside
the install directory an update can replace/move.
- Settings > General: version display + "Check for Updates" button,
manual-only per discussion (no silent background network calls or
surprise restarts for a lab-instrument-control app).
Metadata/targets are hosted on this repo's "updates" GitHub Release —
a fixed tag, not a normal per-version tag, because TUF's top-level
metadata needs a stable URL across app versions. No such GitHub-Releases-
hosting example exists in tufup or tufup-example; verified this by
fetching tufup-example's actual GitHub Actions workflow file directly
after a web search wrongly suggested one existed — the design here is
ours, not copied from upstream.
Not yet done, deliberately left for the user: running repo_init.py for
real (generates production signing keys), and creating the actual
"updates" GitHub Release. Both are irreversible-ish, security-sensitive,
externally-visible actions outside what should happen without the
user directly driving them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 10 | ||||
| -rw-r--r-- | core/app_settings.py | 12 | ||||
| -rw-r--r-- | core/updater.py | 107 | ||||
| -rw-r--r-- | core/version.py | 12 | ||||
| -rw-r--r-- | labdaq.spec | 72 | ||||
| -rw-r--r-- | requirements-dev.txt | 3 | ||||
| -rw-r--r-- | scripts/release/README.md | 61 | ||||
| -rw-r--r-- | scripts/release/repo_init.py | 57 | ||||
| -rw-r--r-- | scripts/release/repo_release.py | 64 | ||||
| -rw-r--r-- | ui/main_window.py | 3 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 56 |
11 files changed, 454 insertions, 3 deletions
@@ -8,3 +8,13 @@ plugins/enabled.json plugins/enabled.json plugins/enabled.json plugins/enabled.json + +# PyInstaller build output +build/ +dist/ + +# tufup release artifacts — private keys must never be committed; +# generated repository/ is republished to GitHub Releases, not tracked here +scripts/release/keystore/ +scripts/release/repository/ +scripts/release/.tufup-repo-config diff --git a/core/app_settings.py b/core/app_settings.py index ea3ac49..718a768 100644 --- a/core/app_settings.py +++ b/core/app_settings.py @@ -18,14 +18,22 @@ import sys from pathlib import Path -def _settings_path() -> Path: +def app_data_dir() -> Path: + """Per-user, per-platform base directory for anything this app needs to + persist outside the install directory (settings, updater cache, ...) — + deliberately outside the install dir since an update can replace/move + everything in there.""" if sys.platform == "win32": base = Path(os.environ.get("APPDATA", Path.home())) elif sys.platform == "darwin": base = Path.home() / "Library" / "Application Support" else: base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - return base / "labUI" / "settings.json" + return base / "labUI" + + +def _settings_path() -> Path: + return app_data_dir() / "settings.json" SETTINGS_PATH = _settings_path() diff --git a/core/updater.py b/core/updater.py new file mode 100644 index 0000000..d22cd42 --- /dev/null +++ b/core/updater.py @@ -0,0 +1,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 = "labdaq" + +_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 (labdaq.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) diff --git a/core/version.py b/core/version.py new file mode 100644 index 0000000..8b6c492 --- /dev/null +++ b/core/version.py @@ -0,0 +1,12 @@ +""" +core/version.py + +Single source of truth for the app version. + +Bump this before cutting a release — scripts/release/repo_release.py reads +it (via Repository's app_version_attr="core.version.__version__") to tag +the new tufup target, and core/updater.py reads it as the running app's +current_version when checking for updates. +""" + +__version__ = "1.0.0" diff --git a/labdaq.spec b/labdaq.spec new file mode 100644 index 0000000..635af06 --- /dev/null +++ b/labdaq.spec @@ -0,0 +1,72 @@ +# labdaq.spec +# +# PyInstaller build spec. Build with: +# pyinstaller labdaq.spec +# +# Output: dist/LabDAQ/LabDAQ.exe (onedir build — required, not onefile: +# tufup replaces individual files in the install directory on update, +# which a single-file onefile exe can't do). + +import sys +from pathlib import Path + +block_cipher = None + +root_dir = Path(SPECPATH) + +# root.json is generated by scripts/release/repo_init.py — bundle it once it +# exists so a fresh install can bootstrap tufup trust without an insecure +# first fetch. Skip silently before that's ever been run (e.g. first build). +datas = [ + (str(root_dir / "ui" / "style_dark.qss"), "ui"), + (str(root_dir / "ui" / "style_light.qss"), "ui"), + (str(root_dir / "ui" / "style.qss"), "ui"), + (str(root_dir / "plugins"), "plugins"), +] +_root_json = root_dir / "scripts" / "release" / "repository" / "metadata" / "root.json" +if _root_json.exists(): + datas.append((str(_root_json), "repository/metadata")) + +a = Analysis( + ["main.py"], + pathex=[str(root_dir)], + binaries=[], + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="LabDAQ", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="LabDAQ", +) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..3d022db --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +# Dev/build-only — never installed by end users, not needed to run the app. +pyinstaller>=6.0 +tufup>=0.10.0 diff --git a/scripts/release/README.md b/scripts/release/README.md new file mode 100644 index 0000000..6707ca1 --- /dev/null +++ b/scripts/release/README.md @@ -0,0 +1,61 @@ +# Release process (manual, local signing) + +All TUF signing keys stay on your machine, in `./keystore` (gitignored — never +commit it). Nothing here touches CI or GitHub Secrets. + +## One-time setup + +``` +pip install -r requirements-dev.txt +python scripts/release/repo_init.py +``` + +Generates `scripts/release/keystore/` (private + public keys for the +root/targets/snapshot/timestamp TUF roles) and `scripts/release/repository/` +(initial signed metadata, including `root.json`). + +**Back up `keystore/` immediately, somewhere private and durable outside this +repo.** Losing the root key means you can never publish a trusted update to +existing installs again — they'd all need a fresh, non-updating reinstall. + +Rebuild once more (`pyinstaller labdaq.spec`) after this step, so the +freshly-generated `root.json` gets bundled into the app (see the `.spec` +file's `_root_json` check — it only bundles the file if it already exists). + +## Every release + +1. Bump `__version__` in `core/version.py`. +2. `pyinstaller labdaq.spec` → produces `dist/LabDAQ/`. +3. `python scripts/release/repo_release.py` → creates+signs + `scripts/release/repository/targets/labdaq-<version>.tar.gz` and updates + the metadata files in `scripts/release/repository/metadata/`. +4. Create (first time) or reuse the GitHub Release tagged **`updates`** on + this repo, and upload every file from `scripts/release/repository/metadata/` + and `scripts/release/repository/targets/` as release assets, **replacing** + any same-named files already there. + - This tag is fixed on purpose — the app's `metadata_base_url` always + points at `https://github.com/<owner>/<repo>/releases/download/updates/`. + A normal per-version-tagged release would give every version a + different URL, which breaks TUF's requirement that top-level metadata + (`timestamp.json`, `snapshot.json`, ...) live at a stable location. + - Cut a normal, separately-tagged human-facing release too if you want a + changelog/download page — this doesn't have to be the same release. +5. Test: launch a previous installed version, Settings → Check for Updates, + confirm it finds and applies the new version. + +## Re-signing without a new release + +Metadata expires even if nothing changed (`expiration_days` in +`repo_init.py`: root 365 days, targets/snapshot/timestamp 90 days). If you +haven't shipped a release before targets/snapshot/timestamp expire, clients +will start rejecting the metadata as stale. Re-sign with: + +``` +python -m tufup sign snapshot scripts/release/keystore +python -m tufup sign timestamp scripts/release/keystore +``` + +(run from the repo root; adjust the key directory path if you invoke this +from elsewhere) and re-upload the resulting files to the `updates` release. +Set a calendar reminder — there is no automation for this on purpose (manual +signing was a deliberate choice, not an oversight). diff --git a/scripts/release/repo_init.py b/scripts/release/repo_init.py new file mode 100644 index 0000000..04c3579 --- /dev/null +++ b/scripts/release/repo_init.py @@ -0,0 +1,57 @@ +""" +scripts/release/repo_init.py + +Run ONCE, ever, by the maintainer, locally. Generates the TUF signing keys +and the initial repository metadata (including root.json, which later gets +bundled into the app via labdaq.spec). + +Running this again after the repo already exists is safe (tufup skips +re-creating keys/roles that already exist) but there is normally no reason +to run it more than once per project. + +Keys land in ./keystore — never commit that directory (already gitignored). +Back it up somewhere safe and private; losing the root key means you can +never publish another trusted update to existing installs. +""" + +import os +from pathlib import Path + +from tufup.repo import Repository, RolesDict + +# Run from this script's own directory — tufup's config file (.tufup-repo-config) +# and all repo_dir/keys_dir paths are resolved relative to the current working +# directory, so invocation location must be consistent every time. +os.chdir(Path(__file__).resolve().parent) + +APP_NAME = "labdaq" + +# tufup-example's 1-day timestamp / 7-day snapshot defaults assume an +# automated CI worker re-signs on a schedule. We're signing manually +# (see README.md in this directory), so use longer, human-manageable +# expirations instead. Whichever of these lapses first is the cadence +# you need to re-sign at, even between actual app releases. +EXPIRATION_DAYS = RolesDict(root=365, targets=90, snapshot=90, timestamp=90) + + +def main(): + # Not using app_version_attr here: tufup's setuptools-based attribute + # reader resolves dotted paths relative to the current working directory, + # which we deliberately pin to this script's own directory below (tufup's + # own config-file/relative-path handling expects that) — the two + # assumptions conflict. repo_release.py passes the version explicitly + # instead, which sidesteps this entirely. + repo = Repository( + app_name=APP_NAME, + repo_dir=Path("repository"), + keys_dir=Path("keystore"), + expiration_days=EXPIRATION_DAYS, + ) + repo.initialize() + repo.save_config() + print(f"Initialized tufup repo for '{APP_NAME}' in ./repository, keys in ./keystore") + print("Back up ./keystore now, somewhere private and durable. It is gitignored on purpose.") + + +if __name__ == "__main__": + main() diff --git a/scripts/release/repo_release.py b/scripts/release/repo_release.py new file mode 100644 index 0000000..a3c8d03 --- /dev/null +++ b/scripts/release/repo_release.py @@ -0,0 +1,64 @@ +""" +scripts/release/repo_release.py + +Run after every `pyinstaller labdaq.spec` build, from anywhere (paths are +made absolute below before the working directory changes). Packages the +freshly built dist/LabDAQ bundle as a new signed tufup target. + +Prerequisites: + - repo_init.py has been run once already (./repository and ./keystore exist) + - core/version.py has been bumped to the new version + - dist/LabDAQ/ exists (pyinstaller labdaq.spec has just been run) + +After this completes, see README.md in this directory for how to publish +the updated ./repository/metadata and ./repository/targets files to the +"updates" GitHub Release. +""" + +import os +import sys +from pathlib import Path + +_THIS_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _THIS_DIR.parent.parent +_DIST_DIR = _REPO_ROOT / "dist" / "LabDAQ" + +# Import core.version before changing cwd — needs the repo root on sys.path, +# which won't be true anymore once we chdir into scripts/release below. +sys.path.insert(0, str(_REPO_ROOT)) +from core.version import __version__ as APP_VERSION + +from tufup.repo import Repository + +# tufup's config file / relative repo_dir / keys_dir all resolve against cwd +# (same reasoning as repo_init.py) — pin it here too, for the same reason. +os.chdir(_THIS_DIR) + + +def main(): + if not _DIST_DIR.is_dir(): + raise SystemExit( + f"{_DIST_DIR} not found — run `pyinstaller labdaq.spec` from the " + f"repo root first." + ) + + repo = Repository.from_config() + + changelog = input(f"One-line changelog for v{APP_VERSION} (optional): ").strip() + custom_metadata = {"changelog": changelog} if changelog else None + + repo.add_bundle( + new_bundle_dir=_DIST_DIR, + new_version=APP_VERSION, + skip_patch=True, # no binary delta patching for now — full archive only + custom_metadata=custom_metadata, + ) + repo.publish_changes(private_key_dirs=[Path("keystore")]) + + print(f"Signed and published v{APP_VERSION} to ./repository") + print("Next: upload ./repository/metadata/* and ./repository/targets/* " + "to the 'updates' GitHub Release — see README.md in this directory.") + + +if __name__ == "__main__": + main() diff --git a/ui/main_window.py b/ui/main_window.py index 90294ac..9090d0f 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -55,7 +55,8 @@ _LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_lig class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("LabDAQ") + from core.version import __version__ + self.setWindowTitle(f"LabDAQ v{__version__}") self.setMinimumSize(1000, 640) self.registry = DeviceRegistry() diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py index 290d9e0..75b6991 100644 --- a/ui/windows/settings_window.py +++ b/ui/windows/settings_window.py @@ -118,6 +118,23 @@ class SettingsWindow(QWidget): ) lay.addRow("Developer mode:", self._dev_chk) + from core.version import __version__ as _app_version + version_lbl = QLabel(f"v{_app_version}") + version_lbl.setObjectName("traceSource") + lay.addRow("Version:", version_lbl) + + update_row = QHBoxLayout() + self._update_btn = QPushButton("Check for Updates") + self._update_btn.setObjectName("configButton") + self._update_btn.clicked.connect(self._check_for_updates) + update_row.addWidget(self._update_btn) + update_row.addStretch() + lay.addRow("Updates:", update_row) + self._update_status_lbl = QLabel("") + self._update_status_lbl.setObjectName("traceSource") + self._update_status_lbl.setWordWrap(True) + lay.addRow("", self._update_status_lbl) + rst = QPushButton("Reset to Defaults"); rst.setObjectName("configButton") rst.clicked.connect(self._reset_to_defaults) lay.addRow("", rst) @@ -286,6 +303,45 @@ class SettingsWindow(QWidget): # ── Actions ─────────────────────────────────────────────────────────── + def _check_for_updates(self): + from core.updater import is_frozen + + if not is_frozen(): + self._update_status_lbl.setText("Not available outside the packaged app (dev mode).") + return + + from PyQt6.QtWidgets import QApplication + from core.updater import apply_update, check_for_update + + self._update_btn.setEnabled(False) + self._update_status_lbl.setText("Checking…") + QApplication.processEvents() + try: + new_version = check_for_update() + except Exception as e: + self._update_status_lbl.setText(f"Check failed: {e}") + self._update_btn.setEnabled(True) + return + self._update_btn.setEnabled(True) + + if not new_version: + self._update_status_lbl.setText("Up to date.") + return + + self._update_status_lbl.setText(f"Version {new_version} available.") + reply = QMessageBox.question( + self, "Update Available", + f"Version {new_version} is available. Download and install now?\n\n" + f"The app will close and relaunch to complete the update.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + apply_update() # may close/relaunch the process during this call + except Exception as e: + QMessageBox.critical(self, "Update Failed", str(e)) + def _reset_to_defaults(self): reply = QMessageBox.question( self, "Reset Settings", |
