diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c5ed4b..491d240 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,9 +29,11 @@ jobs: sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 - name: Worker checks with all engines shell: bash + env: + CYBERSNAPPER_REQUIRED_BROWSERS: chromium,firefox,webkit run: | npm ci - npx playwright install chromium firefox webkit + npx playwright install --with-deps chromium firefox webkit npm run typecheck:worker npm run build:worker npm run test:worker @@ -43,26 +45,26 @@ jobs: fail-fast: false matrix: include: - - runner: ubuntu-24.04 + - runner: ubuntu-22.04 arch: x64 - linuxdeploy_asset: 497463883 - linuxdeploy_sha256: 421ca71d5c69ea97c6309276232990d43df1dcece0edfaa26bbf926ff96ed12e - qt_plugin_asset: 421996681 - qt_plugin_sha256: be1b7e166bf9975cfb694ebe6759ba40502ffc6196440d3e64aa90c4dbd67e9f - appimagetool_asset: 324406736 + node_arch: x64 + qt_host: linux + qt_arch: linux_gcc_64 + tool_arch: x86_64 + linuxdeploy_sha256: c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d + qt_plugin_sha256: 15106be885c1c48a021198e7e1e9a48ce9d02a86dd0a1848f00bdbf3c1c92724 appimagetool_sha256: ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 - runtime_asset: 456065460 - runtime_sha256: 1cc49bcf1e2ccd593c379adb17c9f85a36d619088296504de95b1d06215aebbf + runtime_sha256: 2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d - runner: ubuntu-24.04-arm arch: arm64 - linuxdeploy_asset: 497463664 - linuxdeploy_sha256: 9f04c4c2a8b69c392c4bbcc1a88bdd4d0a8ac03f587cf5242814cb7ae47b78e5 - qt_plugin_asset: 421996594 - qt_plugin_sha256: 5525e6c49c3c774c02b8864d2acc2ae4c5c0ccc3327f7dce626deaa36348e5c6 - appimagetool_asset: 324406707 + node_arch: arm64 + qt_host: linux_arm64 + qt_arch: linux_gcc_arm64 + tool_arch: aarch64 + linuxdeploy_sha256: 620095110d693282b8ebeb244a95b5e911cf8f65f76c88b4b47d16ae6346fcff + qt_plugin_sha256: bf1c24aff6d749b5cf423afad6f15abd4440f81dec1aab95706b25f6667cdcf1 appimagetool_sha256: f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 - runtime_asset: 456064894 - runtime_sha256: 7d5d772b7c32f0c84caf0a452a3072a5709027d7eac5856feb89a7a7a8881372 + runtime_sha256: 00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7 @@ -72,6 +74,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 + architecture: ${{ matrix.node_arch }} cache: npm - name: Verify release version @@ -83,12 +86,14 @@ jobs: - uses: jurplel/install-qt-action@v4 with: version: '6.8.3' + host: ${{ matrix.qt_host }} + arch: ${{ matrix.qt_arch }} cache: true - name: Install Linux build tools run: | sudo apt-get update - sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 libfuse2 file desktop-file-utils + sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-x11-0 libxcb-cursor0 file desktop-file-utils - name: Worker checks shell: bash @@ -127,23 +132,23 @@ jobs: - name: Keep production worker dependencies run: npm prune --omit=dev - # Each tool is pinned by immutable release-asset id plus its SHA-256 so a - # replaced "continuous" asset cannot silently change what we package. + # Every tool comes from a versioned upstream release and is verified + # before execution. APPIMAGE_EXTRACT_AND_RUN avoids a FUSE dependency. - name: Install pinned packaging tools shell: bash env: - GH_TOKEN: ${{ github.token }} + APPIMAGE_EXTRACT_AND_RUN: '1' run: | set -euo pipefail mkdir -p tools - gh api -H 'Accept: application/octet-stream' \ - "repos/linuxdeploy/linuxdeploy/releases/assets/${{ matrix.linuxdeploy_asset }}" > tools/linuxdeploy - gh api -H 'Accept: application/octet-stream' \ - "repos/linuxdeploy/linuxdeploy-plugin-qt/releases/assets/${{ matrix.qt_plugin_asset }}" > tools/linuxdeploy-plugin-qt - gh api -H 'Accept: application/octet-stream' \ - "repos/AppImage/appimagetool/releases/assets/${{ matrix.appimagetool_asset }}" > tools/appimagetool - gh api -H 'Accept: application/octet-stream' \ - "repos/AppImage/type2-runtime/releases/assets/${{ matrix.runtime_asset }}" > tools/AppImage-runtime + curl --fail --location --retry 3 --output tools/linuxdeploy \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-${{ matrix.tool_arch }}.AppImage" + curl --fail --location --retry 3 --output tools/linuxdeploy-plugin-qt \ + "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-qt-${{ matrix.tool_arch }}.AppImage" + curl --fail --location --retry 3 --output tools/appimagetool \ + "https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-${{ matrix.tool_arch }}.AppImage" + curl --fail --location --retry 3 --output tools/AppImage-runtime \ + "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-${{ matrix.tool_arch }}" printf '%s %s\n' "${{ matrix.linuxdeploy_sha256 }}" tools/linuxdeploy | sha256sum --check --strict printf '%s %s\n' "${{ matrix.qt_plugin_sha256 }}" tools/linuxdeploy-plugin-qt | sha256sum --check --strict printf '%s %s\n' "${{ matrix.appimagetool_sha256 }}" tools/appimagetool | sha256sum --check --strict @@ -188,10 +193,12 @@ jobs: include: - runner: windows-2022 arch: x64 + node_arch: x64 qt_arch: win64_msvc2022_64 msvc_arch: x64 - runner: windows-11-arm arch: arm64 + node_arch: arm64 qt_arch: win64_msvc2022_arm64 msvc_arch: amd64_arm64 runs-on: ${{ matrix.runner }} @@ -203,6 +210,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 + architecture: ${{ matrix.node_arch }} cache: npm - name: Verify release version @@ -226,9 +234,6 @@ jobs: run: | npm ci npx playwright install chromium - if [ "${{ runner.os }}" = "Linux" ]; then - npx playwright install firefox webfox webkit - fi npm run typecheck:worker npm run build:worker npm run test:worker @@ -312,8 +317,12 @@ jobs: include: - runner: macos-15-intel arch: x64 + node_arch: x64 + cmake_arch: x86_64 - runner: macos-15 arch: arm64 + node_arch: arm64 + cmake_arch: arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7 @@ -323,6 +332,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 + architecture: ${{ matrix.node_arch }} cache: npm - name: Verify release version @@ -381,6 +391,7 @@ jobs: run: | cmake -S . -B build/native -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DCYBERSNAPPER_BUILD_TESTS=ON \ + -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_arch }} \ -DCYBERSNAPPER_MACOS_ICON="$RUNNER_TEMP/CyberSnapper.icns" cmake --build build/native --parallel ctest --test-dir build/native --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index a33fed9..acf22fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.24) -project(CyberSnapper VERSION 2.3.0 LANGUAGES CXX) +project(CyberSnapper VERSION 2.3.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 491dce1..bece619 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The finished files are normal PNG, WebP, AVIF, or PDF files in a portable folder ## Download -CyberSnapper 2.2.2 is the current stable release. Every package bundles the application, Qt runtime, Node runtime, capture worker, and Chromium. Firefox and WebKit can be installed on demand from Settings. +CyberSnapper 2.3.1 is the current stable release. Every package bundles the application, Qt runtime, Node runtime, capture worker, and Chromium. Firefox and WebKit can be installed on demand from Settings. | Platform | Recommended | Portable archive | | --- | --- | --- | @@ -44,7 +44,7 @@ Every release includes SHA-256 checksums and GitHub build-provenance attestation - Capture full scrolling pages, exact viewports, or one CSS-selected element. - Produce desktop, tablet, mobile, and custom-sized images together with explicit pixel density and mobile-browser controls. -- Wait for pages to settle, block common overlays, and hide chosen elements so banners and animation do not spoil the shot. +- Wait for pages to settle, remove cookie banners with curated or custom rules, and hide chosen elements so overlays and animation do not spoil the shot. - Create portfolio-ready copies with Clean, Aurora, Sunset, Midnight, Graphite, or custom-solid scenes; add browser, tablet, phone, or rounded-card frames; and target 16:9, 4:3, square, or content-fit canvases. - Save labeled target sets for all the projects and pages in a portfolio, then recapture them as one batch. - Export PNG, WebP, AVIF, and Chromium PDF with collision-safe names into ordinary folders. diff --git a/RELEASE.md b/RELEASE.md index fdc6fd8..37fdf52 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -55,13 +55,21 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup - If the tagged source or a packaged application is defective, do not move the tag or silently replace the release. Document the issue and publish a corrected patch release from a new commit and tag. - Keep an incomplete release unannounced until recovery succeeds. If downloads may be unsafe or misleading, mark the release as a prerelease while preparing the corrective release. +## v2.3.1 + +- **Correct packages**: Restores the complete Linux, Windows, and macOS x64/arm64 release matrix with immutable, checksum-verified Linux packaging tools. +- **Safer blocking**: Rejected filters can no longer reach the rules engine, consent handling covers frames, and delayed banner removal releases scroll locks correctly. +- **Working custom rulesets**: Profile selections now survive the configuration dialog and newly created rulesets can be enabled immediately. +- **Reliable subscriptions**: Redirects use their validated destination, oversized downloads abort early, and identical rules produce one stable content-addressed snapshot. +- **Release integrity**: Fixes malformed workflow YAML, native source warnings, version metadata, browser-matrix enforcement, and inaccurate packaging documentation. + ## v2.3.0 -- **Content blocking**: Cookie banner removal, consent handling, site exceptions, custom rulesets, and visual review. -- **Security**: Capture boundary, HTTPS-only downloads, atomic writes, and last-known-good snapshots. -- **Compatibility**: Browser matrix (Chromium/Firefox/WebKit) and schema v5. -- **Developer**: REST API v1 (`/v1/contentBlocking/*`), RPC v1 (`contentBlocking.*`), and worker protocol v2. -- **Documentation**: Updated `ARCHITECTURE.md`, `PROJECT_FORMAT.md`, and `THIRD-PARTY.md`. +- **Content blocking**: Cookie banner removal, consent handling, site exceptions, custom rulesets, and visual review. +- **Security**: Capture boundary, HTTPS-only downloads, atomic writes, and last-known-good snapshots. +- **Compatibility**: Browser matrix (Chromium/Firefox/WebKit) and schema v5. +- **Developer**: REST API v1 (`/v1/contentBlocking/*`), RPC v1 (`contentBlocking.*`), and worker protocol v2. +- **Documentation**: Updated `ARCHITECTURE.md`, `PROJECT_FORMAT.md`, and `THIRD-PARTY.md`. - **Packaging**: AppImage/tar.gz (Linux x64/arm64), NSIS/ZIP (Windows x64), DMG/ZIP (macOS x64/arm64). ## v2.2.2 diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 6db6d1a..a0b4b6d 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -17,7 +17,7 @@ each supported architecture: | Platform | Architectures | Recommended | Portable | Runners | | --- | --- | --- | --- | --- | -| Linux | x64, arm64 | AppImage | tar.gz | `ubuntu-24.04`, `ubuntu-24.04-arm` | +| Linux | x64, arm64 | AppImage | tar.gz | `ubuntu-22.04`, `ubuntu-24.04-arm` | | Windows | x64, arm64 | NSIS setup `.exe` | ZIP | `windows-2022`, `windows-11-arm` | | macOS | x64, arm64 | DMG | ZIP | `macos-15-intel`, `macos-15` | @@ -82,9 +82,11 @@ package: 1. `cmake --install` into an `AppDir/` tree under the build directory. 2. The desktop file, metainfo, and icon are validated (these are installed by the CMake rules for Linux). -3. Unused Qt SQL drivers (`mysql`, `mimer`, `odbc`, `psql`) are removed; only - SQLite is deployed. `libqsqlmimer.so` in particular depends on an absent - `libmimerapi.so` and would otherwise abort deployment. +3. Qt plugins are copied into a private staging directory, where unused SQL + drivers (`mysql`, `mimer`, `odbc`, `psql`) are removed; only SQLite is + deployed. The installed Qt SDK is never modified. `libqsqlmimer.so` in + particular depends on an absent `libmimerapi.so` and would otherwise abort + deployment. 4. `linuxdeploy` with `linuxdeploy-plugin-qt` bundles non-Qt and Qt dependencies into `AppDir/`. A stale Qt 6 hook is removed after deployment. 5. `appimagetool` with a pinned type-2 runtime turns `AppDir/` into the @@ -96,11 +98,15 @@ match the target architecture, and `ldd` must report no unresolved libraries before the AppImage is produced. The four external tools — `linuxdeploy`, `linuxdeploy-plugin-qt`, -`appimagetool`, and the AppImage type-2 runtime — are **pinned by immutable -GitHub release-asset ID plus SHA-256** and downloaded with `gh api`. Pinning by -asset ID (not tag name) prevents a replaced "continuous" upstream asset from -silently changing what gets packaged. The per-architecture IDs and checksums -live in the workflow matrix. +`appimagetool`, and the AppImage type-2 runtime — are downloaded from +**versioned upstream releases and pinned by SHA-256**. The workflow never uses +moving `continuous` assets or repository-hosted binary mirrors. Tool AppImages +run with `APPIMAGE_EXTRACT_AND_RUN=1`, so the build does not depend on FUSE. +The per-architecture checksums live in the workflow matrix. + +Linux x64 builds on Ubuntu 22.04 to retain a lower glibc floor. Qt's official +Linux arm64 package requires Ubuntu 24.04, so the arm64 AppImage has that newer +glibc compatibility floor. ## Windows: setup executable and portable ZIP diff --git a/docs/releases/v2.3.0.md b/docs/releases/v2.3.0.md index 3f290a6..a6b66d7 100644 --- a/docs/releases/v2.3.0.md +++ b/docs/releases/v2.3.0.md @@ -2,48 +2,47 @@ ## Content blocking -- **Cookie banner removal**: Automatically hide and dismiss cookie consent banners using curated filter lists (EasyList Cookie, uBlock Origin Cookie) and custom rulesets. -- **Consent handling**: Choose between "reject non-essential cookies then dismiss" (default) or "dismiss using the site's own accept control" strategies. -- **Site exceptions**: Skip content blocking entirely on specific domains (e.g., `app.example.com`). -- **Custom rulesets**: Create and reuse rulesets with cosmetic filters (`##.promo-overlay`) and trusted click actions (`example.com#click(#save-button)`). -- **Visual review**: Every artifact in History now shows a provenance tooltip with blocked subresources, consent actions, and warnings. +- **Cookie banner removal**: Automatically hide and dismiss cookie consent banners using curated filter lists (EasyList Cookie, uBlock Origin Cookie) and custom rulesets. +- **Consent handling**: Choose between "reject non-essential cookies then dismiss" (default) or "dismiss using the site's own accept control" strategies. +- **Site exceptions**: Skip content blocking entirely on specific domains (e.g., `app.example.com`). +- **Custom rulesets**: Create and reuse rulesets with cosmetic filters (`##.promo-overlay`) and bounded click/hide actions configured by domain and CSS selector. +- **Visual review**: Every artifact in History now shows a provenance tooltip with blocked subresources, consent actions, and warnings. ## Security -- **Capture boundary**: Community filter exceptions (e.g., `@@||127.0.0.1^`) can never bypass the network-security policy; user blocklist fragments always win. -- **HTTPS-only downloads**: Subscription lists and snapshots are fetched over HTTPS with certificate validation. -- **Atomic writes**: Temporary files are never left behind; last-known-good snapshots are used if a refresh fails. +- **Capture boundary**: Community filter exceptions (e.g., `@@||127.0.0.1^`) can never bypass the network-security policy; user blocklist fragments always win. +- **HTTPS-only downloads**: Subscription lists and snapshots are fetched over HTTPS with certificate validation. +- **Atomic writes**: Temporary files are never left behind; last-known-good snapshots are used if a refresh fails. ## Compatibility -- **Browser matrix**: Release packages are tested against Chromium, Firefox, and WebKit on Linux, Windows, and macOS. -- **Schema v5**: Project databases are automatically upgraded; downgrades are not supported. +- **Browser matrix**: Release packages are tested against Chromium, Firefox, and WebKit on Linux, Windows, and macOS. +- **Schema v5**: Project databases are automatically upgraded; downgrades are not supported. ## Developer -- **REST API v1**: New `/v1/contentBlocking/catalog`, `/v1/contentBlocking/status`, and `/v1/contentBlocking/refresh` endpoints. -- **RPC v1**: `contentBlocking.catalog`, `contentBlocking.status`, and `contentBlocking.refresh` methods. -- **Worker protocol v2**: Content blocking events carry `blockedSubresources`, `consentActionsAttempted`, and `consentActionsSucceeded` metrics. +- **REST API v1**: New `/v1/contentBlocking/catalog`, `/v1/contentBlocking/status`, and `/v1/contentBlocking/refresh` endpoints. +- **RPC v1**: `contentBlocking.catalog`, `contentBlocking.status`, and `contentBlocking.refresh` methods. +- **Worker protocol v2**: Content blocking events carry `blockedSubresources`, `consentActionsAttempted`, and `consentActionsSucceeded` metrics. ## Documentation -- `docs/ARCHITECTURE.md`: Updated for the capture boundary, visual review model, and scheduling. -- `docs/PROJECT_FORMAT.md`: Schema v5 reference. -- `docs/THIRD-PARTY.md`: Added EasyList and uBlock Origin licenses. +- `docs/ARCHITECTURE.md`: Updated for the capture boundary, visual review model, and scheduling. +- `docs/PROJECT_FORMAT.md`: Schema v5 reference. +- `docs/THIRD-PARTY.md`: Added EasyList and uBlock Origin licenses. ## Packaging -- **Linux**: AppImage and tar.gz (x64, arm64). -- **Windows**: NSIS installer and ZIP (x64). -- **macOS**: DMG and ZIP (x64, arm64). +- **Linux**: AppImage and tar.gz (x64, arm64). +- **Windows**: NSIS installer and ZIP (x64). +- **macOS**: DMG and ZIP (x64, arm64). ## Upgrading -1. Open any project; the database will be upgraded to schema v5 automatically. -2. Visit **Preferences → Content Blocking** to configure subscriptions and strategy. +1. Open any project; the database will be upgraded to schema v5 automatically. +2. Visit **Preferences → Content Blocking** to configure subscriptions and strategy. 3. Use **History → Open Containing Folder** to inspect provenance details. ## Known issues -- Firefox and WebKit do not support `page.route()` for network-level blocking; cosmetic filters and consent actions work normally. -- macOS WebKit may show spurious "Unable to load page" errors in the Review tab; reload to retry. \ No newline at end of file +- macOS WebKit may show spurious "Unable to load page" errors in the Review tab; reload to retry. diff --git a/docs/releases/v2.3.1.md b/docs/releases/v2.3.1.md new file mode 100644 index 0000000..b87a7ec --- /dev/null +++ b/docs/releases/v2.3.1.md @@ -0,0 +1,45 @@ +# CyberSnapper 2.3.1 (2026-08-22) + +CyberSnapper 2.3.1 is the corrective release for the content-blocking update. +It replaces the incomplete 2.3.0 package set and fixes defects found while +auditing every change since 2.2.2. + +## Content blocking fixes + +- Unsafe or unsupported community rules are now removed before the rules + engine is created, rather than merely counted in diagnostics. +- Selected custom rulesets persist through the configuration dialog and new + rulesets can be enabled for the profile immediately. +- Cookie-consent handling runs in document frames, waits briefly for + asynchronous banner removal, and only releases scroll locks after a + recognized banner disappears. +- PageSpeed's built-in handler no longer prevents applicable custom actions + from running. +- Subscription redirects follow the validated destination and oversized list + downloads are stopped as soon as they cross the 10 MiB limit. +- Ruleset digests are stable for identical inputs, so repeated jobs reuse the + same content-addressed snapshot while retries continue using their exact + original snapshot. + +The prematurely exposed list-pinning option has been removed. Captures use the +latest locally cached lists at submission time; the resulting job snapshot is +then immutable and reused for retries. + +## Packaging fixes + +- Restores valid release-workflow YAML and removes the failed binary-mirror + workaround. +- Downloads linuxdeploy, its Qt plugin, appimagetool, and the type-2 runtime + from immutable versioned upstream releases with verified SHA-256 hashes. +- Uses native Node and Qt packages for each architecture, including the + official Linux arm64 Qt host package. +- Builds Linux x64 on Ubuntu 22.04 for a lower glibc compatibility floor and + Linux arm64 on Ubuntu 24.04 as required by Qt. +- Requires Chromium, Firefox, and WebKit to actually launch in the release + browser-matrix test instead of silently skipping unavailable engines. +- Produces and smoke-tests all twelve expected assets: AppImage/tar.gz, + setup/portable ZIP, and DMG/ZIP for x64 and arm64. + +Every release asset is covered by `SHA256SUMS.txt` and GitHub build-provenance +attestation. Packages remain unsigned on Windows and Linux; macOS bundles are +ad-hoc signed and not notarized. diff --git a/native/src/core/AgentService.cpp b/native/src/core/AgentService.cpp index 8f481e8..37acdf1 100644 --- a/native/src/core/AgentService.cpp +++ b/native/src/core/AgentService.cpp @@ -9,7 +9,8 @@ #include #include #include -#include #include +#include +#include #include #include #include diff --git a/native/src/core/ContentRulesets.cpp b/native/src/core/ContentRulesets.cpp index b0aa8bb..e7600b2 100644 --- a/native/src/core/ContentRulesets.cpp +++ b/native/src/core/ContentRulesets.cpp @@ -24,7 +24,7 @@ const QList &catalog() { QStringLiteral("CC BY-SA 3.0"), true}, {QStringLiteral("ublock-cookie"), QStringLiteral("uBlock Cookie Notices"), QStringLiteral( - "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/cookies.txt"), + "https://ublockorigin.github.io/uAssets/filters/annoyances-cookies.txt"), QStringLiteral("GPLv3"), true}, {QStringLiteral("easylist-ads"), QStringLiteral("EasyList"), QStringLiteral("https://easylist.to/easylist/easylist.txt"), @@ -34,7 +34,7 @@ const QList &catalog() { QStringLiteral("CC BY-SA 3.0"), false}, {QStringLiteral("ublock-annoyances"), QStringLiteral("uBlock Annoyances"), QStringLiteral( - "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt"), + "https://ublockorigin.github.io/uAssets/filters/annoyances.txt"), QStringLiteral("GPLv3"), false}, }; return subscriptions; @@ -144,7 +144,7 @@ RulesetReferenceInfo buildSnapshot(ProjectStore *store, const CaptureProfile &pr } qsizetype totalBytes = 0; - for (const QString &line : collected.lines) totalBytes += line.size() + 1; + for (const QString &line : collected.lines) totalBytes += line.toUtf8().size() + 1; if (totalBytes > 32 * 1024 * 1024) { collected.warnings.append( QStringLiteral("Combined filter text exceeded the 32 MiB limit; content blocking is " @@ -155,14 +155,25 @@ RulesetReferenceInfo buildSnapshot(ProjectStore *store, const CaptureProfile &pr } QJsonObject payloadObject; - payloadObject.insert("generatedAt", utcNow()); payloadObject.insert("subscriptions", collected.sources); QJsonArray rulesJson; for (const QString &line : collected.lines) rulesJson.append(line); payloadObject.insert("rulesText", rulesJson); payloadObject.insert("actions", collected.actions); - const QString payload = + QString payload = QString::fromUtf8(QJsonDocument(payloadObject).toJson(QJsonDocument::Compact)); + if (payload.toUtf8().size() > 32 * 1024 * 1024) { + collected.warnings.append( + QStringLiteral("Encoded filter snapshot exceeded the 32 MiB limit; content blocking is " + "limited to built-in consent handling for this capture")); + collected.lines.clear(); + collected.actions = {}; + collected.sources = {}; + payloadObject.insert("subscriptions", collected.sources); + payloadObject.insert("rulesText", QJsonArray{}); + payloadObject.insert("actions", collected.actions); + payload = QString::fromUtf8(QJsonDocument(payloadObject).toJson(QJsonDocument::Compact)); + } const QString digest = sha256Hex(payload.toUtf8()); const QString relativePath = QStringLiteral(".cybersnapper/rulesets/%1.json").arg(digest); @@ -176,7 +187,8 @@ RulesetReferenceInfo buildSnapshot(ProjectStore *store, const CaptureProfile &pr if (error) *error = file.errorString(); return reference; } - const QJsonObject envelope{{"format", 1}, {"digest", digest}, {"payload", payload}}; + const QJsonObject envelope{{"format", 1}, {"digest", digest}, {"createdAt", utcNow()}, + {"payload", payload}}; file.write(QJsonDocument(envelope).toJson(QJsonDocument::Compact)); if (!file.commit()) { if (error) *error = file.errorString(); diff --git a/native/src/core/Models.cpp b/native/src/core/Models.cpp index a8ad55c..7b3bb7c 100644 --- a/native/src/core/Models.cpp +++ b/native/src/core/Models.cpp @@ -100,10 +100,11 @@ ContentBlockingSettings contentBlockingFromJson(const QJsonObject &profile) { if (settings.customRulesetIds.size() > 64) settings.customRulesetIds = settings.customRulesetIds.mid(0, 64); settings.disabledDomains = stringList(object.value("disabledDomains")); if (settings.disabledDomains.size() > 1000) settings.disabledDomains = settings.disabledDomains.mid(0, 1000); - settings.versionPolicy = object.value("versionPolicy").toString(settings.versionPolicy); - if (settings.versionPolicy != "latest" && settings.versionPolicy != "pinned") { - settings.versionPolicy = QStringLiteral("latest"); - } + // Snapshot pinning was exposed prematurely in 2.3.0 without storing a + // profile-specific digest. Keep the JSON field stable but use the only + // implemented, reproducible policy: snapshot the latest cached lists when + // the job is submitted, then reuse that exact snapshot for retries. + settings.versionPolicy = QStringLiteral("latest"); return settings; } settings.enabled = profile.value("blockPopups").toBool(false); diff --git a/native/src/core/SubscriptionRefresher.cpp b/native/src/core/SubscriptionRefresher.cpp index 883ebe6..687a7b7 100644 --- a/native/src/core/SubscriptionRefresher.cpp +++ b/native/src/core/SubscriptionRefresher.cpp @@ -136,11 +136,18 @@ void SubscriptionRefresher::refreshNow(const QString &subscriptionId) { for (const QString &id : ids) { if (m_pending.contains(id)) continue; m_pending.append(id); - refreshOne(id, kMaximumRedirects); + const auto &subscriptions = subscriptionCatalog(); + for (const auto &info : subscriptions) { + if (info.id == id) { + refreshOne(id, QUrl(info.sourceUrl), kMaximumRedirects); + break; + } + } } } -void SubscriptionRefresher::refreshOne(const QString &subscriptionId, int redirectBudget) { +void SubscriptionRefresher::refreshOne(const QString &subscriptionId, const QUrl &url, + int redirectBudget) { const SubscriptionInfo *info = nullptr; for (const auto &candidate : subscriptionCatalog()) { if (candidate.id == subscriptionId) info = &candidate; @@ -155,7 +162,6 @@ void SubscriptionRefresher::refreshOne(const QString &subscriptionId, int redire emit warning(QStringLiteral("Refresh of %1 stopped: %2").arg(info->name, reason)); }; - const QUrl url(info->sourceUrl); QString reason; if (!sourceUrlIsAllowed(url, &reason)) return finishFailure(reason); @@ -189,15 +195,26 @@ void SubscriptionRefresher::issueRequest(const QUrl &url, const SubscriptionInfo request.setRawHeader( "User-Agent", QStringLiteral("CyberSnapper/%1").arg(QStringLiteral(CYBERSNAPPER_VERSION)).toUtf8()); - const CacheMeta conditional = readMeta(subscriptionId); - if (!conditional.etag.isEmpty()) { - request.setRawHeader("If-None-Match", conditional.etag.toUtf8()); - } - if (!conditional.lastModified.isEmpty()) { - request.setRawHeader("If-Modified-Since", conditional.lastModified.toUtf8()); + if (url == QUrl(info->sourceUrl)) { + const CacheMeta conditional = readMeta(subscriptionId); + if (!conditional.etag.isEmpty()) { + request.setRawHeader("If-None-Match", conditional.etag.toUtf8()); + } + if (!conditional.lastModified.isEmpty()) { + request.setRawHeader("If-Modified-Since", conditional.lastModified.toUtf8()); + } } QNetworkReply *reply = m_network->get(request); + const auto stopOversizedTransfer = [reply] { + const qint64 declared = reply->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + if (declared > kMaximumListBytes || reply->bytesAvailable() > kMaximumListBytes) { + reply->setProperty("cybersnapperListTooLarge", true); + reply->abort(); + } + }; + connect(reply, &QNetworkReply::metaDataChanged, this, stopOversizedTransfer); + connect(reply, &QIODevice::readyRead, this, stopOversizedTransfer); connect(reply, &QNetworkReply::finished, this, [this, reply, info, subscriptionId, redirectBudget, finishFailure] { reply->deleteLater(); @@ -205,6 +222,13 @@ void SubscriptionRefresher::issueRequest(const QUrl &url, const SubscriptionInfo const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->property("cybersnapperListTooLarge").toBool()) { + emit refreshed(subscriptionId, false, QStringLiteral("List exceeds 10 MiB")); + emit warning(QStringLiteral("%1 exceeded the 10 MiB limit and was not updated") + .arg(info->name)); + return; + } + if (status == 304) { CacheMeta meta = readMeta(subscriptionId); meta.expiresAt = QDateTime::currentDateTimeUtc() @@ -226,7 +250,7 @@ void SubscriptionRefresher::issueRequest(const QUrl &url, const SubscriptionInfo } // Re-run the full validation chain (DNS included) on the target. m_pending.append(subscriptionId); - refreshOne(subscriptionId, redirectBudget - 1); + refreshOne(subscriptionId, target, redirectBudget - 1); return; } diff --git a/native/src/core/SubscriptionRefresher.h b/native/src/core/SubscriptionRefresher.h index 72a1528..5d12b32 100644 --- a/native/src/core/SubscriptionRefresher.h +++ b/native/src/core/SubscriptionRefresher.h @@ -9,6 +9,7 @@ class QNetworkAccessManager; class QTimer; +class QUrl; namespace CyberSnapper { @@ -51,7 +52,7 @@ class SubscriptionRefresher final : public QObject { void warning(const QString &message); private: - void refreshOne(const QString &subscriptionId, int redirectBudget); + void refreshOne(const QString &subscriptionId, const QUrl &url, int redirectBudget); void issueRequest(const QUrl &url, const SubscriptionInfo *info, const QString &subscriptionId, int redirectBudget, std::function finishFailure); void scheduleNextCheck(); diff --git a/native/src/gui/ContentBlockingDialog.cpp b/native/src/gui/ContentBlockingDialog.cpp index f6be818..ed102d5 100644 --- a/native/src/gui/ContentBlockingDialog.cpp +++ b/native/src/gui/ContentBlockingDialog.cpp @@ -42,6 +42,10 @@ QString joinedDomains(const QJsonValue &value) { ContentBlockingDialog::ContentBlockingDialog(const QJsonObject &settings, const RpcInvoker &rpc, QWidget *parent) : QDialog(parent), m_rpc(rpc), m_enabled(settings.value("enabled").toBool(true)) { + for (const auto &value : settings.value("customRulesetIds").toArray()) { + const QString id = value.toString().trimmed(); + if (!id.isEmpty()) m_selectedRulesetIds.append(id); + } setWindowTitle("Configure Content Blocking"); resize(780, 620); auto *layout = new QVBoxLayout(this); @@ -85,15 +89,9 @@ ContentBlockingDialog::ContentBlockingDialog(const QJsonObject &settings, const m_consentStrategy->addItem("Dismiss banners using the site's own accept control", "dismiss"); m_consentStrategy->setCurrentIndex(qMax(0, m_consentStrategy->findData( settings.value("consentStrategy").toString("rejectThenDismiss")))); - m_versionPolicy = new QComboBox(behavior); - m_versionPolicy->addItem("Always use the latest downloaded version", "latest"); - m_versionPolicy->addItem("Keep the pinned snapshot until refreshed manually", "pinned"); - m_versionPolicy->setCurrentIndex(qMax(0, m_versionPolicy->findData( - settings.value("versionPolicy").toString("latest")))); m_disabledDomains = new QLineEdit(joinedDomains(settings.value("disabledDomains")), behavior); m_disabledDomains->setPlaceholderText("mail.example.com, app.example.com"); behaviorForm->addRow("Strategy", m_consentStrategy); - behaviorForm->addRow("List versions", m_versionPolicy); behaviorForm->addRow("Never block on sites", m_disabledDomains); behaviorForm->addRow(new QLabel( "Content blocking is skipped entirely on the listed sites.", behavior)); @@ -103,6 +101,7 @@ ContentBlockingDialog::ContentBlockingDialog(const QJsonObject &settings, const auto *rulesetLayout = new QHBoxLayout(rulesetsGroup); auto *left = new QVBoxLayout; m_rulesetList = new QListWidget(rulesetsGroup); + m_rulesetList->setObjectName("contentRulesetList"); m_rulesetList->setMinimumWidth(190); left->addWidget(m_rulesetList, 1); auto *addButton = new QPushButton("New ruleset", rulesetsGroup); @@ -151,6 +150,8 @@ ContentBlockingDialog::ContentBlockingDialog(const QJsonObject &settings, const connect(addButton, &QPushButton::clicked, this, [this] { const int newRow = m_rulesetList->count(); auto *item = new QListWidgetItem(QStringLiteral("Untitled ruleset")); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(Qt::Checked); item->setData(Qt::UserRole, newId()); item->setData(Qt::UserRole + 1, QJsonObject{{"name", ""}, {"kind", "custom"}, {"rulesText", ""}, {"actions", QJsonArray{}}}); @@ -227,18 +228,19 @@ void ContentBlockingDialog::refreshSubscriptionStatus() { } void ContentBlockingDialog::loadRulesets() { - qDebug() << "loadRulesets: m_rpc is empty:" << m_rpc.target_type().name(); if (!m_rpc) { m_editor->setEnabled(false); m_editor->parentWidget()->setEnabled(false); - qDebug() << "Editor and parent disabled in loadRulesets"; return; } m_rpc("contentRuleset.list", {}, [this](const QJsonObject &result) { for (const auto &value : result.value("contentRulesets").toArray()) { const QJsonObject ruleset = value.toObject(); auto *item = new QListWidgetItem(ruleset.value("name").toString()); - item->setData(Qt::UserRole, ruleset.value("id").toString()); + const QString id = ruleset.value("id").toString(); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(m_selectedRulesetIds.contains(id) ? Qt::Checked : Qt::Unchecked); + item->setData(Qt::UserRole, id); item->setData(Qt::UserRole + 1, ruleset); m_rulesetList->addItem(item); } @@ -422,10 +424,18 @@ QJsonObject ContentBlockingDialog::settings() const { for (const QString &part : m_disabledDomains->text().split(',', Qt::SkipEmptyParts)) { disabledDomains.append(part.trimmed().toLower()); } + QJsonArray customRulesetIds; + for (int row = 0; row < m_rulesetList->count(); ++row) { + const QListWidgetItem *item = m_rulesetList->item(row); + if (item->checkState() == Qt::Checked && !item->data(Qt::UserRole).toString().isEmpty()) { + customRulesetIds.append(item->data(Qt::UserRole).toString()); + } + } return QJsonObject{{"enabled", m_enabled}, {"consentStrategy", m_consentStrategy->currentData().toString()}, {"subscriptionIds", subscriptionIds}, - {"versionPolicy", m_versionPolicy->currentData().toString()}, + {"customRulesetIds", customRulesetIds}, + {"versionPolicy", "latest"}, {"disabledDomains", disabledDomains}}; } diff --git a/native/src/gui/ContentBlockingDialog.h b/native/src/gui/ContentBlockingDialog.h index 6149a24..15a9a45 100644 --- a/native/src/gui/ContentBlockingDialog.h +++ b/native/src/gui/ContentBlockingDialog.h @@ -2,6 +2,9 @@ #include #include +#include + +#include class QCheckBox; class QComboBox; @@ -42,8 +45,8 @@ class ContentBlockingDialog final : public QDialog { bool m_enabled = true; QList m_subscriptionChecks; QStringList m_subscriptionIds; + QStringList m_selectedRulesetIds; QComboBox *m_consentStrategy = nullptr; - QComboBox *m_versionPolicy = nullptr; QLineEdit *m_disabledDomains = nullptr; QListWidget *m_rulesetList = nullptr; QWidget *m_editor = nullptr; diff --git a/native/tests/DocFixture.cpp b/native/tests/DocFixture.cpp index 9100746..c2080ff 100644 --- a/native/tests/DocFixture.cpp +++ b/native/tests/DocFixture.cpp @@ -177,7 +177,22 @@ int main(int argc, char **argv) { profile.engines = {"chromium", "firefox"}; profile.formats = {"png", "webp"}; profile.concurrency = 2; - profile.blockPopups = true; + profile.contentBlocking.enabled = true; + if (const QJsonObject ruleset = store.saveContentRuleset( + {{"name", "Portfolio banners"}, + {"rulesText", + "! Hide consent banners on the portfolio sites\n" + "example.org##.cookie-banner\n" + "example.org##.consent-overlay\n" + "||cdn.example.net/consent.js^$script\n"}, + {"actions", QJsonArray{QJsonObject{{"domains", QJsonArray{"example.org"}}, + {"selector", "button#reject-all"}, {"action", "click"}, {"delayMs", 250}}}}}, + &error); + !ruleset.isEmpty()) { + profile.contentBlocking.customRulesetIds.append(ruleset.value("id").toString()); + } else { + return fail(error); + } profile.presentation.enabled = true; profile.presentation.scene = "aurora"; profile.presentation.frame = "darkTablet"; diff --git a/native/tests/TestCore.cpp b/native/tests/TestCore.cpp index cff76cd..f0f4366 100644 --- a/native/tests/TestCore.cpp +++ b/native/tests/TestCore.cpp @@ -344,6 +344,12 @@ void TestCore::rulesSnapshotBuilderAndJobContract() { QVERIFY(parsed.value("rulesText").toArray().contains(QJsonValue("example.org##.banner"))); QCOMPARE(parsed.value("actions").toArray().size(), 1); + // Identical source content must reuse the same content-addressed snapshot. + // Wall-clock creation time belongs to the envelope, not the hashed payload. + const RulesetReferenceInfo repeated = ContentRulesets::buildSnapshot(&store, profile, &error); + QCOMPARE(repeated.digest, reference.digest); + QCOMPARE(repeated.relativePath, reference.relativePath); + // The job contract carries the snapshot as a nested ruleset object and // round-trips through the stored request JSON for recovery. JobRequest request; diff --git a/native/tests/TestGui.cpp b/native/tests/TestGui.cpp index 5f3415b..dce1b39 100644 --- a/native/tests/TestGui.cpp +++ b/native/tests/TestGui.cpp @@ -92,10 +92,10 @@ void TestGui::contentBlockingDialog() { obj["versionPolicy"] = "latest"; return obj; }(); - + // Null RPC: simulates no agent connection (editor disabled) ContentBlockingDialog dialog(settings, {}, nullptr); - + // Subscription checkboxes: easylist-cookie and ublock-cookie should be checked const auto &checks = dialog.findChildren(); bool easylistFound = false, ublockFound = false; @@ -111,7 +111,7 @@ void TestGui::contentBlockingDialog() { } QVERIFY(easylistFound); QVERIFY(ublockFound); - + // Strategy combo: rejectThenDismiss (index 0) const auto &combos = dialog.findChildren(); QComboBox *strategy = nullptr; @@ -123,34 +123,21 @@ void TestGui::contentBlockingDialog() { } QVERIFY(strategy); QCOMPARE(strategy->currentIndex(), 0); - - // Version policy combo: latest (index 0) - QComboBox *version = nullptr; - for (QComboBox *combo : combos) { - if (combo->count() == 2 && combo->itemData(0).toString() == "latest") { - version = combo; - break; - } - } - QVERIFY(version); - QCOMPARE(version->currentIndex(), 0); - + // Editor disabled when no RPC QTest::qWait(50); - qDebug() << "Editor widgets:" << dialog.findChildren(); QWidget *editor = nullptr; for (QWidget *widget : dialog.findChildren()) { QLineEdit *nameEdit = widget->findChild(); QTextEdit *rulesText = widget->findChild(); if (nameEdit && rulesText) { editor = widget; - qDebug() << "Found editor:" << editor << "enabled:" << editor->isEnabled(); break; } } QVERIFY(editor); QVERIFY(!editor->isEnabled()); - + // Validation: add ruleset with empty name → expect rejection QPushButton *addButton = nullptr; for (QPushButton *button : dialog.findChildren()) { @@ -161,9 +148,32 @@ void TestGui::contentBlockingDialog() { } QVERIFY(addButton); QTest::mouseClick(addButton, Qt::LeftButton); - + // Editor should stay disabled (no RPC) QVERIFY(!editor->isEnabled()); + + // A selected custom ruleset must survive opening and accepting the dialog. + QJsonObject customSettings = settings; + customSettings.insert("customRulesetIds", QJsonArray{"ruleset-one"}); + const ContentBlockingDialog::RpcInvoker rpc = []( + const QString &method, const QJsonObject &, + std::function success, + std::function) { + if (method == "contentBlocking.status") { + success({{"subscriptions", QJsonArray{}}}); + } else if (method == "contentRuleset.list") { + success({{"contentRulesets", QJsonArray{QJsonObject{{"id", "ruleset-one"}, + {"name", "Portfolio cleanup"}, {"kind", "custom"}, + {"rulesText", "example.org##.banner"}, {"actions", QJsonArray{}}}}}}); + } + }; + ContentBlockingDialog customDialog(customSettings, rpc, nullptr); + auto *rulesets = customDialog.findChild("contentRulesetList"); + QVERIFY(rulesets); + QCOMPARE(rulesets->count(), 1); + QCOMPARE(rulesets->item(0)->checkState(), Qt::Checked); + QCOMPARE(customDialog.settings().value("customRulesetIds").toArray(), + QJsonArray{"ruleset-one"}); } QTEST_MAIN(TestGui) diff --git a/package-lock.json b/package-lock.json index 9113dcc..6f8e01a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cybersnapper", - "version": "2.2.2", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cybersnapper", - "version": "2.2.2", + "version": "2.3.1", "license": "ISC", "dependencies": { "@ghostery/adblocker": "^2.18.2", diff --git a/package.json b/package.json index 8d1199e..2f23dc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cybersnapper", - "version": "2.3.0", + "version": "2.3.1", "description": "Native cross-platform website screenshot tool and portfolio mockup generator, powered by Qt and Playwright", "scripts": { "build:worker": "node worker/build.mjs", diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 970d9e5..7a2d7aa 100755 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -21,7 +21,7 @@ case "$release_arch" in *) echo "unsupported Linux release architecture: $release_arch" >&2; exit 2 ;; esac -for executable in cmake file desktop-file-validate; do +for executable in cmake file desktop-file-validate timeout; do command -v "$executable" >/dev/null 2>&1 || { echo "$executable is required" >&2 exit 1 @@ -32,10 +32,10 @@ for executable in "$LINUXDEPLOY" "$APPIMAGETOOL"; do done [[ -f "$APPIMAGE_RUNTIME" ]] || { echo "AppImage runtime is missing: $APPIMAGE_RUNTIME" >&2; exit 1; } -qmake=${QMAKE:-qmake6} -qmake=$(command -v "$qmake" || true) -[[ -n "$qmake" && -x "$qmake" ]] || { echo "Qt 6 qmake is required" >&2; exit 1; } -"$qmake" -query QT_VERSION | grep -Eq '^6\.' || { echo "qmake must select Qt 6" >&2; exit 1; } +real_qmake=${QMAKE:-qmake6} +real_qmake=$(command -v "$real_qmake" || true) +[[ -n "$real_qmake" && -x "$real_qmake" ]] || { echo "Qt 6 qmake is required" >&2; exit 1; } +"$real_qmake" -query QT_VERSION | grep -Eq '^6\.' || { echo "qmake must select Qt 6" >&2; exit 1; } mkdir -p "$build_dir" "$output_dir" build_dir=$(cd "$build_dir" && pwd) @@ -50,26 +50,39 @@ icon_file="$app_dir/usr/share/pixmaps/net.cyberbrand.CyberSnapper.png" desktop-file-validate "$desktop_file" [[ -s "$icon_file" ]] || { echo "Linux application icon is missing" >&2; exit 1; } -qt_plugins=$("$qmake" -query QT_INSTALL_PLUGINS) +qt_plugins=$("$real_qmake" -query QT_INSTALL_PLUGINS) +release_plugins="$build_dir/release-qt-plugins" +cmake -E remove_directory "$release_plugins" +cmake -E copy_directory "$qt_plugins" "$release_plugins" # CyberSnapper only uses the SQLite driver. The MySQL, Mimer, ODBC, and # PostgreSQL drivers pull in server libraries the package does not ship and -# that linuxdeploy cannot always resolve (libmimerapi.so is absent on the -# x64 runners), which aborts plugin deployment. -rm -f "$qt_plugins"/sqldrivers/libqsqlmysql.so \ - "$qt_plugins"/sqldrivers/libqsqlmimer.so \ - "$qt_plugins"/sqldrivers/libqsqlodbc.so \ - "$qt_plugins"/sqldrivers/libqsqlpsql.so +# that linuxdeploy cannot always resolve. Curate a private copy rather than +# modifying the installed Qt SDK (which may be read-only or shared). +rm -f "$release_plugins"/sqldrivers/libqsqlmysql.so \ + "$release_plugins"/sqldrivers/libqsqlmimer.so \ + "$release_plugins"/sqldrivers/libqsqlodbc.so \ + "$release_plugins"/sqldrivers/libqsqlpsql.so + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +qmake="$script_dir/release-qmake-wrapper.sh" +export CYBERSNAPPER_REAL_QMAKE="$real_qmake" +export CYBERSNAPPER_RELEASE_PLUGINS="$release_plugins" wayland_plugins=() -for plugin in libqwayland-egl.so libqwayland-generic.so; do - [[ -f "$qt_plugins/platforms/$plugin" ]] && wayland_plugins+=("$plugin") +for plugin in libqwayland.so libqwayland-egl.so libqwayland-generic.so; do + [[ -f "$release_plugins/platforms/$plugin" ]] && wayland_plugins+=("$plugin") done [[ ${#wayland_plugins[@]} -gt 0 ]] || { echo "Qt Wayland platform plugins are required" >&2 exit 1 } -extra_platform_plugins=$(IFS=';'; echo "${wayland_plugins[*]}") +[[ -f "$release_plugins/platforms/libqoffscreen.so" ]] || { + echo "Qt offscreen platform plugin is required for packaged GUI validation" >&2 + exit 1 +} +platform_plugins=(libqoffscreen.so "${wayland_plugins[@]}") +extra_platform_plugins=$(IFS=';'; echo "${platform_plugins[*]}") export APPIMAGE_EXTRACT_AND_RUN=1 export EXTRA_PLATFORM_PLUGINS="$extra_platform_plugins" @@ -87,7 +100,10 @@ export QMAKE="$qmake" # Qt 6 uses qt.conf and AppRun's normal library/plugin discovery. The hook in # the pinned plugin predates its Qt 6 fix and can override the platform theme. +# Remove both the hook and the generated AppRun source line; leaving only one +# of those changes makes every AppImage fail before CyberSnapper starts. rm -f "$app_dir/apprun-hooks/linuxdeploy-plugin-qt-hook.sh" +sed -i '\|linuxdeploy-plugin-qt-hook\.sh|d' "$app_dir/AppRun" required_files=( "$app_dir/usr/bin/CyberSnapper" @@ -140,6 +156,29 @@ chmod +x "$appimage" env -u APPIMAGE_EXTRACT_AND_RUN "$appimage" --appimage-offset | grep -Eq '^[0-9]+$' file "$appimage" | grep -Eq "$architecture_pattern" +# Exercise AppRun and the bundled Qt platform plugin, not just the AppImage +# header. A valid offset can still hide a launcher that fails before main(). +appimage_smoke="$build_dir/appimage-smoke" +cmake -E remove_directory "$appimage_smoke" +mkdir -p "$appimage_smoke/runtime" "$appimage_smoke/config" \ + "$appimage_smoke/data" "$appimage_smoke/cache" +chmod 700 "$appimage_smoke/runtime" +env -u QT_PLUGIN_PATH -u QML2_IMPORT_PATH \ + APPIMAGE_EXTRACT_AND_RUN=1 \ + QT_QPA_PLATFORM=offscreen \ + XDG_RUNTIME_DIR="$appimage_smoke/runtime" \ + XDG_CONFIG_HOME="$appimage_smoke/config" \ + XDG_DATA_HOME="$appimage_smoke/data" \ + XDG_CACHE_HOME="$appimage_smoke/cache" \ + CYBERSNAPPER_AGENT_SERVER="$appimage_smoke/agent.sock" \ + CYBERSNAPPER_DEFAULT_PROJECT="$appimage_smoke/project" \ + CYBERSNAPPER_UI_SCREENSHOT="$appimage_smoke/gui.png" \ + timeout 60 "$appimage" +[[ -s "$appimage_smoke/gui.png" ]] || { + echo "AppImage GUI smoke test did not create a screenshot" >&2 + exit 1 +} + archive="$output_dir/CyberSnapper-linux-$release_arch.tar.gz" tar -C "$app_dir/usr" -czf "$archive" . diff --git a/scripts/smoke-packaged-capture.mjs b/scripts/smoke-packaged-capture.mjs index e69fd98..cbed646 100755 --- a/scripts/smoke-packaged-capture.mjs +++ b/scripts/smoke-packaged-capture.mjs @@ -3,7 +3,6 @@ import { execFile } from 'node:child_process'; import { mkdir, open, readdir, writeFile } from 'node:fs/promises'; import http from 'node:http'; -import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { promisify } from 'node:util'; @@ -19,10 +18,9 @@ const [cli, agent, worker, nodeRuntime, browserCache, stateRoot] = process.argv. const runRoot = path.join(stateRoot, `run-${process.pid}-${Date.now()}`); const projectRoot = path.join(runRoot, 'project'); const projectState = path.join(projectRoot, '.cybersnapper'); -// The agent hosts its IPC socket inside XDG_RUNTIME_DIR, and Unix socket paths -// are limited to about 108 bytes. Keep this directory short instead of nesting -// it under the (potentially deep) state root. -const runtimeRoot = path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', `cs-smoke-${process.pid}`); +// Keep the IPC directory inside the requested work tree while avoiding the +// deeper smoke-state/project nesting that can exceed Unix socket path limits. +const runtimeRoot = path.join(path.resolve(stateRoot, '..', '..'), `cs-smoke-${process.pid}`); await mkdir(projectState, { recursive: true }); await mkdir(runtimeRoot, { recursive: true, mode: 0o700 }); @@ -66,6 +64,10 @@ if (!address || typeof address === 'string') throw new Error('Could not start th const childEnvironment = { ...process.env, CYBERSNAPPER_AGENT: agent, + CYBERSNAPPER_AGENT_SERVER: process.platform === 'win32' + ? `CyberSnapper.PackageSmoke.${process.pid}` + : path.join(runtimeRoot, 'agent.sock'), + CYBERSNAPPER_DEFAULT_PROJECT: projectRoot, CYBERSNAPPER_WORKER_ENTRY: worker, CYBERSNAPPER_NODE: nodeRuntime, CYBERSNAPPER_BROWSER_CACHE: browserCache, diff --git a/site/index.html b/site/index.html index 1bb0c93..565f85c 100644 --- a/site/index.html +++ b/site/index.html @@ -31,7 +31,7 @@ "name": "CyberSnapper", "applicationCategory": "DesignApplication", "operatingSystem": "macOS, Windows, Linux", - "softwareVersion": "2.2.2", + "softwareVersion": "2.3.1", "description": "Open-source website screenshot tool for responsive portfolio images and browser, tablet, and phone mockups.", "url": "https://alex9001.github.io/CyberSnapper/", "downloadUrl": "https://github.com/Alex9001/CyberSnapper/releases/latest", @@ -139,7 +139,10 @@

