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 new file mode 100644 index 0000000..f29446f --- /dev/null +++ b/app/store_updater.py @@ -0,0 +1,197 @@ +"""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 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 + # 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) + # 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: + _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, 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 + + # 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 + + +def store_deep_link() -> str: + """Returns the ``ms-windows-store://`` URI to open the listing in the Store app.""" + return _STORE_DEEP_LINK diff --git a/app/translations.json b/app/translations.json index 0f32418..1e3b071 100644 --- a/app/translations.json +++ b/app/translations.json @@ -551,6 +551,11 @@ "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", + "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}", @@ -1158,6 +1163,11 @@ "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", + "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}", @@ -1765,6 +1775,11 @@ "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", + "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}", @@ -2372,6 +2387,11 @@ "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", + "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}", @@ -2979,6 +2999,11 @@ "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", + "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}", @@ -3586,6 +3611,11 @@ "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": "稍后", + "update.store.btn.dismiss": "此版本不再显示", "tool.convert.converting": "正在转换页面...", "tool.ocr.no_langs": "没有可用的 OCR 语言。请安装 Tesseract 语言包。", "edit.status.note_label": "便签 — 第{n}页", @@ -4193,6 +4223,11 @@ "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", + "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}", @@ -4800,6 +4835,11 @@ "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", + "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/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: diff --git a/app/window.py b/app/window.py index 870afd5..8d2710c 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,78 @@ def _notify_update(self): if reply == QMessageBox.StandardButton.Yes: self._show_update_dialog() + def _notify_store_update(self, latest: str, deep_link: str): + """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)) + # 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.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: + # 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() diff --git a/tests/test_store_updater.py b/tests/test_store_updater.py new file mode 100644 index 0000000..442398d --- /dev/null +++ b/tests/test_store_updater.py @@ -0,0 +1,443 @@ +"""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, + 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 json +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +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 + + +# ── 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 ──────────────────────────────────────────────────── + + +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_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 + 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", + "update.store.btn.dismiss", + } + 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}}"