diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index cdbc33e..3ec38b7 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -284,7 +284,7 @@ def _on_download_finished(self, success: bool, version: str) -> None: f'v{version} installing\u2026', UPDATE_STATUS_AVAILABLE_STYLE, ) - self._apply_update() + self._apply_update(silent=True) return # Manual mode — show ready banner and let user choose when to restart @@ -303,13 +303,18 @@ def _on_download_error(self, error: str) -> None: # Apply # ------------------------------------------------------------------ - def _apply_update(self) -> None: - """Apply the downloaded update and restart.""" + def _apply_update(self, *, silent: bool = False) -> None: + """Apply the downloaded update and restart. + + Args: + silent: When ``True``, suppress the Velopack splash window + by using ``wait_exit_then_apply_updates``. + """ if self._client.updater is None: return try: - self._client.apply_update_on_exit(restart=True) + self._client.apply_update_on_exit(restart=True, silent=silent) logger.info('Update scheduled — restarting application') self._app.quit() except Exception as e: diff --git a/synodic_client/client.py b/synodic_client/client.py index 63dd522..16ad288 100644 --- a/synodic_client/client.py +++ b/synodic_client/client.py @@ -116,14 +116,15 @@ def download_update(self, progress_callback: Callable[[int], None] | None = None return self._updater.download_update(progress_callback) - def apply_update_on_exit(self, restart: bool = True) -> None: + def apply_update_on_exit(self, restart: bool = True, *, silent: bool = False) -> None: """Schedule the update to apply when the application exits. Args: - restart: Whether to restart after applying + restart: Whether to restart after applying. + silent: When ``True``, suppress the Velopack splash window. """ if self._updater is None: logger.warning('Updater not initialized') return - self._updater.apply_update_on_exit(restart=restart) + self._updater.apply_update_on_exit(restart=restart, silent=silent) diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 43a24ed..c4ceb41 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -252,16 +252,24 @@ def download_update(self, progress_callback: Callable[[int], None] | None = None self._update_info.error = str(e) return False - def apply_update_on_exit(self, restart: bool = True, restart_args: list[str] | None = None) -> None: - """Apply the downloaded update, optionally restarting the application. + def apply_update_on_exit( + self, + restart: bool = True, + silent: bool = False, + restart_args: list[str] | None = None, + ) -> None: + """Stage the downloaded update to apply when the process exits. - When *restart* is ``True`` the Velopack runtime applies the update - **and** relaunches the new version (the call does not return). - When ``False`` the update is staged and applied after the process - exits without relaunching. + Uses ``wait_exit_then_apply_updates`` which returns immediately. + The Velopack Update.exe runs after the current process exits, + applies the update, and optionally relaunches the application. + + The caller is responsible for shutting down the process (e.g. + ``QApplication.quit()``) after this method returns. Args: restart: Whether to restart the application after applying. + silent: When ``True``, suppress the Velopack splash window. restart_args: Optional arguments to pass to the restarted application. """ if not self.is_installed: @@ -278,23 +286,14 @@ def apply_update_on_exit(self, restart: bool = True, restart_args: list[str] | N if manager is None: raise RuntimeError('Velopack manager not available') - logger.info('Applying update (restart=%s)', restart) - - if restart: - self._state = UpdateState.APPLYING - if restart_args: - manager.apply_updates_and_restart_with_args( - self._update_info._velopack_info, - restart_args, - ) - else: - manager.apply_updates_and_restart(self._update_info._velopack_info) - # apply_updates_and_restart terminates the process; - # fall through only as a safety net. - sys.exit(0) - else: - manager.apply_updates_and_exit(self._update_info._velopack_info) - self._state = UpdateState.APPLIED + logger.info('Applying update (restart=%s, silent=%s)', restart, silent) + self._state = UpdateState.APPLYING + manager.wait_exit_then_apply_updates( + self._update_info._velopack_info, + silent=silent, + restart=restart, + restart_args=restart_args or [], + ) except Exception as e: logger.exception('Failed to apply update') diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index b18e6cd..ef1745c 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -151,13 +151,13 @@ class TestDownloadFinished: @staticmethod def test_auto_apply_calls_apply_update() -> None: - """When auto_apply=True, a successful download should call _apply_update.""" + """When auto_apply=True, a successful download should call _apply_update(silent=True).""" ctrl, app, client, banner, settings = _make_controller(auto_apply=True) with patch.object(ctrl, '_apply_update') as mock_apply: ctrl._on_download_finished(True, '2.0.0') - mock_apply.assert_called_once() + mock_apply.assert_called_once_with(silent=True) @staticmethod def test_auto_apply_does_not_show_ready_banner() -> None: @@ -213,7 +213,7 @@ def test_apply_update_calls_client_and_quits() -> None: ctrl, app, client, banner, settings = _make_controller() ctrl._apply_update() - client.apply_update_on_exit.assert_called_once_with(restart=True) + client.apply_update_on_exit.assert_called_once_with(restart=True, silent=False) app.quit.assert_called_once() @staticmethod diff --git a/tests/unit/test_client_updater.py b/tests/unit/test_client_updater.py index b23b8e5..dc43456 100644 --- a/tests/unit/test_client_updater.py +++ b/tests/unit/test_client_updater.py @@ -105,4 +105,12 @@ def test_apply_update_on_exit_with_init(client_with_updater: Client) -> None: with patch.object(client_with_updater._updater, 'apply_update_on_exit') as mock_apply: client_with_updater.apply_update_on_exit(restart=False) - mock_apply.assert_called_once_with(restart=False) + mock_apply.assert_called_once_with(restart=False, silent=False) + + @staticmethod + def test_apply_update_on_exit_silent(client_with_updater: Client) -> None: + """Verify apply_update_on_exit forwards silent flag to updater.""" + with patch.object(client_with_updater._updater, 'apply_update_on_exit') as mock_apply: + client_with_updater.apply_update_on_exit(restart=True, silent=True) + + mock_apply.assert_called_once_with(restart=True, silent=True) diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 9185a42..882f7ef 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -337,7 +337,7 @@ def test_apply_on_exit_no_downloaded_update(updater: Updater) -> None: @staticmethod def test_apply_on_exit_with_restart(updater: Updater) -> None: - """Verify apply_update_on_exit(restart=True) uses apply_updates_and_restart.""" + """Verify apply_update_on_exit(restart=True) stages update via wait_exit_then_apply_updates.""" mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, @@ -352,16 +352,20 @@ def test_apply_on_exit_with_restart(updater: Updater) -> None: with ( patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), patch.object(updater, '_get_velopack_manager', return_value=mock_manager), - pytest.raises(SystemExit, match='0'), ): updater.apply_update_on_exit(restart=True) assert updater.state == UpdateState.APPLYING - mock_manager.apply_updates_and_restart.assert_called_once_with(mock_velopack_info) + mock_manager.wait_exit_then_apply_updates.assert_called_once_with( + mock_velopack_info, + silent=False, + restart=True, + restart_args=[], + ) @staticmethod def test_apply_on_exit_with_restart_args(updater: Updater) -> None: - """Verify restart_args are forwarded to apply_updates_and_restart_with_args.""" + """Verify restart_args are forwarded to wait_exit_then_apply_updates.""" mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, @@ -376,19 +380,20 @@ def test_apply_on_exit_with_restart_args(updater: Updater) -> None: with ( patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), patch.object(updater, '_get_velopack_manager', return_value=mock_manager), - pytest.raises(SystemExit, match='0'), ): updater.apply_update_on_exit(restart=True, restart_args=['--minimized']) assert updater.state == UpdateState.APPLYING - mock_manager.apply_updates_and_restart_with_args.assert_called_once_with( + mock_manager.wait_exit_then_apply_updates.assert_called_once_with( mock_velopack_info, - ['--minimized'], + silent=False, + restart=True, + restart_args=['--minimized'], ) @staticmethod def test_apply_on_exit_no_restart(updater: Updater) -> None: - """Verify apply_update_on_exit(restart=False) uses apply_updates_and_exit.""" + """Verify apply_update_on_exit(restart=False) stages update without restart.""" mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) updater._update_info = UpdateInfo( available=True, @@ -406,8 +411,97 @@ def test_apply_on_exit_no_restart(updater: Updater) -> None: ): updater.apply_update_on_exit(restart=False) - assert updater.state == UpdateState.APPLIED - mock_manager.apply_updates_and_exit.assert_called_once_with(mock_velopack_info) + assert updater.state == UpdateState.APPLYING + mock_manager.wait_exit_then_apply_updates.assert_called_once_with( + mock_velopack_info, + silent=False, + restart=False, + restart_args=[], + ) + + @staticmethod + def test_apply_on_exit_silent_restart(updater: Updater) -> None: + """Verify silent=True suppresses the splash window.""" + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) + updater._update_info = UpdateInfo( + available=True, + current_version=Version('1.0.0'), + latest_version=Version('2.0.0'), + _velopack_info=mock_velopack_info, + ) + updater._state = UpdateState.DOWNLOADED + + mock_manager = MagicMock(spec=velopack.UpdateManager) + + with ( + patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), + patch.object(updater, '_get_velopack_manager', return_value=mock_manager), + ): + updater.apply_update_on_exit(restart=True, silent=True) + + assert updater.state == UpdateState.APPLYING + mock_manager.wait_exit_then_apply_updates.assert_called_once_with( + mock_velopack_info, + silent=True, + restart=True, + restart_args=[], + ) + + @staticmethod + def test_apply_on_exit_silent_no_restart(updater: Updater) -> None: + """Verify silent=True, restart=False stages update silently.""" + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) + updater._update_info = UpdateInfo( + available=True, + current_version=Version('1.0.0'), + latest_version=Version('2.0.0'), + _velopack_info=mock_velopack_info, + ) + updater._state = UpdateState.DOWNLOADED + + mock_manager = MagicMock(spec=velopack.UpdateManager) + + with ( + patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), + patch.object(updater, '_get_velopack_manager', return_value=mock_manager), + ): + updater.apply_update_on_exit(restart=False, silent=True) + + assert updater.state == UpdateState.APPLYING + mock_manager.wait_exit_then_apply_updates.assert_called_once_with( + mock_velopack_info, + silent=True, + restart=False, + restart_args=[], + ) + + @staticmethod + def test_apply_on_exit_silent_with_restart_args(updater: Updater) -> None: + """Verify silent mode forwards restart_args.""" + mock_velopack_info = MagicMock(spec=velopack.UpdateInfo) + updater._update_info = UpdateInfo( + available=True, + current_version=Version('1.0.0'), + latest_version=Version('2.0.0'), + _velopack_info=mock_velopack_info, + ) + updater._state = UpdateState.DOWNLOADED + + mock_manager = MagicMock(spec=velopack.UpdateManager) + + with ( + patch.object(Updater, 'is_installed', new_callable=PropertyMock, return_value=True), + patch.object(updater, '_get_velopack_manager', return_value=mock_manager), + ): + updater.apply_update_on_exit(restart=True, silent=True, restart_args=['--minimized']) + + assert updater.state == UpdateState.APPLYING + mock_manager.wait_exit_then_apply_updates.assert_called_once_with( + mock_velopack_info, + silent=True, + restart=True, + restart_args=['--minimized'], + ) class TestInitializeVelopack: