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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion tests/test_update_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QSizePolicy

from utilities.updater import ReleaseAsset, UpdateInfo
from views.update_dialog import UpdateDialog
Expand Down Expand Up @@ -50,6 +50,11 @@ def test_shows_versions_and_buttons(self) -> None:
self.assertFalse(dialog.progress_bar.isVisible())
self.assertTrue(dialog._buttons_spacer.isVisibleTo(dialog))
self.assertTrue(dialog.progress_bar.isTextVisible())
self.assertEqual(
dialog.progress_bar.sizePolicy().horizontalPolicy(),
QSizePolicy.Policy.Expanding,
)
self.assertIn("QProgressBar::chunk", dialog.progress_bar.styleSheet())
self.assertTrue(dialog.body_view.openExternalLinks())

dialog._set_progress_visible(True)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_update_install.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -32,6 +33,42 @@ def test_silent_args_use_silent_not_verysilent(self):
self.assertIn("/SILENT", joined)
self.assertNotIn("/VERYSILENT", joined)

def test_watcher_waits_for_setup_then_relaunch(self):
pending = Path("C:/cache/pending.json")
script = ui.build_relaunch_watcher_ps1(pending, "ABVME-Windows-x64-Setup")
self.assertIn("Wait-Process -Name $SetupName", script)
self.assertIn("Start-Process -FilePath $Meta.target_exe_path", script)
self.assertIn("Test-Path -LiteralPath $Meta.target_exe_path", script)

def test_launch_uses_shell_execute_for_watcher_and_setup(self):
calls: list[tuple[str, str, int]] = []

def fake_shell(
file: str, params: str, work_dir: str, *, show_cmd: int = 1
) -> None:
calls.append((file, params, show_cmd))

with tempfile.TemporaryDirectory() as tmp:
setup = Path(tmp) / "ABVME-Windows-x64-Setup.exe"
setup.write_bytes(b"mz")
target = Path(tmp) / "ABVME.exe"
target.write_bytes(b"mz")
with (
patch.object(ui, "update_cache_dir", return_value=Path(tmp)),
patch.object(ui, "_shell_execute", side_effect=fake_shell),
patch.object(ui, "strip_motw"),
):
ui.launch_setup_and_prepare_relaunch(setup, target, [])

self.assertEqual(len(calls), 2)
self.assertEqual(calls[0][0].lower(), "powershell.exe")
self.assertIn("-WindowStyle Hidden", calls[0][1])
self.assertIn("-File ", calls[0][1])
self.assertEqual(calls[0][2], 0)
self.assertEqual(Path(calls[1][0]), setup)
self.assertIn("/SILENT", calls[1][1])
self.assertEqual(calls[1][2], 1)


if __name__ == "__main__":
unittest.main()
52 changes: 24 additions & 28 deletions utilities/update_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def inno_silent_args(log_path: Path) -> list[str]:
"/CLOSEAPPLICATIONS",
"/FORCECLOSEAPPLICATIONS",
"/NORESTARTAPPLICATIONS",
f'/LOG={log_path}',
f"/LOG={log_path}",
]


Expand All @@ -86,17 +86,28 @@ def _ps_single_quoted(value: str) -> str:


