From 1916c6b5fc2ae6eca048be942dce88fba71904b7 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 28 Jun 2026 13:31:11 +0100 Subject: [PATCH 1/6] feat(store_updater): detect MSIX install + query Microsoft Store API Adds app/store_updater.py with helpers to detect MSIX-packaged execution (PACKAGE_FULL_NAME env var or \WindowsApps\ exe path) and to query the public DisplayCatalog API for the latest published version of listing 9P70QGR8BSMZ. Network/parse failures are logged and return None so a missing connectivity check never blocks app start-up. --- app/store_updater.py | 162 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 app/store_updater.py diff --git a/app/store_updater.py b/app/store_updater.py new file mode 100644 index 0000000..79f0d1b --- /dev/null +++ b/app/store_updater.py @@ -0,0 +1,162 @@ +"""Microsoft Store update notification for MSIX-installed PDFApps. + +When running inside a packaged (MSIX) install, the standard self-updater +(downloading PDFAppsSetup.exe from GitHub Releases) is inappropriate +because the MSIX sandbox forbids running arbitrary installers and would +otherwise result in dual installations (the Store-managed MSIX plus an +NSIS copy in %LOCALAPPDATA%\\Programs\\PDFApps). + +This module: +1. Detects MSIX-packaged execution (env var or WindowsApps path). +2. Queries the Microsoft Store DisplayCatalog API for the latest + published version of listing 9P70QGR8BSMZ. +3. Returns a tuple ``(has_update, latest_version)`` for the UI layer to + show a non-blocking notification with a deep-link to the Store. + +All network failures are swallowed and logged — an offline launch must +never block start-up or surface an error toast in the GUI. +""" + +import json +import logging +import os +import sys +import urllib.request +from typing import Optional, Tuple + +from app.constants import APP_VERSION + +_log = logging.getLogger(__name__) + +# Microsoft Store listing ID for PDFApps (apps.microsoft.com/detail/9P70QGR8BSMZ) +STORE_PRODUCT_ID = "9P70QGR8BSMZ" + +# Public Storefront / DisplayCatalog API endpoint. ``bigIds`` is the +# documented parameter for "fetch product metadata by listing ID". +_DISPLAYCATALOG_URL = ( + "https://displaycatalog.mp.microsoft.com/v7.0/products" + f"?bigIds={STORE_PRODUCT_ID}" + "&market=US" + "&languages=en-US" +) + +_REQUEST_TIMEOUT_SECONDS = 10 + +# ms-windows-store:// URI scheme handled by the Store app; opens the +# product detail page directly. +_STORE_DEEP_LINK = f"ms-windows-store://pdp/?productid={STORE_PRODUCT_ID}" + + +def is_msix_install() -> bool: + """Detect if PDFApps is running inside a Windows App Package (MSIX/AppX). + + Returns True for MSIX installs (Microsoft Store), False for NSIS + installer / portable .exe / non-Windows platforms. Two indicators + are checked because either alone has edge cases: + + * ``PACKAGE_FULL_NAME`` is set by the Windows runtime for packaged + apps but can be unset when tooling launches the .exe outside the + AppContainer. + * The ``\\WindowsApps\\`` path prefix is reliable because MSIX + packages are always extracted to ``C:\\Program Files\\WindowsApps``. + """ + if sys.platform != "win32": + return False + # Method 1: env var set by Windows runtime for packaged apps. + if os.environ.get("PACKAGE_FULL_NAME"): + return True + # Method 2: executable path under WindowsApps (MSIX install location). + # Normalise with realpath so a symlinked launcher still matches. + try: + exe = os.path.realpath(sys.executable).lower() + except OSError: + exe = (sys.executable or "").lower() + if "\\windowsapps\\" in exe or "/windowsapps/" in exe: + return True + return False + + +def _parse_version(s: str) -> Tuple[int, ...]: + """Parse ``'1.13.16'`` or ``'1.13.16.0'`` into a comparable tuple. + + Non-numeric components are coerced to 0 so a tag like + ``'1.13.16-beta'`` still produces a comparable tuple. + """ + if not s: + return (0,) + parts = [] + for chunk in s.split("."): + try: + parts.append(int(chunk)) + except ValueError: + parts.append(0) + return tuple(parts) + + +def get_store_version() -> Optional[str]: + """Query the Microsoft Store DisplayCatalog API for the latest + published version of the PDFApps listing. + + Returns the version string (e.g. ``'1.13.16'``) or ``None`` on any + network/parse failure — callers must treat ``None`` as "could not + determine; do not notify". + """ + try: + req = urllib.request.Request( + _DISPLAYCATALOG_URL, + headers={"User-Agent": f"PDFApps/{APP_VERSION}"}, + ) + with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT_SECONDS) as resp: + data = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + _log.warning("Store version check failed: %s", exc) + return None + + versions: list[Tuple[int, ...]] = [] + try: + for product in data.get("Products", []) or []: + for sku in product.get("DisplaySkuAvailabilities", []) or []: + properties = (sku.get("Sku") or {}).get("Properties") or {} + for pkg in properties.get("Packages", []) or []: + ver = pkg.get("Version") + if ver: + versions.append(_parse_version(ver)) + except Exception as exc: + _log.warning("Store API response parse failed: %s", exc) + return None + + if not versions: + _log.info("Store API returned no version info") + return None + + # Highest version published — Store may list older revisions for + # rollback purposes. + latest = max(versions) + # Drop trailing .0 if present (e.g. 1.13.16.0 -> 1.13.16) so the + # comparison with APP_VERSION ("1.13.16") matches in length. + while len(latest) > 3 and latest[-1] == 0: + latest = latest[:-1] + return ".".join(str(n) for n in latest) + + +def check_for_store_update() -> Tuple[bool, Optional[str]]: + """Returns ``(has_update, latest_version_str)``. + + ``(False, None)`` is returned on error or when the Store reports the + same/older version than ``APP_VERSION``. Only meaningful when + :func:`is_msix_install` is True; callers should guard. + """ + latest = get_store_version() + if not latest: + return False, None + + current = _parse_version(APP_VERSION) + latest_tuple = _parse_version(latest) + + has_update = latest_tuple > current + return has_update, latest if has_update else None + + +def store_deep_link() -> str: + """Returns the ``ms-windows-store://`` URI to open the listing in the Store app.""" + return _STORE_DEEP_LINK From e1389b728dbe06c84dd199102b1369a6e799b30a Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 28 Jun 2026 13:31:19 +0100 Subject: [PATCH 2/6] feat(updater): branch to Store notification path for MSIX users MSIX/Microsoft Store users previously short-circuited in is_system_install() so check_for_update() returned None and they saw no update notification at all. They now flow through a new branch in check_for_update() that queries the Store via app.store_updater and returns a {"msix": True, "latest_version", "deep_link"} payload for the UI layer to render a Store-redirect dialog (the NSIS installer cannot run in the AppContainer sandbox and would create a parallel installation). --- app/updater.py | 46 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/app/updater.py b/app/updater.py index 7fb59b9..bfc7d3d 100644 --- a/app/updater.py +++ b/app/updater.py @@ -74,15 +74,17 @@ class _Signals(QObject): def is_system_install() -> bool: - """True when running from a system package manager (AUR, Snap, Flatpak, apt, rpm, MSIX).""" + """True when running from a system package manager (AUR, Snap, Flatpak, apt, rpm). + + Note: MSIX/Microsoft Store installs used to return True here so the + auto-updater would short-circuit. They are now handled separately + via :mod:`app.store_updater` so the user still gets a notification + (with a deep-link to the Store) instead of silence — see + :func:`check_for_update`. + """ if sys.platform == "win32": - # Detect MSIX / Microsoft Store install — the package is - # extracted under WindowsApps and the Store is responsible - # for updates. Auto-update would also fail because the - # package directory is read-only. - exe = os.path.realpath(sys.executable) - if "\\WindowsApps\\" in exe or "/WindowsApps/" in exe: - return True + # No system package manager on Windows other than MSIX, which is + # branched on separately in check_for_update(). return False # Sandboxed runtimes if os.environ.get("SNAP") or os.environ.get("FLATPAK_ID") or os.environ.get("APPIMAGE"): @@ -96,10 +98,36 @@ def is_system_install() -> bool: def check_for_update() -> dict | None: - """Return release info dict if a newer version exists, else None.""" + """Return release info dict if a newer version exists, else None. + + For MSIX/Microsoft Store installs a special dict shape is returned + so the UI can show a Store-redirect notification instead of trying + to download and run the NSIS installer (which the MSIX sandbox + forbids and which would create a parallel installation): + + {"msix": True, "latest_version": "1.13.17", "deep_link": "ms-windows-store://..."} + """ # System-managed installs (AUR, Snap, Flatpak, rpm, apt) must be updated via the package manager. if is_system_install(): return None + # MSIX / Microsoft Store users — route to Store deep-link instead of + # GitHub Releases download. The NSIS installer cannot run inside the + # AppContainer sandbox and would, in any case, leave the user with + # two parallel installations. + from app.store_updater import ( + is_msix_install, + check_for_store_update, + store_deep_link, + ) + if is_msix_install(): + has_update, latest = check_for_store_update() + if has_update and latest: + return { + "msix": True, + "latest_version": latest, + "deep_link": store_deep_link(), + } + return None try: req = urllib.request.Request(_API_URL, headers={"User-Agent": "PDFApps"}) with urllib.request.urlopen(req, timeout=10) as resp: From 372de911def8c18d3c811c1da0bdabe14a6346ad Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 28 Jun 2026 13:31:27 +0100 Subject: [PATCH 3/6] feat(window): show Microsoft Store deep-link dialog on update available Removes the WindowsApps early-return in _check_for_updates_async so MSIX users now flow through check_for_update(). _notify_update branches on the new {"msix": True, ...} payload and routes to a new _notify_store_update helper that opens a QMessageBox with Open/Later buttons; Open launches the ms-windows-store://pdp/ deep-link via QDesktopServices. _show_update_dialog also branches so the user can re-trigger the Store dialog from the update button. --- app/window.py | 55 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/app/window.py b/app/window.py index 870afd5..30d179d 100644 --- a/app/window.py +++ b/app/window.py @@ -1481,15 +1481,13 @@ def _apply_theme(self): # ── Auto-update ─────────────────────────────────────────────────────── def _check_for_updates_async(self): - # Skip auto-update inside Flatpak/Snap/MSIX — the host package - # manager (or Microsoft Store) handles updates. + # Skip auto-update inside Flatpak/Snap — the host package + # manager handles updates. MSIX installs are NOT skipped here; + # check_for_update() returns a special {"msix": True, ...} dict + # so the user gets a Store-redirect notification (see + # app/store_updater.py and _notify_update below). if os.environ.get("FLATPAK_ID") or os.environ.get("SNAP"): return - import sys as _sys - if _sys.platform == "win32": - exe = os.path.realpath(_sys.executable) - if "\\WindowsApps\\" in exe or "/WindowsApps/" in exe: - return # Pre-import the updater module on the main thread BEFORE the # worker thread starts. The worker would otherwise lazy-import # `app.updater`, which transitively pulls in `urllib.request` @@ -1554,6 +1552,16 @@ def _release_update_worker(self): def _notify_update(self): """Show update notification dialog automatically.""" self._update_btn.setVisible(True) + # MSIX install — route to Microsoft Store deep-link instead of + # the NSIS download dialog (the AppContainer sandbox blocks + # ShellExecuteW("runas", ...) and would, in any case, leave the + # user with two parallel installations). + if self._update_release.get("msix"): + self._notify_store_update( + self._update_release.get("latest_version") or "", + self._update_release.get("deep_link") or "", + ) + return tag = self._update_release.get("tag_name", "?") from PySide6.QtWidgets import QMessageBox msg = t("update.available").format(version=tag) @@ -1566,8 +1574,41 @@ def _notify_update(self): if reply == QMessageBox.StandardButton.Yes: self._show_update_dialog() + def _notify_store_update(self, latest: str, deep_link: str): + """Show a non-blocking dialog directing MSIX users to the Microsoft Store. + + Used when :func:`app.updater.check_for_update` returns the + special ``{"msix": True, ...}`` payload — see the rationale in + :func:`_check_for_updates_async`. + """ + from PySide6.QtWidgets import QMessageBox + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Information) + box.setWindowTitle(t("update.store.title")) + box.setText(t("update.store.message").format(version=latest)) + box.setStandardButtons( + QMessageBox.StandardButton.Open + | QMessageBox.StandardButton.Cancel + ) + box.button(QMessageBox.StandardButton.Open).setText(t("update.store.btn.open")) + box.button(QMessageBox.StandardButton.Cancel).setText(t("update.store.btn.later")) + box.setDefaultButton(QMessageBox.StandardButton.Open) + if box.exec() == QMessageBox.StandardButton.Open and deep_link: + from PySide6.QtCore import QUrl + from PySide6.QtGui import QDesktopServices + QDesktopServices.openUrl(QUrl(deep_link)) + def _show_update_dialog(self): if self._update_release: + # MSIX users go straight to the Store dialog — the standard + # UpdateDialog tries to download PDFAppsSetup.exe which the + # sandbox cannot run. + if self._update_release.get("msix"): + self._notify_store_update( + self._update_release.get("latest_version") or "", + self._update_release.get("deep_link") or "", + ) + return from app.updater import UpdateDialog dlg = UpdateDialog(self._update_release, parent=self) dlg.exec() From fcc6f9141779d4ba1b768f161518c563bfd79621 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 28 Jun 2026 13:31:33 +0100 Subject: [PATCH 4/6] chore(i18n): add 4 update.store.* keys across 8 languages Adds update.store.title, update.store.message (with {version} placeholder), update.store.btn.open and update.store.btn.later for the new Microsoft Store update-notification dialog. Parity maintained across en/pt/es/fr/de/zh/it/nl. --- app/translations.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/translations.json b/app/translations.json index 0f32418..557fdcb 100644 --- a/app/translations.json +++ b/app/translations.json @@ -551,6 +551,10 @@ "tool.watermark.empty_wm": "The watermark PDF has no pages.", "tool.watermark.empty_source": "The source PDF has no pages.", "update.dialog_title": "Update", + "update.store.title": "Update available on Microsoft Store", + "update.store.message": "PDFApps {version} is available. Open the Microsoft Store to update.", + "update.store.btn.open": "Open Microsoft Store", + "update.store.btn.later": "Later", "tool.convert.converting": "Converting pages...", "tool.ocr.no_langs": "No OCR languages available. Install Tesseract language packs.", "edit.status.note_label": "Note — p. {n}", @@ -1158,6 +1162,10 @@ "tool.watermark.empty_wm": "O PDF de marca dágua não tem páginas.", "tool.watermark.empty_source": "O PDF de origem não tem páginas.", "update.dialog_title": "Atualização", + "update.store.title": "Atualização disponível na Microsoft Store", + "update.store.message": "PDFApps {version} está disponível. Abra a Microsoft Store para atualizar.", + "update.store.btn.open": "Abrir Microsoft Store", + "update.store.btn.later": "Mais tarde", "tool.convert.converting": "A converter páginas...", "tool.ocr.no_langs": "Sem idiomas OCR disponíveis. Instala os pacotes de idioma do Tesseract.", "edit.status.note_label": "Nota — p. {n}", @@ -1765,6 +1773,10 @@ "tool.watermark.empty_wm": "El PDF de marca de agua no tiene páginas.", "tool.watermark.empty_source": "El PDF de origen no tiene páginas.", "update.dialog_title": "Actualización", + "update.store.title": "Actualización disponible en Microsoft Store", + "update.store.message": "PDFApps {version} está disponible. Abre Microsoft Store para actualizar.", + "update.store.btn.open": "Abrir Microsoft Store", + "update.store.btn.later": "Más tarde", "tool.convert.converting": "Convirtiendo páginas...", "tool.ocr.no_langs": "No hay idiomas OCR disponibles. Instala los paquetes de idioma de Tesseract.", "edit.status.note_label": "Nota — p. {n}", @@ -2372,6 +2384,10 @@ "tool.watermark.empty_wm": "Le PDF de filigrane n’a aucune page.", "tool.watermark.empty_source": "Le PDF source n’a aucune page.", "update.dialog_title": "Mise à jour", + "update.store.title": "Mise à jour disponible sur Microsoft Store", + "update.store.message": "PDFApps {version} est disponible. Ouvrez le Microsoft Store pour mettre à jour.", + "update.store.btn.open": "Ouvrir Microsoft Store", + "update.store.btn.later": "Plus tard", "tool.convert.converting": "Conversion des pages...", "tool.ocr.no_langs": "Aucune langue OCR disponible. Installez les packs de langue Tesseract.", "edit.status.note_label": "Note — p. {n}", @@ -2979,6 +2995,10 @@ "tool.watermark.empty_wm": "Das Wasserzeichen-PDF hat keine Seiten.", "tool.watermark.empty_source": "Das Quell-PDF hat keine Seiten.", "update.dialog_title": "Aktualisierung", + "update.store.title": "Aktualisierung im Microsoft Store verfügbar", + "update.store.message": "PDFApps {version} ist verfügbar. Öffnen Sie den Microsoft Store zum Aktualisieren.", + "update.store.btn.open": "Microsoft Store öffnen", + "update.store.btn.later": "Später", "tool.convert.converting": "Seiten werden konvertiert...", "tool.ocr.no_langs": "Keine OCR-Sprachen verfügbar. Installieren Sie Tesseract-Sprachpakete.", "edit.status.note_label": "Notiz — S. {n}", @@ -3586,6 +3606,10 @@ "tool.watermark.empty_wm": "水印 PDF 没有页面。", "tool.watermark.empty_source": "源 PDF 没有页面。", "update.dialog_title": "更新", + "update.store.title": "Microsoft Store 上有可用更新", + "update.store.message": "PDFApps {version} 已可用。打开 Microsoft Store 进行更新。", + "update.store.btn.open": "打开 Microsoft Store", + "update.store.btn.later": "稍后", "tool.convert.converting": "正在转换页面...", "tool.ocr.no_langs": "没有可用的 OCR 语言。请安装 Tesseract 语言包。", "edit.status.note_label": "便签 — 第{n}页", @@ -4193,6 +4217,10 @@ "tool.watermark.empty_wm": "Il PDF filigrana non ha pagine.", "tool.watermark.empty_source": "Il PDF sorgente non ha pagine.", "update.dialog_title": "Aggiornamento", + "update.store.title": "Aggiornamento disponibile su Microsoft Store", + "update.store.message": "PDFApps {version} è disponibile. Apri Microsoft Store per aggiornare.", + "update.store.btn.open": "Apri Microsoft Store", + "update.store.btn.later": "Più tardi", "tool.convert.converting": "Conversione delle pagine...", "tool.ocr.no_langs": "Nessuna lingua OCR disponibile. Installa i pacchetti lingua di Tesseract.", "edit.status.note_label": "Nota — p. {n}", @@ -4800,6 +4828,10 @@ "tool.watermark.empty_wm": "Het watermerk-PDF heeft geen pagina’s.", "tool.watermark.empty_source": "Het bron-PDF heeft geen pagina’s.", "update.dialog_title": "Update", + "update.store.title": "Update beschikbaar in Microsoft Store", + "update.store.message": "PDFApps {version} is beschikbaar. Open de Microsoft Store om bij te werken.", + "update.store.btn.open": "Microsoft Store openen", + "update.store.btn.later": "Later", "tool.convert.converting": "Pagina's converteren...", "tool.ocr.no_langs": "Geen OCR-talen beschikbaar. Installeer Tesseract taalpakketten.", "edit.status.note_label": "Notitie — p. {n}", From 6204f85e840674eee24e43cfa55e6d2e2ae6faac Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 28 Jun 2026 13:31:41 +0100 Subject: [PATCH 5/6] test(store_updater): coverage for MSIX detection + Store API + branching 27 new tests cover MSIX install detection (env var + path), version parsing, DisplayCatalog response parsing (highest version wins, trailing .0 stripped), network/parse failure paths, has-update vs same vs older comparisons, deep-link format, source-level guards that app/updater.py + app/window.py branch on the MSIX payload, and i18n parity for the four new update.store.* keys across all eight languages. --- tests/test_store_updater.py | 337 ++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 tests/test_store_updater.py diff --git a/tests/test_store_updater.py b/tests/test_store_updater.py new file mode 100644 index 0000000..d762153 --- /dev/null +++ b/tests/test_store_updater.py @@ -0,0 +1,337 @@ +"""Tests for Microsoft Store update notification (app/store_updater.py). + +Covers: + * MSIX install detection — env var, WindowsApps path, non-Windows. + * Version parsing (numeric + non-numeric suffix tolerance). + * Store DisplayCatalog API response parsing (highest version wins, + trailing .0 stripped). + * Graceful network / parse failure handling (returns None, never raises). + * check_for_store_update branching (newer / same / older / error). + * Store deep-link format. + * Source-level guards that app/updater.py + app/window.py branch on + the new MSIX path. +""" +import io +import json +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +ROOT = Path(__file__).resolve().parent.parent + + +# ── is_msix_install ─────────────────────────────────────────────────── + + +def test_is_msix_install_false_on_non_windows(): + from app.store_updater import is_msix_install + with patch.object(sys, "platform", "linux"): + assert is_msix_install() is False + + +def test_is_msix_install_false_on_darwin(): + from app.store_updater import is_msix_install + with patch.object(sys, "platform", "darwin"): + assert is_msix_install() is False + + +def test_is_msix_install_detects_package_full_name_env(): + from app.store_updater import is_msix_install + with patch.object(sys, "platform", "win32"), \ + patch.dict(os.environ, {"PACKAGE_FULL_NAME": "PDFApps_1.13.16.0_x64..."}, + clear=False), \ + patch.object(sys, "executable", r"C:\\some\\other\\path\\app.exe"): + # Ensure env wins even when path does not match + assert is_msix_install() is True + + +def test_is_msix_install_detects_windowsapps_path(): + from app.store_updater import is_msix_install + fake_env = {k: v for k, v in os.environ.items() if k != "PACKAGE_FULL_NAME"} + with patch.object(sys, "platform", "win32"), \ + patch.dict(os.environ, fake_env, clear=True), \ + patch.object(sys, "executable", + r"C:\\Program Files\\WindowsApps\\PDFApps_1.13.16.0_x64\\app.exe"), \ + patch("os.path.realpath", side_effect=lambda p: p): + assert is_msix_install() is True + + +def test_is_msix_install_false_for_regular_nsis_install(): + from app.store_updater import is_msix_install + fake_env = {k: v for k, v in os.environ.items() if k != "PACKAGE_FULL_NAME"} + with patch.object(sys, "platform", "win32"), \ + patch.dict(os.environ, fake_env, clear=True), \ + patch.object(sys, "executable", + r"C:\\Users\\nelso\\AppData\\Local\\Programs\\PDFApps\\PDFApps.exe"), \ + patch("os.path.realpath", side_effect=lambda p: p): + assert is_msix_install() is False + + +# ── _parse_version ──────────────────────────────────────────────────── + + +def test_parse_version_three_part(): + from app.store_updater import _parse_version + assert _parse_version("1.13.16") == (1, 13, 16) + + +def test_parse_version_four_part(): + from app.store_updater import _parse_version + assert _parse_version("1.13.16.0") == (1, 13, 16, 0) + + +def test_parse_version_non_numeric_coerced_to_zero(): + from app.store_updater import _parse_version + assert _parse_version("1.13.16-beta") == (1, 13, 0) + + +def test_parse_version_empty_returns_zero_tuple(): + from app.store_updater import _parse_version + assert _parse_version("") == (0,) + + +# ── get_store_version ───────────────────────────────────────────────── + + +def _mock_urlopen_response(payload: dict): + """Build a urllib.request.urlopen context manager returning the JSON payload.""" + body = json.dumps(payload).encode("utf-8") + cm = MagicMock() + resp = MagicMock() + resp.read.return_value = body + cm.__enter__.return_value = resp + cm.__exit__.return_value = None + return cm + + +def test_get_store_version_parses_highest_version(): + from app import store_updater + payload = { + "Products": [{ + "DisplaySkuAvailabilities": [{ + "Sku": { + "Properties": { + "Packages": [ + {"Version": "1.13.15.0"}, + {"Version": "1.13.16.0"}, # highest + {"Version": "1.12.0.0"}, + ] + } + } + }] + }] + } + with patch.object(store_updater.urllib.request, "urlopen", + return_value=_mock_urlopen_response(payload)): + assert store_updater.get_store_version() == "1.13.16" + + +def test_get_store_version_strips_trailing_zero(): + from app import store_updater + payload = {"Products": [{"DisplaySkuAvailabilities": [{"Sku": {"Properties": { + "Packages": [{"Version": "2.0.0.0"}] + }}}]}]} + with patch.object(store_updater.urllib.request, "urlopen", + return_value=_mock_urlopen_response(payload)): + # 2.0.0.0 -> drop trailing .0 once -> 2.0.0 (len>3 condition stops at 3) + assert store_updater.get_store_version() == "2.0.0" + + +def test_get_store_version_returns_none_on_network_failure(): + from app import store_updater + with patch.object(store_updater.urllib.request, "urlopen", + side_effect=Exception("network")): + assert store_updater.get_store_version() is None + + +def test_get_store_version_returns_none_on_empty_products(): + from app import store_updater + with patch.object(store_updater.urllib.request, "urlopen", + return_value=_mock_urlopen_response({"Products": []})): + assert store_updater.get_store_version() is None + + +def test_get_store_version_returns_none_on_invalid_json(): + from app import store_updater + cm = MagicMock() + resp = MagicMock() + resp.read.return_value = b"not json" + cm.__enter__.return_value = resp + cm.__exit__.return_value = None + with patch.object(store_updater.urllib.request, "urlopen", return_value=cm): + assert store_updater.get_store_version() is None + + +def test_get_store_version_returns_none_when_no_packages(): + from app import store_updater + payload = {"Products": [{"DisplaySkuAvailabilities": [{"Sku": {"Properties": { + "Packages": [] + }}}]}]} + with patch.object(store_updater.urllib.request, "urlopen", + return_value=_mock_urlopen_response(payload)): + assert store_updater.get_store_version() is None + + +# ── check_for_store_update ──────────────────────────────────────────── + + +def test_check_for_store_update_detects_newer(): + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value="99.0.0"): + has_update, latest = store_updater.check_for_store_update() + assert has_update is True + assert latest == "99.0.0" + + +def test_check_for_store_update_returns_false_when_same_version(): + from app import store_updater + from app.constants import APP_VERSION + with patch.object(store_updater, "get_store_version", return_value=APP_VERSION): + has_update, latest = store_updater.check_for_store_update() + assert has_update is False + assert latest is None + + +def test_check_for_store_update_returns_false_when_older_version(): + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value="0.0.1"): + has_update, latest = store_updater.check_for_store_update() + assert has_update is False + assert latest is None + + +def test_check_for_store_update_returns_false_on_lookup_failure(): + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value=None): + has_update, latest = store_updater.check_for_store_update() + assert has_update is False + assert latest is None + + +# ── store_deep_link ──────────────────────────────────────────────────── + + +def test_store_deep_link_format(): + from app.store_updater import store_deep_link, STORE_PRODUCT_ID + link = store_deep_link() + assert link.startswith("ms-windows-store://pdp/?productid=") + assert STORE_PRODUCT_ID in link + assert STORE_PRODUCT_ID == "9P70QGR8BSMZ" + + +# ── updater.py branching ────────────────────────────────────────────── + + +def test_updater_imports_store_updater_helpers(): + """check_for_update must consult the MSIX branch BEFORE the GitHub API call.""" + src = (ROOT / "app" / "updater.py").read_text(encoding="utf-8") + assert "is_msix_install" in src + assert "check_for_store_update" in src + assert "store_deep_link" in src + assert '"msix": True' in src + + +def test_updater_no_longer_skips_msix_in_is_system_install(): + """Regression: is_system_install previously short-circuited MSIX so the + user never got a notification. Now it returns False on Windows and the + MSIX path is handled in check_for_update via store_updater.""" + from app.updater import is_system_install + with patch.object(sys, "platform", "win32"), \ + patch.object(sys, "executable", + r"C:\\Program Files\\WindowsApps\\PDFApps_1.13.16.0_x64\\app.exe"), \ + patch("os.path.realpath", side_effect=lambda p: p): + assert is_system_install() is False + + +def test_updater_check_for_update_returns_msix_dict_when_store_has_update(): + from app import updater + with patch.object(updater, "is_system_install", return_value=False): + with patch("app.store_updater.is_msix_install", return_value=True), \ + patch("app.store_updater.check_for_store_update", + return_value=(True, "99.0.0")): + result = updater.check_for_update() + assert result is not None + assert result.get("msix") is True + assert result.get("latest_version") == "99.0.0" + assert result.get("deep_link", "").startswith("ms-windows-store://") + + +def test_updater_check_for_update_returns_none_when_msix_up_to_date(): + from app import updater + with patch.object(updater, "is_system_install", return_value=False): + with patch("app.store_updater.is_msix_install", return_value=True), \ + patch("app.store_updater.check_for_store_update", + return_value=(False, None)): + result = updater.check_for_update() + assert result is None + + +# ── window.py branching ─────────────────────────────────────────────── + + +def test_window_has_store_notify_handler(): + src = (ROOT / "app" / "window.py").read_text(encoding="utf-8") + # New handler exists + assert "_notify_store_update" in src + # Branches on the new MSIX payload shape + assert '"msix"' in src + # Uses Store deep-link key + assert "deep_link" in src + # Uses QDesktopServices to open the Store URI + assert "QDesktopServices" in src + + +def test_window_no_longer_early_returns_on_windowsapps(): + """The MSIX skip in _check_for_updates_async must be gone — MSIX now + flows through the regular check_for_update path and gets a Store + notification dialog.""" + src = (ROOT / "app" / "window.py").read_text(encoding="utf-8") + # Walk the _check_for_updates_async method body and assert it no + # longer contains the WindowsApps early-return guard. + start = src.index("def _check_for_updates_async") + end = src.index("def _on_update_found", start) + method_body = src[start:end] + assert "WindowsApps" not in method_body, ( + "MSIX must no longer early-return in _check_for_updates_async; " + "it should flow through check_for_update so the user sees the " + "Microsoft Store notification dialog." + ) + + +# ── i18n parity ─────────────────────────────────────────────────────── + + +def test_store_i18n_keys_parity_across_all_languages(): + data = json.loads((ROOT / "app" / "translations.json").read_text(encoding="utf-8")) + # Walk to find the language root (dict keyed by 2-letter ISO codes) + + def find_langs(obj): + if isinstance(obj, dict): + if "en" in obj and "pt" in obj and isinstance(obj["en"], dict): + return obj + for v in obj.values(): + r = find_langs(v) + if r: + return r + return None + + langs = find_langs(data) + assert langs is not None, "could not locate language root in translations.json" + + required = { + "update.store.title", + "update.store.message", + "update.store.btn.open", + "update.store.btn.later", + } + for lang_code, entries in langs.items(): + missing = required - set(entries.keys()) + assert not missing, f"{lang_code} missing store keys: {sorted(missing)}" + # Message must accept the {version} placeholder + assert "{version}" in entries["update.store.message"], \ + f"{lang_code} update.store.message must contain {{version}}" From 0bd62d744bfff4cc7ab89166519837f2a2640c01 Mon Sep 17 00:00:00 2001 From: nelsoduarte Date: Sun, 28 Jun 2026 13:47:35 +0100 Subject: [PATCH 6/6] fix(store_updater): persistent dismiss + defensive version compare + polish Round 11 review caught a notification-spam regression: the Store update dialog fires on every startup until the user upgrades, which in MSIX (where Store propagation lags 1-7 days behind our releases) means users see the same dialog daily. Changes: - New dismissed_store_version persistent flag in config.json managed via get_dismissed_store_version / set_dismissed_store_version in app/i18n.py. - check_for_store_update returns (False, None) if user already dismissed the current latest (or newer). - _notify_store_update now offers 3 buttons: Open Store (opens + dismisses), Later (re-show), Don't show again for this version (persistent dismiss). Docstring corrected (was claimed non-blocking). - New i18n key update.store.btn.dismiss x 8 languages. - Defensive version compare: pad both sides to 4-tuple to avoid the latent tuple-length asymmetry bug (1.13.16.0 > 1.13.16). - Removed unused 'import io' and 'import pytest' from tests. - Documented PACKAGE_FULL_NAME detection as best-effort fallback. - Added _log.debug of raw response snippet on parse failure. Co-Authored-By: Claude Opus 4.7 --- app/i18n.py | 47 +++++++++++++++ app/store_updater.py | 59 +++++++++++++++---- app/translations.json | 8 +++ app/window.py | 59 +++++++++++++++---- tests/test_store_updater.py | 114 ++++++++++++++++++++++++++++++++++-- 5 files changed, 260 insertions(+), 27 deletions(-) diff --git a/app/i18n.py b/app/i18n.py index 4ded09c..e669e5a 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -198,6 +198,53 @@ def _save_config_language(lang: str): _update_config(lambda cfg: cfg.__setitem__("language", lang)) +# ── Store-update dismiss persistence ────────────────────────────────────── +# +# Without a persistent dismissal flag, the MSIX "update available on +# Microsoft Store" dialog fires on every launch until the user installs +# the new MSIX. Because Store propagation lags 1-7 days behind our +# GitHub release, users would see the same dialog daily — by design +# noisy. We persist the last version the user explicitly dismissed so +# `check_for_store_update` can suppress further nags until a NEWER +# version is published. + +def get_dismissed_store_version() -> str | None: + """Return the Store version the user last dismissed, or ``None``. + + A returned value means: "the user already saw and dismissed this + version; do not nag again unless the Store publishes something + newer". Stored under the ``dismissed_store_version`` key in + config.json. + """ + try: + with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = json.load(f) + except Exception: + return None + if not isinstance(cfg, dict): + return None + val = cfg.get("dismissed_store_version") + if isinstance(val, str) and val: + return val + return None + + +def set_dismissed_store_version(version: str | None) -> None: + """Persist the Store version the user dismissed. + + Pass a version string (e.g. ``"1.13.17"``) to record the dismiss. + Pass ``None`` to clear the flag (so the next available-update + notification is shown unconditionally). + """ + def _mutate(cfg: dict) -> None: + if version: + cfg["dismissed_store_version"] = version + else: + cfg.pop("dismissed_store_version", None) + + _update_config(_mutate) + + def init(): """Initialize i18n: load translations and set language.""" global _LANG diff --git a/app/store_updater.py b/app/store_updater.py index 79f0d1b..f29446f 100644 --- a/app/store_updater.py +++ b/app/store_updater.py @@ -51,14 +51,19 @@ def is_msix_install() -> bool: """Detect if PDFApps is running inside a Windows App Package (MSIX/AppX). Returns True for MSIX installs (Microsoft Store), False for NSIS - installer / portable .exe / non-Windows platforms. Two indicators - are checked because either alone has edge cases: - - * ``PACKAGE_FULL_NAME`` is set by the Windows runtime for packaged - apps but can be unset when tooling launches the .exe outside the - AppContainer. - * The ``\\WindowsApps\\`` path prefix is reliable because MSIX - packages are always extracted to ``C:\\Program Files\\WindowsApps``. + installer / portable .exe / non-Windows platforms. + + Two detection methods are checked: + + 1. ``PACKAGE_FULL_NAME`` env var — best-effort fallback. Not + officially documented by Microsoft as guaranteed, but reported + by some packaged runtimes / tooling. May not fire in every + MSIX launch context, so we never rely on it alone. + 2. Executable path under ``\\WindowsApps\\`` — reliable, because + MSIX packages are always extracted to + ``C:\\Program Files\\WindowsApps`` by the Windows package + installer (immutable convention). This is the load-bearing + detection in practice. """ if sys.platform != "win32": return False @@ -123,6 +128,14 @@ def get_store_version() -> Optional[str]: versions.append(_parse_version(ver)) except Exception as exc: _log.warning("Store API response parse failed: %s", exc) + # Snippet of the raw decoded payload for diagnosis if Microsoft + # changes the DisplayCatalog schema. Limited to 500 chars so a + # huge response doesn't flood logs, and wrapped in try/except + # because data may be unrepresentable. + try: + _log.debug("Raw response prefix: %s", str(data)[:500]) + except Exception: + pass return None if not versions: @@ -142,16 +155,38 @@ def get_store_version() -> Optional[str]: def check_for_store_update() -> Tuple[bool, Optional[str]]: """Returns ``(has_update, latest_version_str)``. - ``(False, None)`` is returned on error or when the Store reports the - same/older version than ``APP_VERSION``. Only meaningful when + ``(False, None)`` is returned on error, when the Store reports the + same/older version than ``APP_VERSION``, or when the user has + already dismissed a notification for this (or a newer) version via + :func:`app.i18n.set_dismissed_store_version`. Only meaningful when :func:`is_msix_install` is True; callers should guard. """ latest = get_store_version() if not latest: return False, None - current = _parse_version(APP_VERSION) - latest_tuple = _parse_version(latest) + # Skip if user already dismissed THIS specific version (or newer): + # without this guard the MSIX update dialog fires on every launch + # until the user installs the new package, which because Store + # propagation lags 1-7 days behind our release means daily nags. + try: + from app.i18n import get_dismissed_store_version + dismissed = get_dismissed_store_version() + except Exception: + dismissed = None + if dismissed: + dismissed_tuple = _parse_version(dismissed) + (0, 0, 0, 0) + latest_for_dismiss = _parse_version(latest) + (0, 0, 0, 0) + if dismissed_tuple[:4] >= latest_for_dismiss[:4]: + return False, None + + # Defensive version compare: pad both sides to a 4-tuple before + # comparing. _parse_version("1.13.16") -> (1, 13, 16) but the Store + # often reports 4-part versions like "1.13.16.0". Without padding, + # (1, 13, 16, 0) > (1, 13, 16) is True in Python (longer tuple wins + # on prefix tie) and we'd report a phantom update. + current = (_parse_version(APP_VERSION) + (0, 0, 0, 0))[:4] + latest_tuple = (_parse_version(latest) + (0, 0, 0, 0))[:4] has_update = latest_tuple > current return has_update, latest if has_update else None diff --git a/app/translations.json b/app/translations.json index 557fdcb..1e3b071 100644 --- a/app/translations.json +++ b/app/translations.json @@ -555,6 +555,7 @@ "update.store.message": "PDFApps {version} is available. Open the Microsoft Store to update.", "update.store.btn.open": "Open Microsoft Store", "update.store.btn.later": "Later", + "update.store.btn.dismiss": "Don't show again for this version", "tool.convert.converting": "Converting pages...", "tool.ocr.no_langs": "No OCR languages available. Install Tesseract language packs.", "edit.status.note_label": "Note — p. {n}", @@ -1166,6 +1167,7 @@ "update.store.message": "PDFApps {version} está disponível. Abra a Microsoft Store para atualizar.", "update.store.btn.open": "Abrir Microsoft Store", "update.store.btn.later": "Mais tarde", + "update.store.btn.dismiss": "Não mostrar de novo para esta versão", "tool.convert.converting": "A converter páginas...", "tool.ocr.no_langs": "Sem idiomas OCR disponíveis. Instala os pacotes de idioma do Tesseract.", "edit.status.note_label": "Nota — p. {n}", @@ -1777,6 +1779,7 @@ "update.store.message": "PDFApps {version} está disponible. Abre Microsoft Store para actualizar.", "update.store.btn.open": "Abrir Microsoft Store", "update.store.btn.later": "Más tarde", + "update.store.btn.dismiss": "No mostrar más para esta versión", "tool.convert.converting": "Convirtiendo páginas...", "tool.ocr.no_langs": "No hay idiomas OCR disponibles. Instala los paquetes de idioma de Tesseract.", "edit.status.note_label": "Nota — p. {n}", @@ -2388,6 +2391,7 @@ "update.store.message": "PDFApps {version} est disponible. Ouvrez le Microsoft Store pour mettre à jour.", "update.store.btn.open": "Ouvrir Microsoft Store", "update.store.btn.later": "Plus tard", + "update.store.btn.dismiss": "Ne plus afficher pour cette version", "tool.convert.converting": "Conversion des pages...", "tool.ocr.no_langs": "Aucune langue OCR disponible. Installez les packs de langue Tesseract.", "edit.status.note_label": "Note — p. {n}", @@ -2999,6 +3003,7 @@ "update.store.message": "PDFApps {version} ist verfügbar. Öffnen Sie den Microsoft Store zum Aktualisieren.", "update.store.btn.open": "Microsoft Store öffnen", "update.store.btn.later": "Später", + "update.store.btn.dismiss": "Für diese Version nicht mehr anzeigen", "tool.convert.converting": "Seiten werden konvertiert...", "tool.ocr.no_langs": "Keine OCR-Sprachen verfügbar. Installieren Sie Tesseract-Sprachpakete.", "edit.status.note_label": "Notiz — S. {n}", @@ -3610,6 +3615,7 @@ "update.store.message": "PDFApps {version} 已可用。打开 Microsoft Store 进行更新。", "update.store.btn.open": "打开 Microsoft Store", "update.store.btn.later": "稍后", + "update.store.btn.dismiss": "此版本不再显示", "tool.convert.converting": "正在转换页面...", "tool.ocr.no_langs": "没有可用的 OCR 语言。请安装 Tesseract 语言包。", "edit.status.note_label": "便签 — 第{n}页", @@ -4221,6 +4227,7 @@ "update.store.message": "PDFApps {version} è disponibile. Apri Microsoft Store per aggiornare.", "update.store.btn.open": "Apri Microsoft Store", "update.store.btn.later": "Più tardi", + "update.store.btn.dismiss": "Non mostrare più per questa versione", "tool.convert.converting": "Conversione delle pagine...", "tool.ocr.no_langs": "Nessuna lingua OCR disponibile. Installa i pacchetti lingua di Tesseract.", "edit.status.note_label": "Nota — p. {n}", @@ -4832,6 +4839,7 @@ "update.store.message": "PDFApps {version} is beschikbaar. Open de Microsoft Store om bij te werken.", "update.store.btn.open": "Microsoft Store openen", "update.store.btn.later": "Later", + "update.store.btn.dismiss": "Niet meer tonen voor deze versie", "tool.convert.converting": "Pagina's converteren...", "tool.ocr.no_langs": "Geen OCR-talen beschikbaar. Installeer Tesseract taalpakketten.", "edit.status.note_label": "Notitie — p. {n}", diff --git a/app/window.py b/app/window.py index 30d179d..8d2710c 100644 --- a/app/window.py +++ b/app/window.py @@ -1575,28 +1575,65 @@ def _notify_update(self): self._show_update_dialog() def _notify_store_update(self, latest: str, deep_link: str): - """Show a non-blocking dialog directing MSIX users to the Microsoft Store. + """Modal dialog directing the user to the Microsoft Store listing. + + Three options: + + * **Open Store** — launch the Store deep-link AND persist + ``latest`` as dismissed (the user took action; no point + nagging again until a newer version ships). + * **Later** — close without persisting; the dialog reappears + on the next launch. + * **Don't show again for this version** — persist ``latest`` + as dismissed so :func:`app.store_updater.check_for_store_update` + suppresses the dialog until the Store publishes something + newer. Used when :func:`app.updater.check_for_update` returns the special ``{"msix": True, ...}`` payload — see the rationale in :func:`_check_for_updates_async`. """ from PySide6.QtWidgets import QMessageBox + from app.i18n import set_dismissed_store_version box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Information) box.setWindowTitle(t("update.store.title")) box.setText(t("update.store.message").format(version=latest)) - box.setStandardButtons( - QMessageBox.StandardButton.Open - | QMessageBox.StandardButton.Cancel + # Custom 3-button layout: addButton + roles, NOT setStandardButtons, + # so we can have an explicit "don't show again" path distinct + # from "later". + open_btn = box.addButton( + t("update.store.btn.open"), + QMessageBox.ButtonRole.AcceptRole, + ) + later_btn = box.addButton( + t("update.store.btn.later"), + QMessageBox.ButtonRole.RejectRole, + ) + dismiss_btn = box.addButton( + t("update.store.btn.dismiss"), + QMessageBox.ButtonRole.ActionRole, ) - box.button(QMessageBox.StandardButton.Open).setText(t("update.store.btn.open")) - box.button(QMessageBox.StandardButton.Cancel).setText(t("update.store.btn.later")) - box.setDefaultButton(QMessageBox.StandardButton.Open) - if box.exec() == QMessageBox.StandardButton.Open and deep_link: - from PySide6.QtCore import QUrl - from PySide6.QtGui import QDesktopServices - QDesktopServices.openUrl(QUrl(deep_link)) + box.setDefaultButton(open_btn) + box.exec() + clicked = box.clickedButton() + if clicked is open_btn: + if deep_link: + from PySide6.QtCore import QUrl + from PySide6.QtGui import QDesktopServices + QDesktopServices.openUrl(QUrl(deep_link)) + # The user acted on this version — persist the dismiss too + # so we don't re-show the dialog before Store finishes + # propagating the new MSIX (which can lag 1-7 days). + with contextlib.suppress(Exception): + if latest: + set_dismissed_store_version(latest) + elif clicked is dismiss_btn: + with contextlib.suppress(Exception): + if latest: + set_dismissed_store_version(latest) + # "Later" (later_btn) and the close-box: do nothing; dialog + # will reappear on the next startup. def _show_update_dialog(self): if self._update_release: diff --git a/tests/test_store_updater.py b/tests/test_store_updater.py index d762153..442398d 100644 --- a/tests/test_store_updater.py +++ b/tests/test_store_updater.py @@ -6,20 +6,19 @@ * Store DisplayCatalog API response parsing (highest version wins, trailing .0 stripped). * Graceful network / parse failure handling (returns None, never raises). - * check_for_store_update branching (newer / same / older / error). + * check_for_store_update branching (newer / same / older / error, + dismissed-version suppression). * Store deep-link format. * Source-level guards that app/updater.py + app/window.py branch on the new MSIX path. + * Persistent dismiss helpers in app/i18n.py. """ -import io import json import os import sys from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) ROOT = Path(__file__).resolve().parent.parent @@ -213,6 +212,95 @@ def test_check_for_store_update_returns_false_on_lookup_failure(): assert latest is None +# ── check_for_store_update + dismissed-version persistence ──────────── + + +def test_check_for_update_skips_if_dismissed_same_version(): + """Dismissed version == Store latest -> no update (suppress nag).""" + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value="99.0.0"), \ + patch("app.i18n.get_dismissed_store_version", return_value="99.0.0"): + has_update, latest = store_updater.check_for_store_update() + assert has_update is False + assert latest is None + + +def test_check_for_update_skips_if_dismissed_newer_than_store(): + """User somehow dismissed a future version > Store -> suppress.""" + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value="99.0.0"), \ + patch("app.i18n.get_dismissed_store_version", return_value="99.0.1"): + has_update, latest = store_updater.check_for_store_update() + assert has_update is False + assert latest is None + + +def test_check_for_update_shows_if_store_newer_than_dismissed(): + """Store published a new version after the dismiss -> notify again.""" + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value="99.0.1"), \ + patch("app.i18n.get_dismissed_store_version", return_value="99.0.0"): + has_update, latest = store_updater.check_for_store_update() + assert has_update is True + assert latest == "99.0.1" + + +def test_check_for_update_shows_if_no_dismissed(): + """Nothing dismissed yet -> standard newer-than-current behaviour.""" + from app import store_updater + with patch.object(store_updater, "get_store_version", return_value="99.0.0"), \ + patch("app.i18n.get_dismissed_store_version", return_value=None): + has_update, latest = store_updater.check_for_store_update() + assert has_update is True + assert latest == "99.0.0" + + +def test_check_for_update_defensive_pad_avoids_phantom_update(): + """Defensive 4-tuple padding: '1.13.16.0' from Store must not look + 'newer' than APP_VERSION '1.13.16' just because the tuple is + longer (Python (1,13,16,0) > (1,13,16) without padding).""" + from app import store_updater + # Force APP_VERSION-shaped current ("X.Y.Z") and Store-shaped latest + # ("X.Y.Z.0") to confirm the padded comparison treats them as equal. + with patch.object(store_updater, "APP_VERSION", "1.13.16"), \ + patch.object(store_updater, "get_store_version", return_value="1.13.16.0"), \ + patch("app.i18n.get_dismissed_store_version", return_value=None): + has_update, latest = store_updater.check_for_store_update() + assert has_update is False + assert latest is None + + +# ── i18n dismiss helpers ────────────────────────────────────────────── + + +def test_dismissed_store_version_helpers_exist(): + """get/set helpers must exist in app/i18n.py.""" + src = (ROOT / "app" / "i18n.py").read_text(encoding="utf-8") + assert "def get_dismissed_store_version" in src + assert "def set_dismissed_store_version" in src + assert "dismissed_store_version" in src # the config key + + +def test_dismissed_store_version_round_trip(tmp_path, monkeypatch): + """set -> get must round-trip the value through config.json, and + passing None must clear it. Uses an isolated tmp config path so the + user's real config.json is untouched.""" + from app import i18n + cfg = tmp_path / "config.json" + monkeypatch.setattr(i18n, "_CONFIG_PATH", str(cfg)) + # Initially nothing + assert i18n.get_dismissed_store_version() is None + # Set + read back + i18n.set_dismissed_store_version("1.13.17") + assert i18n.get_dismissed_store_version() == "1.13.17" + # Overwrite with newer + i18n.set_dismissed_store_version("1.13.18") + assert i18n.get_dismissed_store_version() == "1.13.18" + # Clear with None + i18n.set_dismissed_store_version(None) + assert i18n.get_dismissed_store_version() is None + + # ── store_deep_link ──────────────────────────────────────────────────── @@ -286,6 +374,23 @@ def test_window_has_store_notify_handler(): assert "QDesktopServices" in src +def test_window_dialog_uses_three_buttons_with_persistent_dismiss(): + """_notify_store_update must offer the new dismiss button and + persist the dismiss via app.i18n.set_dismissed_store_version, + otherwise the notification spams every startup until upgrade.""" + src = (ROOT / "app" / "window.py").read_text(encoding="utf-8") + start = src.index("def _notify_store_update") + # Bound the slice to the next def to avoid matching unrelated code. + end = src.index("\n def ", start + 1) + body = src[start:end] + assert "update.store.btn.dismiss" in body, "dismiss button missing" + assert "set_dismissed_store_version" in body, \ + "dismiss must be persisted via set_dismissed_store_version" + # Open + dismiss paths both record the dismiss; Later does not. + assert "clickedButton" in body, \ + "must distinguish Open/Later/Dismiss via clickedButton()" + + def test_window_no_longer_early_returns_on_windowsapps(): """The MSIX skip in _check_for_updates_async must be gone — MSIX now flows through the regular check_for_update path and gets a Store @@ -328,6 +433,7 @@ def find_langs(obj): "update.store.message", "update.store.btn.open", "update.store.btn.later", + "update.store.btn.dismiss", } for lang_code, entries in langs.items(): missing = required - set(entries.keys())