From 9cb514bc72fd3e8cfcc7f560d5639cd8bb423f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 11 Aug 2026 21:05:27 +0700 Subject: [PATCH 1/2] fix: repair Windows updater verification --- docs/architecture/refactoring-log.md | 24 ++++++++++++++ src/core/update_manifest.py | 10 ++++-- src/gui/app_controller.py | 10 ++++++ src/platform_utils/windows_update.py | 49 +++++++++++++++++++++++++--- tests/test_gui_app_controller.py | 48 +++++++++++++++++++++++++++ tests/test_update_manifest.py | 25 ++++++++++++++ tests/test_update_packages.py | 34 +++++++++++++++++++ 7 files changed, 193 insertions(+), 7 deletions(-) diff --git a/docs/architecture/refactoring-log.md b/docs/architecture/refactoring-log.md index 18ea671..14dfccb 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1368,3 +1368,27 @@ rg -n "Wait-Process" scripts/test_windows_single_instance_e2e.ps1 - Verification: the complete suite passes **1321 tests with 25 skipped**; repository-wide Ruff, bytecode compilation, release tag/build metadata validation, and `git diff --check` pass. + +## Windows updater verification repair (2026-08-11) + +- The updater now passes the selected Windows package path to its metadata + probes through a process-scoped environment variable instead of relying on + PowerShell's empty `$args` collection. Literal paths containing spaces or + metacharacters therefore cannot change the command text. +- Windows PowerShell probes receive only the native Windows PowerShell module + root, preventing an inherited PowerShell 7 `PSModulePath` from loading an + incompatible `Microsoft.PowerShell.Security` module. MSI property reads also + suppress the COM `Execute` return value so product fields remain scalar. +- Schema 3 pinned Windows and Android packages are eligible for the verified + in-app handoff; unsigned iOS sideload releases remain manual-only. Download + and failure states now replace the stale "version ready" banner copy with an + explicit progress or retry message. +- A live, non-installing probe downloaded the public 46,878,720-byte + `UTHelper-2.3.0.msi`, verified its hash, pinned signer fingerprint, + timestamp, product version, x64 template, and upgrade code, then exercised + the coordinator through `checking -> update_available -> downloading -> + download_progress -> ready_to_install`. The installer launcher was not + invoked before confirmation. +- Verification: **1325 tests passed with 25 skipped**; repository-wide Ruff, + bytecode compilation, focused live package verification, and + `git diff --check` pass. diff --git a/src/core/update_manifest.py b/src/core/update_manifest.py index 90dd2b0..390eba3 100644 --- a/src/core/update_manifest.py +++ b/src/core/update_manifest.py @@ -455,10 +455,16 @@ def select_candidate( raise ManifestError("ambiguous package candidates") if not matches: return None + package = matches[0] + in_app_install_allowed = manifest.schema_version in {2, 3} and not ( + manifest.schema_version == 3 + and package.platform == "ios" + and package.signature_kind == "unsigned-resign-required" + ) return UpdateCandidate( manifest=manifest, - package=matches[0], - automatic_install_allowed=manifest.schema_version == 2, + package=package, + automatic_install_allowed=in_app_install_allowed, required_update=Version(current) < Version(manifest.minimum_supported_version), ) diff --git a/src/gui/app_controller.py b/src/gui/app_controller.py index ca1f1c0..8f7f033 100644 --- a/src/gui/app_controller.py +++ b/src/gui/app_controller.py @@ -2031,6 +2031,10 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: self._update_progress.visible = False self._update_banner.visible = True elif event.kind in {UpdateEventKind.DOWNLOADING, UpdateEventKind.DOWNLOAD_PROGRESS}: + if candidate is not None: + self._update_text.value = ( + f"Đang tải và xác minh v{candidate.manifest.release_version}..." + ) self._update_btn.disabled = True self._update_btn.content = "Đang tải và xác minh..." self._update_progress.visible = True @@ -2054,6 +2058,12 @@ async def _apply_update_event(self, event: UpdateEvent) -> None: self._update_btn.disabled = False self._update_btn.content = "Thử lại" self._update_progress.visible = False + failed_candidate = candidate or getattr(self, "_update_candidate", None) + if failed_candidate is not None: + self._update_text.value = ( + "Không thể tải hoặc xác minh " + f"v{failed_candidate.manifest.release_version}. Hãy thử lại." + ) if should_notify: self._show_snackbar( "Không thể tải hoặc xác minh bản cập nhật", diff --git a/src/platform_utils/windows_update.py b/src/platform_utils/windows_update.py index d5feda7..29587dd 100644 --- a/src/platform_utils/windows_update.py +++ b/src/platform_utils/windows_update.py @@ -6,6 +6,7 @@ import hashlib import json import math +import os from pathlib import Path import platform import subprocess @@ -33,7 +34,14 @@ _SIGNATURE_SCRIPT = r""" $ErrorActionPreference = 'Stop' -$sig = Get-AuthenticodeSignature -LiteralPath $args[0] +$packagePath = [Environment]::GetEnvironmentVariable( + 'UTHELPER_UPDATE_PACKAGE_PATH', + 'Process' +) +if ([string]::IsNullOrWhiteSpace($packagePath)) { + throw 'Missing update package path' +} +$sig = Get-AuthenticodeSignature -LiteralPath $packagePath $sha256 = if ($sig.SignerCertificate) { $sig.SignerCertificate.GetCertHashString( [Security.Cryptography.HashAlgorithmName]::SHA256 @@ -49,13 +57,20 @@ _MSI_SCRIPT = r""" $ErrorActionPreference = 'Stop' +$packagePath = [Environment]::GetEnvironmentVariable( + 'UTHELPER_UPDATE_PACKAGE_PATH', + 'Process' +) +if ([string]::IsNullOrWhiteSpace($packagePath)) { + throw 'Missing update package path' +} $installer = New-Object -ComObject WindowsInstaller.Installer -$database = $installer.OpenDatabase($args[0], 0) +$database = $installer.OpenDatabase($packagePath, 0) function Read-Property([string]$name) { $view = $database.OpenView("SELECT ``Value`` FROM ``Property`` WHERE ``Property``=?") $record = $installer.CreateRecord(1) $record.StringData(1) = $name - $view.Execute($record) + $null = $view.Execute($record) $row = $view.Fetch() if ($null -eq $row) { throw "Missing MSI property" } return [string]$row.StringData(1) @@ -70,7 +85,14 @@ _EXE_SCRIPT = r""" $ErrorActionPreference = 'Stop' -$version = (Get-Item -LiteralPath $args[0]).VersionInfo +$packagePath = [Environment]::GetEnvironmentVariable( + 'UTHELPER_UPDATE_PACKAGE_PATH', + 'Process' +) +if ([string]::IsNullOrWhiteSpace($packagePath)) { + throw 'Missing update package path' +} +$version = (Get-Item -LiteralPath $packagePath).VersionInfo [ordered]@{ product_name = [string]$version.ProductName product_version = [string]$version.ProductVersion @@ -122,6 +144,23 @@ def _bounded_timeout(value: float) -> float: def _powershell_json(script: str, path: Path, timeout_seconds: float) -> dict: + resolved_path = Path(path).resolve(strict=True) + child_environment = os.environ.copy() + # A packaged app can inherit PowerShell 7's PSModulePath. Windows + # PowerShell 5.1 then imports incompatible Core modules and cannot load + # Get-AuthenticodeSignature. Give the verifier only the native module roots. + system_root = child_environment.get("SystemRoot", r"C:\Windows") + native_module_root = str( + Path(system_root) + / "System32" + / "WindowsPowerShell" + / "v1.0" + / "Modules" + ) + child_environment["PSModulePath"] = native_module_root + # Pass the literal path out-of-band so spaces and PowerShell metacharacters + # can never alter the command text. + child_environment["UTHELPER_UPDATE_PACKAGE_PATH"] = str(resolved_path) completed = subprocess.run( [ "powershell.exe", @@ -130,8 +169,8 @@ def _powershell_json(script: str, path: Path, timeout_seconds: float) -> dict: "-NonInteractive", "-Command", script, - str(Path(path).resolve()), ], + env=child_environment, capture_output=True, text=True, timeout=_bounded_timeout(timeout_seconds), diff --git a/tests/test_gui_app_controller.py b/tests/test_gui_app_controller.py index 290e8a5..9469559 100644 --- a/tests/test_gui_app_controller.py +++ b/tests/test_gui_app_controller.py @@ -8,6 +8,7 @@ import gui.app_controller as app_controller_module from core.today_schedule import ScheduleLoadStatus, TodayScheduleViewState +from core.update_coordinator import UpdateEvent, UpdateEventKind from core.update_models import ReleasePackage from gui.app_controller import ( AppController, @@ -183,3 +184,50 @@ def test_update_confirmation_explains_ios_sideload_and_resigning(monkeypatch): assert dialog.actions[1].content == "Mở trang tải IPA" assert "Sideloadly/AltStore" in dialog.content.value assert "cùng Apple ID" in dialog.content.value + + +def _update_event_controller(): + controller = AppController.__new__(AppController) + controller.settings_view = SimpleNamespace(_check_update_btn=None) + controller._manual_update_check_requested = False + controller._update_candidate = SimpleNamespace( + manifest=SimpleNamespace(release_version="2.3.0") + ) + controller._update_text = SimpleNamespace(value="") + controller._update_btn = SimpleNamespace(disabled=False, content="") + controller._update_progress = SimpleNamespace(visible=False, value=0.0) + controller._update_banner = SimpleNamespace(visible=False) + controller.page = _FakePage() + return controller + + +def test_update_download_replaces_stale_available_copy_with_progress_state(): + controller = _update_event_controller() + candidate = controller._update_candidate + + asyncio.run( + controller._apply_update_event( + UpdateEvent(UpdateEventKind.DOWNLOADING, candidate, progress=0.0) + ) + ) + + assert controller._update_text.value == "Đang tải và xác minh v2.3.0..." + assert controller._update_btn.content == "Đang tải và xác minh..." + assert controller._update_btn.disabled is True + assert controller._update_progress.visible is True + + +def test_update_failure_explains_retry_in_banner_instead_of_stale_ready_copy(): + controller = _update_event_controller() + + asyncio.run( + controller._apply_update_event( + UpdateEvent(UpdateEventKind.FAILED, controller._update_candidate) + ) + ) + + assert controller._update_text.value == ( + "Không thể tải hoặc xác minh v2.3.0. Hãy thử lại." + ) + assert controller._update_btn.content == "Thử lại" + assert controller._update_btn.disabled is False diff --git a/tests/test_update_manifest.py b/tests/test_update_manifest.py index 09f84b3..a8b94d4 100644 --- a/tests/test_update_manifest.py +++ b/tests/test_update_manifest.py @@ -108,6 +108,31 @@ def test_schema3_accepts_unsigned_ios_sideload_package(): assert manifest.schema_version == 3 assert manifest.packages[0].signature_kind == "unsigned-resign-required" + candidate = select_candidate( + manifest, + current_version="2.1.0", + target=RuntimeTarget("ios", "arm64", "sideload"), + ) + + assert candidate is not None + assert candidate.automatic_install_allowed is False + + +def test_schema3_pinned_windows_package_allows_verified_in_app_install(): + manifest = parse_manifest( + _schema3(_schema3_package()), + expected_release_version="2.2.0", + ) + + candidate = select_candidate( + manifest, + current_version="2.1.0", + target=RuntimeTarget("windows", "x64", "msi"), + ) + + assert candidate is not None + assert candidate.automatic_install_allowed is True + @pytest.mark.parametrize("kind", ["apk-pinned", "self-signed-pinned"]) def test_schema3_pinned_signatures_require_identity_and_fingerprint(kind): diff --git a/tests/test_update_packages.py b/tests/test_update_packages.py index fe55859..7988c17 100644 --- a/tests/test_update_packages.py +++ b/tests/test_update_packages.py @@ -37,6 +37,40 @@ def test_compiled_windows_release_pin_is_exact_sha256_identity(): ) +def test_windows_probe_isolates_native_modules_and_passes_literal_path( + tmp_path, + monkeypatch, +): + from platform_utils import windows_update + + package = tmp_path / "update [verified]; package.msi" + package.write_bytes(b"package") + captured = {} + + def fake_run(argv, **kwargs): + captured["argv"] = argv + captured["kwargs"] = kwargs + return subprocess.CompletedProcess(argv, 0, stdout='{"ok": true}', stderr="") + + monkeypatch.setenv("PSModulePath", r"C:\Program Files\PowerShell\7\Modules") + monkeypatch.setenv("SystemRoot", r"C:\Windows") + monkeypatch.setattr(windows_update.subprocess, "run", fake_run) + + assert windows_update._powershell_json("probe-script", package, 10) == { + "ok": True + } + + assert captured["argv"][-1] == "probe-script" + assert str(package.resolve()) not in captured["argv"] + child_environment = captured["kwargs"]["env"] + assert child_environment["UTHELPER_UPDATE_PACKAGE_PATH"] == str( + package.resolve() + ) + assert "PowerShell\\7\\Modules" not in child_environment["PSModulePath"] + assert "WindowsPowerShell" in child_environment["PSModulePath"] + assert "$null = $view.Execute($record)" in windows_update._MSI_SCRIPT + + def _candidate( path: Path, *, From 8a80fec7b84884718c78e65255a8a91c79b69e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Ch=C6=B0?= =?UTF-8?q?=C6=A1ng?= Date: Tue, 11 Aug 2026 21:07:41 +0700 Subject: [PATCH 2/2] chore: prepare v2.3.1 hotfix --- docs/architecture/refactoring-log.md | 12 ++++++++++++ pyproject.toml | 2 +- tests/test_release_metadata.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/architecture/refactoring-log.md b/docs/architecture/refactoring-log.md index 14dfccb..9b4287c 100644 --- a/docs/architecture/refactoring-log.md +++ b/docs/architecture/refactoring-log.md @@ -1392,3 +1392,15 @@ rg -n "Wait-Process" scripts/test_windows_single_instance_e2e.ps1 - Verification: **1325 tests passed with 25 skipped**; repository-wide Ruff, bytecode compilation, focused live package verification, and `git diff --check` pass. + +## v2.3.1 updater hotfix preparation (2026-08-11) + +- The v2.3.0 production updater can discover and download its release package, + but the Windows metadata probe rejects it before the explicit install + confirmation. The compatible verifier repair therefore increments only the + patch component from `2.3.0` to `2.3.1` under Semantic Versioning. +- `pyproject.toml` remains the sole authored version source; release metadata + resolves `v2.3.1` to monotonic build number `2003001`. +- This is a production hotfix branched from `main`. The same `hotfix/2.3.1` + branch must merge into both `main` and `develop` before the protected main + result is tagged. diff --git a/pyproject.toml b/pyproject.toml index e13cd87..deb8545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "uthelper" -version = "2.3.0" +version = "2.3.1" description = "UTHelper — Ứng dụng theo dõi bài tập và deadline UTH E-learning" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 8bff184..4f99f83 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -25,7 +25,7 @@ def test_project_version_is_only_authored_version(tmp_path): def test_this_feature_release_bumps_the_single_authored_version(): - assert read_project_version(ROOT / "pyproject.toml") == "2.3.0" + assert read_project_version(ROOT / "pyproject.toml") == "2.3.1" def test_runtime_version_is_generated_from_the_single_authored_version(tmp_path):