def build_relaunch_watcher_ps1(pending_json: Path, setup_process_name: str) -> str:
"""Wait for Setup to finish, then relaunch the installed exe.

Must be started via ShellExecute (not a child of ABVME) so it survives when
the app hard-exits for a clean install.
"""
pending_literal = _ps_single_quoted(str(pending_json))
process_literal = _ps_single_quoted(setup_process_name)
lines = [
"$ErrorActionPreference = 'Stop'",
f"$Meta = Get-Content -LiteralPath {pending_literal} -Raw | ConvertFrom-Json",
f"$SetupName = {process_literal}",
"for ($i = 0; $i -lt 60; $i++) {",
"for ($i = 0; $i -lt 120; $i++) {",
" if (Get-Process -Name $SetupName -ErrorAction SilentlyContinue) { break }",
" Start-Sleep -Milliseconds 500",
"}",
"Wait-Process -Name $SetupName -ErrorAction SilentlyContinue",
# Give Inno a moment to finish copying / releasing ABVME.exe.
"Start-Sleep -Seconds 1",
"for ($i = 0; $i -lt 30; $i++) {",
" if (Test-Path -LiteralPath $Meta.target_exe_path) { break }",
" Start-Sleep -Milliseconds 500",
"}",
"if ($Meta.relaunch_args -and @($Meta.relaunch_args).Count -gt 0) {",
" Start-Process -FilePath $Meta.target_exe_path -ArgumentList @($Meta.relaunch_args)",
"} else {",
Expand All @@ -107,7 +118,8 @@ def build_relaunch_watcher_ps1(pending_json: Path, setup_process_name: str) -> s
return "\r\n".join(lines) + "\r\n"


def _shell_execute(file: str, params: str, work_dir: str) -> None:
def _shell_execute(file: str, params: str, work_dir: str, *, show_cmd: int = 1) -> None:
"""Launch an independent process via ShellExecuteW (survives ABVME exit)."""
if os.name != "nt":
subprocess.Popen(
[file, *params.split()],
Expand All @@ -124,7 +136,7 @@ def _shell_execute(file: str, params: str, work_dir: str) -> None:
file,
params,
work_dir,
1, # SW_SHOWNORMAL — /SILENT still shows progress
show_cmd,
)
if result <= 32:
raise OSError(f"ShellExecuteW failed with code {result}")
Expand Down Expand Up @@ -157,29 +169,13 @@ def launch_setup_and_prepare_relaunch(

strip_motw(setup_path)
args = inno_silent_args(log_path)
params = subprocess.list2cmdline(args) if os.name == "nt" else " ".join(args)

if os.name == "nt":
subprocess.Popen(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(watcher_ps1),
],
cwd=str(target_exe.parent),
close_fds=True,
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen(
["powershell", "-NoProfile", "-File", str(watcher_ps1)],
cwd=str(target_exe.parent),
close_fds=True,
start_new_session=True,
)
setup_params = subprocess.list2cmdline(args) if os.name == "nt" else " ".join(args)

_shell_execute(str(setup_path), params, str(setup_path.parent))
# Independent of ABVME's process tree — Popen children can die with the app.
watcher_params = (
f'-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass '
f'-File "{watcher_ps1}"'
)
_shell_execute("powershell.exe", watcher_params, str(cache), show_cmd=0)
_shell_execute(str(setup_path), setup_params, str(setup_path.parent), show_cmd=1)
logger.info("Launched setup %s with args %s", setup_path, args)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 37 additions & 8 deletions views/update_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

from __future__ import annotations

import os
import sys
import threading

from PySide6.QtCore import QUrl, Qt
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
Expand Down Expand Up @@ -83,6 +86,27 @@ def __init__(
)
self.progress_bar = QProgressBar()
self.progress_bar.setTextVisible(True)
self.progress_bar.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
self.progress_bar.setMinimumWidth(120)
self.progress_bar.setMinimumHeight(22)
# Global QWidget background matches the dialog, which hides the empty
# track — only the filled chunk would look like a tiny square.
self.progress_bar.setStyleSheet(
"QProgressBar {"
" background-color: #2d2d30;"
" border: 1px solid #555555;"
" border-radius: 4px;"
" text-align: center;"
" color: #cccccc;"
"}"
"QProgressBar::chunk {"
" background-color: #007acc;"
" border-radius: 3px;"
"}"
)
self.progress_bar.setVisible(False)
self.close_button = QPushButton("Close")
self.close_button.setDefault(True)
Expand Down Expand Up @@ -121,24 +145,29 @@ def _progress(done: int, total: int | None) -> None:
if total and total > 0:
self.progress_bar.setRange(0, total)
self.progress_bar.setValue(done)
self.progress_bar.setFormat(f"Downloading… {done} bytes")
# Keep label short so it fits on the stretched bar.
self.progress_bar.setFormat("Downloading… %p%")
else:
self.progress_bar.setFormat(f"Downloading… {done:,} bytes")

download_file(asset.browser_download_url, dest, progress_cb=_progress)
target = install_dir() / "ABVME.exe"
if not target.exists():
target = get_running_executable_path()
self.progress_bar.setFormat("Installer started. The app will close.")
self.progress_bar.setFormat("Installer started. Closing…")
QApplication.processEvents()
launch_setup_and_prepare_relaunch(
dest,
target,
list(sys.argv[1:]),
)
parent = self.parentWidget()
self.accept()
if parent is not None:
parent.window().close()
else:
self.close()
# Hide immediately so Setup is not stacked on a live ABVME window,
# then hard-exit so Inno can replace files without waiting on us.
# Relaunch is handled by the detached PowerShell watcher.
for widget in QApplication.topLevelWidgets():
widget.hide()
QApplication.processEvents()
threading.Timer(0.25, lambda: os._exit(0)).start()
except Exception as exc:
self._set_progress_visible(False)
self.update_now_button.setEnabled(True)
Expand Down
Loading