diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index f345fd3..079c409 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -31,21 +31,20 @@ jobs: - name: Inject version shell: bash run: | - # Version priority: - # 1. Manual input from workflow_dispatch - # 2. Git tag (tagged push) - # 3. Branch name (non-main branches = test build) - # 4. Fallback + # Version precedence: + # 1. Explicit workflow/build version (workflow_dispatch input) + # 2. Exact Git tag (tagged push) + # 3. Authoritative source version from main.py + "-test" marker + # Ordinary branch builds must never regress the embedded version to + # an older Git tag or 0.0.0-dev. if [[ -n "${{ github.event.inputs.version_override }}" ]]; then VERSION="${{ github.event.inputs.version_override }}" elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then VERSION="${GITHUB_REF_NAME#v}" - elif [[ "${GITHUB_REF_NAME}" != "main" ]]; then - BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - VERSION="${BRANCH//\//-}-test" else - VERSION="0.0.0-dev" + SOURCE_VERSION="$(python scripts/inject_version.py --source-version)" + VERSION="${SOURCE_VERSION}-test" fi echo "VERSION=$VERSION" >> "$GITHUB_ENV" python scripts/inject_version.py "$VERSION" diff --git a/.gitignore b/.gitignore index b454b24..96ceb19 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ OpenReader-Test-Cert.cer .env .env.* !.env.example +test-builds/ *.log *.pem *.key diff --git a/AGENTS.md b/AGENTS.md index 95c3722..38097ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This repository is a local-first desktop PDF utility. Keep maintenance changes b ## Product Boundaries -**Current version:** v1.2.5 (2026-07-03) — Icon pipeline, branding docs, site deployment +**Current version:** v1.2.7 (2026-08-02) — Store-aware updates, default-reader UX, Windows file associations - Do not add new user-facing PDF features unless the task explicitly asks for them. - Do not change the local-first privacy philosophy. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa5936..296af19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v1.2.5 — File Association & Branding — 2026-08-02 + +- **Version:** Bumped `__version__` to `1.2.5`, MSIX version to `1.2.5.0`; aligned the MCP package, packaging manifests, and release configuration to a single version source. `scripts/inject_version.py` now propagates the version to `main.py`, `packages/mcp-server/pyproject.toml`, and both MSIX manifests. +- **Added:** `.pdf` file-type association registered in the MSIX manifest (`windows.fileTypeAssociation`) — a Store/MSIX install now appears under **Open with** and **Default apps** with the OpenReader name and icon. The legacy installer sets `ChangesAssociations=yes` so Windows refreshes its association cache. +- **Branding:** Open Product Family alignment — OPEN/Reader header lockup, dark/light themed header icon, OpenPalette canonical spec, 155 icon assets regenerated from the source PNG via Lanczos with RGBA transparency fix, and reader.kovina.org deployed to Cloudflare Pages. +- **Fixed:** Recent-files were persisted under an accidental `***` settings key — migrated to `recentFiles`, with automatic upgrade for existing users. +- **Verification:** Packaging validation test asserts the MSIX `.pdf` association, frozen identity, and version alignment across manifest/appinstaller/MCP package. + +## v1.2.7 — Store-Aware Updates — 2026-08-02 + +- **Added:** Install-source detection (`pdfreader_lib/install_source.py`) — source, MSIX/Store, Setup.exe, or portable ZIP. Packaged execution is detected via the Windows package API (`GetCurrentPackageFamilyName`), with the `WindowsApps` path as a fallback. +- **Changed:** Store/MSIX installs no longer query GitHub — updates are shown as managed by the Microsoft Store with an **Open Microsoft Store** button. GitHub installs keep release detection with current + latest versions shown. +- **Added:** Proper **Software Updates** dialog (replaces the inline message box) with channel-aware content: Store-managed info, release notes, and a clean offline/error state with **Try Again**. +- **Fixed:** "Skip This Version" now persists (`updateSkipVersion`) so the same release isn't re-announced; interactive checks record `updateLastChecked`. +- **Fixed:** Build scripts no longer regress the embedded version to an older Git tag — precedence is explicit build version → exact Git tag → authoritative source `__version__` (`scripts/build_windows.ps1`, `scripts/build_macos.sh`, `.github/workflows/build-windows.yml`). +- **Verification:** Channel detection, skip gating, and semantic-version safety (including `1.2.7-test` vs `1.2.4`/`1.2.7`/`1.2.8`); full suite green. + +## v1.2.6 — Default Reader Experience — 2026-08-02 + +- **Added:** Detect whether OpenReader is the current default PDF handler by reading Windows' `UserChoice` ProgID (`pdfreader_lib/win_default_apps.py`) — ownership resolved via the AppUserModelId for MSIX installs and the `OpenReaderPDF` ProgID for the legacy installer. +- **Added:** Settings dialog (**File → Settings…**) with a Files / Default Apps section — shows the current PDF default, a "Set OpenReader as Default PDF Reader" button that opens the Windows Default Apps page (Windows keeps the final confirmation; OpenReader never writes the default), and a recovery message when the association isn't fully registered. +- **Added:** First-launch prompt to set OpenReader as the default — Set as default / Maybe later / Do not ask again, persisted in QSettings. +- **Fixed:** Default-handler detection now resolves ProgIDs across both `HKCU` and `HKLM` — an admin Inno install registers `OpenReaderPDF` under `HKLM\Software\Classes`, so the Settings recovery warning no longer appears when OpenReader is already the default. Ownership is verified via the registered open command and the AppUserModelId, not a single hardcoded ProgID. When OpenReader is the default, Settings shows a confirmation and the action becomes the neutral **Open Default Apps**. +- **Verification:** 15 unit tests covering Inno (HKCU and HKLM), MSIX (AUMID in either hive), shell-command resolution, another app, unknown/missing, unresolved entries, friendly-name lookup, and non-Windows neutrality. + ## v1.2.4 — Toolbar Icon Redesign — 2026-06-21 - **Version:** Bumped `__version__` to `1.2.4`, MSIX version to `1.2.4.0`. @@ -10,14 +35,6 @@ ## v1.2.3 — Reader UX Polish — 2026-06-20 -- **Version:** Bumped `__version__` to `1.2.3`, MSIX version to `1.2.3.0`. -- **Added:** Default Fit Page on open — all PDFs (normal open, recent files, session restore, new tab) now start in Fit view so the first page fits cleanly inside the document viewport. Uses both width and height constraints for true Fit Page behavior. -- **Added:** Ctrl+Mouse Wheel zoom — scroll up to zoom in, scroll down to zoom out. Works when the PDF viewer has focus. Page scrolling is suppressed while Ctrl is held. -- **Changed:** Zoom buttons redesigned from plain text characters to bold QPainter-drawn vector icons — clear bar, cross, and fit-arrow symbols that render crisply at any size or resolution. -- **Changed:** Annotation buttons (`Copy`, `HL`, `UL`, `ST`, `📝`) replaced with recognizable vector icons — overlapping document pages, highlighter pen, underlined U, strikethrough S, and notepad with pin. The 📝 emoji (reported as "looking like a glue stick") is no longer used. -- **Fixed:** Zoom buttons now have proper hover/press/checked visual feedback via stylesheet. Annotation buttons get consistent hover/press states. -- **Fixed:** Missing `QPen` and `QPointF` imports added for QPainter icon drawing. - - **Version:** Bumped `__version__` to `1.2.3`, MSIX version to `1.2.3.0`. - **Added:** Default Fit Page on open — all PDFs (normal open, recent files, session restore, new tab) now start in Fit view so the first page fits cleanly inside the document viewport. Uses both width and height constraints for true Fit Page behavior. - **Added:** Ctrl+Mouse Wheel zoom — scroll up to zoom in, scroll down to zoom out. Works when the PDF viewer has focus. Page scrolling is suppressed while Ctrl is held. @@ -169,6 +186,8 @@ - **Windows installer publisher docs** — README now explains "Unknown Publisher" warning and that code-signing requires an EV certificate. - **9 new regression tests** — 28 total, all passing. Covers: re-entrant open guard, blank tab, session checkbox, compress guard, post-update verification, unsigned publisher docs, clean cancel message. +## v1.1.0 — MCP Server Release — 2026-06-16 + - **Version:** Bumped `__version__` to `1.1.0-dev`. - **MCP server** (`pdfreader_lib/mcp_server.py`) — new Model Context Protocol server exposing 14 PDF operations as tools for AI agents: - **Reading:** `extract_text`, `get_page_text`, `get_metadata`, `get_page_count` @@ -183,6 +202,8 @@ - **Roadmap:** v1.1.0 marked as current. v1.1.0-plan.md updated to note MCP server shipped; visual polish items deferred to v1.2.0. - All operations are local. No cloud dependencies, no code changes to the existing GUI. +## v1.0.6 — Windows Installed-App Verification — 2026-06-15 + - **Version:** Bumped `__version__` to `1.0.6-dev`. - **Branch:** `windows-installed-app-verification-v1.0.6` diff --git a/CLAUDE.md b/CLAUDE.md index f1b7ad8..d5e00f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Overview -**Latest update:** v1.2.5 — Icon pipeline, branding, site deployment (2026-07-03) +**Latest update:** v1.2.7 — Store-aware updates, default-reader UX, Windows file associations (2026-08-02) Privacy-first, local-only desktop PDF utility for Windows. macOS experimental; Linux unsupported. @@ -11,8 +11,11 @@ macOS experimental; Linux unsupported. | Version | Date | Summary | |---------|------|---------| -| v1.2.5 | 2026-07-02 | Open Product Family branding alignment — header lockup, docs/BRANDING.md, icon generation pipeline, dark/light theme icon | +| v1.2.7 | 2026-08-02 | Store-aware updates — install-source detection, channel-aware Software Updates dialog, persisted skip + last-checked | +| v1.2.6 | 2026-08-02 | Default Reader Experience — Windows UserChoice detection, Settings dialog, first-launch default prompt | +| v1.2.5 | 2026-08-02 | Windows File Association Fix — MSIX `.pdf` association, ChangesAssociations, single-source version injector, changelog cleanup | | v1.2.5 | 2026-07-03 | OpenPalette canonical spec, 155 assets from PNG via Lanczos, RGBA transparency fix, dual-image CSS dark/light transition, reader.kovina.org deployed to Cloudflare Pages | +| v1.2.5 | 2026-07-02 | Open Product Family branding alignment — header lockup, docs/BRANDING.md, icon generation pipeline, dark/light theme icon | | v1.2.4 | 2026-06-28 | (previous release) | ## Architecture Constraints diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 9811c74..f2dc96e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -5,9 +5,22 @@ OpenReader uses semantic version tags to publish packaged builds. ## Version Source of Truth - `__version__` in `main.py` is the canonical source. Set it to the next release version. +- `scripts/inject_version.py` propagates the version to every canonical source so + they stay in lockstep: `main.py`, `packages/mcp-server/pyproject.toml`, + `packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO`, and both MSIX + manifests (`AppxManifest.xml` / `AppInstaller.xml`, mapped to a 4-part + version). Run `python scripts/inject_version.py X.Y.Z` after bumping `main.py`. +- MSIX manifests are only rewritten for strict semver versions; dev/test builds + (`-dev`, `-test`) bump the code/MCP sources but leave the manifests to CI. - Tags must use the format `vMAJOR.MINOR.PATCH`, for example `v1.2.2`. -- The injected runtime version removes the leading `v`, so `v1.2.2` becomes `__version__ = "1.2.2"` in packaged builds. - CI injects the tag version for release builds via `scripts/inject_version.py`. +- **Build version precedence** (local `scripts/build_windows.ps1`, + `scripts/build_macos.sh`, and the CI `build-windows.yml` workflow): + 1. explicit workflow/build version (`version_override` / `BUILD_VERSION`) + 2. exact Git tag on HEAD (`git describe --exact-match`) + 3. authoritative source version from `main.py` `__version__` + Ordinary branch builds never regress the embedded version to an older Git + tag or `0.0.0-dev`; CI test builds append `-test` to the source version. ## Release Architecture @@ -54,16 +67,23 @@ no separate code-signing certificate is needed. ## Update Detection -The app's Help → Check for Updates: - -1. Calls `https://api.github.com/repos/sparshsam/openreader/releases/latest` -2. Reads `tag_name` and compares against `__version__` -3. If a newer version exists, shows a dialog with release notes -4. User clicks "Open Releases Page" → browser opens GitHub Releases -5. User downloads the MSIX (or Setup.exe) and installs manually +Update behaviour is channel-aware (v1.2.7). `pdfreader_lib/install_source.py` +detects how OpenReader was installed: + +- **MSIX / Microsoft Store** — the app never calls GitHub. Help → Check for + Updates shows the Software Updates dialog ("Updates are managed by the + Microsoft Store") with an *Open Microsoft Store* button. +- **Setup.exe / portable ZIP** — the app queries GitHub: + 1. Calls `https://api.github.com/repos/sparshsam/openreader/releases/latest` + 2. Reads `tag_name` and compares against `__version__` + 3. If newer, shows the Software Updates dialog with current → latest, release + notes, and a last-checked timestamp + 4. *Skip This Version* persists (`updateSkipVersion`) so the same release + isn't re-announced; *Open Releases Page* opens GitHub Releases +- **Source** — no auto-update; developers `git pull` and rebuild. Source builds usually run with a `-dev` version and are not the primary update -target. Developers should update source builds with `git pull` and rebuild locally. +target. ## Validation Checklist diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index bf50757..8c55480 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -10,10 +10,17 @@ OpenReader follows [Semantic Versioning 2.0](https://semver.org/): ## Current Version -The current version is tracked in the `__version__` variable in `main.py`. - -- **Source builds** — update `__version__` in `main.py` to match the target release. -- **Packaged releases** — the version can be overridden from the Git tag during the CI release workflow (see `scripts/inject_version.py`). +`__version__` in `main.py` is the canonical source of truth. All other version +sources are derived from it by `scripts/inject_version.py`, which rewrites +`main.py`, `packages/mcp-server/pyproject.toml`, the MCP `egg-info/PKG-INFO`, +and both MSIX manifests (`AppxManifest.xml` / `AppInstaller.xml`, 4-part form) +in one pass. + +- **Source builds** — set `__version__` in `main.py` to the target release, + then run `python scripts/inject_version.py X.Y.Z`. +- **Packaged releases** — CI injects the tag version via + `scripts/inject_version.py`; MSIX manifests are only rewritten for strict + semver versions (dev/test builds bump code/MCP sources only). ## Tag Format diff --git a/docs/store-acceptance-checklist.md b/docs/store-acceptance-checklist.md new file mode 100644 index 0000000..ee25d5c --- /dev/null +++ b/docs/store-acceptance-checklist.md @@ -0,0 +1,60 @@ +# Post-Release Store Acceptance Checklist + +The MSIX package cannot be produced or signed locally: production signing uses +the Microsoft Store identity (no self-procured code-signing certificate), and +the Windows SDK `MakeAppx.exe`/Store signing pipeline runs in Partner Center. +This checklist covers the Store-specific validation that must happen **after** +the `v1.2.7` MSIX is submitted and signed by the Store. + +Frozen identity (do not alter): +| Field | Value | +|-------|-------| +| Identity Name | `SparshSam.OpenReader` | +| Publisher | `CN=E6186421-BF8A-47E0-A89C-0F513DFF91C0` | +| Package Family Name | `SparshSam.OpenReader_yh0byntbzd2qw` | +| Store ID | `9MXDVW2645LL` | + +## On a clean Windows machine, after Store install + +- [ ] Install from the Microsoft Store; app launches and reports **v1.2.7** (Help → About). +- [ ] `Get-AppxPackage` shows the package with the frozen identity and PFN. +- [ ] The process runs with a package identity — install-source detection + reports the Store channel (no GitHub request on launch; Help → Check for + Updates shows the Store-managed dialog with **Open Microsoft Store**). + +### File association (v1.2.5) +- [ ] Right-click a `.pdf` → **Open with** lists **OpenReader** with the correct icon. +- [ ] **Settings → Apps → Default apps** lists OpenReader once, named `OpenReader`, + file-type description "PDF Document". +- [ ] Double-clicking a `.pdf` opens it in OpenReader. +- [ ] With OpenReader running, opening a second `.pdf` routes to the existing + instance as a new tab. +- [ ] Paths with spaces / Unicode / parentheses / long names open correctly. + +### Default reader (v1.2.6) +- [ ] **File → Settings → Files / Default Apps** reads the current handler from + `UserChoice` and shows "OpenReader is the default PDF reader" — **no** + recovery warning. +- [ ] Setting another app as default then reopening Settings shows that app's + name, and the recovery message appears only if OpenReader's association + is genuinely missing. +- [ ] First-launch prompt: **Set as default** opens Default Apps; **Do not ask + again** persists across launches. + +### Updates (v1.2.7) +- [ ] Store install never queries `api.github.com` (silent launch check and + Help → Check for Updates both use the Store channel). +- [ ] **Open Microsoft Store** button opens the Store listing. + +## Sideloaded MSIX (developer mode, for pre-submission testing) + +- [ ] `Add-AppxPackage` the unsigned MSIX from a GitHub test build succeeds in + Developer Mode. +- [ ] Because sideloaded and Store packages both run with an AppX identity, + install-source detection reports the same Store channel — expected. + +## Out of scope + +- Do not weaken or replace the frozen identity. +- Do not publish a GitHub Release or submit to Partner Center without explicit + instruction. diff --git a/docs/updater-architecture.md b/docs/updater-architecture.md index 904297d..3ebb679 100644 --- a/docs/updater-architecture.md +++ b/docs/updater-architecture.md @@ -1,7 +1,7 @@ # Updater Architecture -**Version:** 1.2.0+ -**Status:** Self-update removed — MSIX/App Installer adopted +**Version:** 1.2.7 +**Status:** Self-update removed — MSIX/App Installer adopted; channel-aware update detection (v1.2.7) ## History @@ -99,10 +99,13 @@ The app retains update **detection** but not update **application**: |--------|---------|-----------| | `_parse_version` | Parse semver tags for comparison | ✅ | | `_classify_update_response` | Classify GitHub API responses | ✅ | -| `check_for_updates_silent` | Background check on launch | ✅ (show status bar only) | -| `check_for_updates` | Interactive Help → Check for Updates | ✅ (opens browser only) | -| `_on_update_check_reply` | Handle API response, show dialog | ✅ (opens releases page) | +| `check_for_updates_silent` | Background check on launch | ✅ (skipped for Store installs) | +| `check_for_updates` | Interactive Help → Check for Updates | ✅ (Store → Store dialog; GitHub → releases) | +| `_on_update_check_reply` | Handle API response, show Software Updates dialog | ✅ (v1.2.7) | | `_log_update` | Log update diagnostics | ✅ | +| `install_source.detect_install_source` | Detect MSIX / Setup.exe / portable / source (via `GetCurrentPackageFamilyName`) | ✅ (v1.2.7) | +| `_SoftwareUpdatesDialog` | Channel-aware update dialog | ✅ (v1.2.7) | +| `should_suppress_silent_notify` | Skip re-notification for a skipped release | ✅ (v1.2.7) | | All download/apply methods | — | ❌ Removed (~611 lines) | ### 2. MSIX Packaging (packaging/msix/) @@ -125,9 +128,11 @@ to the GitHub Release as `OpenReader.msix`. ### Manual Update Check 1. Help → Check for Updates -2. If newer → dialog with "Open Releases Page" -3. Browser opens GitHub Releases -4. User downloads and installs +2. **Store install:** Software Updates dialog → "Updates are managed by the + Microsoft Store" → *Open Microsoft Store* (no GitHub request). +3. **GitHub install:** if newer → dialog with current → latest, release notes, + *Skip This Version* (persisted) / *Open Releases Page* / *Later*. +4. Browser opens GitHub Releases; user downloads and installs. ### Future: Automatic Updates Once Store submission is active or App Installer is deployed: @@ -141,7 +146,9 @@ Once Store submission is active or App Installer is deployed: - **No UAC elevation** — the app never requests admin rights for updates - **No self-replacement** — the app never overwrites its own files - **Windows-managed updates** — MSIX packages are validated by Windows -- **GitHub API only** — the only network request is the update check +- **GitHub API only (non-Store)** — the only network request is the update + check, and Store/MSIX installs make no update-related network calls at all + (v1.2.7) ## Limitations diff --git a/installer/setup.iss b/installer/setup.iss index a05afc0..0a7e78b 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -42,6 +42,10 @@ AlwaysShowDirOnReadyPage=yes CloseApplications=force AppMutex=OpenReader +; Refresh the Windows file-association cache after install/uninstall +; so the .pdf → OpenReaderPDF registration is picked up promptly. +ChangesAssociations=yes + [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" diff --git a/main.py b/main.py index 215a356..20251a7 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import tempfile from collections import OrderedDict from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path import fitz @@ -51,12 +52,28 @@ ) -__version__ = "1.2.4" +__version__ = "1.2.7" GITHUB_REPO = "sparshsam/openreader" IPC_SERVER_NAME = "OpenReader-IPC" RECENT_FILES_MAX = 10 -SETTINGS_RECENT_KEY = "***" +SETTINGS_RECENT_KEY = "recentFiles" SETTINGS_AUTO_UPDATE_KEY = "autoCheckUpdates" +SETTINGS_DEFAULT_PROMPT_KEY = "defaultAppPromptShown" +SETTINGS_DEFAULT_DONT_ASK_KEY = "defaultAppDontAsk" +SETTINGS_UPDATE_SKIP_KEY = "updateSkipVersion" +SETTINGS_UPDATE_LAST_CHECKED_KEY = "updateLastChecked" + + +def should_suppress_silent_notify(result: dict, skip_version: str | None) -> bool: + """True when a silent update check should not re-notify about a release. + + Suppression is exact-tag equality against the version the user skipped — + a newer release still notifies. + """ + if result.get("outcome") != "update_available": + return False + latest = result.get("latest_tag") + return bool(latest) and bool(skip_version) and latest == skip_version # ── Performance timer ───────────────────────────────────────────────── @@ -86,6 +103,12 @@ def _perf_end(start: float, label: str) -> None: except ImportError: HAS_LIB_MODULES = False +# Windows default-app detection (stdlib only; no-op on non-Windows). +from pdfreader_lib import win_default_apps + +# Install-source detection for channel-aware updates (stdlib only). +from pdfreader_lib import install_source + # --------------------------------------------------------------------------- # Per-tab state container @@ -979,6 +1002,7 @@ def __init__(self, ipc_server: QLocalServer | None = None): self._log_update(f"app_launch: version={__version__}") self._auto_update_check = self.settings.value(SETTINGS_AUTO_UPDATE_KEY, True, bool) + self._install_source = install_source.detect_install_source() self._build_ui() self._build_actions() @@ -995,6 +1019,9 @@ def __init__(self, ipc_server: QLocalServer | None = None): if self._auto_update_check: QTimer.singleShot(3000, self.check_for_updates_silent) + # First-launch default PDF reader prompt (Windows only) + QTimer.singleShot(1500, self._maybe_prompt_default_app) + _perf_end(_perf_start_t, "PdfReaderWindow.__init__") QApplication.styleHints().colorSchemeChanged.connect(self._on_system_theme_change) @@ -1371,6 +1398,12 @@ def _build_menus(self): file_menu.addSeparator() + settings_action = QAction("Settings…", self) + settings_action.triggered.connect(self._open_settings_dialog) + file_menu.addAction(settings_action) + + file_menu.addSeparator() + quit_action = QAction("Quit", self) quit_action.setShortcut(QKeySequence.Quit) quit_action.triggered.connect(self.close) @@ -1532,10 +1565,23 @@ def _asset_path(name: str) -> str: def _load_recent_files(self) -> list[str]: raw = self.settings.value(SETTINGS_RECENT_KEY, []) if raw is None: - return [] + raw = [] if isinstance(raw, str): raw = [raw] - return [p for p in raw if p and Path(p).exists()] + recents = [p for p in raw if p and Path(p).exists()] + + # Migrate recents stored under the old key that was accidentally + # named "***" (v1.2.4 and earlier) so users keep their history. + if not recents and self.settings.contains("***"): + legacy = self.settings.value("***", []) + if isinstance(legacy, str): + legacy = [legacy] + recents = [p for p in legacy if p and Path(p).exists()] + if recents: + self.settings.setValue(SETTINGS_RECENT_KEY, recents) + self.settings.remove("***") + + return recents def _save_recent_files(self): self.settings.setValue(SETTINGS_RECENT_KEY, self._recent_files) @@ -3155,6 +3201,9 @@ def _set_app_icon(self): def check_for_updates_silent(self): """Silent update check — no user-visible feedback unless update is found.""" + if self._is_store(): + # Updates are managed by the Microsoft Store; don't hit GitHub. + return self._update_nam_silent = QNetworkAccessManager(self) self._update_nam_silent.finished.connect(self._on_silent_update_reply) url = QUrl(f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest") @@ -3172,6 +3221,10 @@ def _on_silent_update_reply(self, reply): ) reply.deleteLater() if result["outcome"] == "update_available": + skip_version = self.settings.value(SETTINGS_UPDATE_SKIP_KEY, "") + if should_suppress_silent_notify(result, skip_version): + self._log_update("update_check=skipped_by_user_preference") + return self.statusBar().showMessage(result["message"], 10000) # ------------------------------------------------------------------ @@ -3232,6 +3285,10 @@ def _parse_version(tag): def _is_packaged(): return getattr(sys, "frozen", False) + def _is_store(self): + """True when installed as an MSIX/Store package (updates via Store).""" + return self._install_source == install_source.STORE_MSIX + @staticmethod def _updater_temp_dir(): temp_dir = Path(tempfile.gettempdir()) / "PDFReader-Updates" @@ -3348,6 +3405,12 @@ def _classify_update_response( return result def check_for_updates(self): + if self._is_store(): + dlg = _SoftwareUpdatesDialog(self, mode="store", current_version=__version__) + dlg.exec() + if dlg.action == "open_store": + self._open_store_listing() + return self.update_action.setEnabled(False) self.statusBar().showMessage("Checking for updates...") url = QUrl(f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest") @@ -3375,65 +3438,51 @@ def _on_update_check_reply(self, reply): self._log_update(f"update_check=outcome={result['outcome']}") self._log_update(f"update_check=latest_tag={result['latest_tag']}") self._log_update(f"update_check=current_version={__version__}") + reply.deleteLater() + + # Record when an interactive check last ran. + self.settings.setValue( + SETTINGS_UPDATE_LAST_CHECKED_KEY, datetime.now().isoformat() + ) if result["outcome"] in ("network_error", "http_error", "json_error"): - self.statusBar().showMessage(result["message"], 5000) - reply.deleteLater() + dlg = _SoftwareUpdatesDialog(self, mode="offline", message=result["message"]) + dlg.exec() + if dlg.action == "try_again": + self.check_for_updates() return - reply.deleteLater() - - # Already on latest version if result["outcome"] == "already_latest": - QMessageBox.information( - self, - "Up to Date", - f"You're already running the latest version of OpenReader (v{__version__}).", - ) - self.statusBar().showMessage(result["message"], 5000) + dlg = _SoftwareUpdatesDialog(self, mode="already_latest", current_version=__version__) + dlg.exec() return - # Update available \u2014 open releases page in browser - latest_tag = result["latest_tag"] + # Update available data = result["data"] - current_version = self._parse_version(__version__) - + latest_tag = result["latest_tag"] release_url = data.get("html_url", f"https://github.com/{GITHUB_REPO}/releases") release_notes = (data.get("body") or "")[:500] + last_checked = self.settings.value(SETTINGS_UPDATE_LAST_CHECKED_KEY, "") - msg = ( - f"

