Compare commits

...
21 Commits
Author SHA1 Message Date
Anthony Stirling 0af1b2897e Set TAURI_WINDOWS_SIGNTOOL_PATH for Windows signing 2026-08-06 22:48:30 +01:00
Anthony Stirling db8aaaf9f3 Fix arm64 signing cert path and add prerelease flag input 2026-08-06 21:00:12 +01:00
Anthony Stirling e556eba832 Return 400 instead of 500 when overlay files are missing
(cherry picked from commit bc95f76add)
2026-08-06 19:00:32 +01:00
Anthony Stirling 4ea39559c0 Fix tools that could not run in Automate
(cherry picked from commit e7b48dbea3)
2026-08-06 19:00:30 +01:00
Anthony Stirling 00d6a6e071 Version bump 2026-08-05 21:47:46 +01:00
brios cc22deda3f fix(search): Fix PDF viewer search showing 0 of 0 results (#7228)
# Description of Changes

Fixes that search always showing 0 of 0 result even when there are
multiple hits in the search.

### New

<img width="706" height="284" alt="image"
src="https://github.com/user-attachments/assets/f3507647-2a41-4e60-8c9c-eca43005a5c4"
/>

### Old

<img width="708" height="292" alt="image"
src="https://github.com/user-attachments/assets/7b0a6a66-f7c5-4df8-af89-134afef72eba"
/>

On the same PDF.

Changes:
* Refactored the effect in `SearchInterface` to fetch the latest search
state from `viewerContext` on each interval, preventing stale closure
values and ensuring up-to-date search results and active index are used.
* Updated the "previous" and "next" result buttons to be disabled
whenever there are zero search results, rather than relying on the
current index, making the UI more robust and intuitive.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit cb45fb159a)
2026-08-05 21:47:45 +01:00
Reece Browne 1ed7338bb9 Fix preview on crop/rotate (#7266)
Preview on crop and rotate shows just the original file you uploaded, if
you switch to a new one it doesn't update.

(cherry picked from commit a8b2957b74)
2026-08-05 21:47:43 +01:00
Anthony Stirling 5dc4ba0204 Desktop installer fixes (#7174)
* Adds a `windows-11-arm` CI/release leg (NSIS, Microsoft JDK 25,
updater keys); JPDFium natives deliberately excluded
(`jpdfiumPlatforms=none`) until published, so don't ship ARM64
installers to users yet
* Defaults `WEBKIT_DISABLE_DMABUF_RENDERER=1` on Linux (crash switching
tools on NVIDIA)
* Strips the bundled libwayland from AppImages (blank window on Fedora
Wayland)
* Blocks off-app webview navigation + window drop guard + close failsafe
(drag-drop bricks the app)
* 120s startup grace before the backend is declared unhealthy, restart
success only announced after a real health check ("Backend stopped
unexpectedly" spam and likely the OAuth port churn)
* Verified: green `windows-arm64` build (234 MB NSIS artifact) and green
Linux run with libwayland confirmed stripped
* JPDFium fixes for multi threading issues
---

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit 921bdac4b7)
2026-08-05 21:47:42 +01:00
Anthony Stirling ad95a046ed Remove depot.dev support from GitHub Actions workflows (#7148)
(cherry picked from commit 831bd4fe94)
2026-08-05 19:42:13 +01:00
dependabot[bot] 2d0d4443f8 build(deps): bump pdfboxVersion from 3.0.7 to 3.0.8 (#7237)
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps `pdfboxVersion` from 3.0.7 to 3.0.8.
Updates `org.apache.pdfbox:preflight` from 3.0.7 to 3.0.8

Updates `org.apache.pdfbox:xmpbox` from 3.0.7 to 3.0.8

Updates `org.apache.pdfbox:pdfbox` from 3.0.7 to 3.0.8

Updates `org.apache.pdfbox:pdfbox-io` from 3.0.7 to 3.0.8

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
(cherry picked from commit 02a7db0f1e)
2026-08-05 19:39:57 +01:00
Anthony Stirling 656a0ae268 Avoid renderer OOM when adding large PDFs to the workbench
(cherry picked from commit 271212d61f)
2026-08-05 19:39:56 +01:00
ConnorYoh f1ee0bdbab Fix Calendly scheduler: blank on first open + slow load (#7075)
Opening the procurement **Schedule a call** modal had two problems:

1. **Blank the first time, works the second time.** The first open
showed nothing; closing and reopening eventually loaded Calendly.
2. **Slow to load** even when it did work.

1. Our script loader treated a script as "ready" the moment its
`<script>` tag was added to the page — not when it had actually finished
downloading. On the first open, two loads overlap (React re-runs the
effect in dev), and the second one returned "ready" too early, before
Calendly's code existed, so nothing rendered. Reopening worked because
by then the script had finished.
2. Nothing was loaded until you clicked, so the first open waited on a
cold download of Calendly's script and then its booking page.

- Make the script loader wait for the script to **actually finish
loading**, and have overlapping loads share the same wait. This fixes
the blank-first-open (and helps every other lazy-loaded script too).
- **Warm up Calendly early**: open the connection and start fetching its
script as soon as the "Schedule a call" button appears, so the modal
opens quickly instead of downloading everything on click.
- If Calendly still can't load (e.g. blocked by an extension), show the
existing "open in a new tab" link instead of an empty modal.

Added a unit test proving the loader only reports "ready" after the
script truly loads. Type-check, lint, and formatting all pass.

Note: I couldn't click through the live modal here (needs a linked
procurement deal running locally) — happy to do a manual open/close/open
pass before merge if you'd like.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
(cherry picked from commit 50c0f2bcb5)
2026-08-05 19:39:37 +01:00
brios abc9a08566 fix(viewer): dynamic page number input width based on total page count (#6607)
# Description of Changes

### Before:

<img width="3092" height="468" alt="image"
src="https://github.com/user-attachments/assets/8ed16ac3-9547-4f07-937e-4177c4ed16fd"
/>

### After:

<img width="1868" height="490" alt="image"
src="https://github.com/user-attachments/assets/8ddf2227-bac4-49f6-973a-90c9f4667dfe"
/>

### Mobile (after):

<img width="842" height="444" alt="image"
src="https://github.com/user-attachments/assets/e601fa94-45a6-46ae-b432-550bb27a98a6"
/>

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit a4ffdc7831)
2026-08-05 19:38:16 +01:00
Ludy ab51bcf0f0 fix(sign): preserve PNG signature placement and page content (#7093)
# Description of Changes

This PR fixes PNG signature application issues in the PDF signing
workflow.

## What was changed

- Reworked signature application to create locked and printable PDFium
stamp annotations with dedicated appearance streams.
- Removed the use of `FPDFPage_GenerateContent()` from the signature
workflow.
- Preserved the signature's original position and dimensions when
converting from the viewer's top-left coordinate system to PDF
coordinates.
- Added CropBox-aware coordinate conversion for PDFs whose visible page
origin differs from the MediaBox origin.
- Improved signature image extraction to handle internal EmbedPDF asset
references and nested image data.
- Refactored PDFium bitmap creation so image objects can safely be
transferred to annotations.
- Corrected PDFium bitmap ownership and cleanup to prevent duplicate
destruction.
- Added a PDFium WASM integration test covering:
  - Existing page-content preservation
  - Stamp appearance generation
  - Signature coordinates and dimensions
  - Printable, read-only, and locked annotation flags
- Persisted image data taking precedence over internal asset references

## Why the change was made

Applying a PNG signature previously regenerated the complete page
content through PDFium. This could corrupt existing vector or font-based
page elements, including the university logo reported in the linked
issue.

The previous coordinate conversion also relied only on the page height
and did not account for CropBox offsets, allowing the applied signature
to move from its preview position.

Creating a PDFium stamp annotation with its own appearance stream avoids
regenerating existing page content while retaining the selected
signature position and size.

Closes #7083

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit f4d760b139)
2026-08-05 19:38:15 +01:00
Ludy 0ba8b9fcc1 fix(licenses): fall back to license URL for backend dependency links (#7046)
This change improves link handling in the third-party licenses section.

- Added a shared `getModuleUrl` helper that returns `moduleUrl` when
available and falls back to `moduleLicenseUrl`.
- Updated backend license entries to render as clickable links when only
a license URL is provided.
- Preserved the existing link behavior, including opening URLs in a new
tab with appropriate security attributes.
- Fixed inconsistent rendering where some dependency names appeared as
plain text despite having an available license URL.

The change was made to ensure backend dependency entries consistently
provide a usable external link, even when dependency metadata does not
include a dedicated module URL.

before:
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/aafee561-43e0-4924-923f-eb4ecf00c873"
/>

after:
<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/10a6f76a-6852-49af-a987-e0a864ec0b91"
/>

---

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit 47fe4a1d06)
2026-08-05 19:38:14 +01:00
Ludy e43af565ab fix(editor): respect no-login settings visibility in config navigation (#6807)
# Description of Changes

This change fixes the editor configuration navigation for issue #6800 by
passing the `showSettingsWhenNoLogin` configuration flag through all
config navigation section hooks.

- Added `config?.showSettingsWhenNoLogin ?? true` when building the app
config modal navigation.
- Extended shared, desktop, and proprietary `useConfigNavSections`
signatures to accept the `showSettingsWhenNoLogin` flag.
- Forwarded the flag from desktop and proprietary config navigation
wrappers into the shared navigation logic.
- Updated proprietary admin section visibility so read-only admin
previews are only shown when login is disabled and
`system.showSettingsWhenNoLogin` allows it.

The change was made to ensure deployments with login disabled can still
control whether settings/admin configuration entries are visible,
matching the existing `showSettingsWhenNoLogin` behavior.

Closes #6800

<img width="1920" height="1032" alt="image"
src="https://github.com/user-attachments/assets/193f083b-3c87-4466-8e91-81a13a27cec3"
/>

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [x] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit 4230d2902b)
2026-08-05 19:37:01 +01:00
Ludy 58326adee4 fix(admin-settings): prevent hydration error in the Admin General section (#6823)
# Description of Changes

- What was changed
- Fixed an invalid HTML nesting issue in `AdminGeneralSection` by
changing the affected Mantine `Text` wrapper from the default `<p>`
element to `component="div"`.
- This prevents a `<div>` from being rendered inside a `<p>` when the
`Group` for the "Logo Style" label is displayed.
- Why the change was made
- Firefox reported a hydration error in the settings modal because the
rendered DOM was invalid.
- The warning was triggered in the Admin General settings section and
affected the settings modal experience.

Firefox 152.0.3 (64-Bit)

```
In HTML, <div> cannot be a descendant of <p>.
This will cause a hydration error.

  ...
    <AdminGeneralSection>
      <div className="settings-s...">
        <@mantine/core/Stack gap="lg" className="settings-s...">
          <@mantine/core/Box ref={null} className="settings-s..." style={{...}} variant={undefined}>
            <div ref={null} style={{...}} className="settings-s..." data-variant={undefined} data-size={undefined} ...>
              <LoginRequiredBanner>
              <div>
              <@mantine/core/Paper withBorder={true} p="md" radius="md">
                <@mantine/core/Box ref={null} mod={[...]} className="m_1b7284a3..." style={{...}} variant={undefined} ...>
                  <div ref={null} style={{...}} className="m_1b7284a3..." data-variant={undefined} data-size={undefined} ...>
                    <@mantine/core/Stack gap="md">
                      <@mantine/core/Box ref={null} className="m_6d731127..." style={{...}} variant={undefined}>
                        <div ref={null} style={{...}} className="m_6d731127..." data-variant={undefined} ...>
                          <@mantine/core/Text>
                          <div>
                          <div>
                            <@mantine/core/Text size="sm" fw={500} mb={4}>
                              <@mantine/core/Box className="mantine-fo..." style={{...}} ref={null} component="p" ...>
>                               <p
>                                 ref={null}
>                                 style={{--text-fz:"var(--mant...",--text-lh:"var(--mant...",marginBottom:"calc(0.25r...", ...}}
>                                 className="mantine-focus-auto m_b6d8b162 mantine-Text-root"
>                                 data-variant={undefined}
>                                 data-size="sm"
>                                 size={undefined}
>                               >
                                  <@mantine/core/Group gap="xs">
                                    <@mantine/core/Box className="m_4081bf90..." style={{...}} ref={null} ...>
>                                     <div
>                                       ref={null}
>                                       style={{--group-gap:"var(--mant...",--group-align:"center",--group-justify:"flex-start", ...}}
>                                       className="m_4081bf90 mantine-Group-root"
>                                       data-variant={undefined}
>                                       data-size={undefined}
>                                       size={undefined}
>                                     >
                            ...
                          ...
              ...
        ...
react-dom-client.development.js:2605:19
```

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit 72e8ba2951)
2026-08-05 19:37:01 +01:00
Ludy fd9f52d756 fix(admin-settings): correctly mask Telegram bot tokens in settings output (#6822)
# Description of Changes

- What was changed
- Fixed the sensitive-field detection logic in `AdminSettingsController`
so `botToken` is matched correctly after lowercasing the field name.
- This ensures Telegram bot tokens are masked consistently in admin
settings responses.
- Why the change was made
- The previous check used `lowerField.contains("botToken")`, which could
never match after converting the field name to lowercase.
- As a result, `botToken` values could remain visible in masked settings
output.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

(cherry picked from commit c6e84a2124)
2026-08-05 19:37:00 +01:00
Ludy 91f4376371 deps(frontend): upgrade @embedpdf packages to v2.14.4 (#6989)
# Description of Changes

- Updated all `@embedpdf/*` packages from earlier releases to `v2.14.4`.
- Upgraded `@embedpdf/engines` from `2.8.0` to `2.14.4`.
- Upgraded `@embedpdf/plugin-selection` from `2.8.0` to `2.14.4`.
- Aligned every EmbedPDF package to the same version to avoid version
mismatches.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
(cherry picked from commit 99887f77a6)
2026-08-05 19:36:59 +01:00
Ludy 29df488b4e fix(temp-files): prevent cleanup of active registered directories (#7006)
(cherry picked from commit d4edff9059)
2026-08-05 19:36:58 +01:00
James Brunton e3ff34efd1 Fix policy running of Redact (#6972)
Policies can currently throw when calling redact:

<img width="1186" height="824" alt="image"
src="https://github.com/user-attachments/assets/bdcc09fe-5bf4-4b0a-b119-bcc33c98c7f2"
/>

Policies really need to be updated to properly make use of the new
bidirectional mappings for this, but this will hopefully fix it for now.

(cherry picked from commit 863cad22bd)
2026-08-05 19:36:56 +01:00
72 changed files with 2283 additions and 1201 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.2
pkgver=2.14.3
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.2
pkgver=2.14.3
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+111 -37
View File
@@ -23,13 +23,9 @@ permissions:
pull-requests: write
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
check-pr:
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.decide.outputs.should_deploy }}
is_fork: ${{ steps.resolve.outputs.is_fork }}
@@ -101,8 +97,8 @@ jobs:
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
deploy-v2-pr:
needs: [pick, check-pr]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: check-pr
runs-on: ubuntu-latest
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
# Concurrency control - only one deployment per PR at a time
concurrency:
@@ -112,10 +108,10 @@ jobs:
contents: read
issues: write
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Single source of truth for whether this preview embeds the admin portal:
# drives the image build-arg and the deployment comment.
BUILD_PORTAL: "true"
steps:
- name: Harden Runner
@@ -187,12 +183,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Get version number
@@ -237,21 +228,9 @@ jobs:
echo "Image needs to be built"
fi
- name: Build and push V2 image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-image.outputs.exists == 'false'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push V2 image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push V2 image
if: steps.check-image.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -259,7 +238,9 @@ jobs:
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
build-args: VERSION_TAG=v2-alpha
build-args: |
VERSION_TAG=v2-alpha
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
platforms: linux/amd64
- name: Set up SSH
@@ -290,6 +271,7 @@ jobs:
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -333,9 +315,70 @@ jobs:
# Set port for output
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
# ---- Storybook preview (only when this PR touches stories/.storybook) ----
# Runs inside the same approved-contributor-gated deploy job, so it deploys
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
list-files: json
filters: |
storybook:
- 'frontend/**/*.stories.@(ts|tsx|mdx)'
- 'frontend/**/*.mdx'
- 'frontend/.storybook/**'
- name: Set up Node.js for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install Task for Storybook
if: steps.sb-changes.outputs.storybook == 'true'
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build and deploy Storybook
id: storybook
if: steps.sb-changes.outputs.storybook == 'true'
env:
VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
VPS_USER: ${{ secrets.NEW_VPS_USERNAME }}
run: |
set -euo pipefail
# `prepare` generates the icon set stories import (not committed).
task frontend:prepare
task frontend:storybook:build
PR=${{ needs.check-pr.outputs.pr_number }}
# Served at the ROOT of its own port so Storybook's global MSW worker
# (/mockServiceWorker.js) resolves. Port = PR + 20000 (bijective, offset
# from the app preview's bare-PR-number port).
SB_PORT=$((PR + 20000))
DIR=/stirling/SB-PR-$PR
tar czf storybook.tgz -C frontend/storybook-static .
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
storybook.tgz "$VPS_USER@$VPS_HOST:/tmp/storybook-$PR.tgz"
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T \
"$VPS_USER@$VPS_HOST" << ENDSSH
set -e
rm -rf "$DIR" && mkdir -p "$DIR"
tar xzf /tmp/storybook-$PR.tgz -C "$DIR"
rm -f /tmp/storybook-$PR.tgz
docker rm -f storybook-pr-$PR 2>/dev/null || true
docker run -d --name storybook-pr-$PR --restart unless-stopped \
-p $SB_PORT:80 -v "$DIR":/usr/share/nginx/html:ro nginx:alpine
ENDSSH
echo "url=http://$VPS_HOST:$SB_PORT/" >> "$GITHUB_OUTPUT"
- name: Post V2 deployment URL to PR
if: success()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SB_URL: ${{ steps.storybook.outputs.url }}
SB_FILES: ${{ steps.sb-changes.outputs.storybook_files }}
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -359,12 +402,40 @@ jobs:
}
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
// Only mention the portal when this image actually embeds it.
// Use the direct IP URL - the SSL hostname isn't supported yet.
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
const portalNote = withPortal
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
: ``;
// Storybook preview: only present when this PR changed stories/config.
const sbUrl = process.env.SB_URL;
let storybookNote = "";
if (sbUrl) {
const files = JSON.parse(process.env.SB_FILES || "[]");
const stories = files.filter((f) => /\.stories\.(ts|tsx|mdx)$/.test(f));
const config = files.filter((f) => f.startsWith("frontend/.storybook/"));
const shorten = (f) =>
f.replace(/^frontend\/editor\/src\//, "").replace(/^frontend\//, "");
const storyList = stories.map((f) => `- \`${shorten(f)}\``).join("\n");
const configList = config.map((f) => `- \`${shorten(f)}\``).join("\n");
const summary =
`${stories.length} stor${stories.length === 1 ? "y" : "ies"} changed` +
(config.length ? ` (+${config.length} config file${config.length === 1 ? "" : "s"})` : "");
storybookNote =
`📚 **Storybook:** [${sbUrl}](${sbUrl})\n\n` +
`<details>\n<summary>${summary}</summary>\n\n` +
(storyList ? `**Stories**\n${storyList}\n\n` : "") +
(configList ? `**Config**\n${configList}\n` : "") +
`</details>\n\n`;
}
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
`Your V2 PR with embedded architecture has been deployed!\n\n` +
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
portalNote +
storybookNote +
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
`🔄 **Auto-deployed** for approved V2 contributors.`;
@@ -377,8 +448,7 @@ jobs:
cleanup-v2-deployment:
if: github.event.action == 'closed'
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
@@ -460,7 +530,11 @@ jobs:
else
echo "V2 PR directory not found, nothing to clean up"
fi
# Remove this PR's Storybook preview (container + files), if any.
docker rm -f storybook-pr-${{ github.event.pull_request.number }} 2>/dev/null || true
rm -rf /stirling/SB-PR-${{ github.event.pull_request.number }}
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
@@ -34,12 +34,8 @@ permissions:
pull-requests: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
check-comment:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
permissions:
issues: write
if: |
@@ -179,15 +175,11 @@ jobs:
}
deploy-pr:
needs: [pick, check-comment]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: check-comment
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -220,9 +212,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -240,12 +232,7 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
@@ -254,23 +241,8 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push PR-specific image (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: |
VERSION_TAG=alpha
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
platforms: linux/amd64
- name: Build and push PR-specific image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push PR-specific image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -283,20 +255,9 @@ jobs:
PROTOTYPES_BUILD=${{ needs.check-comment.outputs.enable_prototypes }}
platforms: linux/amd64
- name: Build and push engine image (Depot)
if: env.USE_DEPOT == 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ./engine
file: ./engine/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:engine-pr-${{ needs.check-comment.outputs.pr_number }}
platforms: linux/amd64
- name: Build and push engine image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push engine image
if: needs.check-comment.outputs.enable_prototypes == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ./engine
file: ./engine/Dockerfile
@@ -510,8 +471,7 @@ jobs:
handle-label-commands:
if: ${{ github.event.issue.pull_request != null }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+17 -20
View File
@@ -2,8 +2,8 @@ name: _runner-pick
# Tiny reusable workflow that classifies the trigger as either a "fork PR
# from an untrusted contributor" or a "trusted commit" so downstream jobs
# can pick a runner class without each one duplicating the 200-char gate
# expression in their own `runs-on:`.
# can trust-gate (skip secret-dependent jobs on forks) without each one
# duplicating the gate expression.
#
# Caller pattern:
#
@@ -13,12 +13,12 @@ name: _runner-pick
#
# real-work:
# needs: pick
# runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
# if: needs.pick.outputs.is_fork != 'true'
# steps: [...]
#
# Output:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise.
# Outputs:
# is_fork: "true" when the trigger is a pull_request from a fork or an
# untrusted author_association, "false" otherwise.
on:
workflow_call:
@@ -50,21 +50,18 @@ jobs:
AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }}
run: |
set -eu
if [ -z "${PR_NUMBER:-}" ]; then
# Not a pull_request event at all (push, schedule, workflow_dispatch,
# workflow_call from a non-PR trigger) -> trusted by default.
echo "is_fork=false" >> "$GITHUB_OUTPUT"
exit 0
is_fork=false
elif [ "${HEAD_REPO_FORK}" = "true" ]; then
is_fork=true
else
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR) is_fork=false ;;
*) is_fork=true ;;
esac
fi
if [ "${HEAD_REPO_FORK}" = "true" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
exit 0
fi
case "${AUTHOR_ASSOC}" in
OWNER|MEMBER|COLLABORATOR)
echo "is_fork=false" >> "$GITHUB_OUTPUT"
;;
*)
echo "is_fork=true" >> "$GITHUB_OUTPUT"
;;
esac
echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT"
-2
View File
@@ -18,8 +18,6 @@ jobs:
permissions:
contents: read
pull-requests: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+4 -10
View File
@@ -19,14 +19,8 @@ permissions:
pull-requests: write
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
build:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -47,7 +41,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -56,9 +50,9 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
+26 -18
View File
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
# Enterprise Playwright suite — exercises premium-key gated features (audit,
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
# stacks under testing/compose. Slow and secret-gated, so it runs in three
# stacks under testing/compose. Slow and secret-gated, so it runs in four
# situations:
#
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
@@ -12,28 +12,14 @@ name: Enterprise E2E (Playwright)
# - on a nightly cron schedule (catches Keycloak image drift, license
# expiry, upstream proprietary changes),
# - manual workflow_dispatch.
#
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
on:
workflow_call:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
type: string
default: "8"
push:
branches: ["main"]
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
type: string
default: "8"
# No `concurrency:` block here on purpose. When this workflow is called via
# workflow_call from build.yml, ${{ github.workflow }}/event_name/pr_number
@@ -52,13 +38,16 @@ jobs:
playwright-e2e-enterprise:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
# so the suite can't boot premium and would fail. See the header comment.
# GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
PREMIUM_ENABLED: "true"
SYSTEM_ENABLEANALYTICS: "false"
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -165,6 +154,8 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -238,6 +229,8 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -268,6 +261,8 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -280,10 +275,23 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
- name: Flag flaky tests
# Runs regardless of the test outcomes: a flaky test (passed on retry)
# leaves its step green, so this is the only place it surfaces. Merges
# all three phase reports (some may be absent if an earlier phase hard-
# failed and skipped the rest). Emits ::warning:: annotations + a job
# summary; never fails the job.
if: always()
working-directory: frontend
run: >
npx tsx editor/scripts/report-flaky-tests.mts
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
-1
View File
@@ -147,7 +147,6 @@ jobs:
permissions:
contents: read
packages: read
id-token: write
uses: ./.github/workflows/test-build-docker.yml
secrets: inherit
with:
-2
View File
@@ -11,8 +11,6 @@ permissions:
jobs:
check-licence:
runs-on: ubuntu-latest
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+4 -10
View File
@@ -10,14 +10,8 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
check-generate-openapi-docs:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -34,7 +28,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -43,9 +37,9 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
+22 -15
View File
@@ -13,17 +13,24 @@ name: Aggregate backend coverage
# producers themselves
on:
workflow_call:
inputs:
frontend-validation-result:
description: Result of the frontend-validation producer job
required: false
type: string
default: skipped
playwright-e2e-live-result:
description: Result of the playwright-e2e-live producer job
required: false
type: string
default: skipped
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
aggregate:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden Runner
@@ -40,7 +47,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -49,9 +56,9 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
cache-disabled: true
- name: Set up Python
@@ -196,9 +203,9 @@ jobs:
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: frontend-coverage
@@ -206,12 +213,12 @@ jobs:
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
continue-on-error: true
+4 -10
View File
@@ -12,15 +12,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
migration-test:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-8' }}
runs-on: ubuntu-latest
timeout-minutes: 30
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -37,7 +31,7 @@ jobs:
distribution: temurin
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -46,9 +40,9 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+7 -50
View File
@@ -10,21 +10,11 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
deploy-v2-on-push:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -35,12 +25,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Get commit hashes for frontend and backend
@@ -105,23 +90,9 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push frontend image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-frontend.outputs.exists == 'false'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/frontend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push frontend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
@@ -134,23 +105,9 @@ jobs:
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image (Depot)
if: env.USE_DEPOT == 'true' && steps.check-backend.outputs.exists == 'false'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/backend/Dockerfile
push: true
tags: |
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image (Docker fork fallback)
if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
+4 -15
View File
@@ -11,28 +11,17 @@ on:
required: false
type: string
default: "false"
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 4 because bench showed 16 was within noise of 4."
required: false
type: string
default: "4"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
docker-compose-tests:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '4') }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
checks: write
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
@@ -50,7 +39,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -59,9 +48,9 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
cache-disabled: true
# When the PR changes the base image, test.sh builds it locally
+12 -12
View File
@@ -5,23 +5,13 @@ name: Playwright E2E (live backend)
# server.
on:
workflow_call:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
required: false
type: string
default: "8"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-live:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden Runner
@@ -62,7 +52,17 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
@@ -169,7 +169,7 @@ jobs:
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
+13 -12
View File
@@ -5,23 +5,13 @@ name: Playwright E2E (stubbed)
# mocks API responses in the browser.
on:
workflow_call:
inputs:
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking. Tuned to 8 to match the other playwright workflows; bench showed flat scaling above 8."
required: false
type: string
default: "8"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -44,11 +34,22 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
# ::warning:: annotations + a job summary; never fails the job.
if: always()
working-directory: frontend
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
@@ -19,13 +19,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
files-changed:
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
licenses-frontend: ${{ steps.changes.outputs.licenses-frontend }}
@@ -48,8 +44,8 @@ jobs:
generate-frontend-license-report:
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: [pick, files-changed]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
@@ -98,6 +94,13 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
env:
PR_IS_FORK: "false"
run: task frontend:licenses:generate
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -292,7 +295,10 @@ jobs:
base: main
title: "Update Frontend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,frontend
labels: |
Licenses
github-actions
Front End
draft: false
delete-branch: true
sign-commits: true
@@ -311,15 +317,13 @@ jobs:
generate-backend-license-report:
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: [pick, files-changed]
needs: files-changed
name: Generate Backend License Report
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
repository-projects: write # Required for enabling automerge
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -347,12 +351,13 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
@@ -512,7 +517,10 @@ jobs:
base: main
title: "Update Backend 3rd Party Licenses"
body: ${{ env.PR_BODY }}
labels: Licenses,github-actions,backend
labels: |
Licenses
github-actions
Back End
delete-branch: true
sign-commits: true
+1 -5
View File
@@ -11,12 +11,8 @@ permissions:
pull-requests: write
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
frontend-validation:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+152 -37
View File
@@ -12,13 +12,14 @@ on:
- "true"
- "false"
platform:
description: "Platform to build (windows, macos, linux, or all)"
description: "Platform to build (windows, windows-arm64, macos, linux, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- windows-arm64
- macos
- linux
sign:
@@ -29,6 +30,15 @@ on:
options:
- "true"
- "false"
prerelease:
description: "GitHub release prerelease flag (keep = leave the release as-is)"
required: false
default: "keep"
type: choice
options:
- "keep"
- "true"
- "false"
release:
types: [created]
@@ -36,13 +46,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
version: ${{ steps.versionNumber.outputs.versionNumber }}
@@ -61,7 +67,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
@@ -71,9 +77,9 @@ jobs:
gradle-${{ runner.os }}-
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -91,31 +97,40 @@ jobs:
- name: Determine build matrix
id: set-matrix
run: |
# windows-arm64: NSIS only (WiX MSI has no arm64 support in Tauri) and no
# JPDFium natives yet - flip to windows-arm64 once JPDFium ships them.
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
ALL="$WINDOWS,$WINDOWS_ARM64,$MACOS,$LINUX"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
case "${{ github.event.inputs.platform }}" in
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$WINDOWS,$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
;;
"windows-arm64")
echo "matrix={\"include\":[$WINDOWS_ARM64]}" >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$MACOS]}" >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$LINUX]}" >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
;;
esac
else
# For push/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}]}' >> $GITHUB_OUTPUT
echo "matrix={\"include\":[$ALL]}" >> $GITHUB_OUTPUT
fi
build-jars:
needs: [pick, determine-matrix]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
matrix:
variant:
@@ -146,9 +161,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -195,7 +210,6 @@ jobs:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -243,16 +257,17 @@ jobs:
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -278,7 +293,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -288,18 +303,18 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
# Decode client certificate (RUNNER_TEMP: arm64 runners have no D: drive)
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
$certPath = Join-Path $env:RUNNER_TEMP "Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_FILE=$certPath" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
@@ -323,7 +338,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -391,7 +406,7 @@ jobs:
# Without this, signCommand failures are opaque (Tauri captures but drops
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -419,10 +434,29 @@ jobs:
# Windows cert store first. --keypair-alias goes direct through PKCS11 and works
# without certsync. All real-world working Tauri+smctl examples use this flag.
#
# Tauri's bundler needs signtool.exe to VERIFY bundled dlls/exes even when a
# custom signCommand does the signing; its auto-lookup fails on arm64 hosts.
- name: Locate signtool for Tauri
if: ${{ startsWith(matrix.platform, 'windows') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: pwsh
run: |
$signtool = (Get-Command signtool.exe -ErrorAction SilentlyContinue).Source
if (-not $signtool) {
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\(arm64|x64)\\' } |
Sort-Object FullName | Select-Object -Last 1 -ExpandProperty FullName
}
if ($signtool) {
Write-Host "Using signtool: $signtool"
echo "TAURI_WINDOWS_SIGNTOOL_PATH=$signtool" >> $env:GITHUB_ENV
} else {
Write-Error "signtool.exe not found on runner"
}
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
# from env (set by prior DigiCert setup step). No --config-file needed.
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -493,6 +527,54 @@ jobs:
args: ${{ matrix.args }}
updaterJsonKeepUniversal: true
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). Repack without it,
# then regenerate the updater .sig (repack invalidates the original) and
# GPG-sign again when release signing is on.
- name: Strip bundled Wayland libs from AppImage
if: matrix.platform == 'ubuntu-22.04'
continue-on-error: true
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
echo "No bundled libwayland - nothing to strip"
rm -rf "$WORK"
exit 0
fi
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
curl -fsSL -o "$WORK/appimagetool" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
# Pinned checksum: never execute an unverified downloaded binary. On
# mismatch (upstream rebuilt continuous) the step aborts and the
# original AppImage ships unchanged - update the pin deliberately.
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
chmod +x "$WORK/appimagetool"
SIGN_ARGS=()
if [ "$GPG_SIGN" = "1" ] && [ -n "${SIGN_KEY:-}" ]; then
SIGN_ARGS=(--sign --sign-key "$SIGN_KEY")
fi
"$WORK/appimagetool" --appimage-extract-and-run "${SIGN_ARGS[@]}" "$WORK/squashfs-root" "$AI.new"
# Updater payload signature must match the repacked bytes. The CLI
# reads the key/password from env - never pass secrets as argv.
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
(cd frontend && npx tauri signer sign "$AI.new")
mv "$AI.new.sig" "$AI.sig"
fi
mv "$AI.new" "$AI"
rm -rf "$WORK"
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
env:
@@ -509,10 +591,31 @@ jobs:
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
timeout-minutes: 15
shell: pwsh
run: |
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
# inner exe before packing and the setup exe after, so verifying the setup
# exe is the arm64 equivalent of the MSI + inner-exe check below.
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
$setupExes = Get-ChildItem -Path "./frontend/editor/src-tauri/target" -Filter "*-setup.exe" -Recurse -File
if ($setupExes.Count -eq 0) {
Write-Host "[ERROR] No NSIS installer found under target/"
exit 1
}
foreach ($exe in $setupExes) {
$sig = Get-AuthenticodeSignature -FilePath $exe.FullName
Write-Host "NSIS installer: $($exe.Name) Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] NSIS installer is not signed"
exit 1
}
}
Write-Host "[SUCCESS] NSIS installer is properly signed"
exit 0
}
$allSigned = $true
# Check MSI installer (outer wrapper - what users download)
@@ -576,7 +679,7 @@ jobs:
# but drops stderr when the command exits non-zero, making failures opaque.
# The real errors live in smctl's log files - surface them here for debugging.
- name: Dump smctl logs on failure
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
shell: pwsh
run: |
$logDir = "$env:USERPROFILE\.signingmanager\logs"
@@ -612,6 +715,11 @@ jobs:
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
find . -name "*.msi.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi.sig" \;
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
# The setup exe is also its own updater payload (-> sibling .sig).
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
find . -name "*-setup.exe.sig" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe.sig" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
# DMG = manual install; .app.tar.gz (+ .sig) = updater payload.
# Raw .app is intentionally not shipped (hundreds of MB of uncompressed input).
@@ -638,8 +746,8 @@ jobs:
retention-days: 1
collect-and-release:
needs: [pick, determine-matrix, build, build-jars]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: [determine-matrix, build, build-jars]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
@@ -726,6 +834,10 @@ jobs:
'bundles': ['Stirling-PDF-windows-x86_64.msi'],
'targets': ['windows-x86_64-msi', 'windows-x86_64'],
},
{
'bundles': ['Stirling-PDF-windows-arm64-setup.exe'],
'targets': ['windows-aarch64-nsis', 'windows-aarch64'],
},
{
'bundles': ['Stirling-PDF-macos-universal.app.tar.gz'],
'targets': ['darwin-x86_64', 'darwin-aarch64'],
@@ -824,6 +936,7 @@ jobs:
files: |
./artifacts/**/*.jar
./artifacts/**/*.msi
./artifacts/**/*-setup.exe
./artifacts/**/*.dmg
./artifacts/**/*.app.tar.gz
./artifacts/**/*.deb
@@ -831,4 +944,6 @@ jobs:
./artifacts/**/*.AppImage
./artifacts/latest.json
draft: false
prerelease: false
# Empty = keep the release's existing prerelease flag (action treats
# empty as undefined); dispatch input can force true/false.
prerelease: ${{ github.event.inputs.prerelease != 'keep' && github.event.inputs.prerelease || '' }}
+3 -7
View File
@@ -13,13 +13,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
playwright-all-browsers:
name: Playwright (chromium + firefox + webkit)
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -53,8 +49,8 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
name: playwright-report-nightly-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 14
# Builds all desktop platforms on a schedule so the Rust dependency cache is
+3 -9
View File
@@ -22,15 +22,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
env:
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -46,9 +40,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+124 -38
View File
@@ -12,7 +12,7 @@ on:
workflow_call:
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)."
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)."
required: false
type: string
default: "all"
@@ -21,23 +21,35 @@ on:
required: false
type: boolean
default: true
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass. Used by PR builds."
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
description: "Platform to build (windows, macos, linux, or all)"
description: "Platform to build (windows, windows-arm64, macos, linux, windows-macos, or all)"
required: true
default: "all"
type: choice
options:
- all
- windows
- windows-arm64
- macos
- linux
- windows-macos
sign:
description: "Sign and notarize the bundles."
required: false
default: true
type: boolean
minimal:
description: "Fast smoke build: Linux deb only, skip rpm and the flaky AppImage pass."
required: false
default: false
type: boolean
permissions:
contents: read
@@ -51,7 +63,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -62,14 +74,19 @@ jobs:
PLATFORM: ${{ inputs.platform }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64","jpdfium_platforms":"windows-x64"}'
# ARM64: NSIS only (WiX MSI has no arm64 support in Tauri) and no JPDFium
# natives yet - flip jpdfium_platforms to windows-arm64 once JPDFium ships it.
WINDOWS_ARM64='{"platform":"windows-11-arm","args":"--target aarch64-pc-windows-msvc --bundles nsis","name":"windows-arm64","jpdfium_platforms":"none"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal","jpdfium_platforms":"darwin-arm64,darwin-x64"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64","jpdfium_platforms":"linux-x64"}'
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS") ;;
linux) ENTRIES=("$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
windows) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64") ;;
windows-arm64) ENTRIES=("$WINDOWS_ARM64") ;;
macos) ENTRIES=("$MACOS") ;;
linux) ENTRIES=("$LINUX") ;;
windows-macos) ENTRIES=("$WINDOWS" "$MACOS") ;;
*) ENTRIES=("$WINDOWS" "$WINDOWS_ARM64" "$MACOS" "$LINUX") ;;
esac
# Drop macOS entries when Apple certificate secret is unavailable
@@ -96,10 +113,14 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
# Per-platform sign gate. macOS signs on any run with the cert available,
# PRs included: Gatekeeper blocks an unsigned .dmg, so an unsigned macOS
# PR build is not testable. Windows and Linux stay main-only, matching the
# gates on their own signing steps below.
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -113,7 +134,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libjavascriptcoregtk-4.0-dev libsoup2.4-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "npm"
@@ -151,16 +172,17 @@ jobs:
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -187,7 +209,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -197,7 +219,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -232,7 +254,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -263,7 +285,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -284,7 +306,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -307,7 +329,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -320,7 +342,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -357,7 +379,7 @@ jobs:
fi
- name: Build Tauri app (signed)
if: inputs.sign
if: env.SIGN_BUNDLE == 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -386,13 +408,13 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
if: env.SIGN_BUNDLE != 'true'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -406,15 +428,18 @@ jobs:
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# Linux: build deb+rpm only here (deb-only on minimal smoke builds).
# AppImage runs in its own continue-on-error step below so its
# persistent linuxdeploy failure (#6127 onwards) does not tank uploads.
args: >-
${{ matrix.platform == 'ubuntu-22.04' && (inputs.minimal && '--bundles deb' || '--bundles deb,rpm') || matrix.args }}
--config '{"bundle":{"createUpdaterArtifacts":false}}'
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
# Skipped on minimal smoke builds (flaky + slow, deb is enough to verify).
- name: Build Tauri app (Linux AppImage)
if: matrix.platform == 'ubuntu-22.04'
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
@@ -433,6 +458,37 @@ jobs:
tauriScript: npx tauri
args: --bundles appimage
# Bundled libwayland conflicts with the host's on some distros (Fedora
# Wayland: EGL_BAD_PARAMETER, blank window - #6878). The AppImage
# ecosystem excludelist agrees these libs must come from the system.
- name: Strip bundled Wayland libs from AppImage
if: matrix.platform == 'ubuntu-22.04' && !inputs.minimal
continue-on-error: true
run: |
set -euo pipefail
AI=$(find "$PWD/frontend/editor/src-tauri/target" -name "*.AppImage" | head -1)
if [ -z "$AI" ]; then echo "No AppImage found - skipping"; exit 0; fi
chmod +x "$AI"
WORK=$(mktemp -d)
(cd "$WORK" && "$AI" --appimage-extract >/dev/null)
if ! ls "$WORK/squashfs-root/usr/lib/"libwayland-* >/dev/null 2>&1; then
echo "No bundled libwayland - nothing to strip"
rm -rf "$WORK"
exit 0
fi
rm -f "$WORK/squashfs-root/usr/lib/"libwayland-*
curl -fsSL -o "$WORK/appimagetool" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
# Pinned checksum: never execute an unverified downloaded binary. On
# mismatch (upstream rebuilt continuous) the step aborts and the
# original AppImage ships unchanged - update the pin deliberately.
echo "a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0 $WORK/appimagetool" | sha256sum -c -
chmod +x "$WORK/appimagetool"
"$WORK/appimagetool" --appimage-extract-and-run "$WORK/squashfs-root" "$AI.new"
mv "$AI.new" "$AI"
rm -rf "$WORK"
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
@@ -444,7 +500,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: inputs.sign && matrix.platform == 'macos-15'
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -471,6 +527,9 @@ jobs:
# Only ship the MSI installer. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
# arm64 ships the NSIS installer (WiX MSI has no arm64 support in Tauri).
find . -name "*-setup.exe" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}-setup.exe" \;
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "$DIST/Stirling-PDF-${{ matrix.name }}.dmg" \;
else
@@ -482,9 +541,28 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
# arm64 ships an NSIS installer, not an MSI. Tauri's signCommand signs the
# inner exe before packing and the setup exe after, so verifying the setup
# exe is the arm64 equivalent of the MSI + inner-exe check below.
if ("${{ matrix.platform }}" -eq "windows-11-arm") {
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}-setup.exe"
if (-not (Test-Path $exePath)) {
Write-Host "[ERROR] NSIS installer not found at $exePath"
exit 1
}
$sig = Get-AuthenticodeSignature -FilePath $exePath
Write-Host "NSIS installer: Status=$($sig.Status), Signer=$($sig.SignerCertificate.Subject)"
if ($sig.Status -ne "Valid") {
Write-Host "[ERROR] NSIS installer is not signed"
exit 1
}
Write-Host "[SUCCESS] NSIS installer is properly signed"
exit 0
}
$allSigned = $true
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
@@ -530,7 +608,7 @@ jobs:
Write-Host "[SUCCESS] MSI and inner exe are properly signed"
- name: Dump smctl logs on failure
if: ${{ failure() && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
if: ${{ failure() && startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' }}
shell: pwsh
run: |
$logDir = "$env:USERPROFILE\.signingmanager\logs"
@@ -557,7 +635,7 @@ jobs:
cd ./frontend/editor/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
if [ "${{ matrix.platform }}" = "windows-latest" ] || [ "${{ matrix.platform }}" = "windows-11-arm" ]; then
echo "Checking for Windows artifacts..."
find . -name "*.exe" -o -name "*.msi" | head -5
if [ $(find . -name "*.exe" | wc -l) -eq 0 ]; then
@@ -601,12 +679,19 @@ jobs:
pr-comment:
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && needs.build.result == 'success'
# Fork and Dependabot pull_request runs receive a read-only GITHUB_TOKEN,
# so the API cannot create or update PR comments there. The artifacts are
# still uploaded and remain available from the Actions run page.
if: >-
github.event_name == 'pull_request' &&
needs.build.result == 'success' &&
!github.event.pull_request.head.repo.fork &&
github.actor != 'dependabot[bot]'
permissions:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
@@ -629,6 +714,7 @@ jobs:
// Map of expected artifact names to display info
const artifactMap = {
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
'Stirling-PDF-windows-arm64': { icon: '🪟', platform: 'Windows ARM64', files: '-setup.exe (NSIS)' },
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
};
@@ -697,7 +783,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
+24 -73
View File
@@ -12,19 +12,16 @@ on:
required: false
type: string
default: "false"
depot_cores:
description: "Depot runner vCPU count (used in runs-on). Override for benchmarking."
dockerfiles-changed:
description: "Whether any Dockerfile changed (forwarded from files-changed). Gates the slow arm64 build leg."
required: false
type: string
default: "8"
default: "false"
permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
# TODO: extract a pre-matrix `prepare` job that runs once and produces
# shared artifacts for the three matrix entries below to consume:
# 1. `task backend:build` — currently runs 3× in parallel with
@@ -40,14 +37,7 @@ jobs:
# spring-security=true matrix entry if `task backend:build` and
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
@@ -95,7 +85,7 @@ jobs:
distribution: "temurin"
- name: Cache Gradle dependency artifacts
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/wrapper
@@ -104,9 +94,9 @@ jobs:
key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
cache-disabled: true
- name: Install Task
@@ -120,16 +110,10 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up QEMU
if: env.USE_DEPOT != 'true'
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
@@ -146,13 +130,22 @@ jobs:
# GITHUB_EVENT_NAME is already provided by the runner.
env:
DOCKER_BASE_CHANGED: ${{ inputs.docker-base-changed }}
DOCKERFILES_CHANGED: ${{ inputs.dockerfiles-changed }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$DOCKER_BASE_CHANGED" = "true" ]; then
# Base Dockerfile changed: build against the locally-built base,
# which only exists for amd64.
echo "base_image=stirling-pdf-base:pr-test" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
else
elif [ "$DOCKERFILES_CHANGED" = "true" ]; then
# A Dockerfile changed: also verify the arm64 build (slow QEMU leg).
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
else
# No Dockerfile change: amd64 only. arm64 is exercised on the base
# image publish and on release, not on every code PR.
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> "$GITHUB_OUTPUT"
echo "platforms=linux/amd64" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
@@ -168,25 +161,11 @@ jobs:
--tag stirling-pdf-embedded:pr-test \
.
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./${{ matrix.docker-rev }}
push: false
platforms: ${{ steps.build-params.outputs.platforms }}
build-args: |
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
provenance: true
sbom: true
# Fork PRs that did NOT change the base use the buildx container builder
# PRs that did NOT change the base use the buildx container builder
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build ${{ matrix.docker-rev }}
if: inputs.docker-base-changed != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -213,14 +192,7 @@ jobs:
if-no-files-found: warn
test-build-unoserver-image:
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' && inputs.docker-base-changed != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -230,36 +202,15 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up QEMU
if: env.USE_DEPOT != 'true'
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
id: buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build docker/unoserver/Dockerfile (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/unoserver/Dockerfile
push: false
load: true
platforms: linux/amd64
tags: stirling-unoserver:pr-test
provenance: false
sbom: false
- name: Build docker/unoserver/Dockerfile (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build docker/unoserver/Dockerfile
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
+10 -39
View File
@@ -20,19 +20,9 @@ permissions:
contents: read
jobs:
pick:
uses: ./.github/workflows/_runner-pick.yml
deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
permissions:
contents: read
id-token: write
env:
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -49,9 +39,9 @@ jobs:
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-version: 9.6.0
gradle-version: 9.6.1
- name: Build with Gradle
run: ./gradlew build
@@ -61,12 +51,7 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Depot CLI
if: env.USE_DEPOT == 'true'
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.0.0
- name: Set up Docker Buildx
if: env.USE_DEPOT != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Get version number
@@ -81,21 +66,8 @@ jobs:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Build and push test image (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: .
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Build and push test image (Docker fork fallback)
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- name: Build and push test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
@@ -153,8 +125,7 @@ jobs:
files-changed:
if: always()
name: detect what files changed
needs: pick
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
@@ -174,8 +145,8 @@ jobs:
test:
if: needs.files-changed.outputs.frontend == 'true'
needs: [pick, deploy, files-changed]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: [deploy, files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -208,8 +179,8 @@ jobs:
FORCE_COLOR: "3"
cleanup:
needs: [pick, deploy, test]
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }}
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
+1
View File
@@ -22,6 +22,7 @@ vars:
linux-amd64) echo "linux-x64";;
linux-arm64) echo "linux-arm64";;
windows-amd64) echo "windows-x64";;
windows-arm64) echo "none";; # no JPDFium windows-arm64 natives published yet
*) echo "all";;
esac
fi
+12 -6
View File
@@ -62,18 +62,24 @@ dependencies {
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
// -PjpdfiumPlatforms=all|none|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
// 'none' skips natives entirely (windows-arm64 builds, until JPDFium ships that platform).
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
def jpdfiumAllPlatforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']
def jpdfiumPlatforms = jpdfiumPlatformsProp == 'all'
? jpdfiumAllPlatforms
: jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
def jpdfiumPlatforms
if (jpdfiumPlatformsProp == 'all') {
jpdfiumPlatforms = jpdfiumAllPlatforms
} else if (jpdfiumPlatformsProp == 'none') {
jpdfiumPlatforms = []
} else {
jpdfiumPlatforms = jpdfiumPlatformsProp.split(',').collect { it.trim() }.findAll { it }
}
def jpdfiumInvalid = jpdfiumPlatforms.findAll { !jpdfiumAllPlatforms.contains(it) }
if (jpdfiumInvalid) {
throw new GradleException("Unknown jpdfiumPlatforms value(s): ${jpdfiumInvalid.join(', ')}. " +
"Valid: ${jpdfiumAllPlatforms.join(', ')} or 'all'.")
"Valid: ${jpdfiumAllPlatforms.join(', ')}, 'all' or 'none'.")
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms ? jpdfiumPlatforms.join(', ') : 'none'}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
}
@@ -144,8 +144,10 @@ public class TempFileCleanupService {
int directoriesDeletedCount = 0;
for (Path directory : registry.getTempDirectories()) {
try {
if (Files.exists(directory)) {
if (Files.exists(directory)
&& shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
directoriesDeletedCount++;
log.debug("Cleaned up temporary directory: {}", directory);
}
@@ -275,6 +277,21 @@ public class TempFileCleanupService {
return totalDeletedCount.get();
}
private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) {
if (maxAgeMillis <= 0) {
return true;
}
try {
long currentTime = System.currentTimeMillis();
long lastModified = Files.getLastModifiedTime(directory).toMillis();
return (currentTime - lastModified) > maxAgeMillis;
} catch (IOException e) {
log.debug("Could not check directory age, skipping cleanup: {}", directory, e);
return false;
}
}
/** Get the system temp directory path based on configuration or system property. */
private Path getSystemTempPath() {
String systemTempDir =
@@ -155,6 +155,7 @@ public class TempFileManager {
if (directory != null && Files.isDirectory(directory)) {
try {
GeneralUtils.deleteDirectory(directory);
registry.unregisterDirectory(directory);
log.debug("Deleted temp directory: {}", directory.toString());
} catch (IOException e) {
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
@@ -85,6 +85,18 @@ public class TempFileRegistry {
return directory;
}
/**
* Unregister a temporary directory from the registry.
*
* @param directory The directory to unregister
*/
public void unregisterDirectory(Path directory) {
if (directory != null) {
tempDirectories.remove(directory);
log.debug("Unregistered temp directory: {}", directory.toString());
}
}
/**
* Register a third-party temporary file that requires special handling.
*
@@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest {
class ScheduledCleanup {
@Test
@DisplayName("deletes registered temp directories and reports counts")
@DisplayName("deletes stale registered temp directories and reports counts")
void deletesRegisteredDirectories() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2);
Path regDir = Files.createDirectories(tempDir.resolve("registeredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Files.setLastModifiedTime(
regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
@@ -193,6 +195,22 @@ class TempFileCleanupServiceMoreTest {
verify(tempFileManager).cleanupOldTempFiles(anyLong());
}
@Test
@DisplayName("keeps a fresh registered temp directory")
void keepsFreshRegisteredDirectory() throws IOException {
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0);
Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir"));
Files.createFile(regDir.resolve("inside.txt"));
Set<Path> dirs = new HashSet<>();
dirs.add(regDir);
when(registry.getTempDirectories()).thenReturn(dirs);
lenient().when(registry.contains(any(File.class))).thenReturn(false);
withIsolatedUserHome(cleanupService::scheduledCleanup);
assertThat(Files.exists(regDir)).isTrue();
}
@Test
@DisplayName("skips a registered directory that no longer exists")
void skipsMissingRegisteredDirectory() {
@@ -57,6 +57,7 @@ public class PdfOverlayController {
int overlayPos = request.getOverlayPosition();
MultipartFile[] overlayFiles = request.getOverlayFiles();
validateOverlayFiles(overlayFiles);
File[] overlayPdfFiles = new File[overlayFiles.length];
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
@@ -116,10 +117,29 @@ public class PdfOverlayController {
}
}
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
// caller omits them, which would otherwise surface as a 500 instead of a 400.
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
if (overlayFiles == null || overlayFiles.length == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFilesRequired", "At least one overlay file is required");
}
for (MultipartFile overlayFile : overlayFiles) {
if (overlayFile == null || overlayFile.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFileEmpty", "Overlay files must not be empty");
}
}
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
if (mode == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
}
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
@@ -136,11 +136,17 @@ public class RedactController {
+ "Users can provide text patterns to redact, with options for regex and whole word matching. "
+ "Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
String[] listOfText = request.getListOfText().split("\n");
String rawListOfText = request.getListOfText();
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) {
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
String[] listOfText = rawListOfText.split("\n");
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.redaction.no.patterns", "No text patterns provided for redaction");
}
@@ -299,6 +299,18 @@ class RedactControllerMoreTest {
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
}
@Test
@DisplayName("null listOfText throws an illegal-argument error before any load")
void nullPatternsThrows() throws Exception {
RedactPdfRequest request = new RedactPdfRequest();
request.setFileInput(pdfFile(new byte[] {1, 2, 3}));
request.setListOfText(null);
assertThatThrownBy(() -> controller.redactPdf(request))
.isInstanceOf(RuntimeException.class);
verify(pdfDocumentFactory, never()).load(any(MultipartFile.class));
}
@Test
@DisplayName("null file input is reported as a failure")
void nullFileThrows() {
@@ -843,8 +843,10 @@ public class AdminSettingsController {
return true;
}
// Check for fields containing 'password' or 'secret'
return lowerField.contains("password") || lowerField.contains("secret");
// Check for fields containing 'password' or 'secret' or 'bottoken'
return lowerField.contains("password")
|| lowerField.contains("secret")
|| lowerField.contains("bottoken");
}
/** Create a masked representation for sensitive fields */
+3 -3
View File
@@ -21,7 +21,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion
ext {
springBootVersion = "4.0.6"
pdfboxVersion = "3.0.7"
pdfboxVersion = "3.0.8"
imageioVersion = "3.13.1"
lombokVersion = "1.18.46"
bouncycastleVersion = "1.84"
@@ -39,7 +39,7 @@ ext {
bucket4jVersion = "8.19.0"
archunitVersion = "1.4.2"
batikVersion = "1.19"
jpdfiumVersion = "1.0.2"
jpdfiumVersion = "1.0.4"
jwtVersion = "0.13.0"
awsSdkVersion = "2.44.12"
testcontainersMinioVersion = "1.21.4"
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.14.2'
version = '2.14.3'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+47 -4
View File
@@ -31,6 +31,8 @@ const OUTPUT_FILE = path.join(
// package.json lives at the workspace root (frontend/), not editor/. The
// script is at frontend/editor/scripts/, so walk up two levels.
const PACKAGE_JSON = path.join(__dirname, "..", "..", "package.json");
const PACKAGE_NAME_PATTERN =
/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
@@ -81,9 +83,12 @@ try {
process.exit(1);
}
const existingModuleUrls = loadExistingModuleUrls();
// Convert license-checker format to array
const licenseArray = licenseData.map((dep) => {
let licenseType = dep.licenseType;
const projectUrl = getProjectUrl(dep.name, dep.link, existingModuleUrls);
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
@@ -121,9 +126,9 @@ try {
dep.remoteVersion ||
"unknown",
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link,
repository: projectUrl,
url: projectUrl,
link: projectUrl,
};
});
@@ -133,7 +138,7 @@ try {
const licenseType = Array.isArray(dep.licenseType)
? dep.licenseType.join(", ")
: dep.licenseType || "Unknown";
const licenseUrl = dep.link || getLicenseUrl(licenseType);
const licenseUrl = getLicenseUrl(licenseType) || dep.link;
return {
moduleName: dep.name,
@@ -231,6 +236,9 @@ try {
function getLicenseUrl(licenseType) {
if (!licenseType || licenseType === "Unknown") return "";
const explicitLicenseUrl = licenseType.match(/https?:\/\/\S+/)?.[0];
if (explicitLicenseUrl) return explicitLicenseUrl;
const licenseUrls = {
MIT: "https://opensource.org/licenses/MIT",
"MIT*": "https://opensource.org/licenses/MIT",
@@ -285,6 +293,41 @@ function getLicenseUrl(licenseType) {
return "";
}
function getProjectUrl(packageName, reportedUrl, existingModuleUrls) {
if (!PACKAGE_NAME_PATTERN.test(packageName)) {
throw new Error(`Invalid package name: ${packageName}`);
}
return (
normalizeProjectUrl(reportedUrl) ||
normalizeProjectUrl(existingModuleUrls.get(packageName)) ||
`https://www.npmjs.com/package/${packageName}`
);
}
function loadExistingModuleUrls() {
try {
const existingReport = JSON.parse(readFileSync(OUTPUT_FILE, "utf8"));
return new Map(
(existingReport.dependencies ?? [])
.filter((dependency) => dependency.moduleName && dependency.moduleUrl)
.map((dependency) => [dependency.moduleName, dependency.moduleUrl]),
);
} catch {
return new Map();
}
}
function normalizeProjectUrl(url) {
if (!url || url === "n/a") return "";
return url
.replace(/^git\+/, "")
.replace(/^git:\/\/github\.com\//, "https://github.com/")
.replace(/^github:/, "https://github.com/")
.replace(/\.git$/, "");
}
/**
* Check for potentially problematic licenses that may not be MIT/corporate compatible
*/
+81 -1
View File
@@ -71,9 +71,43 @@ fn parse_launch_files(args: &[String]) -> Vec<String> {
.collect()
}
// URLs the webview is allowed to show: the bundled app and dev server only.
// Anything else (file://, https://...) must never replace the app UI.
fn is_app_url(url: &tauri::Url) -> bool {
match url.scheme() {
"tauri" | "about" | "blob" | "data" => true,
"http" | "https" => matches!(
url.host_str(),
Some("tauri.localhost") | Some("localhost") | Some("127.0.0.1")
),
_ => false,
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// WebKitGTK's DMA-BUF renderer crashes the web process on NVIDIA and some
// Wayland stacks (blank window, app dying on tool switch). Opt out unless overridden.
#[cfg(target_os = "linux")]
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
tauri::Builder::default()
.plugin(
// Dropping a file outside a dropzone makes WebKit navigate the webview to
// that file, killing the app UI and its close handler (window becomes
// unclosable). Block every off-app navigation at the Rust layer.
tauri::plugin::Builder::<tauri::Wry, ()>::new("navigation-guard")
.on_navigation(|_webview, url| {
let allowed = is_app_url(url);
if !allowed {
add_log(format!("🚫 Blocked webview navigation to: {}", url));
}
allowed
})
.build()
)
.plugin(
tauri_plugin_log::Builder::new()
.level(log::LevelFilter::Info)
@@ -209,10 +243,20 @@ pub fn run() {
// Use Tauri's built-in cleanup
app_handle.cleanup_before_exit();
}
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, label, .. } => {
add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
// Don't cleanup here - let JavaScript handler prevent close if needed
// Backend cleanup happens in ExitRequested when window actually closes
//
// Failsafe: if the webview somehow left the app (JS close handler gone,
// window would stay open forever), destroy the window directly.
if let Some(window) = app_handle.get_webview_window(&label) {
let off_app = window.url().map(|u| !is_app_url(&u)).unwrap_or(false);
if off_app {
add_log(format!("🚨 Webview '{}' is off-app, destroying window directly", label));
let _ = window.destroy();
}
}
}
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => {
use tauri::DragDropEvent;
@@ -267,3 +311,39 @@ pub fn run() {
}
});
}
#[cfg(test)]
mod tests {
use super::is_app_url;
fn allows(raw: &str) -> bool {
is_app_url(&tauri::Url::parse(raw).expect("valid url"))
}
#[test]
fn allows_bundled_app_and_dev_server() {
assert!(allows("tauri://localhost/index.html"));
assert!(allows("http://tauri.localhost/"));
assert!(allows("http://localhost:5173/"));
assert!(allows("http://127.0.0.1:8080/api"));
assert!(allows("about:blank"));
assert!(allows("blob:http://localhost:5173/abc"));
assert!(allows("data:text/html,hi"));
}
#[test]
fn blocks_dropped_files() {
// The #6872 lockup: webview navigating to a dropped PDF.
assert!(!allows("file:///C:/Users/me/report.pdf"));
assert!(!allows("file:///home/me/report.pdf"));
}
#[test]
fn blocks_remote_origins() {
assert!(!allows("https://example.com/"));
assert!(!allows("http://evil.test/"));
// Look-alike hosts must not slip past the allowlist.
assert!(!allows("https://localhost.evil.test/"));
assert!(!allows("https://nottauri.localhost.evil.test/"));
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
"version": "2.14.2",
"version": "2.14.3",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
+149 -149
View File
@@ -2,528 +2,528 @@
"dependencies": [
{
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
"moduleUrl": "https://github.com/atlassian/pragmatic-drag-and-drop",
"moduleVersion": "1.7.9",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "@cantoo/pdf-lib",
"moduleUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git",
"moduleUrl": "https://github.com/cantoo-scribe/pdf-lib",
"moduleVersion": "2.6.5",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@dnd-kit/core",
"moduleUrl": "git+https://github.com/clauderic/dnd-kit.git",
"moduleUrl": "https://github.com/clauderic/dnd-kit",
"moduleVersion": "6.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/clauderic/dnd-kit.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz",
"moduleUrl": "https://www.npmjs.com/package/@embedpdf/core",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/models",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-attachment",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-bookmark",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-document-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-print",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-redaction",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleUrl": "https://github.com/embedpdf/embed-pdf-viewer",
"moduleVersion": "2.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@emotion/react",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/react",
"moduleVersion": "11.14.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@emotion/styled",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleUrl": "https://github.com/emotion-js/emotion/tree/main/packages/styled",
"moduleVersion": "11.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@iconify/react",
"moduleUrl": "git+https://github.com/iconify/iconify.git",
"moduleUrl": "https://github.com/iconify/iconify",
"moduleVersion": "6.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@mantine/core",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleUrl": "https://github.com/mantinedev/mantine",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleUrl": "https://github.com/mantinedev/mantine",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleUrl": "https://github.com/mantinedev/mantine",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@mantine/hooks",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleUrl": "https://github.com/mantinedev/mantine",
"moduleVersion": "8.3.18",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@mui/icons-material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleUrl": "https://github.com/mui/material-ui",
"moduleVersion": "9.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@mui/material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleUrl": "https://github.com/mui/material-ui",
"moduleVersion": "9.0.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@posthog/react",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.8.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@reactour/tour",
"moduleUrl": "git+https://github.com/elrumordelaluz/reactour.git",
"moduleUrl": "https://github.com/elrumordelaluz/reactour",
"moduleVersion": "3.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/elrumordelaluz/reactour.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@stripe/react-stripe-js",
"moduleUrl": "https://github.com/stripe/react-stripe-js.git",
"moduleUrl": "https://github.com/stripe/react-stripe-js",
"moduleVersion": "4.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/stripe/react-stripe-js.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@stripe/stripe-js",
"moduleUrl": "https://github.com/stripe/stripe-js.git",
"moduleUrl": "https://github.com/stripe/stripe-js",
"moduleVersion": "7.9.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/stripe/stripe-js.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@supabase/supabase-js",
"moduleUrl": "https://github.com/supabase/supabase-js.git",
"moduleUrl": "https://github.com/supabase/supabase-js",
"moduleVersion": "2.100.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/supabase/supabase-js.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tailwindcss/postcss",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
"moduleVersion": "4.2.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
"moduleUrl": "https://github.com/TanStack/virtual",
"moduleVersion": "3.13.23",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tauri-apps/api",
"moduleUrl": "git+https://github.com/tauri-apps/tauri.git",
"moduleUrl": "https://github.com/tauri-apps/tauri",
"moduleVersion": "2.10.1",
"moduleLicense": "Apache-2.0 OR MIT",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/tauri.git"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "@tauri-apps/plugin-dialog",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleUrl": "https://github.com/tauri-apps/plugins-workspace",
"moduleVersion": "2.7.0",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tauri-apps/plugin-fs",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleUrl": "https://github.com/tauri-apps/plugins-workspace",
"moduleVersion": "2.5.0",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tauri-apps/plugin-http",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleUrl": "https://github.com/tauri-apps/plugins-workspace",
"moduleVersion": "2.5.7",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tauri-apps/plugin-notification",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleUrl": "https://github.com/tauri-apps/plugins-workspace",
"moduleVersion": "2.3.3",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@tauri-apps/plugin-shell",
"moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git",
"moduleUrl": "https://github.com/tauri-apps/plugins-workspace",
"moduleVersion": "2.3.5",
"moduleLicense": "MIT OR Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "@userback/widget",
"moduleUrl": "git+https://github.com/userback/widget-js.git",
"moduleUrl": "https://github.com/userback/widget-js",
"moduleVersion": "0.3.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/userback/widget-js.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "autoprefixer",
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
"moduleUrl": "postcss/autoprefixer",
"moduleVersion": "10.4.27",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "axios",
"moduleUrl": "git+https://github.com/axios/axios.git",
"moduleUrl": "https://github.com/axios/axios",
"moduleVersion": "1.15.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "d3",
"moduleUrl": "git+https://github.com/d3/d3.git",
"moduleUrl": "https://github.com/d3/d3",
"moduleVersion": "7.9.0",
"moduleLicense": "ISC",
"moduleLicenseUrl": "git+https://github.com/d3/d3.git"
"moduleLicenseUrl": "https://opensource.org/licenses/ISC"
},
{
"moduleName": "globals",
"moduleUrl": "git+https://github.com/sindresorhus/globals.git",
"moduleUrl": "sindresorhus/globals",
"moduleVersion": "17.5.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/sindresorhus/globals.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "i18next",
"moduleUrl": "git+https://github.com/i18next/i18next.git",
"moduleUrl": "https://github.com/i18next/i18next",
"moduleVersion": "25.10.10",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "i18next-browser-languagedetector",
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
"moduleUrl": "https://github.com/i18next/i18next-browser-languageDetector",
"moduleVersion": "8.2.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "jszip",
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
"moduleUrl": "https://github.com/Stuk/jszip",
"moduleVersion": "3.10.1",
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "license-report",
"moduleUrl": "git+https://github.com/bepo65/license-report.git",
"moduleUrl": "https://github.com/kessler/license-report",
"moduleVersion": "6.8.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/bepo65/license-report.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "pdfjs-dist",
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
"moduleUrl": "https://github.com/mozilla/pdf.js",
"moduleVersion": "5.5.207",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "peerjs",
"moduleUrl": "git+https://github.com/peers/peerjs.git",
"moduleUrl": "https://github.com/peers/peerjs",
"moduleVersion": "1.5.5",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/peers/peerjs.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "pixelmatch",
"moduleUrl": "git+https://github.com/mapbox/pixelmatch.git",
"moduleUrl": "https://github.com/mapbox/pixelmatch",
"moduleVersion": "7.1.0",
"moduleLicense": "ISC",
"moduleLicenseUrl": "git+https://github.com/mapbox/pixelmatch.git"
"moduleLicenseUrl": "https://opensource.org/licenses/ISC"
},
{
"moduleName": "posthog-js",
"moduleUrl": "https://github.com/PostHog/posthog-js",
"moduleVersion": "1.363.3",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "https://github.com/PostHog/posthog-js"
"moduleLicenseUrl": "https://github.com/PostHog/posthog-js/blob/main/LICENSE"
},
{
"moduleName": "qrcode.react",
"moduleUrl": "git+https://github.com/zpao/qrcode.react.git",
"moduleUrl": "https://github.com/zpao/qrcode.react",
"moduleVersion": "4.2.0",
"moduleLicense": "ISC",
"moduleLicenseUrl": "git+https://github.com/zpao/qrcode.react.git"
"moduleLicenseUrl": "https://opensource.org/licenses/ISC"
},
{
"moduleName": "react",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleUrl": "https://github.com/facebook/react",
"moduleVersion": "19.2.4",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-dom",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleUrl": "https://github.com/facebook/react",
"moduleVersion": "19.2.4",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-easy-crop",
"moduleUrl": "git+https://github.com/ValentinH/react-easy-crop.git",
"moduleUrl": "https://github.com/ValentinH/react-easy-crop",
"moduleVersion": "5.5.6",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/ValentinH/react-easy-crop.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-i18next",
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
"moduleUrl": "https://github.com/i18next/react-i18next",
"moduleVersion": "16.6.6",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-markdown",
"moduleUrl": "git+https://github.com/remarkjs/react-markdown.git",
"moduleUrl": "remarkjs/react-markdown",
"moduleVersion": "9.1.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remarkjs/react-markdown.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-rnd",
"moduleUrl": "git+https://github.com/bokuweb/react-rnd.git",
"moduleUrl": "https://github.com/bokuweb/react-rnd",
"moduleVersion": "10.5.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/bokuweb/react-rnd.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "react-router-dom",
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
"moduleUrl": "https://github.com/remix-run/react-router",
"moduleVersion": "7.13.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "recharts",
"moduleUrl": "git+https://github.com/recharts/recharts.git",
"moduleUrl": "https://github.com/recharts/recharts",
"moduleVersion": "3.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/recharts/recharts.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "remark-gfm",
"moduleUrl": "git+https://github.com/remarkjs/remark-gfm.git",
"moduleUrl": "remarkjs/remark-gfm",
"moduleVersion": "4.0.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remarkjs/remark-gfm.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "signature_pad",
"moduleUrl": "git+https://github.com/szimek/signature_pad.git",
"moduleUrl": "https://github.com/szimek/signature_pad",
"moduleVersion": "5.1.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/szimek/signature_pad.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "smol-toml",
"moduleUrl": "github:squirrelchat/smol-toml",
"moduleUrl": "https://github.com/squirrelchat/smol-toml",
"moduleVersion": "1.6.1",
"moduleLicense": "BSD-3-Clause",
"moduleLicenseUrl": "github:squirrelchat/smol-toml"
"moduleLicenseUrl": "https://opensource.org/licenses/BSD-3-Clause"
},
{
"moduleName": "tailwindcss",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git",
"moduleUrl": "https://github.com/tailwindlabs/tailwindcss",
"moduleVersion": "4.2.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git"
"moduleLicenseUrl": "https://opensource.org/licenses/MIT"
},
{
"moduleName": "web-vitals",
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
"moduleUrl": "https://github.com/GoogleChrome/web-vitals",
"moduleVersion": "5.1.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
}
]
}
@@ -177,6 +177,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
runningEE,
loginEnabled,
handleCloseSync,
config?.showSettingsWhenNoLogin ?? true,
);
const activeLabel = useMemo(() => {
@@ -41,6 +41,7 @@ export const useConfigNavSections = (
_runningEE: boolean = false,
_loginEnabled: boolean = false,
onRequestClose: () => void = () => {},
_showSettingsWhenNoLogin: boolean = true,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -32,6 +32,9 @@ interface LicensesSectionBodyProps {
dependencies: Dependency[];
}
const getModuleUrl = (dependency: Dependency) =>
dependency.moduleUrl || dependency.moduleLicenseUrl;
function LicensesSectionBody({
title,
description,
@@ -106,9 +109,9 @@ function LicensesSectionBody({
sortedDependencies.map((dependency) => (
<Table.Tr key={getDependencyKey(dependency)}>
<Table.Td>
{dependency.moduleUrl ? (
{getModuleUrl(dependency) ? (
<Anchor
href={dependency.moduleUrl}
href={getModuleUrl(dependency)}
target="_blank"
rel="noopener noreferrer"
>
@@ -34,13 +34,17 @@ export default function ToolSelector({
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
// Filter out excluded tools (like 'automate' itself), tools that don't support
// automation, and tools with no operationConfig - the executor resolves a step
// through operationConfig, so offering one without it fails only at run time.
const baseFilteredTools = useMemo(() => {
return (
Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]
).filter(
([key, tool]) =>
!excludeTools.includes(key) && getToolSupportsAutomate(tool),
!excludeTools.includes(key) &&
getToolSupportsAutomate(tool) &&
Boolean(tool.operationConfig),
);
}, [toolRegistry, excludeTools]);
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from "react";
import { useState, useEffect } from "react";
import {
Stack,
Text,
@@ -12,7 +12,10 @@ import {
import { useTranslation } from "react-i18next";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters";
import { useAllFiles } from "@app/contexts/FileContext";
import {
useViewScopedFiles,
useViewScopedFileStubs,
} from "@app/hooks/tools/shared/useViewScopedFiles";
import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector";
import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs";
import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
@@ -34,32 +37,20 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width
const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
const { t } = useTranslation();
const { files, fileStubs } = useAllFiles();
// Preview and measure the document the crop will actually apply to, so both
// follow the viewer when the user switches files with the tool open.
const [selectedStub = null] = useViewScopedFileStubs();
const [selectedFile = null] = useViewScopedFiles();
// Get the first file for preview
const selectedStub = useMemo(() => {
return fileStubs.length > 0 ? fileStubs[0] : null;
}, [fileStubs]);
// Get the first file for PDF processing
const selectedFile = useMemo(() => {
return files.length > 0 ? files[0] : null;
}, [files]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
const [pdfBounds, setPdfBounds] = useState<PDFBounds | null>(null);
useEffect(() => {
const loadPDFDimensions = async () => {
if (!selectedStub || !selectedFile) {
setPdfBounds(null);
setThumbnail(null);
return;
}
setThumbnail(selectedStub.thumbnailUrl || null);
try {
// Get PDF dimensions from the actual file
const arrayBuffer = await selectedFile.arrayBuffer();
@@ -223,7 +214,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
>
<DocumentThumbnail
file={selectedStub}
thumbnail={thumbnail}
thumbnail={selectedStub?.thumbnailUrl ?? null}
style={{
width: pdfBounds.thumbnailWidth,
height: pdfBounds.thumbnailHeight,
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
const viewer = { activeFileIndex: 0 };
const files = {
files: [
{ fileId: "a", name: "alpha.pdf" },
{ fileId: "b", name: "beta.pdf" },
],
fileStubs: [
{ id: "a", name: "alpha.pdf", thumbnailUrl: "alpha.png" },
{ id: "b", name: "beta.pdf", thumbnailUrl: "beta.png" },
],
};
vi.mock("@app/contexts/ViewerContext", () => ({ useViewer: () => viewer }));
vi.mock("@app/contexts/NavigationContext", () => ({
useNavigationState: () => ({ workbench: "viewer" }),
}));
vi.mock("@app/contexts/FileContext", () => ({ useAllFiles: () => files }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
import RotateSettings from "@app/components/tools/rotate/RotateSettings";
const parameters = {
parameters: { angle: 0 },
rotateClockwise: vi.fn(),
rotateAnticlockwise: vi.fn(),
} as never;
const renderAt = (activeFileIndex: number) => {
viewer.activeFileIndex = activeFileIndex;
return render(
<MantineProvider>
<RotateSettings parameters={parameters} />
</MantineProvider>,
);
};
describe("RotateSettings preview", () => {
it("previews the viewer's active file, not the first loaded one", () => {
renderAt(1);
expect(screen.getByAltText("Preview of beta.pdf")).toBeInTheDocument();
expect(screen.queryByAltText("Preview of alpha.pdf")).toBeNull();
});
it("follows the viewer when the active file changes", () => {
const { rerender } = renderAt(0);
expect(screen.getByAltText("Preview of alpha.pdf")).toBeInTheDocument();
viewer.activeFileIndex = 1;
rerender(
<MantineProvider>
<RotateSettings parameters={parameters} />
</MantineProvider>,
);
expect(screen.getByAltText("Preview of beta.pdf")).toBeInTheDocument();
});
});
@@ -1,10 +1,10 @@
import { useMemo, useState, useEffect } from "react";
import { useEffect } from "react";
import { Stack, Text, Box, ActionIcon, Group, Center } from "@mantine/core";
import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters";
import { useAllFiles } from "@app/contexts/FileContext";
import { useViewScopedFileStubs } from "@app/hooks/tools/shared/useViewScopedFiles";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
interface RotateSettingsProps {
@@ -17,19 +17,9 @@ const RotateSettings = ({
disabled = false,
}: RotateSettingsProps) => {
const { t } = useTranslation();
const { fileStubs } = useAllFiles();
// Get the first file for preview
const selectedStub = useMemo(() => {
return fileStubs.length > 0 ? fileStubs[0] : null;
}, [fileStubs]);
// Get thumbnail for the selected file
const [thumbnail, setThumbnail] = useState<string | null>(null);
useEffect(() => {
setThumbnail(selectedStub?.thumbnailUrl || null);
}, [selectedStub]);
// Preview the document the rotation will actually apply to, so it follows
// the viewer when the user switches files with the tool open.
const [previewStub = null] = useViewScopedFileStubs();
// Calculate current angle display
const currentAngle = parameters.parameters.angle;
@@ -85,7 +75,10 @@ const RotateSettings = ({
justifyContent: "center",
}}
>
<DocumentThumbnail file={selectedStub} thumbnail={thumbnail} />
<DocumentThumbnail
file={previewStub}
thumbnail={previewStub?.thumbnailUrl ?? null}
/>
</Box>
</Box>
</Center>
@@ -25,6 +25,12 @@ import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import MoreVertIcon from "@mui/icons-material/MoreVert";
// Sizing constants for the page number input
const MIN_PAGE_DIGITS = 2;
const MIN_INPUT_WIDTH_PX = 48;
const BASE_INPUT_WIDTH_PX = 32;
const PX_PER_DIGIT = 8;
interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now)
currentPage?: number;
@@ -138,6 +144,15 @@ export function PdfViewerToolbar({
scrollActions.scrollToLastPage();
};
const totalPagesDigits = Math.max(
MIN_PAGE_DIGITS,
(scrollState.totalPages || 1).toString().length,
);
const inputWidth = Math.max(
MIN_INPUT_WIDTH_PX,
BASE_INPUT_WIDTH_PX + totalPagesDigits * PX_PER_DIGIT,
);
return (
<Paper
radius="xl xl 0 0"
@@ -206,10 +221,13 @@ export function PdfViewerToolbar({
hideControls
styles={{
input: {
width: 48,
width: inputWidth,
textAlign: "center",
fontWeight: 500,
fontSize: 16,
paddingLeft: 4,
paddingRight: 4,
boxSizing: "border-box",
},
}}
/>
@@ -12,12 +12,15 @@ interface SearchInterfaceProps {
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => {
viewerContextRef.current = viewerContext;
}, [viewerContext]);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchState = viewerContext?.getSearchState();
const searchResults = searchState?.results;
const searchActiveIndex = searchState?.activeIndex;
const searchActions = viewerContext?.searchActions;
const [searchQuery, setSearchQuery] = useState("");
const [jumpToValue, setJumpToValue] = useState("");
@@ -88,7 +91,11 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
if (!visible) return;
const checkSearchState = () => {
// Use ViewerContext state instead of window APIs
// Fetch fresh search state from ViewerContext ref to avoid closure stale values
const searchState = viewerContextRef.current?.getSearchState();
const searchResults = searchState?.results;
const searchActiveIndex = searchState?.activeIndex;
if (searchResults && searchResults.length > 0) {
const activeIndex = searchActiveIndex || 1;
@@ -114,7 +121,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const interval = setInterval(checkSearchState, 200);
return () => clearInterval(interval);
}, [visible, searchResults, searchActiveIndex, searchQuery]);
}, [visible, searchQuery]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
@@ -278,7 +285,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={!resultInfo || resultInfo.currentIndex <= 1}
disabled={!resultInfo || resultInfo.totalResults === 0}
aria-label={t("viewer.search.previous", "Previous result")}
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
@@ -287,9 +294,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
variant="subtle"
size="sm"
onClick={handleNext}
disabled={
!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults
}
disabled={!resultInfo || resultInfo.totalResults === 0}
aria-label={t("viewer.search.next", "Next result")}
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
@@ -13,7 +13,7 @@ import {
ProcessedFileMetadata,
} from "@app/types/fileContext";
import { FileId, ToolOperation } from "@app/types/file";
import { generateThumbnailWithMetadata } from "@app/utils/thumbnailUtils";
import { generateThumbnailPairWithMetadata } from "@app/utils/thumbnailUtils";
import { FileLifecycleManager } from "@app/contexts/file/lifecycle";
import { buildQuickKeySet } from "@app/contexts/file/fileSelectors";
import { StirlingFile } from "@app/types/fileContext";
@@ -121,11 +121,20 @@ export async function generateProcessedFileMetadata(
}
try {
// Generate unrotated thumbnails for PageEditor (rotation applied via CSS)
const unrotatedResult = await generateThumbnailWithMetadata(file, false);
// One parse produces both variants: unrotated thumbnails for PageEditor
// (rotation applied via CSS) and the rotated one for file manager display.
const { unrotated: unrotatedResult, rotated: rotatedResult } =
await generateThumbnailPairWithMetadata(file);
// Generate rotated thumbnail for file manager display
const rotatedResult = await generateThumbnailWithMetadata(file, true);
// Large PDF whose linearized-prefix attempt failed: report "no metadata"
// (the tolerated failure shape) rather than a bogus zero-page document.
if (
!unrotatedResult.thumbnail &&
unrotatedResult.pageCount === 0 &&
!unrotatedResult.isEncrypted
) {
return undefined;
}
const processedFile = createProcessedFile(
unrotatedResult.pageCount,
@@ -0,0 +1,33 @@
/**
* Registry invariant: the Automate picker offers a tool whenever it doesn't opt out via
* `supportsAutomate: false`, but automationExecutor resolves each step through the tool's
* `operationConfig`. A tool that is offered without one is selectable in the builder and
* only fails when the automation runs, with "Tool operation not supported: <toolId>".
*
* So a tool must either carry an operationConfig or declare supportsAutomate: false.
*/
import { describe, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { getToolSupportsAutomate } from "@app/data/toolsTaxonomy";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: { changeLanguage: vi.fn(), language: "en-US" },
}),
Trans: ({ children }: { children?: unknown }) => children,
}));
describe("automatable tools", () => {
test("every tool offered to Automate can be executed as a step", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
const offeredWithoutConfig = Object.entries(result.current.regularTools)
.filter(([, entry]) => entry && getToolSupportsAutomate(entry))
.filter(([, entry]) => !entry.operationConfig)
.map(([id]) => id);
expect(offeredWithoutConfig).toEqual([]);
});
});
@@ -49,6 +49,8 @@ import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/u
import { signOperationConfig } from "@app/hooks/tools/sign/useSignOperation";
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
import { removeAnnotationsOperationConfig } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation";
import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation";
import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation";
import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation";
@@ -525,6 +527,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
maxFiles: -1,
endpoints: ["validate-signature"],
synonyms: getSynonyms(t, "validateSignature"),
// Reports on signatures rather than transforming the PDF, and its hook is
// not on the operationConfig seam, so it cannot run as an automation step.
supportsAutomate: false,
automationSettings: null,
},
@@ -729,6 +734,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["multi-page-layout"],
operationConfig: asRegistryConfig(pageLayoutOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/pageLayout/PageLayoutSettings"),
),
@@ -941,7 +947,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-image-pdf"],
operationConfig: undefined,
operationConfig: asRegistryConfig(removeImageOperationConfig),
synonyms: getSynonyms(t, "removeImage"),
automationSettings: null,
},
@@ -1170,6 +1176,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["scanner-effect"],
synonyms: getSynonyms(t, "scannerEffect"),
// No frontend implementation yet (component is null), so it has no
// operationConfig to execute as an automation step.
supportsAutomate: false,
automationSettings: null,
},
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
const mockViewer = { activeFileIndex: 0 };
const mockNavigation = { workbench: "viewer" };
const mockFiles: { files: unknown[]; fileStubs: unknown[] } = {
files: [],
fileStubs: [],
};
vi.mock("@app/contexts/ViewerContext", () => ({
useViewer: () => mockViewer,
}));
vi.mock("@app/contexts/NavigationContext", () => ({
useNavigationState: () => mockNavigation,
}));
vi.mock("@app/contexts/FileContext", () => ({
useAllFiles: () => mockFiles,
}));
import {
useViewScopedFiles,
useViewScopedFileStubs,
} from "@app/hooks/tools/shared/useViewScopedFiles";
const file = (id: string) => ({ fileId: id, name: `${id}.pdf` });
const stub = (id: string) => ({
id,
name: `${id}.pdf`,
thumbnailUrl: `${id}.png`,
});
function setState(opts: {
workbench?: string;
activeFileIndex?: number;
files?: unknown[];
fileStubs?: unknown[];
}) {
mockNavigation.workbench = opts.workbench ?? "viewer";
mockViewer.activeFileIndex = opts.activeFileIndex ?? 0;
mockFiles.files = opts.files ?? [];
mockFiles.fileStubs = opts.fileStubs ?? [];
}
describe("useViewScopedFileStubs", () => {
it("follows the viewer's active file rather than the first loaded one", () => {
setState({
files: [file("a"), file("b"), file("c")],
fileStubs: [stub("a"), stub("b"), stub("c")],
activeFileIndex: 2,
});
const { result } = renderHook(() => useViewScopedFileStubs());
expect(result.current.map((s) => s.id)).toEqual(["c"]);
});
it("re-resolves when the viewer switches files", () => {
setState({
files: [file("a"), file("b")],
fileStubs: [stub("a"), stub("b")],
activeFileIndex: 0,
});
const { result, rerender } = renderHook(() => useViewScopedFileStubs());
expect(result.current.map((s) => s.id)).toEqual(["a"]);
setState({
files: [file("a"), file("b")],
fileStubs: [stub("a"), stub("b")],
activeFileIndex: 1,
});
rerender();
expect(result.current.map((s) => s.id)).toEqual(["b"]);
});
it("matches by id, not position, when the two lists are out of step", () => {
// A stub exists before its bytes load, so the stub list can be longer than
// (and offset from) the file list.
setState({
files: [file("b")],
fileStubs: [stub("a"), stub("b")],
activeFileIndex: 0,
});
const { result } = renderHook(() => useViewScopedFileStubs());
expect(result.current.map((s) => s.id)).toEqual(["b"]);
});
it("returns every stub outside the viewer, mirroring useViewScopedFiles", () => {
setState({
workbench: "fileEditor",
files: [file("a"), file("b")],
fileStubs: [stub("a"), stub("b")],
});
const stubs = renderHook(() => useViewScopedFileStubs()).result.current;
const files = renderHook(() => useViewScopedFiles()).result.current;
expect(stubs.map((s) => s.id)).toEqual(["a", "b"]);
expect(stubs).toHaveLength(files.length);
});
it("drops stubs with no loaded file instead of returning undefined holes", () => {
setState({
workbench: "fileEditor",
files: [file("a")],
fileStubs: [stub("a"), stub("orphan")],
});
const { result } = renderHook(() => useViewScopedFileStubs());
expect(result.current.map((s) => s.id)).toEqual(["a"]);
});
});
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { useAllFiles } from "@app/contexts/FileContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { StirlingFile } from "@app/types/fileContext";
import { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
const { activeFileIndex } = useViewer();
@@ -18,3 +18,28 @@ export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
return allFiles;
}, [workbench, allFiles, activeFileIndex, ignoreViewerScope]);
}
/**
* The stubs for {@link useViewScopedFiles} — the same scoping rule, for UI that
* needs stub-only metadata (thumbnails, page counts) rather than file bytes.
* A tool's preview must read from here, not from the raw file list: indexing
* that list pins the preview to whichever file was added first, so it keeps
* showing the original document after the viewer switches files.
*
* Resolved by id rather than by position, because the file and stub lists are
* filtered independently — a stub exists before its bytes finish loading, so
* the two arrays can hold different indices for the same document.
*/
export function useViewScopedFileStubs(
ignoreViewerScope = false,
): StirlingFileStub[] {
const scopedFiles = useViewScopedFiles(ignoreViewerScope);
const { fileStubs } = useAllFiles();
return useMemo(() => {
const stubsById = new Map(fileStubs.map((stub) => [stub.id, stub]));
return scopedFiles
.map((file) => stubsById.get(file.fileId))
.filter((stub): stub is StirlingFileStub => stub != null);
}, [scopedFiles, fileStubs]);
}
@@ -23,7 +23,9 @@ import {
const THUMBNAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export interface StoredStirlingFileRecord extends BaseFileMetadata {
data: ArrayBuffer;
// Blob since the large-file OOM fix (stored by reference, no JS-side copy);
// ArrayBuffer records predate it and are still readable.
data: ArrayBuffer | Blob;
fileId: FileId; // Matches runtime StirlingFile.fileId exactly
quickKey: string; // Matches runtime StirlingFile.quickKey exactly
thumbnail?: string;
@@ -114,7 +116,6 @@ class FileStorageService {
stub: StirlingFileStub,
): Promise<void> {
const db = await this.getDatabase();
const arrayBuffer = await stirlingFile.arrayBuffer();
const record: StoredStirlingFileRecord = {
id: stirlingFile.fileId,
@@ -125,7 +126,9 @@ class FileStorageService {
size: stirlingFile.size,
lastModified: stirlingFile.lastModified,
createdAt: stub.createdAt,
data: arrayBuffer,
// Store the File (a Blob) itself: IndexedDB persists it by reference and
// streams to disk, so multi-GB files never materialize in JS memory.
data: stirlingFile,
thumbnail: stub.thumbnailUrl,
thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined,
isLeaf: stub.isLeaf ?? true,
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.2",
appVersion: "2.14.3",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
@@ -82,6 +82,82 @@ export interface DecodedImage {
height: number;
}
function setImageObjectMatrix(
m: WrappedPdfiumModule,
imageObjPtr: number,
pdfX: number,
pdfY: number,
drawWidth: number,
drawHeight: number,
): boolean {
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
try {
m.pdfium.setValue(matrixPtr, drawWidth, "float");
m.pdfium.setValue(matrixPtr + 4, 0, "float");
m.pdfium.setValue(matrixPtr + 8, 0, "float");
m.pdfium.setValue(matrixPtr + 12, drawHeight, "float");
m.pdfium.setValue(matrixPtr + 16, pdfX, "float");
m.pdfium.setValue(matrixPtr + 20, pdfY, "float");
return m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr);
} finally {
m.pdfium.wasmExports.free(matrixPtr);
}
}
/**
* Create a PDFium image page object from decoded pixels.
*
* The caller owns the returned object until it is inserted into a page or
* appended to an annotation. Destroy it with FPDFPageObj_Destroy on failure.
*/
export function createBitmapImageObject(
m: WrappedPdfiumModule,
docPtr: number,
pagePtr: number,
image: DecodedImage,
pdfX: number,
pdfY: number,
drawWidth: number,
drawHeight: number,
): number | null {
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
if (!bitmapPtr) return null;
try {
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
copyRgbaToBgraHeap(
m,
image.rgba,
bufferPtr,
image.width,
image.height,
stride,
);
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (!imageObjPtr) return null;
if (!m.FPDFImageObj_SetBitmap(pagePtr, 0, imageObjPtr, bitmapPtr)) {
m.FPDFPageObj_Destroy(imageObjPtr);
return null;
}
if (
!setImageObjectMatrix(m, imageObjPtr, pdfX, pdfY, drawWidth, drawHeight)
) {
m.FPDFPageObj_Destroy(imageObjPtr);
return null;
}
return imageObjPtr;
} finally {
// FPDFImageObj_SetBitmap copies the bitmap data into the image object.
m.FPDFBitmap_Destroy(bitmapPtr);
}
}
/**
* Create a PDFium bitmap from decoded RGBA pixels, attach it to a new image
* page object, position it via an affine matrix, and insert it into the page.
@@ -99,70 +175,20 @@ export function embedBitmapImageOnPage(
drawWidth: number,
drawHeight: number,
): boolean {
const bitmapPtr = m.FPDFBitmap_Create(image.width, image.height, 1);
if (!bitmapPtr) return false;
const imageObjPtr = createBitmapImageObject(
m,
docPtr,
pagePtr,
image,
pdfX,
pdfY,
drawWidth,
drawHeight,
);
if (!imageObjPtr) return false;
try {
const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr);
const stride = m.FPDFBitmap_GetStride(bitmapPtr);
copyRgbaToBgraHeap(
m,
image.rgba,
bufferPtr,
image.width,
image.height,
stride,
);
const imageObjPtr = m.FPDFPageObj_NewImageObj(docPtr);
if (!imageObjPtr) return false;
const setBitmapOk = m.FPDFImageObj_SetBitmap(
pagePtr,
0,
imageObjPtr,
bitmapPtr,
);
if (!setBitmapOk) {
m.FPDFPageObj_Destroy(imageObjPtr);
return false;
}
// -- early-destroy the bitmap; PDFium has copied the pixel data internally
m.FPDFBitmap_Destroy(bitmapPtr);
// Set affine transform: [a b c d e f]
const matrixPtr = m.pdfium.wasmExports.malloc(6 * 4);
try {
m.pdfium.setValue(matrixPtr, drawWidth, "float"); // a — scaleX
m.pdfium.setValue(matrixPtr + 4, 0, "float"); // b
m.pdfium.setValue(matrixPtr + 8, 0, "float"); // c
m.pdfium.setValue(matrixPtr + 12, drawHeight, "float"); // d — scaleY
m.pdfium.setValue(matrixPtr + 16, pdfX, "float"); // e — translateX
m.pdfium.setValue(matrixPtr + 20, pdfY, "float"); // f — translateY
if (!m.FPDFPageObj_SetMatrix(imageObjPtr, matrixPtr)) {
m.FPDFPageObj_Destroy(imageObjPtr);
return false;
}
} finally {
m.pdfium.wasmExports.free(matrixPtr);
}
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
return true;
} finally {
// Safety net: FPDFBitmap_Destroy is a no-op if ptr is 0 in most PDFium
// builds but guard anyway. If already destroyed above, the second call
// is harmless because we allow it to be idempotent.
// We use a try-catch to be safe across PDFium WASM builds.
try {
m.FPDFBitmap_Destroy(bitmapPtr);
} catch {
/* already freed */
}
}
m.FPDFPage_InsertObject(pagePtr, imageObjPtr);
return true;
}
/**
* Draw a simple light-grey rectangle as a placeholder for annotations
@@ -206,8 +232,8 @@ export function decodeImageDataUrl(
img.onload = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
resolve(null);
@@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { loadScript, isScriptLoaded } from "@app/utils/scriptLoader";
/** jsdom doesn't fetch external scripts, so we drive the load/error events ourselves. */
function scriptEl(id: string): HTMLScriptElement {
const el = document.getElementById(id) as HTMLScriptElement | null;
if (!el) throw new Error(`no script tag #${id}`);
return el;
}
describe("loadScript", () => {
it("overlapping loads share one tag and resolve only after the script actually loads", async () => {
const id = "test-script-overlap";
const src = "https://example.test/overlap.js";
let firstResolved = false;
let secondResolved = false;
const p1 = loadScript({ src, id }).then(() => {
firstResolved = true;
});
// Second, overlapping call (e.g. a StrictMode double-effect or a remount before the
// first load settled). It must reuse the in-flight load, not resolve on tag existence.
const p2 = loadScript({ src, id }).then(() => {
secondResolved = true;
});
// Only one tag despite two calls.
expect(document.querySelectorAll(`#${id}`)).toHaveLength(1);
// Regression guard: before the script executes, neither promise resolves — previously
// the second call resolved immediately because the tag existed, so callers ran before
// the script's globals were defined (the "blank until you reopen" bug).
await Promise.resolve();
expect(firstResolved).toBe(false);
expect(secondResolved).toBe(false);
expect(isScriptLoaded(id)).toBe(false);
scriptEl(id).dispatchEvent(new Event("load"));
await Promise.all([p1, p2]);
expect(firstResolved).toBe(true);
expect(secondResolved).toBe(true);
expect(isScriptLoaded(id)).toBe(true);
});
it("resolves immediately once the script has already loaded", async () => {
const id = "test-script-cached";
const src = "https://example.test/cached.js";
const first = loadScript({ src, id });
scriptEl(id).dispatchEvent(new Event("load"));
await first;
// A later call must resolve from cache without waiting for a fresh load event (which
// would never come) and without adding a second tag.
await loadScript({ src, id });
expect(document.querySelectorAll(`#${id}`)).toHaveLength(1);
});
it("rejects when the script fails to load", async () => {
const id = "test-script-error";
const src = "https://example.test/error.js";
const p = loadScript({ src, id });
scriptEl(id).dispatchEvent(new Event("error"));
await expect(p).rejects.toThrow(/Failed to load script/);
});
it("drops the failed tag so a retry re-attempts instead of hanging", async () => {
const id = "test-script-retry";
const src = "https://example.test/retry.js";
// First attempt (e.g. a warm-up) fails.
const first = loadScript({ src, id });
scriptEl(id).dispatchEvent(new Event("error"));
await expect(first).rejects.toThrow(/Failed to load script/);
// The poisoned tag must be gone — otherwise a retry would attach to a dead tag.
expect(document.querySelectorAll(`#${id}`)).toHaveLength(0);
// Retry (e.g. the modal opening) creates a fresh tag and can now succeed.
const second = loadScript({ src, id });
expect(document.querySelectorAll(`#${id}`)).toHaveLength(1);
scriptEl(id).dispatchEvent(new Event("load"));
await expect(second).resolves.toBeUndefined();
expect(isScriptLoaded(id)).toBe(true);
});
});
+61 -24
View File
@@ -11,6 +11,11 @@ interface ScriptLoadOptions {
}
const loadedScripts = new Set<string>();
// Loads that have started but not yet finished, keyed by script id/src. Overlapping
// callers (e.g. a React StrictMode double-effect, or a component that remounts before
// the previous load settled) reuse the same promise so they all resolve only when the
// script has actually executed — never on tag existence alone.
const pendingScripts = new Map<string, Promise<void>>();
export function loadScript({
src,
@@ -19,43 +24,75 @@ export function loadScript({
defer = false,
onLoad,
}: ScriptLoadOptions): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already loaded
const scriptId = id || src;
if (loadedScripts.has(scriptId)) {
resolve();
return;
}
const scriptId = id || src;
// Check if script already exists in DOM
const existingScript = id
? document.getElementById(id)
: document.querySelector(`script[src="${src}"]`);
if (existingScript) {
// Already fully loaded and executed.
if (loadedScripts.has(scriptId)) {
onLoad?.();
return Promise.resolve();
}
// A load for the same script is already in flight — wait for it rather than kicking
// off a second one (and, critically, don't resolve just because the tag is present).
const inFlight = pendingScripts.get(scriptId);
if (inFlight) {
return onLoad ? inFlight.then(() => onLoad()) : inFlight;
}
const promise = new Promise<void>((resolve, reject) => {
const settleLoaded = (script: HTMLScriptElement) => {
script.dataset.loaded = "true";
loadedScripts.add(scriptId);
pendingScripts.delete(scriptId);
onLoad?.();
resolve();
};
const settleError = (el: HTMLScriptElement) => {
pendingScripts.delete(scriptId);
// Drop the failed tag so a later retry (e.g. the modal after a warm-up that was
// blocked by an extension) creates a fresh one and re-attempts, rather than
// attaching to a dead tag whose error event won't fire again and hanging forever.
el.remove();
reject(new Error(`Failed to load script: ${src}`));
};
// A matching tag may already be in the DOM (added by an earlier load whose Set/Map
// state was lost, or injected elsewhere). If our own loader finished it, the
// data-loaded flag is set and we can resolve immediately; otherwise attach to its
// load lifecycle instead of assuming it is ready.
const existing = (
id
? document.getElementById(id)
: document.querySelector(`script[src="${src}"]`)
) as HTMLScriptElement | null;
if (existing) {
if (existing.dataset.loaded === "true") {
loadedScripts.add(scriptId);
onLoad?.();
resolve();
return;
}
existing.addEventListener("load", () => settleLoaded(existing), {
once: true,
});
existing.addEventListener("error", () => settleError(existing), {
once: true,
});
return;
}
// Create and append script
const script = document.createElement("script");
script.src = src;
if (id) script.id = id;
script.async = async;
script.defer = defer;
script.onload = () => {
loadedScripts.add(scriptId);
if (onLoad) onLoad();
resolve();
};
script.onerror = () => {
reject(new Error(`Failed to load script: ${src}`));
};
script.addEventListener("load", () => settleLoaded(script), { once: true });
script.addEventListener("error", () => settleError(script), { once: true });
document.head.appendChild(script);
});
pendingScripts.set(scriptId, promise);
return promise;
}
export function isScriptLoaded(idOrSrc: string): boolean {
@@ -0,0 +1,114 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import {
PDFArray,
PDFDict,
PDFDocument,
PDFName,
PDFNumber,
PDFRawStream,
decodePDFRawStream,
} from "@cantoo/pdf-lib";
import { embedSignatureImages } from "@app/utils/signatureFlattening";
const ONE_PIXEL_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
beforeAll(async () => {
const wasmPath = path.resolve(
process.cwd(),
"node_modules/@embedpdf/pdfium/dist/pdfium.wasm",
);
const wasmBytes = await readFile(wasmPath);
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve(
new Response(wasmBytes, {
headers: { "Content-Type": "application/wasm" },
}),
),
),
);
});
afterAll(() => {
vi.unstubAllGlobals();
});
const readPageContentStreams = (document: PDFDocument): string[] => {
const contents = document.getPage(0).node.Contents();
if (!(contents instanceof PDFArray)) return [];
const decoder = new TextDecoder();
const streams: string[] = [];
for (let index = 0; index < contents.size(); index++) {
const stream = contents.lookup(index, PDFRawStream);
streams.push(decoder.decode(decodePDFRawStream(stream).decode()));
}
return streams;
};
describe("signatureFlattening", () => {
test("adds a PDFium stamp without regenerating page content", async () => {
const sourceDocument = await PDFDocument.create();
const sourcePage = sourceDocument.addPage([300, 400]);
const markerStream = sourceDocument.context.stream(
"q\n% ORIGINAL_TYPE3_CONTENT\nQ\n",
);
sourcePage.node.addContentStream(
sourceDocument.context.register(markerStream),
);
const sourceBytes = await sourceDocument.save();
const outputBytes = await embedSignatureImages(
Uint8Array.from(sourceBytes).buffer,
[
{
pageIndex: 0,
annotations: [
{
id: "signature-1",
// EmbedPDF may expose an internal asset reference here after the
// annotation has been placed. The persisted PNG must win.
imageData: "embedpdf-asset-reference",
rect: {
origin: { x: 25, y: 30 },
size: { width: 120, height: 50 },
},
imageSrc: `data:image/png;base64,${ONE_PIXEL_PNG}`,
},
],
},
],
(id) =>
id === "signature-1"
? `data:image/png;base64,${ONE_PIXEL_PNG}`
: undefined,
async () => ({
width: 1,
height: 1,
rgba: new Uint8Array([0, 80, 180, 255]),
}),
);
const outputDocument = await PDFDocument.load(outputBytes);
const contentStreams = readPageContentStreams(outputDocument);
const annotations = outputDocument.getPage(0).node.Annots();
expect(contentStreams).toContain("q\n% ORIGINAL_TYPE3_CONTENT\nQ\n");
expect(annotations).toBeInstanceOf(PDFArray);
const stamp = annotations?.lookup(0, PDFDict);
const stampRect = stamp?.lookup(PDFName.of("Rect"), PDFArray);
expect(stamp?.get(PDFName.of("Subtype"))).toEqual(PDFName.of("Stamp"));
expect(stamp?.lookup(PDFName.of("F"), PDFNumber).asNumber()).toBe(196);
expect(stamp?.get(PDFName.of("AP"))).toBeDefined();
expect(
Array.from({ length: stampRect?.size() ?? 0 }, (_, index) =>
stampRect?.lookup(index, PDFNumber).asNumber(),
),
).toEqual([25, 320, 145, 370]);
}, 20_000);
});
@@ -1,11 +1,15 @@
// PDFium annotation subtype constants
import {
FPDF_ANNOT_INK,
FPDF_ANNOT_LINE,
embedBitmapImageOnPage,
drawPlaceholderRect,
createBitmapImageObject,
decodeImageDataUrl,
type DecodedImage,
} from "@app/utils/pdfiumBitmapUtils";
import {
closeDocAndFreeBuffer,
getPdfiumModule,
openRawDocumentSafe,
readEffectivePageBox,
saveRawDocument,
} from "@app/services/pdfiumService";
import { generateThumbnailWithMetadata } from "@app/utils/thumbnailUtils";
import {
createChildStub,
@@ -18,12 +22,6 @@ import {
StirlingFileStub,
} from "@app/types/fileContext";
import type { SignatureAPI } from "@app/components/viewer/viewerTypes";
import {
getPdfiumModule,
openRawDocumentSafe,
closeDocAndFreeBuffer,
saveRawDocument,
} from "@app/services/pdfiumService";
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
@@ -75,23 +73,9 @@ export async function flattenSignatures(
const pageAnnotations =
await signatureApiRef.current.getPageAnnotations(pageIndex);
if (pageAnnotations && pageAnnotations.length > 0) {
const sessionAnnotations = pageAnnotations.filter((annotation) => {
const hasStoredImageData =
annotation.id && getImageData(annotation.id);
const hasDirectImageData =
annotation.imageData ||
annotation.appearance ||
annotation.stampData ||
annotation.imageSrc ||
annotation.contents ||
annotation.data;
return (
hasStoredImageData ||
(hasDirectImageData &&
typeof hasDirectImageData === "string" &&
hasDirectImageData.startsWith("data:image"))
);
});
const sessionAnnotations = pageAnnotations.filter((annotation) =>
Boolean(getAnnotationImageData(annotation, getImageData)),
);
if (sessionAnnotations.length > 0) {
allAnnotations.push({
@@ -166,143 +150,23 @@ export async function flattenSignatures(
type: "application/pdf",
});
// Step 4: Manually render extracted annotations onto the PDF using PDFium WASM
// Step 4: Add signatures as locked, printable PDFium stamp annotations.
// FPDFAnnot_AppendObject creates the annotation appearance without asking
// PDFium to regenerate the page's existing content. GenerateContent would
// corrupt some Type3/vector content, including the issue #7083 logo.
if (allAnnotations.length > 0) {
try {
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(pdfArrayBufferForFlattening);
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
if (pageIndex < pageCount) {
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) continue;
const pageHeight = m.FPDF_GetPageHeightF(pagePtr);
for (const annotation of annotations) {
try {
const rect =
annotation.rect ||
annotation.bounds ||
annotation.rectangle ||
annotation.position;
if (rect) {
const originalX =
rect.origin?.x || rect.x || rect.left || 0;
const originalY =
rect.origin?.y || rect.y || rect.top || 0;
const width = rect.size?.width || rect.width || 100;
const height = rect.size?.height || rect.height || 50;
// Convert from CSS top-left to PDF bottom-left
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
let imageDataUrl =
annotation.imageData ||
annotation.appearance ||
annotation.stampData ||
annotation.imageSrc ||
annotation.contents ||
annotation.data;
if (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
imageDataUrl = storedImageData;
}
}
// Convert SVG to PNG first if needed
if (
imageDataUrl &&
typeof imageDataUrl === "string" &&
imageDataUrl.startsWith("data:image/svg+xml")
) {
const pngBytes = await rasteriseSvgToPng(
imageDataUrl,
width * 2,
height * 2,
);
if (pngBytes) {
imageDataUrl = await uint8ArrayToPngDataUrl(pngBytes);
} else {
drawPlaceholderRect(
m,
pagePtr,
pdfX,
pdfY,
width,
height,
);
continue;
}
}
if (
imageDataUrl &&
typeof imageDataUrl === "string" &&
imageDataUrl.startsWith("data:image")
) {
// Decode the image data URL to raw pixels via canvas
const imageResult =
await decodeImageDataUrl(imageDataUrl);
if (imageResult) {
embedBitmapImageOnPage(
m,
docPtr,
pagePtr,
imageResult,
pdfX,
pdfY,
width,
height,
);
}
} else if (
annotation.type === FPDF_ANNOT_INK ||
annotation.type === FPDF_ANNOT_LINE
) {
drawPlaceholderRect(
m,
pagePtr,
pdfX,
pdfY,
width,
height,
);
}
}
} catch (annotationError) {
console.warn(
"Failed to render annotation:",
annotationError,
);
}
}
m.FPDFPage_GenerateContent(pagePtr);
m.FPDF_ClosePage(pagePtr);
}
}
const resultBuf = await saveRawDocument(docPtr);
signedFile = new File([resultBuf], currentFile.name, {
type: "application/pdf",
});
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
const resultBytes = await embedSignatureImages(
await signedFile.arrayBuffer(),
allAnnotations,
getImageData,
);
signedFile = new File([resultBytes as BlobPart], currentFile.name, {
type: "application/pdf",
});
} catch (renderError) {
console.error("Failed to manually render annotations:", renderError);
console.warn("Signatures may only show as annotations");
console.error("Failed to embed signature images:", renderError);
console.warn("Signatures may only remain as annotations");
}
}
@@ -343,16 +207,211 @@ export async function flattenSignatures(
}
}
/**
* Convert Uint8Array PNG bytes to a data URL for canvas decoding.
*/
function uint8ArrayToPngDataUrl(pngBytes: Uint8Array): Promise<string> {
return new Promise((resolve) => {
const blob = new Blob([pngBytes as BlobPart], { type: "image/png" });
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
type SignatureAnnotationsByPage = Array<{
pageIndex: number;
annotations: any[];
}>;
function extractImageDataUrl(
value: unknown,
depth = 0,
visited: Set<unknown> = new Set(),
): string | undefined {
if (!value || depth > 6) return undefined;
if (typeof value === "string") {
return value.startsWith("data:image") ? value : undefined;
}
if (typeof value !== "object" || visited.has(value)) return undefined;
visited.add(value);
const entries = Array.isArray(value)
? value
: Object.values(value as Record<string, unknown>);
for (const entry of entries) {
const imageDataUrl = extractImageDataUrl(entry, depth + 1, visited);
if (imageDataUrl) return imageDataUrl;
}
return undefined;
}
function getAnnotationImageData(
annotation: any,
getImageData: (id: string) => string | undefined,
): string | undefined {
// EmbedPDF can replace fields such as imageData/appearance with an internal
// asset reference after placement. Prefer our persistent original and only
// accept values that actually contain an image data URL.
const candidates: unknown[] = [
annotation.id ? getImageData(annotation.id) : undefined,
annotation.imageSrc,
annotation.imageData,
annotation.appearance,
annotation.stampData,
annotation.contents,
annotation.data,
annotation.customData,
annotation.asset,
];
for (const candidate of candidates) {
const imageDataUrl = extractImageDataUrl(candidate);
if (imageDataUrl) return imageDataUrl;
}
return undefined;
}
export async function embedSignatureImages(
pdfArrayBuffer: ArrayBuffer,
annotationsByPage: SignatureAnnotationsByPage,
getImageData: (id: string) => string | undefined,
imageDecoder: (
dataUrl: string,
) => Promise<DecodedImage | null> = decodeImageDataUrl,
): Promise<ArrayBuffer> {
const m = await getPdfiumModule();
const docPtr = await openRawDocumentSafe(pdfArrayBuffer);
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
for (const { pageIndex, annotations } of annotationsByPage) {
if (pageIndex < 0 || pageIndex >= pageCount) continue;
const pagePtr = m.FPDF_LoadPage(docPtr, pageIndex);
if (!pagePtr) continue;
try {
const pageBox = readEffectivePageBox(m, pagePtr);
const cropHeight = pageBox.top - pageBox.bottom;
for (const annotation of annotations) {
const rect =
annotation.rect ??
annotation.bounds ??
annotation.rectangle ??
annotation.position;
if (!rect) continue;
const originalX = rect.origin?.x ?? rect.x ?? rect.left ?? 0;
const originalY = rect.origin?.y ?? rect.y ?? rect.top ?? 0;
const width = rect.size?.width ?? rect.width ?? 100;
const height = rect.size?.height ?? rect.height ?? 50;
if (width <= 0 || height <= 0) continue;
let imageDataUrl = getAnnotationImageData(annotation, getImageData);
if (!imageDataUrl) continue;
if (imageDataUrl.startsWith("data:image/svg+xml")) {
const pngBytes = await rasteriseSvgToPng(
imageDataUrl,
width * 2,
height * 2,
);
if (!pngBytes) continue;
imageDataUrl = `data:image/png;base64,${uint8ArrayToBase64(pngBytes)}`;
}
const decodedImage = await imageDecoder(imageDataUrl);
if (!decodedImage) continue;
const pdfX = pageBox.left + originalX;
const pdfY = pageBox.bottom + cropHeight - originalY - height;
appendStampAnnotation(
m,
docPtr,
pagePtr,
decodedImage,
pdfX,
pdfY,
width,
height,
);
}
} finally {
m.FPDF_ClosePage(pagePtr);
}
}
return await saveRawDocument(docPtr);
} finally {
closeDocAndFreeBuffer(m, docPtr);
}
}
const FPDF_ANNOT_STAMP = 13;
const FPDF_ANNOT_FLAG_PRINT = 1 << 2;
const FPDF_ANNOT_FLAG_READONLY = 1 << 6;
const FPDF_ANNOT_FLAG_LOCKED = 1 << 7;
function appendStampAnnotation(
m: Awaited<ReturnType<typeof getPdfiumModule>>,
docPtr: number,
pagePtr: number,
image: DecodedImage,
pdfX: number,
pdfY: number,
width: number,
height: number,
): boolean {
const annotationIndex = m.FPDFPage_GetAnnotCount(pagePtr);
const annotPtr = m.FPDFPage_CreateAnnot(pagePtr, FPDF_ANNOT_STAMP);
if (!annotPtr) return false;
let appended = false;
let imageObjPtr = 0;
const rectPtr = m.pdfium.wasmExports.malloc(4 * 4);
try {
// FS_RECTF layout: left, top, right, bottom.
m.pdfium.setValue(rectPtr, pdfX, "float");
m.pdfium.setValue(rectPtr + 4, pdfY + height, "float");
m.pdfium.setValue(rectPtr + 8, pdfX + width, "float");
m.pdfium.setValue(rectPtr + 12, pdfY, "float");
if (!m.FPDFAnnot_SetRect(annotPtr, rectPtr)) return false;
imageObjPtr =
createBitmapImageObject(
m,
docPtr,
pagePtr,
image,
pdfX,
pdfY,
width,
height,
) ?? 0;
if (!imageObjPtr) return false;
if (!m.FPDFAnnot_AppendObject(annotPtr, imageObjPtr)) return false;
imageObjPtr = 0; // The annotation owns the object after a successful append.
m.FPDFAnnot_SetFlags(
annotPtr,
FPDF_ANNOT_FLAG_PRINT | FPDF_ANNOT_FLAG_READONLY | FPDF_ANNOT_FLAG_LOCKED,
);
appended = true;
return true;
} finally {
m.pdfium.wasmExports.free(rectPtr);
if (imageObjPtr) m.FPDFPageObj_Destroy(imageObjPtr);
m.FPDFPage_CloseAnnot(annotPtr);
if (!appended) m.FPDFPage_RemoveAnnot(pagePtr, annotationIndex);
}
}
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + chunkSize),
);
}
return btoa(binary);
}
/**
@@ -31,6 +31,14 @@ export function calculateScaleFromFileSize(fileSize: number): number {
/** PDFium error code 4 = password required (encrypted PDF). */
const PDFIUM_ERR_PASSWORD = 4;
/** PDFs at or above this size never get a full-buffer client-side parse
* (renderer OOM) - only the linearized-prefix attempt below. */
export const LARGE_PDF_PARSE_LIMIT = 100 * 1024 * 1024;
/** Linearized PDFs keep page 1 + hint tables in the first bytes, so a small
* prefix is often enough to render a thumbnail without reading the file. */
const LINEARIZED_PREFIX_BYTES = 2 * 1024 * 1024;
interface PdfiumRenderResult {
thumbnail: string;
pageCount: number;
@@ -112,6 +120,72 @@ async function renderPdfThumbnailPdfium(
}
}
/**
* Render both thumbnail variants (upright + rotation-baked) from a single
* document open - halves the parse and memory cost of the add-files path.
*/
async function renderPdfThumbnailPairPdfium(
data: ArrayBuffer,
scale: number,
collectAllPagesMetadata: boolean,
): Promise<{ unrotated: PdfiumRenderResult; rotated: PdfiumRenderResult }> {
const m = await getPdfiumModule();
let docPtr: number;
try {
docPtr = await openRawDocumentSafe(data);
} catch (error) {
if (
error instanceof Error &&
new RegExp(`error ${PDFIUM_ERR_PASSWORD}`).test(error.message)
) {
const encrypted: PdfiumRenderResult = {
thumbnail: "",
pageCount: 1,
pageRotations: [],
pageDimensions: [],
isEncrypted: true,
};
return { unrotated: encrypted, rotated: { ...encrypted } };
}
throw error;
}
try {
const pageCount = m.FPDF_GetPageCount(docPtr);
const unrotatedThumb = await renderPdfiumPageDataUrl(docPtr, 0, scale, {
applyRotation: false,
});
const rotatedThumb = await renderPdfiumPageDataUrl(docPtr, 0, scale, {
applyRotation: true,
});
if (!unrotatedThumb || !rotatedThumb) {
throw new Error("PDFium: failed to render page 0");
}
const firstMeta = await readPdfiumPageMetadata(docPtr, 0);
const pageRotations: number[] = [firstMeta?.rotation ?? 0];
const pageDimensions: Array<{ width: number; height: number }> = [
{ width: firstMeta?.width ?? 0, height: firstMeta?.height ?? 0 },
];
if (collectAllPagesMetadata) {
for (let i = 1; i < pageCount; i++) {
const meta = await readPdfiumPageMetadata(docPtr, i);
if (!meta) continue;
pageRotations[i] = meta.rotation;
pageDimensions[i] = { width: meta.width, height: meta.height };
}
}
const base = { pageCount, pageRotations, pageDimensions };
return {
unrotated: { thumbnail: unrotatedThumb, ...base },
rotated: { thumbnail: rotatedThumb, ...base },
};
} finally {
await closeRawDocument(docPtr);
}
}
async function generatePDFThumbnail(
arrayBuffer: ArrayBuffer,
scale: number,
@@ -133,7 +207,7 @@ async function generatePDFThumbnail(
*/
export async function generateThumbnailForFile(file: File): Promise<string> {
// Very large PDFs skip thumbnail generation — SVG icon shown in UI instead
if (file.size >= 100 * 1024 * 1024) {
if (file.size >= LARGE_PDF_PARSE_LIMIT) {
return "";
}
@@ -152,8 +226,7 @@ export async function generateThumbnailForFile(file: File): Promise<string> {
const scale = calculateScaleFromFileSize(file.size);
// Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const chunk = file.slice(0, Math.min(LINEARIZED_PREFIX_BYTES, file.size));
const arrayBuffer = await chunk.arrayBuffer();
try {
@@ -193,6 +266,31 @@ export async function generateThumbnailWithMetadata(
const scale = calculateScaleFromFileSize(file.size);
// Never full-parse huge PDFs client-side - the renderer process OOMs long
// before system RAM runs out. The prefix succeeds for linearized PDFs.
if (file.size >= LARGE_PDF_PARSE_LIMIT) {
try {
const chunk = await file.slice(0, LINEARIZED_PREFIX_BYTES).arrayBuffer();
const result = await renderPdfThumbnailPdfium(
chunk,
scale,
applyRotation,
false,
);
if (result.isEncrypted) {
return { thumbnail: "", pageCount: 1, isEncrypted: true };
}
return {
thumbnail: result.thumbnail,
pageCount: result.pageCount,
pageRotations: result.pageRotations,
pageDimensions: result.pageDimensions,
};
} catch {
return { thumbnail: "", pageCount: 0 };
}
}
try {
const arrayBuffer = await file.arrayBuffer();
// Always read per-page rotation: PageEditor renders thumbnails upright and
@@ -222,3 +320,41 @@ export async function generateThumbnailWithMetadata(
return { thumbnail: "", pageCount: 1 };
}
}
/**
* Both thumbnail variants + page metadata from ONE full parse instead of two.
* Large PDFs only get the linearized-prefix attempt; if that fails, both
* variants are empty placeholders and page metadata is omitted.
*/
export async function generateThumbnailPairWithMetadata(file: File): Promise<{
unrotated: ThumbnailWithMetadata;
rotated: ThumbnailWithMetadata;
}> {
const scale = calculateScaleFromFileSize(file.size);
try {
const isLarge = file.size >= LARGE_PDF_PARSE_LIMIT;
const buffer = isLarge
? await file.slice(0, LINEARIZED_PREFIX_BYTES).arrayBuffer()
: await file.arrayBuffer();
const pair = await renderPdfThumbnailPairPdfium(buffer, scale, !isLarge);
const toPublic = (r: PdfiumRenderResult): ThumbnailWithMetadata =>
r.isEncrypted
? { thumbnail: "", pageCount: 1, isEncrypted: true }
: {
thumbnail: r.thumbnail,
pageCount: r.pageCount,
pageRotations: r.pageRotations,
pageDimensions: r.pageDimensions,
};
return {
unrotated: toPublic(pair.unrotated),
rotated: toPublic(pair.rotated),
};
} catch {
return {
unrotated: { thumbnail: "", pageCount: 0 },
rotated: { thumbnail: "", pageCount: 0 },
};
}
}
@@ -62,6 +62,20 @@ export function AppProviders({ children }: { children: ReactNode }) {
const [appKey, setAppKey] = useState(0);
const hasLoadedInitialMode = useRef(false);
// Files dropped outside a dropzone must never navigate the webview to the
// file (Linux WebKit renders the PDF fullscreen and orphans the app UI).
// Dropzone-level handlers run before these window-level listeners, so
// in-app drag & drop is unaffected.
useEffect(() => {
const preventNavigation = (e: DragEvent) => e.preventDefault();
window.addEventListener("dragover", preventNavigation);
window.addEventListener("drop", preventNavigation);
return () => {
window.removeEventListener("dragover", preventNavigation);
window.removeEventListener("drop", preventNavigation);
};
}, []);
// Load connection mode on mount and subscribe to future changes
useEffect(() => {
void connectionModeService.getCurrentMode().then((mode) => {
@@ -23,6 +23,7 @@ export const useConfigNavSections = (
runningEE: boolean = false,
loginEnabled: boolean = false,
onRequestClose: () => void = () => {},
showSettingsWhenNoLogin: boolean = true,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -53,6 +54,7 @@ export const useConfigNavSections = (
runningEE,
loginEnabled,
onRequestClose,
showSettingsWhenNoLogin,
);
const connectionModeSection: ConfigNavSection = {
@@ -21,8 +21,6 @@ export function useExitWarning() {
const handleCloseRequested = async (event: {
preventDefault: () => void;
}) => {
event.preventDefault();
if (isClosingRef.current) {
return;
}
@@ -30,6 +28,15 @@ export function useExitWarning() {
const allStubs = selectorsRef.current.getStirlingFileStubs();
const dirtyStubs = allStubs.filter((stub) => stub.isDirty);
// Nothing unsaved: don't preventDefault, so the window closes natively
// without depending on the JS dialog/destroy round-trip below.
if (dirtyStubs.length === 0) {
isClosingRef.current = true;
return;
}
event.preventDefault();
if (dirtyStubs.length > 0) {
const fileList = dirtyStubs.map((f) => `${f.name}`).join("\n");
const saveLabel = t("confirmCloseSave", "Save and close");
@@ -18,6 +18,11 @@ export class TauriBackendService {
private isRecovering = false;
private restartAttempts = 0;
private static readonly MAX_RESTART_ATTEMPTS = 3;
/** Failed health checks before the first-ever success are "starting", not "unhealthy" -
* a slow first boot must not trigger the restart/notification loop. */
private hasBeenHealthy = false;
private startupGraceUntil = 0;
private static readonly STARTUP_GRACE_MS = 120_000;
static getInstance(): TauriBackendService {
if (!TauriBackendService.instance) {
@@ -115,9 +120,18 @@ export class TauriBackendService {
// Reset started flag so startBackend() will run again
this.backendStarted = false;
this.startPromise = null;
// Fresh grace window: the restarted backend needs boot time before failed
// health checks may count as unhealthy again.
this.hasBeenHealthy = false;
this.setStatus("starting");
try {
await this.startBackend();
// startBackend resolves once the port is known, not once Spring is up -
// only declare success after a real health check passes.
const healthy = await this.waitUntilHealthy(60_000);
if (!healthy) {
throw new Error("Backend did not become healthy after restart");
}
this.restartAttempts = 0; // Reset on successful restart
this.isRecovering = false;
console.log("[TauriBackendService] Backend restarted successfully.");
@@ -156,6 +170,7 @@ export class TauriBackendService {
}
this.backendStarted = true; // Mark as active for health checks
this.startupGraceUntil = Date.now() + TauriBackendService.STARTUP_GRACE_MS;
this.setStatus("starting");
this.beginHealthMonitoring();
@@ -176,6 +191,7 @@ export class TauriBackendService {
return this.startPromise;
}
this.startupGraceUntil = Date.now() + TauriBackendService.STARTUP_GRACE_MS;
this.setStatus("starting");
this.startPromise = invoke("start_backend", { backendUrl })
@@ -260,7 +276,7 @@ export class TauriBackendService {
console.warn(
`[TauriBackendService] Health check failed: ${response.status}`,
);
this.setStatus("unhealthy");
this.setStatus(this.isInStartupGrace() ? "starting" : "unhealthy");
return false;
}
@@ -270,15 +286,22 @@ export class TauriBackendService {
`[TauriBackendService] dependenciesReady=${dependenciesReady}`,
);
if (dependenciesReady) {
this.hasBeenHealthy = true;
}
this.setStatus(dependenciesReady ? "healthy" : "starting");
return dependenciesReady;
} catch (error) {
console.error("[TauriBackendService] Health check error:", error);
this.setStatus("unhealthy");
this.setStatus(this.isInStartupGrace() ? "starting" : "unhealthy");
return false;
}
}
private isInStartupGrace(): boolean {
return !this.hasBeenHealthy && Date.now() < this.startupGraceUntil;
}
private async waitForHealthy(): Promise<void> {
while (true) {
const isHealthy = await this.checkBackendHealth();
@@ -289,6 +312,17 @@ export class TauriBackendService {
}
}
private async waitUntilHealthy(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await this.checkBackendHealth()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return false;
}
/**
* Reset backend state (used when switching from external to local backend)
*/
@@ -298,6 +332,8 @@ export class TauriBackendService {
this.isLocalBackend = false;
this.isRecovering = false;
this.restartAttempts = 0;
this.hasBeenHealthy = false;
this.startupGraceUntil = 0;
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
@@ -32,6 +32,7 @@ export const useConfigNavSections = (
runningEE: boolean = false,
loginEnabled: boolean = false,
onRequestClose: () => void = () => {},
showSettingsWhenNoLogin: boolean = true,
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -41,6 +42,7 @@ export const useConfigNavSections = (
runningEE,
loginEnabled,
onRequestClose,
showSettingsWhenNoLogin,
);
// Add account management under Preferences
@@ -64,8 +66,9 @@ export const useConfigNavSections = (
}
}
// Add Admin sections if user is admin OR if login is disabled (but mark as disabled)
if (isAdmin || !loginEnabled) {
// Add Admin sections for admins. When login is disabled, keep the historical
// read-only admin preview only if system.showSettingsWhenNoLogin allows it.
if (isAdmin || (!loginEnabled && showSettingsWhenNoLogin)) {
const requiresLogin = !loginEnabled;
const enableLoginTooltip = t(
"settings.tooltips.enableLoginFirst",
@@ -482,7 +482,7 @@ export default function AdminGeneralSection() {
</div>
<div>
<Text size="sm" fw={500} mb={4}>
<Text component="div" size="sm" fw={500} mb={4}>
<Group gap="xs">
<span>
{t("admin.settings.general.logoStyle.label", "Logo Style")}
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.14.2",
appVersion: "2.14.3",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
+163 -163
View File
@@ -12,29 +12,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.14.1",
"@embedpdf/engines": "^2.8.0",
"@embedpdf/models": "^2.14.1",
"@embedpdf/plugin-annotation": "^2.14.1",
"@embedpdf/plugin-attachment": "^2.14.1",
"@embedpdf/plugin-bookmark": "^2.14.1",
"@embedpdf/plugin-document-manager": "^2.14.1",
"@embedpdf/plugin-export": "^2.14.1",
"@embedpdf/plugin-history": "^2.14.1",
"@embedpdf/plugin-interaction-manager": "^2.14.1",
"@embedpdf/plugin-pan": "^2.14.1",
"@embedpdf/plugin-print": "^2.14.1",
"@embedpdf/plugin-redaction": "^2.14.1",
"@embedpdf/plugin-render": "^2.14.1",
"@embedpdf/plugin-rotate": "^2.14.1",
"@embedpdf/plugin-scroll": "^2.14.1",
"@embedpdf/plugin-search": "^2.14.1",
"@embedpdf/plugin-selection": "^2.8.0",
"@embedpdf/plugin-spread": "^2.14.1",
"@embedpdf/plugin-thumbnail": "^2.14.1",
"@embedpdf/plugin-tiling": "^2.14.1",
"@embedpdf/plugin-viewport": "^2.14.1",
"@embedpdf/plugin-zoom": "^2.14.1",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
"@embedpdf/models": "^2.14.4",
"@embedpdf/plugin-annotation": "^2.14.4",
"@embedpdf/plugin-attachment": "^2.14.4",
"@embedpdf/plugin-bookmark": "^2.14.4",
"@embedpdf/plugin-document-manager": "^2.14.4",
"@embedpdf/plugin-export": "^2.14.4",
"@embedpdf/plugin-history": "^2.14.4",
"@embedpdf/plugin-interaction-manager": "^2.14.4",
"@embedpdf/plugin-pan": "^2.14.4",
"@embedpdf/plugin-print": "^2.14.4",
"@embedpdf/plugin-redaction": "^2.14.4",
"@embedpdf/plugin-render": "^2.14.4",
"@embedpdf/plugin-rotate": "^2.14.4",
"@embedpdf/plugin-scroll": "^2.14.4",
"@embedpdf/plugin-search": "^2.14.4",
"@embedpdf/plugin-selection": "^2.14.4",
"@embedpdf/plugin-spread": "^2.14.4",
"@embedpdf/plugin-thumbnail": "^2.14.4",
"@embedpdf/plugin-tiling": "^2.14.4",
"@embedpdf/plugin-viewport": "^2.14.4",
"@embedpdf/plugin-zoom": "^2.14.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -719,13 +719,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.1.tgz",
"integrity": "sha512-L0lNn5WGnGDPaI1q2wnavVF6C6haRtvPGbMfqhZrjr7V+e0GExeoTO0VjK1DwH4IIBHOuD6nsQFYI89+nY/sJA==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz",
"integrity": "sha512-trrFhKePk1JnAS8Vd+NWrS1fU2SIj3sifwawho/MeWKTkferLLuBay96ccA4S/Q5D7glKNH//M58VRgns09P3A==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "2.14.1",
"@embedpdf/models": "2.14.1"
"@embedpdf/engines": "2.14.4",
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -736,9 +736,9 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.14.1.tgz",
"integrity": "sha512-k+HHuhj7dPKZC+8wUvMbpEUbNfi6F5r96Ky2XbNhBxiMQSWlVljBQVfZWcq9Jy3TLslwn5m2BbrvvHw+UVqe3w==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.14.4.tgz",
"integrity": "sha512-1kWYvrxjpKr5QUt16r93pLQOLoWEuzD447OViwZyRECsZ8ePoTnErlNlxHiTkeAWBgwr4DD7Xd+2VkTdMAacNA==",
"license": "MIT",
"dependencies": {
"@embedpdf/fonts-arabic": "1.0.0",
@@ -748,8 +748,8 @@
"@embedpdf/fonts-latin": "1.0.0",
"@embedpdf/fonts-sc": "1.0.0",
"@embedpdf/fonts-tc": "1.0.0",
"@embedpdf/models": "2.14.1",
"@embedpdf/pdfium": "2.14.1"
"@embedpdf/models": "2.14.4",
"@embedpdf/pdfium": "2.14.4"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -802,32 +802,32 @@
"license": "OFL-1.1"
},
"node_modules/@embedpdf/models": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.14.1.tgz",
"integrity": "sha512-jbuoXKv4jW8F1o8EzCmPIB5U/yVC5DWlWhxL9ZfvBiBgW2HIy68ydcu+AykqOG75eAvCfxqcvF2HzSDsV3+K0A==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.14.4.tgz",
"integrity": "sha512-RlxazYdS1ObWe3Rt78pVUXSqQEMeOb1oHFA0CT3aolwtVooZyop9XO3WTDWpEaGvGrHXvuSv7WQgcCLZ2zKyaQ==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.14.1.tgz",
"integrity": "sha512-4FKhpeb7CYfkZ1k0WyDTq8i2FlIO5MaR0ywIDJ0goxOjenKkYOgHQ4747B5evxRzNEC2H5V3Rk6Dunf7TlIYBQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.14.4.tgz",
"integrity": "sha512-0tDPQEH1WtfXcASNVp5U3dqfzZboNvcE4rDok/P4JCN+DCTaJDG2E9lYHGMo9FtuS2IdHgrzkmPRpOo5WV9MKQ==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.14.1.tgz",
"integrity": "sha512-YGe/DLL5r9HsF54QYgNnsc82W4E2jXRaX6D4WzR38argBhiSLf3IhTJmFXVR2WlPK4VwW6DA0p7WqKcEYUOyDg==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.14.4.tgz",
"integrity": "sha512-ff7rczmrBUoZFXlxC3fT6pSciMUuFAySOxY27oyM1IO3m2QLmzz5Xt9yu3oNYsanRPuO5a8nsgaifd9o8HheNA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1",
"@embedpdf/utils": "2.14.1"
"@embedpdf/models": "2.14.4",
"@embedpdf/utils": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-history": "2.14.1",
"@embedpdf/plugin-interaction-manager": "2.14.1",
"@embedpdf/plugin-scroll": "2.14.1",
"@embedpdf/plugin-selection": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-history": "2.14.4",
"@embedpdf/plugin-interaction-manager": "2.14.4",
"@embedpdf/plugin-scroll": "2.14.4",
"@embedpdf/plugin-selection": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -836,15 +836,15 @@
}
},
"node_modules/@embedpdf/plugin-attachment": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.14.1.tgz",
"integrity": "sha512-RdR43Kp/t15/KcvAiN8VEMxhqJcTfK2ZeU95PbPHr8zZ1rDatRltVGmKQ4fcaBXQMv15VLTEPZNbGFbfRgdF8g==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.14.4.tgz",
"integrity": "sha512-ktcv48w5DQVG7LTTt45rsth5X4dyxaXZb5zS1bElF1ULp6g7SRy4a0s3UWBY/UDKbpVtrby3JTsfs5fMU2VaRg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -853,15 +853,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.14.1.tgz",
"integrity": "sha512-ur5tEu4OaVec8T6qF9c18W9hfMNqg0g6dkVqcuh+qaLUYznJpyxMSsNTMJmw/h607bsVLfA1QCwk4l2vORwNmQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.14.4.tgz",
"integrity": "sha512-SfFhVsqEA+5rJUQSVTP259+cgMqbWR93G/kK6GhDcXou4jNGKNfmiWLeWSMp8lsnGhbMkWxFYJKLuH44NEm63g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -870,15 +870,15 @@
}
},
"node_modules/@embedpdf/plugin-document-manager": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.14.1.tgz",
"integrity": "sha512-Qdj6Xkmpyt0aLHCkqZbOsOK5xzwI4IjaV8C2ihPiggB/iPDhoyXPklJb3wXmJfxjTIl+aw+FZmmPkmr4IpYn7w==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.14.4.tgz",
"integrity": "sha512-1qUKu/RAN9LV+Ih7geig0X/2i4jIn8P1D0I0nQUwSMM9hJmitcyDJ/9SK/eOSsdEs4SulIqt+a2W2IMZOY2Vew==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -887,15 +887,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.14.1.tgz",
"integrity": "sha512-O52WPexZD7dbUJQnjp3ikhNg7D3PmGZJowJmXVcmJ2YlTkBabN799LBppC+FYP7nXrtbwy7UmG6vdGWha1b/NQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.14.4.tgz",
"integrity": "sha512-z1IGsGzWve+g81KCrNbZYN/jHkb9mImodVhTfL35mJfOjOahRmbN0OUn1TvcNHaiXKjy8xcfabvxHDnSaViGlA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -904,15 +904,15 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.14.1.tgz",
"integrity": "sha512-mJUKNO69b8Cf0zpE3zJRM5in/AsVRLZq4bRbuAfK9c5+YG2Z59fYEjfRNSwDefxatzFTgy3mlZ5/fSfZsAZz0w==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.14.4.tgz",
"integrity": "sha512-X6SBfpyKgQTHNmawoZOfeTW/nhE3NUeqd+gPLFCUkeVTaYO31qSeje3Z7z7pm17dMp5ar9TsgT69ajn6zlsG1w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -921,15 +921,15 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.14.1.tgz",
"integrity": "sha512-eyH45cF3vHeZ5BlcgIjH212EtUhi20FvKSRZeLWZW72noOn6saXRDScLy/XGbtfGFjJoTrzkNA553hvrRpqVKA==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.14.4.tgz",
"integrity": "sha512-y4MeHBy5s/SR5ma4rezYADIUujViAZSQe8A/CKehve4eU6Z9YF2AflL/M6KG24gCsUyW4D04AxAn3bvsXLR3nw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -938,17 +938,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.14.1.tgz",
"integrity": "sha512-sHc9gjfoWQhfMcJ2JULsTzH9Sg9z6wX76Qvr5cujSPoOVV5bZ4QCB0kF1vgLFFzA1ia7vIBveGtDHIGkQQjsxA==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.14.4.tgz",
"integrity": "sha512-TAVGzXg9qgfiZHZfZFkHry9jCSQjk/borV1DB8HJTcgTk52phhaSr4EFjBZ808Hj5t+cc9VZWoE1JhL8AAiKfw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-interaction-manager": "2.14.1",
"@embedpdf/plugin-viewport": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-interaction-manager": "2.14.4",
"@embedpdf/plugin-viewport": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -957,15 +957,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.14.1.tgz",
"integrity": "sha512-wYuGuxuBhau7l2BfTlD5oohboRdseOn9/v0uVNGwKDuLHUTrv2WnkpzibBYIjv73gttyMBxTXdB3PZfnWOo8VA==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.14.4.tgz",
"integrity": "sha512-qk1aQhLS+8eBL6lcT46p+fZaNjvLMVQF2Nl/t78aOy+cNskXHeYsnZi9tJKNYGLHbi+8+Dg2iHVIhFvweFn/Ww==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -974,20 +974,20 @@
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.14.1.tgz",
"integrity": "sha512-xlcOgYDNqIKp9u7exT95RtfDGCkW1b+knGikpTNZEA5OJgtYO9RxquTkEZ4rdVZbgIWxs0xiMg6b0mhr33z4JQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.14.4.tgz",
"integrity": "sha512-pECosjyBaLlYz1Fgde4gqquV4uQY8LAkbx7TtMwP1EhBFtG0QeoeuhytoLm9I/cWPi84jfaQjkWZB58HPxR5Zw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1",
"@embedpdf/utils": "2.14.1"
"@embedpdf/models": "2.14.4",
"@embedpdf/utils": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-annotation": "2.14.1",
"@embedpdf/plugin-history": "2.14.1",
"@embedpdf/plugin-interaction-manager": "2.14.1",
"@embedpdf/plugin-selection": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-annotation": "2.14.4",
"@embedpdf/plugin-history": "2.14.4",
"@embedpdf/plugin-interaction-manager": "2.14.4",
"@embedpdf/plugin-selection": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -996,15 +996,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.14.1.tgz",
"integrity": "sha512-KRjeSZeQRrg6fuyUgSU43yE6bOrRredpEiAfPI/FUCGgniO9nokTHjnFYg5G3UtVteJdnzdnKwzBl5sY++NQkg==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.14.4.tgz",
"integrity": "sha512-KNY3EGYf9SRqtDBOEO9mR3Hyv9mN8TaC3ztERj0BX8zra7mkjkQnhGQlMxif1dGOoTD9XIcPqJvaw8o7PWDJDw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1013,15 +1013,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.14.1.tgz",
"integrity": "sha512-ILu0Y6bXzLQg63cj9pAJ0ime5m5HVDOSBOyTT/Nb47hBfDLSbTWQuvAHZXwxPhGOjM5Pn6gHssKWZjW2WiXkBQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.14.4.tgz",
"integrity": "sha512-+bBBHfoSMBMjHubDgPkCCu3o2NZn5vv8l/2gmzH6D8k90UVLB2AOei9VTmfy5xqoit+eoY6iSl7w3qyijNfB0A==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1030,16 +1030,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.14.1.tgz",
"integrity": "sha512-ppnP4t43EVNiXr2coEwybQ7BYucbUEpdTGlcEBm38DhLywgseVVySrcIMx7rHdM7ZylsW7uwS84hCFndTpPezg==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.14.4.tgz",
"integrity": "sha512-0JgPwnQqB9THKJt/mOubgFkiTcCkOI6kFCZz8zlwl88DAJcUbdhJoTwCJZ2STYSfECTLB4ZFjb5fZ9AXyfkf0g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-viewport": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-viewport": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1048,15 +1048,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.14.1.tgz",
"integrity": "sha512-dAX4aIc4WeO+bUImbcHVm4c32UADYelpTeJA0It2eq6agZVND20B25kyDYpJiWKTl9NaVcJbNic+wreudl6vsQ==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.14.4.tgz",
"integrity": "sha512-ErRlg0MKLxZ0c4OKk5RjPOV8f8yCaqFi6zqUxBI49/3yN6wDINKvm8dxt5Q2QGUa740n/XylT5BTbjH6lAjCzQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1065,17 +1065,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.14.1.tgz",
"integrity": "sha512-M+omnIDqgME+QgYX3nW3ypjzRvvz2IrSWpx7vP7vUhfd12R4ZJnTkS9AkKbXgHBVwWrxnbibKlZduiiehRLeKg==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.14.4.tgz",
"integrity": "sha512-8w6ZUdwRcBhNCQjlS8mFvsMXI/wr1Q7DljwlFmKaEQyM9vXm2rI9WGN0z9ffjKHlsYn0yg98pqyPLtTevH1P7Q==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1",
"@embedpdf/utils": "2.14.1"
"@embedpdf/models": "2.14.4",
"@embedpdf/utils": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-interaction-manager": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-interaction-manager": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1084,15 +1084,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.14.1.tgz",
"integrity": "sha512-MychoG54eYqjB+8/1/ArKYF+4eonULtBuGPqU72MO9t/Nadl8nNThaPrOW/eKQr3zQu1Af3KO7ng6tm6Umz4rA==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.14.4.tgz",
"integrity": "sha512-ybJNbOMXBciD9cS+wwAUM1L5ZZMgF0+8Tpi78MaXGwnwH1wJUSx1Tx90YNsO9DxIKKckDsxUQREwUtTTcwFLEA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1101,16 +1101,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.14.1.tgz",
"integrity": "sha512-NOaFve83gbD6BUiANSSbiREjUJ1qpNyQrHl2RdMDNLyDaSUARweUu5zC5vfFRd9I+jUf3XGsCa3GNFlzQt+C9A==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.14.4.tgz",
"integrity": "sha512-80D+R2uXoTsmQXFpGGhtapv3I45WNzTSIkcD3VvLywjS2MHwgCNY51RQsn5bc3HINZW9d/SX1F39qiv4HzPp6A==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-render": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-render": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1119,18 +1119,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.14.1.tgz",
"integrity": "sha512-tvs3pPT6wVh0MjMN7Dp8R1XeoTjguH30j/ToWz9ziTKkwXADfJXdyjqYGhagiamMMTwQeUF9py+yDTMP23QV7Q==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.14.4.tgz",
"integrity": "sha512-++1bzvU0Xqi6a1XeUOLAHU7D/FVUNobC/behSSn3WZnY/wIpYG3dXs5NbhNzeomIGXYHC4jCXbKGBPe5TNdoSw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-render": "2.14.1",
"@embedpdf/plugin-scroll": "2.14.1",
"@embedpdf/plugin-viewport": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-render": "2.14.4",
"@embedpdf/plugin-scroll": "2.14.4",
"@embedpdf/plugin-viewport": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1139,15 +1139,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.14.1.tgz",
"integrity": "sha512-U45B46XoaAFxC3yKnVhBO8Z5Wa+ca17UW7esJC/yWneJ2dNfSnvRAR7309KKP/RpZfDmn/lfJDVOFGCDbR3hvw==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.14.4.tgz",
"integrity": "sha512-ZhbDITPZ/h1Vd9cUbCPShSbH5RXd/biCw9w6fneTItruMauYIcTNUS2eWLJC0Yovhw7nu0TjRVYuTQ71jzs0dg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/core": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1156,17 +1156,17 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.14.1.tgz",
"integrity": "sha512-rVah7XgrWiNe9kT7VGRhNp5mCImf3eMk4cGO9kXIidQWka+yFqBANJw6Rj+FJdtWqz6pamZNWXTDZ1jvQkMKZg==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.14.4.tgz",
"integrity": "sha512-a5kUjbG9sKMyO8qqDTrtL30VJ3dSEyNHHvszhfbvKtv4hppz/fSKS78vieu3lw48K11BoioImzCAF6Y3R0LMiw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.14.1"
"@embedpdf/models": "2.14.4"
},
"peerDependencies": {
"@embedpdf/core": "2.14.1",
"@embedpdf/plugin-scroll": "2.14.1",
"@embedpdf/plugin-viewport": "2.14.1",
"@embedpdf/core": "2.14.4",
"@embedpdf/plugin-scroll": "2.14.4",
"@embedpdf/plugin-viewport": "2.14.4",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1175,9 +1175,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.14.1.tgz",
"integrity": "sha512-TlJBT+SgRgcSMKH2EX3WBXIm/a/LEtgG2IiZ7hXPhT3t2e1HOeXjkvreJi6wcnvkMt4eYtJh3nocetto5gNclw==",
"version": "2.14.4",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.14.4.tgz",
"integrity": "sha512-W7BZSNAQeUPQhi6l96Q4oFtg8Vgbt622LQL5ql0oZjyJNJRbYqCJIiJpR+5ZvK6S/x2MadZnYSzBJ/ekL6394A==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
+23 -23
View File
@@ -8,29 +8,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.14.1",
"@embedpdf/engines": "^2.8.0",
"@embedpdf/models": "^2.14.1",
"@embedpdf/plugin-annotation": "^2.14.1",
"@embedpdf/plugin-attachment": "^2.14.1",
"@embedpdf/plugin-bookmark": "^2.14.1",
"@embedpdf/plugin-document-manager": "^2.14.1",
"@embedpdf/plugin-export": "^2.14.1",
"@embedpdf/plugin-history": "^2.14.1",
"@embedpdf/plugin-interaction-manager": "^2.14.1",
"@embedpdf/plugin-pan": "^2.14.1",
"@embedpdf/plugin-print": "^2.14.1",
"@embedpdf/plugin-redaction": "^2.14.1",
"@embedpdf/plugin-render": "^2.14.1",
"@embedpdf/plugin-rotate": "^2.14.1",
"@embedpdf/plugin-scroll": "^2.14.1",
"@embedpdf/plugin-search": "^2.14.1",
"@embedpdf/plugin-selection": "^2.8.0",
"@embedpdf/plugin-spread": "^2.14.1",
"@embedpdf/plugin-thumbnail": "^2.14.1",
"@embedpdf/plugin-tiling": "^2.14.1",
"@embedpdf/plugin-viewport": "^2.14.1",
"@embedpdf/plugin-zoom": "^2.14.1",
"@embedpdf/core": "^2.14.4",
"@embedpdf/engines": "^2.14.4",
"@embedpdf/models": "^2.14.4",
"@embedpdf/plugin-annotation": "^2.14.4",
"@embedpdf/plugin-attachment": "^2.14.4",
"@embedpdf/plugin-bookmark": "^2.14.4",
"@embedpdf/plugin-document-manager": "^2.14.4",
"@embedpdf/plugin-export": "^2.14.4",
"@embedpdf/plugin-history": "^2.14.4",
"@embedpdf/plugin-interaction-manager": "^2.14.4",
"@embedpdf/plugin-pan": "^2.14.4",
"@embedpdf/plugin-print": "^2.14.4",
"@embedpdf/plugin-redaction": "^2.14.4",
"@embedpdf/plugin-render": "^2.14.4",
"@embedpdf/plugin-rotate": "^2.14.4",
"@embedpdf/plugin-scroll": "^2.14.4",
"@embedpdf/plugin-search": "^2.14.4",
"@embedpdf/plugin-selection": "^2.14.4",
"@embedpdf/plugin-spread": "^2.14.4",
"@embedpdf/plugin-thumbnail": "^2.14.4",
"@embedpdf/plugin-tiling": "^2.14.4",
"@embedpdf/plugin-viewport": "^2.14.4",
"@embedpdf/plugin-zoom": "^2.14.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
-23
View File
@@ -23,29 +23,6 @@ plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
// Depot remote build cache. Silently no-ops when DEPOT_TOKEN is absent
// (local dev without depot login, and fork PRs where GitHub hides secrets),
// so contributors without Depot access still build fine on local cache only.
buildCache {
def depotToken = System.getenv('DEPOT_TOKEN')
local {
enabled = true
}
if (depotToken) {
remote(HttpBuildCache) {
url = 'https://cache.depot.dev'
enabled = true
// Only CI runs push to the shared cache; dev laptops pull-only
// so a misconfigured local task can't poison everyone else.
push = System.getenv('CI') == 'true'
credentials {
username = ''
password = depotToken
}
}
}
}
rootProject.name = 'Stirling PDF'
// Flavors: core | proprietary (default) | saas.