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)