|
| 1 | +"""Launch platform install scripts to upgrade a packaged DBS Annotator build.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import logging |
| 6 | +import os |
| 7 | +import ssl |
| 8 | +import subprocess |
| 9 | +import sys |
| 10 | +import tempfile |
| 11 | +import urllib.error |
| 12 | +import urllib.request |
| 13 | +from pathlib import Path |
| 14 | + |
| 15 | +import certifi |
| 16 | + |
| 17 | +from ..config import RELEASES_GITHUB_REPO |
| 18 | + |
| 19 | +logger = logging.getLogger(__name__) |
| 20 | + |
| 21 | +_INSTALL_SCRIPT_BRANCH = "main" |
| 22 | +_USER_AGENT = "DBSAnnotator-AutoUpdate/1.0" |
| 23 | +# Windows-only; absent on Linux/macOS (CI runs unit tests there too). |
| 24 | +_WINDOWS_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) |
| 25 | + |
| 26 | + |
| 27 | +def _install_script_url(filename: str) -> str: |
| 28 | + return ( |
| 29 | + f"https://raw.githubusercontent.com/{RELEASES_GITHUB_REPO}/" |
| 30 | + f"{_INSTALL_SCRIPT_BRANCH}/scripts/{filename}" |
| 31 | + ) |
| 32 | + |
| 33 | + |
| 34 | +def _download_install_script(filename: str) -> Path: |
| 35 | + url = _install_script_url(filename) |
| 36 | + request = urllib.request.Request( |
| 37 | + url, |
| 38 | + headers={"User-Agent": _USER_AGENT}, |
| 39 | + ) |
| 40 | + ctx = ssl.create_default_context(cafile=certifi.where()) |
| 41 | + with urllib.request.urlopen(request, timeout=60, context=ctx) as response: |
| 42 | + data = response.read() |
| 43 | + suffix = ".ps1" if filename.endswith(".ps1") else ".sh" |
| 44 | + fd, path_str = tempfile.mkstemp(prefix="dbs_annotator_install_", suffix=suffix) |
| 45 | + os.close(fd) |
| 46 | + path = Path(path_str) |
| 47 | + path.write_bytes(data) |
| 48 | + if suffix == ".sh": |
| 49 | + path.chmod(0o755) |
| 50 | + return path |
| 51 | + |
| 52 | + |
| 53 | +def automatic_update_supported() -> bool: |
| 54 | + """True on platforms where ``scripts/install.*`` can be launched.""" |
| 55 | + return ( |
| 56 | + sys.platform == "win32" |
| 57 | + or sys.platform == "darwin" |
| 58 | + or sys.platform.startswith("linux") |
| 59 | + ) |
| 60 | + |
| 61 | + |
| 62 | +def automatic_update_targets_packaged_install() -> bool: |
| 63 | + """True when the updater installs into the standard Briefcase location.""" |
| 64 | + return bool(getattr(sys, "frozen", False)) |
| 65 | + |
| 66 | + |
| 67 | +def launch_automatic_update( |
| 68 | + tag_name: str, |
| 69 | + *, |
| 70 | + dry_run: bool = False, |
| 71 | +) -> tuple[bool, str]: |
| 72 | + """Start the GitHub release installer for *tag_name* (e.g. ``v0.4.0b2``). |
| 73 | +
|
| 74 | + Args: |
| 75 | + dry_run: If True, run the platform install script in preview mode only |
| 76 | + (PowerShell ``-WhatIf`` / ``install.sh --dry-run``). No files are |
| 77 | + written. |
| 78 | +
|
| 79 | + Returns: |
| 80 | + ``(True, user_message)`` on success, ``(False, error_message)`` otherwise. |
| 81 | + The installer runs in a separate process; the user must restart the app. |
| 82 | + """ |
| 83 | + tag = tag_name.strip() |
| 84 | + if not tag: |
| 85 | + return False, "Release tag is missing." |
| 86 | + |
| 87 | + try: |
| 88 | + if sys.platform == "win32": |
| 89 | + return _launch_windows(tag, dry_run=dry_run) |
| 90 | + if sys.platform == "darwin": |
| 91 | + return _launch_unix(tag, "install.sh", dry_run=dry_run) |
| 92 | + if sys.platform.startswith("linux"): |
| 93 | + return _launch_unix(tag, "install.sh", dry_run=dry_run) |
| 94 | + return False, f"Automatic update is not supported on {sys.platform!r}." |
| 95 | + except OSError as exc: |
| 96 | + logger.info("Automatic update failed: %s", exc) |
| 97 | + return False, str(exc) |
| 98 | + except urllib.error.URLError as exc: |
| 99 | + logger.info("Could not download install script: %s", exc) |
| 100 | + return False, f"Could not download the installer script:\n\n{exc}" |
| 101 | + |
| 102 | + |
| 103 | +def _launch_windows(tag: str, *, dry_run: bool = False) -> tuple[bool, str]: |
| 104 | + script = _download_install_script("install.ps1") |
| 105 | + try: |
| 106 | + cmd = [ |
| 107 | + "powershell.exe", |
| 108 | + "-NoProfile", |
| 109 | + "-ExecutionPolicy", |
| 110 | + "Bypass", |
| 111 | + "-File", |
| 112 | + str(script), |
| 113 | + "-VersionTag", |
| 114 | + tag, |
| 115 | + "-GitHubRepository", |
| 116 | + RELEASES_GITHUB_REPO, |
| 117 | + ] |
| 118 | + if dry_run: |
| 119 | + cmd.append("-WhatIf") |
| 120 | + subprocess.Popen( |
| 121 | + cmd, |
| 122 | + creationflags=_WINDOWS_NEW_CONSOLE, |
| 123 | + close_fds=True, |
| 124 | + ) |
| 125 | + finally: |
| 126 | + if not dry_run: |
| 127 | + try: |
| 128 | + script.unlink(missing_ok=True) |
| 129 | + except OSError: |
| 130 | + pass |
| 131 | + |
| 132 | + if dry_run: |
| 133 | + return True, ( |
| 134 | + "Dry run: a PowerShell window will show what the installer would do " |
| 135 | + "(no files are changed). Check that window for errors before running " |
| 136 | + "a real update." |
| 137 | + ) |
| 138 | + return True, ( |
| 139 | + "The updater is running in a new window. When it finishes, close this " |
| 140 | + "application and open DBS Annotator again from the Start menu." |
| 141 | + ) |
| 142 | + |
| 143 | + |
| 144 | +def _launch_unix(tag: str, filename: str, *, dry_run: bool = False) -> tuple[bool, str]: |
| 145 | + script = _download_install_script(filename) |
| 146 | + args = "--dry-run" if dry_run else "" |
| 147 | + wrapper = script.with_name(f"run_{script.name}") |
| 148 | + wrapper.write_text( |
| 149 | + "#!/bin/sh\n" |
| 150 | + f'export DBS_ANNOTATOR_INSTALL_REPO="{RELEASES_GITHUB_REPO}"\n' |
| 151 | + f'export DBS_ANNOTATOR_VERSION="{tag}"\n' |
| 152 | + f'exec "{script}" {args} "{tag}"\n', |
| 153 | + encoding="utf-8", |
| 154 | + ) |
| 155 | + wrapper.chmod(0o755) |
| 156 | + |
| 157 | + if sys.platform == "darwin": |
| 158 | + cmd = [ |
| 159 | + "osascript", |
| 160 | + "-e", |
| 161 | + f'tell application "Terminal" to do script "{wrapper}"', |
| 162 | + ] |
| 163 | + subprocess.Popen(cmd, start_new_session=True, close_fds=True) |
| 164 | + else: |
| 165 | + launched = False |
| 166 | + for term_cmd in ( |
| 167 | + ["x-terminal-emulator", "-e", str(wrapper)], |
| 168 | + ["konsole", "-e", str(wrapper)], |
| 169 | + ["gnome-terminal", "--", str(wrapper)], |
| 170 | + ): |
| 171 | + if _which(term_cmd[0]): |
| 172 | + subprocess.Popen( |
| 173 | + term_cmd, |
| 174 | + start_new_session=True, |
| 175 | + close_fds=True, |
| 176 | + ) |
| 177 | + launched = True |
| 178 | + break |
| 179 | + if not launched: |
| 180 | + env = os.environ.copy() |
| 181 | + env["DBS_ANNOTATOR_INSTALL_REPO"] = RELEASES_GITHUB_REPO |
| 182 | + env["DBS_ANNOTATOR_VERSION"] = tag |
| 183 | + cmd = [str(script)] |
| 184 | + if dry_run: |
| 185 | + cmd.append("--dry-run") |
| 186 | + cmd.append(tag) |
| 187 | + subprocess.Popen( |
| 188 | + cmd, |
| 189 | + env=env, |
| 190 | + start_new_session=True, |
| 191 | + close_fds=True, |
| 192 | + ) |
| 193 | + |
| 194 | + if dry_run: |
| 195 | + return True, ( |
| 196 | + "Dry run: a terminal window will show what the installer would do " |
| 197 | + "(no files are changed). Check that window for errors before running " |
| 198 | + "a real update." |
| 199 | + ) |
| 200 | + return True, ( |
| 201 | + "The updater is running. When it finishes, quit this application and " |
| 202 | + "reopen DBS Annotator from your applications menu." |
| 203 | + ) |
| 204 | + |
| 205 | + |
| 206 | +def _which(name: str) -> str | None: |
| 207 | + from shutil import which |
| 208 | + |
| 209 | + return which(name) |
0 commit comments