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

1"""One-click *Update & restart* for the desktop app (#385). 

2 

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``: 

7 

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`). 

17 

18Everything before step 5 leaves the install untouched. Stdlib and 

19``packaging`` only, no Streamlit: the launcher calls it with no server running. 

20""" 

21 

22from __future__ import annotations 

23 

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 

47 

48from packaging.version import InvalidVersion, Version 

49 

50from . import progress, updates 

51from .updates import Asset, Release, UpdateCheck, UpdateCheckError 

52 

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 

71 

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" 

87 

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" 

94 

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} 

104 

105 

106class UpdateFailed(Exception): 

107 """An update that could not go ahead; ``str()`` says why, for people. 

108 

109 Raised only before the swap begins, so the install is always untouched. 

110 """ 

111 

112 

113@dataclass(frozen=True) 

114class Install: 

115 """Where the running desktop app lives, and what an update replaces. 

116 

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 """ 

123 

124 root: Path 

125 payload: tuple[str, ...] 

126 executable: Path 

127 system: str 

128 

129 

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 

133 

134 

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)) 

139 

140 

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) 

146 

147 

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 ) 

168 

169 

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))) 

175 

176 

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" 

192 

193 

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 

202 

203 

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. 

206 

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" 

216 

217 

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) 

226 

227 

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 

234 

235 

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) 

241 

242 

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 "") 

267 

268 

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) 

292 

293 

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 

307 

308 

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 

315 

316 

317def _can_write(folder: Path, system: str) -> bool: 

318 """Whether this account can create (and so move) entries in ``folder``. 

319 

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 

333 

334 

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() 

349 

350 

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." 

353 

354 

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. 

364 

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 

425 

426 

427def child_env(base: Mapping[str, str] | None = None) -> dict[str, str]: 

428 """The environment for another bundle this one starts. 

429 

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 

445 

446 

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) 

454 

455 

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 

460 

461 

462def _allowed(url: str, *, allow_file: bool) -> bool: 

463 return url.startswith(REPO_DOWNLOADS) or (allow_file and url.startswith("file:")) 

464 

465 

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. 

475 

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 

492 

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 ) 

500 

501 def unsaved(error: OSError) -> UpdateFailed: 

502 return UpdateFailed(f"The download couldn't be saved: {_short_reason(error)}") 

503 

504 def stopped() -> UpdateFailed: 

505 return UpdateFailed("The download stopped before it finished; are you offline?") 

506 

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 

562 

563 

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 

607 

608 

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 ) 

629 

630 

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 

657 

658 

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 ) 

672 

673 

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) 

703 

704 

705@dataclass(frozen=True) 

706class SwapPlan: 

707 """Everything the helper needs: whom to wait for, what to swap, how to relaunch.""" 

708 

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) 

721 

722 

723@dataclass(frozen=True) 

724class UpdateResult: 

725 """How the last update ended: ``updated``, ``rolled_back`` or ``failed``.""" 

726 

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 

734 

735 

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() 

741 

742 

743class _Lock: 

744 """An attempt's hold on :data:`LOCK`; :meth:`release` is idempotent.""" 

745 

746 def __init__(self, handle, key: str) -> None: 

747 self._handle = handle 

748 self._key = key 

749 

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 

761 

762 handle.seek(0) 

763 msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) 

764 except OSError: 

765 pass 

766 finally: 

767 handle.close() 

768 

769 def __del__(self) -> None: 

770 self.release() 

771 

772 

773def _lock(state: Path) -> _Lock: 

774 """Take :data:`LOCK` in ``state``, or refuse: another attempt holds it. 

775 

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 

793 

794 handle.seek(0) 

795 msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) 

796 else: 

797 import fcntl 

798 

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) 

811 

812 

813def _make_state(state: Path) -> None: 

814 """Create the state folder, private to this account (0o700 on POSIX). 

815 

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) 

827 

828 

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)) 

843 

844 

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="" 

861 

862# One timestamped line per stage, on stdout (helper.log), to tell where it stopped. 

863log() { 

864 echo "$(date '+%H:%M:%S') $*" 

865} 

866 

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)" 

870 

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} 

877 

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} 

888 

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} 

898 

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} 

912 

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" 

928 

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 

947 

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 

988 

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""" 

996 

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' 

1017 

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} 

1026 

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)" 

1030 

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} 

1038 

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} 

1052 

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} 

1064 

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} 

1096 

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} 

1102 

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' 

1116 

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 

1141 

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} 

1184 

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""" 

1200 

1201_PLAIN_VERSION = re.compile(r"[0-9A-Za-z.+!_-]+") 

1202 

1203 

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_]+)@@") 

1208 

1209 

1210def _ps_quote(text: str) -> str: 

1211 return "'" + _PS_SINGLE_QUOTES.sub(lambda m: m.group(0) * 2, str(text)) + "'" 

1212 

1213 

1214def helper_script(plan: SwapPlan) -> str: 

1215 """The helper for ``plan``: PowerShell on Windows, POSIX ``sh`` elsewhere. 

1216 

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) 

1262 

1263 

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" 

1267 

1268 

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 ) 

1278 

1279 

1280def helper_command(script: Path, system: str) -> list[str]: 

1281 """How to run the helper script on ``system``. 

1282 

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)] 

1303 

1304 

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. 

1312 

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. 

1322 

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() 

1331 

1332 

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 ) 

1403 

1404 

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) 

1413 

1414 

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. 

1417 

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) 

1430 

1431 

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) 

1438 

1439 

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 ) 

1453 

1454 

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) 

1458 

1459 

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. 

1467 

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 

1481 

1482 

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. 

1485 

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 

1508 

1509 

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 

1534 

1535 

1536def exit_soon(delay: float = RESTART_DELAY_S) -> None: 

1537 """Quit this process ``delay`` seconds from now, the way the idle watcher does. 

1538 

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() 

1546 

1547 

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 + "." 

1559 

1560 

1561#: The steps the About card and ``--update`` name, in order. 

1562STEPS = ("Downloading", "Checking it", "Testing the new version", "Restarting") 

1563 

1564 

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. 

1576 

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. 

1581 

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) 

1588 

1589 def step(index: int) -> None: 

1590 progress.step_to(index) 

1591 if on_step is not None: 

1592 on_step(STEPS[index]) 

1593 

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