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
36 changes: 36 additions & 0 deletions docs/architecture/refactoring-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -1368,3 +1368,39 @@ 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.

## 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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 8 additions & 2 deletions src/core/update_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand Down
10 changes: 10 additions & 0 deletions src/gui/app_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
49 changes: 44 additions & 5 deletions src/platform_utils/windows_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import subprocess
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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),
Expand Down
48 changes: 48 additions & 0 deletions tests/test_gui_app_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion tests/test_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_update_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
34 changes: 34 additions & 0 deletions tests/test_update_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand Down
Loading