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.
- 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
@@ -219,18 +219,18 @@
Quality when needed
-
Available now · Version 1.0.16
-
Safer compatibility for Photoshop PDFs.
+
Available now · Version 1.0.17
+
Safer color handling and a new scan profile.
-
This corrective release restores the proven Ghostscript 10.07.1 engine after a 10.08.0 regression was found to cover visible content in some Photoshop-generated PDFs. The three quality profiles and local-only workflow remain unchanged.
+
FS PDF Compressor now checks embedded color profiles, page geometry, and selectable text before saving a compressed PDF.
- Ghostscript 10.07.1 is included consistently in the signed macOS app, Windows build, Snap, and AppImage.
- The reported Photoshop PDF now preserves its visible content in Preserve, Balanced, and Maximum profiles.
- No interface, privacy, file replacement, or preference behavior has changed.
+ When the original embedded color profile can be identified, the app retries compression at the original image resolution and restores that profile.
+ Smart scans adds adaptive compression for black-and-white documents. Thanks to @MicheleFerr for the contribution.
+ The Windows installer includes WinSparkle for signed update downloads and user-approved installation.
@@ -317,8 +317,8 @@ Windows
Drag, drop, done.
Free and open source under AGPL-3.0-or-later.