Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ project uses [semantic versioning][semver].

### Added

- **The desktop app checks for updates, and declines while a job is running.**
One check a couple of seconds after the window opens, saying nothing unless
there is something to say, with the release named in the header and
**Help, Check for updates now** for an answer either way. **Options, Check
for updates on launch** turns the automatic check off. The check and the
signature probe run on a pool thread, so an unreachable network delays
nothing.

**Three outcomes, not two.** A check that could not be made is reported as a
failure rather than as "you are the newest release": the never-raising form
of the check returns nothing for a failed fetch, a TLS error and an
unparseable feed alike, and saying you are up to date on the strength of a
failed lookup is a claim. Whose result it is belongs to the check that is
running, so a manual check started during the first couple of seconds
survives the launch timer firing behind it instead of being answered
silently.

Installing follows the installer's refusal rather than working around it: a
running *or paused* job declines the update with the reason, since a paused
job is a partially written destination waiting to continue. Otherwise the
installer is downloaded and verified, the queue is checked a second time
because a job can start while the bytes arrive, and the app then closes
itself so maintenance can proceed. That close is what makes the update
possible, so it is announced rather than surprising. Progress is throttled
to roughly one signal per 256 KB but always reports the final block, so the
bar finishes instead of stopping just short.

**Cancel cancels.** The download is a blocking read loop on a pool thread and
cannot be interrupted, so cancellation is cooperative through the progress
callback — but it is terminal: the incomplete download is removed, nothing is
verified, and the app cannot go on to offer what the user just declined. Only
the file the release names is removed, since the download directory can be
one the caller owns.

- **A tag-triggered release candidate workflow.** Pushing `v*` gates the tag
against `src/offloader/_version.py` before spending a packaging run on it,
builds the unsigned bundle and installer on `windows-latest`, confirms every
Expand Down
24 changes: 14 additions & 10 deletions docs/release-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ tests or builds were run for this documentation task.
| Dependencies | Minimum versions and optional extras | Recorded build environment and pinned release dependency sets |
| Media tools | ffmpeg/ffprobe discovered externally; copying works without them | Explicit installer dependency policy and useful missing-tool messaging |
| Integrity | Detailed guarantees and remaining limits in `data-safety.md` | Release-specific regression evidence and operational validation |
| Updates | `offloader update` checks GitHub Releases, verifies the signed installer and hands it over; no in-app check or notification | Wire the check into the desktop interface; qualify an end-to-end update against a published release |
| Updates | `offloader update` and the desktop app both check GitHub Releases, verify the signed installer and hand it over, declining while a job is active | Qualify an end-to-end update against a published signed release on a clean machine |

Sources: [`pyproject.toml`](../pyproject.toml),
[`CI`](../.github/workflows/ci.yml), [`README`](../README.md),
Expand Down Expand Up @@ -207,15 +207,19 @@ an arbitrary install directory that might contain user material.
user-context relaunch, and safe settings preservation. A generic unattended
deployment must not launch an app in a missing or unrelated user's session.

The command-line half of the update client is now implemented in
`src/offloader/update.py` and documented in [updates.md](updates.md). It meets
the conditions this section set: the signature, publisher and embedded version
are all verified before elevation, and the `/D=` target is computed from the
running executable rather than from the uninstall registry key. It does not
close a running instance, so an update requires the operator to finish first
and the installer's refusal remains the guarantee. What is still deferred is
the in-app part: an automatic check, a notification, and a progress surface in
the desktop interface.
The update client is now implemented in `src/offloader/update.py`, wrapped for
the desktop app in `src/offloader/gui/updates.py`, and documented in
[updates.md](updates.md). It meets the conditions this section set: the
signature, publisher and embedded version are all verified before elevation,
and the `/D=` target is computed from the running executable rather than from
the uninstall registry key.

The app never replaces itself under an active transfer. A running or paused job
declines the update with a reason, the queue is rechecked immediately before
the installer is launched, and the app then closes itself deliberately so
maintenance can proceed. What remains is qualification rather than
implementation: an end-to-end update from one signed published release to the
next, on a clean machine, which needs a signed release to exist first.

**Acceptance gates:** exercise first install, custom path, same-version
reinstall, upgrade, failed upgrade recovery, silent install, and uninstall on
Expand Down
49 changes: 46 additions & 3 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,52 @@ place where the installer's own transactional recovery manages it (see
`installation.py`); the updater does not attempt a second recovery mechanism
on top. Nothing is deleted by the updater itself.

**There is no automatic check yet.** `offloader update` is explicit. An in-app
check, a notification and a timer belong with the desktop interface and are
not implemented here; see [release-plan.md](release-plan.md).
## In the desktop app

The app checks once, a couple of seconds after the window opens, and says
nothing unless there is something to say: an unrequested check that reports
"you are up to date" is noise. When a release is found, the header carries a
line naming it. **Help, Check for updates now** runs the same check and does
report either outcome, and **Options, Check for updates on launch** turns the
automatic one off. The check runs on a pool thread, so a slow or unreachable
network delays nothing and a failure appears in the status bar rather than in
a dialog.