Update Available

" - f"

v{'.'.join(str(x) for x in current_version)} \u2192 {latest_tag}

" - "
" - "

PDFReader no longer installs updates from within the app.

" - "

Please download the latest version from the GitHub releases page " - "and install it using the MSIX App Installer (Windows) or " - "the legacy Setup.exe.

" + dlg = _SoftwareUpdatesDialog( + self, + mode="update_available", + current_version=__version__, + latest_tag=latest_tag, + release_url=release_url, + release_notes=release_notes, + last_checked=last_checked, ) - if release_notes: - msg += f"
{release_notes}
" - - btn = QMessageBox(self) - btn.setWindowTitle("Update Available") - btn.setTextFormat(Qt.RichText) - btn.setText(msg) - - open_button = btn.addButton("Open Releases Page", QMessageBox.AcceptRole) - skip_button = btn.addButton("Skip This Version", QMessageBox.RejectRole) - _ = btn.addButton("Later", QMessageBox.DestructiveRole) - - btn.exec() - - if btn.clickedButton() == skip_button: - self.statusBar().showMessage("Update skipped", 3000) - return + dlg.exec() - if btn.clickedButton() == open_button: + if dlg.action == "open_releases": import webbrowser webbrowser.open(release_url) - self.statusBar().showMessage( - "Opening releases page in your browser.", 5000 - ) + self.statusBar().showMessage("Opening releases page in your browser.", 5000) + elif dlg.action == "skip": + self.settings.setValue(SETTINGS_UPDATE_SKIP_KEY, latest_tag) + self._log_update(f"update_check=skipped_version={latest_tag}") + self.statusBar().showMessage("Update skipped for this version", 3000) # Workspace Session Restoration # ------------------------------------------------------------------ @@ -3544,6 +3593,47 @@ def _open_compare_dialog(self): dlg = _CompareDialog(path_a, path_b, self) dlg.exec() + # ------------------------------------------------------------------ + # Settings + # ------------------------------------------------------------------ + + def _open_settings_dialog(self): + dlg = _SettingsDialog(self) + dlg.exec() + + def _maybe_prompt_default_app(self): + """First-launch prompt to set OpenReader as the default PDF reader.""" + if not win_default_apps.is_windows(): + return + if self.settings.value(SETTINGS_DEFAULT_DONT_ASK_KEY, False, bool): + return + if self.settings.value(SETTINGS_DEFAULT_PROMPT_KEY, False, bool): + return + is_default, _ = win_default_apps.default_app_owner() + if is_default: + return + + box = QMessageBox(self) + box.setWindowTitle("Default PDF Reader") + box.setText( + "OpenReader isn't your default PDF reader.\n\n" + "Set it as the default to open PDFs from File Explorer with " + "OpenReader?" + ) + set_btn = box.addButton("Set as Default", QMessageBox.AcceptRole) + _ = box.addButton("Maybe Later", QMessageBox.RejectRole) + dont_ask_btn = box.addButton("Do Not Ask Again", QMessageBox.DestructiveRole) + box.exec() + + clicked = box.clickedButton() + if clicked == set_btn: + self.settings.setValue(SETTINGS_DEFAULT_PROMPT_KEY, True) + win_default_apps.open_default_apps_settings() + elif clicked == dont_ask_btn: + self.settings.setValue(SETTINGS_DEFAULT_PROMPT_KEY, True) + self.settings.setValue(SETTINGS_DEFAULT_DONT_ASK_KEY, True) + # "Maybe Later" — no-op; re-prompts on the next launch. + # ------------------------------------------------------------------ # Semantic Search (integrated with search bar) # ------------------------------------------------------------------ @@ -3632,6 +3722,228 @@ def _semantic_search(self, needle): self.statusBar().showMessage(f"Opened: {r.filename} — page {r.page}", 5000) +# --------------------------------------------------------------------------- +# Settings Dialog +# --------------------------------------------------------------------------- + +class _SettingsDialog(QDialog): + """Application settings — Default Apps (Updates section added in v1.2.7).""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Settings") + self.resize(520, 340) + + layout = QVBoxLayout(self) + + # ── Files / Default Apps ── + files_group = QGroupBox("Files / Default Apps") + files_layout = QVBoxLayout(files_group) + + self.default_label = QLabel() + self.default_label.setTextFormat(Qt.RichText) + self.default_label.setWordWrap(True) + files_layout.addWidget(self.default_label) + + self.set_default_button = QPushButton("Set OpenReader as Default PDF Reader") + self.set_default_button.setToolTip( + "Opens the Windows Default Apps page. " + "Windows keeps the final confirmation." + ) + self.set_default_button.clicked.connect(self._open_default_apps) + files_layout.addWidget(self.set_default_button) + + refresh_button = QPushButton("Refresh Status") + refresh_button.clicked.connect(self._refresh) + files_layout.addWidget(refresh_button) + + self.recovery_label = QLabel() + self.recovery_label.setTextFormat(Qt.RichText) + self.recovery_label.setWordWrap(True) + self.recovery_label.setStyleSheet("color: #b04040;") + files_layout.addWidget(self.recovery_label) + + layout.addWidget(files_group) + + # ── Close ── + close_btn = QPushButton("Close") + close_btn.setFixedWidth(100) + close_btn.clicked.connect(self.accept) + btn_layout = QHBoxLayout() + btn_layout.addStretch() + btn_layout.addWidget(close_btn) + layout.addLayout(btn_layout) + + self._refresh() + + @staticmethod + def _esc(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + def _refresh(self): + if not win_default_apps.is_windows(): + self.default_label.setText( + "Default PDF handling is only configurable on Windows." + ) + self.set_default_button.setEnabled(False) + self.recovery_label.hide() + return + + is_default, progid = win_default_apps.default_app_owner() + + if is_default: + label = ( + "OpenReader is the default PDF reader.
" + "PDFs open in OpenReader from File Explorer." + ) + self.set_default_button.setText("Open Default Apps") + self.set_default_button.setToolTip( + "Open the Windows Default Apps page to manage PDF handling." + ) + else: + self.set_default_button.setText("Set OpenReader as Default PDF Reader") + self.set_default_button.setToolTip( + "Opens the Windows Default Apps page. " + "Windows keeps the final confirmation." + ) + if progid: + name = win_default_apps.friendly_app_name(progid) or progid + label = f"Current default for PDF: {self._esc(name)}" + else: + label = ( + "Current default for PDF: not detected — Windows has not " + "chosen a handler, or the setting couldn't be read." + ) + self.default_label.setText(label) + + # Recovery only when OpenReader is neither the default nor registered. + if is_default or win_default_apps.association_registered(): + self.recovery_label.hide() + else: + self.recovery_label.setText( + "OpenReader's PDF association isn't fully registered. " + "Reinstall OpenReader, or for Store installs use " + "Settings → Apps → Installed apps → OpenReader → Advanced options → " + "Repair." + ) + self.recovery_label.show() + + def _open_default_apps(self): + if not win_default_apps.open_default_apps_settings(): + QMessageBox.warning( + self, + "Default Apps", + "Couldn't open the Windows Default Apps page. " + "Open it manually: Settings → Apps → Default apps.", + ) + + +# --------------------------------------------------------------------------- +# Software Updates Dialog +# --------------------------------------------------------------------------- + +class _SoftwareUpdatesDialog(QDialog): + """Channel-aware Software Updates dialog (v1.2.7). + + ``mode`` selects the content and buttons: + + - ``store`` — Store-managed message + Open Microsoft Store. + - ``update_available`` — current → latest, release notes, Open Releases + Page / Skip This Version / Later. + - ``already_latest`` — up-to-date confirmation. + - ``offline`` — clean error message + Try Again. + + After ``exec()``, ``self.action`` is one of ``open_store``, + ``open_releases``, ``skip``, ``try_again``, or ``close``. + """ + + def __init__( + self, + parent, + mode, + *, + current_version=None, + latest_tag=None, + release_url=None, + release_notes=None, + message=None, + last_checked=None, + ): + super().__init__(parent) + self.parent_window = parent + self.action = "close" + self.setWindowTitle("Software Updates") + self.resize(480, 360) + + layout = QVBoxLayout(self) + + heading = QLabel() + heading.setTextFormat(Qt.RichText) + heading.setWordWrap(True) + layout.addWidget(heading) + + body = QLabel() + body.setTextFormat(Qt.RichText) + body.setWordWrap(True) + layout.addWidget(body) + + if mode == "update_available" and release_notes: + notes = QLabel(f"
{release_notes}
") + notes.setTextFormat(Qt.RichText) + notes.setWordWrap(True) + layout.addWidget(notes) + + if last_checked: + stamp = QLabel(f"Last checked: {last_checked}") + stamp.setTextFormat(Qt.RichText) + stamp.setWordWrap(True) + layout.addWidget(stamp) + + buttons = QHBoxLayout() + buttons.addStretch() + layout.addLayout(buttons) + + if mode == "store": + heading.setText("

Updates are managed by the Microsoft Store

") + body.setText( + f"You're running OpenReader v{current_version}.

" + "Updates for Store installs are delivered automatically by the " + "Microsoft Store. To check for updates, open the Store listing." + ) + self._add(buttons, "Open Microsoft Store", "open_store") + self._add(buttons, "Close", "close") + elif mode == "update_available": + heading.setText("

Update Available

") + body.setText( + f"You're running v{current_version} → " + f"{latest_tag} is available." + ) + self._add(buttons, "Open Releases Page", "open_releases") + self._add(buttons, "Skip This Version", "skip") + self._add(buttons, "Later", "close") + elif mode == "already_latest": + heading.setText("

Up to Date

") + body.setText( + f"You're already running the latest version of OpenReader " + f"(v{current_version})." + ) + self._add(buttons, "Close", "close") + else: # offline / error + heading.setText("

Couldn't Check for Updates

") + body.setText(message or "An error occurred while checking for updates.") + self._add(buttons, "Try Again", "try_again") + self._add(buttons, "Close", "close") + + def _add(self, layout, label, action): + btn = QPushButton(label) + btn.clicked.connect(lambda: self._finish(action)) + layout.addWidget(btn) + + def _finish(self, action): + self.action = action + self.accept() + + # --------------------------------------------------------------------------- # Library Search Dialog # --------------------------------------------------------------------------- @@ -3951,6 +4263,17 @@ def _segments_to_html(segments, side): # Entry Point # --------------------------------------------------------------------------- +def filter_pdf_paths(args: list[str]) -> list[str]: + """Return the subset of CLI args that look like PDF file paths. + + Windows passes a quoted ``%1`` for file activations, so a path survives + intact even with spaces, parentheses, or non-ASCII characters. The IPC + hand-off serializes these as JSON (Unicode-safe). Only the ``.pdf`` suffix + is matched here; existence is checked by the callers. + """ + return [a for a in args if Path(a).suffix.lower() == ".pdf"] + + def _try_send_to_existing_instance(file_paths: list[str]) -> bool: """Send file paths to a running instance via QLocalSocket. Returns True if sent.""" if not file_paths: @@ -3978,7 +4301,7 @@ def main(): app.setOrganizationName("Sparsh Sam") # ---- Single-instance IPC: route file opens to existing window ---- - pdf_paths = [a for a in sys.argv[1:] if Path(a).suffix.lower() == ".pdf"] + pdf_paths = filter_pdf_paths(sys.argv[1:]) if pdf_paths and _try_send_to_existing_instance(pdf_paths): # Paths routed to existing instance — exit this one sys.exit(0) diff --git a/packages/mcp-server/pyproject.toml b/packages/mcp-server/pyproject.toml index 5d72581..eef5581 100644 --- a/packages/mcp-server/pyproject.toml +++ b/packages/mcp-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openreader-mcp" -version = "1.2.4" +version = "1.2.7" description = "AI assistant tools for PDFs — read, search, compare, merge, split, compress, and index PDFs locally." readme = "README.md" license = { text = "AGPL-3.0-or-later" } diff --git a/packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO b/packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO index aed0e82..7bb3f22 100644 --- a/packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO +++ b/packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: openreader-mcp -Version: 1.2.4 +Version: 1.2.7 Summary: AI assistant tools for PDFs — read, search, compare, merge, split, compress, and index PDFs locally. Author: Sparsh Sam License: AGPL-3.0-or-later diff --git a/packaging/msix/AppInstaller.xml b/packaging/msix/AppInstaller.xml index 89c7c96..73404da 100644 --- a/packaging/msix/AppInstaller.xml +++ b/packaging/msix/AppInstaller.xml @@ -18,7 +18,7 @@ diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml index 5bc1292..80e4f3c 100644 --- a/packaging/msix/AppxManifest.xml +++ b/packaging/msix/AppxManifest.xml @@ -24,7 +24,7 @@ + Version="1.2.7.0" /> OpenReader @@ -62,6 +62,18 @@ Square71x71Logo="assets\icon-71x71.png" /> + + + + + PDF Document + assets\icon-44x44.png + + .pdf + + + + diff --git a/packaging/msix/build-msix.ps1 b/packaging/msix/build-msix.ps1 index 58a343e..89555c5 100644 --- a/packaging/msix/build-msix.ps1 +++ b/packaging/msix/build-msix.ps1 @@ -13,9 +13,11 @@ - A code-signing certificate (.pfx) for package signing (or use Store signing) Usage: - .\build-msix.ps1 -ExeDir "..\dist\OpenReader" -Version "1.2.0.0" + .\build-msix.ps1 -ExeDir "..\dist\OpenReader" [-Version "1.2.5.0"] Optional: + -Version 4-part MSIX version. Defaults to the version declared in + AppxManifest.xml (kept in sync by scripts/inject_version.py). -PfxPath ".\certificate.pfx" -PfxPassword "password" The script generates: @@ -36,7 +38,7 @@ param( [string]$ExeDir, [Parameter(Mandatory = $false)] - [string]$Version = "1.2.0.0", + [string]$Version = "", [Parameter(Mandatory = $false)] [string]$PfxPath = "", @@ -57,6 +59,21 @@ if (-not (Test-Path $ExeDir)) { exit 1 } +# Default the version to the one declared in AppxManifest.xml so it never +# drifts from the checked-in source (kept in sync by inject_version.py). +if (-not $Version) { + $defaultManifest = Join-Path $ScriptDir "AppxManifest.xml" + if (Test-Path $defaultManifest) { + $defaultXml = New-Object System.Xml.XmlDocument + $defaultXml.Load($defaultManifest) + $Version = $defaultXml.Package.Identity.Version + Write-Host "Using version from AppxManifest.xml: $Version" -ForegroundColor Cyan + } else { + Write-Error "AppxManifest.xml not found and -Version not provided." + exit 1 + } +} + # --- Locate MakeAppx.exe --- $makeAppx = Get-Command "MakeAppx.exe" -ErrorAction SilentlyContinue # Normalize to path string (Get-Command returns CommandInfo, Get-ChildItem returns FileInfo) diff --git a/pdfreader_lib/install_source.py b/pdfreader_lib/install_source.py new file mode 100644 index 0000000..7b0eb7d --- /dev/null +++ b/pdfreader_lib/install_source.py @@ -0,0 +1,74 @@ +"""Install-source detection for OpenReader. + +Determines how the running copy of OpenReader was installed so update +behaviour can match the channel: + + SOURCE — running from source (``python main.py``) + STORE_MSIX — a packaged MSIX install; by product decision all MSIX + installs (Microsoft Store or sideloaded) are treated as + Store-managed + SETUP_EXE — the legacy Inno Setup install (defaults to Program Files) + PORTABLE_ZIP — a user-placed PyInstaller onedir outside Program Files + +Packaged execution is detected through the Windows package API +(``GetCurrentPackageFamilyName``) — the process-level AppX identity — rather +than by assuming a filename. The ``C:\\Program Files\\WindowsApps\\`` path is +kept only as a fallback for environments where the API is unavailable. +Store and sideloaded MSIX packages are not distinguished at runtime; both are +reported as STORE_MSIX. +""" + +import ctypes +import sys +from pathlib import Path + +SOURCE = "source" +STORE_MSIX = "store_msix" +SETUP_EXE = "setup_exe" +PORTABLE_ZIP = "portable_zip" + +PFN = "SparshSam.OpenReader_yh0byntbzd2qw" + +WINDOWSAPPS_MARKER = "\\windowsapps\\" + + +def _package_family_name() -> str | None: + """Return the current process's AppX package family name, or None. + + ``GetCurrentPackageFamilyName`` returns ``ERROR_INSUFFICIENT_BUFFER`` (122) + on the sizing call and ``APPMODEL_ERROR_NO_PACKAGE`` (15700) when the + process is not running from a package — both handled as "no package". + """ + if not sys.platform.startswith("win"): + return None + try: + get_pfn = ctypes.windll.kernel32.GetCurrentPackageFamilyName + length = ctypes.c_uint32(0) + # First call sizes the buffer; returns ERROR_INSUFFICIENT_BUFFER (122). + hr = get_pfn(ctypes.byref(length), None) + if hr != 122 or length.value == 0: + return None + buf = ctypes.create_unicode_buffer(length.value) + if get_pfn(ctypes.byref(length), buf) == 0: + return buf.value + except (OSError, AttributeError): + return None + return None + + +def detect_install_source() -> str: + """Return the installation source for the running copy of OpenReader.""" + if not getattr(sys, "frozen", False): + return SOURCE + + # Reliable signal: the process carries an AppX package identity. + if _package_family_name() is not None: + return STORE_MSIX + + # Fallback: MSIX packages always run from the WindowsApps store. + exe = str(Path(sys.executable).resolve()).lower() + if WINDOWSAPPS_MARKER in exe: + return STORE_MSIX + if "program files" in exe: + return SETUP_EXE + return PORTABLE_ZIP diff --git a/pdfreader_lib/win_default_apps.py b/pdfreader_lib/win_default_apps.py new file mode 100644 index 0000000..4caac6e --- /dev/null +++ b/pdfreader_lib/win_default_apps.py @@ -0,0 +1,241 @@ +"""Windows default-app detection and Default-apps integration for OpenReader. + +OpenReader registers ``.pdf`` through two distribution channels: + +- **MSIX / Microsoft Store** — Windows creates an OS-generated + (AppX-namespaced) ProgID for the declared ``windows.fileTypeAssociation`` at + install time. That literal ProgID string is never matched; ownership is + resolved by reading the ``AppUserModelId`` under ``\\Application`` + and comparing it to OpenReader's Application User Model ID (AUMID). +- **Legacy Inno installer** — writes the fixed ProgID ``OpenReaderPDF`` to the + registry (see installer/setup.iss). Because the installer is admin-elevated + (``PrivilegesRequired=admin``), its ``HKCR`` entries land in + ``HKLM\\Software\\Classes``. + +ProgID lookups therefore read both ``HKCU\\Software\\Classes`` and +``HKLM\\Software\\Classes`` — the two physical hives that ``HKCR`` merges — so +a per-user MSIX registration and an admin Inno registration are both seen. + +Windows remains the final authority on defaults. This module only *reads* the +``UserChoice`` registry value and opens the Default Apps settings page; it +never writes the hash-protected ``UserChoice`` value, so OpenReader cannot +silently take over the PDF default. + +Every function is defensive: on non-Windows platforms (macOS, WSL dev) or when +the registry cannot be read, it returns a neutral value rather than raising. +""" + +import os +import sys + +try: + import winreg +except ImportError: # pragma: no cover - non-Windows + winreg = None + +PFN = "SparshSam.OpenReader_yh0byntbzd2qw" +APPLICATION_ID = "OpenReader" +AUMID = f"{PFN}!{APPLICATION_ID}" + +INNO_PROGID = "OpenReaderPDF" + +USERCHOICE_KEY = ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.pdf\UserChoice" +) +CLASSES_KEY = r"Software\Classes" + +# resolve_progid_owner() outcomes +OPENREADER = "openreader" +OTHER = "other" + + +def is_windows() -> bool: + """True when running on Windows with a usable registry module.""" + return winreg is not None and sys.platform.startswith("win") + + +def _hives(): + """The physical hives HKCR merges: per-user first, then machine-wide.""" + return (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE) + + +def _open_key(path: str, root=None): + """Open a key; root defaults to HKEY_CURRENT_USER. Returns handle or None.""" + if not is_windows(): + return None + if root is None: + root = winreg.HKEY_CURRENT_USER + try: + return winreg.OpenKey(root, path) + except OSError: + return None + + +def _query_value(key, value_name: str): + """Read a value off a handle, returning None on any failure.""" + try: + value, _ = winreg.QueryValueEx(key, value_name) + return value + except OSError: + return None + + +def _classes_subkey_value(progid: str, sub: str, value_name, root=None) -> str | None: + """Read ``\\\\`` from HKCU then HKLM (merged HKCR view). + + With ``root=None`` both hives are tried; ``value_name=None`` reads the + key's default (unnamed) value. + """ + if not is_windows() or not progid: + return None + subpath = rf"{CLASSES_KEY}\{progid}" + (f"\\{sub}" if sub else "") + hives = _hives() if root is None else (root,) + for hive in hives: + key = _open_key(subpath, root=hive) + if key is None: + continue + try: + value = _query_value(key, value_name) + if isinstance(value, str) and value: + return value + finally: + winreg.CloseKey(key) + return None + + +def _application_subkey_value(progid: str, value_name: str, root=None) -> str | None: + """Best-effort read of ``\\Application\\``.""" + return _classes_subkey_value(progid, "Application", value_name, root=root) + + +def _shell_command(progid: str) -> str | None: + """The ProgID's ``shell\\open\\command`` default value, or None.""" + return _classes_subkey_value(progid, r"shell\open\command", None) + + +def _shell_command_points_at_openreader(progid: str) -> bool: + """True when the registered open command invokes OpenReader.exe.""" + cmd = _shell_command(progid) + return bool(cmd) and "openreader.exe" in cmd.lower() + + +def _progid_key_exists(progid: str) -> bool: + """True when the ProgID exists in HKCU or HKLM classes.""" + for hive in _hives(): + if _open_key(rf"{CLASSES_KEY}\{progid}", root=hive) is not None: + return True + return False + + +def get_userchoice_progid() -> str | None: + """The ProgID Windows currently maps ``.pdf`` to, or None.""" + key = _open_key(USERCHOICE_KEY) + if key is None: + return None + try: + value = _query_value(key, "ProgId") + return value if isinstance(value, str) else None + finally: + winreg.CloseKey(key) + + +def resolve_progid_owner(progid: str | None) -> str | None: + """Return ``OPENREADER`` / ``OTHER`` / None for a ProgID (best-effort). + + Ownership is resolved defensively, never by a single hardcoded string: + + - the Inno Setup ProgID, a ``shell\\open\\command`` that invokes + ``OpenReader.exe``, or an ``AppUserModelId`` equal to OpenReader's AUMID + → ``OPENREADER`` + - any other resolvable handler (a registered command or application + identity) → ``OTHER`` + - nothing resolvable in the registry → None (unknown / unresolved) + """ + if not is_windows() or not progid: + return None + if progid == INNO_PROGID: + return OPENREADER + if _shell_command_points_at_openreader(progid): + return OPENREADER + app_id = _application_subkey_value(progid, "AppUserModelId") + if app_id == AUMID: + return OPENREADER + if app_id or _shell_command(progid) or _application_subkey_value( + progid, "FriendlyAppName" + ): + return OTHER + return None + + +def default_app_owner() -> tuple[bool, str | None]: + """Return ``(is_openreader_default, progid)`` for the current ``.pdf`` handler.""" + progid = get_userchoice_progid() + if not progid: + return (False, None) + return (resolve_progid_owner(progid) == OPENREADER, progid) + + +def _find_progid_with_aumid() -> str | None: + """Scan HKCU then HKLM classes for a ProgID carrying OpenReader's AUMID.""" + if not is_windows(): + return None + for hive in _hives(): + key = _open_key(CLASSES_KEY, root=hive) + if key is None: + continue + try: + index = 0 + while True: + try: + subkey_name = winreg.EnumKey(key, index) + except OSError: + break + index += 1 + # Packaged ProgIDs are AppX-namespaced or embed the package name. + if not ( + subkey_name.startswith("AppX") + or PFN.lower() in subkey_name.lower() + ): + continue + app_id = _application_subkey_value( + subkey_name, "AppUserModelId", root=hive + ) + if app_id == AUMID: + return subkey_name + finally: + winreg.CloseKey(key) + return None + + +def association_registered() -> bool: + """True when OpenReader's ``.pdf`` association exists in the registry. + + Checks both HKCU and HKLM (the hives HKCR merges): an admin Inno install + registers ``OpenReaderPDF`` under HKLM, a per-user MSIX package under HKCU. + """ + if not is_windows(): + return False + if _progid_key_exists(INNO_PROGID): + return True + return _find_progid_with_aumid() is not None + + +def friendly_app_name(progid: str | None) -> str | None: + """Best-effort display name for a ProgID, or None.""" + if not is_windows() or not progid: + return None + name = _application_subkey_value(progid, "FriendlyAppName") + if name: + return name + return _classes_subkey_value(progid, "", None) + + +def open_default_apps_settings() -> bool: + """Open the Windows Default Apps page. Never writes the default.""" + if not is_windows(): + return False + try: + os.startfile("ms-settings:defaultapps") # type: ignore[attr-defined] + return True + except (OSError, AttributeError): + return False diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh index 6e9001d..edaf5a5 100755 --- a/scripts/build_macos.sh +++ b/scripts/build_macos.sh @@ -3,10 +3,25 @@ set -euo pipefail cd "$(dirname "$0")/.." -# Inject version from latest git tag directly into main.py -VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0-dev") -VERSION="${VERSION#v}" -python3 scripts/inject_version.py "$VERSION" +# Version precedence: +# 1. Explicit override (BUILD_VERSION env) +# 2. Exact tag on HEAD (git describe --exact-match) +# 3. Authoritative source version already in main.py +# Never fall back to an older Git tag or 0.0.0-dev, which would silently +# regress the embedded application version. +VERSION="${BUILD_VERSION:-}" +if [ -z "$VERSION" ]; then + TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || true) + if [ -n "$TAG" ]; then + VERSION="${TAG#v}" + fi +fi +if [ -n "$VERSION" ]; then + python3 scripts/inject_version.py "$VERSION" +else + VERSION=$(python3 scripts/inject_version.py --source-version) + echo "Using source version: $VERSION (no build override or exact tag)" +fi if ! command -v python3 >/dev/null 2>&1; then echo "python3 is required. Install Python 3.11 or newer from https://www.python.org/downloads/macos/." diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 157e1ba..91446c1 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -2,14 +2,27 @@ $ErrorActionPreference = "Stop" Set-Location -Path (Split-Path -Parent $PSScriptRoot) -# Inject version from latest git tag directly into main.py -$version = "0.0.0-dev" -$tag = git describe --tags --abbrev=0 2>$null -if ($tag) { - $version = $tag -replace '^v', '' +# Version precedence: +# 1. Explicit override (BUILD_VERSION env) +# 2. Exact tag on HEAD (git describe --exact-match) +# 3. Authoritative source version already in main.py +# Never fall back to an older Git tag or 0.0.0-dev, which would silently +# regress the embedded application version. +$version = $env:BUILD_VERSION +if (-not $version) { + $tag = git describe --tags --exact-match HEAD 2>$null + if ($tag) { + $version = $tag -replace '^v', '' + } +} + +if ($version) { + python scripts/inject_version.py $version + Write-Host "Injected version: $version" +} else { + $version = python scripts/inject_version.py --source-version + Write-Host "Using source version: $version (no build override or exact tag)" } -python scripts/inject_version.py $version -Write-Host "Injected version: $version" if (!(Test-Path -LiteralPath ".\.venv")) { python -m venv .venv diff --git a/scripts/inject_version.py b/scripts/inject_version.py index 59df119..064357b 100644 --- a/scripts/inject_version.py +++ b/scripts/inject_version.py @@ -1,31 +1,107 @@ #!/usr/bin/env python3 -"""Inject version string into main.py at build time. +"""Inject version string into OpenReader's version sources at build time. + +Writes the version into every canonical source so the app, the MCP package, +and the MSIX packaging stay in lockstep: + + - main.py __version__ = "" + - packages/mcp-server/pyproject.toml version = "" + - packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO + Version: + - packaging/msix/AppxManifest.xml Identity Version=".0" + - packaging/msix/AppInstaller.xml MainPackage Version=".0" + +MSIX manifests require a 4-part version (x.y.z.0), so they are rewritten +only when is strict semver (digits only). Dev/test builds such +as 0.0.0-dev or branch-test still bump main.py and the MCP package but +leave the MSIX manifests alone — CI patches those from the git tag. Usage: python scripts/inject_version.py -Example: python scripts/inject_version.py 0.1.5 +Example: python scripts/inject_version.py 1.2.5 """ import re import sys +from pathlib import Path -def main(): - if len(sys.argv) < 2: - version = "0.0.0-dev" - else: - version = sys.argv[1] - - with open("main.py", encoding="utf-8") as f: - src = f.read() +ROOT = Path(__file__).resolve().parent.parent - src = re.sub( +# Canonical sources updated on every build, whatever the version string. +TARGETS = [ + ( + "main.py", r'__version__ = "[^"]+"', - f'__version__ = "{version}"', - src, + lambda v: f'__version__ = "{v}"', + ), + ( + "packages/mcp-server/pyproject.toml", + r'^version = "[^"]+"', + lambda v: f'version = "{v}"', + ), + ( + "packages/mcp-server/src/openreader_mcp.egg-info/PKG-INFO", + r"^Version: [^\n]+", + lambda v: f"Version: {v}", + ), +] + +# MSIX manifests: strict semver only, mapped to a 4-part version (x.y.z.0). +# Patterns are scoped to their element so MinVersion/MaxVersionTested in +# TargetDeviceFamily (and the AppInstaller schema Version) are never touched. +SEMVER = re.compile(r"^\d+\.\d+\.\d+$") +MSIX_TARGETS = [ + ( + "packaging/msix/AppxManifest.xml", + r'(]*?Version=")[^"]+(")', + lambda v: rf"\g<1>{v}.0\g<2>", + ), + ( + "packaging/msix/AppInstaller.xml", + r'(]*?Version=")[^"]+(")', + lambda v: rf"\g<1>{v}.0\g<2>", + ), +] + + +def _rewrite(path: str, pattern: str, replacement: str) -> bool: + full = ROOT / path + if not full.exists(): + print(f" skip (missing) {path}") + return False + text = full.read_text(encoding="utf-8") + new_text, count = re.subn( + pattern, replacement, text, count=1, flags=re.MULTILINE ) + if count == 0: + print(f" skip (no match) {path}") + return False + full.write_text(new_text, encoding="utf-8") + print(f" ok {path}") + return True + + +def main(): + if len(sys.argv) >= 2 and sys.argv[1] == "--source-version": + # Print the authoritative version already in main.py without modifying + # anything. Build scripts use this instead of an inline regex so they + # never regress the embedded version to an older Git tag or 0.0.0-dev. + src = (ROOT / "main.py").read_text(encoding="utf-8") + match = re.search(r'__version__ = "([^"]+)"', src) + print(match.group(1) if match else "0.0.0-dev") + return - with open("main.py", "w", encoding="utf-8") as f: - f.write(src) + version = sys.argv[1] if len(sys.argv) > 1 else "0.0.0-dev" + strict = bool(SEMVER.match(version)) + + print(f"Injecting version: {version}") + for path, pattern, make_repl in TARGETS: + _rewrite(path, pattern, make_repl(version)) + + if strict: + for path, pattern, make_repl in MSIX_TARGETS: + _rewrite(path, pattern, make_repl(version)) + else: + print(" skip MSIX manifests (version is not strict semver)") - print(f"Injected version: {version}") if __name__ == "__main__": main() diff --git a/tests/test_install_source.py b/tests/test_install_source.py new file mode 100644 index 0000000..f249047 --- /dev/null +++ b/tests/test_install_source.py @@ -0,0 +1,73 @@ +"""Tests for pdfreader_lib.install_source (channel-aware update source).""" + +import sys + +from pdfreader_lib import install_source + +STORE_EXE = ( + r"C:\Program Files\WindowsApps\SparshSam.OpenReader_1.2.7.0_x64__yh0byntbzd2qw" + r"\OpenReader.exe" +) + + +def _patch_runtime(monkeypatch, frozen: bool, executable: str, pfn=None): + if frozen: + monkeypatch.setattr(sys, "frozen", True, raising=False) + else: + monkeypatch.delattr(sys, "frozen", raising=False) + monkeypatch.setattr(sys, "executable", executable) + monkeypatch.setattr(install_source, "_package_family_name", lambda: pfn) + + +def test_source_when_not_frozen(monkeypatch): + _patch_runtime(monkeypatch, False, "/usr/bin/python3", pfn=None) + assert install_source.detect_install_source() == install_source.SOURCE + + +def test_msix_detected_via_package_api(monkeypatch): + """Packaged execution is signalled by the Windows package API, not the path.""" + _patch_runtime( + monkeypatch, + True, + r"C:\Packages\OpenReader.exe", # not WindowsApps — the API is the signal + pfn=install_source.PFN, + ) + assert install_source.detect_install_source() == install_source.STORE_MSIX + + +def test_msix_windowsapps_path_fallback(monkeypatch): + """The WindowsApps path remains a fallback when the API is unavailable.""" + _patch_runtime(monkeypatch, True, STORE_EXE, pfn=None) + assert install_source.detect_install_source() == install_source.STORE_MSIX + + +def test_msix_marker_matches_differing_case(monkeypatch): + _patch_runtime( + monkeypatch, + True, + r"C:\Program Files\WindowsApps\SomePkg\OpenReader.exe", + pfn=None, + ) + assert install_source.detect_install_source() == install_source.STORE_MSIX + + +def test_program_files_is_setup_exe(monkeypatch): + _patch_runtime( + monkeypatch, True, r"C:\Program Files\OpenReader\OpenReader.exe", pfn=None + ) + assert install_source.detect_install_source() == install_source.SETUP_EXE + + +def test_elsewhere_is_portable_zip(monkeypatch): + _patch_runtime(monkeypatch, True, r"D:\Tools\OpenReader\OpenReader.exe", pfn=None) + assert install_source.detect_install_source() == install_source.PORTABLE_ZIP + + +def test_all_channels_distinct(): + channels = { + install_source.SOURCE, + install_source.STORE_MSIX, + install_source.SETUP_EXE, + install_source.PORTABLE_ZIP, + } + assert len(channels) == 4 diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..6f42dcc --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,114 @@ +"""Packaging validation tests for OpenReader. + +These tests parse the MSIX manifest, AppInstaller config, and MCP package +metadata directly. They deliberately do NOT import main.py (which pulls in +PySide6) — the app version is read from source via regex. + +Covered: +- Frozen MSIX identity (Name / Publisher) is unchanged. +- The .pdf file-type association is declared for FullTrust activation. +- Every version source agrees with main.py.__version__. +- A version-regex regression cannot touch MinVersion/MaxVersionTested. +""" + +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +APPX_NS = "http://schemas.microsoft.com/appx/manifest/foundation/windows10" +UAP_NS = "http://schemas.microsoft.com/appx/manifest/uap/windows10" +APPINSTALLER_NS = "http://schemas.microsoft.com/appx/appinstaller/2021" + +IDENTITY_NAME = "SparshSam.OpenReader" +IDENTITY_PUBLISHER = "CN=E6186421-BF8A-47E0-A89C-0F513DFF91C0" +EXECUTABLE = "OpenReader.exe" +ENTRY_POINT = "Windows.FullTrustApplication" + + +def _read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def main_version() -> str: + match = re.search(r'__version__ = "([^"]+)"', _read("main.py")) + assert match, "could not find __version__ in main.py" + return match.group(1) + + +def _appx_root() -> ET.Element: + return ET.fromstring(_read("packaging/msix/AppxManifest.xml")) + + +def test_frozen_identity_unchanged(): + identity = _appx_root().find(f"{{{APPX_NS}}}Identity") + assert identity is not None + assert identity.get("Name") == IDENTITY_NAME + assert identity.get("Publisher") == IDENTITY_PUBLISHER + + +def test_application_is_fulltrust_open_reader(): + application = _appx_root().find( + f"{{{APPX_NS}}}Applications/{{{APPX_NS}}}Application" + ) + assert application is not None + assert application.get("Id") == "OpenReader" + assert application.get("Executable") == EXECUTABLE + assert application.get("EntryPoint") == ENTRY_POINT + + +def test_pdf_file_type_association_declared(): + ext = _appx_root().find( + f"{{{APPX_NS}}}Applications/{{{APPX_NS}}}Application/" + f"{{{APPX_NS}}}Extensions/{{{UAP_NS}}}Extension" + ) + assert ext is not None + assert ext.get("Category") == "windows.fileTypeAssociation" + + fta = ext.find(f"{{{UAP_NS}}}FileTypeAssociation") + assert fta is not None + assert fta.get("Name") == "openreader" + + display = fta.find(f"{{{UAP_NS}}}DisplayName") + assert display is not None and display.text + logo = fta.find(f"{{{UAP_NS}}}Logo") + assert logo is not None and logo.text + + file_types = [ + t.text + for t in fta.findall(f"{{{UAP_NS}}}SupportedFileTypes/{{{UAP_NS}}}FileType") + ] + assert ".pdf" in file_types + + +def test_manifest_version_matches_main_version(): + identity = _appx_root().find(f"{{{APPX_NS}}}Identity") + assert identity is not None + assert identity.get("Version") == f"{main_version()}.0" + + +def test_appinstaller_name_and_version(): + root = ET.fromstring(_read("packaging/msix/AppInstaller.xml")) + main_pkg = root.find(f"{{{APPINSTALLER_NS}}}MainPackage") + assert main_pkg is not None + assert main_pkg.get("Name") == IDENTITY_NAME + assert main_pkg.get("Version") == f"{main_version()}.0" + + +def test_mcp_package_version_matches_main_version(): + text = _read("packages/mcp-server/pyproject.toml") + match = re.search(r'^version = "([^"]+)"', text, re.MULTILINE) + assert match, "could not find version in packages/mcp-server/pyproject.toml" + assert match.group(1) == main_version() + + +def test_version_patch_cannot_touch_min_max_version(): + # CI DOM-patches only Identity.Version. This guards against a regression + # where a version rewrite could clobber MinVersion/MaxVersionTested. + dep = _appx_root().find( + f"{{{APPX_NS}}}Dependencies/{{{APPX_NS}}}TargetDeviceFamily" + ) + assert dep is not None + assert dep.get("MinVersion") == "10.0.17763.0" + assert dep.get("MaxVersionTested") == "10.0.22621.0" diff --git a/tests/test_path_handling.py b/tests/test_path_handling.py new file mode 100644 index 0000000..9a2e135 --- /dev/null +++ b/tests/test_path_handling.py @@ -0,0 +1,41 @@ +"""Tests for application-boundary path handling (file-open activation). + +Windows activates OpenReader by passing the PDF path as a quoted ``%1`` argv +element, so the path survives intact even with spaces, parentheses, or +non-ASCII characters. ``filter_pdf_paths`` is the boundary where argv is +reduced to PDF paths, and the IPC hand-off serializes them as JSON +(Unicode-safe). These tests exercise that boundary without faking Windows. +""" + +import json + +import main + + +def test_filter_pdf_paths_keeps_quirky_pdf_paths(): + paths = [ + r"C:\Users\test\My Documents\report (final).pdf", + r"C:\Users\test\我的文件\文档.pdf", + "C:\\Users\\test\\folder with spaces\\nested\\deep\\" + "x" * 180 + ".pdf", + ] + args = ["OpenReader.exe", *paths, "--flag", "notes.txt", "readme.md"] + assert main.filter_pdf_paths(args) == paths + + +def test_filter_pdf_paths_is_case_insensitive_on_suffix(): + args = [r"C:\test\REPORT.PDF", r"C:\test\scan.pdf", r"C:\test\notes.txt"] + assert main.filter_pdf_paths(args) == [r"C:\test\REPORT.PDF", r"C:\test\scan.pdf"] + + +def test_filter_pdf_paths_drops_non_pdf_args(): + assert main.filter_pdf_paths(["OpenReader.exe", "--flag", "file.txt", "readme.md"]) == [] + + +def test_ipc_json_round_trip_preserves_unicode_paths(): + """The IPC hand-off is JSON; Unicode paths must survive the round trip.""" + paths = [ + r"C:\test\我的文件\文档.pdf", + r"C:\test\report (final).pdf", + "C:\\test\\" + "n" * 200 + ".pdf", + ] + assert json.loads(json.dumps(paths)) == paths diff --git a/tests/test_reliability.py b/tests/test_reliability.py index 636aca0..316b19b 100644 --- a/tests/test_reliability.py +++ b/tests/test_reliability.py @@ -254,11 +254,11 @@ def test_compress_size_guard_detects_worse_compression(self): assert "Compression was not beneficial" in src assert "unlink(missing_ok=True)" in src - def test_unsigned_publisher_doc_added(self): - """README should document the unsigned 'Unknown Publisher' status.""" + def test_distribution_channels_documented(self): + """README should document the Store and GitHub distribution channels.""" readme = (Path(ROOT) / "README.md").read_text() - assert 'Unknown Publisher' in readme - assert 'code-signing' in readme + assert "apps.microsoft.com/detail/9MXDVW2645LL" in readme + assert "github.com/sparshsam/openreader/releases" in readme def test_open_pdf_cancelled_message_is_clean(self): """Cancel should show exactly one clean message, not cascading fallback messages.""" @@ -287,9 +287,9 @@ def test_tab_data_defaults_to_fit_on_open(self): assert tab.fit_to_window is True assert tab.zoom == 1.25 - def test_version_is_1_2_4(self): + def test_version_is_1_2_7(self): import main as m - assert m.__version__ == "1.2.4" + assert m.__version__ == "1.2.7" class TestZoomUi: diff --git a/tests/test_security.py b/tests/test_security.py index 80d671c..3647979 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -89,15 +89,16 @@ def test_update_temp_dir_is_not_cwd(self): class TestSubprocessSafety: - def test_subprocess_only_for_update(self): - """Only the updater should use subprocess.""" + def test_no_subprocess_spawning(self): + """The app never spawns subprocesses (self-update was removed). + + Update detection now opens a browser/Store page instead of shelling out. + """ src = Path(__file__).resolve().parent.parent / "main.py" content = src.read_text() - # subprocess should only appear in updater methods - # Count occurrences - should be very few and all in updater - count = content.count("subprocess.Popen") - assert count >= 1, "subprocess should be used for updater" - assert count <= 3, f"Too many subprocess.Popen calls ({count}); review for safety" + assert "subprocess.Popen" not in content + assert "subprocess.run" not in content + assert "subprocess.call" not in content def test_all_subprocess_has_nosec_comment(self): """Every subprocess.Popen call should have # nosec comment.""" diff --git a/tests/test_update_gating.py b/tests/test_update_gating.py new file mode 100644 index 0000000..ff5b76a --- /dev/null +++ b/tests/test_update_gating.py @@ -0,0 +1,97 @@ +"""Tests for channel-aware update gating (v1.2.7). + +Covers the pure ``should_suppress_silent_notify`` helper, the presence of the +store-aware dialogs/helpers wired into main.py, and safe semantic-version +comparison for development/test version strings. +""" + +import json + +import main + + +def test_skip_suppresses_same_release(): + result = {"outcome": "update_available", "latest_tag": "v1.2.7"} + assert main.should_suppress_silent_notify(result, "v1.2.7") is True + + +def test_newer_release_still_notifies(): + result = {"outcome": "update_available", "latest_tag": "v1.2.8"} + assert main.should_suppress_silent_notify(result, "v1.2.7") is False + + +def test_no_skip_preference_notifies(): + result = {"outcome": "update_available", "latest_tag": "v1.2.7"} + assert main.should_suppress_silent_notify(result, "") is False + assert main.should_suppress_silent_notify(result, None) is False + + +def test_non_update_outcomes_never_suppressed(): + for outcome in ("already_latest", "network_error", "http_error", "json_error"): + result = {"outcome": outcome, "latest_tag": "v1.2.7"} + assert main.should_suppress_silent_notify(result, "v1.2.7") is False + + +def test_store_aware_pieces_exist(): + assert hasattr(main, "_SoftwareUpdatesDialog") + assert hasattr(main.PdfReaderWindow, "_is_store") + assert main.SETTINGS_UPDATE_SKIP_KEY == "updateSkipVersion" + assert main.SETTINGS_UPDATE_LAST_CHECKED_KEY == "updateLastChecked" + + +# --------------------------------------------------------------------------- +# Semantic-version comparison safety (1.2.7-test vs 1.2.4 / 1.2.7 / 1.2.8) +# --------------------------------------------------------------------------- + + +def test_parse_version_strips_test_and_prerelease_suffixes(): + parse = main.PdfReaderWindow._parse_version + assert parse("1.2.7-test") == (1, 2, 7) + assert parse("1.2.7") == (1, 2, 7) + assert parse("v1.2.7") == (1, 2, 7) + assert parse("1.2.4") == (1, 2, 4) + assert parse("v1.2.8-beta.1") == (1, 2, 8) + + +def test_parse_version_rejects_malformed(): + assert main.PdfReaderWindow._parse_version("not_a_version") is None + assert main.PdfReaderWindow._parse_version("") is None + + +def _classify(tag, current): + return main.PdfReaderWindow._classify_update_response( + 200, False, "", json.dumps({"tag_name": tag}), current + ) + + +def test_test_build_not_downgraded_by_older_release(): + """1.2.7-test must never report v1.2.4 as newer.""" + result = _classify("v1.2.4", "1.2.7-test") + assert result["outcome"] == "already_latest" + + +def test_test_build_vs_same_base_release(): + result = _classify("v1.2.7", "1.2.7-test") + assert result["outcome"] == "already_latest" + + +def test_newer_release_still_notifies_test_build(): + result = _classify("v1.2.8", "1.2.7-test") + assert result["outcome"] == "update_available" + + +def test_malformed_remote_version_is_json_error(): + result = _classify("garbage", "1.2.7") + assert result["outcome"] == "json_error" + + +def test_missing_remote_tag_is_json_error(): + result = main.PdfReaderWindow._classify_update_response( + 200, False, "", json.dumps({"draft": True}), "1.2.7" + ) + assert result["outcome"] == "json_error" + + +def test_unparseable_current_version_falls_back_to_update_available(): + result = _classify("v1.2.8", "not_a_version") + assert result["outcome"] == "update_available" diff --git a/tests/test_win_default_apps.py b/tests/test_win_default_apps.py new file mode 100644 index 0000000..c8d63b6 --- /dev/null +++ b/tests/test_win_default_apps.py @@ -0,0 +1,244 @@ +"""Tests for pdfreader_lib.win_default_apps (Windows default-app detection). + +The registry is faked with an in-memory HKCU + HKLM tree so no real registry +is touched. On non-Windows without a patch, every function must return a +neutral value. +""" + +import sys + +import pytest + +from pdfreader_lib import win_default_apps as wda + + +class _Handle: + """Opaque registry key handle.""" + + def __init__(self, root, path): + self.root = root + self.path = path + + +class FakeWinreg: + """In-memory HKCU + HKLM registry: {hive: {path: {value_name: value}}}.""" + + HKEY_CURRENT_USER = "HKCU" + HKEY_LOCAL_MACHINE = "HKLM" + + def __init__(self): + self._trees = {self.HKEY_CURRENT_USER: {}, self.HKEY_LOCAL_MACHINE: {}} + + def _tree(self, root): + return self._trees.setdefault(root, {}) + + def set_value(self, path, name, value, root=None): + self._tree(root or self.HKEY_CURRENT_USER).setdefault(path, {})[name] = value + + def set_key(self, path, root=None): + self._tree(root or self.HKEY_CURRENT_USER).setdefault(path, {}) + + # -- winreg API used by the module ------------------------------- + def OpenKey(self, root, path): + tree = self._tree(root) + # Registry semantics: opening an ancestor of an existing key works. + if path in tree: + return _Handle(root, path) + prefix = path + "\\" if path else "" + if any(key.startswith(prefix) for key in tree): + return _Handle(root, path) + raise FileNotFoundError(path) + + def QueryValueEx(self, handle, name): + node = self._tree(handle.root).get(handle.path, {}) + if name not in node: + raise FileNotFoundError(name) + return (node[name], None) + + def EnumKey(self, handle, index): + tree = self._tree(handle.root) + prefix = handle.path + "\\" if handle.path else "" + subkeys = set() + for key in tree: + if key.startswith(prefix): + rest = key[len(prefix):] + if rest: + subkeys.add(rest.split("\\")[0]) + ordered = sorted(subkeys) + if index >= len(ordered): + raise OSError("no more keys") + return ordered[index] + + def CloseKey(self, handle): + pass + + +@pytest.fixture +def fake_windows(monkeypatch): + fake = FakeWinreg() + monkeypatch.setattr(wda, "winreg", fake) + monkeypatch.setattr(sys, "platform", "win32") + return fake + + +# --------------------------------------------------------------------------- +# Non-Windows neutrality +# --------------------------------------------------------------------------- + + +def test_non_windows_returns_neutral(): + assert wda.is_windows() is False + assert wda.get_userchoice_progid() is None + assert wda.resolve_progid_owner("OpenReaderPDF") is None + assert wda.default_app_owner() == (False, None) + assert wda.association_registered() is False + assert wda.friendly_app_name("Acrobat.Document.DC") is None + assert wda.open_default_apps_settings() is False + + +# --------------------------------------------------------------------------- +# Inno installer channel +# --------------------------------------------------------------------------- + + +def test_inno_progid_detected_as_default(fake_windows): + fake_windows.set_value(wda.USERCHOICE_KEY, "ProgId", wda.INNO_PROGID) + fake_windows.set_key(rf"{wda.CLASSES_KEY}\{wda.INNO_PROGID}") + assert wda.default_app_owner() == (True, wda.INNO_PROGID) + assert wda.resolve_progid_owner(wda.INNO_PROGID) == wda.OPENREADER + assert wda.association_registered() is True + + +def test_inno_progid_registered_under_hklm_still_detected(fake_windows): + """Admin Inno installs write HKCR entries to HKLM; registration must still resolve.""" + fake_windows.set_value(wda.USERCHOICE_KEY, "ProgId", wda.INNO_PROGID) + fake_windows.set_key( + rf"{wda.CLASSES_KEY}\{wda.INNO_PROGID}", + root=fake_windows.HKEY_LOCAL_MACHINE, + ) + assert wda.default_app_owner() == (True, wda.INNO_PROGID) + assert wda.association_registered() is True + + +def test_inno_progid_resolved_via_shell_command(fake_windows): + """Ownership is defensible via the registered open command, not just the name.""" + progid = "OpenReaderPDF.Generated" # hypothetical generated identifier + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\{progid}\shell\open\command", + None, + '"C:\\Program Files\\OpenReader\\OpenReader.exe" "%1"', + ) + assert wda.resolve_progid_owner(progid) == wda.OPENREADER + + +# --------------------------------------------------------------------------- +# MSIX / AppX channel +# --------------------------------------------------------------------------- + + +def test_appx_progid_resolved_via_aumid(fake_windows): + progid = "AppXsomepkgopenreader" + fake_windows.set_value(wda.USERCHOICE_KEY, "ProgId", progid) + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\{progid}\Application", "AppUserModelId", wda.AUMID + ) + assert wda.resolve_progid_owner(progid) == wda.OPENREADER + assert wda.default_app_owner() == (True, progid) + assert wda.association_registered() is True + + +def test_appx_scan_finds_our_aumid_under_hklm(fake_windows): + """Packaged registration may land in either hive; both are scanned.""" + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\AppXours\Application", + "AppUserModelId", + wda.AUMID, + root=fake_windows.HKEY_LOCAL_MACHINE, + ) + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\AppXothers\Application", + "AppUserModelId", + "OtherApp!X", + root=fake_windows.HKEY_LOCAL_MACHINE, + ) + assert wda.association_registered() is True + + +# --------------------------------------------------------------------------- +# Other / unknown / missing handlers +# --------------------------------------------------------------------------- + + +def test_other_app_is_default(fake_windows): + progid = "Acrobat.Document.DC" + fake_windows.set_value(wda.USERCHOICE_KEY, "ProgId", progid) + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\{progid}\Application", + "AppUserModelId", + "Acrobat!DC", + ) + assert wda.default_app_owner() == (False, progid) + assert wda.resolve_progid_owner(progid) == wda.OTHER + + +def test_other_app_resolved_via_shell_command(fake_windows): + progid = "Acrobat.Document.DC" + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\{progid}\shell\open\command", + None, + '"C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroRd32.exe" "%1"', + ) + assert wda.resolve_progid_owner(progid) == wda.OTHER + + +def test_unresolvable_progid_is_unknown(fake_windows): + progid = "Some.Opaque.ProgId" + fake_windows.set_value(wda.USERCHOICE_KEY, "ProgId", progid) + assert wda.default_app_owner() == (False, progid) + assert wda.resolve_progid_owner(progid) is None + + +def test_missing_userchoice_reports_no_default(fake_windows): + assert wda.default_app_owner() == (False, None) + + +def test_no_association_registered(fake_windows): + assert wda.association_registered() is False + + +# --------------------------------------------------------------------------- +# Friendly names and opening Default Apps +# --------------------------------------------------------------------------- + + +def test_friendly_app_name_from_application_subkey(fake_windows): + progid = "Acrobat.Document.DC" + fake_windows.set_value( + rf"{wda.CLASSES_KEY}\{progid}\Application", "FriendlyAppName", "Acrobat Reader" + ) + assert wda.friendly_app_name(progid) == "Acrobat Reader" + + +def test_friendly_app_name_falls_back_to_default_value(fake_windows): + progid = wda.INNO_PROGID + fake_windows.set_value(rf"{wda.CLASSES_KEY}\{progid}", None, "PDF Document") + assert wda.friendly_app_name(progid) == "PDF Document" + + +def test_open_default_apps_settings_on_windows(fake_windows, monkeypatch): + calls = [] + + def fake_startfile(target): + calls.append(target) + + monkeypatch.setattr(wda.os, "startfile", fake_startfile, raising=False) + assert wda.open_default_apps_settings() is True + assert calls == ["ms-settings:defaultapps"] + + +def test_open_default_apps_settings_failure(fake_windows, monkeypatch): + def boom(_): + raise OSError("no shell") + + monkeypatch.setattr(wda.os, "startfile", boom, raising=False) + assert wda.open_default_apps_settings() is False