Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
197 changes: 197 additions & 0 deletions app/store_updater.py
Original file line number Diff line number Diff line change
@@ -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:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
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
40 changes: 40 additions & 0 deletions app/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}页",
Expand Down Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down
Loading