Three outcomes, not two. `update.check()` never raises and returns `None` for
a failed fetch, a TLS error and an unparseable feed alike, so a caller that
reports its result to somebody cannot use it: "you are the newest release" is
a claim, and it would be made on the strength of a failed DNS lookup. The app
uses `check_feed()`, which returns `None` only when the feed answered and had
nothing newer, and raises `FeedError` otherwise. A check that could not be
made is reported as a failure.

Whose result it is belongs to the check that is running, not to whoever asked
last. A manual check started inside the first couple of seconds is still
running when the launch timer fires; the timer's check is refused as a
duplicate, and the announcement the manual request asked for survives it. The
intent is only ever raised while work is in flight, so a manual request behind
an automatic check is answered too.

**Cancel cancels.** The download runs on a pool thread and cannot be
interrupted, so cancellation is cooperative: the progress callback is the one
place the loop hands control back often enough, and it raises there. The
request is then terminal — the incomplete download is removed, nothing is
verified, and `ready` is not emitted, so the app cannot go on to offer what
the user just declined. Only the file named by the release is removed, because
the download directory can be one the caller owns.

Installing from the app follows the refusal above rather than working around
it. If any job is running or paused, the update is declined with the reason;
a paused job counts, because it is a partially written destination waiting to
continue. Otherwise the installer is downloaded, verified, and the app asks
once more before closing itself so the installer can proceed. The app closing
is what makes the update possible, so it is announced rather than surprising,
and the queue is checked a second time immediately before it happens: a job
can be started while the bytes are arriving.

`src/offloader/gui/updates.py` holds that sequence, with every step
injectable, so `tests/test_gui_updates.py` drives all of it, including each
refusal, without a network, a certificate or an installer.

## Reference

Expand Down
163 changes: 162 additions & 1 deletion src/offloader/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

from pathlib import Path

from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QMainWindow,
QMessageBox,
QProgressDialog,
QSplitter,
QStackedWidget,
QVBoxLayout,
Expand All @@ -26,6 +27,7 @@
from .preset_mode import PresetModePanel
from .queue_view import QueuePanel, reveal
from .simple_mode import SimpleModePanel
from .updates import UpdateController, refusal
from .widgets import button, label, row
from .worker import JobState, QueueController

Expand All @@ -34,6 +36,10 @@
"sound_on_completion": True,
"warn_on_duplicate": True,
"mode": "preset",
# On by default, and one network request: a packaged copy that never
# learns a fix exists is the worse default for a tool people trust with
# original media.
"check_for_updates": True,
}


Expand Down Expand Up @@ -69,6 +75,18 @@ def __init__(self) -> None:

self.queue = QueuePanel(self.controller)

self._update_progress: QProgressDialog | None = None
self._update_banner = label("", "muted")
self._update_banner.setStyleSheet(f"color: {theme.ACCENT};")
self._update_banner.setVisible(False)
self.updates = UpdateController(self)
self.updates.found.connect(self._on_update_available)
self.updates.upToDate.connect(self._on_update_absent)
self.updates.progress.connect(self._on_update_progress)
self.updates.ready.connect(self._on_update_ready)
self.updates.failed.connect(self._on_update_failed)
self.updates.cancelled.connect(self._on_update_cancelled)

# ----------------------------------------------------------- chrome
self._preset_button = button("Presets")
self._simple_button = button("Simple")
Expand All @@ -87,6 +105,9 @@ def __init__(self) -> None:
self._preset_button,
self._simple_button,
None,
# Right of the stretch, so an available update is visible without
# taking a dialog's worth of attention from whatever is running.
self._update_banner,
)

left = QWidget()
Expand Down Expand Up @@ -123,6 +144,13 @@ def __init__(self) -> None:
self._set_mode(1 if self.settings.get("mode") == "simple" else 0)
self.drives.start()

if self.settings.get("check_for_updates", True):
# Deferred rather than run here: the first paint should not wait on
# a network round trip, and a failure must not stop the window
# opening.
QTimer.singleShot(2500,
lambda: self._check_for_updates(announce=False))

# ---------------------------------------------------------------- chrome
def _build_menu(self) -> None:
job_menu = self.menuBar().addMenu("&Job")
Expand Down Expand Up @@ -166,11 +194,144 @@ def _build_menu(self) -> None:
open_config.triggered.connect(lambda: reveal(config_file(SETTINGS_FILE)))
options_menu.addAction(open_config)

self._update_action = QAction("Check for updates on launch", self,
checkable=True)
self._update_action.setChecked(bool(self.settings["check_for_updates"]))
self._update_action.toggled.connect(
lambda on: self._save_setting("check_for_updates", on))
options_menu.addAction(self._update_action)

help_menu = self.menuBar().addMenu("&Help")
self._check_updates = QAction("Check for updates now", self)
self._check_updates.triggered.connect(
lambda: self._check_for_updates(announce=True))
help_menu.addAction(self._check_updates)
help_menu.addSeparator()
about = QAction("About", self)
about.triggered.connect(self._show_about)
help_menu.addAction(about)

