"""Launch platform install scripts to upgrade a packaged DBS Annotator build."""
from __future__ import annotations
import logging
import os
import ssl
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
import certifi
from ..config import RELEASES_GITHUB_REPO
logger = logging.getLogger(__name__)
_INSTALL_SCRIPT_BRANCH = "main"
_USER_AGENT = "DBSAnnotator-AutoUpdate/1.0"
# Windows-only; absent on Linux/macOS (CI runs unit tests there too).
_WINDOWS_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
def _install_script_url(filename: str) -> str:
return (
f"https://raw.githubusercontent.com/{RELEASES_GITHUB_REPO}/"
f"{_INSTALL_SCRIPT_BRANCH}/scripts/{filename}"
)
def _install_script_dir() -> Path:
"""Stable temp dir so the install script is not deleted before PowerShell starts."""
directory = Path(tempfile.gettempdir()) / "dbs_annotator_update"
directory.mkdir(parents=True, exist_ok=True)
return directory
def _download_install_script(filename: str) -> Path:
url = _install_script_url(filename)
request = urllib.request.Request(
url,
headers={"User-Agent": _USER_AGENT},
)
ctx = ssl.create_default_context(cafile=certifi.where())
with urllib.request.urlopen(request, timeout=60, context=ctx) as response:
data = response.read()
path = _install_script_dir() / filename
path.write_bytes(data)
if filename.endswith(".sh"):
path.chmod(0o755)
return path
def _escape_ps_single_quoted(value: str) -> str:
return value.replace("'", "''")
def _windows_powershell_command(
script: Path, tag: str, *, dry_run: bool = False
) -> list[str]:
"""Build a PowerShell invocation that keeps the console open on install failure."""
whatif = " -WhatIf" if dry_run else ""
ps = (
"$ErrorActionPreference = 'Stop'; "
f"try {{ & '{_escape_ps_single_quoted(script.as_posix())}' "
f"-VersionTag '{_escape_ps_single_quoted(tag)}' "
f"-GitHubRepository '{_escape_ps_single_quoted(RELEASES_GITHUB_REPO)}'{whatif} "
"} catch { "
"Write-Host $_.Exception.Message -ForegroundColor Red; "
"exit 1 "
"}; "
"if ($LASTEXITCODE -ne 0) { "
"Write-Host ''; "
"Write-Host 'Install did not finish (exit' $LASTEXITCODE ').'; "
"Read-Host 'Press Enter to close' "
"}"
)
return [
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
ps,
]
[docs]
def automatic_update_supported() -> bool:
"""True on platforms where ``scripts/install.*`` can be launched."""
return (
sys.platform == "win32"
or sys.platform == "darwin"
or sys.platform.startswith("linux")
)
[docs]
def automatic_update_targets_packaged_install() -> bool:
"""True when the updater installs into the standard Briefcase location."""
return bool(getattr(sys, "frozen", False))
[docs]
def launch_automatic_update(
tag_name: str,
*,
dry_run: bool = False,
) -> tuple[bool, str]:
"""Start the GitHub release installer for *tag_name* (e.g. ``v0.4.0b2``).
Args:
dry_run: If True, run the platform install script in preview mode only
(PowerShell ``-WhatIf`` / ``install.sh --dry-run``). No files are
written.
Returns:
``(True, user_message)`` on success, ``(False, error_message)`` otherwise.
The installer runs in a separate process; the user must restart the app.
"""
tag = tag_name.strip()
if not tag:
return False, "Release tag is missing."
try:
if sys.platform == "win32":
return _launch_windows(tag, dry_run=dry_run)
if sys.platform == "darwin":
return _launch_unix(tag, "install.sh", dry_run=dry_run)
if sys.platform.startswith("linux"):
return _launch_unix(tag, "install.sh", dry_run=dry_run)
return False, f"Automatic update is not supported on {sys.platform!r}."
except OSError as exc:
logger.info("Automatic update failed: %s", exc)
return False, str(exc)
except urllib.error.URLError as exc:
logger.info("Could not download install script: %s", exc)
return False, f"Could not download the installer script:\n\n{exc}"
def _launch_windows(tag: str, *, dry_run: bool = False) -> tuple[bool, str]:
script = _download_install_script("install.ps1")
cmd = _windows_powershell_command(script, tag, dry_run=dry_run)
subprocess.Popen(
cmd,
creationflags=_WINDOWS_NEW_CONSOLE,
close_fds=True,
)
if dry_run:
return True, (
"Dry run: a PowerShell window will show what the installer would do "
"(no files are changed). Check that window for errors before running "
"a real update."
)
return True, (
"The updater is running in a new window. When it finishes, close this "
"application and open DBS Annotator again from the Start menu."
)
def _launch_unix(tag: str, filename: str, *, dry_run: bool = False) -> tuple[bool, str]:
script = _download_install_script(filename)
args = "--dry-run" if dry_run else ""
wrapper = script.with_name(f"run_{script.name}")
wrapper.write_text(
"#!/bin/sh\n"
f'export DBS_ANNOTATOR_INSTALL_REPO="{RELEASES_GITHUB_REPO}"\n'
f'export DBS_ANNOTATOR_VERSION="{tag}"\n'
f'exec "{script}" {args} "{tag}"\n',
encoding="utf-8",
)
wrapper.chmod(0o755)
if sys.platform == "darwin":
cmd = [
"osascript",
"-e",
f'tell application "Terminal" to do script "{wrapper}"',
]
subprocess.Popen(cmd, start_new_session=True, close_fds=True)
else:
launched = False
for term_cmd in (
["x-terminal-emulator", "-e", str(wrapper)],
["konsole", "-e", str(wrapper)],
["gnome-terminal", "--", str(wrapper)],
):
if _which(term_cmd[0]):
subprocess.Popen(
term_cmd,
start_new_session=True,
close_fds=True,
)
launched = True
break
if not launched:
env = os.environ.copy()
env["DBS_ANNOTATOR_INSTALL_REPO"] = RELEASES_GITHUB_REPO
env["DBS_ANNOTATOR_VERSION"] = tag
cmd = [str(script)]
if dry_run:
cmd.append("--dry-run")
cmd.append(tag)
subprocess.Popen(
cmd,
env=env,
start_new_session=True,
close_fds=True,
)
if dry_run:
return True, (
"Dry run: a terminal window will show what the installer would do "
"(no files are changed). Check that window for errors before running "
"a real update."
)
return True, (
"The updater is running. When it finishes, quit this application and "
"reopen DBS Annotator from your applications menu."
)
def _which(name: str) -> str | None:
from shutil import which
return which(name)