From 6049179c71ac0822e610d01662233412f0e85ffa Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:45:30 +0700 Subject: [PATCH] fix: make update progress bar visible and relaunch after silent install Style the dialog progress track so it is not lost in the dark theme, start the relaunch watcher via ShellExecute, and hard-exit after launching Setup so the UI does not linger under the installer. --- tests/test_update_dialog.py | 7 ++++- tests/test_update_install.py | 37 +++++++++++++++++++++++++ utilities/update_install.py | 52 +++++++++++++++++------------------- uv.lock | 2 +- views/update_dialog.py | 45 +++++++++++++++++++++++++------ 5 files changed, 105 insertions(+), 38 deletions(-) diff --git a/tests/test_update_dialog.py b/tests/test_update_dialog.py index 9a379d1..3d83607 100644 --- a/tests/test_update_dialog.py +++ b/tests/test_update_dialog.py @@ -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 @@ -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) diff --git a/tests/test_update_install.py b/tests/test_update_install.py index 932345b..46052e1 100644 --- a/tests/test_update_install.py +++ b/tests/test_update_install.py @@ -1,3 +1,4 @@ +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -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() diff --git a/utilities/update_install.py b/utilities/update_install.py index aca1c5c..518cde0 100644 --- a/utilities/update_install.py +++ b/utilities/update_install.py @@ -67,7 +67,7 @@ def inno_silent_args(log_path: Path) -> list[str]: "/CLOSEAPPLICATIONS", "/FORCECLOSEAPPLICATIONS", "/NORESTARTAPPLICATIONS", - f'/LOG={log_path}', + f"/LOG={log_path}", ] @@ -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 {", @@ -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()], @@ -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}") @@ -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) diff --git a/uv.lock b/uv.lock index 9d51b68..f50b04d 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "abvme" -version = "0.1.9" +version = "0.2.0" source = { virtual = "." } dependencies = [ { name = "pyside6" }, diff --git a/views/update_dialog.py b/views/update_dialog.py index dcab25b..33a6aea 100644 --- a/views/update_dialog.py +++ b/views/update_dialog.py @@ -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, @@ -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) @@ -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)