# ---------------------------------------------------------------- updates
def _check_for_updates(self, *, announce: bool) -> None:
"""`announce` reports "you are up to date"; the automatic check does
not, because an unasked-for check should only ever speak up when there
is something to say.

The intent belongs to the controller, with the check it started. This
used to be stamped here before `check_now` reported whether a check
actually began, so a manual check requested inside the first 2.5
seconds was downgraded by the launch timer firing behind it: the
duplicate was refused, the manual intent was already overwritten, and
the result the user asked for was handled silently.
"""
if not self.updates.check_now(announce=announce) and announce:
self.statusBar().showMessage(
"An update check is already running; its result will be "
"shown when it finishes.", 5000)

def _on_update_available(self, release) -> None:
self._update_banner.setText(
f"Offloader {release.version} is available. "
f"Help → Check for updates now to install it.")
self._update_banner.setVisible(True)
if not self.updates.announce:
return
refused = refusal(
[item.state for item in self.controller.items])
if refused is not None:
QMessageBox.information(self, "Update available", refused)
return
notes = (release.notes or "").strip()
detail = f"\n\n{notes[:800]}" if notes else ""
answer = QMessageBox.question(
self, "Update available",
f"Offloader {release.version} is available; this copy is "
f"{__version__}.\n\nIt will be downloaded and its signature "
f"checked before anything runs. Offloader then has to close for "
f"the installer to replace it.{detail}\n\nDownload it now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if answer != QMessageBox.Yes:
return
self._update_progress = QProgressDialog(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Wire Cancel to cancellation, or remove the button.

The dialog exposes Cancel, but its canceled signal is never connected and UpdateController has no cancellation state. Clicking it only hides the dialog; downloading and verification continue, and _on_update_ready() subsequently offers installation. The user's explicit cancellation therefore does not stop or suppress the operation.

Add cooperative cancellation through download/prepare, clean up only the owned incomplete download, and suppress ready for the canceled request. If cancellation is deferred, remove the Cancel button so the control does not promise behavior it cannot provide. Add a headless test that clicks Cancel during a blocked download and checks the terminal signal.

f"Downloading Offloader {release.version}…", "Cancel", 0, 100,
self)
self._update_progress.setWindowTitle("Update")
self._update_progress.setWindowModality(Qt.WindowModal)
self._update_progress.setAutoClose(False)
# The button offers to stop the download, so it has to stop it. Hiding
# the dialog while the bytes kept arriving, and then offering to
# install what the user had just declined, is worse than no button.
self._update_progress.canceled.connect(self.updates.cancel)
self._update_progress.show()
self.updates.prepare()

def _on_update_progress(self, done: int, total: int) -> None:
if self._update_progress is None:
return
if total:
self._update_progress.setMaximum(100)
self._update_progress.setValue(min(100, done * 100 // total))
else:
self._update_progress.setMaximum(0)

def _on_update_ready(self, release, digest: str) -> None:
self._close_update_progress()
# Checked again here, not only before the download: a job can be
# started while the bytes are arriving, and this is the last moment
# before the installer is asked to replace a running application.
refused = refusal(
[item.state for item in self.controller.items])
if refused is not None:
QMessageBox.information(self, "Update ready", refused)
return
answer = QMessageBox.question(
self, "Install update",
f"Offloader {release.version} is verified and ready.\n\n"
f"SHA-256 {digest[:16]}…\n\nOffloader will close so the "
f"installer can replace it. Install now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
if answer != QMessageBox.Yes:
return
try:
self.updates.install()
except Exception as exc:
QMessageBox.warning(self, "Update", f"Could not start the "
f"installer: {exc}")
return
# Closing is what lets the installer proceed, so it is done only after
# the elevated process has actually started.
self.close()

def _on_update_failed(self, message: str) -> None:
self._close_update_progress()
if self.updates.announce:
QMessageBox.warning(self, "Update", message)
else:
self.statusBar().showMessage(f"Update check: {message}", 8000)

def _on_update_cancelled(self) -> None:
self._close_update_progress()
self.statusBar().showMessage("Update download cancelled.", 5000)

def _close_update_progress(self) -> None:
if self._update_progress is None:
return
# Disconnected first: closing a QProgressDialog emits `canceled`, and
# a cancel raised while tearing down the dialog for a result that has
# already arrived would be reported as the user asking for one.
try:
self._update_progress.canceled.disconnect(self.updates.cancel)
except (RuntimeError, TypeError):
pass
self._update_progress.close()
self._update_progress = None

def _on_update_absent(self) -> None:
if self.updates.announce:
QMessageBox.information(
self, "Up to date",
f"Offloader {__version__} is the newest release available.")

def _save_setting(self, key: str, value) -> None:
self.settings[key] = value
write_json(config_file(SETTINGS_FILE), self.settings)
Expand Down
Loading
Loading