From 3614384eede9b04d7cbbfb39a54223b7cad66fe3 Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:29:02 -0600 Subject: [PATCH 01/11] Add signed Windows updates and platform maintenance revision --- .github/workflows/build-windows-candidate.yml | 6 +- CHANGELOG.md | 14 ++ PRIVACY.md | 10 +- README.md | 2 +- THIRD_PARTY_NOTICES.md | 5 + build_windows.py | 36 +++- docs/RELEASING.md | 57 ++++- docs/WINDOWS.md | 15 +- docs/WINDOWS_TESTING.md | 22 ++ docs/appcast-windows.xml | 9 + fs_pdf_compressor/version.py | 3 +- fs_pdf_compressor/windows_update.py | 111 ++++++++++ installer/windows.iss | 4 + scripts/generate_windows_appcast.py | 102 +++++++++ tests/test_windows_update.py | 202 ++++++++++++++++++ windows_app.py | 65 +++++- 16 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 docs/appcast-windows.xml create mode 100644 fs_pdf_compressor/windows_update.py create mode 100644 scripts/generate_windows_appcast.py create mode 100644 tests/test_windows_update.py diff --git a/.github/workflows/build-windows-candidate.yml b/.github/workflows/build-windows-candidate.yml index d04bdcd..4a0d73f 100644 --- a/.github/workflows/build-windows-candidate.yml +++ b/.github/workflows/build-windows-candidate.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Version matching the checked-out source + description: Windows version matching WINDOWS_APP_VERSION in the checked-out source required: true permissions: @@ -82,7 +82,9 @@ jobs: "$app\ghostscript\bin\gswin64c.exe", "$app\ghostscript\AGPL-3.0.txt", "$app\SOURCE_OFFER.md", - "$app\THIRD_PARTY_MANIFEST.json" + "$app\THIRD_PARTY_MANIFEST.json", + "$app\WinSparkle.dll", + "$app\licenses\winsparkle\COPYING" ) | ForEach-Object { if (-not (Test-Path $_)) { throw "Missing runtime compliance file: $_" } } - name: Upload Windows release candidate diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d51fa..30dc5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.0.16.1 — Windows only — Unreleased + +- Add WinSparkle 0.9.4 to the Windows installer for optional automatic update + checks and user-approved installation of Ed25519-signed updates. +- Refuse update installation during PDF discovery, compression, or pending + Explorer requests; block new work after reserving the idle app for installation. +- Update the existing per-user installation and relaunch the application. + Keep portable ZIP updates manual. +- Add a separate Windows update feed, verified runtime packaging, release + signing tooling, privacy documentation, and regression coverage. +- Keep the shared macOS/Linux version at 1.0.16 and Ghostscript at 10.07.1. + The Windows revision requires Windows 11 end-to-end testing before release; + existing Windows users need one manual update to gain WinSparkle support. + ## 1.0.16 — 2026-09-29 - Restore Ghostscript 10.07.1 on macOS, Windows, AppImage, and Snap after a diff --git a/PRIVACY.md b/PRIVACY.md index a91e94b..dd5bef6 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -20,9 +20,13 @@ limited to software updates and links explicitly opened by the user: - On macOS, Sparkle periodically reads the public update feed hosted on GitHub Pages and can download signed update archives from GitHub Releases. -- On Windows, **Check for Updates…** opens the public GitHub Releases page in - the user's browser. The app does not check for or download updates in the - background. +- Windows installer builds with WinSparkle ask whether to check for updates + automatically. If enabled, they periodically read a public GitHub Pages feed. + **Check for Updates…** also checks that feed on request. Accepted updates are + downloaded from GitHub Releases and their Ed25519 signatures are verified + before installation. No document data is sent. Portable builds and the + published 1.0.16 Windows release open the GitHub Releases page for manual + updates instead. - On Linux, the AppImage contacts the GitHub Releases API only when the user selects **Check for Updates…**. If the user accepts an available update, it downloads the AppImage and its published SHA-256 checksum from GitHub. diff --git a/README.md b/README.md index 0c9822a..f350636 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ user-writable location so in-app updates can replace it safely. The Windows installer includes its dependencies and needs no administrator rights. It is currently unsigned, so verify the matching published SHA-256 -checksum before installing it. Windows updates are manual for now: download a +checksum before installing it. Windows updates in the published 1.0.16 release are manual: download a new installer from the official GitHub release when one is published. See [what changed in 1.0.16](docs/releases/1.0.16.md) or read the complete diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fd57d4d..c49e5b2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -55,6 +55,11 @@ The application and bundled Ghostscript remain available under AGPL-3.0-or-later ## Windows desktop runtime +Windows packages also include WinSparkle 0.9.4 (MIT) for signed installer +updates. Its license and bundled Expat license are included under +`_internal/licenses/winsparkle/`. Corresponding source: +https://github.com/vslavik/winsparkle/tree/v0.9.4 + Windows uses PySide6 and the unmodified official AGPL Ghostscript distribution. The pywin32 package provides access to the Windows Shell recycle operation; its license text is copied from the installed distribution into the package's diff --git a/build_windows.py b/build_windows.py index a94d09f..0389edd 100644 --- a/build_windows.py +++ b/build_windows.py @@ -19,6 +19,8 @@ import shutil import subprocess import sys +import urllib.request +import zipfile from importlib.metadata import version as package_version, distribution from pathlib import Path @@ -27,10 +29,35 @@ DIST = ROOT / os.environ.get("DIST_DIR", "release-windows") BUILD = ROOT / ".windows-build" APP_NAME = "FS PDF Compressor" -from fs_pdf_compressor.version import APP_VERSION +from fs_pdf_compressor.version import WINDOWS_APP_VERSION as APP_VERSION +from fs_pdf_compressor.windows_update import WINSPARKLE_VERSION ARCHITECTURE = "x86_64" GHOSTSCRIPT_VERSION = "10.07.1" PACKAGE_NAME = f"FS-PDF-Compressor-{APP_VERSION}-windows-{ARCHITECTURE}" +WINSPARKLE_SHA256 = "6037df37fc263bd1650a1c4949681a9d40ffe991d01f35892a406cb5d103c976" + + +def bundle_winsparkle(resources: Path) -> None: + """Download a pinned official archive and copy only the x64 runtime.""" + cache = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / ".cache"))) / APP_NAME + cache.mkdir(parents=True, exist_ok=True) + archive = cache / f"WinSparkle-{WINSPARKLE_VERSION}.zip" + if not archive.is_file(): + url = f"https://github.com/vslavik/winsparkle/releases/download/v{WINSPARKLE_VERSION}/{archive.name}" + request = urllib.request.Request(url, headers={"User-Agent": "FS-PDF-Compressor-build"}) + with urllib.request.urlopen(request, timeout=60) as response: + archive.write_bytes(response.read()) + if hashlib.sha256(archive.read_bytes()).hexdigest() != WINSPARKLE_SHA256: + archive.unlink() + raise RuntimeError("WinSparkle archive checksum did not match") + with zipfile.ZipFile(archive) as source: + prefix = f"WinSparkle-{WINSPARKLE_VERSION}/" + resources.mkdir(parents=True, exist_ok=True) + (resources / "WinSparkle.dll").write_bytes(source.read(prefix + "x64/Release/WinSparkle.dll")) + licenses = resources / "licenses" / "winsparkle" + licenses.mkdir(parents=True, exist_ok=True) + for name in ("COPYING", "COPYING.expat"): + (licenses / name).write_bytes(source.read(prefix + name)) def run(*args: str) -> None: @@ -154,7 +181,8 @@ def bundle_compliance_documents(resources: Path, ghostscript_version_value: str) f"https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/{source_tag}/" f"ghostpdl-{ghostscript_version_value}.tar.xz\n" "PySide6 source: https://code.qt.io/pyside/pyside-setup\n" - "PyInstaller source: https://github.com/pyinstaller/pyinstaller\n", + "PyInstaller source: https://github.com/pyinstaller/pyinstaller\n" + f"WinSparkle {WINSPARKLE_VERSION} source: https://github.com/vslavik/winsparkle/tree/v{WINSPARKLE_VERSION}\n", encoding="utf-8", ) (resources / "THIRD_PARTY_MANIFEST.json").write_text( @@ -169,6 +197,9 @@ def bundle_compliance_documents(resources: Path, ghostscript_version_value: str) "ghostscript": ghostscript_version_value, "ghostscript_license": "AGPL-3.0-or-later", "ghostscript_modified": False, + "winsparkle": WINSPARKLE_VERSION, + "winsparkle_license": "MIT", + "winsparkle_source": f"https://github.com/vslavik/winsparkle/tree/v{WINSPARKLE_VERSION}", }, indent=2, sort_keys=True, @@ -216,6 +247,7 @@ def main() -> None: ) application = ROOT / "dist" / APP_NAME resources = application / "_internal" + bundle_winsparkle(resources) _, bundled_ghostscript_version = bundle_ghostscript(resources) bundle_compliance_documents(resources, bundled_ghostscript_version) archive_base = DIST / PACKAGE_NAME diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4bd9c9f..0d6f359 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -132,7 +132,7 @@ automatically. Store credentials must remain outside the repository. ### Windows 11 x64 releases Run **Build Windows x64** from the exact tag being released and enter the same -version stored in `fs_pdf_compressor/version.py`. The workflow rejects a +Windows version (`WINDOWS_APP_VERSION`) stored in `fs_pdf_compressor/version.py`. The workflow rejects a version that does not match the checked-out source and produces these files: ```text @@ -225,6 +225,61 @@ at its GitHub URL. ## Release compliance +### Windows signed updates + +Windows installer builds bundle the official WinSparkle 0.9.4 x64 DLL. The +builder verifies the pinned archive SHA-256 before copying it. The separate +`docs/appcast-windows.xml` feed is initially empty; never add unsigned updates. +The portable ZIP keeps manual updates. Only the executable whose directory +matches the installer's HKCU `Software\gitlares\FS PDF Compressor\InstallDir` +uses WinSparkle. Existing releases need one manual installer update to gain +this integration. + +WinSparkle asks whether to check automatically on first launch and retains the +user's choice. Automatic checks use a daily interval; installation requires the +user's approval in its update dialog. A busy application rejects installation, +including file discovery and pending Explorer requests. Once installation is +approved while idle, new compression work is blocked until shutdown. Inno Setup +updates the existing installation and relaunches the app. + +Updates use the existing Sparkle Ed25519 signing identity in the macOS login +Keychain. Only its public verification key is embedded in the Windows app. +This update signature does not provide Authenticode signing or remove Windows +SmartScreen warnings. + +After completing the Windows 11 release checklist and uploading the exact +installer plus SHA-256 file to its matching GitHub Release, run from the repo +root with a Python environment containing `cryptography`: + +```sh +python3 -m scripts.generate_windows_appcast 1.0.16.1 \ + release-windows/FS-PDF-Compressor-1.0.16.1-windows-x86_64-setup.exe +``` + +The generator checks the published checksum, signs locally using Sparkle's +`sign_update`, and verifies the signature against the Windows app's public key. +It does not export private keys or publish the feed. Review and publish +`docs/appcast-windows.xml` only after the release assets exist. + +Before publishing the first enabled release, test on Windows 11 x64: first-run +automatic-check permission and persistence, manual update checks, no-update +behavior, an actual signed upgrade to a newer test version, rejected tampered +installer, installation deferred during discovery/compression, application +shutdown/relaunch, preserved Keep original and Drop Zone preferences, Explorer +integration, and portable ZIP manual-update behavior. Verify `_internal` contains +`WinSparkle.dll` and `licenses/winsparkle/COPYING`. Do not claim end-to-end +Windows updates based on unit tests or a macOS-only run. + +For a Windows-only maintenance revision, keep `APP_VERSION` at the shared +product version and increment `WINDOWS_APP_VERSION` (for example, 1.0.16.1). +Build from its dedicated source tag `v1.0.16.1-windows` and use 1.0.16.1 as the +Windows workflow input. After testing, publish a Windows-only GitHub Release +with `make_latest=false`, containing only its installer, portable ZIP and +checksums. Do not mark it as a prerelease or replace the general release. +The feed generator uses that Windows-specific tag for four-part versions. +Update Windows download links to that exact release after publication; keep +macOS/Linux links and `docs/appcast.xml` on the existing shared release. + Before publishing, create and push the Git tag named by `SOURCE_REF`. Attach the platform packages to the matching GitHub Release instead of committing binaries to Git. Every published package must be produced from that release diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 0da897a..5291fb9 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -88,13 +88,14 @@ Install the setup executable on a clean Windows VM and check: 6. With **Keep original** on, the original remains beside `name compressed.pdf`. -Windows does not yet provide an in-app auto-update mechanism. Obtain new -versions from the project's GitHub releases after they are published. +The published 1.0.16 Windows build uses manual updates. Installer builds from +the current source add WinSparkle, subject to the +[update release checklist](WINDOWS_TESTING.md#winsparkle-update-checklist). ## Reproducible release builds For ordinary public releases, build from the exact Git tag that defines the -shared version used by macOS, Linux, and Windows. Set `APP_VERSION` and +Windows version in `WINDOWS_APP_VERSION`. Set the build environment's `APP_VERSION` and `SOURCE_REF` to that version/tag when producing the installer. The **Build Windows x64** GitHub Actions workflow is the preferred packaging @@ -103,6 +104,8 @@ version. Download the resulting installer, portable ZIP, and SHA-256 files, then complete [Windows release testing](WINDOWS_TESTING.md) before attaching them to the matching GitHub Release. -Version 1.0.13 introduced the first Windows package. Current shared releases -must build every published platform from the same version tag and identify the -exact public source commit in their release notes. +Windows-only revisions use a four-part version such as 1.0.16.1, with source +tag `v1.0.16.1-windows`. They do not change the shared macOS/Linux version. +Publish them as Windows-only releases without making them the general latest +release; see [release instructions](RELEASING.md#windows-signed-updates). +Every package must identify the exact public source commit used to build it. diff --git a/docs/WINDOWS_TESTING.md b/docs/WINDOWS_TESTING.md index 63179b2..43dbba7 100644 --- a/docs/WINDOWS_TESTING.md +++ b/docs/WINDOWS_TESTING.md @@ -50,3 +50,25 @@ installer itself has a problem: Record the Windows edition, architecture, Ghostscript version, and any SmartScreen message with the test result. Do not publish either package until the checklist succeeds. + +## WinSparkle update checklist + +For the first installer release with WinSparkle, use two successive candidate +versions and a test feed signed with the embedded verification key: + +1. Confirm `_internal/WinSparkle.dll` and its license texts are packaged. +2. Confirm the first-run automatic-check choice persists after restarting. +3. Run **Check for Updates…** against a feed with no newer release. +4. Offer a newer signed installer and verify download, installation in the + existing directory, shutdown, and relaunch without duplicate instances. +5. Confirm Keep original, Drop Zone, and Explorer integration survive upgrading. +6. Change the downloaded installer bytes and verify installation is rejected. +7. Attempt installation while discovering a folder or compressing a batch; + verify the current work is preserved and installation is refused until idle. +8. Once installation starts, verify new drops and **Again** cannot start work. +9. Unpack the portable ZIP in another directory and confirm it uses the manual + release-page link and never runs the installed application's updater. + +See [signed Windows updates](RELEASING.md#windows-signed-updates) for feed +generation. The Ed25519 update signature does not replace Authenticode; the +Windows installer remains unsigned unless separately code-signed. diff --git a/docs/appcast-windows.xml b/docs/appcast-windows.xml new file mode 100644 index 0000000..08f977b --- /dev/null +++ b/docs/appcast-windows.xml @@ -0,0 +1,9 @@ + + + + FS PDF Compressor for Windows + https://gitlares.github.io/fs-pdf-compressor/windows/ + Signed Windows x64 installer updates + en + + diff --git a/fs_pdf_compressor/version.py b/fs_pdf_compressor/version.py index 011c9d8..015f257 100644 --- a/fs_pdf_compressor/version.py +++ b/fs_pdf_compressor/version.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -"""Version embedded in every platform's source and frozen application.""" +"""Shared product version and optional platform-specific maintenance revision.""" APP_VERSION = "1.0.16" +WINDOWS_APP_VERSION = "1.0.16.1" diff --git a/fs_pdf_compressor/windows_update.py b/fs_pdf_compressor/windows_update.py new file mode 100644 index 0000000..ee30939 --- /dev/null +++ b/fs_pdf_compressor/windows_update.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""WinSparkle bridge. Native callbacks never access Qt widgets.""" + +from __future__ import annotations + +import ctypes +import sys +import threading +from pathlib import Path + +from fs_pdf_compressor.version import WINDOWS_APP_VERSION as APP_VERSION + +WINSPARKLE_VERSION = "0.9.4" +FEED_URL = "https://gitlares.github.io/fs-pdf-compressor/appcast-windows.xml" +# Public verification key shared with the existing macOS Sparkle release key. +# The corresponding private key remains in the release maintainer's Keychain. +PUBLIC_KEY = "vpBiAIADYx+8qPd1IvWCIqzok3ne9PmB2WAaZgsXa8M=" +INSTALL_REGISTRY_KEY = r"Software\gitlares\FS PDF Compressor" + + +def is_installed_application() -> bool: + """Do not install an EXE update over a portable ZIP or source checkout.""" + if sys.platform != "win32" or not getattr(sys, "frozen", False): + return False + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, INSTALL_REGISTRY_KEY) as key: + directory, _ = winreg.QueryValueEx(key, "InstallDir") + return Path(directory).resolve() == Path(sys.executable).resolve().parent + except (OSError, TypeError, ValueError): + return False + + +class WindowsUpdater: + """Keep callback lifetimes and atomically reserve an idle app for install.""" + + def __init__(self, shutdown_requested, library=None): + self._lock = threading.Lock() + self._busy = True + self._installing = False + self._initialized = False + root = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) + self._dll = library if library is not None else ctypes.CDLL(str(root / "WinSparkle.dll")) + can_shutdown_type = ctypes.CFUNCTYPE(ctypes.c_int) + shutdown_type = ctypes.CFUNCTYPE(None) + self._can_shutdown_callback = can_shutdown_type(self._reserve_install) + self._shutdown_callback = shutdown_type(shutdown_requested) + self._error_callback = shutdown_type(self._installation_failed) + signatures = { + "set_app_details": ([ctypes.c_wchar_p] * 3, None), + "set_appcast_url": ([ctypes.c_char_p], None), + "set_eddsa_public_key": ([ctypes.c_char_p], ctypes.c_int), + "set_lang": ([ctypes.c_char_p], None), + "set_update_check_interval": ([ctypes.c_int], None), + "set_can_shutdown_callback": ([can_shutdown_type], None), + "set_shutdown_request_callback": ([shutdown_type], None), + "set_error_callback": ([shutdown_type], None), + "init": ([], None), + "cleanup": ([], None), + "check_update_with_ui": ([], None), + } + for name, (args, result) in signatures.items(): + function = getattr(self._dll, "win_sparkle_" + name) + function.argtypes, function.restype = args, result + self._dll.win_sparkle_set_app_details("gitlares", "FS PDF Compressor", APP_VERSION) + self._dll.win_sparkle_set_appcast_url(FEED_URL.encode("ascii")) + if self._dll.win_sparkle_set_eddsa_public_key(PUBLIC_KEY.encode("ascii")) != 1: + raise RuntimeError("WinSparkle rejected the update verification key") + self._dll.win_sparkle_set_lang(b"en") + self._dll.win_sparkle_set_update_check_interval(86400) + self._dll.win_sparkle_set_can_shutdown_callback(self._can_shutdown_callback) + self._dll.win_sparkle_set_shutdown_request_callback(self._shutdown_callback) + self._dll.win_sparkle_set_error_callback(self._error_callback) + + def start(self): + if not self._initialized: + # WinSparkle asks on first launch whether to check automatically; + # subsequent launches preserve that user's registry preference. + self._dll.win_sparkle_init() + self._initialized = True + + def begin_work(self) -> bool: + with self._lock: + if self._installing: + return False + self._busy = True + return True + + def set_busy(self, busy: bool): + with self._lock: + self._busy = busy + + def _reserve_install(self) -> int: + with self._lock: + if self._busy or self._installing: + return 0 + self._installing = True + return 1 + + def check(self): + self._dll.win_sparkle_check_update_with_ui() + + def _installation_failed(self): + with self._lock: + self._installing = False + self._busy = True + + def cleanup(self): + if self._initialized: + self._dll.win_sparkle_cleanup() + self._initialized = False diff --git a/installer/windows.iss b/installer/windows.iss index 218b842..234e7f7 100644 --- a/installer/windows.iss +++ b/installer/windows.iss @@ -38,6 +38,8 @@ PrivilegesRequired=lowest UninstallDisplayIcon={app}\_internal\PDFCompresor.ico SetupIconFile={#SourceDir}\_internal\PDFCompresor.ico WizardStyle=modern +CloseApplications=yes +RestartApplications=no [Files] Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -50,6 +52,7 @@ Name: "{autodesktop}\FS PDF Compressor"; Filename: "{app}\FS PDF Compressor.exe" Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:" [Registry] +Root: HKCU; Subkey: "Software\gitlares\FS PDF Compressor"; ValueType: string; ValueName: "InstallDir"; ValueData: "{app}"; Flags: uninsdeletevalue Root: HKCU; Subkey: "Software\Classes\SystemFileAssociations\.pdf\shell\CompressWithFSPDFCompressor"; ValueType: string; ValueName: "MUIVerb"; ValueData: "Compress with FS PDF Compressor"; Flags: uninsdeletekey Root: HKCU; Subkey: "Software\Classes\SystemFileAssociations\.pdf\shell\CompressWithFSPDFCompressor"; ValueType: string; ValueName: "MultiSelectModel"; ValueData: "Player" Root: HKCU; Subkey: "Software\Classes\SystemFileAssociations\.pdf\shell\CompressWithFSPDFCompressor"; ValueType: string; ValueName: "Icon"; ValueData: "{app}\_internal\PDFCompresor.ico" @@ -57,3 +60,4 @@ Root: HKCU; Subkey: "Software\Classes\SystemFileAssociations\.pdf\shell\Compress [Run] Filename: "{app}\FS PDF Compressor.exe"; Description: "Launch FS PDF Compressor"; Flags: nowait postinstall skipifsilent +Filename: "{app}\FS PDF Compressor.exe"; Flags: nowait runasoriginaluser; Check: WizardSilent diff --git a/scripts/generate_windows_appcast.py b/scripts/generate_windows_appcast.py new file mode 100644 index 0000000..f219c79 --- /dev/null +++ b/scripts/generate_windows_appcast.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Sign an already-published Windows installer without exporting private keys.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import re +import subprocess +import urllib.request +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from email.utils import format_datetime +from pathlib import Path + +from fs_pdf_compressor.windows_update import PUBLIC_KEY + +ROOT = Path(__file__).resolve().parents[1] +SPARKLE = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", SPARKLE) + + +def release_tag(version: str) -> str: + if not re.fullmatch(r"\d+\.\d+\.\d+(?:\.\d+)?", version): + raise ValueError("Expected a stable product version or Windows revision") + return f"v{version}-windows" if version.count(".") == 3 else f"v{version}" + + +def verify_signature(installer: Path, signature: str) -> None: + # Verify against the public key embedded in the Windows app, rather than + # merely trusting whichever signing identity is in the current Keychain. + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + key = Ed25519PublicKey.from_public_bytes(base64.b64decode(PUBLIC_KEY, validate=True)) + key.verify(base64.b64decode(signature, validate=True), installer.read_bytes()) + + +def update_feed(feed: Path, installer: Path, version: str, signature: str) -> None: + tag = release_tag(version) + if installer.name != f"FS-PDF-Compressor-{version}-windows-x86_64-setup.exe": + raise ValueError("Installer filename does not match the requested version") + verify_signature(installer, signature) + document = ET.parse(feed) + channel = document.getroot().find("channel") + if channel is None: + raise ValueError("Appcast channel is missing") + for item in list(channel.findall("item")): + enclosure = item.find("enclosure") + if enclosure is not None and enclosure.get(f"{{{SPARKLE}}}version") == version: + channel.remove(item) + item = ET.Element("item") + ET.SubElement(item, "title").text = f"FS PDF Compressor {version}" + ET.SubElement(item, "pubDate").text = format_datetime(datetime.now(timezone.utc)) + ET.SubElement(item, f"{{{SPARKLE}}}releaseNotesLink").text = f"https://github.com/gitlares/fs-pdf-compressor/releases/tag/{tag}" + ET.SubElement(item, "enclosure", { + "url": f"https://github.com/gitlares/fs-pdf-compressor/releases/download/{tag}/{installer.name}", + "length": str(installer.stat().st_size), + "type": "application/octet-stream", + f"{{{SPARKLE}}}version": version, + f"{{{SPARKLE}}}shortVersionString": version, + f"{{{SPARKLE}}}os": "windows-x64", + f"{{{SPARKLE}}}edSignature": signature, + f"{{{SPARKLE}}}installerArguments": "/SILENT /SP- /NOICONS /NORESTART", + }) + channel.append(item) + items = channel.findall("item") + for old in items: + channel.remove(old) + for old in sorted(items, key=lambda i: tuple(int(p) for p in i.find("enclosure").get(f"{{{SPARKLE}}}version").split(".")), reverse=True): + channel.append(old) + ET.indent(document, space=" ") + pending = feed.with_suffix(".pending.xml") + document.write(pending, encoding="utf-8", xml_declaration=True) + pending.replace(feed) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("installer", type=Path) + parser.add_argument("--sign-tool", type=Path, default=Path.home() / "Library/Caches/FS PDF Compressor/Sparkle-2.9.4/bin/sign_update") + args = parser.parse_args() + try: + tag = release_tag(args.version) + except ValueError as error: + parser.error(str(error)) + expected = f"FS-PDF-Compressor-{args.version}-windows-x86_64-setup.exe" + if args.installer.name != expected or not args.installer.is_file(): + parser.error("Expected the matching release installer") + url = f"https://github.com/gitlares/fs-pdf-compressor/releases/download/{tag}/{expected}.sha256" + with urllib.request.urlopen(url, timeout=30) as response: + checksum = response.read(4096).decode("ascii").split()[0] + if checksum != hashlib.sha256(args.installer.read_bytes()).hexdigest(): + raise RuntimeError("Published installer checksum does not match the local file") + signature = subprocess.run([str(args.sign_tool), "-p", str(args.installer)], check=True, text=True, capture_output=True).stdout.strip() + update_feed(ROOT / "docs/appcast-windows.xml", args.installer, args.version, signature) + print(f"Updated docs/appcast-windows.xml for {args.version}; not published.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_windows_update.py b/tests/test_windows_update.py new file mode 100644 index 0000000..dede1fd --- /dev/null +++ b/tests/test_windows_update.py @@ -0,0 +1,202 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Regression coverage for authenticated updates and safe native shutdown.""" + +import base64 +import importlib.util +import tempfile +import unittest +import xml.etree.ElementTree as ET +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest import mock + +from fs_pdf_compressor import windows_update +import build_windows + + +class WindowsUpdaterTests(unittest.TestCase): + def setUp(self): + self.dll = mock.Mock() + self.dll.win_sparkle_set_eddsa_public_key.return_value = 1 + self.shutdown = mock.Mock() + self.updater = windows_update.WindowsUpdater(self.shutdown, self.dll) + + def test_busy_app_rejects_installer_and_idle_app_reserves_only_once(self): + self.assertEqual(self.updater._can_shutdown_callback(), 0) + self.updater.set_busy(False) + with ThreadPoolExecutor(max_workers=4) as pool: + decisions = list(pool.map(lambda _: self.updater._can_shutdown_callback(), range(4))) + self.assertEqual(sum(decisions), 1) + self.assertFalse(self.updater.begin_work()) + self.updater._shutdown_callback() + self.shutdown.assert_called_once() + + def test_new_batch_prevents_installation(self): + self.updater.set_busy(False) + self.assertTrue(self.updater.begin_work()) + self.assertEqual(self.updater._can_shutdown_callback(), 0) + + def test_native_failure_releases_installation_reservation(self): + self.updater.set_busy(False) + self.assertEqual(self.updater._can_shutdown_callback(), 1) + self.updater._error_callback() + self.assertTrue(self.updater.begin_work()) + + def test_invalid_public_key_never_initializes_updater(self): + self.dll.win_sparkle_set_eddsa_public_key.return_value = 0 + with self.assertRaises(RuntimeError): + windows_update.WindowsUpdater(self.shutdown, self.dll) + self.dll.win_sparkle_init.assert_not_called() + + def test_lifecycle_is_idempotent_and_preserves_check_preference(self): + self.updater.start() + self.updater.start() + self.updater.check() + self.updater.cleanup() + self.updater.cleanup() + self.dll.win_sparkle_init.assert_called_once() + self.dll.win_sparkle_cleanup.assert_called_once() + self.dll.win_sparkle_check_update_with_ui.assert_called_once() + self.dll.win_sparkle_set_automatic_check_for_updates.assert_not_called() + + def test_windows_revision_does_not_change_shared_product_version(self): + from fs_pdf_compressor.version import APP_VERSION, WINDOWS_APP_VERSION + self.assertEqual(WINDOWS_APP_VERSION.rsplit('.', 1)[0], APP_VERSION) + self.assertEqual(build_windows.APP_VERSION, WINDOWS_APP_VERSION) + self.dll.win_sparkle_set_app_details.assert_called_once_with( + 'gitlares', 'FS PDF Compressor', WINDOWS_APP_VERSION) + + def test_portable_directory_cannot_be_updated_as_installed_app(self): + registry = mock.Mock() + registry.OpenKey.return_value.__enter__ = mock.Mock(return_value=object()) + registry.OpenKey.return_value.__exit__ = mock.Mock(return_value=False) + registry.QueryValueEx.return_value = (str(Path('/installed')), 1) + with mock.patch.dict('sys.modules', {'winreg': registry}), mock.patch.object(windows_update.sys, 'platform', 'win32'), mock.patch.object(windows_update.sys, 'frozen', True, create=True), mock.patch.object(windows_update.sys, 'executable', '/portable/app.exe'): + self.assertFalse(windows_update.is_installed_application()) + with mock.patch.object(windows_update.sys, 'executable', '/installed/app.exe'): + self.assertTrue(windows_update.is_installed_application()) + + def test_untrusted_runtime_archive_is_rejected_before_bundling(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cache = root / build_windows.APP_NAME + cache.mkdir() + archive = cache / f'WinSparkle-{windows_update.WINSPARKLE_VERSION}.zip' + archive.write_bytes(b'tampered runtime') + with mock.patch.dict('os.environ', {'LOCALAPPDATA': directory}): + with self.assertRaisesRegex(RuntimeError, 'checksum'): + build_windows.bundle_winsparkle(root / 'resources') + self.assertFalse((root / 'resources/WinSparkle.dll').exists()) + self.assertFalse(archive.exists()) + + +@unittest.skipUnless(importlib.util.find_spec('cryptography'), 'Release signing environment required') +class WindowsAppcastTests(unittest.TestCase): + def test_windows_revisions_have_a_dedicated_release_tag(self): + from scripts.generate_windows_appcast import release_tag + self.assertEqual(release_tag('1.0.16.1'), 'v1.0.16.1-windows') + self.assertEqual(release_tag('1.0.17'), 'v1.0.17') + with self.assertRaises(ValueError): + release_tag('1.0.16+windows.1') + + def test_signed_installer_tampering_and_wrong_identity_are_rejected(self): + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + from scripts import generate_windows_appcast as generator + key = Ed25519PrivateKey.generate() + public = base64.b64encode(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode() + with tempfile.TemporaryDirectory() as directory: + installer = Path(directory) / 'FS-PDF-Compressor-1.0.16.1-windows-x86_64-setup.exe' + installer.write_bytes(b'installer contents') + feed = Path(directory) / 'feed.xml' + feed.write_text('Windows') + signature = base64.b64encode(key.sign(installer.read_bytes())).decode() + with mock.patch.object(generator, 'PUBLIC_KEY', public): + generator.update_feed(feed, installer, '1.0.16.1', signature) + generator.update_feed(feed, installer, '1.0.16.1', signature) + self.assertEqual(len(ET.parse(feed).findall('.//item')), 1) + enclosure = ET.parse(feed).find('.//enclosure') + self.assertEqual(enclosure.get(f'{{{generator.SPARKLE}}}os'), 'windows-x64') + self.assertIn('/v1.0.16.1-windows/', enclosure.get('url')) + self.assertIn('/NORESTART', enclosure.get(f'{{{generator.SPARKLE}}}installerArguments')) + before = feed.read_bytes() + installer.write_bytes(b'tampered installer') + with self.assertRaises(InvalidSignature): + generator.update_feed(feed, installer, '1.0.16.1', signature) + self.assertEqual(feed.read_bytes(), before) + installer.write_bytes(b'installer contents') + with self.assertRaises(InvalidSignature): + generator.update_feed(feed, installer, '1.0.16.1', signature) + self.assertEqual(feed.read_bytes(), before) + + +@unittest.skipUnless(importlib.util.find_spec('PySide6'), 'Qt environment required') +class WindowsUpdateLifecycleTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + import os + os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') + from PySide6 import QtWidgets + cls.app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + def setUp(self): + from PySide6 import QtCore + import windows_app + self.module = windows_app + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + old_format = QtCore.QSettings.defaultFormat() + QtCore.QSettings.setDefaultFormat(QtCore.QSettings.IniFormat) + QtCore.QSettings.setPath(QtCore.QSettings.IniFormat, QtCore.QSettings.UserScope, self.directory.name) + self.addCleanup(QtCore.QSettings.setDefaultFormat, old_format) + self.window = windows_app.WindowsPDFCompressorWindow() + self.window.drop_zone.hide() + self.addCleanup(self.window.drop_zone.deleteLater) + self.addCleanup(self.window.deleteLater) + dll = mock.Mock() + dll.win_sparkle_set_eddsa_public_key.return_value = 1 + self.window._windows_updater = windows_update.WindowsUpdater(self.window.update_shutdown_requested.emit, dll) + + def test_discovery_and_queued_explorer_paths_block_installation(self): + updater = self.window._windows_updater + self.window.discovery_thread = object() + self.window._refresh_updater_busy() + self.assertEqual(updater._can_shutdown_callback(), 0) + self.window.discovery_thread = None + self.window.queue_external_paths(['/test.pdf']) + self.window._refresh_updater_busy() + self.assertEqual(updater._can_shutdown_callback(), 0) + self.window._external_paths_timer.stop() + + def test_install_reservation_blocks_new_discovery_and_repeat_compression(self): + updater = self.window._windows_updater + self.window._refresh_updater_busy() + self.assertEqual(updater._can_shutdown_callback(), 1) + self.assertFalse(self.window.start_paths(['/test.pdf'])) + self.window.start_compression() + self.assertIsNone(self.window.thread) + self.assertIsNone(self.window.discovery_thread) + self.window.queue_external_paths(['/test.pdf']) + self.assertEqual(self.window._external_paths, []) + + def test_native_shutdown_is_queued_to_the_qt_main_thread(self): + with mock.patch.object(self.window, 'request_quit') as quit_app: + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(self.window._windows_updater._shutdown_callback).result() + quit_app.assert_not_called() + self.app.processEvents() + quit_app.assert_called_once() + + def test_manual_check_uses_native_updater(self): + self.window.check_for_updates() + self.window._windows_updater._dll.win_sparkle_check_update_with_ui.assert_called_once() + + def test_about_displays_windows_revision(self): + with mock.patch.object(self.module.QtWidgets.QMessageBox, 'about') as about: + self.window.show_about() + self.assertIn('1.0.16.1', about.call_args.args[2]) + + +if __name__ == '__main__': + unittest.main() diff --git a/windows_app.py b/windows_app.py index 83f97db..4caa7ea 100644 --- a/windows_app.py +++ b/windows_app.py @@ -12,7 +12,9 @@ from PySide6 import QtCore, QtGui, QtNetwork, QtWidgets -from linux_app import APP_NAME, APP_VERSION, PDFCompressorWindow +from linux_app import APP_NAME, PDFCompressorWindow +from fs_pdf_compressor.version import WINDOWS_APP_VERSION as APP_VERSION +from fs_pdf_compressor.windows_update import WindowsUpdater, is_installed_application _INSTANCE_SERVER_NAME = "gitlares.fs-pdf-compressor.windows.instance" @@ -30,7 +32,10 @@ def _app_icon_path() -> Path: class WindowsPDFCompressorWindow(PDFCompressorWindow): """Windows-specific lifecycle behavior for the shared Qt interface.""" + update_shutdown_requested = QtCore.Signal() + def __init__(self): + self._windows_updater = None settings = QtCore.QSettings("gitlares", APP_NAME) # The desktop drop target is part of the Windows experience from its # first launch, while preserving a choice a user has already made. @@ -42,9 +47,66 @@ def __init__(self): self._external_paths_timer = QtCore.QTimer(self) self._external_paths_timer.setSingleShot(True) self._external_paths_timer.timeout.connect(self._start_external_paths) + self.update_shutdown_requested.connect(self._quit_for_update, QtCore.Qt.QueuedConnection) + self._updater_idle_timer = QtCore.QTimer(self) + self._updater_idle_timer.setInterval(200) + self._updater_idle_timer.timeout.connect(self._refresh_updater_busy) + + def initialize_updater(self): + if self._windows_updater is not None or not is_installed_application(): + return + try: + updater = WindowsUpdater(self.update_shutdown_requested.emit) + self._windows_updater = updater + self._refresh_updater_busy() + updater.start() + except (OSError, AttributeError, RuntimeError): + self._windows_updater = None + return + self._updater_idle_timer.start() + QtWidgets.QApplication.instance().aboutToQuit.connect(updater.cleanup) + + def _refresh_updater_busy(self): + if self._windows_updater is not None: + self._windows_updater.set_busy(bool( + self.processing or self.thread is not None + or self.discovery_thread is not None or self.update_thread is not None + or self._external_paths or self._external_paths_timer.isActive() + )) + + def _quit_for_update(self): + # The native callback reserved the idle app before launching Inno Setup. + # This slot runs on the Qt main thread, never on WinSparkle's worker. + self.request_quit() + + def start_paths(self, paths, from_drop_zone=False): + if self._windows_updater is not None and not self._windows_updater.begin_work(): + self.status_label.setText("An update is being installed") + return False + return super().start_paths(paths, from_drop_zone) + + def start_compression(self): + if self._windows_updater is not None and not self._windows_updater.begin_work(): + self.status_label.setText("An update is being installed") + return + return super().start_compression() + + def check_for_updates(self): + if self._windows_updater is not None: + self._windows_updater.check() + else: + QtGui.QDesktopServices.openUrl(QtCore.QUrl("https://github.com/gitlares/fs-pdf-compressor/releases")) + + def show_about(self): + QtWidgets.QMessageBox.about( + self, f"About {APP_NAME}", + f"{APP_NAME} {APP_VERSION}\nFast and Simple PDF compression, entirely local.", + ) def queue_external_paths(self, paths: list[str]) -> None: """Combine Explorer launches into one drop-zone compression batch.""" + if self._windows_updater is not None and not self._windows_updater.begin_work(): + return for path in paths: if path not in self._external_paths: self._external_paths.append(path) @@ -115,6 +177,7 @@ def main() -> int: if paths: QtCore.QTimer.singleShot(0, lambda: window.queue_external_paths(paths)) + QtCore.QTimer.singleShot(0, window.initialize_updater) return application.exec() From 851db786106137e7a1424ca00553750114611813 Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:36:18 -0600 Subject: [PATCH 02/11] Validate Windows installer upgrades and native signed downloads in CI --- .github/workflows/build-windows-candidate.yml | 20 ++ scripts/verify_windows_installer.py | 215 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 scripts/verify_windows_installer.py diff --git a/.github/workflows/build-windows-candidate.yml b/.github/workflows/build-windows-candidate.yml index 4a0d73f..f1dbe40 100644 --- a/.github/workflows/build-windows-candidate.yml +++ b/.github/workflows/build-windows-candidate.yml @@ -60,6 +60,7 @@ jobs: python -m venv .windows-build-venv .windows-build-venv\Scripts\python -m pip install --upgrade pip .windows-build-venv\Scripts\python -m pip install -r requirements-windows.txt + .windows-build-venv\Scripts\python -m pip install cryptography==46.0.3 $env:QT_QPA_PLATFORM = "offscreen" .windows-build-venv\Scripts\python -B -m unittest discover -s tests if ($LASTEXITCODE -ne 0) { throw "Windows regression tests failed" } @@ -87,6 +88,25 @@ jobs: "$app\licenses\winsparkle\COPYING" ) | ForEach-Object { if (-not (Test-Path $_)) { throw "Missing runtime compliance file: $_" } } + - name: Validate installer upgrade and native signed updates + shell: pwsh + timeout-minutes: 12 + run: | + $installer = Get-ChildItem ci-windows-build -Filter "*-setup.exe" | Select-Object -First 1 + .windows-build-venv\Scripts\python -B -m scripts.verify_windows_installer --installer $installer.FullName --portable "$env:RUNNER_TEMP\candidate" --output "$env:RUNNER_TEMP\windows-validation" + if ($LASTEXITCODE -ne 0) { throw "Windows installer/update validation failed" } + + - name: Upload validation evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-validation-${{ inputs.version }} + path: | + ${{ runner.temp }}/windows-validation/*.json + ${{ runner.temp }}/windows-validation/*.log + if-no-files-found: warn + retention-days: 7 + - name: Upload Windows release candidate uses: actions/upload-artifact@v4 with: diff --git a/scripts/verify_windows_installer.py b/scripts/verify_windows_installer.py new file mode 100644 index 0000000..3e60644 --- /dev/null +++ b/scripts/verify_windows_installer.py @@ -0,0 +1,215 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Disposable Windows CI install/upgrade test; never run on a user's desktop.""" + +from __future__ import annotations + +import argparse +import base64 +import ctypes +import hashlib +import http.server +import json +import os +import shutil +import subprocess +import sys +import threading +import time +import urllib.request +import uuid +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest import mock + +from fs_pdf_compressor import windows_update +from fs_pdf_compressor.version import WINDOWS_APP_VERSION + + +def digest(path): + with Path(path).open('rb') as source: + return hashlib.file_digest(source, 'sha256').hexdigest() + + +def native_probe(case, installer, dll_path, output): + """Use the real WinSparkle parser/downloader/Ed25519 verification on loopback.""" + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + root = output.parent / ('probe-' + case) + root.mkdir(parents=True, exist_ok=True) + key = Ed25519PrivateKey.generate() + public = base64.b64encode(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)).decode() + payload = root / installer.name + shutil.copy2(installer, payload) + signature = base64.b64encode(key.sign(payload.read_bytes())).decode() + if case == 'tampered': + with payload.open('ab') as stream: + stream.write(b'CI tamper test') + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(root), **kwargs) + def log_message(self, *args): + pass + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + url = f'http://127.0.0.1:{server.server_port}' + ns = 'http://www.andymatuschak.org/xml-namespaces/sparkle' + ET.register_namespace('sparkle', ns) + rss = ET.Element('rss', {'version': '2.0'}) + channel = ET.SubElement(rss, 'channel') + ET.SubElement(channel, 'title').text = 'Disposable CI update' + if case != 'no-update': + item = ET.SubElement(channel, 'item') + ET.SubElement(item, 'title').text = WINDOWS_APP_VERSION + ET.SubElement(item, 'enclosure', { + 'url': url + '/' + payload.name, 'length': str(payload.stat().st_size), + 'type': 'application/octet-stream', f'{{{ns}}}version': WINDOWS_APP_VERSION, + f'{{{ns}}}os': 'windows-x64', f'{{{ns}}}edSignature': signature, + }) + ET.ElementTree(rss).write(root / 'feed.xml', encoding='utf-8', xml_declaration=True) + dll = ctypes.CDLL(str(dll_path.resolve())) + dll.win_sparkle_set_registry_path.argtypes = [ctypes.c_char_p] + dll.win_sparkle_set_registry_path.restype = None + dll.win_sparkle_set_registry_path(f'Software\\FSPDFCompressorCI\\{uuid.uuid4().hex}'.encode()) + dll.win_sparkle_set_automatic_check_for_updates.argtypes = [ctypes.c_int] + dll.win_sparkle_set_automatic_check_for_updates.restype = None + dll.win_sparkle_set_automatic_check_for_updates(0) + done = threading.Event() + result = {'case': case, 'download_verified': False, 'error': False, 'no_update': False} + void_callback = ctypes.CFUNCTYPE(None) + install_callback_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_wchar_p) + def error(): + result['error'] = True + done.set() + def no_update(): + result['no_update'] = True + done.set() + def installer_ready(path): + try: + # This native callback is reached only after native signature validation. + copied = output.parent / 'native-verified-setup.exe' + shutil.copy2(path, copied) + result['download_verified'] = digest(copied) == digest(installer) + result['installer_path'] = str(copied) + except Exception as exc: + result['callback_failure'] = type(exc).__name__ + done.set() + return 1 # The test handles the verified payload itself, after cleanup. + callbacks = [void_callback(error), void_callback(no_update), install_callback_type(installer_ready)] + for name, callback in zip(['set_error_callback', 'set_did_not_find_update_callback', 'set_user_run_installer_callback'], callbacks): + function = getattr(dll, 'win_sparkle_' + name) + function.argtypes, function.restype = [type(callback)], None + function(callback) + with mock.patch.object(windows_update, 'PUBLIC_KEY', public), mock.patch.object(windows_update, 'FEED_URL', url + '/feed.xml'), mock.patch.object(windows_update, 'APP_VERSION', '1.0.16'): + updater = windows_update.WindowsUpdater(lambda: None, dll) + # WindowsUpdater configures its own error callback; use the test observer. + dll.win_sparkle_set_error_callback(callbacks[0]) + updater.set_busy(False) + updater.start() + try: + dll.win_sparkle_check_update_with_ui_and_install.argtypes = [] + dll.win_sparkle_check_update_with_ui_and_install.restype = None + dll.win_sparkle_check_update_with_ui_and_install() + if not done.wait(120): + raise RuntimeError(f'Native {case} test timed out') + if case == 'valid': + assert result['download_verified'] and not result['error'], result + elif case == 'tampered': + assert result['error'] and not result['download_verified'], result + else: + assert result['no_update'] and not result['error'], result + finally: + updater.cleanup() + server.shutdown() + server.server_close() + output.write_text(json.dumps(result, indent=2)) + + +def install(installer, directory, log): + command = [str(installer.resolve()), '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', f'/DIR={directory}', f'/LOG={log}'] + result = subprocess.run(command, timeout=180) + if result.returncode != 0: + raise RuntimeError(f'Inno Setup failed: exit {result.returncode}') + + +def verify_candidate(installer, portable, output): + import winreg + output.mkdir(parents=True, exist_ok=True) + directory = output / 'installed' + old = output / 'previous-1.0.16-setup.exe' + prefix = 'https://github.com/gitlares/fs-pdf-compressor/releases/download/v1.0.16/' + name = 'FS-PDF-Compressor-1.0.16-windows-x86_64-setup.exe' + urllib.request.urlretrieve(prefix + name, old) + with urllib.request.urlopen(prefix + name + '.sha256', timeout=30) as response: + expected = response.read(4096).decode().split()[0] + assert digest(old) == expected, 'Previous installer checksum mismatch' + install(old, directory, output / 'install-previous.log') + # Silent installers may launch the app: stop only the disposable CI app. + stop_test_app(directory) + settings_key = windows_update.INSTALL_REGISTRY_KEY + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, settings_key) as key: + winreg.SetValueEx(key, 'keepOriginal', 0, winreg.REG_DWORD, 1) + winreg.SetValueEx(key, 'dropZoneEnabled', 0, winreg.REG_DWORD, 0) + runtime = portable / 'FS PDF Compressor' / '_internal' / 'WinSparkle.dll' + for case in ['no-update', 'tampered', 'valid']: + subprocess.run([sys.executable, '-m', 'scripts.verify_windows_installer', '--probe', case, '--installer', str(installer), '--dll', str(runtime), '--output', str(output / (case + '.json'))], check=True, timeout=150) + # Upgrade using the exact payload downloaded and authenticated by WinSparkle. + install(output / 'native-verified-setup.exe', directory, output / 'upgrade.log') + executable = directory / 'FS PDF Compressor.exe' + assert digest(executable) == digest(portable / 'FS PDF Compressor' / executable.name) + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, settings_key) as key: + assert winreg.QueryValueEx(key, 'keepOriginal')[0] == 1 + assert winreg.QueryValueEx(key, 'dropZoneEnabled')[0] == 0 + assert Path(winreg.QueryValueEx(key, 'InstallDir')[0]).resolve() == directory.resolve() + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Software\Classes\SystemFileAssociations\.pdf\shell\CompressWithFSPDFCompressor\command') as key: + assert str(executable) in winreg.QueryValueEx(key, '')[0] + # Validate the frozen app loads Qt/WinSparkle and remains running after launch. + stop_test_app(directory) + process = subprocess.Popen([str(executable)]) + try: + time.sleep(8) + assert process.poll() is None, 'Installed application exited during startup' + finally: + process.terminate() + process.wait(timeout=20) + for path in output.glob('probe-*/*setup.exe'): + path.unlink() # Keep reports, not redundant large installers. + for path in [old, output / 'native-verified-setup.exe']: + path.unlink() + report = { + 'version': WINDOWS_APP_VERSION, 'previous_version': '1.0.16', + 'native_signed_download': True, 'native_tamper_rejection': True, + 'native_no_update': True, 'silent_upgrade': True, + 'preferences_preserved': True, 'explorer_integration': True, + 'installed_app_launch': True, + 'limitations': ['Test-only signing identity and loopback feed', 'Update approval dialog and desktop drag/drop not manually reviewed'], + } + (output / 'report.json').write_text(json.dumps(report, indent=2)) + print(json.dumps(report, indent=2)) + + +def stop_test_app(directory): + # Match executable path rather than killing unrelated applications by name. + executable = str((directory / 'FS PDF Compressor.exe').resolve()).replace("'", "''") + script = f"Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -eq '{executable}' }} | ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force }}" + subprocess.run(['powershell', '-NoProfile', '-Command', script], check=True, timeout=30) + + +def main(): + if sys.platform != 'win32' or os.environ.get('GITHUB_ACTIONS') != 'true': + raise RuntimeError('This installation test is restricted to disposable GitHub Windows runners') + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--installer', required=True, type=Path) + parser.add_argument('--portable', type=Path) + parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--probe', choices=['valid', 'tampered', 'no-update']) + parser.add_argument('--dll', type=Path) + args = parser.parse_args() + if args.probe: + native_probe(args.probe, args.installer, args.dll, args.output) + else: + verify_candidate(args.installer, args.portable, args.output) + + +if __name__ == '__main__': + main() From 912b89232a353df4f44c0d78a22d29b35b8a10ff Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:41:45 -0600 Subject: [PATCH 03/11] Record successful Windows installer and signed update validation --- CHANGELOG.md | 5 +++++ docs/WINDOWS_TESTING.md | 17 +++++++++++++++++ docs/validation/windows-1.0.16.1-ci.json | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 docs/validation/windows-1.0.16.1-ci.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 30dc5d3..07ae88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ - Keep the shared macOS/Linux version at 1.0.16 and Ghostscript at 10.07.1. The Windows revision requires Windows 11 end-to-end testing before release; existing Windows users need one manual update to gain WinSparkle support. +- Verify the Windows installer and native signed-download/tamper-rejection + paths in a disposable Windows Server 2022 GitHub Actions runner. Record + successful upgrade from 1.0.16, preserved preferences and Explorer registry + integration, and installed application startup. Windows 11 visual checks + and production release publication remain pending. ## 1.0.16 — 2026-09-29 diff --git a/docs/WINDOWS_TESTING.md b/docs/WINDOWS_TESTING.md index 43dbba7..9099a0e 100644 --- a/docs/WINDOWS_TESTING.md +++ b/docs/WINDOWS_TESTING.md @@ -72,3 +72,20 @@ versions and a test feed signed with the embedded verification key: See [signed Windows updates](RELEASING.md#windows-signed-updates) for feed generation. The Ed25519 update signature does not replace Authenticode; the Windows installer remains unsigned unless separately code-signed. + +## Automated validation recorded on 2026-10-08 + +[GitHub Actions run 37840732060](https://github.com/gitlares/fs-pdf-compressor/actions/runs/37840732060) +passed for source commit `851db786106137e7a1424ca00553750114611813` on +Windows Server 2022. It built the Windows 1.0.16.1 installer and portable ZIP, +verified package contents, used the real WinSparkle DLL to authenticate a +download and reject a tampered payload, checked an empty update feed, upgraded +the published 1.0.16 installer using the native-verified payload, verified +preserved preferences and Explorer registration, and checked installed +application startup. See the [permanent result](validation/windows-1.0.16.1-ci.json). + +The native download test used a disposable signing key and a loopback feed. +It did not use the production signing key or publish an update. Unattended +installation and process startup do not replace visual review of the update +approval dialog, relaunch, actual Explorer action, drag/drop, or Windows 11 +desktop behavior. Those checks remain pending before a public release. diff --git a/docs/validation/windows-1.0.16.1-ci.json b/docs/validation/windows-1.0.16.1-ci.json new file mode 100644 index 0000000..50a6198 --- /dev/null +++ b/docs/validation/windows-1.0.16.1-ci.json @@ -0,0 +1,24 @@ +{ + "observed_date": "2026-10-08", + "runner": "windows-2022 (Windows Server 2022)", + "source_commit": "851db786106137e7a1424ca00553750114611813", + "run_url": "https://github.com/gitlares/fs-pdf-compressor/actions/runs/37840732060", + "version": "1.0.16.1", + "previous_version": "1.0.16", + "build": "passed", + "package_contents": "passed", + "native_signed_download": true, + "native_tamper_rejection": true, + "native_no_update": true, + "silent_upgrade": true, + "preferences_preserved": true, + "explorer_integration": true, + "installed_app_launch": true, + "production_release_published": false, + "limitations": [ + "Native download verification used a disposable test signing identity and loopback feed, not the production signing key/feed.", + "The exact native-verified installer payload was installed unattended; the update approval dialog was not manually reviewed.", + "Installer relaunch UI, drag/drop, Windows 11 desktop behavior, and an actual Explorer action were not visually reviewed.", + "Preserved preferences and Explorer registration were verified in the registry; startup was checked by process survival." + ] +} From 32cfd30061a1d582971f01894348f554ed2fe424 Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:34:15 -0600 Subject: [PATCH 04/11] Preserve embedded colour profiles during PDF compression --- .github/workflows/build-linux-appimage.yml | 2 +- .github/workflows/build-macos14-arm64.yml | 2 +- .github/workflows/build-snap-candidate.yml | 37 +++++ CHANGELOG.md | 29 ++-- fs_pdf_compressor/core.py | 94 +++++++++--- fs_pdf_compressor/pdf_validation.py | 164 +++++++++++++++++++++ fs_pdf_compressor/version.py | 4 +- snapcraft.yaml | 2 +- tests/test_core.py | 8 + tests/test_file_safety.py | 5 +- tests/test_pdf_validation.py | 82 +++++++++++ tests/test_windows_update.py | 5 +- 12 files changed, 389 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/build-snap-candidate.yml create mode 100644 fs_pdf_compressor/pdf_validation.py create mode 100644 tests/test_pdf_validation.py diff --git a/.github/workflows/build-linux-appimage.yml b/.github/workflows/build-linux-appimage.yml index ef61a95..3577117 100644 --- a/.github/workflows/build-linux-appimage.yml +++ b/.github/workflows/build-linux-appimage.yml @@ -6,7 +6,7 @@ on: version: description: Version matching the checked-out source required: true - default: "1.0.16" + default: "1.0.17" permissions: contents: read diff --git a/.github/workflows/build-macos14-arm64.yml b/.github/workflows/build-macos14-arm64.yml index f331187..dd07b08 100644 --- a/.github/workflows/build-macos14-arm64.yml +++ b/.github/workflows/build-macos14-arm64.yml @@ -6,7 +6,7 @@ on: version: description: Version matching the checked-out source required: true - default: "1.0.16" + default: "1.0.17" permissions: contents: read diff --git a/.github/workflows/build-snap-candidate.yml b/.github/workflows/build-snap-candidate.yml new file mode 100644 index 0000000..c667ce7 --- /dev/null +++ b/.github/workflows/build-snap-candidate.yml @@ -0,0 +1,37 @@ +name: Build Linux Snap candidate + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/build-snap-candidate.yml" + - "snapcraft.yaml" + - "snap/**" + - "fs_pdf_compressor/**" + - "linux_app.py" + +permissions: + contents: read + +jobs: + assemble: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 + id: snapcraft + - name: Generate package checksum + env: + SNAP_FILE: ${{ steps.snapcraft.outputs.snap }} + run: | + cd "$(dirname "$SNAP_FILE")" + snap_name="$(basename "$SNAP_FILE")" + sha256sum "$snap_name" > "$snap_name.sha256" + - uses: actions/upload-artifact@v4 + with: + name: fs-pdf-compressor-snap-amd64 + path: | + ${{ steps.snapcraft.outputs.snap }} + ${{ steps.snapcraft.outputs.snap }}.sha256 + if-no-files-found: error + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ae88f..c0abc9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,16 @@ # Changelog -## 1.0.16.1 — Windows only — Unreleased - -- Add WinSparkle 0.9.4 to the Windows installer for optional automatic update - checks and user-approved installation of Ed25519-signed updates. -- Refuse update installation during PDF discovery, compression, or pending - Explorer requests; block new work after reserving the idle app for installation. -- Update the existing per-user installation and relaunch the application. - Keep portable ZIP updates manual. -- Add a separate Windows update feed, verified runtime packaging, release - signing tooling, privacy documentation, and regression coverage. -- Keep the shared macOS/Linux version at 1.0.16 and Ghostscript at 10.07.1. - The Windows revision requires Windows 11 end-to-end testing before release; - existing Windows users need one manual update to gain WinSparkle support. -- Verify the Windows installer and native signed-download/tamper-rejection - paths in a disposable Windows Server 2022 GitHub Actions runner. Record - successful upgrade from 1.0.16, preserved preferences and Explorer registry - integration, and installed application startup. Windows 11 visual checks - and production release publication remain pending. +## 1.0.17 — Unreleased + +- Detect malformed embedded colour profiles, page geometry changes, and altered selectable + text before replacing an original or saving a compressed copy. +- Retry eligible colour-profile failures with Ghostscript at the original + image resolution. Restore the original embedded ICC profile when unchanged + JPEGs and the surviving profiles identify it unambiguously. +- Add the optional Smart scans profile for monochrome documents. +- Add WinSparkle to the Windows installer for signed update downloads and + user-approved installation. Portable updates remain manual. Existing + Windows users need one manual installation to get the updater. ## 1.0.16 — 2026-09-29 diff --git a/fs_pdf_compressor/core.py b/fs_pdf_compressor/core.py index a1bbd3a..b989a36 100644 --- a/fs_pdf_compressor/core.py +++ b/fs_pdf_compressor/core.py @@ -12,8 +12,17 @@ import tempfile from pathlib import Path +from pypdf import PdfReader + from fs_pdf_compressor.system_trash import TrashError, move_to_system_trash from fs_pdf_compressor.file_guard import FileBusyError, document_lock +from fs_pdf_compressor.pdf_validation import ( + has_invalid_icc, + identify_missing_profile, + restore_missing_profile, + same_page_geometry, + same_page_text, +) from fs_pdf_compressor.smart_scans import preprocess_smart_scans @@ -293,6 +302,22 @@ def _valid_pdf_output(path): return b"%%EOF" in output.read() +class PdfFidelityError(ValueError): + """The rewritten PDF failed a content safety check.""" + + +def _run_pdfwrite(command, destination, environment): + result = subprocess.run( + command, + env=environment, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + **_ghostscript_subprocess_options(), + ) + if result.returncode != 0 or not _valid_pdf_output(destination): + raise ValueError("Ghostscript did not produce a complete PDF") + + def _compress_locked(original_path, pdf_settings, keep_original): filename = os.path.basename(original_path) temp_path = None @@ -331,30 +356,59 @@ def _compress_locked(original_path, pdf_settings, keep_original): prefix=".fs-pdf-", suffix=".tmp", dir=Path(original_path).parent ) os.close(fd) - result = subprocess.run( - _ghostscript_command( - gs_path, - temp_path, - compression_input, - effective_pdf_settings, - ), - env=gs_environment, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - **_ghostscript_subprocess_options(), + command = _ghostscript_command( + gs_path, temp_path, compression_input, effective_pdf_settings ) - if result.returncode != 0 or not os.path.exists(temp_path): - if os.path.exists(temp_path): - os.unlink(temp_path) - return f"{filename} — compression failed", None + _run_pdfwrite(command, temp_path, gs_environment) + if os.path.getsize(temp_path) >= original_size: + Path(temp_path).unlink() + return f"{filename} — no size reduction", None + + source_pdf = PdfReader(original_path) + output_pdf = PdfReader(temp_path) + if has_invalid_icc(output_pdf): + profile = identify_missing_profile(source_pdf, output_pdf) + if profile is None and effective_pdf_settings != "/default": + # Downsampling can erase the unchanged JPEGs that identify + # which source profile Ghostscript truncated. + Path(temp_path).unlink() + command = _ghostscript_command(gs_path, temp_path, original_path, "/default") + _run_pdfwrite(command, temp_path, gs_environment) + profile = identify_missing_profile(source_pdf, PdfReader(temp_path)) + if profile is None: + raise PdfFidelityError("invalid colour profile") + command = _ghostscript_command(gs_path, temp_path, original_path, "/ebook") + command[-1:-1] = [ + "-dDownsampleColorImages=false", + "-dDownsampleGrayImages=false", + "-dDownsampleMonoImages=false", + "-dAutoFilterColorImages=false", + "-sColorImageFilter=DCTEncode", + "-dPassThroughJPEGImages=false", + ] + Path(temp_path).unlink() + _run_pdfwrite(command, temp_path, gs_environment) + if has_invalid_icc(PdfReader(temp_path)): + with tempfile.TemporaryDirectory( + prefix=".fs-pdf-repair-", dir=Path(temp_path).parent + ) as repair_dir: + repaired = Path(repair_dir) / "repaired.pdf" + if not restore_missing_profile(temp_path, repaired, source_pdf, profile): + raise PdfFidelityError("invalid colour profile after retry") + os.replace(repaired, temp_path) + output_pdf = PdfReader(temp_path) + if has_invalid_icc(output_pdf): + raise PdfFidelityError("invalid colour profile after repair") + if not same_page_geometry(source_pdf, output_pdf): + raise PdfFidelityError("page geometry changed") + + if not same_page_text(source_pdf, output_pdf): + raise PdfFidelityError("selectable text changed") new_size = os.path.getsize(temp_path) - if not _valid_pdf_output(temp_path): - raise ValueError("Ghostscript produced an invalid or truncated PDF") if new_size >= original_size: - os.unlink(temp_path) + Path(temp_path).unlink() return f"{filename} — no size reduction", None - current_stat = os.stat(original_path) if (original_stat.st_ino, original_stat.st_size, original_stat.st_mtime_ns) != (current_stat.st_ino, current_stat.st_size, current_stat.st_mtime_ns): raise OSError("Original PDF changed during compression") @@ -384,6 +438,8 @@ def _compress_locked(original_path, pdf_settings, keep_original): os.unlink(temp_path) except OSError: pass + if isinstance(error, PdfFidelityError): + return f"{filename} — compression failed fidelity checks; original kept", None if isinstance(error, TrashError): return f"{filename} — could not recycle original; not replaced", None return f"{filename} — compression failed", None diff --git a/fs_pdf_compressor/pdf_validation.py b/fs_pdf_compressor/pdf_validation.py new file mode 100644 index 0000000..573410e --- /dev/null +++ b/fs_pdf_compressor/pdf_validation.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Check document geometry and embedded colour profiles before accepting output.""" + +from hashlib import sha256 + +from pypdf import PdfWriter +from pypdf.generic import ArrayObject, DictionaryObject, IndirectObject, StreamObject + + +def _objects(root): + pending = [root] + visited = set() + while pending: + obj = pending.pop() + if isinstance(obj, IndirectObject): + key = (obj.idnum, obj.generation) + if key in visited: + continue + visited.add(key) + obj = obj.get_object() + yield obj + if isinstance(obj, DictionaryObject): + pending.extend(obj.values()) + elif isinstance(obj, ArrayObject): + pending.extend(obj) + + +def _valid_profile(profile): + if not isinstance(profile, StreamObject): + return False + data = profile.get_data() + if len(data) < 132 or data[36:40] != b"acsp": + return False + size = int.from_bytes(data[:4], "big") + if not 132 <= size <= len(data): + return False + channels = {b"GRAY": 1, b"RGB ": 3, b"CMYK": 4}.get(data[16:20]) + return channels is not None and channels == profile.get("/N") + + +def has_invalid_icc(reader): + checked = set() + for obj in _objects(reader.root_object): + if isinstance(obj, ArrayObject) and obj and obj[0] == "/ICCBased": + if len(obj) != 2: + return True + profile = obj[1].get_object() + identity = id(profile) + if identity not in checked: + if not _valid_profile(profile): + return True + checked.add(identity) + return False + + +def _icc_spaces(document): + for obj in _objects(document.root_object): + if isinstance(obj, ArrayObject) and len(obj) == 2 and obj[0] == "/ICCBased": + yield obj + + +def _jpeg_profile_evidence(document): + """Use unchanged JPEG bytes and dimensions as evidence of profile identity.""" + evidence = {} + for obj in _objects(document.root_object): + if not isinstance(obj, StreamObject) or obj.get("/Subtype") != "/Image": + continue + if obj.get("/Filter") != "/DCTDecode": + continue + space = obj.get("/ColorSpace") + if space is None: + continue + space = space.get_object() + if isinstance(space, ArrayObject) and len(space) == 2 and space[0] == "/ICCBased": + key = (obj.get("/Width"), obj.get("/Height"), sha256(obj.get_data()).digest()) + evidence.setdefault(key, []).append(space[1].get_object()) + return evidence + + +def identify_missing_profile(source, broken): + """Resolve a single empty profile using surviving profiles and unchanged images.""" + source_profiles = [space[1].get_object() for space in _icc_spaces(source)] + broken_profiles = [space[1].get_object() for space in _icc_spaces(broken)] + missing = {id(profile): profile for profile in broken_profiles if not _valid_profile(profile)} + if len(missing) != 1: + return None + empty = next(iter(missing.values())) + if not isinstance(empty, StreamObject) or empty.get_data(): + return None + + surviving = {profile.get_data() for profile in broken_profiles if _valid_profile(profile)} + candidates = { + profile.get_data(): profile for profile in source_profiles + if _valid_profile(profile) and profile.get("/N") == empty.get("/N") + and profile.get_data() not in surviving + } + if len(candidates) != 1: + return None + data, profile = next(iter(candidates.items())) + original_evidence = _jpeg_profile_evidence(source) + output_evidence = _jpeg_profile_evidence(broken) + matched = { + original.get_data() + for key, profiles in output_evidence.items() if any(item is empty for item in profiles) + for original in original_evidence.get(key, []) + } + if matched != {data}: + return None + return profile + + +def restore_missing_profile(input_path, output_path, source, source_profile): + writer = PdfWriter(clone_from=input_path) + empty_profiles = { + id(profile): profile for space in _icc_spaces(writer) + for profile in [space[1].get_object()] if not _valid_profile(profile) + } + if len(empty_profiles) != 1: + return False + empty = next(iter(empty_profiles.values())) + if not isinstance(empty, StreamObject) or empty.get_data(): + return False + if empty.get("/N") != source_profile.get("/N"): + return False + + surviving_profiles = { + item[1].get_object().get_data() for item in _icc_spaces(writer) + if _valid_profile(item[1].get_object()) + } + missing_source_profiles = { + item[1].get_object().get_data() for item in _icc_spaces(source) + if _valid_profile(item[1].get_object()) + and item[1].get_object().get("/N") == empty.get("/N") + } - surviving_profiles + if missing_source_profiles != {source_profile.get_data()}: + return False + + replacement = source_profile.clone(writer).indirect_reference + for space in _icc_spaces(writer): + if space[1].get_object() is empty: + space[1] = replacement + if has_invalid_icc(writer): + return False + writer.write(output_path) + return True + + +def _page_geometry(reader): + return [ + (tuple(page.mediabox), tuple(page.cropbox), page.rotation, page.user_unit) + for page in reader.pages + ] + + +def same_page_geometry(source, output): + return _page_geometry(source) == _page_geometry(output) + + +def same_page_text(source, output): + """Ignore wrapping and spacing changes, but detect lost or altered text.""" + return all( + "".join(before.extract_text().split()) == "".join(after.extract_text().split()) + for before, after in zip(source.pages, output.pages, strict=True) + ) diff --git a/fs_pdf_compressor/version.py b/fs_pdf_compressor/version.py index 015f257..d8d4552 100644 --- a/fs_pdf_compressor/version.py +++ b/fs_pdf_compressor/version.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Shared product version and optional platform-specific maintenance revision.""" -APP_VERSION = "1.0.16" -WINDOWS_APP_VERSION = "1.0.16.1" +APP_VERSION = "1.0.17" +WINDOWS_APP_VERSION = "1.0.17" diff --git a/snapcraft.yaml b/snapcraft.yaml index c88e4e0..fc8c684 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,6 +1,6 @@ name: fs-pdf-compressor title: FS PDF Compressor -version: "1.0.16" +version: "1.0.17" summary: Fast and simple local PDF compression description: | Compress PDF files locally with Ghostscript. Drag in one or more PDFs (or a diff --git a/tests/test_core.py b/tests/test_core.py index 1e28192..10751db 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -518,6 +518,8 @@ def write_smaller(command, **_kwargs): return subprocess.CompletedProcess(command, 0, b"", b"") with ( + # These tests use stub PDF bytes to isolate file handling. + mock.patch("fs_pdf_compressor.core.PdfReader"), mock.patch( "fs_pdf_compressor.core.get_ghostscript_config", return_value=("gs", {}), @@ -551,6 +553,8 @@ def write_smaller(command, **_kwargs): return subprocess.CompletedProcess(command, 0, b"", b"") with ( + # These tests use stub PDF bytes to isolate file handling. + mock.patch("fs_pdf_compressor.core.PdfReader"), mock.patch( "fs_pdf_compressor.core.get_ghostscript_config", return_value=("gs", {}), @@ -639,6 +643,8 @@ def write_smaller(command, **_kwargs): return subprocess.CompletedProcess(command, 0, b"", b"") with ( + # These tests use stub PDF bytes to isolate file handling. + mock.patch("fs_pdf_compressor.core.PdfReader"), mock.patch( "fs_pdf_compressor.core.get_ghostscript_config", return_value=("gs", {}), @@ -674,6 +680,8 @@ def write_smaller(command, **_kwargs): return subprocess.CompletedProcess(command, 0, b"", b"") with ( + # These tests use stub PDF bytes to isolate file handling. + mock.patch("fs_pdf_compressor.core.PdfReader"), mock.patch( "fs_pdf_compressor.core.get_ghostscript_config", return_value=("gs", {}), diff --git a/tests/test_file_safety.py b/tests/test_file_safety.py index 05afce8..6ad0a9a 100644 --- a/tests/test_file_safety.py +++ b/tests/test_file_safety.py @@ -33,7 +33,10 @@ def run(command, **kwargs): if after_write: after_write() return subprocess.CompletedProcess(command, 0) - with mock.patch.object(core.subprocess, "run", side_effect=run): + with ( + mock.patch.object(core.subprocess, "run", side_effect=run), + mock.patch.object(core, "PdfReader"), + ): return core.compress_pdf(str(self.original), "/ebook", keep) def test_existing_legacy_temporary_is_never_overwritten(self): diff --git a/tests/test_pdf_validation.py b/tests/test_pdf_validation.py new file mode 100644 index 0000000..b4f5f43 --- /dev/null +++ b/tests/test_pdf_validation.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +from io import BytesIO +from unittest.mock import patch +import unittest + +from PIL import ImageCms +from pypdf import PdfReader, PdfWriter +from pypdf.generic import ArrayObject, DecodedStreamObject, DictionaryObject, NameObject, NumberObject + +from fs_pdf_compressor.pdf_validation import ( + has_invalid_icc, + identify_missing_profile, + restore_missing_profile, + same_page_geometry, +) + + +def document(profile_data=None, *, components=3, width=500, extra_space=None): + writer = PdfWriter() + page = writer.add_blank_page(width=width, height=400) + spaces = DictionaryObject() + if profile_data is not None: + profile = DecodedStreamObject() + profile.set_data(profile_data) + profile[NameObject("/N")] = NumberObject(components) + spaces[NameObject("/Custom")] = ArrayObject([ + NameObject("/ICCBased"), writer._add_object(profile) + ]) + if extra_space: + spaces[NameObject("/Other")] = NameObject(extra_space) + page[NameObject("/Resources")] = DictionaryObject({NameObject("/ColorSpace"): spaces}) + buffer = BytesIO() + writer.write(buffer) + return PdfReader(buffer) + + +class PdfFidelityTests(unittest.TestCase): + def test_valid_embedded_rgb_profile_does_not_need_a_known_profile_name(self): + profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes() + reader = document(profile) + self.assertFalse(has_invalid_icc(reader)) + + def test_rejects_empty_truncated_and_mismatched_profiles(self): + profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes() + for data, components in [(b"", 3), (profile[:132], 3), (profile, 4)]: + with self.subTest(length=len(data), components=components): + reader = document(data, components=components) + self.assertTrue(has_invalid_icc(reader)) + + def test_checks_document_dimensions(self): + self.assertTrue(same_page_geometry(document(), document())) + self.assertFalse(same_page_geometry(document(), document(width=510))) + + def test_ambiguous_or_unmatched_profile_is_not_restored(self): + profile = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes() + source, output = document(profile), document(b"") + self.assertIsNone(identify_missing_profile(source, output)) + with patch( + "fs_pdf_compressor.pdf_validation._jpeg_profile_evidence", + side_effect=[{}, {}], + ): + self.assertIsNone(identify_missing_profile(source, output)) + + def test_restores_original_profile_bytes(self): + data = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes() + source, output = document(data), document(b"") + profile = source.pages[0]["/Resources"]["/ColorSpace"]["/Custom"][1].get_object() + empty = output.pages[0]["/Resources"]["/ColorSpace"]["/Custom"][1].get_object() + key = (400, 300, b"same-image") + with patch( + "fs_pdf_compressor.pdf_validation._jpeg_profile_evidence", + side_effect=[{key: [profile]}, {key: [empty]}], + ): + matched = identify_missing_profile(source, output) + self.assertIs(matched, profile) + repaired = BytesIO() + self.assertTrue(restore_missing_profile(output, repaired, source, matched)) + checked = PdfReader(repaired) + self.assertFalse(has_invalid_icc(checked)) + space = checked.pages[0]["/Resources"]["/ColorSpace"]["/Custom"] + self.assertEqual(space[1].get_object().get_data(), data) diff --git a/tests/test_windows_update.py b/tests/test_windows_update.py index dede1fd..9cdb43a 100644 --- a/tests/test_windows_update.py +++ b/tests/test_windows_update.py @@ -61,7 +61,7 @@ def test_lifecycle_is_idempotent_and_preserves_check_preference(self): def test_windows_revision_does_not_change_shared_product_version(self): from fs_pdf_compressor.version import APP_VERSION, WINDOWS_APP_VERSION - self.assertEqual(WINDOWS_APP_VERSION.rsplit('.', 1)[0], APP_VERSION) + self.assertIn(APP_VERSION, (WINDOWS_APP_VERSION, WINDOWS_APP_VERSION.rsplit('.', 1)[0])) self.assertEqual(build_windows.APP_VERSION, WINDOWS_APP_VERSION) self.dll.win_sparkle_set_app_details.assert_called_once_with( 'gitlares', 'FS PDF Compressor', WINDOWS_APP_VERSION) @@ -193,9 +193,10 @@ def test_manual_check_uses_native_updater(self): self.window._windows_updater._dll.win_sparkle_check_update_with_ui.assert_called_once() def test_about_displays_windows_revision(self): + from fs_pdf_compressor.version import WINDOWS_APP_VERSION with mock.patch.object(self.module.QtWidgets.QMessageBox, 'about') as about: self.window.show_about() - self.assertIn('1.0.16.1', about.call_args.args[2]) + self.assertIn(WINDOWS_APP_VERSION, about.call_args.args[2]) if __name__ == '__main__': From 28d8adfb717cc2259ffe92ee256e8174c66b11ea Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:35:14 -0600 Subject: [PATCH 05/11] Build macOS compatibility candidates on supported runners --- .github/workflows/build-macos14-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-macos14-arm64.yml b/.github/workflows/build-macos14-arm64.yml index dd07b08..5c51cac 100644 --- a/.github/workflows/build-macos14-arm64.yml +++ b/.github/workflows/build-macos14-arm64.yml @@ -13,7 +13,7 @@ permissions: jobs: assemble: - runs-on: macos-14 + runs-on: macos-15 env: APP_VERSION: ${{ inputs.version }} SOURCE_REF: ${{ github.sha }} From ad5d077459452fffa3e8b719c52374980634826a Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:41:07 -0600 Subject: [PATCH 06/11] Pin compatible macOS and AppImage build dependencies --- .github/workflows/build-linux-appimage.yml | 4 ++-- .github/workflows/build-macos14-arm64.yml | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-linux-appimage.yml b/.github/workflows/build-linux-appimage.yml index 3577117..4d2c28f 100644 --- a/.github/workflows/build-linux-appimage.yml +++ b/.github/workflows/build-linux-appimage.yml @@ -34,8 +34,8 @@ jobs: mkdir -p .tools curl --fail --location --retry 3 \ --output "$APPIMAGETOOL" \ - https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage - echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $APPIMAGETOOL" | sha256sum --check --status + https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage + echo "ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 $APPIMAGETOOL" | sha256sum --check chmod +x "$APPIMAGETOOL" - name: Assemble AppImage env: diff --git a/.github/workflows/build-macos14-arm64.yml b/.github/workflows/build-macos14-arm64.yml index 5c51cac..4ec94d3 100644 --- a/.github/workflows/build-macos14-arm64.yml +++ b/.github/workflows/build-macos14-arm64.yml @@ -23,12 +23,29 @@ jobs: DIST_DIR: ci-build steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + architecture: arm64 - name: Install Apple Silicon dependencies run: | - brew install python@3.12 ghostscript - python3.12 -m venv .build-venv + brew install ghostscript + python -m venv .build-venv .build-venv/bin/python -m pip install --upgrade pip .build-venv/bin/python -m pip install -r requirements-build.txt + - name: Build macOS 14-compatible libidn + run: | + archive="$RUNNER_TEMP/libidn-1.44.tar.gz" + prefix="$RUNNER_TEMP/libidn-1.44-install" + curl --fail --location --retry 3 --output "$archive" \ + https://ftp.gnu.org/gnu/libidn/libidn-1.44.tar.gz + echo "499608bab3a65650a0ea52888c13a8deebe3f71408e319acd9ec52e02eb13959 $archive" | shasum -a 256 --check + tar -xf "$archive" -C "$RUNNER_TEMP" + cd "$RUNNER_TEMP/libidn-1.44" + ./configure --prefix="$prefix" --disable-shared --enable-static + make -j "$(sysctl -n hw.ncpu)" + make install + echo "PKG_CONFIG_PATH=$prefix/lib/pkgconfig" >> "$GITHUB_ENV" - name: Build pinned Ghostscript 10.07.1 run: | archive="$RUNNER_TEMP/ghostscript-10.07.1.tar.xz" From da8b8b9fc96c85cd3c2093e94109e1cd288f5e2d Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:47:20 -0600 Subject: [PATCH 07/11] Bundle the Python license from framework installations --- build_macos.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build_macos.py b/build_macos.py index 7748143..424afb8 100644 --- a/build_macos.py +++ b/build_macos.py @@ -24,6 +24,7 @@ import shutil import subprocess import sys +import sysconfig import tarfile import urllib.request from pathlib import Path @@ -387,7 +388,8 @@ def bundle_python_runtime_licenses() -> dict[str, str]: python_executable = Path(sys.executable).resolve() _copy_required_license( destination / "Python-PSF-LICENSE.txt", - [parent / "LICENSE" for parent in python_executable.parents], + [Path(sysconfig.get_path("stdlib")) / "LICENSE.txt"] + + [parent / "LICENSE" for parent in python_executable.parents], ) _copy_required_license( destination / "PyInstaller-GPL-2.0-with-exception.txt", From f3412d2ed73a6d3a88e4d688d415634e46b060dc Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:59:17 -0600 Subject: [PATCH 08/11] Install Ghostscript resources for relocated application bundles --- .github/workflows/build-macos14-arm64.yml | 2 ++ scripts/build-ghostscript-linux.sh | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/build-macos14-arm64.yml b/.github/workflows/build-macos14-arm64.yml index 4ec94d3..41bbf21 100644 --- a/.github/workflows/build-macos14-arm64.yml +++ b/.github/workflows/build-macos14-arm64.yml @@ -60,6 +60,8 @@ jobs: ./configure --prefix="$prefix" --disable-cups --disable-fontconfig --without-x make -j "$(sysctl -n hw.ncpu)" make install + # Make the resource tree available when the app sets GS_LIB. + make install-resdata0 install-iccdata0 cp doc/COPYING "$prefix/LICENSE" test "$("$prefix/bin/gs" --version)" = "10.07.1" echo "GHOSTSCRIPT_ROOT=$prefix" >> "$GITHUB_ENV" diff --git a/scripts/build-ghostscript-linux.sh b/scripts/build-ghostscript-linux.sh index 95412af..eebb629 100755 --- a/scripts/build-ghostscript-linux.sh +++ b/scripts/build-ghostscript-linux.sh @@ -15,4 +15,6 @@ cd "$work_dir/ghostscript-${version}" ./configure --prefix="$prefix" make -j "$(nproc)" make install +# The packaged application sets GS_LIB to the relocated resource tree. +make install-resdata0 install-iccdata0 test "$("$prefix/bin/gs" --version)" = "$version" From 5a0a9e0744873a26bc71af37510f434bf3f868c5 Mon Sep 17 00:00:00 2001 From: gitlares <57019432+gitlares@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:14:42 -0600 Subject: [PATCH 09/11] Document version 1.0.17 and credit the Smart scans contribution --- CHANGELOG.md | 2 ++ README.md | 12 ++++++------ docs/WINDOWS.md | 8 ++++---- docs/app.js | 6 +++--- docs/index.html | 28 ++++++++++++++-------------- docs/linux/index.html | 4 ++-- docs/llms.txt | 10 +++++----- docs/mac/index.html | 6 +++--- docs/releases/1.0.17.md | 15 +++++++++++++++ docs/windows/index.html | 8 ++++---- 10 files changed, 58 insertions(+), 41 deletions(-) create mode 100644 docs/releases/1.0.17.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c0abc9c..ad7c770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ image resolution. Restore the original embedded ICC profile when unchanged JPEGs and the surviving profiles identify it unambiguously. - Add the optional Smart scans profile for monochrome documents. + Thanks to [@MicheleFerr](https://github.com/MicheleFerr) for + [PR #18](https://github.com/gitlares/fs-pdf-compressor/pull/18). - Add WinSparkle to the Windows installer for signed update downloads and user-approved installation. Portable updates remain manual. Existing Windows users need one manual installation to get the updater. diff --git a/README.md b/README.md index 22a2164..fc380e3 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ no document is uploaded. - **macOS 14+ on Apple Silicon:** [download the signed and notarized DMG](https://github.com/gitlares/fs-pdf-compressor/releases/latest). - **Linux x86_64:** [install from the Snap Store](https://snapcraft.io/fs-pdf-compressor) for automatic updates, or [download the self-contained AppImage](https://github.com/gitlares/fs-pdf-compressor/releases/latest/download/FS-PDF-Compressor-x86_64.AppImage). -- **Windows 11 x64:** [download the per-user installer](https://github.com/gitlares/fs-pdf-compressor/releases/latest/download/FS-PDF-Compressor-1.0.16-windows-x86_64-setup.exe) or the [portable ZIP](https://github.com/gitlares/fs-pdf-compressor/releases/latest/download/FS-PDF-Compressor-1.0.16-windows-x86_64.zip). +- **Windows 11 x64:** [download the per-user installer](https://github.com/gitlares/fs-pdf-compressor/releases/latest/download/FS-PDF-Compressor-1.0.17-windows-x86_64-setup.exe) or the [portable ZIP](https://github.com/gitlares/fs-pdf-compressor/releases/latest/download/FS-PDF-Compressor-1.0.17-windows-x86_64.zip). The macOS build is Developer ID signed and Apple-notarized, so it opens normally with Gatekeeper enabled. The Linux AppImage is portable and bundles @@ -114,10 +114,10 @@ user-writable location so in-app updates can replace it safely. The Windows installer includes its dependencies and needs no administrator rights. It is currently unsigned, so verify the matching published SHA-256 -checksum before installing it. Windows updates in the published 1.0.16 release are manual: download a -new installer from the official GitHub release when one is published. +checksum before installing it. Install 1.0.17 manually to enable future signed +updates through WinSparkle. Portable updates are manual. -See [what changed in 1.0.16](docs/releases/1.0.16.md) or read the complete +See [what changed in 1.0.17](docs/releases/1.0.17.md) or read the complete [changelog](CHANGELOG.md). ## Build from source @@ -151,7 +151,7 @@ check; see [Linux instructions](docs/LINUX.md). The Windows edition uses the shared Qt interface and can be built from source as an x86_64 per-user installer. See the [Windows build and test instructions](docs/WINDOWS.md). The public installer and its portable ZIP are -available from the [1.0.16 release](https://github.com/gitlares/fs-pdf-compressor/releases/tag/v1.0.16). +available from the [1.0.17 release](https://github.com/gitlares/fs-pdf-compressor/releases/tag/v1.0.17). ## Code structure @@ -196,7 +196,7 @@ delivery and links opened by the user; see the short ## License FS PDF Compressor is released under the GNU Affero General Public License v3.0 -or later. Version 1.0.16 bundles Ghostscript 10.07.1 under the AGPL and +or later. Version 1.0.17 bundles Ghostscript 10.07.1 under the AGPL and other open-source libraries; see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Copyright © 2026 Daniel Lares. diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 5291fb9..706d515 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -1,13 +1,13 @@ # Build FS PDF Compressor on Windows FS PDF Compressor can be built on Windows from this repository. The Windows -edition uses the same local compression engine and three quality profiles as +edition uses the same local compression engine and quality profiles as the macOS and Linux editions. It is distributed under AGPL-3.0-or-later. ## Install the public build For Windows 11 x64, download the per-user installer or portable ZIP from the -[1.0.16 release](https://github.com/gitlares/fs-pdf-compressor/releases/tag/v1.0.16). +[1.0.17 release](https://github.com/gitlares/fs-pdf-compressor/releases/tag/v1.0.17). Each artifact has a matching SHA-256 file. The installer includes the unmodified AGPL Ghostscript runtime and needs no administrator rights. @@ -88,8 +88,8 @@ Install the setup executable on a clean Windows VM and check: 6. With **Keep original** on, the original remains beside `name compressed.pdf`. -The published 1.0.16 Windows build uses manual updates. Installer builds from -the current source add WinSparkle, subject to the +Install 1.0.17 manually to enable future WinSparkle updates. Portable +updates are manual. See the [update release checklist](WINDOWS_TESTING.md#winsparkle-update-checklist). ## Reproducible release builds diff --git a/docs/app.js b/docs/app.js index 866b84c..1f0c85e 100644 --- a/docs/app.js +++ b/docs/app.js @@ -1,7 +1,7 @@ const fallback = { - version: "v1.0.16", - macosUrl: "https://github.com/gitlares/fs-pdf-compressor/releases/download/v1.0.16/FS-PDF-Compressor-1.0.16-arm64.dmg", - windowsUrl: "https://github.com/gitlares/fs-pdf-compressor/releases/download/v1.0.16/FS-PDF-Compressor-1.0.16-windows-x86_64-setup.exe", + version: "v1.0.17", + macosUrl: "https://github.com/gitlares/fs-pdf-compressor/releases/download/v1.0.17/FS-PDF-Compressor-1.0.17-arm64.dmg", + windowsUrl: "https://github.com/gitlares/fs-pdf-compressor/releases/download/v1.0.17/FS-PDF-Compressor-1.0.17-windows-x86_64-setup.exe", linuxUrl: "https://github.com/gitlares/fs-pdf-compressor/releases/latest/download/FS-PDF-Compressor-x86_64.AppImage", }; diff --git a/docs/index.html b/docs/index.html index 78a2a1d..5f206dd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -79,10 +79,10 @@ "Linux x86_64" ], "softwareRequirements": "macOS 14+ on Apple Silicon, Windows 11 x64, or Linux x86_64", - "softwareVersion": "1.0.16", + "softwareVersion": "1.0.17", "dateModified": "2026-10-02", "downloadUrl": "https://github.com/gitlares/fs-pdf-compressor/releases/latest", - "releaseNotes": "https://github.com/gitlares/fs-pdf-compressor/releases/tag/v1.0.16", + "releaseNotes": "https://github.com/gitlares/fs-pdf-compressor/releases/tag/v1.0.17", "license": "https://github.com/gitlares/fs-pdf-compressor/blob/main/LICENSE", "isAccessibleForFree": true, "image": "https://gitlares.github.io/fs-pdf-compressor/og.png", @@ -167,15 +167,15 @@

Free PDF Compressor
for your desktop.

FS PDF Compressor is a free, open-source, offline PDF compressor for macOS Apple Silicon, Windows, and Linux. FS means Fast & Simple: drop in a PDF and get a smaller one—without uploads, accounts, or complicated settings. Compress PDFs without uploading them, with a signed and notarized Mac app, Windows installer, Snap Store install, or self-contained Linux AppImage.

- + Download for Apple Silicon - Download for Windows + Download for Windows Get it from Snap Store Linux AppImage View source
-

v1.0.16 · macOS 14+ Apple Silicon · Windows 11 x64 · Linux x86_64 via Snap or AppImage · Free forever

+

v1.0.17 · macOS 14+ Apple Silicon · Windows 11 x64 · Linux x86_64 via Snap or AppImage · Free forever