Files
hermes-agent/hermes_cli/windows_appinstaller_update.py
ethernet 2bf86dd20c fix(desktop): run the Windows update checkers from bundled core code
A packaged Windows build reported "Microsoft Store returned invalid
JSON" on every update check. The Store checker, the App Installer
checker and the relaunch waiter were resolved under the payload's repo
snapshot (<payload>/hermes-agent/apps/desktop/scripts/), but
INERT_SNAPSHOT_DIRS has excluded apps/ and scripts/ from that snapshot
since f6713f2762. The installed 2026.6523.3313.0 package has no apps/
directory at all. The payload python then failed to open the script,
exited 2 with empty stdout, and parseResult reported invalid JSON.

The two checkers are python that runs under the payload interpreter, so
they belong with the core code, like the other hermes_cli helpers the
desktop runs with -m:

- apps/desktop/scripts/check-store-update.py becomes
  hermes_cli/windows_store_update.py and check-appinstaller-update.py
  becomes hermes_cli/windows_appinstaller_update.py (git mv).
- The runner spawns `python -P -m <module>` with PYTHONPATH set to the
  bundled core code, then the venv dependencies: the order the bin
  launchers give sys.path. -P keeps the working directory off sys.path.
- The relaunch waiter is PowerShell run outside the package, not core
  code. It ships as an app resource (win.extraResources) and resolves
  from process.resourcesPath.

The Python tests now import the modules, and a real `python -P -m`
subprocess test proves a checker failure still reaches the desktop as
JSON. A vitest checks the packaging config ships the waiter.
2026-10-02 10:42:07 -04:00

97 lines
4.2 KiB
Python

"""Check for an App Installer update for the running MSIX package.
The out-of-store desktop installs are App Installer owned: the OS registered
the .appinstaller URI as the package's update source at install, so the
OS can tell us whether a newer package version is available. The desktop
shows its own prompt + tears down before the OS applies the swap.
Run with the BUNDLED payload python (the winrt package ships there):
<payload>/tools/<python-entry>/python.exe -P -m hermes_cli.windows_appinstaller_update
Exit codes:
0 no update available, or this process has no package identity
2 update available (the caller decides whether to prompt/teardown)
1 error, including a missing registered App Installer update source
API shape (pywinrt projection, verified against the installed winrt 3.2.1):
``Package.current`` is a static PROPERTY; the update check is the instance
method ``Package.check_update_availability_async`` — ``PackageManager`` has
no such method. ``PackageUpdateAvailability`` members (verified live in the
installed projection): UNKNOWN=0, NO_UPDATES=1, AVAILABLE=2, REQUIRED=3,
ERROR=4. Only the specific "no package identity" HRESULT (0x80073D54) means
not-packaged; every other failure is an unknown, never a "no update".
"""
import json
import sys
# HRESULT 0x80073D54 — "The process has no package identity." The one
# failure that positively identifies a non-packaged (dev) run.
_NO_PACKAGE_IDENTITY_HRESULT = -2147009196
def _load_projection():
from winrt.windows.applicationmodel import Package, PackageUpdateAvailability
return Package, PackageUpdateAvailability
def main() -> int:
try:
Package, PackageUpdateAvailability = _load_projection()
except ImportError as exc:
# The winrt module is absent (older payload without the dep): the
# update check cannot run. Report unknown, not "no update".
print(json.dumps({"available": None, "error": f"winrt import failed: {exc}"}))
return 1
# Property access, not a call: a packaged process gets the Package
# instance; a dev run raises the package-identity HRESULT.
try:
package = Package.current
except OSError as exc:
if getattr(exc, "winerror", None) == _NO_PACKAGE_IDENTITY_HRESULT:
print(json.dumps({"available": False, "reason": "not-packaged"}))
return 0
print(json.dumps({"available": None, "error": f"package identity failed: {exc}"}))
return 1
except Exception as exc: # noqa: BLE001 — projection shape surprises are unknowns
print(json.dumps({"available": None, "error": f"package identity failed: {exc}"}))
return 1
try:
source = package.get_app_installer_info()
if source is None:
print(json.dumps({
"available": None,
"reason": "no-app-installer-source",
"error": "No App Installer update source is registered. Install Hermes through its .appinstaller file to enable updates.",
}))
return 1
source_uri = source.uri.absolute_uri
result = package.check_update_availability_async().get()
availability = PackageUpdateAvailability(result.availability)
except Exception as exc: # noqa: BLE001
print(json.dumps({"available": None, "error": f"check failed: {exc}"}))
return 1
# Only AVAILABLE / REQUIRED mean "a newer package exists". NO_UPDATES is
# the one honest "no". UNKNOWN and ERROR are NOT "no update" — reporting
# them as such would suppress a real update prompt — so they surface as
# an unknown with the availability name as the extended error.
if availability == PackageUpdateAvailability.NO_UPDATES:
print(json.dumps({"available": False, "availability": availability.name, "source_uri": source_uri}))
return 0
if availability in (PackageUpdateAvailability.AVAILABLE, PackageUpdateAvailability.REQUIRED):
print(json.dumps({"available": True, "availability": availability.name, "source_uri": source_uri}))
return 2
print(json.dumps({"available": None, "error": f"availability unknown: {availability.name}"}))
return 1
if __name__ == "__main__":
sys.exit(main())