Present your work at its best,
without capturing by hand.

Frame exactly what matters

Capture the full scrolling page, the visible viewport, or one CSS-selected element. Show the complete build or isolate its strongest detail.

-

Clean shots, every time

Wait for the page to settle, block common overlays, hide selectors, and control scrolling delays so cookie banners and animation do not ruin the image.

+

Remove cookie banners

Automatically hide and dismiss cookie consent banners using curated filter lists (EasyList Cookie, uBlock Origin Cookie) and custom rulesets. Choose between "reject non-essential cookies then dismiss" or "dismiss using the site's own accept control" strategies.

+
+
+
🛡️

Content blocking

Block subresources, skip content blocking on specific domains, and create reusable custom rulesets with cosmetic filters and trusted click actions. Every artifact shows a provenance tooltip with blocked counts, consent actions, and warnings.

Your whole portfolio in one batch

Save labeled sets of projects and pages, enable only the ones you need, and recapture the collection without rebuilding a URL list every time.

diff --git a/worker/src/blocking.ts b/worker/src/blocking.ts index 5d5e22f..6c4a2a0 100644 --- a/worker/src/blocking.ts +++ b/worker/src/blocking.ts @@ -11,7 +11,7 @@ import type { ContentBlocking, ContentBlockingMetrics, RulesetSourceVersion, Str // depending on JSON key ordering across Qt and JavaScript. export interface RulesetSnapshotPayload { - generatedAt: string; + generatedAt?: string; subscriptions: Record>; rulesText: string[]; actions: StructuredAction[]; @@ -262,6 +262,7 @@ export function hostMatches(host: string, domain: string): boolean { const normalizedHost = host.toLowerCase().replace(/\.$/, ''); const normalizedDomain = domain.toLowerCase().replace(/\.$/, '').replace(/^\*\./, ''); if (!normalizedDomain) return false; + if (normalizedDomain === '*') return true; return normalizedHost === normalizedDomain || normalizedHost.endsWith(`.${normalizedDomain}`); } @@ -321,9 +322,9 @@ function normalizeSettings(settings?: Partial): ContentBlocking // Everything this function needs is defined inside its body: Playwright // serializes only the function source into the page, so module-scope // constants would not exist there. -function consentPassInPage(strategy: string): { +async function consentPassInPage(strategy: string): Promise<{ attempted: number; succeeded: number; removed: number; locked: boolean; -} { +}> { const consentContainerSelector = [ '[id*="cookie" i]', '[class*="cookie" i]', '[name*="cookie" i]', '[aria-label*="cookie" i]', '[id*="consent" i]', '[class*="consent" i]', '[aria-label*="consent" i]', @@ -390,6 +391,10 @@ function consentPassInPage(strategy: string): { ? clickFirst(container, rejectPatterns) || clickFirst(container, dismissPatterns) : clickFirst(container, dismissPatterns); if (!clicked) continue; + // Many consent managers remove the banner in a microtask or animation + // callback. Give the recognized container a brief chance to disappear + // before deciding whether it is safe to release the page's scroll lock. + await new Promise((resolve) => setTimeout(resolve, 100)); const stillThere = container.isConnected && visible(container); if (!stillThere) report.removed += 1; } @@ -507,15 +512,19 @@ export class ContentBlocker { return blocker; } blocker.metrics.rulesetDigest = blocker.snapshot.digest; - blocker.metrics.unsupportedRules = normalizeRulesText(blocker.snapshot.payload.rulesText.join('\n')).unsupported; + const normalized = normalizeRulesText(blocker.snapshot.payload.rulesText.join('\n')); + blocker.metrics.unsupportedRules = normalized.unsupported; try { - blocker.engine = FiltersEngine.parse(blocker.snapshot.payload.rulesText.join('\n')); + blocker.engine = normalized.lines.length > 0 + ? FiltersEngine.parse(normalized.lines.join('\n')) + : null; } catch (error) { blocker.engine = null; blocker.metrics.warnings.push(`Rules snapshot could not be parsed: ${ error instanceof Error ? error.message : String(error)}`); } - blocker.actions = blocker.snapshot.payload.actions; + blocker.actions = [...blocker.snapshot.payload.actions, ...normalized.actions] + .slice(0, maximumCustomActions); return blocker; } @@ -607,11 +616,15 @@ export class ContentBlocker { this.metrics.consentActionsAttempted += 1; this.metrics.consentActionsSucceeded += 1; } - return; + } else { + for (const frame of page.frames()) { + try { + const report = await frame.evaluate(consentPassInPage, effectiveStrategy); + this.metrics.consentActionsAttempted += report.attempted; + this.metrics.consentActionsSucceeded += report.succeeded; + } catch { /* A frame may navigate or disappear during the pass. */ } + } } - const report = await page.evaluate(consentPassInPage, effectiveStrategy); - this.metrics.consentActionsAttempted += report.attempted; - this.metrics.consentActionsSucceeded += report.succeeded; } catch { /* Consent handling is best effort and never fails a capture. */ } await this.runCustomActions(page); } diff --git a/worker/test/blocking.fixtures.test.cjs b/worker/test/blocking.fixtures.test.cjs index 1bc9ff4..e061b92 100644 --- a/worker/test/blocking.fixtures.test.cjs +++ b/worker/test/blocking.fixtures.test.cjs @@ -7,7 +7,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const http = require('node:http'); @@ -34,6 +33,12 @@ async function availableEngines() { engines.push(name); } catch { /* engine not installed or missing system dependencies */ } } + const required = (process.env.CYBERSNAPPER_REQUIRED_BROWSERS ?? '') + .split(',').map((name) => name.trim()).filter(Boolean); + const missing = required.filter((name) => !engines.includes(name)); + if (missing.length > 0) { + throw new Error(`Required Playwright browser(s) unavailable: ${missing.join(', ')}`); + } return engines; } @@ -68,16 +73,18 @@ function writeSnapshot(root, rulesText, { actions = [] } = {}) { async function withFixture(pageHtml, rulesText, run, settingsExtra = {}) { const engines = await getEngines(); if (engines.length === 0) return; - + const server = http.createServer((request, response) => { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end(pageHtml); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const url = `http://127.0.0.1:${server.address().port}/`; - + + const testRoot = path.join(process.cwd(), 'build', 'test-runtime'); + fs.mkdirSync(testRoot, { recursive: true }); for (const engineName of engines) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-fixture-')); + const root = fs.mkdtempSync(path.join(testRoot, 'cs-fixture-')); const ruleset = writeSnapshot(root, rulesText, settingsExtra); const blocker = await ContentBlocker.load({ projectRoot: root, diff --git a/worker/test/blocking.test.cjs b/worker/test/blocking.test.cjs index c041222..b23ec9e 100644 --- a/worker/test/blocking.test.cjs +++ b/worker/test/blocking.test.cjs @@ -3,7 +3,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const { @@ -128,6 +127,24 @@ test('ContentBlocker blocks matching subresources but not unrelated requests', a assert.equal(blocker.metrics.blockedSubresources, 0, 'counting happens in routing, not matching'); }); +test('ContentBlocker never loads rules rejected by the safe normalizer', async () => { + const reference = snapshotReference(makeSnapshot(), [ + '||unsafe.example^$redirect=noop.txt', + 'example.org##.banner:style(position:fixed)', + 'example.org##+js(set-constant, document.title, changed)', + ]); + const blocker = await ContentBlocker.load({ + projectRoot: reference.projectRoot, + profile: { contentBlocking: enabledSettings([]) }, + ruleset: reference, + }); + assert.equal(blocker.metrics.unsupportedRules, 3); + assert.equal(blocker.blocksSubresource('https://unsafe.example/file.js', 'script'), false); + assert.equal(blocker.cosmeticStyles('https://example.org/', { + classes: ['banner'], ids: [], hrefs: [], + }), '.banner { display: none !important; }'); +}); + test('disabledDomains disable all content blocking for that site', async () => { const settings = enabledSettings(['easylist-cookie']); settings.disabledDomains = ['exempt.example']; @@ -153,8 +170,10 @@ test('inert blockers do nothing (blockPopups=false stays off)', async () => { }); test('missing or broken snapshots warn and fall back to built-in consent handling', async () => { + const testRoot = path.join(process.cwd(), 'build', 'test-runtime'); + fs.mkdirSync(testRoot, { recursive: true }); const missing = await ContentBlocker.load({ - projectRoot: os.tmpdir(), + projectRoot: testRoot, profile: { contentBlocking: enabledSettings([]) }, ruleset: {}, }); @@ -190,7 +209,9 @@ function snapshotReference(snapshotText, extraRules) { rulesText: [...JSON.parse(document.payload).rulesText, ...(extraRules ?? [])], }); const finalText = JSON.stringify({ format: 1, digest: sha256Hex(payload), payload }); - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cybersnapper-rules-')); + const testRoot = path.join(process.cwd(), 'build', 'test-runtime'); + fs.mkdirSync(testRoot, { recursive: true }); + const root = fs.mkdtempSync(path.join(testRoot, 'cybersnapper-rules-')); fs.mkdirSync(path.join(root, 'rulesets'), { recursive: true }); fs.writeFileSync(path.join(root, 'rulesets', `${sha256Hex(payload)}.json`), finalText); return { projectRoot: root, digest: sha256Hex(payload), relativePath: `rulesets/${sha256Hex(payload)}.json` }; @@ -199,7 +220,9 @@ function snapshotReference(snapshotText, extraRules) { const domWithCookieClass = { classes: ['cookie-banner'], ids: [], hrefs: [] }; function writeProjectWithSnapshot(text) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cybersnapper-bad-')); + const testRoot = path.join(process.cwd(), 'build', 'test-runtime'); + fs.mkdirSync(testRoot, { recursive: true }); + const root = fs.mkdtempSync(path.join(testRoot, 'cybersnapper-bad-')); fs.mkdirSync(path.join(root, 'rulesets'), { recursive: true }); fs.writeFileSync(path.join(root, 'rulesets', `${sha256Hex('mismatch')}.json`), text); return root;