Files
hermes-agent/apps/desktop/scripts/check-appinstaller-update.py
2026-09-09 10:54:10 -04:00

98 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""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 scripts/check-appinstaller-update.py
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())