Coverage for scanpath_studio/desktop_update.py: 90%
612 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""One-click *Update & restart* for the desktop app (#385).
3Only the frozen bundle can replace itself, so this belongs to the desktop app
4alone: Help → About → *Check for updates* → **Update & restart**, or
5``ScanpathStudio --update`` headless. The design is Section 3 of
6``plans/139-build-versions-and-updates.md``:
81. :func:`refusal` — can this install update itself at all?
92. :func:`download` the release's archive and check its sha256 against the
10 ``digest`` GitHub publishes for it.
113. :func:`stage` it on the install's own volume, so the swap is a rename; on
12 macOS require the running app's Developer ID team and Gatekeeper's yes.
134. :func:`self_test` the staged copy with the launcher's ``--selfcheck``.
145. :func:`start_swap` — a detached helper waits for this process to exit,
15 swaps the payload, relaunches, and puts the old version back if the new
16 one never reports in (:func:`note_start`, :func:`note_boot`).
18Everything before step 5 leaves the install untouched. Stdlib and
19``packaging`` only, no Streamlit: the launcher calls it with no server running.
20"""
22from __future__ import annotations
24import base64
25import hashlib
26import http.client
27import json
28import math
29import os
30import platform
31import re
32import shlex
33import shutil
34import stat
35import subprocess
36import sys
37import tarfile
38import tempfile
39import threading
40import time
41import urllib.error
42import urllib.request
43import zipfile
44from collections.abc import Callable, Mapping
45from dataclasses import dataclass, field
46from pathlib import Path
48from packaging.version import InvalidVersion, Version
50from . import progress, updates
51from .updates import Asset, Release, UpdateCheck, UpdateCheckError
53APP_NAME = "ScanpathStudio"
54#: The macOS tools the updater trusts, by absolute path so nothing earlier on
55#: ``PATH`` can stand in for them.
56CODESIGN = "/usr/bin/codesign"
57SPCTL = "/usr/sbin/spctl"
58HDIUTIL = "/usr/bin/hdiutil"
59DITTO = "/usr/bin/ditto"
60OPEN = "/usr/bin/open"
61#: Where every update must come from; the test-only feed may use ``file:``.
62REPO_DOWNLOADS = f"https://github.com/{updates.REPO}/releases/download/"
63#: A local JSON file in GitHub's release shape that ``--update`` reads instead
64#: of the API — what the CI end-to-end run offers the fresh build through.
65FEED_ENV = "SCANPATH_UPDATE_FEED"
66#: Free space wanted on the state folder's volume: the download, the staged
67#: copy, and room to move the old version aside.
68DISK_FACTOR = 3
69#: An attempt this old with no helper behind it is abandoned.
70STALE_AFTER_S = 3600
72# The marker files below, the keys of ``pending.json`` and ``result.json``,
73# ``state_dir``'s paths and ``_ours``' root matching are a wire format between
74# releases: the helper an *old* version starts waits on what the *new*
75# version's launcher writes. Never change one without a migration
76# (``tests/test_desktop_update.py::test_the_update_marker_contract_is_pinned``).
77#: The attempt under way: ``root``, ``version``, ``previous``, ``at``.
78PENDING = "pending.json"
79#: The relaunched launcher's pid, written at launch (:func:`note_start`).
80STARTED = "started"
81#: Written once the relaunched server answers (:func:`note_boot`).
82BOOTED = "booted"
83#: How the attempt ended: ``status``, ``version``, ``previous``, ``reason``, ``pid``.
84RESULT = "result.json"
85#: The helper's first act, which :func:`start_swap` waits for before the app quits.
86HELPER_STARTED = "helper-started"
88#: Locked from :func:`prepare` until :func:`start_swap` has recorded the
89#: attempt, so a second attempt (another About tab, or ``--update``) can't
90#: clear the first one's files from under it. An OS lock on the file, so a
91#: process that dies lets go of it. Only one version ever reads it, so it is
92#: not part of the contract above.
93LOCK = "update.lock"
95#: The archive the updater installs per (platform, machine) — the names
96#: ``.github/workflows/desktop.yml`` gives them. Windows takes the ``.zip``,
97#: not the installer *Download* offers: the updater swaps files itself.
98UPDATE_ARCHIVES = {
99 ("darwin", "arm64"): "ScanpathStudio-macos-arm64.dmg",
100 ("win32", "amd64"): "ScanpathStudio-windows-x86_64.zip",
101 ("win32", "x86_64"): "ScanpathStudio-windows-x86_64.zip",
102 ("linux", "x86_64"): "ScanpathStudio-linux-x86_64.tar.gz",
103}
106class UpdateFailed(Exception):
107 """An update that could not go ahead; ``str()`` says why, for people.
109 Raised only before the swap begins, so the install is always untouched.
110 """
113@dataclass(frozen=True)
114class Install:
115 """Where the running desktop app lives, and what an update replaces.
117 ``root`` is the ``.app`` on macOS and the folder holding the executable
118 elsewhere. ``payload`` is what an update swaps inside it: ``Contents`` on
119 macOS (the user owns the ``.app`` they dragged in, even on a standard
120 account), else the executable and ``_internal`` — so the Windows
121 installer's ``unins000.*`` beside them survive an update.
122 """
124 root: Path
125 payload: tuple[str, ...]
126 executable: Path
127 system: str
130def _system(system: str | None) -> str:
131 system = sys.platform if system is None else system
132 return "linux" if system.startswith("linux") else system
135def update_archive(system: str | None = None, machine: str | None = None) -> str | None:
136 """The archive name the updater installs on this computer, or ``None``."""
137 machine = (platform.machine() if machine is None else machine).lower()
138 return UPDATE_ARCHIVES.get((_system(system), machine))
141def executable_in(root: Path, system: str) -> Path:
142 """The launcher executable inside a bundle rooted at ``root``."""
143 if system == "darwin":
144 return root / "Contents" / "MacOS" / APP_NAME
145 return root / (f"{APP_NAME}.exe" if system == "win32" else APP_NAME)
148def install_at(executable: Path, system: str | None = None) -> Install | None:
149 """The install whose launcher is ``executable`` — ``None`` if it isn't one."""
150 system = _system(system)
151 executable = Path(executable)
152 if system == "darwin":
153 contents = executable.parent.parent
154 app = contents.parent
155 if (
156 executable.name != APP_NAME
157 or executable.parent.name != "MacOS"
158 or contents.name != "Contents"
159 or app.suffix != ".app"
160 ):
161 return None
162 return Install(app, ("Contents",), executable, system)
163 if executable != executable_in(executable.parent, system):
164 return None
165 return Install(
166 executable.parent, (executable.name, "_internal"), executable, system
167 )
170def current_install() -> Install | None:
171 """The running bundle's install, or ``None`` outside the frozen app."""
172 if not getattr(sys, "frozen", False):
173 return None
174 return install_at(Path(os.path.abspath(sys.executable)))
177def _user_state_dir(system: str, env: Mapping[str, str]) -> Path:
178 home = Path(env["HOME"]) if env.get("HOME") else Path.home()
179 if system == "darwin":
180 return home / "Library" / "Caches" / "Scanpath Studio" / "update"
181 if system == "win32":
182 base = (
183 Path(env["LOCALAPPDATA"])
184 if env.get("LOCALAPPDATA")
185 else (home / "AppData" / "Local")
186 )
187 return base / "Scanpath Studio" / "update"
188 base = (
189 Path(env["XDG_CACHE_HOME"]) if env.get("XDG_CACHE_HOME") else (home / ".cache")
190 )
191 return base / "scanpath-studio" / "update"
194def _device(path: Path) -> int | None:
195 """The volume ``path`` is on — read from its nearest existing ancestor."""
196 for candidate in (path, *path.parents):
197 try:
198 return candidate.stat().st_dev
199 except OSError:
200 continue
201 return None
204def state_dir(install: Install, *, env: Mapping[str, str] | None = None) -> Path:
205 """The folder an update stages in, swaps through and leaves its markers in.
207 On the install's own volume, so every move is a rename: the per-user cache
208 folder when it shares the volume, else a hidden folder beside the install.
209 The relaunched launcher calls this too, so it must stay deterministic.
210 """
211 env = os.environ if env is None else env
212 user = _user_state_dir(install.system, env)
213 if _device(user) == _device(install.root):
214 return user
215 return install.root.parent / f".{APP_NAME}-update"
218def asset_for(
219 check: UpdateCheck, install: Install, *, machine: str | None = None
220) -> Asset | None:
221 """The release file the updater would install here, if the release has it."""
222 name = update_archive(install.system, machine)
223 if check.latest is None or name is None:
224 return None
225 return check.latest.asset(name)
228def _read_json(path: Path) -> dict | None:
229 try:
230 data = json.loads(path.read_text(encoding="utf-8-sig"))
231 except (OSError, ValueError):
232 return None
233 return data if isinstance(data, dict) else None
236def _write_json(path: Path, data: dict) -> None:
237 """Write atomically: a reader sees the old file or the new, never half."""
238 partial = path.with_name(path.name + ".tmp")
239 partial.write_text(json.dumps(data), encoding="utf-8")
240 os.replace(partial, path)
243def _run(
244 run: Callable,
245 argv: list[str],
246 failure: str,
247 *,
248 timeout: float = 600,
249 env: Mapping[str, str] | None = None,
250) -> str:
251 """Run one tool; any failure becomes :class:`UpdateFailed` with ``failure``."""
252 try:
253 result = run(
254 argv,
255 capture_output=True,
256 text=True,
257 errors="replace",
258 timeout=timeout,
259 check=False,
260 env=env,
261 )
262 except (OSError, subprocess.SubprocessError) as error:
263 raise UpdateFailed(failure) from error
264 if result.returncode != 0:
265 raise UpdateFailed(failure)
266 return (result.stdout or "") + (result.stderr or "")
269def team_id(app: Path, *, run: Callable = subprocess.run) -> str | None:
270 """The Developer ID team that signed ``app`` — ``None`` if ad-hoc or unsigned."""
271 try:
272 result = run(
273 [CODESIGN, "-dv", "--verbose=2", str(app)],
274 capture_output=True,
275 text=True,
276 errors="replace",
277 timeout=60,
278 check=False,
279 )
280 except (OSError, subprocess.SubprocessError):
281 return None
282 found = re.search(
283 r"^TeamIdentifier=(\S+)$",
284 (result.stdout or "") + (result.stderr or ""),
285 re.MULTILINE,
286 )
287 # Ad-hoc and unsigned code says "TeamIdentifier=not set", which the
288 # pattern (one token, then the end of the line) never matches.
289 if result.returncode != 0 or found is None:
290 return None
291 return found.group(1)
294def _pending(state: Path) -> dict | None:
295 """The attempt under way in ``state``, unless it is stale."""
296 pending = _read_json(state / PENDING)
297 if pending is None:
298 return None
299 try:
300 started = float(pending.get("at") or 0)
301 except (TypeError, ValueError):
302 return None
303 # A start in the future is a clock that moved, not an attempt to wait on.
304 if abs(time.time() - started) > STALE_AFTER_S:
305 return None
306 return pending
309def _nearest_existing(path: Path) -> Path:
310 """``path``, or the closest folder above it that exists."""
311 for candidate in (path, *path.parents):
312 if candidate.exists():
313 return candidate
314 return path
317def _can_write(folder: Path, system: str) -> bool:
318 """Whether this account can create (and so move) entries in ``folder``.
320 On Windows ``os.access`` reads only the read-only attribute and ignores
321 the folder's ACL, so a real file is created there and removed again.
322 Elsewhere the permission bits ``os.access`` reads decide it.
323 """
324 if system != "win32":
325 return os.access(folder, os.W_OK)
326 try:
327 # Delete-on-close (O_TEMPORARY): the OS removes it even while
328 # antivirus holds it, so no probe is ever left behind.
329 with tempfile.TemporaryFile(prefix=".scanpath-write-test-", dir=folder):
330 return True
331 except OSError:
332 return False
335def _not_ours(state: Path) -> bool:
336 """Whether ``state`` exists but isn't safe to stage in: a link, not a
337 folder, or (on POSIX) a folder another account owns — beside a shared
338 install, someone else could have made it to swap in their own files."""
339 try:
340 info = state.lstat()
341 except FileNotFoundError:
342 return False
343 except OSError:
344 return True
345 if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
346 return True
347 geteuid = getattr(os, "geteuid", None)
348 return geteuid is not None and info.st_uid != geteuid()
351def _not_ours_reason(state: Path) -> str:
352 return f"{state} isn't a folder of this account's own, so the update won't use it."
355def refusal(
356 check: UpdateCheck,
357 install: Install | None,
358 *,
359 state: Path | None = None,
360 run: Callable = subprocess.run,
361 machine: str | None = None,
362) -> str | None:
363 """Why this app can't update itself to ``check.latest`` — ``None`` if it can.
365 Cheap enough for every render of About: no download, and on macOS one
366 ``codesign`` call. It creates nothing: the state folder is made by
367 :func:`prepare`.
368 """
369 if install is None:
370 return "Only the desktop app can update itself."
371 if check.status != "update_available" or check.latest is None:
372 return "There is no newer release to update to."
373 try:
374 prerelease = Version(check.latest.version).is_prerelease
375 except InvalidVersion:
376 return f"{check.latest.tag} isn't a version this app can install."
377 if prerelease:
378 return f"v{check.latest.version} is a pre-release; it is never installed automatically."
379 asset = asset_for(check, install, machine=machine)
380 if asset is None:
381 if update_archive(install.system, machine) is None:
382 return "There is no desktop build for this computer."
383 return (
384 "This release has no update for this computer yet; desktop builds "
385 "are uploaded up to an hour after a release."
386 )
387 if not asset.digest.startswith("sha256:"):
388 return (
389 "This release's download has no checksum to verify it against, so "
390 "it can't be installed automatically."
391 )
392 if "/AppTranslocation/" in install.root.as_posix():
393 return (
394 "macOS is running Scanpath Studio from a temporary copy. Move it to "
395 "Applications, open it from there, and try again."
396 )
397 if not _can_write(install.root, install.system):
398 return (
399 f"This account can't change {install.root}; whoever installed "
400 "Scanpath Studio has to update it."
401 )
402 state = state_dir(install) if state is None else state
403 if _not_ours(state):
404 return _not_ours_reason(state)
405 # The folder itself, or where prepare() will make it.
406 there = _nearest_existing(state)
407 if not _can_write(there, install.system) or _device(there) != _device(install.root):
408 return (
409 f"There's nowhere on this disk beside {install.root} to prepare the update."
410 )
411 if _pending(state) is not None:
412 return "An update is already under way."
413 free = shutil.disk_usage(there).free
414 if asset.size and free < DISK_FACTOR * asset.size:
415 return (
416 f"Updating needs about {DISK_FACTOR * asset.size / 1e6:,.0f} MB free; "
417 f"this disk has {free / 1e6:,.0f} MB."
418 )
419 if install.system == "darwin" and team_id(install.root, run=run) is None:
420 return (
421 "This copy of Scanpath Studio isn't signed by its developers, so an "
422 "update can't be checked against it."
423 )
424 return None
427def child_env(base: Mapping[str, str] | None = None) -> dict[str, str]:
428 """The environment for another bundle this one starts.
430 PyInstaller's bootloader leaves its own state in the environment (and, on
431 Linux, points ``LD_LIBRARY_PATH`` into this bundle). The staged copy and
432 the relaunched app must start as fresh top-level apps:
433 ``PYINSTALLER_RESET_ENVIRONMENT`` tells their bootloader so, and the
434 library path the user had is put back.
435 """
436 env = dict(os.environ if base is None else base)
437 env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
438 original = env.pop("LD_LIBRARY_PATH_ORIG", None)
439 meipass = getattr(sys, "_MEIPASS", None)
440 if original is not None:
441 env["LD_LIBRARY_PATH"] = original
442 elif meipass and env.get("LD_LIBRARY_PATH", "").startswith(meipass):
443 del env["LD_LIBRARY_PATH"]
444 return env
447def feed_release(path: Path) -> Release:
448 """A release read from a local JSON file in GitHub's shape (``FEED_ENV``)."""
449 try:
450 payload = json.loads(Path(path).read_text(encoding="utf-8"))
451 except (OSError, ValueError) as error:
452 raise UpdateCheckError(f"The update feed {path} couldn't be read.") from error
453 return updates._release_from(payload)
456CHUNK = 1 << 20
457#: The staged copy's ``--selfcheck``. Windows' first scan of a fresh bundle
458#: can take minutes, like the smoke test's budget.
459SELFCHECK_TIMEOUT_S = 300
462def _allowed(url: str, *, allow_file: bool) -> bool:
463 return url.startswith(REPO_DOWNLOADS) or (allow_file and url.startswith("file:"))
466def download(
467 asset: Asset,
468 folder: Path,
469 *,
470 allow_file: bool = False,
471 opener: Callable | None = None,
472 timeout: float = 30.0,
473) -> Path:
474 """Fetch ``asset`` into ``folder`` and check it against its sha256 digest.
476 Reports bytes to the active progress task, whose cancel checkpoint this
477 loop therefore is. A partial or mismatched file is deleted, never kept,
478 and so is one that grows past the size the release lists.
479 """
480 if not _allowed(asset.url, allow_file=allow_file):
481 raise UpdateFailed(
482 "The download isn't from Scanpath Studio's own releases, so it was "
483 "not fetched."
484 )
485 algorithm, _, expected = asset.digest.partition(":")
486 if algorithm != "sha256" or not expected:
487 raise UpdateFailed(
488 "This release's download has no checksum to verify it against, so "
489 "it can't be installed automatically."
490 )
491 from .build_info import build_info
493 opener = updates._urlopen if opener is None else opener
494 folder.mkdir(parents=True, exist_ok=True)
495 target = folder / asset.name
496 partial = target.with_name(target.name + ".part")
497 request = urllib.request.Request(
498 asset.url, headers={"User-Agent": f"scanpath-studio/{build_info().version}"}
499 )
501 def unsaved(error: OSError) -> UpdateFailed:
502 return UpdateFailed(f"The download couldn't be saved: {_short_reason(error)}")
504 def stopped() -> UpdateFailed:
505 return UpdateFailed("The download stopped before it finished; are you offline?")
507 digest = hashlib.sha256()
508 done = 0
509 finished = False
510 try:
511 out = open(partial, "wb")
512 except OSError as error:
513 raise unsaved(error) from error
514 try:
515 with out:
516 progress.report(0, asset.size or None, unit="bytes")
517 try:
518 response = opener(request, timeout=timeout)
519 except urllib.error.HTTPError as error:
520 raise UpdateFailed(
521 f"GitHub answered HTTP {error.code} for the download, so it "
522 "was not fetched."
523 ) from error
524 except (OSError, http.client.HTTPException) as error:
525 raise UpdateFailed(
526 "The download couldn't start; are you offline?"
527 ) from error
528 with response:
529 while True:
530 try:
531 chunk = response.read(CHUNK)
532 except (OSError, http.client.HTTPException) as error:
533 raise stopped() from error
534 if not chunk:
535 break
536 done += len(chunk)
537 if asset.size and done > asset.size:
538 raise UpdateFailed(
539 "The download is larger than the release says it "
540 "is, so it was thrown away."
541 )
542 try:
543 out.write(chunk)
544 except OSError as error:
545 raise unsaved(error) from error
546 digest.update(chunk)
547 progress.report(done, asset.size or None, unit="bytes")
548 finished = True
549 except OSError as error: # closing the file: the disk filled up
550 raise unsaved(error) from error
551 finally:
552 if not finished:
553 partial.unlink(missing_ok=True)
554 if digest.hexdigest() != expected.lower():
555 partial.unlink(missing_ok=True)
556 raise UpdateFailed(
557 "The download doesn't match the checksum GitHub published for it, "
558 "so it was thrown away."
559 )
560 os.replace(partial, target)
561 return target
564def _stage_dmg(dmg: Path, target: Path, *, run: Callable) -> Path:
565 """Copy the ``.app`` out of the disk image, read-only and unseen by Finder."""
566 mount = target / "mount"
567 mount.mkdir()
568 app = target / f"{APP_NAME}.app"
569 # The attach is inside the `try`: one that mounted the image and then
570 # timed out still gets the detach, rather than leaving the image mounted.
571 try:
572 _run(
573 run,
574 [
575 HDIUTIL,
576 "attach",
577 "-nobrowse",
578 "-readonly",
579 "-mountpoint",
580 str(mount),
581 str(dmg),
582 ],
583 "The downloaded disk image couldn't be opened.",
584 )
585 if not (mount / f"{APP_NAME}.app").is_dir():
586 raise UpdateFailed(
587 "The download doesn't contain Scanpath Studio where it should."
588 )
589 _run(
590 run,
591 [DITTO, str(mount / f"{APP_NAME}.app"), str(app)],
592 "The new version couldn't be copied out of the disk image.",
593 )
594 finally:
595 try:
596 run(
597 [HDIUTIL, "detach", str(mount), "-force"],
598 capture_output=True,
599 text=True,
600 errors="replace",
601 timeout=120,
602 check=False,
603 )
604 except (OSError, subprocess.SubprocessError):
605 pass
606 return app
609def verify_signature(
610 staged_app: Path, running_app: Path, *, run: Callable = subprocess.run
611) -> None:
612 """The staged ``.app`` must be intact, from this app's team, and pass Gatekeeper."""
613 _run(
614 run,
615 [CODESIGN, "--verify", "--deep", "--strict", str(staged_app)],
616 "The new version's signature is broken, so it was not installed.",
617 )
618 expected = team_id(running_app, run=run)
619 if expected is None or team_id(staged_app, run=run) != expected:
620 raise UpdateFailed(
621 "The new version isn't signed by the same developers as this one, so "
622 "it was not installed."
623 )
624 _run(
625 run,
626 [SPCTL, "--assess", "--type", "exec", str(staged_app)],
627 "macOS's Gatekeeper rejected the new version, so it was not installed.",
628 )
631def stage(
632 archive: Path, state: Path, install: Install, *, run: Callable = subprocess.run
633) -> Path:
634 """Unpack ``archive`` into ``<state>/staged``; return the new copy's root."""
635 target = state / "staged"
636 shutil.rmtree(target, ignore_errors=True)
637 target.mkdir(parents=True)
638 try:
639 if install.system == "darwin":
640 root = _stage_dmg(archive, target, run=run)
641 verify_signature(root, install.root, run=run)
642 elif install.system == "win32":
643 with zipfile.ZipFile(archive) as bundle:
644 bundle.extractall(target)
645 root = target / APP_NAME
646 else:
647 with tarfile.open(archive) as bundle:
648 bundle.extractall(target, filter="data")
649 root = target / APP_NAME
650 except (OSError, zipfile.BadZipFile, tarfile.TarError) as error:
651 raise UpdateFailed("The download couldn't be unpacked.") from error
652 if not executable_in(root, install.system).is_file():
653 raise UpdateFailed(
654 "The download doesn't contain Scanpath Studio where it should."
655 )
656 return root
659def self_test(
660 staged_root: Path, system: str, *, run: Callable = subprocess.run
661) -> None:
662 """Run the staged copy's ``--selfcheck``: it must load and draw before it replaces this one."""
663 env = child_env()
664 env["SCANPATH_DESKTOP_NO_LOG_FILE"] = "1"
665 _run(
666 run,
667 [str(executable_in(staged_root, system)), "--selfcheck"],
668 "The new version failed its self-test, so it was not installed.",
669 timeout=SELFCHECK_TIMEOUT_S,
670 env=env,
671 )
674#: How long the relaunched version has to answer before the old one is put
675#: back. The relaunched launcher itself waits up to its ``HEALTH_TIMEOUT_S``
676#: (180 s — Windows' first-launch scan of a new bundle) for its server before
677#: it reports in, so this must leave room beyond that.
678BOOT_TIMEOUT_S = 240
679#: How long :func:`start_swap` waits for the helper's first sign of life.
680HANDSHAKE_TIMEOUT_S = 15
681#: How long the helper waits for this process to exit before giving up.
682QUIT_TIMEOUT_S = 60
683#: Between "Restarting into vX" and the exit, so the message reaches the browser.
684RESTART_DELAY_S = 2.0
685#: Launch settings the relaunched app keeps; ``open -n`` starts it with a
686#: fresh environment, so on macOS they are passed on explicitly.
687FORWARDED_ENV = (
688 "SCANPATH_DESKTOP_PORT",
689 "SCANPATH_DESKTOP_NO_BROWSER",
690 "SCANPATH_DESKTOP_BROWSER",
691 "SCANPATH_DESKTOP_NO_LOG_FILE",
692 "SCANPATH_DESKTOP_IDLE_EXIT_S",
693)
694#: The Windows installer's uninstall entry (desktop/windows_installer.iss's
695#: AppId), whose DisplayVersion the helper brings up to date.
696UNINSTALL_KEY = (
697 "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
698 "{6F1C9A52-3B7E-4D21-9C8A-5E2F4B7D1A93}_is1"
699)
700#: What one attempt leaves in the state folder; ``helper.log`` stays for a bug report.
701_ATTEMPT_FOLDERS = ("download", "staged", "old", "failed")
702_ATTEMPT_FILES = (PENDING, STARTED, BOOTED, RESULT, HELPER_STARTED)
705@dataclass(frozen=True)
706class SwapPlan:
707 """Everything the helper needs: whom to wait for, what to swap, how to relaunch."""
709 pid: int
710 install: Install
711 staged: Path
712 state: Path
713 version: str
714 previous: str
715 relaunch: tuple[str, ...]
716 boot_timeout_s: float = BOOT_TIMEOUT_S
717 quit_timeout_s: float = QUIT_TIMEOUT_S
718 #: The attempt's :data:`LOCK`, which :func:`start_swap` lets go of once it
719 #: has recorded the attempt. Dropping the plan lets go of it too.
720 lock: _Lock | None = field(default=None, compare=False, repr=False)
723@dataclass(frozen=True)
724class UpdateResult:
725 """How the last update ended: ``updated``, ``rolled_back`` or ``failed``."""
727 status: str
728 version: str
729 previous: str
730 reason: str = ""
731 pid: int | None = None
732 #: When the helper wrote it (the file's time), so About can let it go.
733 at: float | None = None
736#: The state folders this process holds :data:`LOCK` in. Where ``flock`` is
737#: emulated with POSIX record locks (NFS, CIFS), the lock is the process's,
738#: so two About tabs would not exclude each other without this.
739_HELD: set[str] = set()
740_HELD_GUARD = threading.Lock()
743class _Lock:
744 """An attempt's hold on :data:`LOCK`; :meth:`release` is idempotent."""
746 def __init__(self, handle, key: str) -> None:
747 self._handle = handle
748 self._key = key
750 def release(self) -> None:
751 with _HELD_GUARD:
752 key, self._key = self._key, None
753 if key is not None:
754 _HELD.discard(key)
755 handle, self._handle = self._handle, None
756 if handle is None:
757 return
758 try:
759 if os.name == "nt":
760 import msvcrt
762 handle.seek(0)
763 msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
764 except OSError:
765 pass
766 finally:
767 handle.close()
769 def __del__(self) -> None:
770 self.release()
773def _lock(state: Path) -> _Lock:
774 """Take :data:`LOCK` in ``state``, or refuse: another attempt holds it.
776 Two About tabs share one process, so :data:`_HELD` excludes them there;
777 the file lock excludes ``--update`` or a second copy of the app.
778 """
779 key = os.path.normcase(os.path.realpath(state))
780 with _HELD_GUARD:
781 if key in _HELD:
782 raise UpdateFailed("An update is already under way.")
783 _HELD.add(key)
784 try:
785 handle = open(state / LOCK, "a+b")
786 except BaseException:
787 with _HELD_GUARD:
788 _HELD.discard(key)
789 raise
790 try:
791 if os.name == "nt":
792 import msvcrt
794 handle.seek(0)
795 msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
796 else:
797 import fcntl
799 fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
800 except BlockingIOError:
801 _Lock(handle, key).release()
802 raise UpdateFailed("An update is already under way.") from None
803 except OSError:
804 if os.name == "nt": # locking() says only that the byte is taken
805 _Lock(handle, key).release()
806 raise UpdateFailed("An update is already under way.") from None
807 # A file system without locks: only this process is guarded.
808 handle.close()
809 return _Lock(None, key)
810 return _Lock(handle, key)
813def _make_state(state: Path) -> None:
814 """Create the state folder, private to this account (0o700 on POSIX).
816 Refuses one that is a link or another account's, checked again after
817 making it, since a folder beside a shared install could appear between
818 the check and the ``mkdir``.
819 """
820 if _not_ours(state):
821 raise UpdateFailed(_not_ours_reason(state))
822 state.mkdir(mode=0o700, parents=True, exist_ok=True)
823 if _not_ours(state):
824 raise UpdateFailed(_not_ours_reason(state))
825 if os.name == "posix":
826 os.chmod(state, 0o700)
829def relaunch_command(
830 install: Install, env: Mapping[str, str] | None = None
831) -> tuple[str, ...]:
832 """How the helper starts the app again: through Launch Services on macOS."""
833 if install.system != "darwin":
834 return (str(install.executable),)
835 env = os.environ if env is None else env
836 forwarded = [
837 item
838 for name in FORWARDED_ENV
839 if name in env
840 for item in ("--env", f"{name}={env[name]}")
841 ]
842 return (OPEN, "-n", *forwarded, str(install.root))
845_SH_HELPER = r"""#!/bin/sh
846# Scanpath Studio's update helper (#385), written by desktop_update.helper_script.
847# Waits for the app to quit, swaps the new version in, relaunches it, and puts
848# the old version back if the new one never reports in.
849APP_PID=@@PID@@
850ROOT=@@ROOT@@
851STATE=@@STATE@@
852NEW=@@NEW@@
853ENTRIES=@@ENTRIES@@
854QUIT_TICKS=@@QUIT_TICKS@@
855BOOT_TICKS=@@BOOT_TICKS@@
856BOOT_TIMEOUT=@@BOOT_TIMEOUT@@
857VERSION=@@VERSION@@
858PREVIOUS=@@PREVIOUS@@
859TRACK_LAUNCH=@@TRACK_LAUNCH@@
860LAUNCHED_PID=""
862# One timestamped line per stage, on stdout (helper.log), to tell where it stopped.
863log() {
864 echo "$(date '+%H:%M:%S') $*"
865}
867# First, before anything can fail: the app waits for this before it quits.
868: > "$STATE/helper-started"
869log "helper started (waiting for pid $APP_PID to quit)"
871result() {
872 log "result: $1 $2"
873 printf '{"status": "%s", "version": "%s", "previous": "%s", "reason": "%s", "pid": %s}\n' \
874 "$1" "$VERSION" "$PREVIOUS" "$2" "${3:-null}" > "$STATE/result.json.tmp" &&
875 mv -f "$STATE/result.json.tmp" "$STATE/result.json"
876}
878# The launched process, for the rollback of a version that never wrote
879# `started` — except through macOS's `open`, whose $! exits at once: a pid to
880# kill later could by then be some other process's.
881relaunch() {
882 nohup @@RELAUNCH@@ >/dev/null 2>&1 &
883 if [ "$TRACK_LAUNCH" = 1 ]; then
884 LAUNCHED_PID=$!
885 fi
886 log "relaunched (pid ${LAUNCHED_PID:-unknown})"
887}
889# The old version is (or is not) in place and the attempt is over: say so,
890# forget the attempt so the app does not report in for nobody, start it again.
891give_up() {
892 log "giving up: $1"
893 result failed "$1"
894 rm -f "$STATE/@@PENDING@@" "$STATE/started" "$STATE/booted" "$STATE/helper-started"
895 relaunch
896 exit 1
897}
899# Move every entry from $1 to $2; on a failure put back what moved. An entry
900# that already exists at the destination is a failure, never nested into.
901move_all() {
902 moved=""
903 for entry in $ENTRIES; do
904 if [ ! -e "$2/$entry" ] && mv "$1/$entry" "$2/$entry"; then
905 moved="$moved $entry"
906 else
907 for back in $moved; do mv "$2/$back" "$1/$back"; done
908 return 1
909 fi
910 done
911}
913ticks=0
914while kill -0 "$APP_PID" 2>/dev/null; do
915 if [ "$ticks" -ge "$QUIT_TICKS" ]; then
916 # The old version is still running, so nothing is relaunched; forget the
917 # attempt so a retry isn't refused as one already under way.
918 log "app did not quit within the timeout"
919 result failed "the app did not quit"
920 rm -f "$STATE/@@PENDING@@" "$STATE/started" "$STATE/booted" "$STATE/helper-started"
921 log "done (gave up)"
922 exit 1
923 fi
924 sleep 0.5
925 ticks=$((ticks + 1))
926done
927log "app quit"
929rm -rf "$STATE/old" "$STATE/failed"
930rm -f "$STATE/started" "$STATE/booted"
931if ! mkdir "$STATE/old" "$STATE/failed"; then
932 give_up "the update folder could not be prepared"
933fi
934if ! move_all "$ROOT" "$STATE/old"; then
935 give_up "the old version could not be moved aside"
936fi
937log "moved old aside"
938if ! move_all "$NEW" "$ROOT"; then
939 if move_all "$STATE/old" "$ROOT"; then
940 give_up "the new version could not be moved into place"
941 fi
942 give_up "the new version could not be moved into place, and the old one could not be put back"
943fi
944log "moved new in"
945touch "$ROOT"
946relaunch
948log "waiting for the new version to boot (up to $BOOT_TIMEOUT seconds)"
949ticks=0
950while [ ! -e "$STATE/booted" ]; do
951 if [ "$ticks" -ge "$BOOT_TICKS" ]; then
952 log "boot timeout, rolling back"
953 new_pid=$(cat "$STATE/started" 2>/dev/null)
954 # A version that hung before writing `started` is still the process we launched.
955 [ -n "$new_pid" ] || new_pid=$LAUNCHED_PID
956 if [ -n "$new_pid" ]; then
957 log "stopping the new version (pid $new_pid)"
958 kill "$new_pid" 2>/dev/null
959 sleep 2
960 kill -9 "$new_pid" 2>/dev/null
961 fi
962 rm -f "$STATE/@@PENDING@@" "$STATE/started" "$STATE/helper-started"
963 cleanup=1
964 if move_all "$ROOT" "$STATE/failed"; then
965 if move_all "$STATE/old" "$ROOT"; then
966 result rolled_back "the new version did not start within $BOOT_TIMEOUT seconds"
967 else
968 # Never leave the install empty, and keep the new version's files.
969 move_all "$STATE/failed" "$ROOT"
970 result failed "the new version did not start, and the old one could not be put back"
971 cleanup=0
972 fi
973 else
974 result failed "the new version did not start, and could not be removed"
975 cleanup=0
976 fi
977 touch "$ROOT"
978 relaunch
979 if [ "$cleanup" = 1 ]; then
980 rm -rf "$STATE/old" "$STATE/failed" "$STATE/staged" "$STATE/download"
981 fi
982 log "done (rollback finished, cleanup=$cleanup)"
983 exit 1
984 fi
985 sleep 0.5
986 ticks=$((ticks + 1))
987done
989new_pid=$(cat "$STATE/started" 2>/dev/null)
990log "booted (pid ${new_pid:-unknown})"
991result updated "" "${new_pid:-null}"
992rm -rf "$STATE/old" "$STATE/failed" "$STATE/staged" "$STATE/download"
993rm -f "$STATE/@@PENDING@@" "$STATE/started" "$STATE/booted" "$STATE/helper-started"
994log "done"
995"""
997_PS_HELPER = r"""# Scanpath Studio's update helper (#385), written by desktop_update.helper_script.
998# Waits for the app to quit, swaps the new version in, relaunches it, and puts
999# the old version back if the new one never reports in. Windows PowerShell 5.1.
1000$AppPid = @@PID@@
1001$Root = @@ROOT@@
1002$State = @@STATE@@
1003$New = @@NEW@@
1004$Entries = @(@@ENTRIES@@)
1005$QuitTimeoutMs = @@QUIT_MS@@
1006$BootTicks = @@BOOT_TICKS@@
1007$BootTimeout = @@BOOT_TIMEOUT@@
1008$Version = @@VERSION@@
1009$Previous = @@PREVIOUS@@
1010$Relaunch = @(@@RELAUNCH@@)
1011$UninstallKey = @@UNINSTALL_KEY@@
1012$Pending = @@PENDING@@
1013$script:Launched = $null
1014# Under -EncodedCommand with redirected output, progress records are
1015# serialized into the log as CLIXML.
1016$ProgressPreference = 'SilentlyContinue'
1018# One timestamped line per stage, on stdout (helper.log), to tell where it
1019# stopped. Raw stdout skips the host's CLIXML wrapping of -EncodedCommand
1020# output (which the information stream gets) and never reaches a function's
1021# pipeline or return value.
1022function Write-Log($Message) {
1023 [Console]::Out.WriteLine((Get-Date -Format 'HH:mm:ss') + ' ' + $Message)
1024 [Console]::Out.Flush()
1025}
1027# First, before anything can fail: the app waits for this before it quits.
1028[IO.File]::WriteAllText((Join-Path $State 'helper-started'), '')
1029Write-Log "helper started (waiting for pid $AppPid to quit)"
1031function Write-Result($Status, $Reason, $NewPid) {
1032 Write-Log "result: $Status $Reason"
1033 $record = [ordered]@{ status = $Status; version = $Version; previous = $Previous; reason = $Reason; pid = $NewPid }
1034 $tmp = Join-Path $State 'result.json.tmp'
1035 [IO.File]::WriteAllText($tmp, ($record | ConvertTo-Json -Compress))
1036 Move-Item -LiteralPath $tmp -Destination (Join-Path $State 'result.json') -Force
1037}
1039# Keeps the started process, for the rollback of a version that never wrote
1040# `started`; assigned, so nothing reaches the pipeline.
1041function Start-App {
1042 $rest = @($Relaunch | Select-Object -Skip 1 | ForEach-Object { '"' + $_ + '"' })
1043 if ($rest.Count) {
1044 $script:Launched = Start-Process -FilePath $Relaunch[0] -ArgumentList $rest -WorkingDirectory $Root -PassThru
1045 } else {
1046 $script:Launched = Start-Process -FilePath $Relaunch[0] -WorkingDirectory $Root -PassThru
1047 }
1048 $launchedId = 'unknown'
1049 if ($script:Launched) { $launchedId = $script:Launched.Id }
1050 Write-Log "relaunched (pid $launchedId)"
1051}
1053# The old version is (or is not) in place and the attempt is over: say so,
1054# forget the attempt so the app does not report in for nobody, start it again.
1055function Stop-Update($Reason) {
1056 Write-Log "giving up: $Reason"
1057 Write-Result 'failed' $Reason $null
1058 foreach ($name in $Pending, 'started', 'booted', 'helper-started') {
1059 Remove-Item -LiteralPath (Join-Path $State $name) -Force -ErrorAction SilentlyContinue
1060 }
1061 Start-App
1062 exit 1
1063}
1065# Move every entry from $From to $To, retrying while antivirus holds a file;
1066# on a failure put back what moved. An entry that already exists at the
1067# destination is a failure, never nested into.
1068function Move-All($From, $To) {
1069 $moved = @()
1070 foreach ($entry in $Entries) {
1071 $done = $false
1072 $target = Join-Path $To $entry
1073 $source = Join-Path $From $entry
1074 # Retry only what antivirus can hold up: a missing source or a taken
1075 # destination is a failure at once.
1076 if ((Test-Path -LiteralPath $source) -and -not (Test-Path -LiteralPath $target)) {
1077 for ($try = 0; $try -lt 60 -and -not $done; $try++) {
1078 try {
1079 Move-Item -LiteralPath $source -Destination $target -ErrorAction Stop
1080 $done = $true
1081 } catch {
1082 Start-Sleep -Milliseconds 500
1083 }
1084 }
1085 }
1086 if (-not $done) {
1087 foreach ($back in $moved) {
1088 Move-Item -LiteralPath (Join-Path $To $back) -Destination (Join-Path $From $back) -ErrorAction SilentlyContinue
1089 }
1090 return $false
1091 }
1092 $moved += $entry
1093 }
1094 return $true
1095}
1097function Read-NewPid {
1098 $file = Join-Path $State 'started'
1099 if (Test-Path -LiteralPath $file) { return [int](Get-Content -LiteralPath $file -Raw).Trim() }
1100 return $null
1101}
1103$app = Get-Process -Id $AppPid -ErrorAction SilentlyContinue
1104if ($app -and -not $app.WaitForExit($QuitTimeoutMs)) {
1105 # The old version is still running, so nothing is relaunched; forget the
1106 # attempt so a retry isn't refused as one already under way.
1107 Write-Log 'app did not quit within the timeout'
1108 Write-Result 'failed' 'the app did not quit' $null
1109 foreach ($name in $Pending, 'started', 'booted', 'helper-started') {
1110 Remove-Item -LiteralPath (Join-Path $State $name) -Force -ErrorAction SilentlyContinue
1111 }
1112 Write-Log 'done (gave up)'
1113 exit 1
1114}
1115Write-Log 'app quit'
1117foreach ($name in 'old', 'failed') {
1118 $path = Join-Path $State $name
1119 try {
1120 if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop }
1121 [IO.Directory]::CreateDirectory($path) | Out-Null
1122 } catch {
1123 Stop-Update 'the update folder could not be prepared'
1124 }
1125}
1126foreach ($name in 'started', 'booted') {
1127 Remove-Item -LiteralPath (Join-Path $State $name) -Force -ErrorAction SilentlyContinue
1128}
1129if (-not (Move-All $Root (Join-Path $State 'old'))) {
1130 Stop-Update 'the old version could not be moved aside'
1131}
1132Write-Log 'moved old aside'
1133if (-not (Move-All $New $Root)) {
1134 if (Move-All (Join-Path $State 'old') $Root) {
1135 Stop-Update 'the new version could not be moved into place'
1136 }
1137 Stop-Update 'the new version could not be moved into place, and the old one could not be put back'
1138}
1139Write-Log 'moved new in'
1140Start-App
1142Write-Log "waiting for the new version to boot (up to $BootTimeout seconds)"
1143$ticks = 0
1144while (-not (Test-Path -LiteralPath (Join-Path $State 'booted'))) {
1145 if ($ticks -ge $BootTicks) {
1146 Write-Log 'boot timeout, rolling back'
1147 $newPid = Read-NewPid
1148 # A version that hung before writing `started` is still the process we launched.
1149 if (-not $newPid -and $script:Launched) { $newPid = $script:Launched.Id }
1150 if ($newPid) {
1151 Write-Log "stopping the new version (pid $newPid)"
1152 Stop-Process -Id $newPid -Force -ErrorAction SilentlyContinue
1153 Start-Sleep -Seconds 2
1154 }
1155 foreach ($name in $Pending, 'started', 'helper-started') {
1156 Remove-Item -LiteralPath (Join-Path $State $name) -Force -ErrorAction SilentlyContinue
1157 }
1158 $cleanup = $true
1159 if (Move-All $Root (Join-Path $State 'failed')) {
1160 if (Move-All (Join-Path $State 'old') $Root) {
1161 Write-Result 'rolled_back' "the new version did not start within $BootTimeout seconds" $null
1162 } else {
1163 # Never leave the install empty, and keep the new version's files.
1164 Move-All (Join-Path $State 'failed') $Root | Out-Null
1165 Write-Result 'failed' 'the new version did not start, and the old one could not be put back' $null
1166 $cleanup = $false
1167 }
1168 } else {
1169 Write-Result 'failed' 'the new version did not start, and could not be removed' $null
1170 $cleanup = $false
1171 }
1172 Start-App
1173 if ($cleanup) {
1174 foreach ($name in 'old', 'failed', 'staged', 'download') {
1175 Remove-Item -LiteralPath (Join-Path $State $name) -Recurse -Force -ErrorAction SilentlyContinue
1176 }
1177 }
1178 Write-Log "done (rollback finished, cleanup=$cleanup)"
1179 exit 1
1180 }
1181 Start-Sleep -Milliseconds 500
1182 $ticks++
1183}
1185Write-Log ('booted (pid ' + (Read-NewPid) + ')')
1186Write-Result 'updated' '' (Read-NewPid)
1187if ($env:OS -eq 'Windows_NT' -and (Test-Path -LiteralPath $UninstallKey)) {
1188 try {
1189 $location = (Get-ItemProperty -LiteralPath $UninstallKey).InstallLocation
1190 if ($location -and $location.TrimEnd('\') -eq $Root.TrimEnd('\')) {
1191 Set-ItemProperty -LiteralPath $UninstallKey -Name DisplayVersion -Value $Version
1192 }
1193 } catch { }
1194}
1195foreach ($name in 'old', 'failed', 'staged', 'download', $Pending, 'started', 'booted', 'helper-started') {
1196 Remove-Item -LiteralPath (Join-Path $State $name) -Recurse -Force -ErrorAction SilentlyContinue
1197}
1198Write-Log 'done'
1199"""
1201_PLAIN_VERSION = re.compile(r"[0-9A-Za-z.+!_-]+")
1204#: What PowerShell reads as a single quote: the ASCII one and four typographic
1205#: ones. Inside a single-quoted string each is escaped by doubling it.
1206_PS_SINGLE_QUOTES = re.compile("['\u2018\u2019\u201a\u201b]")
1207_PLACEHOLDER = re.compile(r"@@([A-Z_]+)@@")
1210def _ps_quote(text: str) -> str:
1211 return "'" + _PS_SINGLE_QUOTES.sub(lambda m: m.group(0) * 2, str(text)) + "'"
1214def helper_script(plan: SwapPlan) -> str:
1215 """The helper for ``plan``: PowerShell on Windows, POSIX ``sh`` elsewhere.
1217 Values are substituted quoted for their shell. The two versions also
1218 land inside the result JSON unescaped, so they must be plain version
1219 strings.
1220 """
1221 for version in (plan.version, plan.previous):
1222 if not _PLAIN_VERSION.fullmatch(version):
1223 raise ValueError(f"not a plain version: {version!r}")
1224 boot_ticks = math.ceil(plan.boot_timeout_s * 2)
1225 if plan.install.system == "win32":
1226 values = {
1227 "PID": str(plan.pid),
1228 "ROOT": _ps_quote(plan.install.root),
1229 "STATE": _ps_quote(plan.state),
1230 "NEW": _ps_quote(plan.staged),
1231 "ENTRIES": ", ".join(_ps_quote(entry) for entry in plan.install.payload),
1232 "QUIT_MS": str(math.ceil(plan.quit_timeout_s * 1000)),
1233 "BOOT_TICKS": str(boot_ticks),
1234 "BOOT_TIMEOUT": str(math.ceil(plan.boot_timeout_s)),
1235 "VERSION": _ps_quote(plan.version),
1236 "PREVIOUS": _ps_quote(plan.previous),
1237 "RELAUNCH": ", ".join(_ps_quote(arg) for arg in plan.relaunch),
1238 "UNINSTALL_KEY": _ps_quote(UNINSTALL_KEY),
1239 "PENDING": _ps_quote(PENDING),
1240 }
1241 template = _PS_HELPER
1242 else:
1243 values = {
1244 "PID": str(plan.pid),
1245 "ROOT": shlex.quote(str(plan.install.root)),
1246 "STATE": shlex.quote(str(plan.state)),
1247 "NEW": shlex.quote(str(plan.staged)),
1248 "ENTRIES": shlex.quote(" ".join(plan.install.payload)),
1249 "QUIT_TICKS": str(math.ceil(plan.quit_timeout_s * 2)),
1250 "BOOT_TICKS": str(boot_ticks),
1251 "BOOT_TIMEOUT": str(math.ceil(plan.boot_timeout_s)),
1252 "VERSION": shlex.quote(plan.version),
1253 "PREVIOUS": shlex.quote(plan.previous),
1254 "RELAUNCH": " ".join(shlex.quote(arg) for arg in plan.relaunch),
1255 # `open` exits at once, so its pid may be reused before a rollback.
1256 "TRACK_LAUNCH": "0" if _is_open(plan.relaunch) else "1",
1257 "PENDING": PENDING,
1258 }
1259 template = _SH_HELPER
1260 # One pass, so a value that itself reads `@@NAME@@` stays as it is.
1261 return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)
1264def _is_open(relaunch: tuple[str, ...]) -> bool:
1265 """Whether ``relaunch`` goes through macOS's ``open``."""
1266 return bool(relaunch) and os.path.basename(relaunch[0]) == "open"
1269def _powershell() -> str:
1270 """Windows PowerShell 5.1, by absolute path rather than whatever ``PATH`` finds."""
1271 return os.path.join(
1272 os.environ.get("SystemRoot", r"C:\Windows"),
1273 "System32",
1274 "WindowsPowerShell",
1275 "v1.0",
1276 "powershell.exe",
1277 )
1280def helper_command(script: Path, system: str) -> list[str]:
1281 """How to run the helper script on ``system``.
1283 On Windows the script travels as ``-EncodedCommand`` (its text in
1284 UTF-16-LE, base64): Group Policy can set an execution policy that
1285 refuses ``-File`` scripts, and ``-ExecutionPolicy Bypass`` does not
1286 override a policy set by Group Policy, whereas the policy does not apply
1287 to ``-EncodedCommand``. The ~5 KB script stays far under the 32,767
1288 character command-line limit.
1289 """
1290 if system == "win32":
1291 text = script.read_text(encoding="utf-8-sig")
1292 encoded = base64.b64encode(text.encode("utf-16-le")).decode("ascii")
1293 return [
1294 _powershell(),
1295 "-NoProfile",
1296 "-NonInteractive",
1297 "-WindowStyle",
1298 "Hidden",
1299 "-EncodedCommand",
1300 encoded,
1301 ]
1302 return ["/bin/sh", str(script)]
1305def start_swap(
1306 plan: SwapPlan,
1307 *,
1308 popen: Callable = subprocess.Popen,
1309 handshake_timeout_s: float = HANDSHAKE_TIMEOUT_S,
1310) -> None:
1311 """Record the attempt and start the helper, detached; the caller then exits.
1313 The helper's output goes to ``helper.log`` in the state folder, which
1314 stays after the attempt for a bug report. The script is written first and
1315 the attempt recorded after it, so nothing that can fail is left behind
1316 as an attempt "already under way". Returns only once the helper has
1317 written ``HELPER_STARTED``, its first act: a helper that antivirus
1318 stopped or that didn't parse would otherwise leave the app gone, nothing
1319 relaunched and the attempt blocking a retry. Then the helper is killed
1320 (if it is running at all) and the attempt forgotten, and the app stays.
1321 The wait is a cancel checkpoint too, ending the same way.
1323 Lets go of the plan's :data:`LOCK` either way: ``pending.json`` guards
1324 the attempt from here on.
1325 """
1326 try:
1327 _start_swap(plan, popen=popen, handshake_timeout_s=handshake_timeout_s)
1328 finally:
1329 if plan.lock is not None:
1330 plan.lock.release()
1333def _start_swap(plan: SwapPlan, *, popen: Callable, handshake_timeout_s: float) -> None:
1334 pending = plan.state / PENDING
1335 marker = plan.state / HELPER_STARTED
1336 windows = plan.install.system == "win32"
1337 script = plan.state / ("helper.ps1" if windows else "helper.sh")
1338 if windows:
1339 # Not DETACHED_PROCESS: Windows PowerShell 5.1 exits without running
1340 # its command when it starts with no console at all. CREATE_NO_WINDOW
1341 # gives it a hidden console of its own, which also keeps it alive
1342 # after the app quits.
1343 flags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(
1344 subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200
1345 )
1346 # Out of any job the app runs in, which may end its processes when the
1347 # app quits. A job that forbids that refuses the flag outright, and
1348 # the helper starts inside it. Such a job closing as the app quits
1349 # usually ends the helper before it begins (it waits for the app to
1350 # quit first), but could end it mid-swap; the next launch of whatever
1351 # is in place then finds no helper, and the attempt goes stale.
1352 breakaway = getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0x01000000)
1353 attempts = [{"creationflags": flags | breakaway}, {"creationflags": flags}]
1354 else:
1355 attempts = [{"start_new_session": True}]
1356 try:
1357 marker.unlink(missing_ok=True)
1358 # With a BOM: a human running helper.ps1 to diagnose it gets the same
1359 # reading in Windows PowerShell 5.1, which assumes the ANSI code page.
1360 script.write_text(
1361 helper_script(plan), encoding="utf-8-sig" if windows else "utf-8"
1362 )
1363 _write_json(
1364 pending,
1365 {
1366 "root": str(plan.install.root),
1367 "version": plan.version,
1368 "previous": plan.previous,
1369 "at": time.time(),
1370 },
1371 )
1372 command = helper_command(script, plan.install.system)
1373 with open(plan.state / "helper.log", "w", encoding="utf-8") as log:
1374 for attempt, detach in enumerate(attempts, start=1):
1375 try:
1376 helper = popen(
1377 command,
1378 stdin=subprocess.DEVNULL,
1379 stdout=log,
1380 stderr=subprocess.STDOUT,
1381 env=child_env(),
1382 **detach,
1383 )
1384 break
1385 except OSError:
1386 if attempt == len(attempts):
1387 raise
1388 except (OSError, ValueError) as error:
1389 pending.unlink(missing_ok=True)
1390 raise UpdateFailed(
1391 "The helper that swaps the versions couldn't be started."
1392 ) from error
1393 try:
1394 started = _helper_started(marker, helper, handshake_timeout_s)
1395 except progress.Cancelled:
1396 _abandon(helper, pending)
1397 raise
1398 if not started:
1399 _abandon(helper, pending)
1400 raise UpdateFailed(
1401 "The helper that swaps the versions didn't start, so nothing was changed."
1402 )
1405def _abandon(helper: object, pending: Path) -> None:
1406 """Stop a helper that never took over (it is still waiting for this
1407 process to quit), and forget the attempt so a retry isn't refused."""
1408 try:
1409 helper.kill()
1410 except Exception:
1411 pass
1412 pending.unlink(missing_ok=True)
1415def _helper_started(marker: Path, helper: object, timeout_s: float) -> bool:
1416 """Wait up to ``timeout_s`` for the helper's marker; stop early if it exited.
1418 Each wait is a cancel checkpoint for the progress task, if one is active.
1419 """
1420 deadline = time.monotonic() + timeout_s
1421 while True:
1422 if marker.exists():
1423 return True
1424 poll = getattr(helper, "poll", None)
1425 exited = poll is not None and poll() is not None
1426 if exited or time.monotonic() >= deadline:
1427 return marker.exists() # it may have written it on its way out
1428 progress.report()
1429 time.sleep(0.05)
1432def clear_attempt(state: Path) -> None:
1433 """Remove what an earlier attempt left, keeping only ``helper.log``."""
1434 for name in _ATTEMPT_FOLDERS:
1435 shutil.rmtree(state / name, ignore_errors=True)
1436 for name in _ATTEMPT_FILES:
1437 (state / name).unlink(missing_ok=True)
1440def _same_root(recorded: object, root: Path) -> bool:
1441 """Whether ``pending.json``'s root names ``root`` — through a symlink or
1442 a firmlink too, so a relaunch by another spelling still reports in."""
1443 if not isinstance(recorded, str) or not recorded:
1444 return False
1445 try:
1446 if os.path.exists(recorded) and os.path.exists(root):
1447 return os.path.samefile(recorded, root)
1448 except OSError:
1449 pass
1450 return os.path.normcase(os.path.realpath(recorded)) == os.path.normcase(
1451 os.path.realpath(root)
1452 )
1455def _ours(state: Path, install: Install) -> bool:
1456 pending = _pending(state)
1457 return pending is not None and _same_root(pending.get("root"), install.root)
1460def note_start(
1461 install: Install | None = None,
1462 *,
1463 state: Path | None = None,
1464 pid: int | None = None,
1465) -> None:
1466 """At launch: if a helper is waiting for this install, say which process we are.
1468 Never raises: a launch must not fail over an update's bookkeeping.
1469 """
1470 try:
1471 install = current_install() if install is None else install
1472 if install is None:
1473 return
1474 state = state_dir(install) if state is None else state
1475 if _ours(state, install):
1476 (state / STARTED).write_text(
1477 str(os.getpid() if pid is None else pid), encoding="utf-8"
1478 )
1479 except Exception:
1480 pass
1483def note_boot(install: Install | None = None, *, state: Path | None = None) -> None:
1484 """Once the server answers: confirm a pending update, or clear an abandoned one.
1486 Never raises. An attempt is abandoned when no helper is waiting on it
1487 and its files are older than ``STALE_AFTER_S``: a cancelled download, a
1488 failed self-test, or a helper that died.
1489 """
1490 try:
1491 install = current_install() if install is None else install
1492 if install is None:
1493 return
1494 state = state_dir(install) if state is None else state
1495 if _ours(state, install):
1496 (state / BOOTED).write_text("", encoding="utf-8")
1497 return
1498 cutoff = time.time() - STALE_AFTER_S
1499 for name in (*_ATTEMPT_FOLDERS, PENDING, STARTED, BOOTED, HELPER_STARTED):
1500 path = state / name
1501 if path.exists() and path.stat().st_mtime < cutoff:
1502 if path.is_dir():
1503 shutil.rmtree(path, ignore_errors=True)
1504 else:
1505 path.unlink(missing_ok=True)
1506 except Exception:
1507 pass
1510def last_result(
1511 install: Install | None = None, *, state: Path | None = None
1512) -> UpdateResult | None:
1513 """How the last update on this install ended — ``None`` if there is no record."""
1514 try:
1515 if state is None:
1516 install = current_install() if install is None else install
1517 if install is None:
1518 return None
1519 state = state_dir(install)
1520 data = _read_json(state / RESULT)
1521 if data is None:
1522 return None
1523 pid = data.get("pid")
1524 return UpdateResult(
1525 status=str(data["status"]),
1526 version=str(data["version"]),
1527 previous=str(data["previous"]),
1528 reason=str(data.get("reason") or ""),
1529 pid=int(pid) if pid not in (None, "") else None,
1530 at=(state / RESULT).stat().st_mtime,
1531 )
1532 except Exception:
1533 return None
1536def exit_soon(delay: float = RESTART_DELAY_S) -> None:
1537 """Quit this process ``delay`` seconds from now, the way the idle watcher does.
1539 The server runs in this process and owns the main thread, so
1540 ``os._exit`` from a timer is the only way out; the delay lets the
1541 "Restarting" message reach the browser first.
1542 """
1543 timer = threading.Timer(delay, os._exit, args=(0,))
1544 timer.daemon = True
1545 timer.start()
1548def _short_reason(error: BaseException, limit: int = 160) -> str:
1549 """One sentence for people: the OS's own words (and the file) for an
1550 ``OSError``, else ``str(error)``, on one line and ending in a stop."""
1551 reason = getattr(error, "strerror", None)
1552 filename = getattr(error, "filename", None)
1553 if reason and filename:
1554 reason = f"{reason} ({os.path.basename(str(filename)) or filename})"
1555 reason = " ".join(str(reason or error or type(error).__name__).split())
1556 if len(reason) > limit:
1557 reason = reason[: limit - 1] + "…"
1558 return reason if reason.endswith((".", "…", "!", "?")) else reason + "."
1561#: The steps the About card and ``--update`` name, in order.
1562STEPS = ("Downloading", "Checking it", "Testing the new version", "Restarting")
1565def prepare(
1566 check: UpdateCheck,
1567 install: Install | None,
1568 *,
1569 allow_file: bool = False,
1570 run: Callable = subprocess.run,
1571 opener: Callable | None = None,
1572 machine: str | None = None,
1573 on_step: Callable[[str], None] | None = None,
1574) -> SwapPlan:
1575 """Steps 1-4: refuse, download, stage and self-test — the swap is the caller's.
1577 Raises :class:`UpdateFailed` (the install untouched) or, from inside a
1578 progress task that was cancelled, ``progress.Cancelled``. Anything else
1579 that goes wrong on the way — a folder that can't be written, a file that
1580 can't be read — is an :class:`UpdateFailed` too, never a raw exception.
1582 Makes the state folder and takes its :data:`LOCK` before touching
1583 anything in it; the returned plan holds the lock for :func:`start_swap`.
1584 """
1585 reason = refusal(check, install, run=run, machine=machine)
1586 if reason is not None:
1587 raise UpdateFailed(reason)
1589 def step(index: int) -> None:
1590 progress.step_to(index)
1591 if on_step is not None:
1592 on_step(STEPS[index])
1594 lock = None
1595 try:
1596 try:
1597 state = state_dir(install)
1598 _make_state(state)
1599 lock = _lock(state)
1600 # Another attempt may have recorded itself and let go of the lock
1601 # between refusal() and here.
1602 if _pending(state) is not None:
1603 raise UpdateFailed("An update is already under way.")
1604 asset = asset_for(check, install, machine=machine)
1605 clear_attempt(state)
1606 step(0)
1607 archive = download(
1608 asset, state / "download", allow_file=allow_file, opener=opener
1609 )
1610 step(1)
1611 staged = stage(archive, state, install, run=run)
1612 step(2)
1613 self_test(staged, install.system, run=run)
1614 except (OSError, ValueError) as error: # UnicodeDecodeError is a ValueError
1615 raise UpdateFailed(
1616 f"Preparing the update failed: {_short_reason(error)}"
1617 ) from error
1618 step(3)
1619 plan = SwapPlan(
1620 pid=os.getpid(),
1621 install=install,
1622 staged=staged,
1623 state=state,
1624 version=check.latest.version,
1625 previous=check.current,
1626 relaunch=relaunch_command(install),
1627 lock=lock,
1628 )
1629 except BaseException:
1630 if lock is not None:
1631 lock.release()
1632 raise
1633 return plan