From 1fb0242942b2d6983b31ecc3771d4b84f21bb830 Mon Sep 17 00:00:00 2001 From: jojofox0815 <125126720+jojofox0815@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:44:43 +0100 Subject: [PATCH] feat: security & limits settings, verified updates, start-up fixes, accessible donut, quality fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One change set in eleven parts. Each part has its own tests; nothing changes for existing settings files (the new sections read as their defaults). Security and limits (new settings page "Security and limits", nav.security) - Documents with active content: before a document goes through LibreOffice, ActiveContentScanner looks for VBA macros (module names from vbaProject.bin, auto-run entry points such as AutoOpen / Document_Open / Workbook_Open), ActiveX controls, external relationships (attached templates, OLE links), DDE fields, ODF Basic / script libraries and event listeners, linked sections and objects, and legacy DOC / XLS / PPT macro storages including Excel 4.0 macro sheets (OpenMcdf, already shipped through MsgReader, now referenced directly). Policy: Ask (default), Convert, Refuse. "Ask" shows ActiveContentWindow once per job with every finding: trust and convert, leave these files out, or cancel; "Don't ask again" stores the choice. Folder watching and the CLI never ask (filee-cli --active-content ask|convert|refuse; --json lists the findings). - LibreOffice hardening: every worker profile gets MacroSecurityLevel=3, DisableMacrosExecution, no link updates in Writer and Calc, BlockUntrustedRefererLinks and VBA Executable=false in registrymodifications.xcu (other entries are kept). Headless --convert-to doesn't run document macros today (checked with LibreOffice 26.8 on a dom:load macro and a Basic UDF); this keeps it so if that ever changes. - Picture limits: largest picture in megapixels (default 500, checked from the header before decoding, so a small "image bomb" is refused with error.image_too_large), longest side, ImageMagick memory and disk; 0 = automatic. - Parallel files (replaces Environment.ProcessorCount in the queue) and a time limit per step for built-in engines (default 15 min, 0 = none; error.conversion_timeout). Engines that run another program keep their own limits (IHasOwnTimeout). ProcessRunner no longer waits forever for the output of a killed process; the msiexec log is deleted after a successful engine install. Updates - FileeExpectedPublisher (Directory.Build.props, empty by default): when set, an update installer must be signed by exactly this Authenticode publisher, even if the installed copy is unsigned. - FileeUpdatePublicKey (ECDSA P-256, empty by default): when set, the release's SHA256SUMS file must carry a valid signature (.sig) on every platform, or the update is refused (fail closed). build/sign-checksums.ps1 creates keys and signatures; release.yml and sign-release.ps1 sign when the key is available. Without the properties nothing changes. Code signing only when possible - sign-windows.ps1 / sign-release.ps1 look for a valid code-signing certificate themselves (build/signing-certificate.ps1), warn and continue unsigned when there is none; -RequireSignature makes a missing certificate an error. Start-up problems - CET crash (the README's workaround from #486): Filee.Setup is built with CETCompat=false so Setup always starts. On Windows 10 (< 22000) Setup runs "filee-cli.exe version" once after installing; when it dies with 0xC0000409 (shadow stack fail-fast) a machine-wide install applies the README's Set-ProcessMitigation exception for Filee.exe and filee-cli.exe only and notes it in the uninstall entry; the uninstaller removes it again. Windows 11 isn't probed; the app keeps CET everywhere else. - #450: the General page shows when Windows' Task Manager disabled the startup entry (StartupApproved\Run) and offers to enable it again. Release chain (docs/RELEASE-SECURITY.md explains each step and proposes the next ones) - All actions pinned to commit SHAs (version in a comment), least-privilege permissions and timeouts per job. - New: CodeQL (C#), dependency review, Dependabot (actions, NuGet), weekly engine tests (LibreOffice and others), build provenance attestations and an SBOM for release files. Splitting large classes (moves only, no behaviour change) - JobQueue: .Fetch, .Combine, .Dates, .Outputs, .Sweep, .Allocators; UserDataStore: SettingsMigrations; UpdateService: .Verification, .Linux, .Workspaces; DonutMenu: .Input, .Animation, .Rendering; PresetEditorViewModel: .Apply, .Visibility. Accessible donut (see #524) - DonutMenuAutomationPeer: the donut is a list, each slice a list item with its label (and caption), the reason a slice is disabled as help text, selection and invoke patterns; hover and keyboard changes are announced. - Focusable with a focus ring; in the toolbar editor arrows choose a slice, Enter / Space / F2 / context-menu key edit it, Ctrl + arrow moves it (screen readers read the keys out as the donut's help text). High contrast: the ring follows the system's contrast theme. Quality issues - #416 / #506 (Word → PDF): a first-page header of its own is drawn on the first page, and a tall header gets room instead of overlapping the body. - #433 (PDF → Word): a picture covering most of a page with a text layer goes behind the text instead of being dropped (OCR layers keep the old behaviour). - #377 (merged PDF with blank pages): after merging, every page is drawn small; a page blank in the result but not in its source triggers a second merge with PdfPig, keeping PDFsharp's file if that fails too. - #468 (PSD loses transparency): when the composite image has no alpha but the layers do, the layers are flattened. - #406 (Markdown → PDF): code blocks in a monospace box that stays together, quotes with a bar and background, list items no longer cut off. - #384 (HWP stamps): rhwp already draws floating pictures in the cases tested; no change without a sample file. Tests that failed outside a Korean or English system (German Windows 11 here) - OfdTests wrote the OFD XML with the system's number format (Size="4,2333" on a German system): invariant now. - SpreadsheetFormatTests: an ODS by LibreOffice keeps the text Calc showed in its locale ("1.350.000원"), compared without separators now; LibreOffice measures Korean text with its locale's default East Asian font, so the XLS width gets the same tolerance as the ODS one (the column ratio stays checked). - The two preset editor tests set the regional format their expected labels use (labels follow it since 2.1.0). - Engines page: a LibreOffice Filee didn't download now shows its version. LibreOffice 26 dropped MsiProductVersion from version.ini, so CopyVersions reads soffice.exe's product version too; this also brings back "update recommended" for such copies. App tests: 96 minutes → 11 minutes (a run looked hung) - Every language switch wrote the ~1,200 texts into the application's resources one by one, and every write made each DynamicResource in every open window look its key up again: 1 to 4 seconds per switch, and the tests switch languages over a thousand times. LocalizationService now builds the new texts in a dictionary of their own and puts it in the old one's place at once, and does nothing when the language stays. A language switch in the app does one update instead of a thousand as well. Translations: new strings in all 16 languages, pt-PT included (machine-translated; native speakers please check). Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 21 + .github/workflows/ci.yml | 15 +- .github/workflows/codeql.yml | 42 + .github/workflows/dependency-review.yml | 20 + .github/workflows/engines-weekly.yml | 32 + .github/workflows/portable.yml | 26 +- .github/workflows/release.yml | 58 +- Directory.Build.props | 8 + Directory.Packages.props | 2 + README.md | 2 + SECURITY.md | 12 + THIRD-PARTY-NOTICES.md | 2 +- build/sign-checksums.ps1 | 71 ++ build/sign-release.ps1 | 37 +- build/sign-windows.ps1 | 26 +- build/signing-certificate.ps1 | 42 + docs/RELEASE-SECURITY.md | 57 ++ src/Filee.App/App.axaml.cs | 2 + src/Filee.App/Assets/i18n/ar.json | 48 + src/Filee.App/Assets/i18n/en.json | 48 + src/Filee.App/Assets/i18n/es.json | 48 + src/Filee.App/Assets/i18n/fr.json | 48 + src/Filee.App/Assets/i18n/hi.json | 48 + src/Filee.App/Assets/i18n/ja.json | 48 + src/Filee.App/Assets/i18n/ko.json | 48 + src/Filee.App/Assets/i18n/ne.json | 48 + src/Filee.App/Assets/i18n/pl.json | 48 + src/Filee.App/Assets/i18n/pt-BR.json | 48 + src/Filee.App/Assets/i18n/pt-PT.json | 47 + src/Filee.App/Assets/i18n/ru.json | 48 + src/Filee.App/Assets/i18n/th.json | 48 + src/Filee.App/Assets/i18n/tr.json | 48 + src/Filee.App/Assets/i18n/zh-CN.json | 48 + src/Filee.App/Assets/i18n/zh-TW.json | 48 + .../Controls/DonutMenu.Accessibility.cs | 266 +++++ src/Filee.App/Controls/DonutMenu.Animation.cs | 250 +++++ src/Filee.App/Controls/DonutMenu.Input.cs | 161 +++ src/Filee.App/Controls/DonutMenu.Rendering.cs | 416 ++++++++ src/Filee.App/Controls/DonutMenu.cs | 779 +-------------- src/Filee.App/Filee.App.csproj | 9 + src/Filee.App/Services/AppHost.cs | 8 +- src/Filee.App/Services/ConversionService.cs | 11 +- src/Filee.App/Services/LocalizationService.cs | 43 +- src/Filee.App/Services/UpdateService.Linux.cs | 264 +++++ .../Services/UpdateService.Verification.cs | 157 +++ .../Services/UpdateService.Workspaces.cs | 202 ++++ src/Filee.App/Services/UpdateService.cs | 532 +--------- src/Filee.App/Services/WindowService.cs | 27 + src/Filee.App/Styles/Controls.axaml | 9 + src/Filee.App/Styles/Icons.axaml | 2 + .../ViewModels/ActiveContentViewModel.cs | 55 ++ .../ViewModels/MainWindowViewModel.cs | 1 + .../ViewModels/Pages/GeneralPageViewModel.cs | 20 +- .../ViewModels/Pages/SecurityPageViewModel.cs | 113 +++ .../ViewModels/PresetEditorViewModel.Apply.cs | 142 +++ .../PresetEditorViewModel.Visibility.cs | 120 +++ .../ViewModels/PresetEditorViewModel.cs | 237 ----- src/Filee.App/Views/ActiveContentWindow.axaml | 60 ++ .../Views/ActiveContentWindow.axaml.cs | 36 + src/Filee.App/Views/Pages/GeneralPage.axaml | 7 + src/Filee.App/Views/Pages/SecurityPage.axaml | 77 ++ .../Views/Pages/SecurityPage.axaml.cs | 10 + src/Filee.App/Views/Pages/ToolbarPage.axaml | 2 + .../Views/Pages/ToolbarPage.axaml.cs | 2 + src/Filee.App/Views/RadialWindow.axaml | 2 +- src/Filee.Cli/Cli.cs | 21 +- src/Filee.Cli/CliHost.cs | 2 + src/Filee.Cli/CliOptions.cs | 14 + src/Filee.Cli/Help.cs | 8 + src/Filee.Core/Conversion/ActiveContent.cs | 83 ++ src/Filee.Core/Conversion/ConversionJob.cs | 20 +- src/Filee.Core/Conversion/IConverter.cs | 10 + .../Conversion/JobQueue.ActiveContent.cs | 186 ++++ .../Conversion/JobQueue.Allocators.cs | 133 +++ src/Filee.Core/Conversion/JobQueue.Combine.cs | 396 ++++++++ src/Filee.Core/Conversion/JobQueue.Dates.cs | 53 + src/Filee.Core/Conversion/JobQueue.Fetch.cs | 80 ++ src/Filee.Core/Conversion/JobQueue.Outputs.cs | 237 +++++ src/Filee.Core/Conversion/JobQueue.Sweep.cs | 82 ++ src/Filee.Core/Conversion/JobQueue.cs | 923 +----------------- src/Filee.Core/Platform/IPlatformServices.cs | 11 + src/Filee.Core/Settings/AppSettings.cs | 9 + src/Filee.Core/Settings/SecuritySettings.cs | 99 ++ src/Filee.Core/Settings/SettingsMigrations.cs | 917 +++++++++++++++++ src/Filee.Core/Settings/UserDataStore.cs | 909 +---------------- .../Archives/ArchiveConverter.cs | 2 +- src/Filee.Engines/Ebooks/CalibreConverter.cs | 2 +- src/Filee.Engines/Filee.Engines.csproj | 1 + src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 86 +- src/Filee.Engines/Hwp/Hwpx/MarkdownReader.cs | 79 +- src/Filee.Engines/Hwp/RhwpConverter.cs | 2 +- .../Infrastructure/CopyVersions.cs | 7 +- .../Infrastructure/EngineInstaller.cs | 8 + .../Infrastructure/ProcessRunner.cs | 13 +- .../Infrastructure/SecurityAndLimits.cs | 30 + src/Filee.Engines/Magick/ImageReader.cs | 43 +- src/Filee.Engines/Magick/MagickLimits.cs | 77 ++ src/Filee.Engines/Media/FfmpegConverter.cs | 2 +- src/Filee.Engines/Ocr/TesseractConverter.cs | 2 +- .../Office/ActiveContentScanner.cs | 496 ++++++++++ .../Office/LibreOfficeConverter.cs | 35 +- .../Office/LibreOfficeSecurity.cs | 86 ++ src/Filee.Engines/Office/PandocConverter.cs | 2 +- src/Filee.Engines/Pdf/PdfDocumentReader.cs | 29 +- src/Filee.Engines/Pdf/PdfMergeCheck.cs | 81 ++ src/Filee.Engines/Pdf/PdfSharpConverter.cs | 41 +- .../Vector/GhostscriptConverter.cs | 2 +- .../WindowsPlatformServices.cs | 32 + src/Filee.Setup/Filee.Setup.csproj | 5 + src/Filee.Setup/Installation/CetCheck.cs | 89 ++ src/Filee.Setup/Installation/Installer.cs | 7 + src/Filee.Setup/Installation/SetupContext.cs | 4 +- src/Filee.Setup/Installation/SetupNames.cs | 6 + src/Filee.Setup/Installation/Uninstaller.cs | 3 + .../DonutAccessibilityTests.cs | 113 +++ .../LocalizationResourceTests.cs | 31 + tests/Filee.App.Tests/RenderTests.cs | 46 + tests/Filee.App.Tests/UpdateSigningTests.cs | 171 ++++ .../Filee.Core.Tests/JobQueueSecurityTests.cs | 245 +++++ .../Filee.Core.Tests/SecuritySettingsTests.cs | 97 ++ .../ActiveContentScannerTests.cs | 260 +++++ .../HardeningAndQualityTests.cs | 242 +++++ tests/Filee.Engines.Tests/OfdTests.cs | 13 +- tests/Filee.Engines.Tests/OwnCopyTests.cs | 24 + .../SpreadsheetFormatTests.cs | 22 +- tests/Filee.Setup.Tests/CetCheckTests.cs | 122 +++ tests/Filee.Setup.Tests/TestSupport.cs | 2 +- 127 files changed, 8676 insertions(+), 3439 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/engines-weekly.yml create mode 100644 build/sign-checksums.ps1 create mode 100644 build/signing-certificate.ps1 create mode 100644 docs/RELEASE-SECURITY.md create mode 100644 src/Filee.App/Controls/DonutMenu.Accessibility.cs create mode 100644 src/Filee.App/Controls/DonutMenu.Animation.cs create mode 100644 src/Filee.App/Controls/DonutMenu.Input.cs create mode 100644 src/Filee.App/Controls/DonutMenu.Rendering.cs create mode 100644 src/Filee.App/Services/UpdateService.Linux.cs create mode 100644 src/Filee.App/Services/UpdateService.Verification.cs create mode 100644 src/Filee.App/Services/UpdateService.Workspaces.cs create mode 100644 src/Filee.App/ViewModels/ActiveContentViewModel.cs create mode 100644 src/Filee.App/ViewModels/Pages/SecurityPageViewModel.cs create mode 100644 src/Filee.App/ViewModels/PresetEditorViewModel.Apply.cs create mode 100644 src/Filee.App/ViewModels/PresetEditorViewModel.Visibility.cs create mode 100644 src/Filee.App/Views/ActiveContentWindow.axaml create mode 100644 src/Filee.App/Views/ActiveContentWindow.axaml.cs create mode 100644 src/Filee.App/Views/Pages/SecurityPage.axaml create mode 100644 src/Filee.App/Views/Pages/SecurityPage.axaml.cs create mode 100644 src/Filee.Core/Conversion/ActiveContent.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.ActiveContent.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.Allocators.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.Combine.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.Dates.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.Fetch.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.Outputs.cs create mode 100644 src/Filee.Core/Conversion/JobQueue.Sweep.cs create mode 100644 src/Filee.Core/Settings/SecuritySettings.cs create mode 100644 src/Filee.Core/Settings/SettingsMigrations.cs create mode 100644 src/Filee.Engines/Infrastructure/SecurityAndLimits.cs create mode 100644 src/Filee.Engines/Magick/MagickLimits.cs create mode 100644 src/Filee.Engines/Office/ActiveContentScanner.cs create mode 100644 src/Filee.Engines/Office/LibreOfficeSecurity.cs create mode 100644 src/Filee.Engines/Pdf/PdfMergeCheck.cs create mode 100644 src/Filee.Setup/Installation/CetCheck.cs create mode 100644 tests/Filee.App.Tests/DonutAccessibilityTests.cs create mode 100644 tests/Filee.App.Tests/UpdateSigningTests.cs create mode 100644 tests/Filee.Core.Tests/JobQueueSecurityTests.cs create mode 100644 tests/Filee.Core.Tests/SecuritySettingsTests.cs create mode 100644 tests/Filee.Engines.Tests/ActiveContentScannerTests.cs create mode 100644 tests/Filee.Engines.Tests/HardeningAndQualityTests.cs create mode 100644 tests/Filee.Setup.Tests/CetCheckTests.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..efb52846a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# Pull requests for new versions of the GitHub Actions (pinned to commits, so they don't update by themselves) and of +# the NuGet packages (Directory.Packages.props). Grouped weekly so they arrive as one pull request each. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ['*'] + + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + groups: + nuget: + patterns: ['*'] + update-types: [minor, patch] + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6195b2868..286a36251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: code: ${{ steps.files.outputs.code }} macos: ${{ steps.files.outputs.macos }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: files env: @@ -98,10 +98,11 @@ jobs: needs: changes if: ${{ !cancelled() && needs.changes.outputs.code != 'false' }} runs-on: windows-latest + timeout-minutes: 90 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json @@ -124,7 +125,7 @@ jobs: # downloaded once and not on every run. Release builds restore the same cache. - name: Restore Zig toolchain id: zig - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: build/.cache/zig-x86_64-windows-0.16.0 key: zig-0.16.0-x86_64-windows @@ -135,7 +136,7 @@ jobs: - name: Save Zig toolchain if: steps.zig.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: build/.cache/zig-x86_64-windows-0.16.0 key: zig-0.16.0-x86_64-windows @@ -162,7 +163,7 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-results-windows path: TestResults/ @@ -170,7 +171,7 @@ jobs: - name: Upload UI screenshots if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ui-screenshots path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..26d110980 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +# Static analysis of the C# code with GitHub CodeQL: on pushes to main, on pull requests and once a week (new queries +# find old code too). Findings appear under Security > Code scanning. Built on Windows like CI (Windows-only projects). +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '23 4 * * 1' + +permissions: + contents: read + +jobs: + analyze: + runs-on: windows-latest + timeout-minutes: 60 + permissions: + contents: read + security-events: write # upload the results + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + languages: csharp + build-mode: manual + queries: security-and-quality + + - name: Build + run: dotnet build Filee.slnx -c Release + + - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + with: + category: /language:csharp diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..23f7c2aef --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# Pull requests that add or update dependencies (NuGet packages, actions): fails on known vulnerabilities of high +# severity and on licences that don't fit an MIT project's redistribution, before they are merged. +name: Dependency review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + review: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high + deny-licenses: AGPL-1.0-only, AGPL-1.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later diff --git a/.github/workflows/engines-weekly.yml b/.github/workflows/engines-weekly.yml new file mode 100644 index 000000000..0a08dc2ea --- /dev/null +++ b/.github/workflows/engines-weekly.yml @@ -0,0 +1,32 @@ +# CI skips the LibreOffice tests to save time (the engine with H2Orestart and Java is ~420 MB). This runs them once a +# week, and on demand, with every engine fetched, so a LibreOffice route that broke shows up before a release. +name: Engine tests (weekly) + +on: + schedule: + - cron: '41 3 * * 0' + workflow_dispatch: + +permissions: + contents: read + +jobs: + engines: + runs-on: windows-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - name: Fetch every engine + shell: pwsh + run: ./build/fetch-engines.ps1 + + - name: Build + run: dotnet build Filee.slnx -c Release + + - name: Engine tests + run: dotnet test tests/Filee.Engines.Tests --no-build -c Release --blame-hang-timeout 10m --logger "console;verbosity=normal" diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index 2f8e8f4eb..43aae9a7b 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -51,7 +51,7 @@ jobs: env: NATIVE_OUT: ${{ github.workspace }}/build/.cache/linux-native/${{ matrix.rid }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Fails when engines.json moved to another rhwp version while build/build-linux-native.py still builds the old # source (also when the build below comes from the cache). @@ -60,7 +60,7 @@ jobs: - name: Restore native builds id: native-cache - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: build/.cache/linux-native/${{ matrix.rid }} key: linux-native-${{ matrix.rid }}-ubuntu-22.04-${{ hashFiles('build/build-linux-native.py') }} @@ -79,7 +79,7 @@ jobs: - name: Restore cargo downloads if: steps.native-cache.outputs.cache-hit != 'true' - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | ~/.cargo/registry/index @@ -96,7 +96,7 @@ jobs: run: python3 build/check-elf-glibc.py --max 2.35 --needed "$NATIVE_OUT" - name: Upload native builds - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: linux-native-${{ matrix.rid }} path: build/.cache/linux-native/${{ matrix.rid }}/ @@ -119,16 +119,16 @@ jobs: run: shell: pwsh steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download Linux native builds if: runner.os == 'Linux' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: linux-native-${{ matrix.rid }} path: ${{ runner.temp }}/linux-native - - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json @@ -144,7 +144,7 @@ jobs: run: ./build/fetch-fonts.ps1 - name: Restore engine downloads - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: build/.cache/portable-downloads key: portable-engines-${{ matrix.rid }}-${{ hashFiles('src/Filee.Engines/Infrastructure/engines.json') }} @@ -203,7 +203,7 @@ jobs: done - name: Upload packages - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: portable-${{ matrix.rid }} path: | @@ -214,7 +214,7 @@ jobs: - name: Upload test diagnostics if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: unix-test-results-${{ matrix.rid }} path: | @@ -245,7 +245,7 @@ jobs: run: shell: pwsh steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Linux desktop dependencies (Ubuntu 22.04) shell: bash @@ -255,7 +255,7 @@ jobs: libfontconfig1 libfreetype6 libx11-6 libice6 libsm6 libxrandr2 libxkbcommon0 libxi6 libxtst6 libxt6 xvfb fonts-noto-cjk - name: Download package - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: portable-${{ matrix.rid }} path: Releases @@ -280,7 +280,7 @@ jobs: - name: Upload diagnostics if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ubuntu-22-04-results-${{ matrix.rid }} path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ad9db949..b18ecdc43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,9 @@ # their authors and new contributors. # Large engines are not in the installer; its engine page (or the app, on first run and in Settings > Engines) # downloads them. +# Supply chain (docs/RELEASE-SECURITY.md): actions are pinned to commits, the workflow token can only read except in +# the job that drafts the release, the packages get a build provenance attestation and an SBOM, and with the +# FILEE_UPDATE_SIGNING_KEY secret set the checksum files are signed for the updater (build/sign-checksums.ps1). name: Release on: @@ -18,10 +21,12 @@ on: tags: ['v*'] permissions: - contents: write + contents: read jobs: portable: + permissions: + contents: read uses: ./.github/workflows/portable.yml with: version: ${{ github.ref_name }} @@ -29,10 +34,18 @@ jobs: windows: needs: portable runs-on: windows-latest + timeout-minutes: 120 + permissions: + contents: write # draft the release + id-token: write # sign the build provenance attestation (Sigstore) + attestations: write # store it with the repository + env: + # Optional: without the secret the checksum files stay unsigned (sign-checksums.ps1 only warns). + FILEE_UPDATE_SIGNING_KEY: ${{ secrets.FILEE_UPDATE_SIGNING_KEY }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json @@ -48,7 +61,7 @@ jobs: run: ./build/fetch-fonts.ps1 - name: Restore Zig toolchain - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: build/.cache/zig-x86_64-windows-0.16.0 key: zig-0.16.0-x86_64-windows @@ -77,7 +90,7 @@ jobs: - name: Upload test results if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-results-windows path: TestResults/ @@ -99,7 +112,7 @@ jobs: Move-Item publish/win-x64 unsigned/app Copy-Item build/.cache/setup/Filee.Setup.exe unsigned/ - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: windows-unsigned path: unsigned/ @@ -107,12 +120,39 @@ jobs: retention-days: 30 - name: Download Linux and macOS packages - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: portable-* merge-multiple: true path: Releases + # What the release is made of (NuGet packages and their versions), next to the packages. + - name: SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + path: unsigned/app + format: spdx-json + output-file: Releases/Filee-${{ steps.version.outputs.value }}-sbom.spdx.json + upload-artifact: false + upload-release-assets: false + + - name: Sign the checksum files for the updater + shell: pwsh + run: | + $sums = @(Get-ChildItem Releases -File -Filter '*-SHA256SUMS.txt' | ForEach-Object FullName) + if ($sums.Count) { ./build/sign-checksums.ps1 -Path $sums } + + # Anyone can check that a package came out of this workflow run: gh attestation verify -R . + # The Windows files are signed later on a PC (build/sign-release.ps1): their attestation covers the tested, + # unsigned build they are signed from. + - name: Build provenance + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-path: | + Releases/Filee-* + unsigned/Filee.Setup.exe + unsigned/app/Filee.exe + - name: Draft the GitHub release shell: pwsh env: @@ -120,7 +160,9 @@ jobs: run: | $version = '${{ steps.version.outputs.value }}' # Linux and macOS only: build/sign-release.ps1 adds the signed Windows files. - $windows = "Filee-$version-win-Setup.exe", "Filee-$version-win-Portable.zip", "Filee-$version-SHA256SUMS.txt" + # The Windows checksum file and its signature change once the Windows files are signed. + $windows = "Filee-$version-win-Setup.exe", "Filee-$version-win-Portable.zip", "Filee-$version-SHA256SUMS.txt", + "Filee-$version-SHA256SUMS.txt.sig" $files = Get-ChildItem Releases -File -Filter "Filee-$version-*" | Where-Object Name -notin $windows | ForEach-Object FullName # "## 1.4.0" up to the next "## " heading of CHANGELOG.md, without the heading itself. $changes = '' diff --git a/Directory.Build.props b/Directory.Build.props index 6734481a0..684a8c332 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,6 +22,14 @@ https://github.com/KnifeLemon/Filee $(FileeRepositoryUrl) + + + + + + diff --git a/README.md b/README.md index 5e2d9ff97..34615a522 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ This happens on Windows 10 that hasn't been updated for a long time, on a PC who Windows turns that protection on but lacks a part that .NET needs, so Filee and Setup stop right away. - **Recommended:** install the available Windows updates (the latest cumulative update). +- **Setup does the next step by itself** for an install for all users: it starts on such a PC, notices when Filee + can't, and turns the protection off for Filee's two programs only. Uninstalling turns it back on. - **If updating isn't possible:** turning the protection off for Filee alone usually works. Run this in PowerShell as administrator: diff --git a/SECURITY.md b/SECURITY.md index 142c081c0..592a864b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,10 +43,22 @@ Out of scope: - **Updates:** an installed copy downloads the new installer from GitHub Releases and checks it against the release's `SHA256SUMS` file before it runs it. It also checks that Windows trusts the installer's Authenticode signature and that the publisher who signed the installed Filee signed it; an installer signed by anyone else is discarded. + Builds made with `FileeExpectedPublisher` accept only that publisher, even when the installed copy is unsigned. + Builds made with `FileeUpdatePublicKey` also check the checksum file's signature (`SHA256SUMS.txt.sig`, ECDSA + P-256) and refuse an update without a valid one, on every platform (see + [Release security](docs/RELEASE-SECURITY.md)). - **Engines:** every engine comes from the official location pinned in [`engines.json`](src/Filee.Engines/Infrastructure/engines.json), with its SHA-256 and size. A download that doesn't match is discarded. - **Release files:** each release lists the SHA-256 of every package in `Filee--SHA256SUMS.txt`. +## Documents with macros and links + +Filee never runs a document's macros. Before a document goes through LibreOffice, Filee looks for macros (and those +that run on opening), ActiveX controls, DDE fields and links to outside files. Settings → Security and limits decides +whether Filee asks first (the default), converts such documents anyway, or refuses them; folder watching and the +command line never ask (`filee-cli --active-content`). LibreOffice itself runs with macros disabled and doesn't update +links. + Filee converts files on your own computer and never uploads them. See the [Privacy policy](README.md#privacy-policy). diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 7e00ed2c8..f608191b0 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -27,7 +27,7 @@ Each component remains under its own license. | PdfPig (PDF text, layout and pictures) | Apache-2.0 | https://github.com/UglyToad/PdfPig | | MimeKitLite (e-mail / MIME parsing) | MIT | https://github.com/jstedfast/MimeKit | | MsgReader (Outlook .msg reading) | MIT, Copyright (c) Kees van Spelde | https://github.com/Sicos1977/MSGReader | -| OpenMcdf (OLE compound files, used by MsgReader) | MPL-2.0 (source: the project page) | https://github.com/openmcdf/openmcdf | +| OpenMcdf (OLE compound files: MsgReader, and the macro check of DOC, XLS and PPT files) | MPL-2.0 (source: the project page) | https://github.com/openmcdf/openmcdf | | RtfPipe (RTF → HTML, used by MsgReader) | MIT, Copyright (c) 2018 Eric Domke | https://github.com/erdomke/RtfPipe | | UTF.Unknown (charset detection, used by MsgReader) | MPL-1.1 (source: the project page) | https://github.com/CharsetDetector/UTF-unknown | | Microsoft.Maui.Graphics, Microsoft.IO.RecyclableMemoryStream (used by MsgReader) | MIT | https://github.com/dotnet/maui, https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream | diff --git a/build/sign-checksums.ps1 b/build/sign-checksums.ps1 new file mode 100644 index 000000000..657bc5784 --- /dev/null +++ b/build/sign-checksums.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + Signs a release's SHA256SUMS files with Filee's update key, so copies built with FileeUpdatePublicKey accept them. + +.DESCRIPTION + The checksum file sits on the same release as the installer: someone who can replace one can replace the other, and + the SHA-256 check passes. A signature with a key kept outside GitHub can't be replaced with them. UpdateService + checks it when Directory.Build.props has FileeUpdatePublicKey (Linux and macOS have no Authenticode). + + Writes .sig next to each file: the ECDSA P-256 / SHA-256 signature of the file's exact bytes, base64 (DER). + The private key (PKCS#8 PEM) comes from -KeyFile, or the PEM text in $env:FILEE_UPDATE_SIGNING_KEY (a CI secret). + Signs only if possible: without a key it warns and writes nothing, unless -RequireSignature. + + -NewKey creates a key pair once: the private key goes to -KeyFile (keep it outside the repository, in a password + manager or CI secret), the public key is printed for FileeUpdatePublicKey. + +.EXAMPLE + pwsh build/sign-checksums.ps1 -NewKey -KeyFile ~/filee-update-key.pem + pwsh build/sign-checksums.ps1 -Path Releases/Filee-2.1.0-SHA256SUMS.txt -KeyFile ~/filee-update-key.pem +#> +[CmdletBinding(DefaultParameterSetName = 'Sign')] +param( + [Parameter(Mandatory, ParameterSetName = 'Sign')] + [string[]]$Path, + [string]$KeyFile = $env:FILEE_UPDATE_SIGNING_KEY_FILE, + [Parameter(ParameterSetName = 'Sign')] + [switch]$RequireSignature, + [Parameter(Mandatory, ParameterSetName = 'NewKey')] + [switch]$NewKey +) + +$ErrorActionPreference = 'Stop' + +if ($NewKey) { + if (-not $KeyFile) { throw 'Pass -KeyFile: where the private key is saved.' } + if (Test-Path $KeyFile) { throw "$KeyFile exists already; refusing to overwrite a key." } + $key = [System.Security.Cryptography.ECDsa]::Create([System.Security.Cryptography.ECCurve+NamedCurves]::nistP256) + try { + Set-Content -Path $KeyFile -Value $key.ExportPkcs8PrivateKeyPem() -NoNewline -Encoding ascii + Write-Host "private key: $KeyFile (keep it secret, outside the repository)" + Write-Host 'public key for Directory.Build.props :' + Write-Output ([Convert]::ToBase64String($key.ExportSubjectPublicKeyInfo())) + } + finally { + $key.Dispose() + } + return +} + +$pem = if ($KeyFile) { Get-Content -Raw $KeyFile } else { $env:FILEE_UPDATE_SIGNING_KEY } +if (-not $pem) { + if ($RequireSignature) { throw 'No update signing key: pass -KeyFile or set FILEE_UPDATE_SIGNING_KEY.' } + Write-Warning 'No update signing key (-KeyFile or FILEE_UPDATE_SIGNING_KEY): checksum files stay unsigned.' + return +} + +$key = [System.Security.Cryptography.ECDsa]::Create() +try { + $key.ImportFromPem($pem) + if ($key.KeySize -ne 256) { throw "The update key must be ECDSA P-256, not $($key.KeySize) bits." } + foreach ($file in $Path) { + $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $file).Path) + $signature = $key.SignData($bytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.DSASignatureFormat]::Rfc3279DerSequence) + Set-Content -Path "$file.sig" -Value ([Convert]::ToBase64String($signature)) -NoNewline -Encoding ascii + Write-Host "signed $(Split-Path $file -Leaf) -> $(Split-Path "$file.sig" -Leaf)" + } +} +finally { + $key.Dispose() +} diff --git a/build/sign-release.ps1 b/build/sign-release.ps1 index c7d324762..5799791a3 100644 --- a/build/sign-release.ps1 +++ b/build/sign-release.ps1 @@ -13,14 +13,20 @@ SHA-256 sums; 3. installs the signed Setup into a sandbox (nothing on the PC is touched) and checks that Filee.exe and uninstall.exe come out signed, then uninstalls it; - 4. uploads Filee--win-Setup.exe, the portable zip and Filee--SHA256SUMS.txt to the draft, and - with -Publish publishes the release. + 4. signs Filee--SHA256SUMS.txt with the update key when there is one (sign-checksums.ps1), so copies + built with FileeUpdatePublicKey accept the installer; + 5. uploads Filee--win-Setup.exe, the portable zip, Filee--SHA256SUMS.txt (and its .sig) to the + draft, and with -Publish publishes the release. + + The certificate is found as in sign-windows.ps1 (signing-certificate.ps1). Without one the script stops, unless + -AllowUnsigned says to publish the tested build unsigned ("sign only if possible"). Needs gh (signed in), git and the .NET SDK. Output: build/.cache/release-/out. .EXAMPLE pwsh build/sign-release.ps1 -Version 2.1.0 -CertificateThumbprint pwsh build/sign-release.ps1 -Version 2.1.0 -Publish + pwsh build/sign-release.ps1 -Version 2.1.0 -AllowUnsigned #> [CmdletBinding()] param( @@ -28,12 +34,22 @@ param( [string]$Version, # Defaults to $env:FILEE_SIGNING_CERT (see sign-windows.ps1). [string]$CertificateThumbprint = $env:FILEE_SIGNING_CERT, - [switch]$Publish + [switch]$Publish, + # Publish the tested build unsigned when there is no certificate, instead of stopping. + [switch]$AllowUnsigned ) $ErrorActionPreference = 'Stop' if (-not $IsWindows) { throw 'Windows releases are signed on Windows.' } -if (-not $CertificateThumbprint) { throw 'Pass -CertificateThumbprint or set FILEE_SIGNING_CERT.' } +. (Join-Path $PSScriptRoot 'signing-certificate.ps1') +$certificate = Find-FileeSigningCertificate -Thumbprint $CertificateThumbprint +if (-not $certificate) { + if (-not $AllowUnsigned) { + throw 'No code signing certificate: sign in to SimplySign Desktop, pass -CertificateThumbprint, or pass -AllowUnsigned to release unsigned.' + } + Write-Warning 'No code signing certificate: this release stays unsigned (SmartScreen and Smart App Control will warn).' +} +$signing = if ($certificate) { @{ CertificateThumbprint = $certificate.Thumbprint } } else { @{} } $root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Version = $Version.TrimStart('v') $tag = "v$Version" @@ -65,7 +81,7 @@ try { $out = Join-Path $work 'out' & (Join-Path $source 'build\build-installer.ps1') -Version $Version -Portable -OutputDir $out ` -PublishDir (Join-Path $work 'unsigned\app') -SetupProgram (Join-Path $work 'unsigned\Filee.Setup.exe') ` - -CertificateThumbprint $CertificateThumbprint + @signing } finally { git -C $root worktree remove --force $source @@ -75,6 +91,11 @@ $setup = Join-Path $out "Filee-$Version-win-Setup.exe" $files = @($setup, (Join-Path $out "Filee-$Version-win-Portable.zip"), (Join-Path $out "Filee-$Version-SHA256SUMS.txt")) foreach ($file in $files) { if (-not (Test-Path $file)) { throw "$file was not written." } } +# The checksum file's signature (UpdateService checks it in copies built with FileeUpdatePublicKey). +& (Join-Path $PSScriptRoot 'sign-checksums.ps1') -Path (Join-Path $out "Filee-$Version-SHA256SUMS.txt") +$signature = Join-Path $out "Filee-$Version-SHA256SUMS.txt.sig" +if (Test-Path $signature) { $files += $signature } + # The signed Setup still finds its payload, and what it installs is signed, the uninstaller too. $sandbox = Join-Path $work 'sandbox' function Invoke-Setup([string]$exe) { @@ -85,8 +106,10 @@ Write-Host 'install the signed Setup into a sandbox' Invoke-Setup $setup $installed = Join-Path $sandbox 'Program Files\Filee' foreach ($file in 'Filee.exe', 'filee-cli.exe', 'uninstall.exe', 'engines\rhwp\rhwp.exe') { - $signature = Get-AuthenticodeSignature (Join-Path $installed $file) - if ($signature.Status -ne 'Valid') { throw "$file is not signed after installing ($($signature.Status))." } + if (-not (Test-Path (Join-Path $installed $file))) { throw "$file is missing after installing." } + if (-not $certificate) { continue } + $authenticode = Get-AuthenticodeSignature (Join-Path $installed $file) + if ($authenticode.Status -ne 'Valid') { throw "$file is not signed after installing ($($authenticode.Status))." } } Invoke-Setup (Join-Path $installed 'uninstall.exe') Remove-Item $sandbox -Recurse -Force -ErrorAction SilentlyContinue diff --git a/build/sign-windows.ps1 b/build/sign-windows.ps1 index ee269cdca..37b5df78e 100644 --- a/build/sign-windows.ps1 +++ b/build/sign-windows.ps1 @@ -11,9 +11,16 @@ SimplySign phone app) before running this. signtool comes from the Microsoft.Windows.SDK.BuildTools NuGet package, downloaded once into build/.cache. + Signs only if possible: without a certificate (none named and none found, see signing-certificate.ps1) or off + Windows, it warns and leaves the files unsigned, so a build without the certificate still finishes. -RequireSignature + makes that an error. + .PARAMETER CertificateThumbprint SHA-1 thumbprint of the certificate ("Cert Hash(sha1)" in certutil -user -store My). Defaults to - $env:FILEE_SIGNING_CERT. + $env:FILEE_SIGNING_CERT; without one, the current user's store is searched. + +.PARAMETER RequireSignature + Fail instead of leaving the files unsigned when there is no certificate. .EXAMPLE pwsh build/sign-windows.ps1 -Path publish\win-x64 -CertificateThumbprint @@ -22,19 +29,22 @@ param( [Parameter(Mandatory)] [string[]]$Path, - [string]$CertificateThumbprint = $env:FILEE_SIGNING_CERT + [string]$CertificateThumbprint = $env:FILEE_SIGNING_CERT, + [switch]$RequireSignature ) $ErrorActionPreference = 'Stop' -if (-not $IsWindows) { throw 'Authenticode signing needs Windows.' } -if (-not $CertificateThumbprint) { throw 'Pass -CertificateThumbprint or set FILEE_SIGNING_CERT.' } -$CertificateThumbprint = $CertificateThumbprint -replace '\s', '' +. (Join-Path $PSScriptRoot 'signing-certificate.ps1') -$certificate = Get-Item "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction SilentlyContinue +$certificate = Find-FileeSigningCertificate -Thumbprint $CertificateThumbprint if (-not $certificate) { - throw "Certificate $CertificateThumbprint is not in the current user's store. Sign in to SimplySign Desktop first." + $why = if ($IsWindows) { 'no code signing certificate (sign in to SimplySign Desktop, or pass -CertificateThumbprint)' } else { 'Authenticode signing needs Windows' } + if ($RequireSignature) { throw "Can't sign: $why." } + Write-Warning "Not signing: $why. The programs stay unsigned (SmartScreen and Smart App Control may warn about them)." + return } -if ($certificate.NotAfter -lt (Get-Date)) { throw "Certificate $CertificateThumbprint expired on $($certificate.NotAfter)." } +$CertificateThumbprint = $certificate.Thumbprint +Write-Host "sign with $($certificate.Subject), valid until $($certificate.NotAfter.ToString('yyyy-MM-dd'))" # signtool from the Windows SDK build tools, pinned by version and SHA-256 like the engines (fetch-engines.ps1). $sdkVersion = '10.0.28000.2526' diff --git a/build/signing-certificate.ps1 b/build/signing-certificate.ps1 new file mode 100644 index 000000000..1438492a8 --- /dev/null +++ b/build/signing-certificate.ps1 @@ -0,0 +1,42 @@ +<# +.SYNOPSIS + Finds the code signing certificate the signing scripts use (dot-sourced by sign-windows.ps1 and sign-release.ps1). + +.DESCRIPTION + Find-FileeSigningCertificate returns the certificate to sign with, or $null when there is none, so a build can go on + unsigned ("sign only if possible"): + * -Thumbprint (or $env:FILEE_SIGNING_CERT) names it; it must be in the current user's store and not expired. + * Without one, the current user's store is searched for a valid code signing certificate with its private key + (SimplySign Desktop puts Certum's cloud certificate there once signed in). $env:FILEE_SIGNING_SUBJECT narrows it + down by subject when there are several. + A named certificate that is missing or expired is an error, not "unsigned": that is a mistake to fix, not a PC + without a certificate. +#> + +function Find-FileeSigningCertificate { + [CmdletBinding()] + param([string]$Thumbprint) + + if (-not $IsWindows) { return $null } + if ($Thumbprint) { + $Thumbprint = $Thumbprint -replace '\s', '' + $certificate = Get-Item "Cert:\CurrentUser\My\$Thumbprint" -ErrorAction SilentlyContinue + if (-not $certificate) { + throw "Certificate $Thumbprint is not in the current user's store. Sign in to SimplySign Desktop first." + } + if ($certificate.NotAfter -lt (Get-Date)) { throw "Certificate $Thumbprint expired on $($certificate.NotAfter)." } + return $certificate + } + + $codeSigning = '1.3.6.1.5.5.7.3.3' + $candidates = @(Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | Where-Object { + $_.HasPrivateKey -and $_.NotAfter -gt (Get-Date) -and $_.NotBefore -lt (Get-Date) -and + ($_.EnhancedKeyUsageList.ObjectId -contains $codeSigning) -and + (-not $env:FILEE_SIGNING_SUBJECT -or $_.Subject -like "*$($env:FILEE_SIGNING_SUBJECT)*") + }) + if ($candidates.Count -gt 1) { + $names = ($candidates | ForEach-Object { "$($_.Subject) ($($_.Thumbprint))" }) -join '; ' + throw "Several code signing certificates: $names. Pass -CertificateThumbprint or set FILEE_SIGNING_SUBJECT." + } + return $candidates | Select-Object -First 1 +} diff --git a/docs/RELEASE-SECURITY.md b/docs/RELEASE-SECURITY.md new file mode 100644 index 000000000..89aa9f412 --- /dev/null +++ b/docs/RELEASE-SECURITY.md @@ -0,0 +1,57 @@ +# Release and update security + +How a Filee release gets from a tag to a user's computer, what protects each step, and what is still open. The +workflows and scripts named here carry the details in their own comments. + +## What a release goes through + +1. **Build and test** (`.github/workflows/release.yml`, `portable.yml`): every action is pinned to a commit (a moved + tag can't change what runs), the workflow token can only read except in the job that drafts the release, and every + job has a time limit. +2. **Provenance and SBOM**: the packages, the tested Windows build and Setup get a build provenance attestation + (`gh attestation verify -R KnifeLemon/Filee` shows the workflow run that made them) and the release an SPDX + SBOM (`Filee--sbom.spdx.json`). +3. **Signing**: Windows programs are signed with Filee's Authenticode certificate on a PC signed in to Certum's + SimplySign (`build/sign-release.ps1`). The scripts find the certificate themselves and sign only if there is one: + without it they warn and the build stays unsigned (`-RequireSignature` / no `-AllowUnsigned` make that an error). +4. **Checksums**: every package is listed in a `SHA256SUMS` file. With the update key (secret + `FILEE_UPDATE_SIGNING_KEY`, or `-KeyFile` on the signing PC) the checksum files get a `.sig` (`build/sign-checksums.ps1`). + +## What the updater checks (`UpdateService`) + +| Check | When | +|---|---| +| SHA-256 of the installer against the release's checksum file | always | +| Signature of the checksum file with the update key | when the build has `FileeUpdatePublicKey` (`Directory.Build.props`); then a missing or wrong signature stops the update | +| Authenticode publisher of the installer = publisher of the running copy | signed copies | +| Authenticode publisher of the installer = `FileeExpectedPublisher` | copies that aren't signed themselves (built from source), when the build pins a publisher | + +The checksum file sits on the same release as the installer: whoever can replace one can replace the other. The two +signatures can't be replaced with them. Linux and macOS have no Authenticode, so there the checksum signature is the +only check beyond the SHA-256. + +### Turning the signatures on + +1. Create the update key once: `pwsh build/sign-checksums.ps1 -NewKey -KeyFile `. Keep the + private key in a password manager and as the repository secret `FILEE_UPDATE_SIGNING_KEY` (the PEM text). +2. Put the printed public key into `` and the certificate subject (as + `Get-AuthenticodeSignature` shows `SignerCertificate.Subject`) into ``. +3. From that release on, sign every checksum file. Copies built before step 2 keep checking only the SHA-256 (and + their Authenticode publisher), so nothing breaks for them. + +Losing the private key means a new key and a release that carries the new public key, signed with the old one. + +## Suggestions not done yet + +- **Sign in CI.** Signing on a PC means nobody can prove that the published Windows files are the tested ones. A + cloud signing service CI can call (for example Azure Trusted Signing, or Certum's SimplySign with a CI-capable + token) would sign in the same run that tests, and the attestation would cover the signed files. +- **Reproducible builds.** `ContinuousIntegrationBuild`, `Deterministic` and a pinned SDK (`global.json` already) let + anyone rebuild a release and compare it byte for byte. +- **Sign the Explorer menu package.** `FileeExplorerMenu.msix` is unsigned on purpose: its publisher carries the OID + Windows requires for unsigned packages, and its family name derives from that publisher. Signing it needs a new + publisher (the certificate's subject), a new family name, and Setup removing the old registration once; it might also + avoid the registration error 0x80073CF6 of #450, but that is not confirmed. +- **macOS notarization** (#265), so Gatekeeper accepts the app without a warning. +- **Branch protection** on `main` and required reviews for `.github/workflows` and `build/` (CODEOWNERS), so a + workflow change can't publish on its own. diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 688873860..b954ce9b0 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -283,6 +283,8 @@ private static void ApplySettings(AppSettings settings) AppHost.Get().Apply(settings.Theme, loc.Language, custom); AppHost.Get().Apply(settings); AppHost.Get().Priority = settings.EnginePriority; + // Settings → Security and limits: picture limits, files at once, step time limit, documents with active content. + SecurityAndLimits.Apply(settings, AppHost.Get()); if (ownCopiesChanged || routesChanged) { AppHost.Get().Refresh(); diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 3de1151ec..4ec66f123 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -9,6 +9,7 @@ "nav.watch": "مراقبة المجلدات", "nav.theme": "المظهر", "nav.engines": "المحركات", + "nav.security": "الأمان والحدود", "nav.about": "حول التطبيق", "tray.open": "فتح Filee", "tray.pause": "إيقاف الاختصارات مؤقتًا", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "لا يحتوي أي صف في CSV على موقع صالح للاستخدام", "error.geo_unsupported_shape": "هذا النوع من الأشكال غير مدعوم", "error.geo_too_large": "سيتجاوز ملف Shapefile حد الصيغة البالغ 2 غيغابايت", + "error.active_content_refused": "يحتوي المستند على وحدات ماكرو أو محتوى نشط آخر (الإعدادات ← الأمان والحدود)", + "error.conversion_timeout": "استغرق المحرك وقتًا طويلًا جدًا فتم إيقافه (الإعدادات ← الأمان والحدود)", + "error.image_too_large": "الصورة أكبر من الحد المضبوط في الإعدادات ← الأمان والحدود", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", "notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)", "notice.original_kept": "تم الاحتفاظ بالأصل وحُفظت النتيجة كنسخة", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "بعض الصفوف لا تحتوي على موقع صالح، فتم الاحتفاظ بها دون شكل هندسي", "notice.geo_field_names": "الحد الأقصى لأسماء حقول Shapefile هو 10 محارف: اختُصرت بعض الأسماء", "notice.geo_shapefile_split": "تحتفظ Shapefile بنوع واحد من الأشكال، لذا حُفظت النقاط والخطوط والمضلعات في ملفات منفصلة", + "notice.active_content": "يحتوي المستند على وحدات ماكرو أو محتوى نشط آخر، ولم يُشغَّل أي شيء منه", + "notice.active_content_skipped": "تم استبعاده: يحتوي المستند على وحدات ماكرو أو محتوى نشط آخر", "engine.reason.not_installed": "غير مثبّت", "engine.reason.error": "خطأ أثناء التحقق", "engine.reason.unsupported_platform": "غير متاح على هذا النظام", @@ -304,6 +310,20 @@ "preset.merge_pdf": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", "merge.order_hint": "حدّد ملفًا وانقله إلى أعلى أو أسفل. تُدمج الملفات بالترتيب من الأعلى إلى الأسفل. (‎Alt+↑ / Alt+↓‎)", + "active.title": "وحدات الماكرو والمحتوى النشط", + "active.summary": "ملفات تحتوي على وحدات ماكرو أو محتوى نشط آخر: {0}", + "active.hint": "لا يشغّلها Filee أبدًا: يفتح LibreOffice هذه الملفات مع إيقاف وحدات الماكرو ولا يحدّث الروابط. حوّلها فقط إذا كنت تثق بمصدرها.", + "active.remember": "لا تسأل مرة أخرى", + "active.skip": "بدون هذه الملفات", + "active.trust": "أثق بها، حوّلها", + "active.starts_by_itself": "يبدأ ماكرو تلقائيًا عند فتح المستند.", + "active.kind.macro": "ماكرو", + "active.kind.autorun": "يبدأ تلقائيًا", + "active.kind.script": "برنامج نصي", + "active.kind.activex": "عنصر تحكم ActiveX", + "active.kind.external_link": "رابط خارجي", + "active.kind.dde": "حقل DDE", + "active.kind.embedded": "كائن مضمَّن", "preset.group_pdf_edit": "تحرير", "preset.pdf_crop": "اقتصاص PDF", "preset.pdf_crop.short": "اقتصاص", @@ -371,6 +391,8 @@ "general.language": "اللغة", "general.language_auto": "لغة النظام", "general.start_with_system": "تشغيل Filee عند تسجيل الدخول", + "general.startup_disabled": "أوقف Windows تشغيل Filee عند تسجيل الدخول (إدارة المهام ← تطبيقات بدء التشغيل)، لذلك لا يبدأ Filee تلقائيًا.", + "general.startup_enable": "أعد تشغيله", "general.show_in_dock": "إظهار Filee في شريط Dock", "general.show_menu_bar_icon": "إظهار Filee في شريط القوائم", "general.dock_failed": "تعذّر تغيير أيقونة Filee في Dock.", @@ -504,6 +526,7 @@ "triggers.excluded_note": "لن تعمل الإيماءات والاختصارات عندما يكون المؤشر فوق هذه التطبيقات.", "triggers.paused": "الاختصارات متوقفة مؤقتًا", "toolbar.title": "شريط الأدوات الدائري", + "toolbar.keyboard_hint": "باستخدام لوحة المفاتيح: تختار الأسهم شريحة، ويحرّرها Enter، وينقلها Ctrl + سهم.", "toolbar.step_profile": "1. اختر ملف تعريف", "toolbar.step_arrange": "2. رتّب الإعدادات المسبقة", "toolbar.add_profile": "ملف تعريف جديد", @@ -876,6 +899,31 @@ "engines.state.unpacking": "جارٍ التثبيت…", "engines.state.installed": "مثبّت", "engines.state.failed": "فشل: {0}", + + "security.title": "الأمان والحدود", + "security.active_content": "مستندات تحتوي على وحدات ماكرو", + "security.active_content_hint": "قد تحتوي مستندات Office على وحدات ماكرو وعناصر تحكم ActiveX وروابط إلى ملفات أو عناوين خارجية وحقول DDE. يفحص Filee المستندات بحثًا عنها قبل أن يفتحها LibreOffice.", + "security.policy": "عندما يحتوي مستند على وحدات ماكرو أو محتوى نشط آخر", + "security.policy.Ask": "اسألني", + "security.policy.Ask_hint": "يعرض Filee ما وجده وأنت تقرر. لا تستطيع المجلدات المراقَبة وسطر الأوامر السؤال، فتحوّل المستند وتذكر النتيجة ما يحتويه.", + "security.policy.Convert": "حوّل دون سؤال", + "security.policy.Convert_hint": "تُحوَّل هذه المستندات مثل غيرها، وتذكر النتيجة ما تحتويه.", + "security.policy.Refuse": "لا تحوّلها أبدًا", + "security.policy.Refuse_hint": "لا تُفتح المستندات التي تحتوي على محتوى نشط، وتذكر النتيجة السبب.", + "security.never_run": "مهما اخترت، لا يشغّل Filee وحدات الماكرو أو البرامج النصية أبدًا: يحوّل LibreOffice مع إيقاف وحدات الماكرو ولا يحدّث الروابط.", + "security.images": "الصور", + "security.images_hint": "حدود للصور الكبيرة جدًا، مثل \"قنابل الصور\": ملفات صغيرة تتوسع إلى مليارات البكسلات.", + "security.max_megapixels": "أكبر صورة (ميغابكسل)", + "security.max_side": "أطول ضلع (بكسل)", + "security.max_memory": "الذاكرة للصور (ميغابايت)", + "security.max_disk": "مساحة القرص للصور (ميغابايت)", + "security.zero_auto": "0 = تلقائي: بدون حد خاص بـ Filee.", + "security.conversions": "التحويلات", + "security.parallel_files": "الملفات المحوَّلة في وقت واحد", + "security.parallel_hint": "0 = واحد لكل نواة معالج ({0} في هذا الكمبيوتر). العدد الأقل يوفّر الذاكرة مع الملفات الكبيرة.", + "security.step_timeout": "المهلة لكل خطوة (دقائق)", + "security.step_timeout_hint": "يتوقف المحرك المدمج الذي يستغرق وقتًا أطول، ويفشل الملف على أنه عالق. يحتفظ LibreOffice وFFmpeg والبرامج الأخرى بحدودها الخاصة. 0 = بلا حد.", + "security.reset_limits": "إعادة تعيين الحدود", "about.title": "حول التطبيق", "about.updates": "التحديثات", "common.search": "بحث", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 75d0ee92a..fd513414e 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -10,6 +10,7 @@ "nav.watch": "Watch folders", "nav.theme": "Theme", "nav.engines": "Engines", + "nav.security": "Security and limits", "nav.about": "About", "tray.open": "Open Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "No row of the CSV has a usable location", "error.geo_unsupported_shape": "This kind of shape isn't supported", "error.geo_too_large": "The Shapefile would pass the format's 2 GB limit", + "error.active_content_refused": "The document has macros or other active content (Settings → Security and limits)", + "error.conversion_timeout": "The engine took too long and was stopped (Settings → Security and limits)", + "error.image_too_large": "The picture is larger than the limit in Settings → Security and limits", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out", "notice.original_kept": "The original was kept, the result was saved as a copy", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "Some rows have no usable location and were kept without a shape", "notice.geo_field_names": "Shapefile field names are 10 characters at most: some were shortened", "notice.geo_shapefile_split": "A Shapefile holds one kind of shape, so points, lines and polygons were saved as separate files", + "notice.active_content": "The document has macros or other active content; nothing in it was run", + "notice.active_content_skipped": "Left out: the document has macros or other active content", "engine.reason.not_installed": "Not installed", "engine.reason.error": "Error while checking", @@ -314,6 +320,20 @@ "preset.merge_pdf": "Merge PDF", "merge.order_title": "Choose merge order", "merge.order_hint": "Select a file and move it up or down. Files are merged from top to bottom. (Alt+↑ / Alt+↓)", + "active.title": "Macros and active content", + "active.summary": "Files with macros or other active content: {0}", + "active.hint": "Filee never runs them: LibreOffice opens these files with macros switched off and doesn't update links. Convert them only if you trust where they come from.", + "active.remember": "Don't ask again", + "active.skip": "Without these files", + "active.trust": "Trust and convert", + "active.starts_by_itself": "A macro starts by itself when the document opens.", + "active.kind.macro": "Macro", + "active.kind.autorun": "Starts by itself", + "active.kind.script": "Script", + "active.kind.activex": "ActiveX control", + "active.kind.external_link": "External link", + "active.kind.dde": "DDE field", + "active.kind.embedded": "Embedded object", "preset.group_pdf_edit": "Edit", "preset.pdf_crop": "Crop PDF", "preset.pdf_crop.short": "Crop", @@ -383,6 +403,8 @@ "general.language": "Language", "general.language_auto": "System default", "general.start_with_system": "Start Filee when I sign in", + "general.startup_disabled": "Windows has turned off starting Filee at sign-in (Task Manager → Startup apps), so Filee doesn't start by itself.", + "general.startup_enable": "Turn it on again", "general.show_in_dock": "Show Filee in the Dock", "general.show_menu_bar_icon": "Show Filee in the menu bar", "general.dock_failed": "Could not change Filee's Dock icon.", @@ -518,6 +540,7 @@ "triggers.paused": "Shortcuts are paused", "toolbar.title": "Donut toolbar", + "toolbar.keyboard_hint": "With the keyboard: arrows choose a slice, Enter edits it, Ctrl + arrow moves it.", "toolbar.step_profile": "1. Choose a profile", "toolbar.step_arrange": "2. Arrange presets", "toolbar.add_profile": "New profile", @@ -894,6 +917,31 @@ "engines.state.installed": "Installed", "engines.state.failed": "Failed: {0}", + "security.title": "Security and limits", + "security.active_content": "Documents with macros", + "security.active_content_hint": "Office documents can carry macros, ActiveX controls, links to outside files or addresses and DDE fields. Filee checks documents for them before LibreOffice opens them.", + "security.policy": "When a document has macros or other active content", + "security.policy.Ask": "Ask me", + "security.policy.Ask_hint": "Filee shows what it found and you decide. Watch folders and the command line can't ask: they convert, and the result lists what the document carries.", + "security.policy.Convert": "Convert without asking", + "security.policy.Convert_hint": "Such documents convert like any other. The result still lists what they carry.", + "security.policy.Refuse": "Never convert them", + "security.policy.Refuse_hint": "Documents with active content are not opened. Their result says why.", + "security.never_run": "Whatever you choose, Filee never runs macros or scripts: LibreOffice converts with macros switched off and doesn't update links.", + "security.images": "Pictures", + "security.images_hint": "Limits for very large pictures, such as \"image bombs\": tiny files that unpack to billions of pixels.", + "security.max_megapixels": "Largest picture (megapixels)", + "security.max_side": "Longest side (pixels)", + "security.max_memory": "Memory for pictures (MB)", + "security.max_disk": "Disk space for pictures (MB)", + "security.zero_auto": "0 = automatic: no limit of Filee's own.", + "security.conversions": "Conversions", + "security.parallel_files": "Files converted at once", + "security.parallel_hint": "0 = one per processor core ({0} on this computer). Fewer saves memory with large files.", + "security.step_timeout": "Time limit per step (minutes)", + "security.step_timeout_hint": "A built-in engine that takes longer stops, and the file fails as stuck. LibreOffice, FFmpeg and other programs keep their own limits. 0 = no limit.", + "security.reset_limits": "Reset limits", + "about.title": "About", "about.updates": "Updates", "common.search": "Search", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index c074a17f9..bc4032e5e 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -9,6 +9,7 @@ "nav.watch": "Carpetas supervisadas", "nav.theme": "Tema", "nav.engines": "Motores", + "nav.security": "Seguridad y límites", "nav.about": "Acerca de", "tray.open": "Abrir Filee", "tray.pause": "Pausar atajos", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "Ninguna fila del CSV tiene una ubicación utilizable", "error.geo_unsupported_shape": "Este tipo de forma no es compatible", "error.geo_too_large": "El Shapefile superaría el límite de 2 GB del formato", + "error.active_content_refused": "El documento tiene macros u otro contenido activo (Ajustes → Seguridad y límites)", + "error.conversion_timeout": "El motor tardó demasiado y se detuvo (Ajustes → Seguridad y límites)", + "error.image_too_large": "La imagen supera el límite de Ajustes → Seguridad y límites", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", "notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)", "notice.original_kept": "Se conservó el original, el resultado se guardó como copia", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "Las filas sin ubicación utilizable se guardaron sin forma", "notice.geo_field_names": "Los nombres de campo de Shapefile tienen 10 caracteres como máximo: se acortaron algunos", "notice.geo_shapefile_split": "Un Shapefile guarda un solo tipo de forma, así que puntos, líneas y polígonos se guardaron en archivos separados", + "notice.active_content": "El documento tiene macros u otro contenido activo; no se ejecutó nada", + "notice.active_content_skipped": "Omitido: el documento tiene macros u otro contenido activo", "engine.reason.not_installed": "No instalado", "engine.reason.error": "Error al comprobar", "engine.reason.unsupported_platform": "No disponible en esta plataforma", @@ -304,6 +310,20 @@ "preset.merge_pdf": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", "merge.order_hint": "Selecciona un archivo y muévelo arriba o abajo. Los archivos se combinan de arriba abajo. (Alt+↑ / Alt+↓)", + "active.title": "Macros y contenido activo", + "active.summary": "Archivos con macros u otro contenido activo: {0}", + "active.hint": "Filee nunca los ejecuta: LibreOffice abre estos archivos con las macros desactivadas y no actualiza los vínculos. Conviértelos solo si confías en su origen.", + "active.remember": "No volver a preguntar", + "active.skip": "Sin estos archivos", + "active.trust": "Confiar y convertir", + "active.starts_by_itself": "Una macro se inicia sola al abrir el documento.", + "active.kind.macro": "Macro", + "active.kind.autorun": "Se inicia sola", + "active.kind.script": "Script", + "active.kind.activex": "Control ActiveX", + "active.kind.external_link": "Vínculo externo", + "active.kind.dde": "Campo DDE", + "active.kind.embedded": "Objeto incrustado", "preset.group_pdf_edit": "Editar", "preset.pdf_crop": "Recortar PDF", "preset.pdf_crop.short": "Recortar", @@ -371,6 +391,8 @@ "general.language": "Idioma", "general.language_auto": "Predeterminado del sistema", "general.start_with_system": "Iniciar Filee al iniciar sesión", + "general.startup_disabled": "Windows desactivó el inicio de Filee al iniciar sesión (Administrador de tareas → Aplicaciones de arranque), así que Filee no se inicia solo.", + "general.startup_enable": "Volver a activarlo", "general.show_in_dock": "Mostrar Filee en el Dock", "general.show_menu_bar_icon": "Mostrar Filee en la barra de menús", "general.dock_failed": "No se pudo cambiar el icono de Filee en el Dock.", @@ -504,6 +526,7 @@ "triggers.excluded_note": "Los gestos y los atajos no hacen nada mientras el puntero esté sobre estas apps.", "triggers.paused": "Los atajos están en pausa", "toolbar.title": "Menú circular", + "toolbar.keyboard_hint": "Con el teclado: las flechas eligen una porción, Intro la edita y Ctrl + flecha la mueve.", "toolbar.step_profile": "1. Elige un perfil", "toolbar.step_arrange": "2. Coloca los ajustes preestablecidos", "toolbar.add_profile": "Nuevo perfil", @@ -876,6 +899,31 @@ "engines.state.unpacking": "Instalando…", "engines.state.installed": "Instalado", "engines.state.failed": "Error: {0}", + + "security.title": "Seguridad y límites", + "security.active_content": "Documentos con macros", + "security.active_content_hint": "Los documentos de Office pueden contener macros, controles ActiveX, vínculos a archivos o direcciones externos y campos DDE. Filee los busca antes de que LibreOffice abra el documento.", + "security.policy": "Cuando un documento tiene macros u otro contenido activo", + "security.policy.Ask": "Preguntarme", + "security.policy.Ask_hint": "Filee muestra lo que encontró y tú decides. Las carpetas vigiladas y la línea de comandos no pueden preguntar: convierten, y el resultado indica lo que contiene el documento.", + "security.policy.Convert": "Convertir sin preguntar", + "security.policy.Convert_hint": "Estos documentos se convierten como cualquier otro. El resultado sigue indicando lo que contienen.", + "security.policy.Refuse": "No convertirlos nunca", + "security.policy.Refuse_hint": "Los documentos con contenido activo no se abren. El resultado explica por qué.", + "security.never_run": "Elijas lo que elijas, Filee nunca ejecuta macros ni scripts: LibreOffice convierte con las macros desactivadas y no actualiza los vínculos.", + "security.images": "Imágenes", + "security.images_hint": "Límites para imágenes muy grandes, como las \"bombas de imagen\": archivos diminutos que se descomprimen en miles de millones de píxeles.", + "security.max_megapixels": "Imagen más grande (megapíxeles)", + "security.max_side": "Lado más largo (píxeles)", + "security.max_memory": "Memoria para imágenes (MB)", + "security.max_disk": "Espacio en disco para imágenes (MB)", + "security.zero_auto": "0 = automático: sin límite propio de Filee.", + "security.conversions": "Conversiones", + "security.parallel_files": "Archivos convertidos a la vez", + "security.parallel_hint": "0 = uno por núcleo del procesador ({0} en este equipo). Menos ahorra memoria con archivos grandes.", + "security.step_timeout": "Tiempo máximo por paso (minutos)", + "security.step_timeout_hint": "Un motor integrado que tarde más se detiene y el archivo falla como bloqueado. LibreOffice, FFmpeg y otros programas mantienen sus propios límites. 0 = sin límite.", + "security.reset_limits": "Restablecer límites", "about.title": "Acerca de", "about.updates": "Actualizaciones", "common.search": "Buscar", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 68101164f..2a5b65d56 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -10,6 +10,7 @@ "nav.watch": "Dossiers surveillés", "nav.theme": "Thème", "nav.engines": "Moteurs", + "nav.security": "Sécurité et limites", "nav.about": "À propos", "tray.open": "Ouvrir Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "Aucune ligne du CSV n'a d'emplacement exploitable", "error.geo_unsupported_shape": "Ce type de forme n'est pas pris en charge", "error.geo_too_large": "Le Shapefile dépasserait la limite de 2 Go du format", + "error.active_content_refused": "Le document contient des macros ou un autre contenu actif (Paramètres → Sécurité et limites)", + "error.conversion_timeout": "Le moteur a mis trop de temps et a été arrêté (Paramètres → Sécurité et limites)", + "error.image_too_large": "L'image dépasse la limite fixée dans Paramètres → Sécurité et limites", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", "notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis", "notice.original_kept": "L'original a été conservé, le résultat a été enregistré en copie", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "Certaines lignes sans emplacement exploitable ont été conservées sans forme", "notice.geo_field_names": "Les noms de champs Shapefile font 10 caractères au maximum : certains ont été raccourcis", "notice.geo_shapefile_split": "Un Shapefile ne contient qu'un type de forme : les points, lignes et polygones ont été enregistrés dans des fichiers distincts", + "notice.active_content": "Le document contient des macros ou un autre contenu actif ; rien n'a été exécuté", + "notice.active_content_skipped": "Ignoré : le document contient des macros ou un autre contenu actif", "engine.reason.not_installed": "Non installé", "engine.reason.error": "Erreur lors de la vérification", @@ -314,6 +320,20 @@ "preset.merge_pdf": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", "merge.order_hint": "Sélectionnez un fichier et déplacez-le vers le haut ou le bas. Les fichiers sont fusionnés de haut en bas. (Alt+↑ / Alt+↓)", + "active.title": "Macros et contenu actif", + "active.summary": "Fichiers avec des macros ou un autre contenu actif : {0}", + "active.hint": "Filee ne les exécute jamais : LibreOffice ouvre ces fichiers avec les macros désactivées et ne met pas à jour les liens. Ne les convertissez que si vous faites confiance à leur provenance.", + "active.remember": "Ne plus demander", + "active.skip": "Sans ces fichiers", + "active.trust": "Faire confiance et convertir", + "active.starts_by_itself": "Une macro démarre d'elle-même à l'ouverture du document.", + "active.kind.macro": "Macro", + "active.kind.autorun": "Démarre seule", + "active.kind.script": "Script", + "active.kind.activex": "Contrôle ActiveX", + "active.kind.external_link": "Lien externe", + "active.kind.dde": "Champ DDE", + "active.kind.embedded": "Objet incorporé", "preset.group_pdf_edit": "Modifier", "preset.pdf_crop": "Rogner le PDF", "preset.pdf_crop.short": "Rogner", @@ -383,6 +403,8 @@ "general.language": "Langue", "general.language_auto": "Langue du système", "general.start_with_system": "Lancer Filee à l'ouverture de session", + "general.startup_disabled": "Windows a désactivé le démarrage de Filee à l'ouverture de session (Gestionnaire des tâches → Applications de démarrage) : Filee ne démarre donc pas tout seul.", + "general.startup_enable": "Le réactiver", "general.show_in_dock": "Afficher Filee dans le Dock", "general.show_menu_bar_icon": "Afficher Filee dans la barre des menus", "general.dock_failed": "Impossible de modifier l'icône de Filee dans le Dock.", @@ -518,6 +540,7 @@ "triggers.paused": "Raccourcis en pause", "toolbar.title": "Menu circulaire", + "toolbar.keyboard_hint": "Au clavier : les flèches choisissent une part, Entrée la modifie, Ctrl + flèche la déplace.", "toolbar.step_profile": "1. Choisir un profil", "toolbar.step_arrange": "2. Organiser les préréglages", "toolbar.add_profile": "Nouveau profil", @@ -894,6 +917,31 @@ "engines.state.installed": "Installé", "engines.state.failed": "Échec : {0}", + "security.title": "Sécurité et limites", + "security.active_content": "Documents avec des macros", + "security.active_content_hint": "Les documents Office peuvent contenir des macros, des contrôles ActiveX, des liens vers des fichiers ou adresses externes et des champs DDE. Filee les recherche avant que LibreOffice n'ouvre le document.", + "security.policy": "Quand un document contient des macros ou un autre contenu actif", + "security.policy.Ask": "Me demander", + "security.policy.Ask_hint": "Filee montre ce qu'il a trouvé et vous décidez. Les dossiers surveillés et la ligne de commande ne peuvent pas demander : ils convertissent, et le résultat indique ce que contient le document.", + "security.policy.Convert": "Convertir sans demander", + "security.policy.Convert_hint": "Ces documents sont convertis comme les autres. Le résultat indique toujours ce qu'ils contiennent.", + "security.policy.Refuse": "Ne jamais les convertir", + "security.policy.Refuse_hint": "Les documents avec du contenu actif ne sont pas ouverts. Le résultat explique pourquoi.", + "security.never_run": "Quel que soit votre choix, Filee n'exécute jamais de macros ni de scripts : LibreOffice convertit avec les macros désactivées et ne met pas à jour les liens.", + "security.images": "Images", + "security.images_hint": "Limites pour les très grandes images, comme les « bombes d'image » : de tout petits fichiers qui se décompressent en milliards de pixels.", + "security.max_megapixels": "Plus grande image (mégapixels)", + "security.max_side": "Plus grand côté (pixels)", + "security.max_memory": "Mémoire pour les images (Mo)", + "security.max_disk": "Espace disque pour les images (Mo)", + "security.zero_auto": "0 = automatique : aucune limite propre à Filee.", + "security.conversions": "Conversions", + "security.parallel_files": "Fichiers convertis en même temps", + "security.parallel_hint": "0 = un par cœur de processeur ({0} sur cet ordinateur). Moins économise la mémoire avec de gros fichiers.", + "security.step_timeout": "Délai par étape (minutes)", + "security.step_timeout_hint": "Un moteur intégré qui prend plus de temps s'arrête et le fichier échoue comme bloqué. LibreOffice, FFmpeg et les autres programmes gardent leurs propres limites. 0 = aucune limite.", + "security.reset_limits": "Réinitialiser les limites", + "about.title": "À propos", "about.updates": "Mises à jour", "common.search": "Rechercher", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 957153f43..9c23e3629 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -9,6 +9,7 @@ "nav.watch": "वॉच फ़ोल्डर", "nav.theme": "थीम", "nav.engines": "इंजन", + "nav.security": "सुरक्षा और सीमाएँ", "nav.about": "परिचय", "tray.open": "Filee खोलें", "tray.pause": "शॉर्टकट रोकें", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "CSV की किसी भी पंक्ति में इस्तेमाल लायक स्थान नहीं है", "error.geo_unsupported_shape": "इस तरह का आकार समर्थित नहीं है", "error.geo_too_large": "Shapefile फ़ॉर्मैट की 2 GB सीमा से बड़ी हो जाएगी", + "error.active_content_refused": "दस्तावेज़ में मैक्रो या अन्य सक्रिय सामग्री है (सेटिंग्स → सुरक्षा और सीमाएँ)", + "error.conversion_timeout": "इंजन ने बहुत समय लिया और उसे रोक दिया गया (सेटिंग्स → सुरक्षा और सीमाएँ)", + "error.image_too_large": "चित्र सेटिंग्स → सुरक्षा और सीमाएँ में तय सीमा से बड़ा है", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", "notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए", "notice.original_kept": "मूल फ़ाइल रखी गई, परिणाम कॉपी के रूप में सहेजा गया", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "कुछ पंक्तियों में इस्तेमाल लायक स्थान नहीं है, उन्हें बिना आकार के रखा गया", "notice.geo_field_names": "Shapefile फ़ील्ड के नाम ज़्यादा से ज़्यादा 10 अक्षरों के होते हैं: कुछ नाम छोटे किए गए", "notice.geo_shapefile_split": "Shapefile में एक ही तरह का आकार रहता है, इसलिए पॉइंट, लाइन और पॉलीगॉन अलग-अलग फ़ाइलों में सहेजे गए", + "notice.active_content": "दस्तावेज़ में मैक्रो या अन्य सक्रिय सामग्री है; उसमें से कुछ भी चलाया नहीं गया", + "notice.active_content_skipped": "छोड़ दिया गया: दस्तावेज़ में मैक्रो या अन्य सक्रिय सामग्री है", "engine.reason.not_installed": "इंस्टॉल नहीं है", "engine.reason.error": "जाँच के दौरान त्रुटि", "engine.reason.unsupported_platform": "इस प्लेटफ़ॉर्म पर उपलब्ध नहीं है", @@ -304,6 +310,20 @@ "preset.merge_pdf": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", "merge.order_hint": "फ़ाइल चुनकर उसे ऊपर या नीचे ले जाएँ। फ़ाइलें ऊपर से नीचे के क्रम में मर्ज होंगी। (Alt+↑ / Alt+↓)", + "active.title": "मैक्रो और सक्रिय सामग्री", + "active.summary": "मैक्रो या अन्य सक्रिय सामग्री वाली फ़ाइलें: {0}", + "active.hint": "Filee इन्हें कभी नहीं चलाता: LibreOffice इन फ़ाइलों को मैक्रो बंद करके खोलता है और लिंक अपडेट नहीं करता। इन्हें तभी बदलें जब आपको इनके स्रोत पर भरोसा हो।", + "active.remember": "फिर से न पूछें", + "active.skip": "इन फ़ाइलों के बिना", + "active.trust": "भरोसा करें और बदलें", + "active.starts_by_itself": "दस्तावेज़ खुलते ही एक मैक्रो अपने-आप शुरू होता है।", + "active.kind.macro": "मैक्रो", + "active.kind.autorun": "अपने-आप शुरू", + "active.kind.script": "स्क्रिप्ट", + "active.kind.activex": "ActiveX नियंत्रण", + "active.kind.external_link": "बाहरी लिंक", + "active.kind.dde": "DDE फ़ील्ड", + "active.kind.embedded": "एम्बेड की गई वस्तु", "preset.group_pdf_edit": "संपादित करें", "preset.pdf_crop": "PDF क्रॉप करें", "preset.pdf_crop.short": "क्रॉप", @@ -371,6 +391,8 @@ "general.language": "भाषा", "general.language_auto": "सिस्टम डिफ़ॉल्ट", "general.start_with_system": "साइन इन करने पर Filee शुरू करें", + "general.startup_disabled": "Windows ने साइन-इन पर Filee शुरू होना बंद कर दिया है (टास्क मैनेजर → स्टार्टअप ऐप्स), इसलिए Filee अपने-आप शुरू नहीं होता।", + "general.startup_enable": "फिर से चालू करें", "general.show_in_dock": "Dock में Filee दिखाएँ", "general.show_menu_bar_icon": "मेनू बार में Filee दिखाएँ", "general.dock_failed": "Dock में Filee का आइकन नहीं बदला जा सका।", @@ -504,6 +526,7 @@ "triggers.excluded_note": "पॉइंटर इन ऐप्स पर होने पर जेस्चर और शॉर्टकट कुछ नहीं करते।", "triggers.paused": "शॉर्टकट रुके हुए हैं", "toolbar.title": "डोनट टूलबार", + "toolbar.keyboard_hint": "कीबोर्ड से: तीर कुंजियाँ एक हिस्सा चुनती हैं, Enter उसे संपादित करता है, Ctrl + तीर उसे खिसकाता है।", "toolbar.step_profile": "1. प्रोफ़ाइल चुनें", "toolbar.step_arrange": "2. प्रीसेट व्यवस्थित करें", "toolbar.add_profile": "नई प्रोफ़ाइल", @@ -876,6 +899,31 @@ "engines.state.unpacking": "इंस्टॉल हो रहा है…", "engines.state.installed": "इंस्टॉल हो गया", "engines.state.failed": "विफल: {0}", + + "security.title": "सुरक्षा और सीमाएँ", + "security.active_content": "मैक्रो वाले दस्तावेज़", + "security.active_content_hint": "Office दस्तावेज़ों में मैक्रो, ActiveX नियंत्रण, बाहरी फ़ाइलों या पतों के लिंक और DDE फ़ील्ड हो सकते हैं। LibreOffice के खोलने से पहले Filee दस्तावेज़ों में इनकी जाँच करता है।", + "security.policy": "जब किसी दस्तावेज़ में मैक्रो या अन्य सक्रिय सामग्री हो", + "security.policy.Ask": "मुझसे पूछें", + "security.policy.Ask_hint": "Filee जो मिला वह दिखाता है और फ़ैसला आप करते हैं। निगरानी वाले फ़ोल्डर और कमांड लाइन पूछ नहीं सकते: वे बदल देते हैं और परिणाम में बताया जाता है कि दस्तावेज़ में क्या है।", + "security.policy.Convert": "बिना पूछे बदलें", + "security.policy.Convert_hint": "ऐसे दस्तावेज़ बाकी की तरह बदले जाते हैं। परिणाम में फिर भी बताया जाता है कि उनमें क्या है।", + "security.policy.Refuse": "इन्हें कभी न बदलें", + "security.policy.Refuse_hint": "सक्रिय सामग्री वाले दस्तावेज़ खोले नहीं जाते। परिणाम में कारण बताया जाता है।", + "security.never_run": "आप जो भी चुनें, Filee कभी मैक्रो या स्क्रिप्ट नहीं चलाता: LibreOffice मैक्रो बंद करके बदलता है और लिंक अपडेट नहीं करता।", + "security.images": "चित्र", + "security.images_hint": "बहुत बड़े चित्रों की सीमाएँ, जैसे \"इमेज बम\": छोटी फ़ाइलें जो खुलकर अरबों पिक्सेल बन जाती हैं।", + "security.max_megapixels": "सबसे बड़ा चित्र (मेगापिक्सेल)", + "security.max_side": "सबसे लंबी भुजा (पिक्सेल)", + "security.max_memory": "चित्रों के लिए मेमोरी (MB)", + "security.max_disk": "चित्रों के लिए डिस्क स्थान (MB)", + "security.zero_auto": "0 = स्वचालित: Filee की अपनी कोई सीमा नहीं।", + "security.conversions": "रूपांतरण", + "security.parallel_files": "एक साथ बदली जाने वाली फ़ाइलें", + "security.parallel_hint": "0 = हर प्रोसेसर कोर पर एक (इस कंप्यूटर पर {0})। कम रखने से बड़ी फ़ाइलों में मेमोरी बचती है।", + "security.step_timeout": "हर चरण की समय-सीमा (मिनट)", + "security.step_timeout_hint": "जो बिल्ट-इन इंजन इससे ज़्यादा समय ले, वह रुक जाता है और फ़ाइल अटकी हुई मानकर विफल होती है। LibreOffice, FFmpeg और दूसरे प्रोग्राम अपनी सीमाएँ रखते हैं। 0 = कोई सीमा नहीं।", + "security.reset_limits": "सीमाएँ रीसेट करें", "about.title": "परिचय", "about.updates": "अपडेट", "common.search": "खोजें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5dcc03f93..04de4e72f 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -9,6 +9,7 @@ "nav.watch": "監視フォルダー", "nav.theme": "テーマ", "nav.engines": "変換エンジン", + "nav.security": "セキュリティと制限", "nav.about": "Fileeについて", "tray.open": "Fileeを開く", "tray.pause": "ショートカットを一時停止", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "CSVに使える位置情報のある行がありません", "error.geo_unsupported_shape": "この種類の図形には対応していません", "error.geo_too_large": "Shapefile形式の上限(2 GB)を超えてしまいます", + "error.active_content_refused": "文書にマクロなどのアクティブコンテンツが含まれています(「設定」→「セキュリティと制限」)", + "error.conversion_timeout": "エンジンの処理に時間がかかりすぎたため停止しました(「設定」→「セキュリティと制限」)", + "error.image_too_large": "画像が「設定」→「セキュリティと制限」の上限を超えています", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", "notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました", "notice.original_kept": "元のファイルを残し、結果をコピーとして保存しました", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "使える位置情報のない行は、図形なしで残しました", "notice.geo_field_names": "Shapefileのフィールド名は最大10文字です。一部の名前を短くしました", "notice.geo_shapefile_split": "Shapefileには1種類の図形しか入らないため、ポイント、ライン、ポリゴンを別々のファイルに保存しました", + "notice.active_content": "文書にマクロなどのアクティブコンテンツが含まれています。何も実行していません", + "notice.active_content_skipped": "除外しました:文書にマクロなどのアクティブコンテンツが含まれています", "engine.reason.not_installed": "未インストール", "engine.reason.error": "確認中にエラーが発生しました", "engine.reason.unsupported_platform": "このプラットフォームでは利用できません", @@ -304,6 +310,20 @@ "preset.merge_pdf": "結合", "merge.order_title": "結合順序を選択", "merge.order_hint": "ファイルを選択して上下に移動します。上から順に結合されます。(Alt+↑ / Alt+↓)", + "active.title": "マクロとアクティブコンテンツ", + "active.summary": "マクロなどのアクティブコンテンツを含むファイル:{0}", + "active.hint": "Filee がこれらを実行することはありません。LibreOffice はマクロを無効にしてファイルを開き、リンクも更新しません。入手元を信頼できる場合にだけ変換してください。", + "active.remember": "今後は確認しない", + "active.skip": "これらを除外", + "active.trust": "信頼して変換", + "active.starts_by_itself": "文書を開くとマクロが自動で起動します。", + "active.kind.macro": "マクロ", + "active.kind.autorun": "自動起動", + "active.kind.script": "スクリプト", + "active.kind.activex": "ActiveX コントロール", + "active.kind.external_link": "外部リンク", + "active.kind.dde": "DDE フィールド", + "active.kind.embedded": "埋め込みオブジェクト", "preset.group_pdf_edit": "編集", "preset.pdf_crop": "PDF をトリミング", "preset.pdf_crop.short": "トリミング", @@ -371,6 +391,8 @@ "general.language": "言語", "general.language_auto": "システム設定に合わせる", "general.start_with_system": "サインイン時にFileeを起動", + "general.startup_disabled": "Windows がサインイン時の Filee の起動を無効にしています(タスク マネージャー →「スタートアップ アプリ」)。そのため Filee は自動で起動しません。", + "general.startup_enable": "再び有効にする", "general.show_in_dock": "DockにFileeを表示", "general.show_menu_bar_icon": "メニューバーにFileeを表示", "general.dock_failed": "DockのFileeアイコンを変更できませんでした。", @@ -504,6 +526,7 @@ "triggers.excluded_note": "ポインターがこれらのアプリ上にある間、ジェスチャーとショートカットは動作しません。", "triggers.paused": "ショートカットは一時停止中です", "toolbar.title": "ドーナツメニュー", + "toolbar.keyboard_hint": "キーボードでは、矢印キーで項目を選び、Enter で編集、Ctrl + 矢印キーで移動します。", "toolbar.step_profile": "1. プロファイルを選択", "toolbar.step_arrange": "2. プリセットを配置", "toolbar.add_profile": "新しいプロファイル", @@ -876,6 +899,31 @@ "engines.state.unpacking": "インストール中…", "engines.state.installed": "インストール済み", "engines.state.failed": "失敗:{0}", + + "security.title": "セキュリティと制限", + "security.active_content": "マクロを含む文書", + "security.active_content_hint": "Office 文書にはマクロ、ActiveX コントロール、外部ファイルやアドレスへのリンク、DDE フィールドが含まれることがあります。Filee は LibreOffice が文書を開く前にこれらを確認します。", + "security.policy": "文書にマクロなどのアクティブコンテンツがある場合", + "security.policy.Ask": "確認する", + "security.policy.Ask_hint": "見つかった内容を表示し、どうするかを選べます。監視フォルダーとコマンドラインは確認できないため変換を行い、結果に含まれていた内容を表示します。", + "security.policy.Convert": "確認せずに変換", + "security.policy.Convert_hint": "ほかの文書と同じように変換します。結果には含まれていた内容が表示されます。", + "security.policy.Refuse": "変換しない", + "security.policy.Refuse_hint": "アクティブコンテンツを含む文書は開きません。結果に理由が表示されます。", + "security.never_run": "どれを選んでも、Filee がマクロやスクリプトを実行することはありません。LibreOffice はマクロを無効にして変換し、リンクも更新しません。", + "security.images": "画像", + "security.images_hint": "非常に大きな画像の上限です。たとえば「画像爆弾」は、展開すると数十億ピクセルになる小さなファイルです。", + "security.max_megapixels": "最大画像サイズ(メガピクセル)", + "security.max_side": "長辺の最大値(ピクセル)", + "security.max_memory": "画像用メモリ(MB)", + "security.max_disk": "画像用ディスク容量(MB)", + "security.zero_auto": "0 = 自動(Filee 独自の上限なし)", + "security.conversions": "変換", + "security.parallel_files": "同時に変換するファイル数", + "security.parallel_hint": "0 = プロセッサコアごとに 1 つ(このコンピューターでは {0})。少なくすると大きなファイルでメモリを節約できます。", + "security.step_timeout": "ステップごとの制限時間(分)", + "security.step_timeout_hint": "これより長くかかった組み込みエンジンは停止し、ファイルは処理が止まったものとして失敗します。LibreOffice や FFmpeg などのプログラムは独自の制限を使います。0 = 制限なし。", + "security.reset_limits": "制限を元に戻す", "about.title": "Fileeについて", "about.updates": "更新", "common.search": "検索", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 5e899b549..3d3498369 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -10,6 +10,7 @@ "nav.watch": "자동 변환 폴더", "nav.theme": "테마", "nav.engines": "변환 엔진", + "nav.security": "보안 및 제한", "nav.about": "정보", "tray.open": "Filee 열기", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "위치를 읽을 수 있는 행이 하나도 없어요", "error.geo_unsupported_shape": "지원하지 않는 도형 종류예요", "error.geo_too_large": "Shapefile의 한계인 2GB를 넘어요", + "error.active_content_refused": "문서에 매크로 같은 활성 콘텐츠가 있어요 (설정 → 보안 및 제한)", + "error.conversion_timeout": "엔진이 너무 오래 걸려서 멈췄어요 (설정 → 보안 및 제한)", + "error.image_too_large": "그림이 설정 → 보안 및 제한의 한도보다 커요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요", "notice.original_kept": "원본은 그대로 두고 결과를 사본으로 저장했어요", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "위치를 읽을 수 없는 행은 도형 없이 저장했어요", "notice.geo_field_names": "Shapefile 필드 이름은 10자까지라서 일부를 줄였어요", "notice.geo_shapefile_split": "Shapefile은 한 종류의 도형만 담아서 점·선·면을 파일로 나눠 저장했어요", + "notice.active_content": "문서에 매크로 같은 활성 콘텐츠가 있어요. 아무것도 실행하지 않았어요", + "notice.active_content_skipped": "제외했어요: 문서에 매크로 같은 활성 콘텐츠가 있어요", "engine.reason.not_installed": "설치되지 않음", "engine.reason.error": "확인 중 오류", @@ -314,6 +320,20 @@ "preset.merge_pdf": "PDF 병합", "merge.order_title": "병합 순서 선택", "merge.order_hint": "파일을 골라 위아래로 옮기세요. 위에서부터 순서대로 병합돼요. (Alt+↑ / Alt+↓)", + "active.title": "매크로와 활성 콘텐츠", + "active.summary": "매크로 같은 활성 콘텐츠가 있는 파일: {0}개", + "active.hint": "Filee는 이것들을 절대 실행하지 않아요. LibreOffice는 매크로를 끈 채로 파일을 열고 링크도 업데이트하지 않아요. 출처를 믿을 수 있을 때만 변환하세요.", + "active.remember": "다시 묻지 않기", + "active.skip": "이 파일 빼고", + "active.trust": "믿고 변환", + "active.starts_by_itself": "문서를 열면 매크로가 저절로 시작돼요.", + "active.kind.macro": "매크로", + "active.kind.autorun": "자동 시작", + "active.kind.script": "스크립트", + "active.kind.activex": "ActiveX 컨트롤", + "active.kind.external_link": "외부 링크", + "active.kind.dde": "DDE 필드", + "active.kind.embedded": "포함된 개체", "preset.group_pdf_edit": "편집", "preset.pdf_crop": "PDF 자르기", "preset.pdf_crop.short": "자르기", @@ -383,6 +403,8 @@ "general.language": "언어", "general.language_auto": "시스템 기본값", "general.start_with_system": "로그인할 때 Filee 시작", + "general.startup_disabled": "Windows에서 로그인할 때 Filee가 시작되지 않도록 꺼 두었어요 (작업 관리자 → 시작 앱). 그래서 Filee가 저절로 시작되지 않아요.", + "general.startup_enable": "다시 켜기", "general.show_in_dock": "Dock에 Filee 표시", "general.show_menu_bar_icon": "메뉴 막대에 Filee 아이콘 표시", "general.dock_failed": "Dock의 Filee 아이콘을 변경하지 못했어요.", @@ -518,6 +540,7 @@ "triggers.paused": "단축키가 일시 중지되었어요", "toolbar.title": "원형 툴바", + "toolbar.keyboard_hint": "키보드로는 화살표로 칸을 고르고, Enter로 편집하고, Ctrl + 화살표로 옮겨요.", "toolbar.step_profile": "1. 프로필 선택", "toolbar.step_arrange": "2. 프리셋 배치", "toolbar.add_profile": "새 프로필", @@ -894,6 +917,31 @@ "engines.state.installed": "설치됨", "engines.state.failed": "실패: {0}", + "security.title": "보안 및 제한", + "security.active_content": "매크로가 있는 문서", + "security.active_content_hint": "Office 문서에는 매크로, ActiveX 컨트롤, 외부 파일이나 주소로 가는 링크, DDE 필드가 들어 있을 수 있어요. Filee는 LibreOffice가 문서를 열기 전에 이것들을 확인해요.", + "security.policy": "문서에 매크로 같은 활성 콘텐츠가 있을 때", + "security.policy.Ask": "물어보기", + "security.policy.Ask_hint": "찾은 내용을 보여 주고 직접 정할 수 있어요. 자동 변환 폴더와 명령줄은 물어볼 수 없어서 변환하고, 결과에 문서에 든 내용을 알려 줘요.", + "security.policy.Convert": "묻지 않고 변환", + "security.policy.Convert_hint": "다른 문서처럼 변환해요. 결과에는 들어 있던 내용이 그대로 표시돼요.", + "security.policy.Refuse": "변환하지 않기", + "security.policy.Refuse_hint": "활성 콘텐츠가 있는 문서는 열지 않아요. 결과에 이유가 표시돼요.", + "security.never_run": "무엇을 고르든 Filee는 매크로나 스크립트를 실행하지 않아요. LibreOffice는 매크로를 끈 채로 변환하고 링크도 업데이트하지 않아요.", + "security.images": "그림", + "security.images_hint": "아주 큰 그림의 한도예요. 예를 들어 \"이미지 폭탄\"은 풀면 수십억 픽셀이 되는 작은 파일이에요.", + "security.max_megapixels": "가장 큰 그림 (메가픽셀)", + "security.max_side": "가장 긴 변 (픽셀)", + "security.max_memory": "그림용 메모리 (MB)", + "security.max_disk": "그림용 디스크 공간 (MB)", + "security.zero_auto": "0 = 자동: Filee 자체 한도 없음", + "security.conversions": "변환", + "security.parallel_files": "동시에 변환할 파일 수", + "security.parallel_hint": "0 = 프로세서 코어마다 하나 (이 컴퓨터는 {0}개). 줄이면 큰 파일에서 메모리를 아낄 수 있어요.", + "security.step_timeout": "단계별 시간 제한 (분)", + "security.step_timeout_hint": "이보다 오래 걸리는 내장 엔진은 멈추고 파일은 멈춘 것으로 실패 처리돼요. LibreOffice, FFmpeg 같은 프로그램은 자체 한도를 써요. 0 = 제한 없음.", + "security.reset_limits": "한도 초기화", + "about.title": "정보", "about.updates": "업데이트", "common.search": "검색", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index a9a85890c..743c96b77 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -9,6 +9,7 @@ "nav.watch": "वाच फोल्डरहरू", "nav.theme": "थिम", "nav.engines": "इन्जिनहरू", + "nav.security": "सुरक्षा र सीमाहरू", "nav.about": "परिचय", "tray.open": "Filee खोल्नुहोस्", "tray.pause": "सर्टकटहरू पज गर्नुहोस्", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "CSV को कुनै पनि पङ्क्तिमा प्रयोगयोग्य स्थान छैन", "error.geo_unsupported_shape": "यस प्रकारको आकार समर्थित छैन", "error.geo_too_large": "Shapefile ढाँचाको २ GB सीमा नाघ्नेछ", + "error.active_content_refused": "कागजातमा म्याक्रो वा अन्य सक्रिय सामग्री छ (सेटिङहरू → सुरक्षा र सीमाहरू)", + "error.conversion_timeout": "इन्जिनले धेरै समय लियो र रोकियो (सेटिङहरू → सुरक्षा र सीमाहरू)", + "error.image_too_large": "तस्बिर सेटिङहरू → सुरक्षा र सीमाहरूको सीमाभन्दा ठूलो छ", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए", "notice.original_kept": "मूल फाइल राखियो, नतिजा प्रतिलिपिको रूपमा सुरक्षित भयो", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "केही पङ्क्तिहरूमा प्रयोगयोग्य स्थान छैन, तिनीहरूलाई आकार बिना राखियो", "notice.geo_field_names": "Shapefile फिल्ड नामहरू बढीमा १० अक्षरका हुन्छन्: केही छोट्याइए", "notice.geo_shapefile_split": "Shapefile मा एक प्रकारको आकार मात्र रहन्छ, त्यसैले पोइन्ट, रेखा र पोलिगनहरू छुट्टाछुट्टै फाइलमा सुरक्षित गरिए", + "notice.active_content": "कागजातमा म्याक्रो वा अन्य सक्रिय सामग्री छ; त्यसमध्ये केही पनि चलाइएन", + "notice.active_content_skipped": "छोडियो: कागजातमा म्याक्रो वा अन्य सक्रिय सामग्री छ", "engine.reason.not_installed": "स्थापना गरिएको छैन", "engine.reason.error": "जाँच गर्दा त्रुटि भयो", "engine.reason.unsupported_platform": "यस प्लेटफर्ममा उपलब्ध छैन", @@ -304,6 +310,20 @@ "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", "merge.order_hint": "फाइलहरूलाई इच्छित क्रममा मिलाउन तान्नुहोस् र छाड्नुहोस्", + "active.title": "म्याक्रो र सक्रिय सामग्री", + "active.summary": "म्याक्रो वा अन्य सक्रिय सामग्री भएका फाइलहरू: {0}", + "active.hint": "Filee ले यिनलाई कहिल्यै चलाउँदैन: LibreOffice ले यी फाइलहरू म्याक्रो बन्द गरेर खोल्छ र लिङ्कहरू अद्यावधिक गर्दैन। स्रोतमा विश्वास भए मात्र रूपान्तरण गर्नुहोस्।", + "active.remember": "फेरि नसोध्नुहोस्", + "active.skip": "यी फाइलहरू बाहेक", + "active.trust": "विश्वास गरी रूपान्तरण गर्नुहोस्", + "active.starts_by_itself": "कागजात खुल्दा एउटा म्याक्रो आफैँ सुरु हुन्छ।", + "active.kind.macro": "म्याक्रो", + "active.kind.autorun": "आफैँ सुरु हुन्छ", + "active.kind.script": "स्क्रिप्ट", + "active.kind.activex": "ActiveX नियन्त्रण", + "active.kind.external_link": "बाह्य लिङ्क", + "active.kind.dde": "DDE फिल्ड", + "active.kind.embedded": "इम्बेड गरिएको वस्तु", "preset.group_pdf_edit": "सम्पादन", "preset.pdf_crop": "PDF क्रप गर्नुहोस्", "preset.pdf_crop.short": "क्रप", @@ -371,6 +391,8 @@ "general.language": "भाषा", "general.language_auto": "प्रणाली पूर्वनिर्धारित", "general.start_with_system": "म साइन इन गर्दा Filee सुरु गर्नुहोस्", + "general.startup_disabled": "Windows ले साइन इन गर्दा Filee सुरु हुने बन्द गरेको छ (टास्क म्यानेजर → स्टार्टअप एपहरू), त्यसैले Filee आफैँ सुरु हुँदैन।", + "general.startup_enable": "फेरि सक्रिय गर्नुहोस्", "general.show_in_dock": "Dock मा Filee देखाउनुहोस्", "general.show_menu_bar_icon": "मेनु पट्टीमा Filee देखाउनुहोस्", "general.dock_failed": "Dock मा Filee को आइकन परिवर्तन गर्न सकिएन।", @@ -504,6 +526,7 @@ "triggers.excluded_note": "यी एपहरूमाथि पोइन्टर हुँदा जेस्चर र सर्टकटहरूले केही गर्दैनन्।", "triggers.paused": "सर्टकटहरू रोकिएका छन्", "toolbar.title": "डोनट टुलबार", + "toolbar.keyboard_hint": "किबोर्डबाट: एरोले भाग छान्छ, Enter ले सम्पादन गर्छ, Ctrl + एरोले सार्छ।", "toolbar.step_profile": "१. प्रोफाइल छनोट गर्नुहोस्", "toolbar.step_arrange": "२. प्रिसेटहरू व्यवस्थित गर्नुहोस्", "toolbar.add_profile": "नयाँ प्रोफाइल", @@ -876,6 +899,31 @@ "engines.state.unpacking": "स्थापना हुँदैछ…", "engines.state.installed": "स्थापना गरियो", "engines.state.failed": "असफल भयो: {0}", + + "security.title": "सुरक्षा र सीमाहरू", + "security.active_content": "म्याक्रो भएका कागजातहरू", + "security.active_content_hint": "Office कागजातहरूमा म्याक्रो, ActiveX नियन्त्रण, बाहिरी फाइल वा ठेगानाका लिङ्क र DDE फिल्डहरू हुन सक्छन्। LibreOffice ले खोल्नुअघि Filee ले कागजातहरूमा यिनको जाँच गर्छ।", + "security.policy": "कागजातमा म्याक्रो वा अन्य सक्रिय सामग्री हुँदा", + "security.policy.Ask": "मलाई सोध्नुहोस्", + "security.policy.Ask_hint": "Filee ले भेटेको कुरा देखाउँछ र तपाईं निर्णय गर्नुहुन्छ। निगरानी फोल्डर र कमान्ड लाइनले सोध्न सक्दैनन्: तिनीहरूले रूपान्तरण गर्छन् र नतिजामा कागजातमा के छ भनी देखाइन्छ।", + "security.policy.Convert": "नसोधी रूपान्तरण गर्नुहोस्", + "security.policy.Convert_hint": "यस्ता कागजातहरू अरू जस्तै रूपान्तरण हुन्छन्। नतिजामा तिनमा के छ भनी अझै देखाइन्छ।", + "security.policy.Refuse": "कहिल्यै रूपान्तरण नगर्नुहोस्", + "security.policy.Refuse_hint": "सक्रिय सामग्री भएका कागजातहरू खोलिँदैनन्। नतिजामा कारण देखाइन्छ।", + "security.never_run": "तपाईं जे छान्नुहोस्, Filee ले कहिल्यै म्याक्रो वा स्क्रिप्ट चलाउँदैन: LibreOffice ले म्याक्रो बन्द गरेर रूपान्तरण गर्छ र लिङ्कहरू अद्यावधिक गर्दैन।", + "security.images": "तस्बिरहरू", + "security.images_hint": "धेरै ठूला तस्बिरहरूका सीमा, जस्तै \"इमेज बम\": खोल्दा अर्बौं पिक्सेल बन्ने साना फाइलहरू।", + "security.max_megapixels": "सबैभन्दा ठूलो तस्बिर (मेगापिक्सेल)", + "security.max_side": "सबैभन्दा लामो भुजा (पिक्सेल)", + "security.max_memory": "तस्बिरका लागि मेमोरी (MB)", + "security.max_disk": "तस्बिरका लागि डिस्क ठाउँ (MB)", + "security.zero_auto": "0 = स्वचालित: Filee को आफ्नै सीमा छैन।", + "security.conversions": "रूपान्तरणहरू", + "security.parallel_files": "एकैपटक रूपान्तरण हुने फाइलहरू", + "security.parallel_hint": "0 = प्रत्येक प्रोसेसर कोरमा एउटा (यो कम्प्युटरमा {0})। कम राख्दा ठूला फाइलमा मेमोरी बचत हुन्छ।", + "security.step_timeout": "प्रत्येक चरणको समय सीमा (मिनेट)", + "security.step_timeout_hint": "योभन्दा बढी समय लिने बिल्ट-इन इन्जिन रोकिन्छ, र फाइल अड्किएको भनी असफल हुन्छ। LibreOffice, FFmpeg र अन्य प्रोग्रामले आफ्नै सीमा राख्छन्। 0 = सीमा छैन।", + "security.reset_limits": "सीमाहरू रिसेट गर्नुहोस्", "about.title": "बारेमा", "about.updates": "अपडेटहरू", "common.search": "खोज्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 72f4aa02f..076b3372a 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -10,6 +10,7 @@ "nav.watch": "Obserwowane foldery", "nav.theme": "Motyw", "nav.engines": "Silniki", + "nav.security": "Bezpieczeństwo i limity", "nav.about": "O programie", "tray.open": "Otwórz Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "Żaden wiersz pliku CSV nie ma odczytywalnego położenia", "error.geo_unsupported_shape": "Ten rodzaj kształtu nie jest obsługiwany", "error.geo_too_large": "Shapefile przekroczyłby limit 2 GB tego formatu", + "error.active_content_refused": "Dokument zawiera makra lub inną aktywną zawartość (Ustawienia → Bezpieczeństwo i limity)", + "error.conversion_timeout": "Silnik pracował zbyt długo i został zatrzymany (Ustawienia → Bezpieczeństwo i limity)", + "error.image_too_large": "Obraz przekracza limit z Ustawienia → Bezpieczeństwo i limity", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", "notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)", "notice.original_kept": "Oryginał został zachowany, wynik zapisano jako kopię", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "Wiersze bez odczytywalnego położenia zapisano bez kształtu", "notice.geo_field_names": "Nazwy pól Shapefile mają najwyżej 10 znaków: niektóre skrócono", "notice.geo_shapefile_split": "Shapefile przechowuje jeden rodzaj kształtu, więc punkty, linie i poligony zapisano w osobnych plikach", + "notice.active_content": "Dokument zawiera makra lub inną aktywną zawartość; nic z niej nie zostało uruchomione", + "notice.active_content_skipped": "Pominięto: dokument zawiera makra lub inną aktywną zawartość", "engine.reason.not_installed": "Nie zainstalowano", "engine.reason.error": "Błąd podczas sprawdzania", @@ -314,6 +320,20 @@ "preset.merge_pdf": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", "merge.order_hint": "Wybierz plik i przesuń go w górę lub w dół. Pliki są scalane od góry do dołu. (Alt+↑ / Alt+↓)", + "active.title": "Makra i aktywna zawartość", + "active.summary": "Pliki z makrami lub inną aktywną zawartością: {0}", + "active.hint": "Filee nigdy ich nie uruchamia: LibreOffice otwiera te pliki z wyłączonymi makrami i nie aktualizuje łączy. Konwertuj je tylko wtedy, gdy ufasz ich źródłu.", + "active.remember": "Nie pytaj ponownie", + "active.skip": "Bez tych plików", + "active.trust": "Ufam, konwertuj", + "active.starts_by_itself": "Makro uruchamia się samo po otwarciu dokumentu.", + "active.kind.macro": "Makro", + "active.kind.autorun": "Uruchamia się samo", + "active.kind.script": "Skrypt", + "active.kind.activex": "Kontrolka ActiveX", + "active.kind.external_link": "Łącze zewnętrzne", + "active.kind.dde": "Pole DDE", + "active.kind.embedded": "Osadzony obiekt", "preset.group_pdf_edit": "Edytuj", "preset.pdf_crop": "Przytnij PDF", "preset.pdf_crop.short": "Przytnij", @@ -383,6 +403,8 @@ "general.language": "Język", "general.language_auto": "Domyślny systemu", "general.start_with_system": "Uruchamiaj Filee po zalogowaniu", + "general.startup_disabled": "Windows wyłączył uruchamianie Filee po zalogowaniu (Menedżer zadań → Aplikacje autostartu), więc Filee nie uruchamia się sam.", + "general.startup_enable": "Włącz ponownie", "general.show_in_dock": "Pokazuj Filee w Docku", "general.show_menu_bar_icon": "Pokazuj ikonę Filee na pasku menu", "general.dock_failed": "Nie udało się zmienić ikony Filee w Docku.", @@ -518,6 +540,7 @@ "triggers.paused": "Skróty są wstrzymane", "toolbar.title": "Pasek-pierścień", + "toolbar.keyboard_hint": "Z klawiatury: strzałki wybierają wycinek, Enter go edytuje, Ctrl + strzałka go przesuwa.", "toolbar.step_profile": "1. Wybierz profil", "toolbar.step_arrange": "2. Ułóż ustawienia", "toolbar.add_profile": "Nowy profil", @@ -894,6 +917,31 @@ "engines.state.installed": "Zainstalowano", "engines.state.failed": "Błąd: {0}", + "security.title": "Bezpieczeństwo i limity", + "security.active_content": "Dokumenty z makrami", + "security.active_content_hint": "Dokumenty Office mogą zawierać makra, kontrolki ActiveX, łącza do zewnętrznych plików lub adresów i pola DDE. Filee sprawdza je, zanim LibreOffice otworzy dokument.", + "security.policy": "Gdy dokument zawiera makra lub inną aktywną zawartość", + "security.policy.Ask": "Pytaj mnie", + "security.policy.Ask_hint": "Filee pokazuje, co znalazł, a Ty decydujesz. Obserwowane foldery i wiersz poleceń nie mogą pytać: konwertują, a wynik pokazuje, co zawiera dokument.", + "security.policy.Convert": "Konwertuj bez pytania", + "security.policy.Convert_hint": "Takie dokumenty są konwertowane jak inne. Wynik nadal pokazuje, co zawierają.", + "security.policy.Refuse": "Nigdy ich nie konwertuj", + "security.policy.Refuse_hint": "Dokumenty z aktywną zawartością nie są otwierane. Wynik wyjaśnia dlaczego.", + "security.never_run": "Niezależnie od wyboru Filee nigdy nie uruchamia makr ani skryptów: LibreOffice konwertuje z wyłączonymi makrami i nie aktualizuje łączy.", + "security.images": "Obrazy", + "security.images_hint": "Limity dla bardzo dużych obrazów, np. „bomb obrazowych”: maleńkich plików, które rozpakowują się do miliardów pikseli.", + "security.max_megapixels": "Największy obraz (megapiksele)", + "security.max_side": "Najdłuższy bok (piksele)", + "security.max_memory": "Pamięć na obrazy (MB)", + "security.max_disk": "Miejsce na dysku na obrazy (MB)", + "security.zero_auto": "0 = automatycznie: bez własnego limitu Filee.", + "security.conversions": "Konwersje", + "security.parallel_files": "Pliki konwertowane naraz", + "security.parallel_hint": "0 = jeden na rdzeń procesora ({0} na tym komputerze). Mniej oszczędza pamięć przy dużych plikach.", + "security.step_timeout": "Limit czasu na krok (minuty)", + "security.step_timeout_hint": "Wbudowany silnik, który pracuje dłużej, zostaje zatrzymany, a plik kończy się błędem jako zawieszony. LibreOffice, FFmpeg i inne programy mają własne limity. 0 = bez limitu.", + "security.reset_limits": "Przywróć limity", + "about.title": "O programie", "about.updates": "Aktualizacje", "common.search": "Szukaj", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 750998136..addbc87d7 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -10,6 +10,7 @@ "nav.watch": "Pastas monitoradas", "nav.theme": "Tema", "nav.engines": "Motores", + "nav.security": "Segurança e limites", "nav.about": "Sobre", "tray.open": "Abrir o Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "Nenhuma linha do CSV tem uma localização utilizável", "error.geo_unsupported_shape": "Este tipo de forma não é compatível", "error.geo_too_large": "O Shapefile passaria do limite de 2 GB do formato", + "error.active_content_refused": "O documento tem macros ou outro conteúdo ativo (Configurações → Segurança e limites)", + "error.conversion_timeout": "O motor demorou demais e foi interrompido (Configurações → Segurança e limites)", + "error.image_too_large": "A imagem passa do limite em Configurações → Segurança e limites", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", "notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora", "notice.original_kept": "O original foi mantido, o resultado foi salvo como cópia", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "Algumas linhas não têm localização utilizável e foram mantidas sem forma", "notice.geo_field_names": "Nomes de campo de Shapefile têm no máximo 10 caracteres: alguns foram encurtados", "notice.geo_shapefile_split": "Um Shapefile guarda um tipo de forma, então pontos, linhas e polígonos foram salvos em arquivos separados", + "notice.active_content": "O documento tem macros ou outro conteúdo ativo; nada foi executado", + "notice.active_content_skipped": "Deixado de fora: o documento tem macros ou outro conteúdo ativo", "engine.reason.not_installed": "Não instalado", "engine.reason.error": "Erro ao verificar", @@ -314,6 +320,20 @@ "preset.merge_pdf": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", "merge.order_hint": "Selecione um arquivo e mova para cima ou para baixo. Os arquivos são mesclados de cima para baixo. (Alt+↑ / Alt+↓)", + "active.title": "Macros e conteúdo ativo", + "active.summary": "Arquivos com macros ou outro conteúdo ativo: {0}", + "active.hint": "O Filee nunca os executa: o LibreOffice abre esses arquivos com as macros desligadas e não atualiza vínculos. Converta-os só se você confia na origem deles.", + "active.remember": "Não perguntar de novo", + "active.skip": "Sem estes arquivos", + "active.trust": "Confiar e converter", + "active.starts_by_itself": "Uma macro começa sozinha quando o documento abre.", + "active.kind.macro": "Macro", + "active.kind.autorun": "Começa sozinha", + "active.kind.script": "Script", + "active.kind.activex": "Controle ActiveX", + "active.kind.external_link": "Vínculo externo", + "active.kind.dde": "Campo DDE", + "active.kind.embedded": "Objeto incorporado", "preset.group_pdf_edit": "Editar", "preset.pdf_crop": "Recortar PDF", "preset.pdf_crop.short": "Recortar", @@ -383,6 +403,8 @@ "general.language": "Idioma", "general.language_auto": "Padrão do sistema", "general.start_with_system": "Iniciar o Filee quando eu fizer login", + "general.startup_disabled": "O Windows desligou a inicialização do Filee ao entrar (Gerenciador de Tarefas → Aplicativos de inicialização), então o Filee não abre sozinho.", + "general.startup_enable": "Ligar de novo", "general.show_in_dock": "Mostrar o Filee no Dock", "general.show_menu_bar_icon": "Mostrar o Filee na barra de menus", "general.dock_failed": "Não foi possível mudar o ícone do Filee no Dock.", @@ -518,6 +540,7 @@ "triggers.paused": "Os atalhos estão pausados", "toolbar.title": "Menu circular", + "toolbar.keyboard_hint": "Pelo teclado: as setas escolhem uma fatia, Enter a edita e Ctrl + seta a move.", "toolbar.step_profile": "1. Escolha um perfil", "toolbar.step_arrange": "2. Organize as predefinições", "toolbar.add_profile": "Novo perfil", @@ -894,6 +917,31 @@ "engines.state.installed": "Instalado", "engines.state.failed": "Falhou: {0}", + "security.title": "Segurança e limites", + "security.active_content": "Documentos com macros", + "security.active_content_hint": "Documentos do Office podem ter macros, controles ActiveX, vínculos para arquivos ou endereços externos e campos DDE. O Filee procura por eles antes que o LibreOffice abra o documento.", + "security.policy": "Quando um documento tem macros ou outro conteúdo ativo", + "security.policy.Ask": "Perguntar", + "security.policy.Ask_hint": "O Filee mostra o que encontrou e você decide. Pastas monitoradas e a linha de comando não podem perguntar: elas convertem, e o resultado lista o que o documento contém.", + "security.policy.Convert": "Converter sem perguntar", + "security.policy.Convert_hint": "Esses documentos são convertidos como qualquer outro. O resultado ainda lista o que eles contêm.", + "security.policy.Refuse": "Nunca converter", + "security.policy.Refuse_hint": "Documentos com conteúdo ativo não são abertos. O resultado diz o motivo.", + "security.never_run": "Seja qual for a escolha, o Filee nunca executa macros ou scripts: o LibreOffice converte com as macros desligadas e não atualiza vínculos.", + "security.images": "Imagens", + "security.images_hint": "Limites para imagens muito grandes, como as \"bombas de imagem\": arquivos minúsculos que se expandem para bilhões de pixels.", + "security.max_megapixels": "Maior imagem (megapixels)", + "security.max_side": "Lado mais longo (pixels)", + "security.max_memory": "Memória para imagens (MB)", + "security.max_disk": "Espaço em disco para imagens (MB)", + "security.zero_auto": "0 = automático: sem limite próprio do Filee.", + "security.conversions": "Conversões", + "security.parallel_files": "Arquivos convertidos ao mesmo tempo", + "security.parallel_hint": "0 = um por núcleo do processador ({0} neste computador). Menos economiza memória com arquivos grandes.", + "security.step_timeout": "Tempo limite por etapa (minutos)", + "security.step_timeout_hint": "Um motor integrado que demore mais é interrompido, e o arquivo falha como travado. LibreOffice, FFmpeg e outros programas mantêm seus próprios limites. 0 = sem limite.", + "security.reset_limits": "Redefinir limites", + "about.title": "Sobre", "about.updates": "Atualizações", "common.search": "Pesquisar", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 11e8a5038..fff08f7ae 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -10,6 +10,7 @@ "nav.watch": "Pastas monitorizadas", "nav.theme": "Tema", "nav.engines": "Motores", + "nav.security": "Segurança e limites", "nav.about": "Acerca de", "tray.open": "Abrir o Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "Nenhuma linha do CSV tem uma localização utilizável", "error.geo_unsupported_shape": "Este tipo de geometria não é suportado", "error.geo_too_large": "O Shapefile ultrapassaria o limite de 2 GB do formato", + "error.active_content_refused": "O documento tem macros ou outro conteúdo ativo (Definições → Segurança e limites)", + "error.conversion_timeout": "O motor demorou demasiado e foi parado (Definições → Segurança e limites)", + "error.image_too_large": "A imagem ultrapassa o limite definido em Definições → Segurança e limites", "notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações", "notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos", "notice.original_kept": "O original foi mantido e o resultado foi guardado como cópia", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "Algumas linhas não têm uma localização utilizável e foram mantidas sem geometria", "notice.geo_field_names": "Os nomes dos campos Shapefile admitem, no máximo, 10 carateres: alguns foram abreviados", "notice.geo_shapefile_split": "Um Shapefile guarda apenas um tipo de geometria, pelo que os pontos, linhas e polígonos foram guardados em ficheiros separados", + "notice.active_content": "O documento tem macros ou outro conteúdo ativo; nada foi executado", + "notice.active_content_skipped": "Deixado de fora: o documento tem macros ou outro conteúdo ativo", "engine.reason.not_installed": "Não instalado", "engine.reason.error": "Erro na verificação", @@ -314,6 +320,20 @@ "preset.merge_pdf": "Juntar PDF", "merge.order_title": "Escolher a ordem dos ficheiros", "merge.order_hint": "Seleciona um ficheiro e move-o para cima ou para baixo. Os ficheiros são combinados pela ordem da lista, de cima para baixo. (Alt+↑ / Alt+↓)", + "active.title": "Macros e conteúdo ativo", + "active.summary": "Ficheiros com macros ou outro conteúdo ativo: {0}", + "active.hint": "O Filee nunca os executa: o LibreOffice abre estes ficheiros com as macros desativadas e não atualiza ligações. Converte-os apenas se confiares na sua origem.", + "active.remember": "Não voltar a perguntar", + "active.skip": "Sem estes ficheiros", + "active.trust": "Confiar e converter", + "active.starts_by_itself": "Uma macro arranca sozinha quando o documento é aberto.", + "active.kind.macro": "Macro", + "active.kind.autorun": "Arranca sozinha", + "active.kind.script": "Script", + "active.kind.activex": "Controlo ActiveX", + "active.kind.external_link": "Ligação externa", + "active.kind.dde": "Campo DDE", + "active.kind.embedded": "Objeto incorporado", "preset.group_pdf_edit": "Editar", "preset.pdf_crop": "Recortar PDF", "preset.pdf_crop.short": "Recortar", @@ -383,6 +403,8 @@ "general.language": "Idioma", "general.language_auto": "Predefinição do sistema", "general.start_with_system": "Abrir o Filee ao iniciar sessão", + "general.startup_disabled": "O Windows desativou o arranque do Filee ao iniciar sessão (Gestor de tarefas → Aplicações de arranque), por isso o Filee não abre sozinho.", + "general.startup_enable": "Voltar a ativar", "general.show_in_dock": "Mostrar o Filee na Dock", "general.show_menu_bar_icon": "Mostrar o Filee na barra de menus", "general.dock_failed": "Não foi possível alterar o ícone do Filee na Dock.", @@ -518,6 +540,7 @@ "triggers.paused": "Os atalhos estão em pausa", "toolbar.title": "Barra circular", + "toolbar.keyboard_hint": "Com o teclado: as setas escolhem um segmento, Enter edita-o e Ctrl + seta move-o.", "toolbar.step_profile": "1. Escolhe um perfil", "toolbar.step_arrange": "2. Organiza as predefinições", "toolbar.add_profile": "Novo perfil", @@ -893,6 +916,30 @@ "engines.state.unpacking": "A instalar…", "engines.state.installed": "Instalado", "engines.state.failed": "Falhou: {0}", + "security.title": "Segurança e limites", + "security.active_content": "Documentos com macros", + "security.active_content_hint": "Os documentos do Office podem conter macros, controlos ActiveX, ligações a ficheiros ou endereços externos e campos DDE. O Filee procura-os antes de o LibreOffice abrir o documento.", + "security.policy": "Quando um documento tem macros ou outro conteúdo ativo", + "security.policy.Ask": "Perguntar", + "security.policy.Ask_hint": "O Filee mostra o que encontrou e tu decides. As pastas monitorizadas e a linha de comandos não podem perguntar: convertem, e o resultado indica o que o documento contém.", + "security.policy.Convert": "Converter sem perguntar", + "security.policy.Convert_hint": "Estes documentos são convertidos como quaisquer outros. O resultado continua a indicar o que contêm.", + "security.policy.Refuse": "Nunca converter", + "security.policy.Refuse_hint": "Os documentos com conteúdo ativo não são abertos. O resultado indica o motivo.", + "security.never_run": "Seja qual for a escolha, o Filee nunca executa macros nem scripts: o LibreOffice converte com as macros desativadas e não atualiza ligações.", + "security.images": "Imagens", + "security.images_hint": "Limites para imagens muito grandes, como as «bombas de imagem»: ficheiros minúsculos que se expandem para milhares de milhões de píxeis.", + "security.max_megapixels": "Maior imagem (megapíxeis)", + "security.max_side": "Lado mais longo (píxeis)", + "security.max_memory": "Memória para imagens (MB)", + "security.max_disk": "Espaço em disco para imagens (MB)", + "security.zero_auto": "0 = automático: sem limite próprio do Filee.", + "security.conversions": "Conversões", + "security.parallel_files": "Ficheiros convertidos em simultâneo", + "security.parallel_hint": "0 = um por núcleo do processador ({0} neste computador). Menos poupa memória com ficheiros grandes.", + "security.step_timeout": "Tempo limite por passo (minutos)", + "security.step_timeout_hint": "Um motor integrado que demore mais é parado e o ficheiro falha como bloqueado. O LibreOffice, o FFmpeg e outros programas mantêm os seus próprios limites. 0 = sem limite.", + "security.reset_limits": "Repor limites", "about.title": "Acerca de", "about.updates": "Atualizações", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 29acdd1e0..37d9b25e2 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -10,6 +10,7 @@ "nav.watch": "Папки наблюдения", "nav.theme": "Тема", "nav.engines": "Движки", + "nav.security": "Безопасность и лимиты", "nav.about": "О программе", "tray.open": "Открыть Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "Ни в одной строке CSV нет пригодного местоположения", "error.geo_unsupported_shape": "Такой тип фигуры не поддерживается", "error.geo_too_large": "Shapefile превысил бы предел формата в 2 ГБ", + "error.active_content_refused": "В документе есть макросы или другое активное содержимое («Настройки → Безопасность и лимиты»)", + "error.conversion_timeout": "Движок работал слишком долго и был остановлен («Настройки → Безопасность и лимиты»)", + "error.image_too_large": "Изображение больше предела в разделе «Настройки → Безопасность и лимиты»", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", "notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены", "notice.original_kept": "Оригинал оставлен, результат сохранён как копия", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "В некоторых строках нет пригодного местоположения: они сохранены без фигуры", "notice.geo_field_names": "Имена полей Shapefile не длиннее 10 символов: некоторые сокращены", "notice.geo_shapefile_split": "Shapefile хранит фигуры только одного вида, поэтому точки, линии и полигоны сохранены в отдельные файлы", + "notice.active_content": "В документе есть макросы или другое активное содержимое; ничего из этого не запускалось", + "notice.active_content_skipped": "Пропущено: в документе есть макросы или другое активное содержимое", "engine.reason.not_installed": "Не установлен", "engine.reason.error": "Ошибка при проверке", @@ -314,6 +320,20 @@ "preset.merge_pdf": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", "merge.order_hint": "Выберите файл и переместите его вверх или вниз. Файлы объединяются сверху вниз. (Alt+↑ / Alt+↓)", + "active.title": "Макросы и активное содержимое", + "active.summary": "Файлы с макросами или другим активным содержимым: {0}", + "active.hint": "Filee никогда их не запускает: LibreOffice открывает эти файлы с отключёнными макросами и не обновляет связи. Конвертируйте их, только если доверяете источнику.", + "active.remember": "Больше не спрашивать", + "active.skip": "Без этих файлов", + "active.trust": "Доверять и конвертировать", + "active.starts_by_itself": "Макрос запускается сам при открытии документа.", + "active.kind.macro": "Макрос", + "active.kind.autorun": "Запускается сам", + "active.kind.script": "Скрипт", + "active.kind.activex": "Элемент ActiveX", + "active.kind.external_link": "Внешняя связь", + "active.kind.dde": "Поле DDE", + "active.kind.embedded": "Внедрённый объект", "preset.group_pdf_edit": "Изменить", "preset.pdf_crop": "Обрезать PDF", "preset.pdf_crop.short": "Обрезать", @@ -383,6 +403,8 @@ "general.language": "Язык", "general.language_auto": "Как в системе", "general.start_with_system": "Запускать Filee при входе в систему", + "general.startup_disabled": "Windows отключила запуск Filee при входе («Диспетчер задач → Автозагрузка»), поэтому Filee не запускается сам.", + "general.startup_enable": "Включить снова", "general.show_in_dock": "Показывать Filee в Dock", "general.show_menu_bar_icon": "Показывать Filee в строке меню", "general.dock_failed": "Не удалось изменить значок Filee в Dock.", @@ -518,6 +540,7 @@ "triggers.paused": "Горячие клавиши приостановлены", "toolbar.title": "Круговое меню", + "toolbar.keyboard_hint": "С клавиатуры: стрелки выбирают сектор, Enter изменяет его, Ctrl + стрелка перемещает.", "toolbar.step_profile": "1. Выберите профиль", "toolbar.step_arrange": "2. Расставьте пресеты", "toolbar.add_profile": "Новый профиль", @@ -894,6 +917,31 @@ "engines.state.installed": "Установлен", "engines.state.failed": "Ошибка: {0}", + "security.title": "Безопасность и лимиты", + "security.active_content": "Документы с макросами", + "security.active_content_hint": "Документы Office могут содержать макросы, элементы ActiveX, связи с внешними файлами или адресами и поля DDE. Filee проверяет документы на их наличие до того, как их откроет LibreOffice.", + "security.policy": "Если в документе есть макросы или другое активное содержимое", + "security.policy.Ask": "Спрашивать", + "security.policy.Ask_hint": "Filee показывает найденное, а вы решаете. Отслеживаемые папки и командная строка спросить не могут: они конвертируют, а в результате указано, что содержит документ.", + "security.policy.Convert": "Конвертировать без вопросов", + "security.policy.Convert_hint": "Такие документы конвертируются как обычно. В результате по-прежнему указано, что они содержат.", + "security.policy.Refuse": "Никогда не конвертировать", + "security.policy.Refuse_hint": "Документы с активным содержимым не открываются. В результате указана причина.", + "security.never_run": "Что бы вы ни выбрали, Filee никогда не запускает макросы и скрипты: LibreOffice конвертирует с отключёнными макросами и не обновляет связи.", + "security.images": "Изображения", + "security.images_hint": "Ограничения для очень больших изображений, например «бомб»: крошечных файлов, которые распаковываются в миллиарды пикселей.", + "security.max_megapixels": "Наибольшее изображение (мегапиксели)", + "security.max_side": "Наибольшая сторона (пиксели)", + "security.max_memory": "Память для изображений (МБ)", + "security.max_disk": "Место на диске для изображений (МБ)", + "security.zero_auto": "0 = автоматически: без собственного ограничения Filee.", + "security.conversions": "Конвертация", + "security.parallel_files": "Файлов одновременно", + "security.parallel_hint": "0 = по одному на ядро процессора ({0} на этом компьютере). Меньше — экономия памяти на больших файлах.", + "security.step_timeout": "Лимит времени на шаг (минуты)", + "security.step_timeout_hint": "Встроенный движок, работающий дольше, останавливается, и файл завершается ошибкой как зависший. LibreOffice, FFmpeg и другие программы сохраняют собственные лимиты. 0 = без ограничения.", + "security.reset_limits": "Сбросить ограничения", + "about.title": "О программе", "about.updates": "Обновления", "common.search": "Поиск", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 8c419e6b3..23fd127f3 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -9,6 +9,7 @@ "nav.watch": "โฟลเดอร์เฝ้าติดตาม", "nav.theme": "ธีม", "nav.engines": "เครื่องมือแปลงไฟล์", + "nav.security": "ความปลอดภัยและขีดจำกัด", "nav.about": "เกี่ยวกับ", "tray.open": "เปิด Filee", "tray.pause": "หยุดปุ่มลัดชั่วคราว", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "ไม่มีแถวใดใน CSV ที่มีตำแหน่งที่ใช้งานได้", "error.geo_unsupported_shape": "ไม่รองรับรูปทรงเรขาคณิตประเภทนี้", "error.geo_too_large": "Shapefile จะมีขนาดเกินขีดจำกัดของฟอร์แมตที่ 2 GB", + "error.active_content_refused": "เอกสารมีแมโครหรือเนื้อหาที่ทำงานได้อื่นๆ (การตั้งค่า → ความปลอดภัยและขีดจำกัด)", + "error.conversion_timeout": "เครื่องมือแปลงไฟล์ใช้เวลานานเกินไปจึงถูกหยุด (การตั้งค่า → ความปลอดภัยและขีดจำกัด)", + "error.image_too_large": "ภาพมีขนาดใหญ่เกินขีดจำกัดใน การตั้งค่า → ความปลอดภัยและขีดจำกัด", "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", "notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้", "notice.original_kept": "เก็บไฟล์ต้นฉบับไว้ และบันทึกผลลัพธ์เป็นสำเนา", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "บางแถวไม่มีตำแหน่งที่ใช้งานได้และถูกเก็บไว้โดยไม่มีรูปทรงเรขาคณิต", "notice.geo_field_names": "ชื่อฟิลด์ของ Shapefile มีความยาวได้ไม่เกิน 10 ตัวอักษร บางชื่อจึงถูกย่อให้สั้นลง", "notice.geo_shapefile_split": "Shapefile เก็บรูปทรงเรขาคณิตได้ชนิดเดียวต่อไฟล์ จุด เส้น และโพลีกอนจึงถูกบันทึกแยกเป็นคนละไฟล์", + "notice.active_content": "เอกสารมีแมโครหรือเนื้อหาที่ทำงานได้อื่นๆ แต่ไม่มีสิ่งใดถูกเรียกใช้", + "notice.active_content_skipped": "ข้ามไป: เอกสารมีแมโครหรือเนื้อหาที่ทำงานได้อื่นๆ", "engine.reason.not_installed": "ยังไม่ได้ติดตั้ง", "engine.reason.error": "เกิดข้อผิดพลาดขณะตรวจสอบ", "engine.reason.unsupported_platform": "ไม่พร้อมใช้งานบนแพลตฟอร์มนี้", @@ -304,6 +310,20 @@ "preset.merge_pdf": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", "merge.order_hint": "เลือกไฟล์แล้วเลื่อนขึ้นหรือลง ไฟล์จะรวมจากบนลงล่าง (Alt+↑ / Alt+↓)", + "active.title": "แมโครและเนื้อหาที่ทำงานได้", + "active.summary": "ไฟล์ที่มีแมโครหรือเนื้อหาที่ทำงานได้อื่นๆ: {0}", + "active.hint": "Filee ไม่เรียกใช้สิ่งเหล่านี้เลย LibreOffice จะเปิดไฟล์โดยปิดแมโครและไม่อัปเดตลิงก์ แปลงไฟล์เฉพาะเมื่อคุณไว้ใจแหล่งที่มาเท่านั้น", + "active.remember": "ไม่ต้องถามอีก", + "active.skip": "ข้ามไฟล์เหล่านี้", + "active.trust": "เชื่อถือและแปลง", + "active.starts_by_itself": "มีแมโครที่เริ่มทำงานเองเมื่อเปิดเอกสาร", + "active.kind.macro": "แมโคร", + "active.kind.autorun": "เริ่มทำงานเอง", + "active.kind.script": "สคริปต์", + "active.kind.activex": "ตัวควบคุม ActiveX", + "active.kind.external_link": "ลิงก์ภายนอก", + "active.kind.dde": "ฟิลด์ DDE", + "active.kind.embedded": "ออบเจ็กต์ที่ฝังไว้", "preset.group_pdf_edit": "แก้ไข", "preset.pdf_crop": "ครอบตัด PDF", "preset.pdf_crop.short": "ครอบตัด", @@ -371,6 +391,8 @@ "general.language": "ภาษา", "general.language_auto": "ตามระบบ", "general.start_with_system": "เปิด Filee อัตโนมัติเมื่อเข้าสู่ระบบ", + "general.startup_disabled": "Windows ปิดการเปิด Filee เมื่อลงชื่อเข้าใช้ (ตัวจัดการงาน → แอปเริ่มต้น) Filee จึงไม่เริ่มทำงานเอง", + "general.startup_enable": "เปิดอีกครั้ง", "general.show_in_dock": "แสดง Filee ใน Dock", "general.show_menu_bar_icon": "แสดง Filee ในแถบเมนู", "general.dock_failed": "ไม่สามารถเปลี่ยนไอคอน Dock ของ Filee ได้", @@ -504,6 +526,7 @@ "triggers.excluded_note": "ท่าทางและปุ่มลัดจะไม่ทำงานขณะเคอร์เซอร์อยู่เหนือแอปเหล่านี้", "triggers.paused": "หยุดปุ่มลัดชั่วคราวแล้ว", "toolbar.title": "แถบโดนัท", + "toolbar.keyboard_hint": "ใช้แป้นพิมพ์: ลูกศรเลือกช่อง Enter แก้ไข Ctrl + ลูกศรย้ายตำแหน่ง", "toolbar.step_profile": "1. เลือกโปรไฟล์", "toolbar.step_arrange": "2. จัดเรียงพรีเซ็ต", "toolbar.add_profile": "โปรไฟล์ใหม่", @@ -876,6 +899,31 @@ "engines.state.unpacking": "กำลังติดตั้ง…", "engines.state.installed": "ติดตั้งแล้ว", "engines.state.failed": "ล้มเหลว: {0}", + + "security.title": "ความปลอดภัยและขีดจำกัด", + "security.active_content": "เอกสารที่มีแมโคร", + "security.active_content_hint": "เอกสาร Office อาจมีแมโคร ตัวควบคุม ActiveX ลิงก์ไปยังไฟล์หรือที่อยู่ภายนอก และฟิลด์ DDE Filee จะตรวจสอบสิ่งเหล่านี้ก่อนที่ LibreOffice จะเปิดเอกสาร", + "security.policy": "เมื่อเอกสารมีแมโครหรือเนื้อหาที่ทำงานได้อื่นๆ", + "security.policy.Ask": "ถามฉัน", + "security.policy.Ask_hint": "Filee จะแสดงสิ่งที่พบให้คุณตัดสินใจ โฟลเดอร์แปลงอัตโนมัติและบรรทัดคำสั่งถามไม่ได้ จึงแปลงไฟล์และแสดงในผลลัพธ์ว่าเอกสารมีอะไร", + "security.policy.Convert": "แปลงโดยไม่ถาม", + "security.policy.Convert_hint": "เอกสารเหล่านี้จะถูกแปลงเหมือนไฟล์อื่น ผลลัพธ์ยังคงแสดงว่ามีอะไรอยู่", + "security.policy.Refuse": "ไม่แปลงเลย", + "security.policy.Refuse_hint": "เอกสารที่มีเนื้อหาที่ทำงานได้จะไม่ถูกเปิด ผลลัพธ์จะบอกเหตุผล", + "security.never_run": "ไม่ว่าจะเลือกแบบใด Filee จะไม่เรียกใช้แมโครหรือสคริปต์ LibreOffice แปลงไฟล์โดยปิดแมโครและไม่อัปเดตลิงก์", + "security.images": "รูปภาพ", + "security.images_hint": "ขีดจำกัดสำหรับภาพขนาดใหญ่มาก เช่น \"ระเบิดภาพ\" ซึ่งเป็นไฟล์เล็กๆ ที่คลายออกมาเป็นพันล้านพิกเซล", + "security.max_megapixels": "ภาพใหญ่ที่สุด (เมกะพิกเซล)", + "security.max_side": "ด้านที่ยาวที่สุด (พิกเซล)", + "security.max_memory": "หน่วยความจำสำหรับภาพ (MB)", + "security.max_disk": "พื้นที่ดิสก์สำหรับภาพ (MB)", + "security.zero_auto": "0 = อัตโนมัติ: ไม่มีขีดจำกัดของ Filee เอง", + "security.conversions": "การแปลงไฟล์", + "security.parallel_files": "จำนวนไฟล์ที่แปลงพร้อมกัน", + "security.parallel_hint": "0 = หนึ่งไฟล์ต่อคอร์ประมวลผล ({0} ในเครื่องนี้) ตั้งให้น้อยลงช่วยประหยัดหน่วยความจำกับไฟล์ใหญ่", + "security.step_timeout": "เวลาสูงสุดต่อขั้นตอน (นาที)", + "security.step_timeout_hint": "เครื่องมือในตัวที่ใช้เวลานานกว่านี้จะถูกหยุด และไฟล์จะล้มเหลวเพราะค้าง LibreOffice, FFmpeg และโปรแกรมอื่นใช้ขีดจำกัดของตัวเอง 0 = ไม่จำกัด", + "security.reset_limits": "รีเซ็ตขีดจำกัด", "about.title": "เกี่ยวกับ", "about.updates": "การอัปเดต", "common.search": "ค้นหา", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 62a8e5e1d..5d050cedf 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -9,6 +9,7 @@ "nav.watch": "İzlenen klasörler", "nav.theme": "Tema", "nav.engines": "Dönüştürme motorları", + "nav.security": "Güvenlik ve sınırlar", "nav.about": "Hakkında", "tray.open": "Filee'yi aç", "tray.pause": "Kısayolları duraklat", @@ -107,6 +108,9 @@ "error.geo_csv_no_locations": "CSV'nin hiçbir satırında kullanılabilir konum yok", "error.geo_unsupported_shape": "Bu geometri türü desteklenmiyor", "error.geo_too_large": "Shapefile, biçimin 2 GB sınırını aşacak", + "error.active_content_refused": "Belgede makro veya başka etkin içerik var (Ayarlar → Güvenlik ve sınırlar)", + "error.conversion_timeout": "Motor çok uzun sürdü ve durduruldu (Ayarlar → Güvenlik ve sınırlar)", + "error.image_too_large": "Resim, Ayarlar → Güvenlik ve sınırlar bölümündeki sınırdan büyük", "notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı", "notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı", "notice.original_kept": "Özgün dosya korundu, sonuç kopya olarak kaydedildi", @@ -129,6 +133,8 @@ "notice.geo_rows_without_location": "Bazı satırların kullanılabilir konumu yok; geometrisiz olarak korundular", "notice.geo_field_names": "Shapefile alan adları en fazla 10 karakter olabilir; bazıları kısaltıldı", "notice.geo_shapefile_split": "Shapefile tek bir geometri türü tutar; noktalar, çizgiler ve çokgenler ayrı dosyalara kaydedildi", + "notice.active_content": "Belgede makro veya başka etkin içerik var; hiçbiri çalıştırılmadı", + "notice.active_content_skipped": "Dışarıda bırakıldı: belgede makro veya başka etkin içerik var", "engine.reason.not_installed": "Kurulu değil", "engine.reason.error": "Denetim sırasında hata", "engine.reason.unsupported_platform": "Bu platformda kullanılamıyor", @@ -304,6 +310,20 @@ "preset.merge_pdf": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", "merge.order_hint": "Bir dosya seçip yukarı veya aşağı taşıyın. Dosyalar yukarıdan aşağıya birleştirilir. (Alt+↑ / Alt+↓)", + "active.title": "Makrolar ve etkin içerik", + "active.summary": "Makro veya başka etkin içerik barındıran dosyalar: {0}", + "active.hint": "Filee bunları asla çalıştırmaz: LibreOffice bu dosyaları makrolar kapalıyken açar ve bağlantıları güncellemez. Yalnızca kaynağına güveniyorsanız dönüştürün.", + "active.remember": "Bir daha sorma", + "active.skip": "Bu dosyalar olmadan", + "active.trust": "Güven ve dönüştür", + "active.starts_by_itself": "Belge açılınca bir makro kendiliğinden başlar.", + "active.kind.macro": "Makro", + "active.kind.autorun": "Kendiliğinden başlar", + "active.kind.script": "Betik", + "active.kind.activex": "ActiveX denetimi", + "active.kind.external_link": "Dış bağlantı", + "active.kind.dde": "DDE alanı", + "active.kind.embedded": "Gömülü nesne", "preset.group_pdf_edit": "Düzenle", "preset.pdf_crop": "PDF kırp", "preset.pdf_crop.short": "Kırp", @@ -371,6 +391,8 @@ "general.language": "Dil", "general.language_auto": "Sistem varsayılanı", "general.start_with_system": "Oturum açtığımda Filee'yi başlat", + "general.startup_disabled": "Windows, oturum açılınca Filee'nin başlamasını kapattı (Görev Yöneticisi → Başlangıç uygulamaları); bu yüzden Filee kendiliğinden başlamıyor.", + "general.startup_enable": "Yeniden aç", "general.show_in_dock": "Filee'yi Dock'ta göster", "general.show_menu_bar_icon": "Filee'yi menü çubuğunda göster", "general.dock_failed": "Filee'nin Dock simgesi değiştirilemedi.", @@ -504,6 +526,7 @@ "triggers.excluded_note": "İşaretçi bu uygulamaların üzerindeyken hareketler ve kısayollar çalışmaz.", "triggers.paused": "Kısayollar duraklatıldı", "toolbar.title": "Halka araç çubuğu", + "toolbar.keyboard_hint": "Klavyeyle: oklar bir dilim seçer, Enter düzenler, Ctrl + ok taşır.", "toolbar.step_profile": "1. Profil seçin", "toolbar.step_arrange": "2. Ön ayarları düzenleyin", "toolbar.add_profile": "Yeni profil", @@ -876,6 +899,31 @@ "engines.state.unpacking": "Kuruluyor…", "engines.state.installed": "Kuruldu", "engines.state.failed": "Başarısız: {0}", + + "security.title": "Güvenlik ve sınırlar", + "security.active_content": "Makrolu belgeler", + "security.active_content_hint": "Office belgeleri makrolar, ActiveX denetimleri, dış dosya ya da adreslere bağlantılar ve DDE alanları içerebilir. Filee, LibreOffice belgeyi açmadan önce bunları denetler.", + "security.policy": "Belgede makro veya başka etkin içerik olduğunda", + "security.policy.Ask": "Bana sor", + "security.policy.Ask_hint": "Filee bulduklarını gösterir, siz karar verirsiniz. İzlenen klasörler ve komut satırı soramaz: dönüştürürler ve sonuçta belgenin neler içerdiği listelenir.", + "security.policy.Convert": "Sormadan dönüştür", + "security.policy.Convert_hint": "Bu belgeler diğerleri gibi dönüştürülür. Sonuç yine de neler içerdiklerini listeler.", + "security.policy.Refuse": "Asla dönüştürme", + "security.policy.Refuse_hint": "Etkin içerikli belgeler açılmaz. Sonuç nedenini belirtir.", + "security.never_run": "Ne seçerseniz seçin Filee makro veya betik çalıştırmaz: LibreOffice makrolar kapalıyken dönüştürür ve bağlantıları güncellemez.", + "security.images": "Resimler", + "security.images_hint": "Çok büyük resimler için sınırlar; örneğin açıldığında milyarlarca piksele dönüşen küçük dosyalar olan \"resim bombaları\".", + "security.max_megapixels": "En büyük resim (megapiksel)", + "security.max_side": "En uzun kenar (piksel)", + "security.max_memory": "Resimler için bellek (MB)", + "security.max_disk": "Resimler için disk alanı (MB)", + "security.zero_auto": "0 = otomatik: Filee'nin kendi sınırı yok.", + "security.conversions": "Dönüştürmeler", + "security.parallel_files": "Aynı anda dönüştürülen dosyalar", + "security.parallel_hint": "0 = işlemci çekirdeği başına bir ({0} bu bilgisayarda). Daha az, büyük dosyalarda bellek tasarrufu sağlar.", + "security.step_timeout": "Adım başına süre sınırı (dakika)", + "security.step_timeout_hint": "Daha uzun süren yerleşik motor durdurulur ve dosya takılmış olarak başarısız olur. LibreOffice, FFmpeg ve diğer programlar kendi sınırlarını korur. 0 = sınır yok.", + "security.reset_limits": "Sınırları sıfırla", "about.title": "Hakkında", "about.updates": "Güncellemeler", "common.search": "Ara", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 340de83b0..29c945ee3 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -10,6 +10,7 @@ "nav.watch": "自动转换文件夹", "nav.theme": "主题", "nav.engines": "转换引擎", + "nav.security": "安全与限制", "nav.about": "关于", "tray.open": "打开 Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "CSV 中没有一行能读出位置", "error.geo_unsupported_shape": "不支持此类形状", "error.geo_too_large": "将超过 Shapefile 2 GB 的上限", + "error.active_content_refused": "文档包含宏或其他活动内容(“设置 → 安全与限制”)", + "error.conversion_timeout": "引擎耗时过长,已停止(“设置 → 安全与限制”)", + "error.image_too_large": "图片超出“设置 → 安全与限制”中的上限", "notice.pdf_not_smaller": "无法再缩小,已原样复制", "notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)", "notice.original_kept": "已保留原文件,结果另存为副本", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "部分行无法读出位置,已不带形状保存", "notice.geo_field_names": "Shapefile 字段名最多 10 个字符,部分名称已缩短", "notice.geo_shapefile_split": "Shapefile 只能保存一种形状,因此点、线、面被分别存成了多个文件", + "notice.active_content": "文档包含宏或其他活动内容,均未运行", + "notice.active_content_skipped": "已跳过:文档包含宏或其他活动内容", "engine.reason.not_installed": "未安装", "engine.reason.error": "检查时出错", @@ -314,6 +320,20 @@ "preset.merge_pdf": "合并 PDF", "merge.order_title": "选择合并顺序", "merge.order_hint": "选择文件并上下移动。文件将按从上到下的顺序合并。(Alt+↑ / Alt+↓)", + "active.title": "宏与活动内容", + "active.summary": "包含宏或其他活动内容的文件:{0} 个", + "active.hint": "Filee 从不运行它们:LibreOffice 会在关闭宏的情况下打开这些文件,也不会更新链接。只有信任其来源时才转换。", + "active.remember": "不再询问", + "active.skip": "跳过这些文件", + "active.trust": "信任并转换", + "active.starts_by_itself": "打开文档时会自动启动一个宏。", + "active.kind.macro": "宏", + "active.kind.autorun": "自动启动", + "active.kind.script": "脚本", + "active.kind.activex": "ActiveX 控件", + "active.kind.external_link": "外部链接", + "active.kind.dde": "DDE 域", + "active.kind.embedded": "嵌入对象", "preset.group_pdf_edit": "编辑", "preset.pdf_crop": "裁剪 PDF", "preset.pdf_crop.short": "裁剪", @@ -383,6 +403,8 @@ "general.language": "语言", "general.language_auto": "跟随系统", "general.start_with_system": "登录时启动 Filee", + "general.startup_disabled": "Windows 已关闭登录时启动 Filee(“任务管理器 → 启动应用”),因此 Filee 不会自动启动。", + "general.startup_enable": "重新开启", "general.show_in_dock": "在程序坞中显示 Filee", "general.show_menu_bar_icon": "在菜单栏中显示 Filee 图标", "general.dock_failed": "无法更改 Filee 在程序坞中的图标。", @@ -518,6 +540,7 @@ "triggers.paused": "快捷操作已暂停", "toolbar.title": "环形工具栏", + "toolbar.keyboard_hint": "使用键盘:方向键选择扇区,Enter 编辑,Ctrl + 方向键移动。", "toolbar.step_profile": "1. 选择配置", "toolbar.step_arrange": "2. 排列预设", "toolbar.add_profile": "新建配置", @@ -894,6 +917,31 @@ "engines.state.installed": "已安装", "engines.state.failed": "失败:{0}", + "security.title": "安全与限制", + "security.active_content": "含宏的文档", + "security.active_content_hint": "Office 文档可能包含宏、ActiveX 控件、指向外部文件或地址的链接以及 DDE 域。在 LibreOffice 打开文档之前,Filee 会先检查这些内容。", + "security.policy": "当文档包含宏或其他活动内容时", + "security.policy.Ask": "询问我", + "security.policy.Ask_hint": "Filee 会显示找到的内容,由你决定。自动转换文件夹和命令行无法询问:它们会直接转换,并在结果中列出文档包含的内容。", + "security.policy.Convert": "直接转换,不询问", + "security.policy.Convert_hint": "这些文档会像其他文档一样转换,结果中仍会列出其包含的内容。", + "security.policy.Refuse": "从不转换", + "security.policy.Refuse_hint": "不会打开包含活动内容的文档,结果中会说明原因。", + "security.never_run": "无论选择哪项,Filee 都不会运行宏或脚本:LibreOffice 在关闭宏的情况下转换,也不会更新链接。", + "security.images": "图片", + "security.images_hint": "针对超大图片的限制,例如“图片炸弹”:解压后可达数十亿像素的小文件。", + "security.max_megapixels": "最大图片(百万像素)", + "security.max_side": "最长边(像素)", + "security.max_memory": "图片内存(MB)", + "security.max_disk": "图片磁盘空间(MB)", + "security.zero_auto": "0 = 自动:Filee 不另设上限。", + "security.conversions": "转换", + "security.parallel_files": "同时转换的文件数", + "security.parallel_hint": "0 = 每个处理器核心一个(本机 {0} 个)。数量少一些可在处理大文件时节省内存。", + "security.step_timeout": "每步时间上限(分钟)", + "security.step_timeout_hint": "内置引擎超过此时间会被停止,文件按卡住处理为失败。LibreOffice、FFmpeg 等程序使用各自的限制。0 = 不限制。", + "security.reset_limits": "重置限制", + "about.title": "关于", "about.updates": "更新", "common.search": "搜索", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ee508a742..7eace6daa 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -10,6 +10,7 @@ "nav.watch": "自動轉換資料夾", "nav.theme": "主題", "nav.engines": "轉換引擎", + "nav.security": "安全與限制", "nav.about": "關於", "tray.open": "開啟 Filee", @@ -113,6 +114,9 @@ "error.geo_csv_no_locations": "CSV 中沒有任何一列能讀出位置", "error.geo_unsupported_shape": "不支援此類形狀", "error.geo_too_large": "將超過 Shapefile 2 GB 的上限", + "error.active_content_refused": "文件包含巨集或其他作用中內容(「設定 → 安全與限制」)", + "error.conversion_timeout": "引擎耗時過長,已停止(「設定 → 安全與限制」)", + "error.image_too_large": "圖片超出「設定 → 安全與限制」中的上限", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", "notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)", "notice.original_kept": "已保留原始檔案,結果另存為副本", @@ -135,6 +139,8 @@ "notice.geo_rows_without_location": "部分列無法讀出位置,已不帶形狀儲存", "notice.geo_field_names": "Shapefile 欄位名最多 10 個字元,部分名稱已縮短", "notice.geo_shapefile_split": "Shapefile 只能儲存一種形狀,因此點、線、面被分別存成了多個檔案", + "notice.active_content": "文件包含巨集或其他作用中內容,均未執行", + "notice.active_content_skipped": "已略過:文件包含巨集或其他作用中內容", "engine.reason.not_installed": "未安裝", "engine.reason.error": "檢查時出錯", @@ -314,6 +320,20 @@ "preset.merge_pdf": "合併 PDF", "merge.order_title": "選擇合併順序", "merge.order_hint": "選擇檔案並上下移動。檔案將按從上到下的順序合併。(Alt+↑ / Alt+↓)", + "active.title": "巨集與作用中內容", + "active.summary": "包含巨集或其他作用中內容的檔案:{0} 個", + "active.hint": "Filee 從不執行它們:LibreOffice 會在停用巨集的情況下開啟這些檔案,也不會更新連結。只有信任其來源時才轉換。", + "active.remember": "不再詢問", + "active.skip": "略過這些檔案", + "active.trust": "信任並轉換", + "active.starts_by_itself": "開啟文件時會自動啟動一個巨集。", + "active.kind.macro": "巨集", + "active.kind.autorun": "自動啟動", + "active.kind.script": "指令碼", + "active.kind.activex": "ActiveX 控制項", + "active.kind.external_link": "外部連結", + "active.kind.dde": "DDE 欄位", + "active.kind.embedded": "內嵌物件", "preset.group_pdf_edit": "編輯", "preset.pdf_crop": "裁剪 PDF", "preset.pdf_crop.short": "裁剪", @@ -383,6 +403,8 @@ "general.language": "語言", "general.language_auto": "跟隨系統", "general.start_with_system": "登入時啟動 Filee", + "general.startup_disabled": "Windows 已關閉登入時啟動 Filee(「工作管理員 → 啟動應用程式」),因此 Filee 不會自動啟動。", + "general.startup_enable": "重新開啟", "general.show_in_dock": "在 Dock 中顯示 Filee", "general.show_menu_bar_icon": "在選單列中顯示 Filee 圖示", "general.dock_failed": "無法變更 Filee 在 Dock 中的圖示。", @@ -518,6 +540,7 @@ "triggers.paused": "快捷操作已暫停", "toolbar.title": "環形工具列", + "toolbar.keyboard_hint": "使用鍵盤:方向鍵選擇扇區,Enter 編輯,Ctrl + 方向鍵移動。", "toolbar.step_profile": "1. 選擇設定檔", "toolbar.step_arrange": "2. 排列預設", "toolbar.add_profile": "新增設定檔", @@ -894,6 +917,31 @@ "engines.state.installed": "已安裝", "engines.state.failed": "失敗:{0}", + "security.title": "安全與限制", + "security.active_content": "含巨集的文件", + "security.active_content_hint": "Office 文件可能包含巨集、ActiveX 控制項、指向外部檔案或位址的連結以及 DDE 欄位。在 LibreOffice 開啟文件之前,Filee 會先檢查這些內容。", + "security.policy": "當文件包含巨集或其他作用中內容時", + "security.policy.Ask": "詢問我", + "security.policy.Ask_hint": "Filee 會顯示找到的內容,由你決定。自動轉換資料夾和命令列無法詢問:它們會直接轉換,並在結果中列出文件包含的內容。", + "security.policy.Convert": "直接轉換,不詢問", + "security.policy.Convert_hint": "這些文件會像其他文件一樣轉換,結果中仍會列出其包含的內容。", + "security.policy.Refuse": "從不轉換", + "security.policy.Refuse_hint": "不會開啟包含作用中內容的文件,結果中會說明原因。", + "security.never_run": "無論選擇哪項,Filee 都不會執行巨集或指令碼:LibreOffice 在停用巨集的情況下轉換,也不會更新連結。", + "security.images": "圖片", + "security.images_hint": "針對超大圖片的限制,例如「圖片炸彈」:解壓後可達數十億像素的小檔案。", + "security.max_megapixels": "最大圖片(百萬像素)", + "security.max_side": "最長邊(像素)", + "security.max_memory": "圖片記憶體(MB)", + "security.max_disk": "圖片磁碟空間(MB)", + "security.zero_auto": "0 = 自動:Filee 不另設上限。", + "security.conversions": "轉換", + "security.parallel_files": "同時轉換的檔案數", + "security.parallel_hint": "0 = 每個處理器核心一個(本機 {0} 個)。數量少一些可在處理大檔案時節省記憶體。", + "security.step_timeout": "每步時間上限(分鐘)", + "security.step_timeout_hint": "內建引擎超過此時間會被停止,檔案按卡住處理為失敗。LibreOffice、FFmpeg 等程式使用各自的限制。0 = 不限制。", + "security.reset_limits": "重設限制", + "about.title": "關於", "about.updates": "更新", "common.search": "搜尋", diff --git a/src/Filee.App/Controls/DonutMenu.Accessibility.cs b/src/Filee.App/Controls/DonutMenu.Accessibility.cs new file mode 100644 index 000000000..abbea1e38 --- /dev/null +++ b/src/Filee.App/Controls/DonutMenu.Accessibility.cs @@ -0,0 +1,266 @@ +// DonutMenu: screen readers, the keyboard in the editor, a focus ring and high contrast. +// +// The ring is drawn by hand (no child controls), so a screen reader would find nothing in it: DonutMenuAutomationPeer +// presents the donut as a list and every slice as an item that can be selected (the keyboard's slice) and invoked +// (convert, open a group; in the editor: edit it, as ✎ does), with a disabled slice's reason as its help text. +// In the editor (Settings → Donut toolbar) the arrows move between slices, Enter / Space / F2 / the context-menu key +// edit one and Ctrl + arrow moves it; the ✎ button shows for the keyboard's slice as for the hovered one. +// With the system's high contrast on, the "high-contrast" class draws the ring in the theme's text colour on its +// background, with heavier outlines (Styles/Controls.axaml). + +using Avalonia; +using Avalonia.Automation; +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Platform; + +namespace Filee.App.Controls; + +public sealed partial class DonutMenu +{ + private DonutMenuAutomationPeer? _peer; + private IPlatformSettings? _platformSettings; + + /// Ctrl + arrow in the editor: move the slice From to position To. + public event EventHandler<(int From, int To)>? SegmentMoveRequested; + + /// The system asks for high contrast (Windows: Settings → Accessibility → Contrast themes). + public bool IsHighContrast => Classes.Contains("high-contrast"); + + protected override AutomationPeer OnCreateAutomationPeer() => _peer = new DonutMenuAutomationPeer(this); + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _platformSettings = Application.Current?.PlatformSettings; + if (_platformSettings is null) + return; + _platformSettings.ColorValuesChanged += OnColorValuesChanged; + OnColorValuesChanged(this, _platformSettings.GetColorValues()); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + if (_platformSettings is not null) + _platformSettings.ColorValuesChanged -= OnColorValuesChanged; + _platformSettings = null; + base.OnDetachedFromVisualTree(e); + } + + private void OnColorValuesChanged(object? sender, PlatformColorValues values) + { + Classes.Set("high-contrast", values.ContrastPreference == ColorContrastPreference.High); + InvalidateVisual(); + } + + protected override void OnGotFocus(FocusChangedEventArgs e) + { + base.OnGotFocus(e); + InvalidateVisual(); + } + + protected override void OnLostFocus(FocusChangedEventArgs e) + { + base.OnLostFocus(e); + InvalidateVisual(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (IsEditMode && HandleEditKey(e.Key, e.KeyModifiers)) + { + e.Handled = true; + return; + } + base.OnKeyDown(e); + } + + /// + /// The editor's keyboard: arrows choose a slice, Enter / Space / F2 / the context-menu key edit it, Ctrl + arrow + /// moves it. Returns false for keys left to the page (Tab moves the focus on). + /// + public bool HandleEditKey(Key key, KeyModifiers modifiers = KeyModifiers.None) + { + if (!IsEditMode || Count == 0) + return false; + var current = _hoverIndex; + var step = key switch + { + Key.Right or Key.Down => 1, + Key.Left or Key.Up => -1, + _ => 0, + }; + if (step != 0 && current >= 0 && modifiers.HasFlag(KeyModifiers.Control)) + { + var to = (current + step + Count) % Count; + SegmentMoveRequested?.Invoke(this, (current, to)); + SetHover(to); + return true; + } + if (step != 0) + { + SetHover(current < 0 ? (step > 0 ? 0 : Count - 1) : (current + step + Count) % Count); + return true; + } + if (key is Key.Enter or Key.Space or Key.F2 or Key.Apps && current >= 0) + { + SegmentContextRequested?.Invoke(this, current); + return true; + } + return false; + } + + /// A screen reader chose a slice (selection pattern): it becomes the keyboard's slice. + internal void SelectFromAutomation(int index) + { + if (index < 0 || index >= Count) + return; + SetHover(index); + _keyboardHold = true; + _keyAnchor = _lastPointer; + } + + /// A screen reader invoked a slice: edit it in the editor, else what Enter does (convert or open a group). + internal void InvokeFromAutomation(int index) + { + if (index < 0 || index >= Count) + return; + if (IsEditMode) + { + SegmentContextRequested?.Invoke(this, index); + return; + } + SelectFromAutomation(index); + HandleKey(Key.Enter); + } + + /// The slice a screen reader can show: its box in this control's coordinates. + internal Rect SliceBounds(int index) => + index < 0 || index >= Count ? default : CreateWedge(Center, InnerRadius, OuterRadius, index, Count).Bounds; + + internal DonutItem? ItemAt(int index) => Items is { } items && index >= 0 && index < items.Count ? items[index] : null; + + /// Tells a screen reader the keyboard's slice changed (called by SetHover). + private void AnnounceHover(int previous, int current) => _peer?.SelectionChanged(previous, current); + + /// A ring around the donut while the keyboard has the focus on it. + private void DrawFocusRing(DrawingContext context) + { + if (!IsFocused || !PseudoClasses.Contains(":focus-visible") || _progressMode) + return; + var pen = new Pen(AccentBrush ?? Foreground, IsHighContrast ? 3 : 2); + var radius = OuterRadius + 4; + context.DrawEllipse(null, pen, Center, radius, radius); + } +} + +/// The donut for screen readers: a list of slices, the keyboard's slice selected. +internal sealed class DonutMenuAutomationPeer(DonutMenu owner) : ControlAutomationPeer(owner), ISelectionProvider +{ + private readonly List _slices = []; + + public DonutMenu Menu { get; } = owner; + + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.List; + + protected override string? GetNameCore() => + base.GetNameCore() is { Length: > 0 } name ? name : Menu.CenterTitle; + + protected override IReadOnlyList? GetChildrenCore() + { + var count = Menu.Items?.Count ?? 0; + while (_slices.Count < count) + _slices.Add(new DonutSliceAutomationPeer(this, _slices.Count)); + if (_slices.Count > count) + _slices.RemoveRange(count, _slices.Count - count); + return _slices; + } + + bool ISelectionProvider.CanSelectMultiple => false; + bool ISelectionProvider.IsSelectionRequired => false; + + IReadOnlyList ISelectionProvider.GetSelection() => + Slice(Menu.ActiveIndex) is { } slice ? [slice] : []; + + /// Raises the selection change for the slices that lost and got it. + internal void SelectionChanged(int previous, int current) + { + GetChildrenCore(); + Slice(previous)?.RaisePropertyChangedEvent(SelectionItemPatternIdentifiers.IsSelectedProperty, true, false); + Slice(current)?.RaisePropertyChangedEvent(SelectionItemPatternIdentifiers.IsSelectedProperty, false, true); + RaisePropertyChangedEvent(SelectionPatternIdentifiers.SelectionProperty, null, null); + } + + private DonutSliceAutomationPeer? Slice(int index) => index >= 0 && index < _slices.Count ? _slices[index] : null; +} + +/// One slice for screen readers: its label, why it's disabled, select and invoke. +internal sealed class DonutSliceAutomationPeer(DonutMenuAutomationPeer parent, int index) + : AutomationPeer, IInvokeProvider, ISelectionItemProvider +{ + private DonutMenu Menu => parent.Menu; + private DonutItem? Item => Menu.ItemAt(index); + + protected override void BringIntoViewCore() + { + } + + protected override string? GetAcceleratorKeyCore() => null; + protected override string? GetAccessKeyCore() => null; + protected override AutomationControlType GetAutomationControlTypeCore() => AutomationControlType.ListItem; + protected override string? GetAutomationIdCore() => Item?.Id; + + protected override Rect GetBoundingRectangleCore() + { + var box = Menu.SliceBounds(index); + return TopLevel.GetTopLevel(Menu) is { } root && Menu.TransformToVisual(root) is { } transform + ? box.TransformToAABB(transform) + : box; + } + + protected override IReadOnlyList GetOrCreateChildrenCore() => []; + protected override string GetClassNameCore() => "DonutSlice"; + protected override AutomationPeer? GetLabeledByCore() => null; + + protected override string? GetNameCore() => Item is { } item + ? item.Caption is { Length: > 0 } caption && caption != item.Label ? $"{item.Label}, {caption}" : item.Label + : null; + + protected override string? GetHelpTextCore() => Item is { IsEnabled: false } item ? item.DisabledReason : null; + protected override AutomationPeer? GetParentCore() => parent; + protected override bool HasKeyboardFocusCore() => Menu.IsFocused && Menu.ActiveIndex == index; + protected override bool IsContentElementCore() => true; + protected override bool IsControlElementCore() => true; + protected override bool IsEnabledCore() => Item?.IsEnabled ?? false; + protected override bool IsKeyboardFocusableCore() => Menu.Focusable; + + protected override void SetFocusCore() + { + Menu.Focus(NavigationMethod.Tab); + Menu.SelectFromAutomation(index); + } + + protected override bool ShowContextMenuCore() + { + if (!Menu.IsEditMode) + return false; + Menu.InvokeFromAutomation(index); + return true; + } + + protected override bool TrySetParent(AutomationPeer? newParent) => ReferenceEquals(newParent, parent); + + void IInvokeProvider.Invoke() => Menu.InvokeFromAutomation(index); + + bool ISelectionItemProvider.IsSelected => Menu.ActiveIndex == index; + ISelectionProvider? ISelectionItemProvider.SelectionContainer => parent; + void ISelectionItemProvider.Select() => Menu.SelectFromAutomation(index); + void ISelectionItemProvider.AddToSelection() => Menu.SelectFromAutomation(index); + + void ISelectionItemProvider.RemoveFromSelection() + { + } +} diff --git a/src/Filee.App/Controls/DonutMenu.Animation.cs b/src/Filee.App/Controls/DonutMenu.Animation.cs new file mode 100644 index 000000000..01023c28c --- /dev/null +++ b/src/Filee.App/Controls/DonutMenu.Animation.cs @@ -0,0 +1,250 @@ +// DonutMenu: the per-frame animation loop. Springs for hover, open/close and layout; with +// Motion.Enabled == false every value jumps to its target. + +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using Filee.App.Services; + +namespace Filee.App.Controls; + +public sealed partial class DonutMenu +{ + // ───────────────────────── Animation ───────────────────────── + + /// Damping at which a spring of stiffness 320 settles without overshooting (2·√320). + private const double CriticalDamping = 36; + + /// Underdamped spring: gives the soft "boing" when a slice pops out. + private struct Spring + { + public double Value; + public double Velocity; + + /// + /// Longest time one integration step covers. With these stiffnesses a single step of a late frame (50 ms) + /// overshoots more each frame instead of settling: slices stayed lit and flew apart on a busy machine. + /// + private const double MaxStep = 1 / 120.0; + + public bool Step(double target, double dt, double stiffness = 320, double damping = 20) + { + var steps = Math.Max(1, (int)Math.Ceiling(dt / MaxStep)); + var h = dt / steps; + for (var i = 0; i < steps; i++) + { + var force = stiffness * (target - Value) - damping * Velocity; + Velocity += force * h; + Value += Velocity * h; + } + var settled = Math.Abs(target - Value) < 0.001 && Math.Abs(Velocity) < 0.01; + if (settled) + { + Value = target; + Velocity = 0; + } + return !settled; + } + } + + /// + /// A frame loop that got no frame for this long is treated as stopped: a frame request can be lost (the window + /// hidden in between, for one), and every later animation would wait for it, so the slice under a dragged file + /// would never light up. + /// + internal const long StalledLoopMs = 250; + + private int _loop; // the current frame loop; frames of an older one are ignored + private long _lastTick; // Environment.TickCount64 of the last frame or frame request + + /// Frame requests asked again before the animation is finished at once (see ). + internal const int FrameRetries = 2; + + private int _stalls; // times the current animation's frames stopped coming + + private void StartAnimation() + { + InvalidateVisual(); + if (_animating && Environment.TickCount64 - _lastTick < StalledLoopMs) + return; + _stalls = 0; + StartLoop(); + } + + private void StartLoop() + { + var top = TopLevel.GetTopLevel(this); + if (top is null) + { + _animating = false; + return; + } + _animating = true; + _lastFrame = null; + _lastTick = Environment.TickCount64; + var loop = ++_loop; + top.RequestAnimationFrame(time => OnFrame(time, loop)); + Watch(loop); + } + + private void Watch(int loop) => + DispatcherTimer.RunOnce(() => + { + if (loop == _loop) + CheckLoop(); + }, TimeSpan.FromMilliseconds(StalledLoopMs + 50)); + + /// + /// Watches the frame loop without waiting for another animation call. When its frames stop (a request lost + /// while the window was hidden, or the renderer waking up after a long idle), the frame is asked for again; after + /// stalls the animations jump to their end, so an opening donut always appears. Stalls + /// are counted per animation: frames that trickle in once per retry would otherwise open the donut over seconds. + /// + internal void CheckLoop() + { + if (!_animating) + return; + if (Environment.TickCount64 - _lastTick < StalledLoopMs) + { + Watch(_loop); // frames are coming: keep watching until the loop ends + return; + } + if (++_stalls <= FrameRetries) + { + StartLoop(); + return; + } + _animating = false; + _loop++; + FinishAnimations(); + } + + /// + /// Sets the opening, the highlights and the slice layout to where they end (a closing donut closes). The progress + /// ring keeps its own timers. + /// + internal void FinishAnimations() + { + for (var i = 0; i < _hover.Length; i++) + _hover[i] = new Spring { Value = i == ActiveIndex ? 1 : 0 }; + _centerPulse = default; + _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; + if (_expanded < 0) + _shownExpanded = -1; + StepLayout(0, snap: true); + FinishFlip(); + if (_openClock < double.MaxValue) + { + _openClock = double.MaxValue; + Array.Fill(_open, _closing ? 0 : 1); + if (_closing) + FinishClose(); + } + InvalidateVisual(); + } + + private void OnFrame(TimeSpan time, int loop) + { + if (loop != _loop) + return; // a loop that was given up on as stalled, replaced by a newer one + _lastTick = Environment.TickCount64; + var dt = _lastFrame is { } last ? Math.Clamp((time - last).TotalSeconds, 0, 0.05) : 1 / 60.0; + _lastFrame = time; + var active = Advance(dt); + + InvalidateVisual(); + if (active && TopLevel.GetTopLevel(this) is { } top) + top.RequestAnimationFrame(next => OnFrame(next, loop)); + else + _animating = false; + } + + /// + /// Advances every animation by seconds. Returns true while more frames are needed. + /// Internal so tests can step time deterministically. + /// + internal bool Advance(double dt) + { + var active = false; + + if (!Motion.Enabled) + { + for (var i = 0; i < _hover.Length; i++) + { + _hover[i].Value = i == ActiveIndex ? 1 : 0; + _hover[i].Velocity = 0; + } + Array.Fill(_open, _closing ? 0 : 1); + _centerPulse = default; + _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; + if (_expanded < 0) + _shownExpanded = -1; + StepLayout(dt); + FinishFlip(); // animations switched off halfway through a flip + if (_closing) + FinishClose(); + active |= StepProgress(dt); // hold timers still run without animation + } + else + { + // Popping out bounces; letting go settles without a bounce: one below zero would make the faded accent + // flash once more on its way back. + for (var i = 0; i < _hover.Length; i++) + active |= i == ActiveIndex ? _hover[i].Step(1, dt) : _hover[i].Step(0, dt, 320, CriticalDamping); + active |= _centerPulse.Step(0, dt, 260, 12); + // A soft bounce opening, like the slices popping out; folding away settles without one. The bounce grows the + // ring by about a pixel, well inside the room kept for it (SubRingSpace). + active |= _expanded >= 0 ? _expand.Step(1, dt, 320, 24) : _expand.Step(0, dt, 320, CriticalDamping); + if (_expanded < 0 && _expand.Value <= 0.001) + _shownExpanded = -1; + active |= StepProgress(dt); + active |= StepLayout(dt); + active |= StepFlip(dt); + + if (_openClock < double.MaxValue) + { + _openClock += dt; + var done = true; + for (var i = 0; i < _open.Length; i++) + { + var t = Math.Clamp((_openClock - i * (_closing ? 0.01 : OpenStagger)) / (_closing ? 0.16 : OpenDuration), 0, 1); + _open[i] = _closing ? 1 - EaseInCubic(t) : EaseOutBack(t); + done &= t >= 1; + } + if (done) + { + _openClock = double.MaxValue; + if (_closing) + FinishClose(); + } + else + { + active = true; + } + } + } + + return active; + } + + private void FinishClose() + { + _closing = false; + var callback = _closeCallback; + _closeCallback = null; + callback?.Invoke(); + } + + private static double EaseOutBack(double t) + { + const double c1 = 1.55; + const double c3 = c1 + 1; + return 1 + c3 * Math.Pow(t - 1, 3) + c1 * Math.Pow(t - 1, 2); + } + + private static double EaseInCubic(double t) => t * t * t; +} diff --git a/src/Filee.App/Controls/DonutMenu.Input.cs b/src/Filee.App/Controls/DonutMenu.Input.cs new file mode 100644 index 000000000..70506c9c8 --- /dev/null +++ b/src/Filee.App/Controls/DonutMenu.Input.cs @@ -0,0 +1,161 @@ +// DonutMenu: pointer input. Hover, press, drag of a slice (editor) and release on a slice or the hub. + +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using Filee.App.Services; + +namespace Filee.App.Controls; + +public sealed partial class DonutMenu +{ + // ───────────────────────── Input ───────────────────────── + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var p = e.GetPosition(this); + if (_progressMode) + { + OnProgressPointerMoved(p); + return; + } + + if (_press is { } press && IsEditMode && Distance(p, press.Start) > DragStartDistance && press.Index >= 0) + { + _press = null; + SegmentDragStarted?.Invoke(this, (press.Index, e)); + return; + } + + if (_keyboardHold) + { + if (_keyAnchor is not { } anchor) + _keyAnchor = anchor = p; + if (Distance(p, anchor) < KeyboardJitter) + return; + _keyboardHold = false; + } + _lastPointer = p; + var hit = HitTest(p); + if (IsFlipping) + PointAtAngle(DonutGeometry.AngleOf(Center, p), hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment); + TrackGroups(hit, p); + SetHover(hit.Kind switch + { + DonutHitKind.Segment or DonutHitKind.EditButton or DonutHitKind.SubSegment => hit.Index, + _ => -1, + }); + // On a group's ring, why that slice (not the group) is disabled. + var hovered = hit.Kind == DonutHitKind.SubSegment ? Items![hit.Index].Children![hit.Sub] + : _hoverIndex >= 0 ? Items![_hoverIndex] : null; + ToolTip.SetTip(this, hovered is { IsEnabled: false } ? hovered.DisabledReason : null); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (_progressMode) + { + OnProgressPointerMoved(new Point(double.MaxValue / 4, 0)); + return; + } + if (_press is null && !LeftThroughSeam(e.GetPosition(this))) + { + CollapseGroup(); + SetHover(-1); + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var point = e.GetCurrentPoint(this); + var hit = HitTest(point.Position); + + if (_progressMode) + { + if (point.Properties.IsLeftButtonPressed) + { + _press = (-1, point.Position, e); + e.Handled = true; + } + return; + } + + if (point.Properties.IsRightButtonPressed) + { + if (hit.Kind is DonutHitKind.Segment or DonutHitKind.EditButton && (IsEditMode || AllowContextEdit)) + { + SegmentContextRequested?.Invoke(this, hit.Index); + e.Handled = true; + } + else if (hit.Kind == DonutHitKind.SubSegment && AllowContextEdit) + { + SubSegmentContextRequested?.Invoke(this, (hit.Index, hit.Sub)); + e.Handled = true; + } + return; + } + + if (point.Properties.IsLeftButtonPressed) + { + _press = (hit.Kind == DonutHitKind.Segment ? hit.Index : -1, point.Position, e); + e.Handled = true; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_press is null) + return; + _press = null; + + if (_progressMode) + { + OnProgressPointerReleased(e.GetPosition(this)); + e.Handled = true; + return; + } + + var hit = HitTest(e.GetPosition(this)); + switch (hit.Kind) + { + case DonutHitKind.EditButton: + SegmentContextRequested?.Invoke(this, hit.Index); + break; + case DonutHitKind.Segment when !IsEditMode && Items![hit.Index] is { IsEnabled: true, IsGroup: false }: + SegmentInvoked?.Invoke(this, hit.Index); + break; + case DonutHitKind.Segment when !IsEditMode && Items![hit.Index].Children is { Count: > 0 }: + Expand(hit.Index); // no waiting when clicked; a tap has no hover to rest on at all + break; + case DonutHitKind.Outside when !IsEditMode && _expanded >= 0: + CollapseGroup(); // clicked past the ring + break; + case DonutHitKind.SubSegment when !IsEditMode && Items![hit.Index].Children![hit.Sub].IsEnabled: + SubSegmentInvoked?.Invoke(this, (hit.Index, hit.Sub)); + break; + case DonutHitKind.Center when !IsEditMode: + CenterInvoked?.Invoke(this, EventArgs.Empty); + break; + } + e.Handled = true; + } + + private void SetHover(int index) + { + if (_hoverIndex == index) + return; + var previous = _hoverIndex; + _hoverIndex = index; + ActiveIndexChanged?.Invoke(this, ActiveIndex); + AnnounceHover(previous, index); + StartAnimation(); + } +} diff --git a/src/Filee.App/Controls/DonutMenu.Rendering.cs b/src/Filee.App/Controls/DonutMenu.Rendering.cs new file mode 100644 index 000000000..842346ea5 --- /dev/null +++ b/src/Filee.App/Controls/DonutMenu.Rendering.cs @@ -0,0 +1,416 @@ +// DonutMenu: drawing. The ring of wedges, labels, the hub, the editor's ✎ button and the drop preview. + +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using Filee.App.Services; + +namespace Filee.App.Controls; + +public sealed partial class DonutMenu +{ + // ───────────────────────── Rendering ───────────────────────── + + public override void Render(DrawingContext context) + { + var center = Center; + var r2 = OuterRadius; + var r1 = InnerRadius; + var n = Count; + var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold); + var typefaceRegular = new Typeface(GetValue(TextElement.FontFamilyProperty)); + + if (_progressMode) + { + RenderProgress(context, center, r1, r2, typeface, typefaceRegular); + return; + } + + RenderRing(context, center, r1, r2, n, typeface, typefaceRegular); + + var reaching = _expanded >= 0 ? _expanded : _pendingGroup; + if (!IsEditMode && reaching >= 0 && reaching < n && Items![reaching].Children is { Count: > 0 } reachable) + DrawHitBackdrop(context, center, reaching, reachable.Count); + if (!IsEditMode && _shownExpanded >= 0 && _shownExpanded < n && _expand.Value > 0.01) + DrawSubRing(context, center, typeface, typefaceRegular); + if (HasOldGroupRing) + DrawOldSubRing(context, center, typeface, typefaceRegular); + + DrawCenter(context, center, r1, typeface, typefaceRegular); + + if (IsEditMode && _hoverIndex >= 0 && _hoverIndex < n && _dimmedIndex < 0 && !IsFlipping) + DrawEditButton(context, EditButtonCenter(_hoverIndex)); + DrawFocusRing(context); + } + + /// The shadow, the slices and the edit mode's drop preview. + private void RenderRing(DrawingContext context, Point center, double r1, double r2, int n, Typeface typeface, Typeface typefaceRegular) + { + DrawSoftShadow(context, center, r2); + + if (n == 0) + { + var ring = CreateRing(center, r1, r2); + context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity), BorderPen(), ring); + if (_dropTargetIndex >= 0) + context.DrawGeometry(WithOpacity(AccentBrush, 0.35), null, ring); + } + + for (var i = 0; i < n; i++) + { + if (IsFlipping && SliceFlipWidth(false, i) == 0) + continue; + var item = Items![i]; + var open = i < _open.Length ? _open[i] : 1; + if (open <= 0.001) + continue; + + var previewing = IsEditMode && i < _mid.Length; + var hover = i < _hover.Length && _previewSlot < 0 ? _hover[i].Value : 0; + var mid = previewing ? _mid[i].Value : DonutGeometry.MidAngle(i, n); + var span = previewing ? _span[i].Value : DonutGeometry.Span(n); + if (span < 0.01) + continue; + var offset = DonutGeometry.PointAt(new Point(0, 0), hover * PopOut, mid); + + // Open animation: scale up from the centre with a small twist. + var scale = 0.55 + 0.45 * open; + var rotate = (1 - Math.Min(open, 1)) * -0.35; + var transform = + Matrix.CreateTranslation(-center.X, -center.Y) * + Matrix.CreateRotation(rotate) * + Matrix.CreateScale(scale * (1 + hover * 0.025), scale * (1 + hover * 0.025)) * + Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * + SliceFlipTransform(false, i); + + using (context.PushTransform(transform)) + using (context.PushOpacity(Math.Clamp(open, 0, 1) * (i == _dimmedIndex ? 0.3 : 1))) + { + var wedge = CreateWedge(center, r1, r2, mid, span); + context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); + + // The accent fades in and out with the highlight (no floor: a floor made it vanish with a jump). + var emphasis = i == _dropTargetIndex ? 1.0 : hover; + if (emphasis > 0.01 && item.IsEnabled) + context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(emphasis, 0, 1)), null, wedge); + + var hot = (i == ActiveIndex || i == _dropTargetIndex) && item.IsEnabled; + DrawLabel(context, item, center, (r1 + r2) / 2, mid, span * (r1 + r2) / 2 - 10, + r2 - r1 - 16, typeface, typefaceRegular, hot); + } + } + + if (_oldFace is { } old) + for (var i = 0; i < old.Length; i++) + { + var slice = old[i]; + if (SliceFlipWidth(true, i) == 0 || slice.Open <= 0.001) + continue; + var offset = DonutGeometry.PointAt(new Point(0, 0), slice.Hover * PopOut, slice.Mid); + var scale = 0.55 + 0.45 * slice.Open; + var rotate = (1 - Math.Min(slice.Open, 1)) * -0.35; + var transform = Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateRotation(rotate) * + Matrix.CreateScale(scale * (1 + slice.Hover * 0.025), scale * (1 + slice.Hover * 0.025)) * + Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * + SliceFlipTransform(true, i); + using (context.PushTransform(transform)) + using (context.PushOpacity(Math.Clamp(slice.Open, 0, 1) * (slice.Dimmed ? 0.3 : 1))) + { + var wedge = CreateWedge(center, r1, r2, slice.Mid, slice.Span); + context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (slice.Item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); + if (slice.Emphasis > 0.01 && slice.Item.IsEnabled) + context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(slice.Emphasis, 0, 1)), null, wedge); + DrawLabel(context, slice.Item, center, (r1 + r2) / 2, slice.Mid, + slice.Span * (r1 + r2) / 2 - 10, r2 - r1 - 16, typeface, typefaceRegular, + slice.Hot && slice.Item.IsEnabled); + } + } + + if (IsEditMode && _previewSpan.Value > 0.01) + DrawPreview(context, center, r1, r2, typeface, typefaceRegular); + } + + private void DrawSoftShadow(DrawingContext context, Point center, double r2) + { + // A radial gradient ring fakes a soft drop shadow without blur effects (cheap on any GPU). + var shadowRadius = r2 + 14; + var brush = new RadialGradientBrush + { + GradientStops = + { + new GradientStop(Color.FromArgb(0, 0, 0, 0), 0), + new GradientStop(Color.FromArgb(0, 0, 0, 0), (r2 - 18) / shadowRadius), + new GradientStop(Color.FromArgb(46, 0, 0, 0), (r2 - 2) / shadowRadius), + new GradientStop(Color.FromArgb(0, 0, 0, 0), 1), + }, + }; + var opening = _open.Length == 0 ? 1 : Math.Clamp(_open.Average(), 0, 1); + using (context.PushOpacity(opening * ShadowCoverage)) + context.DrawEllipse(brush, null, new Point(center.X, center.Y + 4), shadowRadius, shadowRadius); + } + + private void DrawCenter(DrawingContext context, Point center, double r1, Typeface bold, Typeface regular) + { + var pulse = 1 + _centerPulse.Value * 0.06; + var radius = (r1 - 6) * pulse; + var cancelHot = _externalCenterHot; + context.DrawEllipse(CenterBrush, BorderPen(), center, radius, radius); + if (cancelHot) + context.DrawEllipse(WithOpacity(DangerBrush, 0.18), null, center, radius, radius); + + var maxWidth = radius * 1.6; + var title = MakeText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); + var subtitle = MakeText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); + // "Tab ↻ back": small, under the rest, a little narrower (the hub is round). + var hint = string.IsNullOrEmpty(CenterHint) ? null : MakeText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85); + var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5); + var y = center.Y - total / 2; + DrawCentered(context, title, center.X, y); + y += title.Height; + if (!string.IsNullOrEmpty(CenterSubtitle)) + { + DrawCentered(context, subtitle, center.X, y + 2); + y += subtitle.Height + 2; + } + if (hint is not null) + { + using (context.PushOpacity(0.85)) + { + if (!CenterHintWheel) + { + DrawCentered(context, hint, center.X, y + 5); + } + else + { + // A small mouse in front: "🖱 ↻ back" when the wheel flips and no key does. + const double iconWidth = 7, gap = 4; + var left = center.X - (hint.Width + iconWidth + gap) / 2; + DrawMouse(context, new Rect(left, y + 5 + (hint.Height - 10) / 2, iconWidth, 10), SecondaryForeground); + DrawCentered(context, hint, center.X + (iconWidth + gap) / 2, y + 5); + } + } + } + } + + /// An outline of a mouse with its wheel, filling . + private static void DrawMouse(DrawingContext context, Rect box, IBrush? brush) + { + var pen = new Pen(brush, 1.1); + context.DrawRectangle(null, pen, box, box.Width / 2, box.Width / 2); + context.DrawLine(pen, new Point(box.Center.X, box.Top + 2), new Point(box.Center.X, box.Top + 4.5)); + } + + private void DrawLabel(DrawingContext context, DonutItem item, Point center, double radius, double angle, + double maxWidth, double maxHeight, Typeface bold, Typeface regular, bool hot) + { + var foreground = hot ? OnAccentBrush : item.IsEnabled ? Foreground : SecondaryForeground; + var width = Math.Clamp(maxWidth, 30, 120); + var text = KeepKoreanWords(item.Label); + var label = MakeText(text, bold, LabelSize(text, bold, width), foreground, width, maxLines: 2); + var showCaption = !string.IsNullOrEmpty(item.Caption) && + !string.Equals(item.Caption, item.Label, StringComparison.OrdinalIgnoreCase) && + label.Height + 14 < maxHeight; + var caption = showCaption + ? MakeCaption(item.Caption!, regular, hot ? OnAccentBrush : SecondaryForeground, width, label.Height, maxHeight) + : null; + + var total = label.Height + (caption?.Height ?? 0); + var anchor = DonutGeometry.PointAt(center, radius, angle); + var y = anchor.Y - total / 2; + DrawCentered(context, label, anchor.X, y); + if (caption is not null) + DrawCentered(context, caption, anchor.X, y + label.Height); + } + + /// + /// A slice's caption under its name: one line, or two where the slice is deep enough, so a caption wider than the + /// slice ("Same format" on a small donut) wraps instead of ending in "Same for…". + /// + internal static FormattedText MakeCaption(string caption, Typeface typeface, IBrush? brush, double width, + double labelHeight, double maxHeight) => + MakeText(caption, typeface, 10.5, brush, width, maxLines: labelHeight + 28 < maxHeight ? 2 : 1); + + private const double LabelFontSize = 13; + private const double MinLabelFontSize = 10; + private readonly Dictionary<(string Text, Typeface Typeface, double Width), double> _labelSizes = new(); + + /// + /// The size a slice's name is drawn at: 13, or smaller (down to 10) when one of its words is wider than the slice + /// (), which would otherwise break in the middle ("Grayscal|e" on a thin slice). Words in + /// Chinese or Japanese are left out: those break between any two characters. + /// + internal double LabelSize(string text, Typeface typeface, double width) + { + var line = Math.Round(width); + var key = (text, typeface, line); + if (_labelSizes.TryGetValue(key, out var size)) + return size; + if (_labelSizes.Count >= MaxCachedWedges) + _labelSizes.Clear(); + var widest = text.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Where(word => !word.Any(IsHanOrKana)) + .Select(word => new FormattedText(word, CultureInfo.CurrentUICulture, TextDirection, typeface, LabelFontSize, null).Width) + .DefaultIfEmpty(0) + .Max(); + // A pixel to spare: a word exactly as wide as the line still wraps. + size = widest < line ? LabelFontSize : Math.Max(MinLabelFontSize, LabelFontSize * (line - 1) / widest); + return _labelSizes[key] = size; + } + + /// Chinese characters, kana and the CJK symbols and full-width forms: a line may break between any two. + private static bool IsHanOrKana(char c) => + c is >= '\u2E80' and <= '\u312F' or >= '\u3190' and <= '\u9FFF' or >= '\uF900' and <= '\uFAFF' or >= '\uFF00' and <= '\uFFEF'; + + /// + /// Korean is written with spaces between words and wraps there, but the text layout also breaks between any two + /// Hangul syllables ("ZIP 하나|로"). A word joiner between them keeps each word on one line. + /// + internal static string KeepKoreanWords(string text) + { + if (!text.Any(IsHangul)) + return text; + var kept = new System.Text.StringBuilder(text.Length * 2); + for (var i = 0; i < text.Length; i++) + { + kept.Append(text[i]); + if (i + 1 < text.Length && IsHangul(text[i]) && IsHangul(text[i + 1])) + kept.Append('\u2060'); + } + return kept.ToString(); + } + + private static bool IsHangul(char c) => c is >= '\uAC00' and <= '\uD7A3'; + + private Point EditButtonCenter(int index) + { + var mid = DonutGeometry.MidAngle(index, Count); + return DonutGeometry.PointAt(Center, OuterRadius - 14 + PopOut, mid); + } + + private void DrawEditButton(DrawingContext context, Point at) + { + context.DrawEllipse(AccentBrush, new Pen(CenterBrush, 2), at, 12, 12); + var bounds = PencilIcon.Bounds; + var s = 13 / Math.Max(bounds.Width, bounds.Height); + var matrix = Matrix.CreateTranslation(-bounds.Center.X, -bounds.Center.Y) * Matrix.CreateScale(s, s) * Matrix.CreateTranslation(at.X, at.Y); + using (context.PushTransform(matrix)) + context.DrawGeometry(OnAccentBrush, null, PencilIcon); + } + + /// + /// Draws text horizontally centred on . + /// The text is laid out with inside a box of , + /// so the box (not the measured ) must be centred — otherwise short labels + /// drift right by (box − text) / 2. + /// + internal static void DrawCentered(DrawingContext context, FormattedText text, double centerX, double top) => + context.DrawText(text, new Point(centerX - text.MaxTextWidth / 2, top)); + + private Pen? BorderPen() => SliceBorderBrush is null ? null : new Pen(SliceBorderBrush, IsHighContrast ? 2 : 1); + + private static FormattedText MakeText(string text, Typeface typeface, double size, IBrush? brush, double maxWidth, int maxLines = 1) => + new(text, CultureInfo.CurrentUICulture, TextDirection, typeface, size, brush) + { + MaxTextWidth = Math.Max(10, maxWidth), + MaxLineCount = maxLines, + Trimming = TextTrimming.CharacterEllipsis, + TextAlignment = TextAlignment.Center, + }; + + private static FlowDirection TextDirection => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft + ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; + + private static IBrush? WithOpacity(IBrush? brush, double opacity) => brush switch + { + ISolidColorBrush solid => new SolidColorBrush(solid.Color, solid.Opacity * opacity), + _ => brush, + }; + + private static double Distance(Point a, Point b) => Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2)); + + /// The translucent slice where a dragged preset would land, with its name. + private void DrawPreview(DrawingContext context, Point center, double r1, double r2, Typeface bold, Typeface regular) + { + var mid = _previewMid.Value; + var span = _previewSpan.Value; + var wedge = CreateWedge(center, r1, r2, mid, span); + var outline = new Pen(WithOpacity(AccentBrush, 0.9), 1.5, new DashStyle([4, 3], 0)); + context.DrawGeometry(WithOpacity(AccentBrush, 0.22), outline, wedge); + if (_previewLabel is null || span < 0.25) + return; + using (context.PushOpacity(Math.Clamp((span - 0.25) / 0.3, 0, 1) * 0.9)) + DrawLabel(context, new DonutItem { Id = "", Label = _previewLabel }, center, (r1 + r2) / 2, mid, + span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot: false); + } + + /// + /// How round the slices' corners are: half the theme's softness (0 to 28), so the largest setting still leaves the + /// slices clearly apart. shrinks it for slices too thin for it. + /// + internal double SliceCornerRadius => Math.Clamp(CornerRadius.TopLeft, 0, 28) * 0.5; + + private StreamGeometry CreateWedge(Point c, double r1, double r2, int index, int count) => + count == 1 ? CreateRing(c, r1, r2) : CreateWedge(c, r1, r2, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count)); + + /// A rounded slice of radians centred on (a ring when it is all of it). + private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span) => + CreateWedge(c, r1, r2, mid, span, SliceCornerRadius); + + // Slices are drawn every frame while the donut animates, but their shapes only change with the size, the layout + // or the softness: the shapes are kept and reused. Animations move them with transforms; only the edit mode's + // sliding slices make new ones, and the cache starts over when it fills up. + private readonly Dictionary<(Point C, double R1, double R2, double Mid, double Span, double Corner), StreamGeometry> _wedges = new(); + private const int MaxCachedWedges = 256; + + private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) + { + var key = (c, r1, r2, mid, span, cornerRadius); + if (_wedges.TryGetValue(key, out var cached)) + return cached; + if (_wedges.Count >= MaxCachedWedges) + _wedges.Clear(); + return _wedges[key] = BuildWedge(c, r1, r2, mid, span, cornerRadius); + } + + /// A slice with a constant-width gap to its neighbours and corners rounded by . + private static StreamGeometry BuildWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) + { + if (span >= 2 * Math.PI - 0.001) + return CreateRing(c, r1, r2); + + var outline = DonutGeometry.Wedge(c, r1, r2, mid, span, GapPixels, cornerRadius); + var geometry = new StreamGeometry(); + using var ctx = geometry.Open(); + ctx.BeginFigure(outline.Start, true); + foreach (var step in outline.Steps) + { + if (step.IsArc) + ctx.ArcTo(step.To, new Size(step.ArcRadius, step.ArcRadius), 0, step.IsLargeArc, + step.Clockwise ? SweepDirection.Clockwise : SweepDirection.CounterClockwise); + else + ctx.LineTo(step.To); + } + ctx.EndFigure(true); + return geometry; + } + + /// Full ring (used for a single slice and for the empty donut). + private static StreamGeometry CreateRing(Point c, double r1, double r2) + { + var geometry = new StreamGeometry(); + using var ctx = geometry.Open(); + ctx.SetFillRule(FillRule.EvenOdd); + foreach (var r in new[] { r2, r1 }) + { + ctx.BeginFigure(new Point(c.X, c.Y - r), true); + ctx.ArcTo(new Point(c.X, c.Y + r), new Size(r, r), 0, false, SweepDirection.Clockwise); + ctx.ArcTo(new Point(c.X, c.Y - r), new Size(r, r), 0, false, SweepDirection.Clockwise); + ctx.EndFigure(true); + } + return geometry; + } +} diff --git a/src/Filee.App/Controls/DonutMenu.cs b/src/Filee.App/Controls/DonutMenu.cs index b7f16df7b..8df6927cf 100644 --- a/src/Filee.App/Controls/DonutMenu.cs +++ b/src/Filee.App/Controls/DonutMenu.cs @@ -143,6 +143,8 @@ public DonutMenu() FlowDirection = FlowDirection.LeftToRight; ClipToBounds = false; Cursor = new Cursor(StandardCursorType.Hand); + // A tab stop, so the keyboard reaches the editor's donut (DonutMenu.Accessibility.cs). + Focusable = true; } public IReadOnlyList? Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } @@ -424,781 +426,4 @@ private void OnItemsChanged() InvalidateVisual(); } - // ───────────────────────── Input ───────────────────────── - - protected override void OnPointerMoved(PointerEventArgs e) - { - base.OnPointerMoved(e); - var p = e.GetPosition(this); - if (_progressMode) - { - OnProgressPointerMoved(p); - return; - } - - if (_press is { } press && IsEditMode && Distance(p, press.Start) > DragStartDistance && press.Index >= 0) - { - _press = null; - SegmentDragStarted?.Invoke(this, (press.Index, e)); - return; - } - - if (_keyboardHold) - { - if (_keyAnchor is not { } anchor) - _keyAnchor = anchor = p; - if (Distance(p, anchor) < KeyboardJitter) - return; - _keyboardHold = false; - } - _lastPointer = p; - var hit = HitTest(p); - if (IsFlipping) - PointAtAngle(DonutGeometry.AngleOf(Center, p), hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment); - TrackGroups(hit, p); - SetHover(hit.Kind switch - { - DonutHitKind.Segment or DonutHitKind.EditButton or DonutHitKind.SubSegment => hit.Index, - _ => -1, - }); - // On a group's ring, why that slice (not the group) is disabled. - var hovered = hit.Kind == DonutHitKind.SubSegment ? Items![hit.Index].Children![hit.Sub] - : _hoverIndex >= 0 ? Items![_hoverIndex] : null; - ToolTip.SetTip(this, hovered is { IsEnabled: false } ? hovered.DisabledReason : null); - } - - protected override void OnPointerExited(PointerEventArgs e) - { - base.OnPointerExited(e); - if (_progressMode) - { - OnProgressPointerMoved(new Point(double.MaxValue / 4, 0)); - return; - } - if (_press is null && !LeftThroughSeam(e.GetPosition(this))) - { - CollapseGroup(); - SetHover(-1); - } - } - - protected override void OnPointerPressed(PointerPressedEventArgs e) - { - base.OnPointerPressed(e); - var point = e.GetCurrentPoint(this); - var hit = HitTest(point.Position); - - if (_progressMode) - { - if (point.Properties.IsLeftButtonPressed) - { - _press = (-1, point.Position, e); - e.Handled = true; - } - return; - } - - if (point.Properties.IsRightButtonPressed) - { - if (hit.Kind is DonutHitKind.Segment or DonutHitKind.EditButton && (IsEditMode || AllowContextEdit)) - { - SegmentContextRequested?.Invoke(this, hit.Index); - e.Handled = true; - } - else if (hit.Kind == DonutHitKind.SubSegment && AllowContextEdit) - { - SubSegmentContextRequested?.Invoke(this, (hit.Index, hit.Sub)); - e.Handled = true; - } - return; - } - - if (point.Properties.IsLeftButtonPressed) - { - _press = (hit.Kind == DonutHitKind.Segment ? hit.Index : -1, point.Position, e); - e.Handled = true; - } - } - - protected override void OnPointerReleased(PointerReleasedEventArgs e) - { - base.OnPointerReleased(e); - if (_press is null) - return; - _press = null; - - if (_progressMode) - { - OnProgressPointerReleased(e.GetPosition(this)); - e.Handled = true; - return; - } - - var hit = HitTest(e.GetPosition(this)); - switch (hit.Kind) - { - case DonutHitKind.EditButton: - SegmentContextRequested?.Invoke(this, hit.Index); - break; - case DonutHitKind.Segment when !IsEditMode && Items![hit.Index] is { IsEnabled: true, IsGroup: false }: - SegmentInvoked?.Invoke(this, hit.Index); - break; - case DonutHitKind.Segment when !IsEditMode && Items![hit.Index].Children is { Count: > 0 }: - Expand(hit.Index); // no waiting when clicked; a tap has no hover to rest on at all - break; - case DonutHitKind.Outside when !IsEditMode && _expanded >= 0: - CollapseGroup(); // clicked past the ring - break; - case DonutHitKind.SubSegment when !IsEditMode && Items![hit.Index].Children![hit.Sub].IsEnabled: - SubSegmentInvoked?.Invoke(this, (hit.Index, hit.Sub)); - break; - case DonutHitKind.Center when !IsEditMode: - CenterInvoked?.Invoke(this, EventArgs.Empty); - break; - } - e.Handled = true; - } - - private void SetHover(int index) - { - if (_hoverIndex == index) - return; - _hoverIndex = index; - ActiveIndexChanged?.Invoke(this, ActiveIndex); - StartAnimation(); - } - - // ───────────────────────── Animation ───────────────────────── - - /// Damping at which a spring of stiffness 320 settles without overshooting (2·√320). - private const double CriticalDamping = 36; - - /// Underdamped spring: gives the soft "boing" when a slice pops out. - private struct Spring - { - public double Value; - public double Velocity; - - /// - /// Longest time one integration step covers. With these stiffnesses a single step of a late frame (50 ms) - /// overshoots more each frame instead of settling: slices stayed lit and flew apart on a busy machine. - /// - private const double MaxStep = 1 / 120.0; - - public bool Step(double target, double dt, double stiffness = 320, double damping = 20) - { - var steps = Math.Max(1, (int)Math.Ceiling(dt / MaxStep)); - var h = dt / steps; - for (var i = 0; i < steps; i++) - { - var force = stiffness * (target - Value) - damping * Velocity; - Velocity += force * h; - Value += Velocity * h; - } - var settled = Math.Abs(target - Value) < 0.001 && Math.Abs(Velocity) < 0.01; - if (settled) - { - Value = target; - Velocity = 0; - } - return !settled; - } - } - - /// - /// A frame loop that got no frame for this long is treated as stopped: a frame request can be lost (the window - /// hidden in between, for one), and every later animation would wait for it, so the slice under a dragged file - /// would never light up. - /// - internal const long StalledLoopMs = 250; - - private int _loop; // the current frame loop; frames of an older one are ignored - private long _lastTick; // Environment.TickCount64 of the last frame or frame request - - /// Frame requests asked again before the animation is finished at once (see ). - internal const int FrameRetries = 2; - - private int _stalls; // times the current animation's frames stopped coming - - private void StartAnimation() - { - InvalidateVisual(); - if (_animating && Environment.TickCount64 - _lastTick < StalledLoopMs) - return; - _stalls = 0; - StartLoop(); - } - - private void StartLoop() - { - var top = TopLevel.GetTopLevel(this); - if (top is null) - { - _animating = false; - return; - } - _animating = true; - _lastFrame = null; - _lastTick = Environment.TickCount64; - var loop = ++_loop; - top.RequestAnimationFrame(time => OnFrame(time, loop)); - Watch(loop); - } - - private void Watch(int loop) => - DispatcherTimer.RunOnce(() => - { - if (loop == _loop) - CheckLoop(); - }, TimeSpan.FromMilliseconds(StalledLoopMs + 50)); - - /// - /// Watches the frame loop without waiting for another animation call. When its frames stop (a request lost - /// while the window was hidden, or the renderer waking up after a long idle), the frame is asked for again; after - /// stalls the animations jump to their end, so an opening donut always appears. Stalls - /// are counted per animation: frames that trickle in once per retry would otherwise open the donut over seconds. - /// - internal void CheckLoop() - { - if (!_animating) - return; - if (Environment.TickCount64 - _lastTick < StalledLoopMs) - { - Watch(_loop); // frames are coming: keep watching until the loop ends - return; - } - if (++_stalls <= FrameRetries) - { - StartLoop(); - return; - } - _animating = false; - _loop++; - FinishAnimations(); - } - - /// - /// Sets the opening, the highlights and the slice layout to where they end (a closing donut closes). The progress - /// ring keeps its own timers. - /// - internal void FinishAnimations() - { - for (var i = 0; i < _hover.Length; i++) - _hover[i] = new Spring { Value = i == ActiveIndex ? 1 : 0 }; - _centerPulse = default; - _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; - if (_expanded < 0) - _shownExpanded = -1; - StepLayout(0, snap: true); - FinishFlip(); - if (_openClock < double.MaxValue) - { - _openClock = double.MaxValue; - Array.Fill(_open, _closing ? 0 : 1); - if (_closing) - FinishClose(); - } - InvalidateVisual(); - } - - private void OnFrame(TimeSpan time, int loop) - { - if (loop != _loop) - return; // a loop that was given up on as stalled, replaced by a newer one - _lastTick = Environment.TickCount64; - var dt = _lastFrame is { } last ? Math.Clamp((time - last).TotalSeconds, 0, 0.05) : 1 / 60.0; - _lastFrame = time; - var active = Advance(dt); - - InvalidateVisual(); - if (active && TopLevel.GetTopLevel(this) is { } top) - top.RequestAnimationFrame(next => OnFrame(next, loop)); - else - _animating = false; - } - - /// - /// Advances every animation by seconds. Returns true while more frames are needed. - /// Internal so tests can step time deterministically. - /// - internal bool Advance(double dt) - { - var active = false; - - if (!Motion.Enabled) - { - for (var i = 0; i < _hover.Length; i++) - { - _hover[i].Value = i == ActiveIndex ? 1 : 0; - _hover[i].Velocity = 0; - } - Array.Fill(_open, _closing ? 0 : 1); - _centerPulse = default; - _expand = new Spring { Value = _expanded >= 0 ? 1 : 0 }; - if (_expanded < 0) - _shownExpanded = -1; - StepLayout(dt); - FinishFlip(); // animations switched off halfway through a flip - if (_closing) - FinishClose(); - active |= StepProgress(dt); // hold timers still run without animation - } - else - { - // Popping out bounces; letting go settles without a bounce: one below zero would make the faded accent - // flash once more on its way back. - for (var i = 0; i < _hover.Length; i++) - active |= i == ActiveIndex ? _hover[i].Step(1, dt) : _hover[i].Step(0, dt, 320, CriticalDamping); - active |= _centerPulse.Step(0, dt, 260, 12); - // A soft bounce opening, like the slices popping out; folding away settles without one. The bounce grows the - // ring by about a pixel, well inside the room kept for it (SubRingSpace). - active |= _expanded >= 0 ? _expand.Step(1, dt, 320, 24) : _expand.Step(0, dt, 320, CriticalDamping); - if (_expanded < 0 && _expand.Value <= 0.001) - _shownExpanded = -1; - active |= StepProgress(dt); - active |= StepLayout(dt); - active |= StepFlip(dt); - - if (_openClock < double.MaxValue) - { - _openClock += dt; - var done = true; - for (var i = 0; i < _open.Length; i++) - { - var t = Math.Clamp((_openClock - i * (_closing ? 0.01 : OpenStagger)) / (_closing ? 0.16 : OpenDuration), 0, 1); - _open[i] = _closing ? 1 - EaseInCubic(t) : EaseOutBack(t); - done &= t >= 1; - } - if (done) - { - _openClock = double.MaxValue; - if (_closing) - FinishClose(); - } - else - { - active = true; - } - } - } - - return active; - } - - private void FinishClose() - { - _closing = false; - var callback = _closeCallback; - _closeCallback = null; - callback?.Invoke(); - } - - private static double EaseOutBack(double t) - { - const double c1 = 1.55; - const double c3 = c1 + 1; - return 1 + c3 * Math.Pow(t - 1, 3) + c1 * Math.Pow(t - 1, 2); - } - - private static double EaseInCubic(double t) => t * t * t; - - // ───────────────────────── Rendering ───────────────────────── - - public override void Render(DrawingContext context) - { - var center = Center; - var r2 = OuterRadius; - var r1 = InnerRadius; - var n = Count; - var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold); - var typefaceRegular = new Typeface(GetValue(TextElement.FontFamilyProperty)); - - if (_progressMode) - { - RenderProgress(context, center, r1, r2, typeface, typefaceRegular); - return; - } - - RenderRing(context, center, r1, r2, n, typeface, typefaceRegular); - - var reaching = _expanded >= 0 ? _expanded : _pendingGroup; - if (!IsEditMode && reaching >= 0 && reaching < n && Items![reaching].Children is { Count: > 0 } reachable) - DrawHitBackdrop(context, center, reaching, reachable.Count); - if (!IsEditMode && _shownExpanded >= 0 && _shownExpanded < n && _expand.Value > 0.01) - DrawSubRing(context, center, typeface, typefaceRegular); - if (HasOldGroupRing) - DrawOldSubRing(context, center, typeface, typefaceRegular); - - DrawCenter(context, center, r1, typeface, typefaceRegular); - - if (IsEditMode && _hoverIndex >= 0 && _hoverIndex < n && _dimmedIndex < 0 && !IsFlipping) - DrawEditButton(context, EditButtonCenter(_hoverIndex)); - } - - /// The shadow, the slices and the edit mode's drop preview. - private void RenderRing(DrawingContext context, Point center, double r1, double r2, int n, Typeface typeface, Typeface typefaceRegular) - { - DrawSoftShadow(context, center, r2); - - if (n == 0) - { - var ring = CreateRing(center, r1, r2); - context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity), BorderPen(), ring); - if (_dropTargetIndex >= 0) - context.DrawGeometry(WithOpacity(AccentBrush, 0.35), null, ring); - } - - for (var i = 0; i < n; i++) - { - if (IsFlipping && SliceFlipWidth(false, i) == 0) - continue; - var item = Items![i]; - var open = i < _open.Length ? _open[i] : 1; - if (open <= 0.001) - continue; - - var previewing = IsEditMode && i < _mid.Length; - var hover = i < _hover.Length && _previewSlot < 0 ? _hover[i].Value : 0; - var mid = previewing ? _mid[i].Value : DonutGeometry.MidAngle(i, n); - var span = previewing ? _span[i].Value : DonutGeometry.Span(n); - if (span < 0.01) - continue; - var offset = DonutGeometry.PointAt(new Point(0, 0), hover * PopOut, mid); - - // Open animation: scale up from the centre with a small twist. - var scale = 0.55 + 0.45 * open; - var rotate = (1 - Math.Min(open, 1)) * -0.35; - var transform = - Matrix.CreateTranslation(-center.X, -center.Y) * - Matrix.CreateRotation(rotate) * - Matrix.CreateScale(scale * (1 + hover * 0.025), scale * (1 + hover * 0.025)) * - Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * - SliceFlipTransform(false, i); - - using (context.PushTransform(transform)) - using (context.PushOpacity(Math.Clamp(open, 0, 1) * (i == _dimmedIndex ? 0.3 : 1))) - { - var wedge = CreateWedge(center, r1, r2, mid, span); - context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); - - // The accent fades in and out with the highlight (no floor: a floor made it vanish with a jump). - var emphasis = i == _dropTargetIndex ? 1.0 : hover; - if (emphasis > 0.01 && item.IsEnabled) - context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(emphasis, 0, 1)), null, wedge); - - var hot = (i == ActiveIndex || i == _dropTargetIndex) && item.IsEnabled; - DrawLabel(context, item, center, (r1 + r2) / 2, mid, span * (r1 + r2) / 2 - 10, - r2 - r1 - 16, typeface, typefaceRegular, hot); - } - } - - if (_oldFace is { } old) - for (var i = 0; i < old.Length; i++) - { - var slice = old[i]; - if (SliceFlipWidth(true, i) == 0 || slice.Open <= 0.001) - continue; - var offset = DonutGeometry.PointAt(new Point(0, 0), slice.Hover * PopOut, slice.Mid); - var scale = 0.55 + 0.45 * slice.Open; - var rotate = (1 - Math.Min(slice.Open, 1)) * -0.35; - var transform = Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateRotation(rotate) * - Matrix.CreateScale(scale * (1 + slice.Hover * 0.025), scale * (1 + slice.Hover * 0.025)) * - Matrix.CreateTranslation(center.X + offset.X, center.Y + offset.Y) * - SliceFlipTransform(true, i); - using (context.PushTransform(transform)) - using (context.PushOpacity(Math.Clamp(slice.Open, 0, 1) * (slice.Dimmed ? 0.3 : 1))) - { - var wedge = CreateWedge(center, r1, r2, slice.Mid, slice.Span); - context.DrawGeometry(WithOpacity(SliceBrush, SliceOpacity * (slice.Item.IsEnabled ? 1 : 0.55)), BorderPen(), wedge); - if (slice.Emphasis > 0.01 && slice.Item.IsEnabled) - context.DrawGeometry(WithOpacity(AccentBrush, 0.82 * Math.Clamp(slice.Emphasis, 0, 1)), null, wedge); - DrawLabel(context, slice.Item, center, (r1 + r2) / 2, slice.Mid, - slice.Span * (r1 + r2) / 2 - 10, r2 - r1 - 16, typeface, typefaceRegular, - slice.Hot && slice.Item.IsEnabled); - } - } - - if (IsEditMode && _previewSpan.Value > 0.01) - DrawPreview(context, center, r1, r2, typeface, typefaceRegular); - } - - private void DrawSoftShadow(DrawingContext context, Point center, double r2) - { - // A radial gradient ring fakes a soft drop shadow without blur effects (cheap on any GPU). - var shadowRadius = r2 + 14; - var brush = new RadialGradientBrush - { - GradientStops = - { - new GradientStop(Color.FromArgb(0, 0, 0, 0), 0), - new GradientStop(Color.FromArgb(0, 0, 0, 0), (r2 - 18) / shadowRadius), - new GradientStop(Color.FromArgb(46, 0, 0, 0), (r2 - 2) / shadowRadius), - new GradientStop(Color.FromArgb(0, 0, 0, 0), 1), - }, - }; - var opening = _open.Length == 0 ? 1 : Math.Clamp(_open.Average(), 0, 1); - using (context.PushOpacity(opening * ShadowCoverage)) - context.DrawEllipse(brush, null, new Point(center.X, center.Y + 4), shadowRadius, shadowRadius); - } - - private void DrawCenter(DrawingContext context, Point center, double r1, Typeface bold, Typeface regular) - { - var pulse = 1 + _centerPulse.Value * 0.06; - var radius = (r1 - 6) * pulse; - var cancelHot = _externalCenterHot; - context.DrawEllipse(CenterBrush, BorderPen(), center, radius, radius); - if (cancelHot) - context.DrawEllipse(WithOpacity(DangerBrush, 0.18), null, center, radius, radius); - - var maxWidth = radius * 1.6; - var title = MakeText(CenterTitle ?? "", bold, 14, Foreground, maxWidth, maxLines: 2); - var subtitle = MakeText(CenterSubtitle ?? "", regular, 11.5, cancelHot ? DangerBrush : SecondaryForeground, maxWidth, maxLines: 2); - // "Tab ↻ back": small, under the rest, a little narrower (the hub is round). - var hint = string.IsNullOrEmpty(CenterHint) ? null : MakeText(CenterHint, regular, 10.5, SecondaryForeground, maxWidth * 0.85); - var total = title.Height + (string.IsNullOrEmpty(CenterSubtitle) ? 0 : subtitle.Height + 2) + (hint is null ? 0 : hint.Height + 5); - var y = center.Y - total / 2; - DrawCentered(context, title, center.X, y); - y += title.Height; - if (!string.IsNullOrEmpty(CenterSubtitle)) - { - DrawCentered(context, subtitle, center.X, y + 2); - y += subtitle.Height + 2; - } - if (hint is not null) - { - using (context.PushOpacity(0.85)) - { - if (!CenterHintWheel) - { - DrawCentered(context, hint, center.X, y + 5); - } - else - { - // A small mouse in front: "🖱 ↻ back" when the wheel flips and no key does. - const double iconWidth = 7, gap = 4; - var left = center.X - (hint.Width + iconWidth + gap) / 2; - DrawMouse(context, new Rect(left, y + 5 + (hint.Height - 10) / 2, iconWidth, 10), SecondaryForeground); - DrawCentered(context, hint, center.X + (iconWidth + gap) / 2, y + 5); - } - } - } - } - - /// An outline of a mouse with its wheel, filling . - private static void DrawMouse(DrawingContext context, Rect box, IBrush? brush) - { - var pen = new Pen(brush, 1.1); - context.DrawRectangle(null, pen, box, box.Width / 2, box.Width / 2); - context.DrawLine(pen, new Point(box.Center.X, box.Top + 2), new Point(box.Center.X, box.Top + 4.5)); - } - - private void DrawLabel(DrawingContext context, DonutItem item, Point center, double radius, double angle, - double maxWidth, double maxHeight, Typeface bold, Typeface regular, bool hot) - { - var foreground = hot ? OnAccentBrush : item.IsEnabled ? Foreground : SecondaryForeground; - var width = Math.Clamp(maxWidth, 30, 120); - var text = KeepKoreanWords(item.Label); - var label = MakeText(text, bold, LabelSize(text, bold, width), foreground, width, maxLines: 2); - var showCaption = !string.IsNullOrEmpty(item.Caption) && - !string.Equals(item.Caption, item.Label, StringComparison.OrdinalIgnoreCase) && - label.Height + 14 < maxHeight; - var caption = showCaption - ? MakeCaption(item.Caption!, regular, hot ? OnAccentBrush : SecondaryForeground, width, label.Height, maxHeight) - : null; - - var total = label.Height + (caption?.Height ?? 0); - var anchor = DonutGeometry.PointAt(center, radius, angle); - var y = anchor.Y - total / 2; - DrawCentered(context, label, anchor.X, y); - if (caption is not null) - DrawCentered(context, caption, anchor.X, y + label.Height); - } - - /// - /// A slice's caption under its name: one line, or two where the slice is deep enough, so a caption wider than the - /// slice ("Same format" on a small donut) wraps instead of ending in "Same for…". - /// - internal static FormattedText MakeCaption(string caption, Typeface typeface, IBrush? brush, double width, - double labelHeight, double maxHeight) => - MakeText(caption, typeface, 10.5, brush, width, maxLines: labelHeight + 28 < maxHeight ? 2 : 1); - - private const double LabelFontSize = 13; - private const double MinLabelFontSize = 10; - private readonly Dictionary<(string Text, Typeface Typeface, double Width), double> _labelSizes = new(); - - /// - /// The size a slice's name is drawn at: 13, or smaller (down to 10) when one of its words is wider than the slice - /// (), which would otherwise break in the middle ("Grayscal|e" on a thin slice). Words in - /// Chinese or Japanese are left out: those break between any two characters. - /// - internal double LabelSize(string text, Typeface typeface, double width) - { - var line = Math.Round(width); - var key = (text, typeface, line); - if (_labelSizes.TryGetValue(key, out var size)) - return size; - if (_labelSizes.Count >= MaxCachedWedges) - _labelSizes.Clear(); - var widest = text.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Where(word => !word.Any(IsHanOrKana)) - .Select(word => new FormattedText(word, CultureInfo.CurrentUICulture, TextDirection, typeface, LabelFontSize, null).Width) - .DefaultIfEmpty(0) - .Max(); - // A pixel to spare: a word exactly as wide as the line still wraps. - size = widest < line ? LabelFontSize : Math.Max(MinLabelFontSize, LabelFontSize * (line - 1) / widest); - return _labelSizes[key] = size; - } - - /// Chinese characters, kana and the CJK symbols and full-width forms: a line may break between any two. - private static bool IsHanOrKana(char c) => - c is >= '\u2E80' and <= '\u312F' or >= '\u3190' and <= '\u9FFF' or >= '\uF900' and <= '\uFAFF' or >= '\uFF00' and <= '\uFFEF'; - - /// - /// Korean is written with spaces between words and wraps there, but the text layout also breaks between any two - /// Hangul syllables ("ZIP 하나|로"). A word joiner between them keeps each word on one line. - /// - internal static string KeepKoreanWords(string text) - { - if (!text.Any(IsHangul)) - return text; - var kept = new System.Text.StringBuilder(text.Length * 2); - for (var i = 0; i < text.Length; i++) - { - kept.Append(text[i]); - if (i + 1 < text.Length && IsHangul(text[i]) && IsHangul(text[i + 1])) - kept.Append('\u2060'); - } - return kept.ToString(); - } - - private static bool IsHangul(char c) => c is >= '\uAC00' and <= '\uD7A3'; - - private Point EditButtonCenter(int index) - { - var mid = DonutGeometry.MidAngle(index, Count); - return DonutGeometry.PointAt(Center, OuterRadius - 14 + PopOut, mid); - } - - private void DrawEditButton(DrawingContext context, Point at) - { - context.DrawEllipse(AccentBrush, new Pen(CenterBrush, 2), at, 12, 12); - var bounds = PencilIcon.Bounds; - var s = 13 / Math.Max(bounds.Width, bounds.Height); - var matrix = Matrix.CreateTranslation(-bounds.Center.X, -bounds.Center.Y) * Matrix.CreateScale(s, s) * Matrix.CreateTranslation(at.X, at.Y); - using (context.PushTransform(matrix)) - context.DrawGeometry(OnAccentBrush, null, PencilIcon); - } - - /// - /// Draws text horizontally centred on . - /// The text is laid out with inside a box of , - /// so the box (not the measured ) must be centred — otherwise short labels - /// drift right by (box − text) / 2. - /// - internal static void DrawCentered(DrawingContext context, FormattedText text, double centerX, double top) => - context.DrawText(text, new Point(centerX - text.MaxTextWidth / 2, top)); - - private Pen? BorderPen() => SliceBorderBrush is null ? null : new Pen(SliceBorderBrush, 1); - - private static FormattedText MakeText(string text, Typeface typeface, double size, IBrush? brush, double maxWidth, int maxLines = 1) => - new(text, CultureInfo.CurrentUICulture, TextDirection, typeface, size, brush) - { - MaxTextWidth = Math.Max(10, maxWidth), - MaxLineCount = maxLines, - Trimming = TextTrimming.CharacterEllipsis, - TextAlignment = TextAlignment.Center, - }; - - private static FlowDirection TextDirection => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft - ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; - - private static IBrush? WithOpacity(IBrush? brush, double opacity) => brush switch - { - ISolidColorBrush solid => new SolidColorBrush(solid.Color, solid.Opacity * opacity), - _ => brush, - }; - - private static double Distance(Point a, Point b) => Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2)); - - /// The translucent slice where a dragged preset would land, with its name. - private void DrawPreview(DrawingContext context, Point center, double r1, double r2, Typeface bold, Typeface regular) - { - var mid = _previewMid.Value; - var span = _previewSpan.Value; - var wedge = CreateWedge(center, r1, r2, mid, span); - var outline = new Pen(WithOpacity(AccentBrush, 0.9), 1.5, new DashStyle([4, 3], 0)); - context.DrawGeometry(WithOpacity(AccentBrush, 0.22), outline, wedge); - if (_previewLabel is null || span < 0.25) - return; - using (context.PushOpacity(Math.Clamp((span - 0.25) / 0.3, 0, 1) * 0.9)) - DrawLabel(context, new DonutItem { Id = "", Label = _previewLabel }, center, (r1 + r2) / 2, mid, - span * (r1 + r2) / 2 - 10, r2 - r1 - 16, bold, regular, hot: false); - } - - /// - /// How round the slices' corners are: half the theme's softness (0 to 28), so the largest setting still leaves the - /// slices clearly apart. shrinks it for slices too thin for it. - /// - internal double SliceCornerRadius => Math.Clamp(CornerRadius.TopLeft, 0, 28) * 0.5; - - private StreamGeometry CreateWedge(Point c, double r1, double r2, int index, int count) => - count == 1 ? CreateRing(c, r1, r2) : CreateWedge(c, r1, r2, DonutGeometry.MidAngle(index, count), DonutGeometry.Span(count)); - - /// A rounded slice of radians centred on (a ring when it is all of it). - private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span) => - CreateWedge(c, r1, r2, mid, span, SliceCornerRadius); - - // Slices are drawn every frame while the donut animates, but their shapes only change with the size, the layout - // or the softness: the shapes are kept and reused. Animations move them with transforms; only the edit mode's - // sliding slices make new ones, and the cache starts over when it fills up. - private readonly Dictionary<(Point C, double R1, double R2, double Mid, double Span, double Corner), StreamGeometry> _wedges = new(); - private const int MaxCachedWedges = 256; - - private StreamGeometry CreateWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) - { - var key = (c, r1, r2, mid, span, cornerRadius); - if (_wedges.TryGetValue(key, out var cached)) - return cached; - if (_wedges.Count >= MaxCachedWedges) - _wedges.Clear(); - return _wedges[key] = BuildWedge(c, r1, r2, mid, span, cornerRadius); - } - - /// A slice with a constant-width gap to its neighbours and corners rounded by . - private static StreamGeometry BuildWedge(Point c, double r1, double r2, double mid, double span, double cornerRadius) - { - if (span >= 2 * Math.PI - 0.001) - return CreateRing(c, r1, r2); - - var outline = DonutGeometry.Wedge(c, r1, r2, mid, span, GapPixels, cornerRadius); - var geometry = new StreamGeometry(); - using var ctx = geometry.Open(); - ctx.BeginFigure(outline.Start, true); - foreach (var step in outline.Steps) - { - if (step.IsArc) - ctx.ArcTo(step.To, new Size(step.ArcRadius, step.ArcRadius), 0, step.IsLargeArc, - step.Clockwise ? SweepDirection.Clockwise : SweepDirection.CounterClockwise); - else - ctx.LineTo(step.To); - } - ctx.EndFigure(true); - return geometry; - } - - /// Full ring (used for a single slice and for the empty donut). - private static StreamGeometry CreateRing(Point c, double r1, double r2) - { - var geometry = new StreamGeometry(); - using var ctx = geometry.Open(); - ctx.SetFillRule(FillRule.EvenOdd); - foreach (var r in new[] { r2, r1 }) - { - ctx.BeginFigure(new Point(c.X, c.Y - r), true); - ctx.ArcTo(new Point(c.X, c.Y + r), new Size(r, r), 0, false, SweepDirection.Clockwise); - ctx.ArcTo(new Point(c.X, c.Y - r), new Size(r, r), 0, false, SweepDirection.Clockwise); - ctx.EndFigure(true); - } - return geometry; - } } diff --git a/src/Filee.App/Filee.App.csproj b/src/Filee.App/Filee.App.csproj index fee2ef10a..f36688e0a 100644 --- a/src/Filee.App/Filee.App.csproj +++ b/src/Filee.App/Filee.App.csproj @@ -75,5 +75,14 @@ <_Parameter1>FileeRelease <_Parameter2>true + + + <_Parameter1>FileeExpectedPublisher + <_Parameter2>$(FileeExpectedPublisher) + + + <_Parameter1>FileeUpdatePublicKey + <_Parameter2>$(FileeUpdatePublicKey) + diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 2eb90c92a..79b049228 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -58,7 +58,13 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore EngineRegistry.FindFileCombiner(sp.GetRequiredService>()), EngineRegistry.FindTiffMerger(sp.GetRequiredService>()), EngineRegistry.FindMediaJoiner(sp.GetRequiredService>()), - sp.GetRequiredService().MoveToTrash)); + sp.GetRequiredService().MoveToTrash) + { + // Documents with macros or other active content: the user is asked in a window (resolved when first + // needed, as WindowService depends on services that need the queue). The rest of Settings → Security and + // limits is applied with the other settings (App.ApplySettings). + ActiveContentPrompt = (job, reports, ct) => sp.GetRequiredService().AskActiveContentAsync(job, reports, ct), + }); // Files dragged from a browser with only an address are downloaded without the browser's cookies. services.AddSingleton(_ => new WebDownloader(userAgent: $"Filee/{typeof(AppHost).Assembly.GetName().Version?.ToString(3)}")); diff --git a/src/Filee.App/Services/ConversionService.cs b/src/Filee.App/Services/ConversionService.cs index 44b0c0fd9..c7c6d3178 100644 --- a/src/Filee.App/Services/ConversionService.cs +++ b/src/Filee.App/Services/ConversionService.cs @@ -65,8 +65,13 @@ public ConversionService(JobQueue queue, UserDataStore store, ILocalizer loc, IP /// The browser drop stand for: the job saves them first, into the folder Settings › /// General names for files from a browser. The job owns it from now on (disposed here when nothing starts). /// + /// + /// Nobody is there to answer a question (a watch folder): documents with macros or other active content follow + /// Settings → Security and limits without asking. + /// /// The job's view model, or null when there was nothing to convert. - public JobViewModel? Start(IReadOnlyList files, Preset preset, bool showInToast = true, WebDrop? web = null) + public JobViewModel? Start(IReadOnlyList files, Preset preset, bool showInToast = true, WebDrop? web = null, + bool unattended = false) { if (files.Count == 0 || preset.IsGroup || preset.IsTool) { @@ -82,7 +87,7 @@ public ConversionService(JobQueue queue, UserDataStore store, ILocalizer loc, IP // "Next to the source" is the download folder, also when the original is only kept until converted. preset = fetcher.Apply(preset); } - var job = _queue.Enqueue(files, preset, _loc.DisplayName(preset), fetcher); + var job = _queue.Enqueue(files, preset, _loc.DisplayName(preset), fetcher, unattended); var vm = new JobViewModel(job, _loc, _platform, Remove, RetryFailed); _byId[job.Id] = vm; Jobs.Insert(0, vm); @@ -180,7 +185,7 @@ private void RetryFailed(JobViewModel vm) { var vm = await Dispatcher.UIThread.InvokeAsync(() => { - var started = Start(files, preset); + var started = Start(files, preset, unattended: watchRuleId is not null); if (started is not null && watchRuleId is not null) _watchJobs[started.Job.Id] = watchRuleId; return started; diff --git a/src/Filee.App/Services/LocalizationService.cs b/src/Filee.App/Services/LocalizationService.cs index 50ab77c1a..a56b08cfe 100644 --- a/src/Filee.App/Services/LocalizationService.cs +++ b/src/Filee.App/Services/LocalizationService.cs @@ -18,7 +18,11 @@ public sealed class LocalizationService : ILocalizer /// Languages shipped with Filee: code → native name (shown in the language picker; see AppLanguages). public static readonly IReadOnlyList<(string Code, string NativeName)> Languages = AppLanguages.All; - private readonly ResourceDictionary _resources = new(); + private ResourceDictionary _resources = new(); + + /// The merged dictionaries is in (the application's). + private readonly List> _owners = []; + private readonly Dictionary _english; private Dictionary _current; @@ -55,7 +59,11 @@ public string Format(string key, params object[] args) } /// Registers the string dictionary with the application so XAML can use DynamicResource. - public void Attach(Application app) => app.Resources.MergedDictionaries.Add(_resources); + public void Attach(Application app) + { + app.Resources.MergedDictionaries.Add(_resources); + _owners.Add(app.Resources.MergedDictionaries); + } /// /// Switches the UI language. is a language code or "auto" @@ -64,21 +72,40 @@ public string Format(string key, params object[] args) public void SetLanguage(string setting) { var code = Resolve(setting); - _current = code == "en" ? _english : LoadStrings(code); - Language = code; + if (!_initialized || code != Language) + { + _current = code == "en" ? _english : LoadStrings(code); + Language = code; + Publish(); + } var culture = CultureInfo.GetCultureInfo(code); CultureInfo.CurrentUICulture = culture; CultureInfo.DefaultThreadCurrentUICulture = culture; - // Every key is published (English fallback for missing translations) so DynamicResource never breaks. - foreach (var key in _english.Keys.Concat(_current.Keys).Distinct()) - _resources[key] = this[key]; - _initialized = true; LanguageChanged?.Invoke(this, EventArgs.Empty); } + /// + /// Publishes every key (English fallback for missing translations, so DynamicResource never breaks) in a new + /// dictionary that takes the old one's place at once. Written key by key, each of the thousand writes would make + /// every DynamicResource in every open window look its key up again. + /// + private void Publish() + { + var next = new ResourceDictionary(); + foreach (var key in _english.Keys.Concat(_current.Keys).Distinct()) + next[key] = this[key]; + foreach (var owner in _owners) + { + var index = owner.IndexOf(_resources); + if (index >= 0) + owner[index] = next; + } + _resources = next; + } + /// /// Left-to-right inside a sentence of the UI language: in Arabic a list like ".jpg, .png" /// keeps its dots in front (left-to-right marks around it). Unchanged in left-to-right languages. diff --git a/src/Filee.App/Services/UpdateService.Linux.cs b/src/Filee.App/Services/UpdateService.Linux.cs new file mode 100644 index 000000000..2cdd851f5 --- /dev/null +++ b/src/Filee.App/Services/UpdateService.Linux.cs @@ -0,0 +1,264 @@ +// UpdateService: Linux packages. Safe extraction of the release archive and where the per-user install lives. + +using System.ComponentModel; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Settings; +using Filee.Platform.Windows; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +public sealed partial class UpdateService +{ + /// + /// Extracts a Linux release archive (.tar.gz) into with security validation + /// (rejecting path traversal, absolute paths, unsafe entry types, directory symlinks, and escaping/component symlinks). + /// Returns the path to the extracted payload folder containing install.sh. + /// + internal static string ExtractLinuxArchive(string archivePath, string destinationDirectory) + { + if (!File.Exists(archivePath)) + throw new FileNotFoundException("Archive file not found", archivePath); + + var fullDestRoot = Path.GetFullPath(destinationDirectory); + if (!fullDestRoot.EndsWith(Path.DirectorySeparatorChar)) + fullDestRoot += Path.DirectorySeparatorChar; + + Directory.CreateDirectory(fullDestRoot); + + using (var fileStream = File.OpenRead(archivePath)) + using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress)) + using (var tarReader = new TarReader(gzipStream)) + { + while (tarReader.GetNextEntry() is { } entry) + { + if (entry.EntryType is TarEntryType.GlobalExtendedAttributes or TarEntryType.ExtendedAttributes) + continue; + + if (string.IsNullOrWhiteSpace(entry.Name)) + throw new InvalidDataException("Archive contains an entry with an empty name."); + + if (Path.IsPathRooted(entry.Name) || entry.Name.StartsWith('/') || entry.Name.StartsWith('\\') || entry.Name.Contains('\\')) + throw new InvalidDataException($"Archive entry '{entry.Name}' has an invalid or rooted path."); + + var destinationPath = Path.GetFullPath(Path.Combine(fullDestRoot, entry.Name)); + if (!destinationPath.StartsWith(fullDestRoot, StringComparison.Ordinal)) + throw new InvalidDataException($"Archive entry '{entry.Name}' attempts path traversal outside destination."); + + // Validate that no ancestor directory component between fullDestRoot and destinationPath is a symlink on disk + ValidateNoSymlinkComponents(fullDestRoot, destinationPath); + + switch (entry.EntryType) + { + case TarEntryType.Directory: + if (Path.Exists(destinationPath)) + { + var info = new DirectoryInfo(destinationPath); + if (info.LinkTarget is not null || info.Attributes.HasFlag(FileAttributes.ReparsePoint)) + throw new InvalidDataException($"Archive entry '{entry.Name}' conflicts with an existing symbolic link."); + } + Directory.CreateDirectory(destinationPath); + break; + + case TarEntryType.RegularFile: + case TarEntryType.V7RegularFile: + if (Directory.Exists(destinationPath)) + throw new InvalidDataException($"Archive entry '{entry.Name}' conflicts with an existing directory."); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + // If destinationPath already exists and is a symlink, delete it rather than writing through it + if (Path.Exists(destinationPath)) + { + var existingInfo = new FileInfo(destinationPath); + if (existingInfo.LinkTarget is not null || existingInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + File.Delete(destinationPath); + } + + entry.ExtractToFile(destinationPath, overwrite: true); + + if (OperatingSystem.IsLinux()) + { + var mode = (entry.Mode & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(destinationPath, mode); + } + break; + + case TarEntryType.SymbolicLink: + if (string.IsNullOrWhiteSpace(entry.LinkName) + || Path.IsPathRooted(entry.LinkName) + || entry.LinkName.StartsWith('/') + || entry.LinkName.StartsWith('\\') + || entry.LinkName.Contains('\\')) + { + throw new InvalidDataException($"Tar symlink '{entry.Name}' has an invalid target '{entry.LinkName}'."); + } + + var linkParent = Path.GetDirectoryName(destinationPath)!; + var linkTarget = Path.GetFullPath(Path.Combine(linkParent, entry.LinkName)); + if (!linkTarget.StartsWith(fullDestRoot, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Tar symlink '{entry.Name}' points outside destination directory: '{entry.LinkName}'."); + } + + // Directory symlinks are disallowed to prevent symlink-component traversal and link-order attacks + if (Directory.Exists(linkTarget)) + { + throw new InvalidDataException($"Tar symlink '{entry.Name}' points to a directory, which is prohibited."); + } + + if (Directory.Exists(destinationPath)) + throw new InvalidDataException($"Archive symlink '{entry.Name}' conflicts with an existing directory."); + + if (!string.IsNullOrEmpty(linkParent)) + Directory.CreateDirectory(linkParent); + + if (Path.Exists(destinationPath)) + File.Delete(destinationPath); + + File.CreateSymbolicLink(destinationPath, entry.LinkName); + break; + + case TarEntryType.HardLink: + if (string.IsNullOrWhiteSpace(entry.LinkName) + || Path.IsPathRooted(entry.LinkName) + || entry.LinkName.StartsWith('/') + || entry.LinkName.StartsWith('\\') + || entry.LinkName.Contains('\\')) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' has an invalid target '{entry.LinkName}'."); + } + + var hardTarget = Path.GetFullPath(Path.Combine(fullDestRoot, entry.LinkName)); + if (!hardTarget.StartsWith(fullDestRoot, StringComparison.Ordinal)) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' points outside destination directory: '{entry.LinkName}'."); + } + + if (Directory.Exists(hardTarget)) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' points to a directory, which is prohibited."); + } + + if (!File.Exists(hardTarget)) + { + throw new InvalidDataException($"Tar hard link '{entry.Name}' target '{entry.LinkName}' does not exist."); + } + + if (Directory.Exists(destinationPath)) + throw new InvalidDataException($"Archive hard link '{entry.Name}' conflicts with an existing directory."); + + var hardParent = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(hardParent)) + Directory.CreateDirectory(hardParent); + + if (Path.Exists(destinationPath)) + File.Delete(destinationPath); + + File.Copy(hardTarget, destinationPath, overwrite: true); + break; + + default: + throw new InvalidDataException($"Unsupported or unsafe tar entry type '{entry.EntryType}' for entry '{entry.Name}'."); + } + } + } + + var rootTrimmed = Path.TrimEndingDirectorySeparator(fullDestRoot); + string payloadDir; + if (File.Exists(Path.Combine(rootTrimmed, "install.sh"))) + { + payloadDir = rootTrimmed; + } + else + { + var candidates = Directory.GetDirectories(rootTrimmed) + .Where(d => File.Exists(Path.Combine(d, "install.sh"))) + .ToList(); + if (candidates.Count == 1) + { + payloadDir = candidates[0]; + } + else + { + throw new InvalidDataException("Archive does not contain a single valid Filee payload with install.sh"); + } + } + + var installSh = Path.Combine(payloadDir, "install.sh"); + var fileeExe = Path.Combine(payloadDir, "Filee"); + if (!File.Exists(installSh) || !File.Exists(fileeExe)) + { + throw new InvalidDataException("Archive is missing install.sh or Filee executable in the payload directory."); + } + + if (OperatingSystem.IsLinux()) + { + EnsureExecutable(installSh); + EnsureExecutable(fileeExe); + var cli = Path.Combine(payloadDir, "filee-cli"); + if (File.Exists(cli)) + EnsureExecutable(cli); + var uninstall = Path.Combine(payloadDir, "uninstall.sh"); + if (File.Exists(uninstall)) + EnsureExecutable(uninstall); + } + + return payloadDir; + } + + /// + /// Verifies that no intermediate directory component between and is a symbolic link. + /// + private static void ValidateNoSymlinkComponents(string rootPath, string targetPath) + { + var current = rootPath.TrimEnd(Path.DirectorySeparatorChar); + var relative = Path.GetRelativePath(current, targetPath); + var segments = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + + for (var i = 0; i < segments.Length - 1; i++) + { + current = Path.Combine(current, segments[i]); + if (Path.Exists(current)) + { + var dirInfo = new DirectoryInfo(current); + if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new InvalidDataException($"Archive path component '{segments[i]}' in '{relative}' is a symbolic link."); + } + } + } + } + + private static void EnsureExecutable(string path) + { + if (OperatingSystem.IsLinux() && File.Exists(path)) + { + var mode = (File.GetUnixFileMode(path) & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + File.SetUnixFileMode(path, mode); + } + } + + /// Locates the installation directory on Linux. + internal static string GetLinuxInstallDirectory() + { + if (IsLinuxInstalled(AppContext.BaseDirectory)) + return AppContext.BaseDirectory; + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var root = !string.IsNullOrWhiteSpace(dataHome) + ? dataHome + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(root, "filee", "application"); + } +} diff --git a/src/Filee.App/Services/UpdateService.Verification.cs b/src/Filee.App/Services/UpdateService.Verification.cs new file mode 100644 index 000000000..a21e85481 --- /dev/null +++ b/src/Filee.App/Services/UpdateService.Verification.cs @@ -0,0 +1,157 @@ +// UpdateService: downloads the installer and checks it before anything runs it: the release's checksum file must carry +// a valid signature of Filee's update key (when the build has one), the installer must match its SHA-256, and on +// Windows it must be signed by the publisher that signed this copy (or the publisher pinned in the build). + +using System.ComponentModel; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Settings; +using Filee.Platform.Windows; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +public sealed partial class UpdateService +{ + /// + /// Downloads the installer of the latest release into and checks its SHA-256 against + /// the release's checksum file. Returns the verified file; throws when it does + /// not match. + /// + internal async Task DownloadInstallerAsync(string folder, IProgress? progress, + CancellationToken cancellationToken = default) + { + var installer = _installer ?? throw new InvalidOperationException("No installer in the latest release"); + var checksums = _checksums ?? throw new InvalidOperationException("No checksum file in the latest release"); + + var sumsBytes = await GetBytesAsync(checksums.Url, cancellationToken); + await VerifyChecksumSignatureAsync(checksums, sumsBytes, cancellationToken); + var sums = System.Text.Encoding.UTF8.GetString(sumsBytes).TrimStart(''); + var expected = ParseChecksum(sums, installer.Name) + ?? throw new InvalidDataException($"{checksums.Name} has no line for {installer.Name}"); + + Directory.CreateDirectory(folder); + var target = Path.Combine(folder, installer.Name); + // Downloaded already (Update pressed again after a declined prompt): reuse it if it is still intact. + if (File.Exists(target) && await Sha256Async(target, cancellationToken) == expected) + { + progress?.Report(100); + return target; + } + foreach (var old in Directory.EnumerateFiles(folder)) + TryDelete(old); + + var partial = target + ".part"; + TryDelete(partial); + await DownloadAsync(installer, partial, progress, cancellationToken); + var actual = await Sha256Async(partial, cancellationToken); + if (actual != expected) + { + TryDelete(partial); + throw new InvalidDataException($"{installer.Name}: SHA-256 {actual} does not match {expected}"); + } + File.Move(partial, target, overwrite: true); + return target; + } + + /// + /// With a public key in the build (), the checksum file must carry a valid signature + /// (<checksum file>.sig next to it in the release, base64): the checksum file sits on the same release + /// as the installer, so a replaced pair would pass the SHA-256 check together; the key's signature can't be + /// replaced with them. Without a key (releases don't sign it yet) only the SHA-256 is checked, as before. + /// + /// The signature is missing or doesn't match. + internal async Task VerifyChecksumSignatureAsync(ReleaseFile checksums, byte[] sums, CancellationToken cancellationToken) + { + if (UpdatePublicKey is not { } key) + { + _log.LogInformation("This build has no update signing key: {File} is not signature-checked", checksums.Name); + return; + } + string signature; + try + { + signature = await GetTextAsync(checksums.Url + ".sig", cancellationToken); + } + catch (HttpRequestException ex) + { + throw new InvalidDataException($"{checksums.Name}.sig, the signature of the checksum file, could not be downloaded: {ex.Message}", ex); + } + if (!VerifySignature(key, sums, signature)) + throw new InvalidDataException($"{checksums.Name} is not signed with Filee's update key"); + } + + /// + /// Checks an ECDSA P-256 / SHA-256 signature (base64; DER as openssl dgst -sign writes it, or IEEE P1363 as + /// .NET's SignData does) of with a base64 SubjectPublicKeyInfo key. + /// + internal static bool VerifySignature(string publicKey, byte[] data, string signature) + { + try + { + using var ecdsa = ECDsa.Create(); + ecdsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey.Trim()), out _); + var bytes = Convert.FromBase64String(signature.Trim()); + return ecdsa.VerifyData(data, bytes, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence) + || ecdsa.VerifyData(data, bytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (Exception e) when (e is FormatException or CryptographicException) + { + return false; + } + } + + /// + /// Throws (and deletes the installer) unless the publisher that signed this copy + /// of Filee signed as well. The checksum file sits on the same release as the + /// installer, so it can't tell a replaced installer from the real one; the signature can. A copy that is not + /// signed itself (built from source) compares with the publisher pinned in the build (); + /// with none it keeps to the checksum. + /// + internal void CheckPublisher(string installer) + { + var publisher = (OwnProgram is null ? null : SignerOf(OwnProgram, false)) ?? ExpectedPublisher; + if (publisher is null) + { + _log.LogInformation("This copy of Filee is not signed and its build pins no publisher: the installer is checked by its SHA-256 only"); + return; + } + var signer = SignerOf(installer, true); + if (signer == publisher) + return; + // A signature that holds without the revocation check failed only that check: the certificate authority + // could not be reached (offline, a proxy or a firewall) or the certificate was revoked. + var reason = signer is null && SignerOf(installer, false) == publisher + ? $"is signed by {publisher}, but the revocation check failed (the certificate authority could not be " + + "reached, or the certificate was revoked)" + : $"is signed by {signer ?? "no publisher Windows trusts"}, not by {publisher}"; + TryDelete(installer); + throw new InvalidDataException($"{Path.GetFileName(installer)} {reason}"); + } + + /// Finds the hash for in a sha256sum-style file ("hash name" lines). + internal static string? ParseChecksum(string sums, string fileName) + { + foreach (var line in sums.Split('\n')) + { + var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 && parts[1].Trim().TrimStart('*') == fileName && parts[0].Length == 64) + return parts[0].ToLowerInvariant(); + } + return null; + } + + private static async Task Sha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); + return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, cancellationToken)); + } +} diff --git a/src/Filee.App/Services/UpdateService.Workspaces.cs b/src/Filee.App/Services/UpdateService.Workspaces.cs new file mode 100644 index 000000000..f9912174d --- /dev/null +++ b/src/Filee.App/Services/UpdateService.Workspaces.cs @@ -0,0 +1,202 @@ +// UpdateService: private work folders for an update (one per process) and the sweep of ones left behind. + +using System.ComponentModel; +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Settings; +using Filee.Platform.Windows; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +public sealed partial class UpdateService +{ + /// Gets the default root directory where secure workspaces are allocated. + internal static string GetDefaultWorkspaceBaseDirectory() + { + if (OperatingSystem.IsLinux()) + { + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + var root = !string.IsNullOrWhiteSpace(dataHome) + ? dataHome + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(root, "filee"); + } + return Path.GetTempPath(); + } + + /// + /// Creates a private, dedicated temporary workspace for this update instance. + /// Mode 0700 on Unix, inaccessible to other local users, non-predictable, and never reusing pre-existing paths. + /// + internal static string CreateSecureWorkspace(string? baseDir = null) + { + baseDir = string.IsNullOrWhiteSpace(baseDir) ? GetDefaultWorkspaceBaseDirectory() : baseDir; + + Directory.CreateDirectory(baseDir); + + for (var attempt = 0; attempt < 100; attempt++) + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + var candidate = Path.Combine(baseDir, $".update-{Environment.ProcessId}-{token}"); + if (Path.Exists(candidate)) + continue; + + if (OperatingSystem.IsLinux()) + { + Directory.CreateDirectory(candidate, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(candidate, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + else + { + Directory.CreateDirectory(candidate); + } + + var dirInfo = new DirectoryInfo(candidate); + if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + throw new InvalidOperationException($"Secure workspace candidate '{candidate}' is a symbolic link."); + } + + return candidate; + } + + throw new IOException("Failed to create a unique secure workspace directory after multiple attempts."); + } + + /// Safely deletes a secure workspace directory created by this update, refusing to follow symlinks. + internal static void DeleteSecureWorkspace(string? workspacePath) + { + if (string.IsNullOrWhiteSpace(workspacePath) || !Path.Exists(workspacePath)) + return; + + var dirInfo = new DirectoryInfo(workspacePath); + if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return; + + try + { + Directory.Delete(workspacePath, recursive: true); + } + catch + { + // Best effort cleanup + } + } + + /// + /// Checks whether a process with the given is currently running. + /// Used to protect active update workspaces from being swept. + /// + internal static bool IsProcessAlive(int pid) + { + if (pid <= 0) + return false; + + try + { + if (OperatingSystem.IsLinux()) + return Directory.Exists($"/proc/{pid}"); + + using var proc = Process.GetProcessById(pid); + return !proc.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + catch (Exception) + { + // If in doubt (e.g. access denied), assume alive to safeguard against deleting active workspaces + return true; + } + } + + /// + /// Checks whether matches the Filee-owned workspace pattern + /// .update-<pid>-<32-hex-characters> and extracts the valid positive process ID. + /// + internal static bool TryParseWorkspaceName(string name, out int pid) + { + pid = 0; + var parts = name.Split('-'); + if (parts.Length != 3 || parts[0] != ".update") + return false; + + // The final component must contain exactly 32 hexadecimal characters (0-9, a-f, or A-F) + if (parts[2].Length != 32 || !parts[2].All(char.IsAsciiHexDigit)) + return false; + + // must be a valid positive process ID in decimal (digits only, no signs) + if (parts[1].Length == 0 || !parts[1].All(char.IsAsciiDigit) || !int.TryParse(parts[1], out var parsedPid) || parsedPid <= 0) + return false; + + pid = parsedPid; + return true; + } + + /// + /// Deletes stale update workspaces left behind by crashes, forced kills, or aborted updates. + /// Protects active workspaces by checking both an age threshold (default 1 day) and process liveness + /// (never deleting a workspace belonging to a running PID). Restricts cleanup strictly to Filee-owned + /// workspace directories matching .update-<pid>-<32-hex-characters>. + /// + public static void DeleteStaleWorkspaces(string? root = null, TimeSpan? age = null, ILogger? log = null) + { + // Workspaces are only made on Linux, under Filee's own data folder. Elsewhere the default root is the shared + // temp folder, where other programs' folders live. + if (root is null && !OperatingSystem.IsLinux()) + return; + var threshold = age ?? TimeSpan.FromDays(1); + try + { + var baseDir = root ?? GetDefaultWorkspaceBaseDirectory(); + var dirInfo = new DirectoryInfo(baseDir); + if (!dirInfo.Exists || dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return; + + foreach (var folder in dirInfo.EnumerateDirectories(".update-*")) + { + if (folder.LinkTarget is not null || folder.Attributes.HasFlag(FileAttributes.ReparsePoint)) + continue; + + // Restrict cleanup strictly to Filee-owned workspace names: .update--<32-hex-chars> + // Foreign directories (e.g. .update-something or .update-cache) are rejected immediately. + if (!TryParseWorkspaceName(folder.Name, out var pid)) + continue; + + try + { + if (IsProcessAlive(pid)) + continue; + + if (DateTime.UtcNow - folder.LastWriteTimeUtc > threshold) + { + DeleteSecureWorkspace(folder.FullName); + log?.LogInformation("Deleted stale update workspace {Workspace}", folder.FullName); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.LogWarning(ex, "Could not delete stale update workspace {Workspace}", folder.FullName); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.LogWarning(ex, "Failed to enumerate update workspaces in {Root}", root); + } + } +} diff --git a/src/Filee.App/Services/UpdateService.cs b/src/Filee.App/Services/UpdateService.cs index 53f142382..869513eff 100644 --- a/src/Filee.App/Services/UpdateService.cs +++ b/src/Filee.App/Services/UpdateService.cs @@ -174,6 +174,22 @@ private static string DefaultTargetPlatform() /// This copy of Filee, whose publisher must have signed the installer too (tests set it). internal string? OwnProgram { get; init; } = Environment.ProcessPath; + /// + /// The publisher every installer must be signed by when this copy isn't signed itself (FileeExpectedPublisher in + /// Directory.Build.props), or null. Tests set it. + /// + internal string? ExpectedPublisher { get; init; } = BuildMetadata("FileeExpectedPublisher"); + + /// + /// The public key (base64 SubjectPublicKeyInfo, ECDSA P-256) that signs every release's checksum file + /// (FileeUpdatePublicKey in Directory.Build.props), or null while releases don't sign it. Tests set it. + /// + internal string? UpdatePublicKey { get; init; } = BuildMetadata("FileeUpdatePublicKey"); + + private static string? BuildMetadata(string key) => + typeof(UpdateService).Assembly.GetCustomAttributes() + .FirstOrDefault(a => a.Key == key)?.Value is { Length: > 0 } value ? value.Trim() : null; + /// The newer version found by the last check (e.g. "1.0.2"), or null when up to date / unknown. [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsUpdateAvailable))] @@ -480,72 +496,6 @@ public async Task UpdateAsync() throw new PlatformNotSupportedException("In-app update is only supported on Windows and Linux"); } - /// - /// Downloads the installer of the latest release into and checks its SHA-256 against - /// the release's checksum file. Returns the verified file; throws when it does - /// not match. - /// - internal async Task DownloadInstallerAsync(string folder, IProgress? progress, - CancellationToken cancellationToken = default) - { - var installer = _installer ?? throw new InvalidOperationException("No installer in the latest release"); - var checksums = _checksums ?? throw new InvalidOperationException("No checksum file in the latest release"); - - var sums = await GetTextAsync(checksums.Url, cancellationToken); - var expected = ParseChecksum(sums, installer.Name) - ?? throw new InvalidDataException($"{checksums.Name} has no line for {installer.Name}"); - - Directory.CreateDirectory(folder); - var target = Path.Combine(folder, installer.Name); - // Downloaded already (Update pressed again after a declined prompt): reuse it if it is still intact. - if (File.Exists(target) && await Sha256Async(target, cancellationToken) == expected) - { - progress?.Report(100); - return target; - } - foreach (var old in Directory.EnumerateFiles(folder)) - TryDelete(old); - - var partial = target + ".part"; - TryDelete(partial); - await DownloadAsync(installer, partial, progress, cancellationToken); - var actual = await Sha256Async(partial, cancellationToken); - if (actual != expected) - { - TryDelete(partial); - throw new InvalidDataException($"{installer.Name}: SHA-256 {actual} does not match {expected}"); - } - File.Move(partial, target, overwrite: true); - return target; - } - - /// - /// Throws (and deletes the installer) unless the publisher that signed this copy - /// of Filee signed as well. The checksum file sits on the same release as the - /// installer, so it can't tell a replaced installer from the real one; the signature can. A copy that is not - /// signed itself (built from source) has no publisher to compare with and keeps to the checksum. - /// - internal void CheckPublisher(string installer) - { - var publisher = OwnProgram is null ? null : SignerOf(OwnProgram, false); - if (publisher is null) - { - _log.LogInformation("This copy of Filee is not signed: the installer is checked by its SHA-256 only"); - return; - } - var signer = SignerOf(installer, true); - if (signer == publisher) - return; - // A signature that holds without the revocation check failed only that check: the certificate authority - // could not be reached (offline, a proxy or a firewall) or the certificate was revoked. - var reason = signer is null && SignerOf(installer, false) == publisher - ? $"is signed by {publisher}, but the revocation check failed (the certificate authority could not be " + - "reached, or the certificate was revoked)" - : $"is signed by {signer ?? "no publisher Windows trusts"}, not by {publisher}"; - TryDelete(installer); - throw new InvalidDataException($"{Path.GetFileName(installer)} {reason}"); - } - private async Task DownloadAsync(ReleaseFile file, string path, IProgress? progress, CancellationToken cancellationToken) { using var stall = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -575,13 +525,17 @@ private async Task DownloadAsync(ReleaseFile file, string path, IProgress? throw new IOException($"{file.Name}: got {received} of {file.Size} bytes"); } - private async Task GetTextAsync(string url, CancellationToken cancellationToken) + private async Task GetTextAsync(string url, CancellationToken cancellationToken) => + System.Text.Encoding.UTF8.GetString(await GetBytesAsync(url, cancellationToken)).TrimStart(''); + + /// A small file as it is on the server: a signature covers its exact bytes. + private async Task GetBytesAsync(string url, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Accept.ParseAdd("application/octet-stream"); using var response = await _http.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(cancellationToken); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); } /// Opens a download page: the website for installed copies, the release page (portable zip) otherwise. @@ -715,258 +669,9 @@ internal static (ReleaseFile? Installer, ReleaseFile? Checksums) FindFiles(JsonE UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; - /// - /// Extracts a Linux release archive (.tar.gz) into with security validation - /// (rejecting path traversal, absolute paths, unsafe entry types, directory symlinks, and escaping/component symlinks). - /// Returns the path to the extracted payload folder containing install.sh. - /// - internal static string ExtractLinuxArchive(string archivePath, string destinationDirectory) - { - if (!File.Exists(archivePath)) - throw new FileNotFoundException("Archive file not found", archivePath); - - var fullDestRoot = Path.GetFullPath(destinationDirectory); - if (!fullDestRoot.EndsWith(Path.DirectorySeparatorChar)) - fullDestRoot += Path.DirectorySeparatorChar; - - Directory.CreateDirectory(fullDestRoot); - - using (var fileStream = File.OpenRead(archivePath)) - using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress)) - using (var tarReader = new TarReader(gzipStream)) - { - while (tarReader.GetNextEntry() is { } entry) - { - if (entry.EntryType is TarEntryType.GlobalExtendedAttributes or TarEntryType.ExtendedAttributes) - continue; - - if (string.IsNullOrWhiteSpace(entry.Name)) - throw new InvalidDataException("Archive contains an entry with an empty name."); - - if (Path.IsPathRooted(entry.Name) || entry.Name.StartsWith('/') || entry.Name.StartsWith('\\') || entry.Name.Contains('\\')) - throw new InvalidDataException($"Archive entry '{entry.Name}' has an invalid or rooted path."); - - var destinationPath = Path.GetFullPath(Path.Combine(fullDestRoot, entry.Name)); - if (!destinationPath.StartsWith(fullDestRoot, StringComparison.Ordinal)) - throw new InvalidDataException($"Archive entry '{entry.Name}' attempts path traversal outside destination."); - - // Validate that no ancestor directory component between fullDestRoot and destinationPath is a symlink on disk - ValidateNoSymlinkComponents(fullDestRoot, destinationPath); - - switch (entry.EntryType) - { - case TarEntryType.Directory: - if (Path.Exists(destinationPath)) - { - var info = new DirectoryInfo(destinationPath); - if (info.LinkTarget is not null || info.Attributes.HasFlag(FileAttributes.ReparsePoint)) - throw new InvalidDataException($"Archive entry '{entry.Name}' conflicts with an existing symbolic link."); - } - Directory.CreateDirectory(destinationPath); - break; - - case TarEntryType.RegularFile: - case TarEntryType.V7RegularFile: - if (Directory.Exists(destinationPath)) - throw new InvalidDataException($"Archive entry '{entry.Name}' conflicts with an existing directory."); - - var parentDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentDir)) - Directory.CreateDirectory(parentDir); - - // If destinationPath already exists and is a symlink, delete it rather than writing through it - if (Path.Exists(destinationPath)) - { - var existingInfo = new FileInfo(destinationPath); - if (existingInfo.LinkTarget is not null || existingInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - File.Delete(destinationPath); - } - - entry.ExtractToFile(destinationPath, overwrite: true); - - if (OperatingSystem.IsLinux()) - { - var mode = (entry.Mode & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite; - File.SetUnixFileMode(destinationPath, mode); - } - break; - - case TarEntryType.SymbolicLink: - if (string.IsNullOrWhiteSpace(entry.LinkName) - || Path.IsPathRooted(entry.LinkName) - || entry.LinkName.StartsWith('/') - || entry.LinkName.StartsWith('\\') - || entry.LinkName.Contains('\\')) - { - throw new InvalidDataException($"Tar symlink '{entry.Name}' has an invalid target '{entry.LinkName}'."); - } - - var linkParent = Path.GetDirectoryName(destinationPath)!; - var linkTarget = Path.GetFullPath(Path.Combine(linkParent, entry.LinkName)); - if (!linkTarget.StartsWith(fullDestRoot, StringComparison.Ordinal)) - { - throw new InvalidDataException($"Tar symlink '{entry.Name}' points outside destination directory: '{entry.LinkName}'."); - } - - // Directory symlinks are disallowed to prevent symlink-component traversal and link-order attacks - if (Directory.Exists(linkTarget)) - { - throw new InvalidDataException($"Tar symlink '{entry.Name}' points to a directory, which is prohibited."); - } - - if (Directory.Exists(destinationPath)) - throw new InvalidDataException($"Archive symlink '{entry.Name}' conflicts with an existing directory."); - - if (!string.IsNullOrEmpty(linkParent)) - Directory.CreateDirectory(linkParent); - - if (Path.Exists(destinationPath)) - File.Delete(destinationPath); - - File.CreateSymbolicLink(destinationPath, entry.LinkName); - break; - - case TarEntryType.HardLink: - if (string.IsNullOrWhiteSpace(entry.LinkName) - || Path.IsPathRooted(entry.LinkName) - || entry.LinkName.StartsWith('/') - || entry.LinkName.StartsWith('\\') - || entry.LinkName.Contains('\\')) - { - throw new InvalidDataException($"Tar hard link '{entry.Name}' has an invalid target '{entry.LinkName}'."); - } - - var hardTarget = Path.GetFullPath(Path.Combine(fullDestRoot, entry.LinkName)); - if (!hardTarget.StartsWith(fullDestRoot, StringComparison.Ordinal)) - { - throw new InvalidDataException($"Tar hard link '{entry.Name}' points outside destination directory: '{entry.LinkName}'."); - } - - if (Directory.Exists(hardTarget)) - { - throw new InvalidDataException($"Tar hard link '{entry.Name}' points to a directory, which is prohibited."); - } - - if (!File.Exists(hardTarget)) - { - throw new InvalidDataException($"Tar hard link '{entry.Name}' target '{entry.LinkName}' does not exist."); - } - - if (Directory.Exists(destinationPath)) - throw new InvalidDataException($"Archive hard link '{entry.Name}' conflicts with an existing directory."); - - var hardParent = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(hardParent)) - Directory.CreateDirectory(hardParent); - - if (Path.Exists(destinationPath)) - File.Delete(destinationPath); - - File.Copy(hardTarget, destinationPath, overwrite: true); - break; - - default: - throw new InvalidDataException($"Unsupported or unsafe tar entry type '{entry.EntryType}' for entry '{entry.Name}'."); - } - } - } - - var rootTrimmed = Path.TrimEndingDirectorySeparator(fullDestRoot); - string payloadDir; - if (File.Exists(Path.Combine(rootTrimmed, "install.sh"))) - { - payloadDir = rootTrimmed; - } - else - { - var candidates = Directory.GetDirectories(rootTrimmed) - .Where(d => File.Exists(Path.Combine(d, "install.sh"))) - .ToList(); - if (candidates.Count == 1) - { - payloadDir = candidates[0]; - } - else - { - throw new InvalidDataException("Archive does not contain a single valid Filee payload with install.sh"); - } - } - - var installSh = Path.Combine(payloadDir, "install.sh"); - var fileeExe = Path.Combine(payloadDir, "Filee"); - if (!File.Exists(installSh) || !File.Exists(fileeExe)) - { - throw new InvalidDataException("Archive is missing install.sh or Filee executable in the payload directory."); - } - - if (OperatingSystem.IsLinux()) - { - EnsureExecutable(installSh); - EnsureExecutable(fileeExe); - var cli = Path.Combine(payloadDir, "filee-cli"); - if (File.Exists(cli)) - EnsureExecutable(cli); - var uninstall = Path.Combine(payloadDir, "uninstall.sh"); - if (File.Exists(uninstall)) - EnsureExecutable(uninstall); - } - - return payloadDir; - } - - /// - /// Verifies that no intermediate directory component between and is a symbolic link. - /// - private static void ValidateNoSymlinkComponents(string rootPath, string targetPath) - { - var current = rootPath.TrimEnd(Path.DirectorySeparatorChar); - var relative = Path.GetRelativePath(current, targetPath); - var segments = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - - for (var i = 0; i < segments.Length - 1; i++) - { - current = Path.Combine(current, segments[i]); - if (Path.Exists(current)) - { - var dirInfo = new DirectoryInfo(current); - if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - throw new InvalidDataException($"Archive path component '{segments[i]}' in '{relative}' is a symbolic link."); - } - } - } - } - - private static void EnsureExecutable(string path) - { - if (OperatingSystem.IsLinux() && File.Exists(path)) - { - var mode = (File.GetUnixFileMode(path) & RwxMask) | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; - File.SetUnixFileMode(path, mode); - } - } - - /// Finds the hash for in a sha256sum-style file ("hash name" lines). - internal static string? ParseChecksum(string sums, string fileName) - { - foreach (var line in sums.Split('\n')) - { - var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 2 && parts[1].Trim().TrimStart('*') == fileName && parts[0].Length == 64) - return parts[0].ToLowerInvariant(); - } - return null; - } - /// Lets tests hand in a release without calling GitHub. internal void UseRelease(ReleaseFile? installer, ReleaseFile? checksums) => (_installer, _checksums) = (installer, checksums); - private static async Task Sha256Async(string path, CancellationToken cancellationToken) - { - await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); - return Convert.ToHexStringLower(await SHA256.HashDataAsync(stream, cancellationToken)); - } - private void TryDelete(string path) { try @@ -979,199 +684,6 @@ private void TryDelete(string path) } } - /// Gets the default root directory where secure workspaces are allocated. - internal static string GetDefaultWorkspaceBaseDirectory() - { - if (OperatingSystem.IsLinux()) - { - var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - var root = !string.IsNullOrWhiteSpace(dataHome) - ? dataHome - : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(root, "filee"); - } - return Path.GetTempPath(); - } - - /// - /// Creates a private, dedicated temporary workspace for this update instance. - /// Mode 0700 on Unix, inaccessible to other local users, non-predictable, and never reusing pre-existing paths. - /// - internal static string CreateSecureWorkspace(string? baseDir = null) - { - baseDir = string.IsNullOrWhiteSpace(baseDir) ? GetDefaultWorkspaceBaseDirectory() : baseDir; - - Directory.CreateDirectory(baseDir); - - for (var attempt = 0; attempt < 100; attempt++) - { - var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); - var candidate = Path.Combine(baseDir, $".update-{Environment.ProcessId}-{token}"); - if (Path.Exists(candidate)) - continue; - - if (OperatingSystem.IsLinux()) - { - Directory.CreateDirectory(candidate, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - File.SetUnixFileMode(candidate, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } - else - { - Directory.CreateDirectory(candidate); - } - - var dirInfo = new DirectoryInfo(candidate); - if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - { - throw new InvalidOperationException($"Secure workspace candidate '{candidate}' is a symbolic link."); - } - - return candidate; - } - - throw new IOException("Failed to create a unique secure workspace directory after multiple attempts."); - } - - /// Safely deletes a secure workspace directory created by this update, refusing to follow symlinks. - internal static void DeleteSecureWorkspace(string? workspacePath) - { - if (string.IsNullOrWhiteSpace(workspacePath) || !Path.Exists(workspacePath)) - return; - - var dirInfo = new DirectoryInfo(workspacePath); - if (dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - return; - - try - { - Directory.Delete(workspacePath, recursive: true); - } - catch - { - // Best effort cleanup - } - } - - /// - /// Checks whether a process with the given is currently running. - /// Used to protect active update workspaces from being swept. - /// - internal static bool IsProcessAlive(int pid) - { - if (pid <= 0) - return false; - - try - { - if (OperatingSystem.IsLinux()) - return Directory.Exists($"/proc/{pid}"); - - using var proc = Process.GetProcessById(pid); - return !proc.HasExited; - } - catch (ArgumentException) - { - return false; - } - catch (InvalidOperationException) - { - return false; - } - catch (Exception) - { - // If in doubt (e.g. access denied), assume alive to safeguard against deleting active workspaces - return true; - } - } - - /// - /// Checks whether matches the Filee-owned workspace pattern - /// .update-<pid>-<32-hex-characters> and extracts the valid positive process ID. - /// - internal static bool TryParseWorkspaceName(string name, out int pid) - { - pid = 0; - var parts = name.Split('-'); - if (parts.Length != 3 || parts[0] != ".update") - return false; - - // The final component must contain exactly 32 hexadecimal characters (0-9, a-f, or A-F) - if (parts[2].Length != 32 || !parts[2].All(char.IsAsciiHexDigit)) - return false; - - // must be a valid positive process ID in decimal (digits only, no signs) - if (parts[1].Length == 0 || !parts[1].All(char.IsAsciiDigit) || !int.TryParse(parts[1], out var parsedPid) || parsedPid <= 0) - return false; - - pid = parsedPid; - return true; - } - - /// - /// Deletes stale update workspaces left behind by crashes, forced kills, or aborted updates. - /// Protects active workspaces by checking both an age threshold (default 1 day) and process liveness - /// (never deleting a workspace belonging to a running PID). Restricts cleanup strictly to Filee-owned - /// workspace directories matching .update-<pid>-<32-hex-characters>. - /// - public static void DeleteStaleWorkspaces(string? root = null, TimeSpan? age = null, ILogger? log = null) - { - // Workspaces are only made on Linux, under Filee's own data folder. Elsewhere the default root is the shared - // temp folder, where other programs' folders live. - if (root is null && !OperatingSystem.IsLinux()) - return; - var threshold = age ?? TimeSpan.FromDays(1); - try - { - var baseDir = root ?? GetDefaultWorkspaceBaseDirectory(); - var dirInfo = new DirectoryInfo(baseDir); - if (!dirInfo.Exists || dirInfo.LinkTarget is not null || dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - return; - - foreach (var folder in dirInfo.EnumerateDirectories(".update-*")) - { - if (folder.LinkTarget is not null || folder.Attributes.HasFlag(FileAttributes.ReparsePoint)) - continue; - - // Restrict cleanup strictly to Filee-owned workspace names: .update--<32-hex-chars> - // Foreign directories (e.g. .update-something or .update-cache) are rejected immediately. - if (!TryParseWorkspaceName(folder.Name, out var pid)) - continue; - - try - { - if (IsProcessAlive(pid)) - continue; - - if (DateTime.UtcNow - folder.LastWriteTimeUtc > threshold) - { - DeleteSecureWorkspace(folder.FullName); - log?.LogInformation("Deleted stale update workspace {Workspace}", folder.FullName); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - log?.LogWarning(ex, "Could not delete stale update workspace {Workspace}", folder.FullName); - } - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - log?.LogWarning(ex, "Failed to enumerate update workspaces in {Root}", root); - } - } - - /// Locates the installation directory on Linux. - internal static string GetLinuxInstallDirectory() - { - if (IsLinuxInstalled(AppContext.BaseDirectory)) - return AppContext.BaseDirectory; - var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - var root = !string.IsNullOrWhiteSpace(dataHome) - ? dataHome - : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(root, "filee", "application"); - } - /// True when release tag ("v1.2.0") is a higher version than . internal static bool IsNewer(string? tag, string current) => TryParse(tag, out var latest) && TryParse(current, out var installed) && latest > installed; diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 20ed7e986..6461db188 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -67,6 +67,33 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem return dialog.Result; } + /// + /// Asks about a job's documents with macros or other active content (JobQueue.ActiveContentPrompt). Callable from + /// any thread; the window opens on the UI thread. "Don't ask again" makes the choice the setting. Closing the + /// window, or the job being cancelled meanwhile, cancels the job. + /// + public Task AskActiveContentAsync(ConversionJob job, IReadOnlyList reports, + CancellationToken cancellationToken) => + Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => + { + var dialog = new ActiveContentWindow { DataContext = new ActiveContentViewModel(reports, loc) }; + PrepareWindow(dialog); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + dialog.Closed += (_, _) => closed.TrySetResult(); + using var cancel = cancellationToken.Register(() => Avalonia.Threading.Dispatcher.UIThread.Post(dialog.Close)); + dialog.Show(); + dialog.Activate(); + await closed.Task; + if (dialog.Remember && dialog.Result != ActiveContentDecision.Cancel && services.GetService() is { } store) + { + store.Settings.Security.ActiveContent = dialog.Result == ActiveContentDecision.Trust + ? ActiveContentPolicy.Convert + : ActiveContentPolicy.Refuse; + store.SaveSettings(); + } + return dialog.Result; + }); + public MainWindow? Main => _main; /// diff --git a/src/Filee.App/Styles/Controls.axaml b/src/Filee.App/Styles/Controls.axaml index 9448185da..d76a70adb 100644 --- a/src/Filee.App/Styles/Controls.axaml +++ b/src/Filee.App/Styles/Controls.axaml @@ -21,4 +21,13 @@ + + + diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml index 21ca4ad72..d63148b16 100644 --- a/src/Filee.App/Styles/Icons.axaml +++ b/src/Filee.App/Styles/Icons.axaml @@ -28,6 +28,8 @@ M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z + + M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9L10,17Z M3,17.25V21h3.75L17.81,9.94l-3.75-3.75L3,17.25z M20.71,7.04c0.39-0.39,0.39-1.02,0-1.41l-2.34-2.34c-0.39-0.39-1.02-0.39-1.41,0l-1.83,1.83l3.75,3.75L20.71,7.04z M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 5.03,16.5 5.03,16.5C4.12,15.88 5.1,15.9 5.1,15.9C6.1,15.97 6.63,16.93 6.63,16.93C7.5,18.45 8.97,18 9.54,17.76C9.63,17.11 9.89,16.67 10.17,16.42C7.95,16.17 5.62,15.31 5.62,11.5C5.62,10.39 6,9.5 6.65,8.79C6.55,8.54 6.2,7.5 6.75,6.15C6.75,6.15 7.59,5.88 9.5,7.17C10.29,6.95 11.15,6.84 12,6.84C12.85,6.84 13.71,6.95 14.5,7.17C16.41,5.88 17.25,6.15 17.25,6.15C17.8,7.5 17.45,8.54 17.35,8.79C18,9.5 18.38,10.39 18.38,11.5C18.38,15.32 16.04,16.16 13.81,16.41C14.17,16.72 14.5,17.33 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0 0,0 12,2Z diff --git a/src/Filee.App/ViewModels/ActiveContentViewModel.cs b/src/Filee.App/ViewModels/ActiveContentViewModel.cs new file mode 100644 index 000000000..51198defa --- /dev/null +++ b/src/Filee.App/ViewModels/ActiveContentViewModel.cs @@ -0,0 +1,55 @@ +// The documents of a job that carry macros or other active content, shown in ActiveContentWindow before any of them is +// opened in an office suite. Filee never runs that content; the user decides whether to convert the files at all. + +using Filee.Core.Conversion; +using Filee.Core.Localization; + +namespace Filee.App.ViewModels; + +/// One document and what it carries. +/// File name. +/// Folder it is in. +/// Full path (tooltip). +/// What was found, one line each. +/// A macro would start by itself when the document opens. +public sealed record ActiveContentFile(string Name, string Folder, string Path, IReadOnlyList Findings, bool StartsByItself); + +/// Everything the window shows. +public sealed class ActiveContentViewModel +{ + public ActiveContentViewModel(IReadOnlyList reports, ILocalizer loc) + { + Files = reports.Select(r => new ActiveContentFile( + System.IO.Path.GetFileName(r.SourcePath), + System.IO.Path.GetDirectoryName(r.SourcePath) ?? "", + r.SourcePath, + r.Findings.Select(f => Line(f, loc)).ToList(), + r.Findings.Any(f => f.Kind == ActiveContentKind.AutoRunMacro))) + .ToList(); + Summary = string.Format(loc["active.summary"], Files.Count); + } + + public IReadOnlyList Files { get; } + + /// "2 files contain macros or other active content." + public string Summary { get; } + + /// "Macro: Module1", "Starts by itself: AutoOpen (Module1)", "External link: attachedTemplate — http://…". + internal static string Line(ActiveContentFinding finding, ILocalizer loc) + { + var kind = loc[$"active.kind.{Key(finding.Kind)}"]; + return finding.Detail is { Length: > 0 } detail ? $"{kind}: {finding.Name} — {detail}" : $"{kind}: {finding.Name}"; + } + + /// The localization key part of a kind (active.kind.*). + internal static string Key(ActiveContentKind kind) => kind switch + { + ActiveContentKind.Macro => "macro", + ActiveContentKind.AutoRunMacro => "autorun", + ActiveContentKind.Script => "script", + ActiveContentKind.ActiveX => "activex", + ActiveContentKind.ExternalLink => "external_link", + ActiveContentKind.Dde => "dde", + _ => "embedded", + }; +} diff --git a/src/Filee.App/ViewModels/MainWindowViewModel.cs b/src/Filee.App/ViewModels/MainWindowViewModel.cs index a4229275d..5b9103a1a 100644 --- a/src/Filee.App/ViewModels/MainWindowViewModel.cs +++ b/src/Filee.App/ViewModels/MainWindowViewModel.cs @@ -56,6 +56,7 @@ public MainWindowViewModel(IServiceProvider services, ILocalizer loc, UpdateServ new("watch", "nav.watch", "Icon.Folder", typeof(WatchFoldersPageViewModel)), new("theme", "nav.theme", "Icon.Palette", typeof(ThemePageViewModel)), new("engines", "nav.engines", "Icon.Engine", typeof(EnginesPageViewModel)), + new("security", "nav.security", "Icon.Shield", typeof(SecurityPageViewModel)), new("about", "nav.about", "Icon.Info", typeof(AboutPageViewModel)), ]; foreach (var item in Items) diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index c55d35401..18c54740f 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -161,7 +161,25 @@ partial void OnLanguageChanged(Choice? oldValue, Choice newValue if (!Choices.Restore(oldValue, newValue, v => Language = v)) Save(s => s.Language = newValue.Value); } - partial void OnStartWithSystemChanged(bool value) => Save(s => s.StartWithSystem = value); + partial void OnStartWithSystemChanged(bool value) + { + Save(s => s.StartWithSystem = value); + OnPropertyChanged(nameof(StartupDisabledBySystem)); + } + + /// + /// The switch is on, but Windows turned Filee's start at sign-in off in Task Manager → Startup apps (#450): without + /// this the switch would look right while Filee doesn't start. + /// + public bool StartupDisabledBySystem => StartWithSystem && _platform.StartupDisabledBySystem; + + /// Turns it on again in Task Manager's list. Only on request: Filee never overrides that choice by itself. + [RelayCommand] + private void ReenableStartup() + { + _platform.ReenableStartup(); + OnPropertyChanged(nameof(StartupDisabledBySystem)); + } partial void OnShowInDockChanged(bool value) { diff --git a/src/Filee.App/ViewModels/Pages/SecurityPageViewModel.cs b/src/Filee.App/ViewModels/Pages/SecurityPageViewModel.cs new file mode 100644 index 000000000..583bcda7a --- /dev/null +++ b/src/Filee.App/ViewModels/Pages/SecurityPageViewModel.cs @@ -0,0 +1,113 @@ +// Settings → Security and limits: documents with macros or other active content (AppSettings.Security), and the limits +// for pictures, files converted at once and the time a built-in engine may spend on one step (AppSettings.Limits). + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.Core.Localization; +using Filee.Core.Settings; + +namespace Filee.App.ViewModels.Pages; + +public sealed partial class SecurityPageViewModel : ObservableObject, IDisposable +{ + private readonly UserDataStore _store; + private readonly ILocalizer _loc; + + public SecurityPageViewModel(UserDataStore store, ILocalizer loc) + { + _store = store; + _loc = loc; + Policies = Enum.GetValues() + .Select(p => new Choice(p, loc[$"security.policy.{p}"], loc[$"security.policy.{p}_hint"])) + .ToList(); + _policy = Policies.First(p => p.Value == store.Settings.Security.ActiveContent); + Load(store.Settings.Limits); + store.SettingsChanged += OnSettingsChanged; + } + + /// Ask / convert / never, each with what it means. + public IReadOnlyList> Policies { get; } + + [ObservableProperty] private Choice _policy; + + partial void OnPolicyChanged(Choice? oldValue, Choice newValue) + { + if (!Choices.Restore(oldValue, newValue, v => Policy = v) && _store.Settings.Security.ActiveContent != newValue.Value) + Save(s => s.Security.ActiveContent = newValue.Value); + } + + /// Largest picture in megapixels; 0 for no limit. + [ObservableProperty] private int _maxImageMegapixels; + + /// Longest side of a picture in pixels; 0 for ImageMagick's default. + [ObservableProperty] private int _maxImageSide; + + /// Memory for pictures in MB; 0 for ImageMagick's default. + [ObservableProperty] private int _maxImageMemoryMb; + + /// Disk space for pictures in MB; 0 for ImageMagick's default. + [ObservableProperty] private int _maxImageDiskMb; + + /// Files of a job converted at once; 0 for one per processor core. + [ObservableProperty] private int _maxParallelFiles; + + /// Minutes per step for built-in engines; 0 for no limit. + [ObservableProperty] private int _stepTimeoutMinutes; + + public int MaxMegapixels => LimitSettings.MaxMegapixels; + public int MaxSide => LimitSettings.MaxSide; + public int MaxMegabytes => LimitSettings.MaxMegabytes; + public int MaxFiles => LimitSettings.MaxFiles; + public int MaxTimeoutMinutes => LimitSettings.MaxTimeoutMinutes; + + /// "0 = one per processor core (8 on this computer)". + public string ParallelHint => string.Format(_loc["security.parallel_hint"], Environment.ProcessorCount); + + partial void OnMaxImageMegapixelsChanged(int value) => Save(s => s.Limits.MaxImageMegapixels = Math.Clamp(value, 0, LimitSettings.MaxMegapixels)); + partial void OnMaxImageSideChanged(int value) => Save(s => s.Limits.MaxImageSide = Math.Clamp(value, 0, LimitSettings.MaxSide)); + partial void OnMaxImageMemoryMbChanged(int value) => Save(s => s.Limits.MaxImageMemoryMb = Math.Clamp(value, 0, LimitSettings.MaxMegabytes)); + partial void OnMaxImageDiskMbChanged(int value) => Save(s => s.Limits.MaxImageDiskMb = Math.Clamp(value, 0, LimitSettings.MaxMegabytes)); + partial void OnMaxParallelFilesChanged(int value) => Save(s => s.Limits.MaxParallelFiles = Math.Clamp(value, 0, LimitSettings.MaxFiles)); + partial void OnStepTimeoutMinutesChanged(int value) => Save(s => s.Limits.StepTimeoutMinutes = Math.Clamp(value, 0, LimitSettings.MaxTimeoutMinutes)); + + /// Puts every limit back to Filee's default. + [RelayCommand] + private void ResetLimits() + { + Save(s => s.Limits = new LimitSettings()); + Load(_store.Settings.Limits); + } + + private void Load(LimitSettings limits) + { + // Through the properties: the change handlers save the same values again, which is harmless. + MaxImageMegapixels = limits.EffectiveMaxImageMegapixels; + MaxImageSide = limits.EffectiveMaxImageSide; + MaxImageMemoryMb = limits.EffectiveMaxImageMemoryMb; + MaxImageDiskMb = limits.EffectiveMaxImageDiskMb; + MaxParallelFiles = Math.Clamp(limits.MaxParallelFiles, 0, LimitSettings.MaxFiles); + StepTimeoutMinutes = Math.Clamp(limits.StepTimeoutMinutes, 0, LimitSettings.MaxTimeoutMinutes); + } + + /// "Don't ask again" in the window about active content sets the policy too. + private void OnSettingsChanged(object? sender, EventArgs e) + { + if (Policy.Value != _store.Settings.Security.ActiveContent) + Policy = Policies.First(p => p.Value == _store.Settings.Security.ActiveContent); + } + + public void Dispose() => _store.SettingsChanged -= OnSettingsChanged; + + private void Save(Action change) + { + var before = Snapshot(); + change(_store.Settings); + if (Snapshot() != before) + _store.SaveSettings(); + } + + private string Snapshot() => + $"{_store.Settings.Security.ActiveContent}|{_store.Settings.Limits.MaxImageMegapixels}|{_store.Settings.Limits.MaxImageSide}|" + + $"{_store.Settings.Limits.MaxImageMemoryMb}|{_store.Settings.Limits.MaxImageDiskMb}|{_store.Settings.Limits.MaxParallelFiles}|" + + $"{_store.Settings.Limits.StepTimeoutMinutes}"; +} diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.Apply.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.Apply.cs new file mode 100644 index 000000000..93ec99daf --- /dev/null +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.Apply.cs @@ -0,0 +1,142 @@ +// PresetEditorViewModel: Apply() writes the edited clone back into the original preset. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Engines.Ocr; + +namespace Filee.App.ViewModels; + +public sealed partial class PresetEditorViewModel +{ + /// Writes the edits into the original preset. Returns false if the input is invalid. + public bool Apply() + { + if (!IsValid) + return false; + + // Keep a built-in preset's translatable name unless the user actually renamed it. + if (_original.NameKey is null || Name.Trim() != _loc.DisplayName(_original)) + _original.Name = Name.Trim(); + + _original.TargetFormat = TargetId; + var image = _original.Image; + image.Quality = Math.Clamp(Quality, 1, 100); + image.Resize = Resize.Value; + // Only images and PDFs are turned; for any other target a rotation left over from an earlier target would make + // every file fail ("can only turn images") with nothing on screen to turn it off. + image.Rotation = CanTurn ? Rotation.Value : ImageRotation.None; + image.Flip = CanTurn ? Flip.Value : ImageFlip.None; + image.ResizePercent = Math.Clamp(ResizePercent, 1, 1000); + image.LongEdge = Math.Clamp(LongEdge, 16, 20000); + image.Width = Math.Clamp(Width, 16, 20000); + image.Dpi = SetDpi ? Math.Clamp(Dpi, 36, 2400) : null; + image.KeepMetadata = KeepMetadata; + image.KeepColorProfile = KeepColorProfile; + image.Grayscale = Grayscale; + image.RemoveBackground = RemoveBackground; + image.BackgroundTolerance = Math.Clamp(BackgroundTolerance, 0, 100); + image.KeepEnclosedBackground = KeepEnclosedBackground; + image.Background = string.IsNullOrWhiteSpace(Background) ? "#FFFFFF" : Background.Trim(); + image.TiffCompression = TiffCompression.Value; + image.WebpLossless = WebpLossless; + image.MultiPageTiff = MultiPageTiff; + image.FitsTiff = FitsTiffMode.Value; + image.FitsDebayer = FitsDebayer; + image.IcoSizes = IcoSizes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => int.TryParse(s, out var v) ? v : 0) + .Where(v => v is > 0 and <= 256) + .Distinct() + .ToList(); + if (image.IcoSizes.Count == 0) + image.IcoSizes = [256, 48, 32, 16]; + // Only where the option is shown: a speed chosen for GIF and left behind by a JPG target saves nothing. + image.AnimationSpeed = ShowAnimationSpeed ? AnimationSpeed.Value : 1; + + var pdf = _original.Pdf; + pdf.MergeIntoSingle = Merge; + pdf.PageSize = PageSize.Value; + pdf.MarginMm = Math.Clamp(MarginMm, 0, 100); + pdf.RenderDpi = Math.Clamp(RenderDpi, 36, 1200); + pdf.PageRange = PageRange.Trim(); + pdf.SplitPages = SplitPages; + pdf.CombinePages = CombinePages; + pdf.TrimPageMargins = TrimPageMargins; + pdf.SplitTallImage = SplitTallImage; + pdf.Compression = CompressionLevel.Value; + pdf.CompressWithGhostscript = CompressWithGhostscript; + // Only for a PDF target, where they are shown: a password left behind by it must not lock nothing. + pdf.Watermark = ShowPdfBuild ? Watermark.Trim() : ""; + pdf.PageNumbers = ShowPdfBuild && PageNumbers; + // PDF/A can't be encrypted, so the box is off while it is ticked. + pdf.Password = ShowPdfBuild && !PdfA ? PdfPassword : ""; + pdf.PasswordLeftOut = false; + // Only where the option is shown, as with SaveEveryFrame below. + pdf.Ocr = Recognizes; + pdf.OcrLanguage = OcrLanguage.Value; + if (pdf.Ocr) + { + // Hidden while text is recognized, so nothing is kept behind it that the PDF wouldn't do (Recognizes). + pdf.SplitPages = false; + pdf.Compression = PdfCompression.None; + pdf.CompressWithGhostscript = false; + pdf.PageSize = PdfPageSize.FitImage; + pdf.MarginMm = 0; + } + _original.Document.PdfA = PdfA; + // Only where the option is shown, as with SaveEveryFrame below. + _original.Document.LeaveOutPictures = ShowLeaveOutPictures && LeaveOutPictures; + + var media = _original.Media; + media.Quality = MediaQuality.Value; + media.MaxHeight = MaxHeight.Value; + media.AudioBitrateKbps = AudioBitrate.Value; + media.RemoveAudio = RemoveAudio; + media.AudioChannels = AudioChannels.Value; + media.AudioSampleRate = SampleRate.Value; + media.WavFormat = WavFormat.Value; + // A Live Photo's clip is a MOV (or an MP4 in a Motion Photo): H.264 or HEVC, whatever the photo's format. + media.VideoCodec = IsLivePhoto + ? (_videoCodecValue == VideoCodec.Hevc ? VideoCodec.Hevc : VideoCodec.Default) + : MediaCodecs.Effective(TargetId, _videoCodecValue); + media.AudioCodec = MediaCodecs.Effective(TargetId, _audioCodecValue); + media.HardwareEncoding = HardwareEncoding; + // Only where the option is shown: a box ticked for PNG and left behind by another target saves nothing. + media.SaveEveryFrame = ShowSaveEveryFrame && SaveEveryFrame; + media.FrameInterval = media.SaveEveryFrame ? Math.Clamp(FrameInterval, 1, 1000) : 1; + media.ExtractMotionPhoto = ShowExtractMotionPhoto && ExtractMotionPhoto; + if (ShowSlideshow) + { + media.SlideSeconds = double.IsFinite(SlideSeconds) ? Math.Clamp(SlideSeconds, MediaOptions.MinSlideSeconds, MediaOptions.MaxSlideSeconds) : 3; + media.Crossfade = Crossfade; + } + media.LivePhoto = IsLivePhoto ? _livePhotoValue : LivePhotoKind.None; + static double Seconds(double value, double fallback, double min, double max) => + double.IsFinite(value) ? Math.Clamp(value, min, max) : fallback; + media.LivePhotoStart = Seconds(LivePhotoStart, 0, 0, 24 * 3600); + media.LivePhotoLength = Seconds(LivePhotoLength, 3, MediaOptions.MinLivePhotoLength, MediaOptions.MaxLivePhotoLength); + media.LivePhotoCover = Seconds(LivePhotoCover, 1.5, 0, MediaOptions.MaxLivePhotoLength); + _original.Archive.Level = ArchiveLevel.Value; + _original.Archive.CombineIntoOne = CombineIntoOne; + _original.Trace.Mode = TraceMode.Value; + _original.Trace.Colors = Math.Clamp(TraceColors, TracingOptions.MinColors, TracingOptions.MaxColors); + _original.Trace.Detail = Math.Clamp(TraceDetail, 0, 100); + // Only where it is shown: a version chosen for DWG and left behind by a PNG target is not kept. + _original.Cad.Version = ShowCad ? CadVersion.Value : default; + + var output = _original.Output; + output.Location = Location.Value; + output.SubfolderName = string.IsNullOrWhiteSpace(SubfolderName) ? "converted" : SubfolderName.Trim(); + // Only with a subfolder, where it is offered: a box ticked there must not stay on hidden behind another location. + output.SubfolderOnlyForSeveralFiles = ShowSubfolder && SubfolderOnlyForSeveralFiles; + output.CustomFolder = CustomFolder.Trim(); + output.FileNamePattern = string.IsNullOrWhiteSpace(Pattern) ? "{name}" : Pattern.Trim(); + output.Conflict = Conflict.Value; + output.KeepDates = KeepDates; + // Only for geospatial targets, where it is offered: a box ticked for Shapefile must not zip a later PNG. + output.ZipResult = ShowZipResult && ZipResult; + output.ReplaceOriginal = ReplaceOriginal; + return true; + } +} diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.Visibility.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.Visibility.cs new file mode 100644 index 000000000..bafafb0db --- /dev/null +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.Visibility.cs @@ -0,0 +1,120 @@ +// PresetEditorViewModel: which options the editor shows for the chosen target (the Show* properties), +// and whether the input is valid. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Engines.Ocr; + +namespace Filee.App.ViewModels; + +public sealed partial class PresetEditorViewModel +{ + private string TargetId => Target.Value; + private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category; + private bool TargetIsImage => TargetId == BuiltInData.SameAsSource || TargetCategory == FormatCategory.Image; + /// A video target, or "Cut a clip", whose exact cuts are encoded with the video options. + private bool TargetIsVideo => TargetCategory == FormatCategory.Video || _original.IsClipTool; + private bool TargetIsAudio => TargetCategory == FormatCategory.Audio; + private bool TargetIsLossyMedia => TargetIsVideo + || (TargetIsAudio && !LosslessAudioTargets.Contains(TargetId) && MediaCodecs.Effective(TargetId, _audioCodecValue) != AudioCodec.Alac); + + /// A tool (Crop) keeps the source as it is: none of the image options apply. + public bool ShowImage => !IsTool && TargetIsImage && !IsLivePhoto; + /// JPG and HEIC: a video can become a Live / Motion Photo instead of one still frame. + public bool ShowLivePhotoChoice => !IsTool && LivePhotoKinds.Count > 1; + /// A Live / Motion Photo is chosen and the target can be written as it: its clip options are shown. + public bool IsLivePhoto => ShowLivePhotoChoice && _livePhotoValue != LivePhotoKind.None + && MediaOptions.LivePhotoTargets(_livePhotoValue).Contains(TargetId); + /// What the chosen kind writes and where it is expected to work. + public string LivePhotoHint => _loc[_livePhotoValue == LivePhotoKind.Android ? "presets.live_photo_hint.android" : "presets.live_photo_hint.apple"]; + /// Images and PDFs (pictures placed on pages) can be turned and flipped. + private bool CanTurn => TargetIsImage || TargetId == "pdf"; + /// Rotate and flip for a PDF target, which has no image card to show them in. + public bool ShowTurnForPdf => TargetId == "pdf"; + public bool ShowQuality => !IsTool && (TargetId == BuiltInData.SameAsSource || LossyTargets.Contains(TargetId)); + public bool ShowTiff => TargetId is "tiff" or BuiltInData.SameAsSource; + public bool ShowWebp => TargetId is "webp" or BuiltInData.SameAsSource; + public bool ShowMultiPageTiff => TargetId is "tiff"; + /// FITS (astronomy) → TIFF: linear or stretched, at the file's own depth. + public bool ShowFitsTiff => !IsTool && TargetId is "tiff"; + /// FITS → any other picture is stretched; a colour camera's raw frame can be debayered or kept mono. + public bool ShowFitsDebayer => !IsTool && TargetCategory == FormatCategory.Image && TargetId != "tiff"; + public bool ShowIco => TargetId == "ico"; + /// PDF options (also used when a PDF source becomes images) mean nothing for media, archive, font, subtitle, data, e-mail, 3D and geospatial targets. + public bool ShowPdf => !IsTool && !IsLivePhoto && TargetCategory is not (FormatCategory.Video or FormatCategory.Audio or FormatCategory.Archive or FormatCategory.Font + or FormatCategory.Subtitle or FormatCategory.Data or FormatCategory.Model3D or FormatCategory.Geo) && TargetId != "eml"; + /// "Save as ZIP" is offered for geospatial targets, whose results are several files (a Shapefile) or travel zipped. + public bool ShowZipResult => TargetCategory == FormatCategory.Geo; + public bool ShowPdfBuild => TargetId == "pdf"; + + /// Page size and margin of a PDF built from pictures; not with text recognition (). + public bool ShowPageSize => ShowPdfBuild && !Recognizes; + public bool ShowPdfSplit => TargetId == "pdf" && !Recognizes; + + /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on. + public bool ShowPdfCompression => TargetId == "pdf" && !Recognizes; + + /// XLSX: what a PDF becomes (its ruled tables, PdfTablesConverter). + public bool ShowPdfTablesNote => TargetId == "xlsx"; + public bool ShowPdfCompressionOptions => ShowPdfCompression && CompressionLevel.Value != PdfCompression.None; + + /// Text from scans goes into the formats Filee writes from a PDF's text itself (PdfOptions.OcrTargets). + public bool ShowOcr => !IsTool && PdfOptions.OcrTargets.Contains(TargetId); + + /// PDF → a single-image format: the pages can be stacked into one tall image (TIFF, GIF, APNG and MNG keep pages). + public bool ShowPdfCombine => TargetCategory == FormatCategory.Image + && TargetId is not ("tiff" or "gif" or "apng" or "mng" or "ico" or "cur" or "icns" or "svg" or "pdf"); + public bool ShowDocument => TargetId == "pdf"; + /// Markdown can't hold pictures: they go into a folder next to it, or are left out. + public bool ShowLeaveOutPictures => TargetId == "md"; + public bool ShowMedia => ShowMediaQuality || ShowMaxHeight || ShowRemoveAudio || ShowAudioChannels || ShowVideoCodec || ShowAudioCodec; + /// Mono / stereo for every audio target but AMR, which is mono by definition. + public bool ShowAudioChannels => TargetIsAudio && TargetId != "amr"; + /// Sample rate and sample format: the PCM of WAV and W64 takes any. + public bool ShowWav => TargetId is "wav" or "w64"; + /// MP4, MKV, MOV and WebM can be written with HEVC or AV1 besides their usual codec. + public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1; + /// Hardware encoding applies to H.264, HEVC and AV1 only (not VP9, MPEG-2, …). + public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue); + /// M4A / M4B can hold Apple Lossless besides AAC. + public bool ShowAudioCodec => AudioCodecs.Count > 1; + public bool ShowMediaQuality => TargetIsLossyMedia; + public bool ShowMaxHeight => TargetIsVideo; + public bool ShowAudioBitrate => TargetIsLossyMedia; + public bool ShowRemoveAudio => TargetIsVideo; + public bool ShowArchive => TargetCategory == FormatCategory.Archive; + public bool ShowArchiveOptions => ShowArchive && TargetId != FormatRegistry.Folder; + /// "Extract" has nothing to set; a line says what it does instead. + public bool ShowExtractNote => TargetId == FormatRegistry.Folder; + /// "Save every frame" of a video: PNG and JPG (format ids, ). + public bool ShowSaveEveryFrame => !IsTool && !IsLivePhoto && MediaOptions.FrameTargets.Contains(TargetId); + /// The slideshow options, for a preset that makes a video of pictures (MP4). + public bool ShowSlideshow => _isSlideshow && MediaOptions.JoinTargets.Contains(TargetId) && TargetIsVideo; + /// The video of a Live / Motion Photo: MP4 and MOV (). + public bool ShowExtractMotionPhoto => !IsTool && MediaOptions.MotionPhotoTargets.Contains(TargetId); + /// + /// Playback speed: for the formats an animation stays animated in (an animated GIF, WebP, APNG or MNG, or a video + /// made into a GIF or APNG). Other targets get one frame. + /// + public bool ShowAnimationSpeed => TargetId is "gif" or "webp" or "apng" or "mng" or BuiltInData.SameAsSource; + /// + /// Tracing options: raster images reach SVG (and DXF / DWG through it) only by tracing. For other sources (a + /// drawing, a CAD file) the options are ignored. + /// + public bool ShowTrace => TargetId is "svg" or "svgz" or "dxf" or "dwg"; + + /// The AutoCAD version, for presets that write DWG or DXF. + public bool ShowCad => !IsTool && TargetId is "dxf" or "dwg"; + public bool ShowTraceColors => TraceMode.Value == TracingMode.Color; + public bool ShowPercent => Resize.Value == ResizeMode.Percent; + public bool ShowLongEdge => Resize.Value == ResizeMode.LongEdge; + public bool ShowWidth => Resize.Value == ResizeMode.Width; + public bool ShowMargin => PageSize.Value != PdfPageSize.FitImage; + public bool ShowSubfolder => Location.Value == OutputLocation.Subfolder; + public bool ShowCustomFolder => Location.Value == OutputLocation.CustomFolder; + + public string? NameError => string.IsNullOrWhiteSpace(Name) ? _loc["presets.name_required"] : null; + public bool IsValid => NameError is null; +} diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 44cb482cf..403b20137 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -536,243 +536,6 @@ partial void OnCompressionLevelChanged(Choice? oldValue, Choice< partial void OnAnimationSpeedChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => AnimationSpeed = v); - private string TargetId => Target.Value; - private FormatCategory? TargetCategory => FormatRegistry.FindById(TargetId)?.Category; - private bool TargetIsImage => TargetId == BuiltInData.SameAsSource || TargetCategory == FormatCategory.Image; - /// A video target, or "Cut a clip", whose exact cuts are encoded with the video options. - private bool TargetIsVideo => TargetCategory == FormatCategory.Video || _original.IsClipTool; - private bool TargetIsAudio => TargetCategory == FormatCategory.Audio; - private bool TargetIsLossyMedia => TargetIsVideo - || (TargetIsAudio && !LosslessAudioTargets.Contains(TargetId) && MediaCodecs.Effective(TargetId, _audioCodecValue) != AudioCodec.Alac); - - /// A tool (Crop) keeps the source as it is: none of the image options apply. - public bool ShowImage => !IsTool && TargetIsImage && !IsLivePhoto; - /// JPG and HEIC: a video can become a Live / Motion Photo instead of one still frame. - public bool ShowLivePhotoChoice => !IsTool && LivePhotoKinds.Count > 1; - /// A Live / Motion Photo is chosen and the target can be written as it: its clip options are shown. - public bool IsLivePhoto => ShowLivePhotoChoice && _livePhotoValue != LivePhotoKind.None - && MediaOptions.LivePhotoTargets(_livePhotoValue).Contains(TargetId); - /// What the chosen kind writes and where it is expected to work. - public string LivePhotoHint => _loc[_livePhotoValue == LivePhotoKind.Android ? "presets.live_photo_hint.android" : "presets.live_photo_hint.apple"]; - /// Images and PDFs (pictures placed on pages) can be turned and flipped. - private bool CanTurn => TargetIsImage || TargetId == "pdf"; - /// Rotate and flip for a PDF target, which has no image card to show them in. - public bool ShowTurnForPdf => TargetId == "pdf"; - public bool ShowQuality => !IsTool && (TargetId == BuiltInData.SameAsSource || LossyTargets.Contains(TargetId)); - public bool ShowTiff => TargetId is "tiff" or BuiltInData.SameAsSource; - public bool ShowWebp => TargetId is "webp" or BuiltInData.SameAsSource; - public bool ShowMultiPageTiff => TargetId is "tiff"; - /// FITS (astronomy) → TIFF: linear or stretched, at the file's own depth. - public bool ShowFitsTiff => !IsTool && TargetId is "tiff"; - /// FITS → any other picture is stretched; a colour camera's raw frame can be debayered or kept mono. - public bool ShowFitsDebayer => !IsTool && TargetCategory == FormatCategory.Image && TargetId != "tiff"; - public bool ShowIco => TargetId == "ico"; - /// PDF options (also used when a PDF source becomes images) mean nothing for media, archive, font, subtitle, data, e-mail, 3D and geospatial targets. - public bool ShowPdf => !IsTool && !IsLivePhoto && TargetCategory is not (FormatCategory.Video or FormatCategory.Audio or FormatCategory.Archive or FormatCategory.Font - or FormatCategory.Subtitle or FormatCategory.Data or FormatCategory.Model3D or FormatCategory.Geo) && TargetId != "eml"; - /// "Save as ZIP" is offered for geospatial targets, whose results are several files (a Shapefile) or travel zipped. - public bool ShowZipResult => TargetCategory == FormatCategory.Geo; - public bool ShowPdfBuild => TargetId == "pdf"; - - /// Page size and margin of a PDF built from pictures; not with text recognition (). - public bool ShowPageSize => ShowPdfBuild && !Recognizes; - public bool ShowPdfSplit => TargetId == "pdf" && !Recognizes; - - /// Compression is for PDF → PDF; Ghostscript and metadata only matter while it is on. - public bool ShowPdfCompression => TargetId == "pdf" && !Recognizes; - - /// XLSX: what a PDF becomes (its ruled tables, PdfTablesConverter). - public bool ShowPdfTablesNote => TargetId == "xlsx"; - public bool ShowPdfCompressionOptions => ShowPdfCompression && CompressionLevel.Value != PdfCompression.None; - - /// Text from scans goes into the formats Filee writes from a PDF's text itself (PdfOptions.OcrTargets). - public bool ShowOcr => !IsTool && PdfOptions.OcrTargets.Contains(TargetId); - - /// PDF → a single-image format: the pages can be stacked into one tall image (TIFF, GIF, APNG and MNG keep pages). - public bool ShowPdfCombine => TargetCategory == FormatCategory.Image - && TargetId is not ("tiff" or "gif" or "apng" or "mng" or "ico" or "cur" or "icns" or "svg" or "pdf"); - public bool ShowDocument => TargetId == "pdf"; - /// Markdown can't hold pictures: they go into a folder next to it, or are left out. - public bool ShowLeaveOutPictures => TargetId == "md"; - public bool ShowMedia => ShowMediaQuality || ShowMaxHeight || ShowRemoveAudio || ShowAudioChannels || ShowVideoCodec || ShowAudioCodec; - /// Mono / stereo for every audio target but AMR, which is mono by definition. - public bool ShowAudioChannels => TargetIsAudio && TargetId != "amr"; - /// Sample rate and sample format: the PCM of WAV and W64 takes any. - public bool ShowWav => TargetId is "wav" or "w64"; - /// MP4, MKV, MOV and WebM can be written with HEVC or AV1 besides their usual codec. - public bool ShowVideoCodec => TargetIsVideo && VideoCodecs.Count > 1; - /// Hardware encoding applies to H.264, HEVC and AV1 only (not VP9, MPEG-2, …). - public bool ShowHardwareEncoding => TargetIsVideo && MediaCodecs.TakesHardwareEncoding(TargetId, _videoCodecValue); - /// M4A / M4B can hold Apple Lossless besides AAC. - public bool ShowAudioCodec => AudioCodecs.Count > 1; - public bool ShowMediaQuality => TargetIsLossyMedia; - public bool ShowMaxHeight => TargetIsVideo; - public bool ShowAudioBitrate => TargetIsLossyMedia; - public bool ShowRemoveAudio => TargetIsVideo; - public bool ShowArchive => TargetCategory == FormatCategory.Archive; - public bool ShowArchiveOptions => ShowArchive && TargetId != FormatRegistry.Folder; - /// "Extract" has nothing to set; a line says what it does instead. - public bool ShowExtractNote => TargetId == FormatRegistry.Folder; - /// "Save every frame" of a video: PNG and JPG (format ids, ). - public bool ShowSaveEveryFrame => !IsTool && !IsLivePhoto && MediaOptions.FrameTargets.Contains(TargetId); - /// The slideshow options, for a preset that makes a video of pictures (MP4). - public bool ShowSlideshow => _isSlideshow && MediaOptions.JoinTargets.Contains(TargetId) && TargetIsVideo; - /// The video of a Live / Motion Photo: MP4 and MOV (). - public bool ShowExtractMotionPhoto => !IsTool && MediaOptions.MotionPhotoTargets.Contains(TargetId); - /// - /// Playback speed: for the formats an animation stays animated in (an animated GIF, WebP, APNG or MNG, or a video - /// made into a GIF or APNG). Other targets get one frame. - /// - public bool ShowAnimationSpeed => TargetId is "gif" or "webp" or "apng" or "mng" or BuiltInData.SameAsSource; - /// - /// Tracing options: raster images reach SVG (and DXF / DWG through it) only by tracing. For other sources (a - /// drawing, a CAD file) the options are ignored. - /// - public bool ShowTrace => TargetId is "svg" or "svgz" or "dxf" or "dwg"; - - /// The AutoCAD version, for presets that write DWG or DXF. - public bool ShowCad => !IsTool && TargetId is "dxf" or "dwg"; - public bool ShowTraceColors => TraceMode.Value == TracingMode.Color; - public bool ShowPercent => Resize.Value == ResizeMode.Percent; - public bool ShowLongEdge => Resize.Value == ResizeMode.LongEdge; - public bool ShowWidth => Resize.Value == ResizeMode.Width; - public bool ShowMargin => PageSize.Value != PdfPageSize.FitImage; - public bool ShowSubfolder => Location.Value == OutputLocation.Subfolder; - public bool ShowCustomFolder => Location.Value == OutputLocation.CustomFolder; - - public string? NameError => string.IsNullOrWhiteSpace(Name) ? _loc["presets.name_required"] : null; - public bool IsValid => NameError is null; - - /// Writes the edits into the original preset. Returns false if the input is invalid. - public bool Apply() - { - if (!IsValid) - return false; - - // Keep a built-in preset's translatable name unless the user actually renamed it. - if (_original.NameKey is null || Name.Trim() != _loc.DisplayName(_original)) - _original.Name = Name.Trim(); - - _original.TargetFormat = TargetId; - var image = _original.Image; - image.Quality = Math.Clamp(Quality, 1, 100); - image.Resize = Resize.Value; - // Only images and PDFs are turned; for any other target a rotation left over from an earlier target would make - // every file fail ("can only turn images") with nothing on screen to turn it off. - image.Rotation = CanTurn ? Rotation.Value : ImageRotation.None; - image.Flip = CanTurn ? Flip.Value : ImageFlip.None; - image.ResizePercent = Math.Clamp(ResizePercent, 1, 1000); - image.LongEdge = Math.Clamp(LongEdge, 16, 20000); - image.Width = Math.Clamp(Width, 16, 20000); - image.Dpi = SetDpi ? Math.Clamp(Dpi, 36, 2400) : null; - image.KeepMetadata = KeepMetadata; - image.KeepColorProfile = KeepColorProfile; - image.Grayscale = Grayscale; - image.RemoveBackground = RemoveBackground; - image.BackgroundTolerance = Math.Clamp(BackgroundTolerance, 0, 100); - image.KeepEnclosedBackground = KeepEnclosedBackground; - image.Background = string.IsNullOrWhiteSpace(Background) ? "#FFFFFF" : Background.Trim(); - image.TiffCompression = TiffCompression.Value; - image.WebpLossless = WebpLossless; - image.MultiPageTiff = MultiPageTiff; - image.FitsTiff = FitsTiffMode.Value; - image.FitsDebayer = FitsDebayer; - image.IcoSizes = IcoSizes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(s => int.TryParse(s, out var v) ? v : 0) - .Where(v => v is > 0 and <= 256) - .Distinct() - .ToList(); - if (image.IcoSizes.Count == 0) - image.IcoSizes = [256, 48, 32, 16]; - // Only where the option is shown: a speed chosen for GIF and left behind by a JPG target saves nothing. - image.AnimationSpeed = ShowAnimationSpeed ? AnimationSpeed.Value : 1; - - var pdf = _original.Pdf; - pdf.MergeIntoSingle = Merge; - pdf.PageSize = PageSize.Value; - pdf.MarginMm = Math.Clamp(MarginMm, 0, 100); - pdf.RenderDpi = Math.Clamp(RenderDpi, 36, 1200); - pdf.PageRange = PageRange.Trim(); - pdf.SplitPages = SplitPages; - pdf.CombinePages = CombinePages; - pdf.TrimPageMargins = TrimPageMargins; - pdf.SplitTallImage = SplitTallImage; - pdf.Compression = CompressionLevel.Value; - pdf.CompressWithGhostscript = CompressWithGhostscript; - // Only for a PDF target, where they are shown: a password left behind by it must not lock nothing. - pdf.Watermark = ShowPdfBuild ? Watermark.Trim() : ""; - pdf.PageNumbers = ShowPdfBuild && PageNumbers; - // PDF/A can't be encrypted, so the box is off while it is ticked. - pdf.Password = ShowPdfBuild && !PdfA ? PdfPassword : ""; - pdf.PasswordLeftOut = false; - // Only where the option is shown, as with SaveEveryFrame below. - pdf.Ocr = Recognizes; - pdf.OcrLanguage = OcrLanguage.Value; - if (pdf.Ocr) - { - // Hidden while text is recognized, so nothing is kept behind it that the PDF wouldn't do (Recognizes). - pdf.SplitPages = false; - pdf.Compression = PdfCompression.None; - pdf.CompressWithGhostscript = false; - pdf.PageSize = PdfPageSize.FitImage; - pdf.MarginMm = 0; - } - _original.Document.PdfA = PdfA; - // Only where the option is shown, as with SaveEveryFrame below. - _original.Document.LeaveOutPictures = ShowLeaveOutPictures && LeaveOutPictures; - - var media = _original.Media; - media.Quality = MediaQuality.Value; - media.MaxHeight = MaxHeight.Value; - media.AudioBitrateKbps = AudioBitrate.Value; - media.RemoveAudio = RemoveAudio; - media.AudioChannels = AudioChannels.Value; - media.AudioSampleRate = SampleRate.Value; - media.WavFormat = WavFormat.Value; - // A Live Photo's clip is a MOV (or an MP4 in a Motion Photo): H.264 or HEVC, whatever the photo's format. - media.VideoCodec = IsLivePhoto - ? (_videoCodecValue == VideoCodec.Hevc ? VideoCodec.Hevc : VideoCodec.Default) - : MediaCodecs.Effective(TargetId, _videoCodecValue); - media.AudioCodec = MediaCodecs.Effective(TargetId, _audioCodecValue); - media.HardwareEncoding = HardwareEncoding; - // Only where the option is shown: a box ticked for PNG and left behind by another target saves nothing. - media.SaveEveryFrame = ShowSaveEveryFrame && SaveEveryFrame; - media.FrameInterval = media.SaveEveryFrame ? Math.Clamp(FrameInterval, 1, 1000) : 1; - media.ExtractMotionPhoto = ShowExtractMotionPhoto && ExtractMotionPhoto; - if (ShowSlideshow) - { - media.SlideSeconds = double.IsFinite(SlideSeconds) ? Math.Clamp(SlideSeconds, MediaOptions.MinSlideSeconds, MediaOptions.MaxSlideSeconds) : 3; - media.Crossfade = Crossfade; - } - media.LivePhoto = IsLivePhoto ? _livePhotoValue : LivePhotoKind.None; - static double Seconds(double value, double fallback, double min, double max) => - double.IsFinite(value) ? Math.Clamp(value, min, max) : fallback; - media.LivePhotoStart = Seconds(LivePhotoStart, 0, 0, 24 * 3600); - media.LivePhotoLength = Seconds(LivePhotoLength, 3, MediaOptions.MinLivePhotoLength, MediaOptions.MaxLivePhotoLength); - media.LivePhotoCover = Seconds(LivePhotoCover, 1.5, 0, MediaOptions.MaxLivePhotoLength); - _original.Archive.Level = ArchiveLevel.Value; - _original.Archive.CombineIntoOne = CombineIntoOne; - _original.Trace.Mode = TraceMode.Value; - _original.Trace.Colors = Math.Clamp(TraceColors, TracingOptions.MinColors, TracingOptions.MaxColors); - _original.Trace.Detail = Math.Clamp(TraceDetail, 0, 100); - // Only where it is shown: a version chosen for DWG and left behind by a PNG target is not kept. - _original.Cad.Version = ShowCad ? CadVersion.Value : default; - - var output = _original.Output; - output.Location = Location.Value; - output.SubfolderName = string.IsNullOrWhiteSpace(SubfolderName) ? "converted" : SubfolderName.Trim(); - // Only with a subfolder, where it is offered: a box ticked there must not stay on hidden behind another location. - output.SubfolderOnlyForSeveralFiles = ShowSubfolder && SubfolderOnlyForSeveralFiles; - output.CustomFolder = CustomFolder.Trim(); - output.FileNamePattern = string.IsNullOrWhiteSpace(Pattern) ? "{name}" : Pattern.Trim(); - output.Conflict = Conflict.Value; - output.KeepDates = KeepDates; - // Only for geospatial targets, where it is offered: a box ticked for Shapefile must not zip a later PNG. - output.ZipResult = ShowZipResult && ZipResult; - output.ReplaceOriginal = ReplaceOriginal; - return true; - } - /// Playback multipliers using the user's regional decimal separator. private static string SpeedLabel(double speed) => speed.ToString("0.##", System.Globalization.CultureInfo.CurrentCulture) + "×"; diff --git a/src/Filee.App/Views/ActiveContentWindow.axaml b/src/Filee.App/Views/ActiveContentWindow.axaml new file mode 100644 index 000000000..159de139b --- /dev/null +++ b/src/Filee.App/Views/ActiveContentWindow.axaml @@ -0,0 +1,60 @@ + + + + + + + + public IReadOnlyList MissingFonts { get; set; } = []; + + /// + /// Macros, ActiveX controls, external links or DDE fields the source carries, found before a step through an office + /// suite (); empty when none were found or the file wasn't checked. + /// + public IReadOnlyList ActiveContent { get; set; } = []; + + /// The source was checked for active content before the job ran (JobQueue doesn't check it again). + internal bool ActiveContentChecked { get; set; } } /// A batch of files converted with one preset. @@ -76,8 +85,11 @@ public sealed class ConversionJob private readonly CancellationTokenSource _cts = new(); /// Saves sources that aren't files yet before they are converted (files from a browser). - public ConversionJob(IReadOnlyList sources, Preset preset, string presetDisplayName, ISourceFetcher? fetcher = null) + /// Nobody is there to answer a question (a watch folder): see . + public ConversionJob(IReadOnlyList sources, Preset preset, string presetDisplayName, ISourceFetcher? fetcher = null, + bool unattended = false) { + Unattended = unattended; // A Shapefile's .dbf, .shx, .prj, ... dropped with its .shp are part of it, not files to convert on their own. sources = Formats.CompanionFiles.WithoutCompanions(sources); Preset = preset.Clone(); // later edits of the preset must not affect a running job @@ -94,6 +106,12 @@ public ConversionJob(IReadOnlyList sources, Preset preset, string preset /// Saves the sources that aren't files yet when the job runs; null when all of them are files. public ISourceFetcher? Fetcher { get; } + + /// + /// The job runs without anybody to ask (a watch folder): documents with active content follow the setting, and + /// "Ask" converts them, with what they carry in the file's result. + /// + public bool Unattended { get; } public Preset Preset { get; } public string PresetDisplayName { get; } public IReadOnlyList Files { get; } diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs index 1d835c29b..a88eed7fa 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -182,6 +182,16 @@ public interface IImageTransformer bool Transforms(string from, string to); } +/// +/// An engine that stops a conversion running too long on its own: it runs another program (LibreOffice, FFmpeg, +/// Ghostscript, …) under a time limit fitting that program, or packs archives that can take hours. The step time limit +/// of Settings → Security and limits () applies only to the other, built-in engines, +/// which run inside Filee and have none. +/// +public interface IHasOwnTimeout +{ +} + /// /// The scripts of a file's text an engine would draw wrong, from one scan. /// diff --git a/src/Filee.Core/Conversion/JobQueue.ActiveContent.cs b/src/Filee.Core/Conversion/JobQueue.ActiveContent.cs new file mode 100644 index 000000000..6171e0659 --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.ActiveContent.cs @@ -0,0 +1,186 @@ +// JobQueue: Settings → Security and limits. Documents with active content are checked before a step through an office +// suite (asking the user, or following the setting), files of a job convert at most so many at once, and a built-in +// engine's step fails as stuck after the step time limit. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + private int _parallelFiles = Environment.ProcessorCount; + + /// Finds macros and other active content in documents; without it no document is checked. + public IActiveContentScanner? ActiveContentScanner { get; set; } + + /// What happens to a document with active content (). + public ActiveContentPolicy ActiveContentPolicy { get; set; } = ActiveContentPolicy.Ask; + + /// + /// Asks the user about a job's documents with active content under . Null (the + /// command line) and unattended jobs () convert them without asking. + /// + public ActiveContentPrompt? ActiveContentPrompt { get; set; } + + /// How many files of a job convert at once (); at least 1. + public int ParallelFiles + { + get => _parallelFiles; + set => _parallelFiles = Math.Max(1, value); + } + + /// + /// How long a built-in engine may spend on one step before the file fails as stuck (); + /// null for no limit. Engines with a time limit of their own () keep theirs. + /// + public TimeSpan? StepTimeout { get; set; } + + /// + /// Checks the job's documents that would go through an office suite for active content before any of them is + /// converted, so the user is asked once for the whole job. Under "Ask" with nobody to ask, they convert and the file's + /// result says what they carry. + /// + private async Task ScreenActiveContentAsync(ConversionJob job, RoutePlanner planner, string? intermediate) + { + if (ActiveContentScanner is not { } scanner) + return; + var found = new List<(FileResult File, ActiveContentReport Report)>(); + foreach (var file in job.Files) + { + if (Stopped(file) || FormatRegistry.Detect(file.SourcePath) is not { } source) + continue; + var target = intermediate + ?? (job.Preset.TargetFormat == BuiltInData.SameAsSource ? source.Id : job.Preset.TargetFormat); + if (planner.Plan(source.Id, target) is not { } route || !route.Steps.Any(s => s.Converter is IOpensDocumentsInOfficeSuite)) + continue; + file.ActiveContentChecked = true; + if (Scan(scanner, file.SourcePath, source.Id) is not { } report) + continue; + file.ActiveContent = report.Findings; + found.Add((file, report)); + } + if (found.Count == 0) + return; + + _log.LogInformation("Job {Job}: {Count} document(s) with active content, policy {Policy}", job.Id, found.Count, ActiveContentPolicy); + var decision = ActiveContentPolicy switch + { + ActiveContentPolicy.Convert => ActiveContentDecision.Trust, + ActiveContentPolicy.Refuse => ActiveContentDecision.Skip, + _ when job.Unattended || ActiveContentPrompt is null => ActiveContentDecision.Trust, + _ => await AskAsync(job, found.Select(f => f.Report).ToList()), + }; + foreach (var (file, report) in found) + { + switch (decision) + { + case ActiveContentDecision.Trust: + file.NoticeKey ??= "notice.active_content"; + break; + case ActiveContentDecision.Skip when ActiveContentPolicy == ActiveContentPolicy.Refuse: + FailFile(job, file, "error.active_content_refused", Summary(report)); + break; + case ActiveContentDecision.Skip: + file.State = FileState.Skipped; + file.NoticeKey = "notice.active_content_skipped"; + file.Progress = 1; + Notify(job); + break; + } + } + if (decision == ActiveContentDecision.Cancel) + job.Cancel(); + } + + /// Asks the user; a prompt that fails leaves the documents out rather than converting them unasked. + private async Task AskAsync(ConversionJob job, IReadOnlyList reports) + { + try + { + return await ActiveContentPrompt!(job, reports, job.CancellationToken); + } + catch (OperationCanceledException) + { + return ActiveContentDecision.Cancel; + } + catch (Exception ex) + { + _log.LogWarning(ex, "Asking about active content failed for job {Job}: leaving those documents out", job.Id); + return ActiveContentDecision.Skip; + } + } + + /// + /// A file whose route goes through an office suite only now (rerouted for its scripts, say) and wasn't checked before + /// the job ran: refused under "Never", else converted with what it carries in its result. Returns the error key, or null. + /// + private string? CheckActiveContentLate(FileResult file, string sourceFormat, Route route) + { + if (file.ActiveContentChecked || ActiveContentScanner is not { } scanner + || !route.Steps.Any(s => s.Converter is IOpensDocumentsInOfficeSuite)) + return null; + file.ActiveContentChecked = true; + if (Scan(scanner, file.SourcePath, sourceFormat) is not { } report) + return null; + file.ActiveContent = report.Findings; + if (ActiveContentPolicy == ActiveContentPolicy.Refuse) + return "error.active_content_refused"; + file.NoticeKey ??= "notice.active_content"; + return null; + } + + private ActiveContentReport? Scan(IActiveContentScanner scanner, string path, string formatId) + { + try + { + return scanner.Scan(path, formatId) is { Findings.Count: > 0 } report ? report : null; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // An unreadable package is the conversion's problem, which reports it with its own words. + _log.LogInformation("Checking {File} for active content failed: {Message}", path, ex.Message); + return null; + } + } + + /// "Macro: Module1, ExternalLink: http://…" for the log and the error detail (not localized). + internal static string Summary(ActiveContentReport report) => + string.Join(", ", report.Findings.Take(5).Select(f => $"{f.Kind}: {f.Name}")) + + (report.Findings.Count > 5 ? $", +{report.Findings.Count - 5}" : ""); + + /// + /// Runs one step, failing it as stuck after for an engine without a time limit of its own. + /// The engine's token is cancelled then; one that doesn't check it finishes in the background, but the job goes on. + /// + private async Task> ConvertWithTimeoutAsync(IConverter converter, ConversionStep step, + IProgress progress, CancellationToken ct) + { + if (StepTimeout is not { } limit || converter is IHasOwnTimeout) + return await converter.ConvertAsync(step, progress, ct); + + var stop = CancellationTokenSource.CreateLinkedTokenSource(ct); + var task = converter.ConvertAsync(step, progress, stop.Token); + try + { + return await task.WaitAsync(limit, ct); + } + catch (TimeoutException) + { + stop.Cancel(); + _log.LogWarning("{Engine} took longer than {Minutes} min on {File}: stopped", converter.DisplayName, limit.TotalMinutes, step.InputPath); + // Observe the abandoned step so its exception isn't reported as unobserved later. + _ = task.ContinueWith(t => _ = t.Exception, TaskScheduler.Default); + throw new ConversionRefusedException("error.conversion_timeout", $"{converter.DisplayName}, {limit.TotalMinutes:0} min"); + } + finally + { + if (task.IsCompleted) + stop.Dispose(); + else + _ = task.ContinueWith(_ => stop.Dispose(), TaskScheduler.Default); + } + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.Allocators.cs b/src/Filee.Core/Conversion/JobQueue.Allocators.cs new file mode 100644 index 000000000..403f69901 --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.Allocators.cs @@ -0,0 +1,133 @@ +// JobQueue: the output allocators converters write through: final outputs (named by OutputPathResolver) +// and temporary files for intermediate steps. + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + /// Allocates final output paths according to the preset's . + private sealed class FinalAllocator(JobQueue queue, OutputRule rule, OutputPathResolver.Tokens tokens) : IOutputAllocator + { + /// + /// Every path handed out, and whether it is this conversion's alone: nothing was there before (not a file it + /// overwrites) and no other conversion had it. + /// + private readonly List<(string Path, bool Own)> _allocated = []; + + /// How many paths were handed out: an output and the picture folder that goes with it are two. + public int Handed => _allocated.Count; + + public string? Allocate(string extension, string? suffix = null) + { + // Reserve under a lock so two parallel files never pick the same name. + lock (queue._reservedOutputs) + { + var path = OutputPathResolver.Resolve(rule, tokens, extension, + p => File.Exists(p) || Directory.Exists(p) || queue._reservedOutputs.ContainsKey(p), suffix); + if (path is null) + return null; + + var nothingThere = !File.Exists(path) && !Directory.Exists(path); + _allocated.Add((path, queue.Reserve(path) && nothingThere)); + var folder = Path.GetFullPath(Path.GetDirectoryName(path)!); + if (!Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + queue._madeFolders.Add(folder); + } + // An empty extension asks for a folder (archive extraction): the converter creates it. + return path; + } + } + + /// The one output was moved to (next to its source): its name goes with it. + public void Moved(string from, string to) + { + lock (queue._reservedOutputs) + { + var index = _allocated.FindIndex(a => string.Equals(a.Path, from, StringComparison.OrdinalIgnoreCase)); + if (index < 0) + return; + // Finished and in its place: nothing to undo there any more. + _allocated[index] = (to, false); + queue.Unreserve(from); + queue.Reserve(to); + } + } + + /// + /// Frees the names handed out but not in (the outputs the engine returned, which stay + /// taken until the job ends). + /// + public void Release(IEnumerable except) + { + var kept = except.ToHashSet(StringComparer.OrdinalIgnoreCase); + lock (queue._reservedOutputs) + { + foreach (var (path, _) in _allocated) + { + if (!kept.Contains(path)) + queue.Unreserve(path); + } + _allocated.Clear(); + } + } + + /// + /// Undoes a conversion that failed or was cancelled: deletes what it started writing at paths that were its + /// alone (never a file it was to overwrite, nor one another conversion had too), so no half-written file is left + /// where the user expects a finished one; frees every name, so trying again gets the same one instead of + /// "name (2)"; and removes a folder it made for them if that left it empty. + /// + public void Discard() + { + lock (queue._reservedOutputs) + { + foreach (var (path, own) in _allocated) + { + if (own && queue._reservedOutputs.TryGetValue(path, out var reservation) && !reservation.Shared) + TryDeleteOutput(path); + queue.Unreserve(path); + } + foreach (var folder in _allocated.Select(a => Path.GetDirectoryName(Path.GetFullPath(a.Path))!) + .Distinct(Platform.FileSystemPaths.Comparer).ToList()) + queue.RemoveIfEmpty(folder); + _allocated.Clear(); + } + } + + private void TryDeleteOutput(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + else if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + queue._log.LogWarning(ex, "Could not delete the unfinished output {Output}", path); + } + } + } + + /// Allocates unique paths inside the job's work directory for intermediate files. + private sealed class TempAllocator(string workDir, string baseName) : IOutputAllocator + { + public string? Allocate(string extension, string? suffix = null) + { + var dir = Path.Combine(workDir, Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(dir); + var ext = extension.TrimStart('.'); + return Path.Combine(dir, ext.Length == 0 ? $"{baseName}{suffix}" : $"{baseName}{suffix}.{ext}"); + } + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.Combine.cs b/src/Filee.Core/Conversion/JobQueue.Combine.cs new file mode 100644 index 000000000..553c1b140 --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.Combine.cs @@ -0,0 +1,396 @@ +// JobQueue: jobs that turn several files into one result: merged PDF, multi-page TIFF, joined media, +// a combined archive, and PDF finishing on the result. + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + /// Applies the preset's PDF finishing () to the PDFs among . + private async Task FinishPdfsAsync(Preset preset, IReadOnlyList outputs, CancellationToken ct) + { + // A retry from history whose preset (and so its password) is gone: no PDF without the password it was meant to have. + if (preset.Pdf.PasswordLeftOut && preset.Pdf.Password.Length == 0) + throw new ConversionRefusedException("error.pdf_password_gone"); + if (!preset.Pdf.FinishesPdf || _catalog.All.OfType().FirstOrDefault() is not { } finisher) + return; + foreach (var pdf in outputs.Where(o => FormatRegistry.Detect(o)?.Id == "pdf")) + await finisher.FinishAsync(pdf, preset.Pdf, ct); + } + + /// Converts every file to PDF in the work directory, then merges them into one output. + private async Task RunMergedAsync(ConversionJob job, RoutePlanner planner, string workDir) + { + var parts = new string?[job.Files.Count]; + await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), + new ParallelOptions { MaxDegreeOfParallelism = ParallelFiles }, + async (i, _) => + { + var file = job.Files[i]; + if (Stopped(file)) + return; + // An empty PDF goes the long way, where it fails as empty instead of breaking the whole merge; so does + // one whose text is recognized first. + if (FormatRegistry.Detect(file.SourcePath)?.Id == "pdf" && !IsEmptyFile(file.SourcePath) && !job.Preset.RecognizesTextOf("pdf")) + { + // Already a PDF, merged as it is: its pages can't be turned (only images going into it can). + if (job.Preset.Image.TurnsOrMirrors) + FailUnturned(job, file, "pdf → pdf"); + else + parts[i] = file.SourcePath; + return; + } + var produced = await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false); + parts[i] = produced.FirstOrDefault(); + }); + + var inputs = parts.OfType().ToList(); + if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) + return; + + var first = job.Files[0]; + var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); + var output = allocator.Allocate("pdf"); + if (output is null) + { + foreach (var f in job.Files) f.State = FileState.Skipped; + return; + } + + try + { + await _pdfMerger!.MergeAsync(inputs, output, job.CancellationToken); + // Once for the merged PDF, so its pages are numbered through. + await FinishPdfsAsync(job.Preset, [output], job.CancellationToken); + } + catch + { + allocator.Discard(); + throw; + } + first.Outputs.Add(output); + foreach (var f in job.Files.Where(f => f.State != FileState.Failed)) + { + f.State = FileState.Done; + f.Progress = 1; + } + Notify(job); + } + + /// + /// Converts every file to TIFF pages in the work directory (a TIFF is taken as it is), then writes them all as one + /// multi-page TIFF named after the first file. + /// + private async Task RunMultiPageTiffAsync(ConversionJob job, RoutePlanner planner, string workDir) + { + var parts = new IReadOnlyList[job.Files.Count]; + await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), + new ParallelOptions { MaxDegreeOfParallelism = ParallelFiles }, + async (i, _) => + { + var file = job.Files[i]; + if (Stopped(file)) + return; + // A TIFF joins as it is, unless it is to be turned: then it goes through the engine like the rest. + parts[i] = FormatRegistry.Detect(file.SourcePath)?.Id == "tiff" && !job.Preset.Image.TurnsOrMirrors + && !IsEmptyFile(file.SourcePath) + ? [file.SourcePath] + : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "tiff"); + }); + + var inputs = parts.SelectMany(p => p ?? []).ToList(); + if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) + return; + + var first = job.Files[0]; + var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); + var output = allocator.Allocate("tiff"); + if (output is null) + { + foreach (var f in job.Files) f.State = FileState.Skipped; + return; + } + + try + { + await _tiffMerger!.MergeAsync(inputs, output, job.Preset.Image, job.CancellationToken); + } + catch + { + allocator.Discard(); + throw; + } + first.Outputs.Add(output); + foreach (var f in job.Files.Where(f => f.State != FileState.Failed)) + { + f.State = FileState.Done; + f.Progress = 1; + } + Notify(job); + } + + /// Share of a slideshow's progress that preparing its pictures takes; encoding the video fills the rest. + private const double SlidesReadyShare = 0.3; + + /// + /// "Merge videos" / "Merge audio": the videos or audio files go into one file as they are. "Video from images": + /// every picture becomes PNG in the work directory first (a PNG is taken as it is), with its orientation applied by + /// the image engines, then the pictures become one video. The output is named after the first file. + /// + private async Task RunJoinedMediaAsync(ConversionJob job, RoutePlanner planner, string workDir) + { + var slideshow = job.Preset.Media.Slideshow; + var target = FormatRegistry.Get(job.Preset.TargetFormat); + // Before any picture is converted: too many fail at once instead of after minutes of PNG steps. Files whose + // download already failed don't count. + var pictures = job.Files.Count(f => !Stopped(f)); + if (slideshow && pictures > MediaOptions.MaxSlides) + { + foreach (var f in job.Files.Where(f => !Stopped(f))) + FailFile(job, f, "error.too_many_pictures", $"{pictures} > {MediaOptions.MaxSlides}"); + return; + } + var parts = new IReadOnlyList?[job.Files.Count]; + await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), + new ParallelOptions { MaxDegreeOfParallelism = ParallelFiles }, + async (i, _) => + { + var file = job.Files[i]; + if (Stopped(file)) + return; + var source = FormatRegistry.Detect(file.SourcePath); + if (source is null) + { + FailFile(job, file, "error.unsupported_source", "." + FormatRegistry.ExtensionOf(file.SourcePath)); + return; + } + if (IsEmptyFile(file.SourcePath)) + { + FailFile(job, file, "error.empty_file", null); + return; + } + if (slideshow) + { + parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors + ? [file.SourcePath] + : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png", + ceiling: SlidesReadyShare); + return; + } + // A video's sound can join an audio file; a picture or a document joins nothing. + var joins = source.Category == FormatCategory.Video + || source.Category == FormatCategory.Audio && target.Category == FormatCategory.Audio; + if (!joins) + { + FailFile(job, file, "error.no_route", $"{source.Id} → {target.Id}"); + return; + } + parts[i] = [file.SourcePath]; + }); + + var joined = job.Files.Where((f, i) => parts[i] is { Count: > 0 } && f.State != FileState.Failed).ToList(); + var inputs = parts.Where(p => p is not null).SelectMany(p => p!).ToList(); + if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) + { + Notify(job); + return; + } + + var first = joined[0]; + var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); + var output = allocator.Allocate(target.PrimaryExtension); + if (output is null) + { + foreach (var f in joined) + { + f.State = FileState.Skipped; + f.Progress = 1; + } + Notify(job); + return; + } + + foreach (var f in joined) + f.State = FileState.Running; + Notify(job); + var floor = joined.Min(f => f.Progress); + var progress = new Progress(p => + { + // Reports arrive on the thread pool, possibly out of order: progress only moves forward. + var value = floor + (1 - floor) * Math.Clamp(p, 0, 1); + if (value <= first.Progress) + return; + foreach (var f in joined) + f.Progress = value; + Notify(job); + }); + + try + { + await _mediaJoiner!.JoinAsync(inputs, output, target.Id, job.Preset, workDir, progress, job.CancellationToken); + first.Outputs.Add(output); + foreach (var f in joined) + { + f.State = FileState.Done; + f.Progress = 1; + } + } + catch (OperationCanceledException) + { + allocator.Discard(); + foreach (var f in joined) + f.State = FileState.Cancelled; + } + catch (ConversionRefusedException ex) + { + allocator.Discard(); + _log.LogInformation("Joining {Count} file(s) into {Output} was refused: {Reason}", inputs.Count, output, ex.Message); + foreach (var f in joined) + FailFile(job, f, ex.ErrorKey, ex.Detail); + } + catch (Exception ex) + { + allocator.Discard(); + _log.LogWarning(ex, "Joining {Count} file(s) into {Output} failed", inputs.Count, output); + foreach (var f in joined) + FailFile(job, f, "error.conversion_failed", ex.Message); + } + Notify(job); + } + + /// Packs every source file, unchanged, into one archive named after the first file. + private async Task RunCombinedAsync(ConversionJob job, string workDir) + { + var format = FormatRegistry.Get(job.Preset.TargetFormat); + var inputs = new List(); + foreach (var file in job.Files.Where(f => !Stopped(f))) + { + // Folders are packed with their content. + if (File.Exists(file.SourcePath) || Directory.Exists(file.SourcePath)) + inputs.Add(file); + else + MarkFailed(file, "The file no longer exists."); + } + if (inputs.Count == 0) + { + Notify(job); + return; + } + + var allocator = CreateFinalAllocator(job, inputs[0].SourcePath, 1, oneFile: true); + var output = allocator.Allocate(format.PrimaryExtension); + if (output is null) + { + foreach (var f in inputs) + { + f.State = FileState.Skipped; + f.Progress = 1; + } + Notify(job); + return; + } + + foreach (var f in inputs) + f.State = FileState.Running; + Notify(job); + var progress = new Progress(p => + { + // Reports arrive on the thread pool, possibly out of order: progress only moves forward. + var value = Math.Clamp(p, 0, 1); + if (value <= inputs[0].Progress) + return; + foreach (var f in inputs) + f.Progress = value; + Notify(job); + }); + + try + { + // A Shapefile goes into the archive with its companion files (.shx, .dbf, .prj, ...): the .shp alone is no map. + var files = new List(); + var packed = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var source in inputs.Select(f => f.SourcePath)) + { + foreach (var file in new[] { source }.Concat(CompanionFiles.Of(source))) + { + if (packed.Add(Path.GetFullPath(file))) + files.Add(file); + } + } + await _combiner!.CombineAsync(files, output, format.Id, job.Preset, workDir, progress, job.CancellationToken); + inputs[0].Outputs.Add(output); + foreach (var f in inputs) + { + f.State = FileState.Done; + f.Progress = 1; + } + } + catch (OperationCanceledException) + { + allocator.Discard(); + foreach (var f in inputs) + f.State = FileState.Cancelled; + } + catch (Exception ex) + { + allocator.Discard(); + _log.LogWarning(ex, "Packing {Count} file(s) into {Output} failed", inputs.Count, output); + foreach (var f in inputs) + MarkFailed(f, ex.Message); + } + Notify(job); + + static void MarkFailed(FileResult f, string detail) + { + f.State = FileState.Failed; + f.ErrorKey = "error.conversion_failed"; + f.ErrorDetail = detail; + f.Progress = 1; + } + } + + /// + /// Packs the files one source was converted to into one ZIP (named by the preset's output rule, so the conflict + /// policy applies to it) and returns it; empty when the rule says to skip an existing ZIP. Files keep their names + /// inside, a clash gets "name (2).ext", and a ZIP that can't be completed is deleted. + /// + private IReadOnlyList PackZip(ConversionJob job, FileResult file, int index, IReadOnlyList files, CancellationToken ct) + { + var allocator = CreateFinalAllocator(job, file.SourcePath, index, oneFile: true); + if (allocator.Allocate("zip") is not { } zipPath) + return []; + try + { + using (var stream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16)) + using (var archive = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create)) + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in files) + { + ct.ThrowIfCancellationRequested(); + var name = Path.GetFileName(path); + for (var n = 2; !used.Add(name); n++) + name = $"{Path.GetFileNameWithoutExtension(path)} ({n}){Path.GetExtension(path)}"; + System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, path, name, System.IO.Compression.CompressionLevel.Optimal); + } + } + } + catch + { + try + { + File.Delete(zipPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + allocator.Discard(); + throw; + } + return [zipPath]; + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.Dates.cs b/src/Filee.Core/Conversion/JobQueue.Dates.cs new file mode 100644 index 000000000..b27e7789c --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.Dates.cs @@ -0,0 +1,53 @@ +// JobQueue: copies the source's creation and modification dates to the outputs (OutputRule.KeepDates). + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + /// The creation and modification dates of , or null when they can't be read. + private static (DateTime Created, DateTime Modified)? DatesOf(string source) + { + try + { + return (File.GetCreationTimeUtc(source), File.GetLastWriteTimeUtc(source)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + /// Gives the outputs the creation and modification dates of the source (). + private void CopyDates((DateTime Created, DateTime Modified) dates, IEnumerable outputs) + { + try + { + var (created, modified) = dates; + foreach (var output in outputs) + { + if (File.Exists(output)) + { + File.SetCreationTimeUtc(output, created); + File.SetLastWriteTimeUtc(output, modified); + } + else if (Directory.Exists(output)) + { + Directory.SetCreationTimeUtc(output, created); + Directory.SetLastWriteTimeUtc(output, modified); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The conversion itself worked: keep the files, only the dates are new. + _log.LogWarning(ex, "Could not copy the source's dates to {Outputs}", string.Join(", ", outputs)); + } + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.Fetch.cs b/src/Filee.Core/Conversion/JobQueue.Fetch.cs new file mode 100644 index 000000000..a159a80d3 --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.Fetch.cs @@ -0,0 +1,80 @@ +// JobQueue: saves the sources of a job first when they come from a web browser (ISourceFetcher). + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + /// Share of a fetched file's progress that saving it takes; converting it fills the rest. + private const double FetchShare = 0.5; + + /// + /// Saves the sources the job's fetcher brings (files dragged from a browser) before anything is converted. A file + /// that can't be saved fails with its reason; the others still convert. + /// + private async Task FetchSourcesAsync(ConversionJob job, ISourceFetcher fetcher) + { + var files = job.Files.Where(f => f.NeedsFetch).ToList(); + await Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (file, _) => + { + file.State = FileState.Running; + file.Transfer = new TransferProgress(0, null); + Notify(job); + var progress = new SyncProgress(p => + { + if (file.State != FileState.Running || file.Saved) + return; + file.Transfer = p; + var value = (p.Fraction ?? 0) * FetchShare; + if (value > file.Progress) + file.Progress = value; + Notify(job); + }); + try + { + file.SourcePath = await fetcher.FetchAsync(file.SourcePath, progress, job.CancellationToken); + file.Saved = true; + file.State = FileState.Pending; + file.Progress = FetchShare; + } + catch (OperationCanceledException) + { + file.State = FileState.Cancelled; + } + catch (ConversionRefusedException ex) + { + _log.LogInformation("Saving {File} was refused: {Reason}", file.SourcePath, ex.Message); + FailFetch(file, ex.ErrorKey, ex.Detail); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Saving {File} failed", file.SourcePath); + FailFetch(file, "error.web_download_failed", ex.Message); + } + Notify(job); + }); + + static void FailFetch(FileResult file, string key, string? detail) + { + file.State = FileState.Failed; + file.ErrorKey = key; + file.ErrorDetail = detail; + file.Progress = 1; + } + } + + /// + /// Reports on the reporting thread, in order: Progress<T> posts to the thread pool, where a late byte count could + /// land after the file was saved. + /// + private sealed class SyncProgress(Action report) : IProgress + { + public void Report(T value) => report(value); + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.Outputs.cs b/src/Filee.Core/Conversion/JobQueue.Outputs.cs new file mode 100644 index 000000000..e1dfbd7d3 --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.Outputs.cs @@ -0,0 +1,237 @@ +// JobQueue: output paths. Reserves names while a conversion holds them, puts results next to the source +// and replaces the original when the preset asks for it. + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + /// The output is one file whatever happens (a merged PDF, a multi-page TIFF, an archive). + private FinalAllocator CreateFinalAllocator(ConversionJob job, string sourcePath, int index, bool oneFile = false) => + new(this, oneFile ? ForOneFile(job.Preset.Output) : job.Preset.Output, + new OutputPathResolver.Tokens(sourcePath, job.PresetDisplayName, index, job.CreatedAt)); + + /// + /// The rule for a single output file: with it is saved next to + /// the source instead of in the subfolder. + /// + private static OutputRule ForOneFile(OutputRule rule) + { + if (!rule.SubfolderOnlyForSeveralFiles || rule.Location != OutputLocation.Subfolder) + return rule; + var single = rule.Clone(); + single.Location = OutputLocation.SameFolder; + return single; + } + + /// + /// With : moves the one file a source became out of the + /// subfolder to the folder next to the source, keeping its name (the conflict policy applies there), and removes + /// the subfolder if that left it empty. A file that can't be moved (the policy skips an existing file, or the file + /// is in use) stays in the subfolder. + /// + private string NextToSource(ConversionJob job, FileResult file, int index, string output, FinalAllocator allocator) + { + var rule = ForOneFile(job.Preset.Output); + if (ReferenceEquals(rule, job.Preset.Output) || !File.Exists(output) + // Pandoc writes a document's pictures into "_files" beside it, outside the allocator: they stay together. + || Directory.Exists(Path.Combine(Path.GetDirectoryName(output)!, Path.GetFileNameWithoutExtension(output) + "_files"))) + return output; + // The name was already made from the pattern and renames: it is kept as it is. + rule.FileNamePattern = Path.GetFileNameWithoutExtension(output); + rule.Renames = []; + + lock (_reservedOutputs) + { + var tokens = new OutputPathResolver.Tokens(file.SourcePath, job.PresetDisplayName, index, job.CreatedAt); + var target = OutputPathResolver.Resolve(rule, tokens, Path.GetExtension(output), + p => !PathsEqual(p, output) && (File.Exists(p) || Directory.Exists(p) || _reservedOutputs.ContainsKey(p))); + if (target is null || PathsEqual(target, output)) + return output; + try + { + // Over an existing file only when the policy says so: one that appeared since is never replaced. + File.Move(output, target, overwrite: rule.Conflict == ConflictPolicy.Overwrite); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.LogWarning(ex, "Could not move {Output} next to its source", output); + return output; + } + allocator.Moved(output, target); + // Another file of this or a running job may still be written into the same subfolder: RemoveIfEmpty waits. + RemoveIfEmpty(Path.GetDirectoryName(output)!); + return target; + } + + static bool PathsEqual(string a, string b) => + string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), Platform.FileSystemPaths.Comparison); + } + + /// How many conversions hold an output path, and whether more than one ever did. + private sealed class Reservation + { + public int Holders = 1; + + /// + /// Handed to a second conversion too: "Overwrite" gives two files that become the same name the same path. Then + /// neither may delete it when it fails: the other one's finished file may be there. + /// + public bool Shared; + } + + /// Takes (under the lock); true if nobody held it. + private bool Reserve(string path) + { + if (_reservedOutputs.TryGetValue(path, out var reservation)) + { + reservation.Holders++; + reservation.Shared = true; + return false; + } + _reservedOutputs[path] = new Reservation(); + return true; + } + + /// + /// "Replace the original" (): the finished takes the + /// source's place and the source goes to the Recycle Bin / Trash. The path of the result is returned. The result is + /// first moved next to the source under a temporary name (a copy when it was saved on another drive), so a failure + /// there leaves the source alone; then the source goes to the Trash; then the result is renamed within the folder. + /// Anything that stops it keeps the source, with a note saying why. + /// + /// "Only if smaller" copied the source as the result: nothing changed, so the source stays + /// where it is and the copy goes. + private string ReplaceOriginal(ConversionJob job, FileResult file, string sourceFormat, string output, bool copiedSource) + { + var source = file.SourcePath; + if (!job.Preset.Output.ReplaceOriginal || job.Fetcher is not null || !File.Exists(output) + || FormatRegistry.Detect(output) is not { } format || format.Id != sourceFormat + || string.Equals(Path.GetFullPath(output), Path.GetFullPath(source), Platform.FileSystemPaths.Comparison)) + return output; + // One format can cover different kinds of file (TXT: .bat, .py, .log; PPT: .pot; AIFF: .aifc), and the result + // takes the original's name: only the same extension, or another spelling of it (.jpeg / .jpg), replaces. + if (!SameKindOfFile(Path.GetExtension(output), Path.GetExtension(source))) + return output; + if (copiedSource) + { + try + { + File.Delete(output); + file.NoticeKey = "notice.original_unchanged"; + return source; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return output; + } + } + if (_moveToTrash is null) + return Kept(new Platform.TrashRefusal("trash.no_recycle_bin"), output); + + var staged = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(source))!, + $".{Path.GetFileName(source)}.{Guid.NewGuid().ToString("N")[..8]}.filee-tmp"); + try + { + File.Move(output, staged); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.LogWarning(ex, "Could not move {Output} next to {Source}", output, source); + return Kept(new Platform.TrashRefusal("trash.cannot_place", ex.Message), output); + } + + if (_moveToTrash(source) is { } refusal) + { + // The result goes back to where it was saved. + try + { + File.Move(staged, output); + return Kept(refusal, output); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log.LogWarning(ex, "Could not move {Staged} back to {Output}", staged, output); + return Kept(refusal, staged); + } + } + try + { + File.Move(staged, source); + return source; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The original is in the Trash already: the result goes back to the name it was saved under. + _log.LogWarning(ex, "Could not rename {Staged} to {Source}", staged, source); + file.NoticeKey = "notice.original_in_trash"; + file.NoticeReasonKey = "trash.cannot_place"; + file.NoticeDetail = ex.Message; + try + { + File.Move(staged, output); + return output; + } + catch (Exception again) when (again is IOException or UnauthorizedAccessException) + { + _log.LogWarning(again, "Could not move {Staged} back to {Output}", staged, output); + return staged; + } + } + + string Kept(Platform.TrashRefusal why, string result) + { + _log.LogInformation("Kept the original {Source}: {Reason} {Detail}", source, why.ReasonKey, why.Detail); + file.NoticeKey = "notice.original_kept"; + file.NoticeReasonKey = why.ReasonKey; + file.NoticeDetail = why.Detail; + return result; + } + } + + /// Extensions that only spell the same kind of file differently ("Replace the original" may swap them). + private static readonly string[][] Spellings = + [ + [".jpg", ".jpeg", ".jpe", ".jfif"], [".tiff", ".tif"], [".html", ".htm"], [".md", ".markdown"], [".yaml", ".yml"], + [".mpg", ".mpeg"], [".wav", ".wave"], [".aiff", ".aif"], [".ogg", ".oga"], [".3gp", ".3gpp"], [".m2ts", ".mts"], + [".mov", ".qt"], [".tex", ".latex"], + ]; + + private static bool SameKindOfFile(string a, string b) => + string.Equals(a, b, StringComparison.OrdinalIgnoreCase) + || Spellings.Any(s => s.Contains(a, StringComparer.OrdinalIgnoreCase) && s.Contains(b, StringComparer.OrdinalIgnoreCase)); + + /// Lets go of (under the lock): free once nobody holds it. + private void Unreserve(string path) + { + if (_reservedOutputs.TryGetValue(path, out var reservation) && --reservation.Holders <= 0) + _reservedOutputs.Remove(path); + } + + private bool HasReservationIn(string folder) => + _reservedOutputs.Keys.Any(p => Platform.FileSystemPaths.Comparer.Equals(Path.GetDirectoryName(Path.GetFullPath(p)), folder)); + + /// + /// Removes a folder this queue made for outputs once nothing is in it and no running conversion is to write into it + /// (under the lock). Never a folder that was there before, and never one with anything inside. + /// + private void RemoveIfEmpty(string folder) + { + folder = Path.GetFullPath(folder); + if (!_madeFolders.Contains(folder) || HasReservationIn(folder)) + return; + try + { + if (Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any()) + Directory.Delete(folder); + _madeFolders.Remove(folder); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.Sweep.cs b/src/Filee.Core/Conversion/JobQueue.Sweep.cs new file mode 100644 index 000000000..494e37823 --- /dev/null +++ b/src/Filee.Core/Conversion/JobQueue.Sweep.cs @@ -0,0 +1,82 @@ +// JobQueue: the work root for temporary files and the start-up sweep of folders a crash left behind. + +using System.Collections.Concurrent; +using System.Threading.Channels; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Conversion; + +public sealed partial class JobQueue +{ + /// Where every job has its work folder, named by the job's . + public static string DefaultWorkRoot => Path.Combine(Path.GetTempPath(), "Filee"); + + /// + /// Deletes the work folders of jobs a crash, a forced quit or a power cut kept from removing them: intermediate + /// PDFs, TIFF pages and video frames can take gigabytes, and the OS doesn't empty its temp folder by itself. Only a + /// job's folder nothing was written in for goes, so a job still running in another Filee (the + /// command line) keeps its own; files from a browser have a sweep of their own (WebDrop.DeleteStale). + /// + public static void DeleteStaleWorkFolders(string workRoot, TimeSpan age) + { + try + { + var root = new DirectoryInfo(workRoot); + // A temp folder everyone can write in (/tmp on Linux) may hold a link to somewhere else: never follow one. + if (!root.Exists || root.LinkTarget is not null) + return; + foreach (var folder in root.EnumerateDirectories()) + { + if (!IsJobId(folder.Name) || folder.LinkTarget is not null) + continue; + try + { + if (DateTime.UtcNow - LastWrite(folder) > age) + TryDelete(folder.FullName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Something in it can't be read: it stays, and the other folders are still looked at. + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + + static bool IsJobId(string name) => name.Length == 10 && name.All(char.IsAsciiHexDigitLower); + + // A folder's own time changes only when an entry right inside it does: a long job writes deeper down. Links + // inside are not followed, and a folder that can't be read throws (the job's folder then stays). + static DateTime LastWrite(DirectoryInfo folder) + { + var last = folder.LastWriteTimeUtc; + var everything = new EnumerationOptions + { + RecurseSubdirectories = true, + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false, + }; + foreach (var entry in folder.EnumerateFileSystemInfos("*", everything)) + { + if (entry.LastWriteTimeUtc > last) + last = entry.LastWriteTimeUtc; + } + return last; + } + } + + private static void TryDelete(string dir) + { + try + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + catch (IOException) { /* a viewer may still hold a temp file; the OS cleans temp eventually */ } + catch (UnauthorizedAccessException) { } + } +} diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index 70bb2bbfd..d153c8b6b 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -11,7 +11,7 @@ namespace Filee.Core.Conversion; /// Background queue that executes s one after another. -public sealed class JobQueue : IAsyncDisposable +public sealed partial class JobQueue : IAsyncDisposable { private readonly ConverterCatalog _catalog; private readonly IPdfMerger? _pdfMerger; @@ -66,9 +66,11 @@ public JobQueue(ConverterCatalog catalog, IPdfMerger? pdfMerger = null, ILogger< /// Adds a job to the queue and returns it immediately. /// Saves sources that aren't files yet (files from a browser) when the job runs. - public ConversionJob Enqueue(IReadOnlyList sources, Preset preset, string presetDisplayName, ISourceFetcher? fetcher = null) + /// Nobody is there to answer a question (a watch folder): see . + public ConversionJob Enqueue(IReadOnlyList sources, Preset preset, string presetDisplayName, ISourceFetcher? fetcher = null, + bool unattended = false) { - var job = new ConversionJob(sources, preset, presetDisplayName, fetcher); + var job = new ConversionJob(sources, preset, presetDisplayName, fetcher, unattended); Interlocked.Increment(ref _busy); // until the worker is done with it _channel.Writer.TryWrite(job); JobUpdated?.Invoke(this, job); @@ -114,6 +116,11 @@ private async Task RunJobAsync(ConversionJob job) // Clips back to back, or pictures one after the other, in the order they were dropped. var join = job.Preset.JoinsIntoOneMedia && _mediaJoiner is not null; + // Documents with macros or other active content: asked about once for the whole job, before any of them + // is opened in an office suite. Joined media and archives packed as they are open no documents. + if (!join && !combine) + await ScreenActiveContentAsync(job, planner, merge ? "pdf" : multiPageTiff ? "tiff" : null); + if (join) await RunJoinedMediaAsync(job, planner, workDir); else if (merge) @@ -125,7 +132,7 @@ private async Task RunJobAsync(ConversionJob job) else await Parallel.ForEachAsync( job.Files.Select((f, i) => (File: f, Index: i + 1)), - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = CancellationToken.None }, + new ParallelOptions { MaxDegreeOfParallelism = ParallelFiles, CancellationToken = CancellationToken.None }, async (item, _) => await RunFileAsync(job, item.File, item.Index, planner, workDir, finalOutput: true)); job.State = job.CancellationToken.IsCancellationRequested ? JobState.Cancelled @@ -155,75 +162,11 @@ await Parallel.ForEachAsync( JobFinished?.Invoke(this, job); } - /// Share of a fetched file's progress that saving it takes; converting it fills the rest. - private const double FetchShare = 0.5; - - /// - /// Saves the sources the job's fetcher brings (files dragged from a browser) before anything is converted. A file - /// that can't be saved fails with its reason; the others still convert. - /// - private async Task FetchSourcesAsync(ConversionJob job, ISourceFetcher fetcher) - { - var files = job.Files.Where(f => f.NeedsFetch).ToList(); - await Parallel.ForEachAsync(files, new ParallelOptions { MaxDegreeOfParallelism = 4 }, async (file, _) => - { - file.State = FileState.Running; - file.Transfer = new TransferProgress(0, null); - Notify(job); - var progress = new SyncProgress(p => - { - if (file.State != FileState.Running || file.Saved) - return; - file.Transfer = p; - var value = (p.Fraction ?? 0) * FetchShare; - if (value > file.Progress) - file.Progress = value; - Notify(job); - }); - try - { - file.SourcePath = await fetcher.FetchAsync(file.SourcePath, progress, job.CancellationToken); - file.Saved = true; - file.State = FileState.Pending; - file.Progress = FetchShare; - } - catch (OperationCanceledException) - { - file.State = FileState.Cancelled; - } - catch (ConversionRefusedException ex) - { - _log.LogInformation("Saving {File} was refused: {Reason}", file.SourcePath, ex.Message); - FailFetch(file, ex.ErrorKey, ex.Detail); - } - catch (Exception ex) - { - _log.LogWarning(ex, "Saving {File} failed", file.SourcePath); - FailFetch(file, "error.web_download_failed", ex.Message); - } - Notify(job); - }); - - static void FailFetch(FileResult file, string key, string? detail) - { - file.State = FileState.Failed; - file.ErrorKey = key; - file.ErrorDetail = detail; - file.Progress = 1; - } - } - /// - /// Reports on the reporting thread, in order: Progress<T> posts to the thread pool, where a late byte count could - /// land after the file was saved. + /// A file that didn't get as far as being converted: saving it failed or was cancelled, or it was left out before the + /// job ran (a document with active content the user didn't trust). /// - private sealed class SyncProgress(Action report) : IProgress - { - public void Report(T value) => report(value); - } - - /// A file that didn't get as far as being converted (saving it failed or was cancelled). - private static bool Stopped(FileResult file) => file.State is FileState.Failed or FileState.Cancelled; + private static bool Stopped(FileResult file) => file.State is FileState.Failed or FileState.Cancelled or FileState.Skipped; /// Lets the job's fetcher clean up (originals that aren't kept). private void Release(ConversionJob job) @@ -238,258 +181,6 @@ private void Release(ConversionJob job) } } - /// Applies the preset's PDF finishing () to the PDFs among . - private async Task FinishPdfsAsync(Preset preset, IReadOnlyList outputs, CancellationToken ct) - { - // A retry from history whose preset (and so its password) is gone: no PDF without the password it was meant to have. - if (preset.Pdf.PasswordLeftOut && preset.Pdf.Password.Length == 0) - throw new ConversionRefusedException("error.pdf_password_gone"); - if (!preset.Pdf.FinishesPdf || _catalog.All.OfType().FirstOrDefault() is not { } finisher) - return; - foreach (var pdf in outputs.Where(o => FormatRegistry.Detect(o)?.Id == "pdf")) - await finisher.FinishAsync(pdf, preset.Pdf, ct); - } - - /// Converts every file to PDF in the work directory, then merges them into one output. - private async Task RunMergedAsync(ConversionJob job, RoutePlanner planner, string workDir) - { - var parts = new string?[job.Files.Count]; - await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - async (i, _) => - { - var file = job.Files[i]; - if (Stopped(file)) - return; - // An empty PDF goes the long way, where it fails as empty instead of breaking the whole merge; so does - // one whose text is recognized first. - if (FormatRegistry.Detect(file.SourcePath)?.Id == "pdf" && !IsEmptyFile(file.SourcePath) && !job.Preset.RecognizesTextOf("pdf")) - { - // Already a PDF, merged as it is: its pages can't be turned (only images going into it can). - if (job.Preset.Image.TurnsOrMirrors) - FailUnturned(job, file, "pdf → pdf"); - else - parts[i] = file.SourcePath; - return; - } - var produced = await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false); - parts[i] = produced.FirstOrDefault(); - }); - - var inputs = parts.OfType().ToList(); - if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) - return; - - var first = job.Files[0]; - var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); - var output = allocator.Allocate("pdf"); - if (output is null) - { - foreach (var f in job.Files) f.State = FileState.Skipped; - return; - } - - try - { - await _pdfMerger!.MergeAsync(inputs, output, job.CancellationToken); - // Once for the merged PDF, so its pages are numbered through. - await FinishPdfsAsync(job.Preset, [output], job.CancellationToken); - } - catch - { - allocator.Discard(); - throw; - } - first.Outputs.Add(output); - foreach (var f in job.Files.Where(f => f.State != FileState.Failed)) - { - f.State = FileState.Done; - f.Progress = 1; - } - Notify(job); - } - - /// - /// Converts every file to TIFF pages in the work directory (a TIFF is taken as it is), then writes them all as one - /// multi-page TIFF named after the first file. - /// - private async Task RunMultiPageTiffAsync(ConversionJob job, RoutePlanner planner, string workDir) - { - var parts = new IReadOnlyList[job.Files.Count]; - await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - async (i, _) => - { - var file = job.Files[i]; - if (Stopped(file)) - return; - // A TIFF joins as it is, unless it is to be turned: then it goes through the engine like the rest. - parts[i] = FormatRegistry.Detect(file.SourcePath)?.Id == "tiff" && !job.Preset.Image.TurnsOrMirrors - && !IsEmptyFile(file.SourcePath) - ? [file.SourcePath] - : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "tiff"); - }); - - var inputs = parts.SelectMany(p => p ?? []).ToList(); - if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) - return; - - var first = job.Files[0]; - var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); - var output = allocator.Allocate("tiff"); - if (output is null) - { - foreach (var f in job.Files) f.State = FileState.Skipped; - return; - } - - try - { - await _tiffMerger!.MergeAsync(inputs, output, job.Preset.Image, job.CancellationToken); - } - catch - { - allocator.Discard(); - throw; - } - first.Outputs.Add(output); - foreach (var f in job.Files.Where(f => f.State != FileState.Failed)) - { - f.State = FileState.Done; - f.Progress = 1; - } - Notify(job); - } - - /// Share of a slideshow's progress that preparing its pictures takes; encoding the video fills the rest. - private const double SlidesReadyShare = 0.3; - - /// - /// "Merge videos" / "Merge audio": the videos or audio files go into one file as they are. "Video from images": - /// every picture becomes PNG in the work directory first (a PNG is taken as it is), with its orientation applied by - /// the image engines, then the pictures become one video. The output is named after the first file. - /// - private async Task RunJoinedMediaAsync(ConversionJob job, RoutePlanner planner, string workDir) - { - var slideshow = job.Preset.Media.Slideshow; - var target = FormatRegistry.Get(job.Preset.TargetFormat); - // Before any picture is converted: too many fail at once instead of after minutes of PNG steps. Files whose - // download already failed don't count. - var pictures = job.Files.Count(f => !Stopped(f)); - if (slideshow && pictures > MediaOptions.MaxSlides) - { - foreach (var f in job.Files.Where(f => !Stopped(f))) - FailFile(job, f, "error.too_many_pictures", $"{pictures} > {MediaOptions.MaxSlides}"); - return; - } - var parts = new IReadOnlyList?[job.Files.Count]; - await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), - new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, - async (i, _) => - { - var file = job.Files[i]; - if (Stopped(file)) - return; - var source = FormatRegistry.Detect(file.SourcePath); - if (source is null) - { - FailFile(job, file, "error.unsupported_source", "." + FormatRegistry.ExtensionOf(file.SourcePath)); - return; - } - if (IsEmptyFile(file.SourcePath)) - { - FailFile(job, file, "error.empty_file", null); - return; - } - if (slideshow) - { - parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors - ? [file.SourcePath] - : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png", - ceiling: SlidesReadyShare); - return; - } - // A video's sound can join an audio file; a picture or a document joins nothing. - var joins = source.Category == FormatCategory.Video - || source.Category == FormatCategory.Audio && target.Category == FormatCategory.Audio; - if (!joins) - { - FailFile(job, file, "error.no_route", $"{source.Id} → {target.Id}"); - return; - } - parts[i] = [file.SourcePath]; - }); - - var joined = job.Files.Where((f, i) => parts[i] is { Count: > 0 } && f.State != FileState.Failed).ToList(); - var inputs = parts.Where(p => p is not null).SelectMany(p => p!).ToList(); - if (inputs.Count == 0 || job.CancellationToken.IsCancellationRequested) - { - Notify(job); - return; - } - - var first = joined[0]; - var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); - var output = allocator.Allocate(target.PrimaryExtension); - if (output is null) - { - foreach (var f in joined) - { - f.State = FileState.Skipped; - f.Progress = 1; - } - Notify(job); - return; - } - - foreach (var f in joined) - f.State = FileState.Running; - Notify(job); - var floor = joined.Min(f => f.Progress); - var progress = new Progress(p => - { - // Reports arrive on the thread pool, possibly out of order: progress only moves forward. - var value = floor + (1 - floor) * Math.Clamp(p, 0, 1); - if (value <= first.Progress) - return; - foreach (var f in joined) - f.Progress = value; - Notify(job); - }); - - try - { - await _mediaJoiner!.JoinAsync(inputs, output, target.Id, job.Preset, workDir, progress, job.CancellationToken); - first.Outputs.Add(output); - foreach (var f in joined) - { - f.State = FileState.Done; - f.Progress = 1; - } - } - catch (OperationCanceledException) - { - allocator.Discard(); - foreach (var f in joined) - f.State = FileState.Cancelled; - } - catch (ConversionRefusedException ex) - { - allocator.Discard(); - _log.LogInformation("Joining {Count} file(s) into {Output} was refused: {Reason}", inputs.Count, output, ex.Message); - foreach (var f in joined) - FailFile(job, f, ex.ErrorKey, ex.Detail); - } - catch (Exception ex) - { - allocator.Discard(); - _log.LogWarning(ex, "Joining {Count} file(s) into {Output} failed", inputs.Count, output); - foreach (var f in joined) - FailFile(job, f, "error.conversion_failed", ex.Message); - } - Notify(job); - } - private void FailFile(ConversionJob job, FileResult file, string key, string? detail) { file.State = FileState.Failed; @@ -509,97 +200,6 @@ private void FailUnturned(ConversionJob job, FileResult file, string detail) Notify(job); } - /// Packs every source file, unchanged, into one archive named after the first file. - private async Task RunCombinedAsync(ConversionJob job, string workDir) - { - var format = FormatRegistry.Get(job.Preset.TargetFormat); - var inputs = new List(); - foreach (var file in job.Files.Where(f => !Stopped(f))) - { - // Folders are packed with their content. - if (File.Exists(file.SourcePath) || Directory.Exists(file.SourcePath)) - inputs.Add(file); - else - MarkFailed(file, "The file no longer exists."); - } - if (inputs.Count == 0) - { - Notify(job); - return; - } - - var allocator = CreateFinalAllocator(job, inputs[0].SourcePath, 1, oneFile: true); - var output = allocator.Allocate(format.PrimaryExtension); - if (output is null) - { - foreach (var f in inputs) - { - f.State = FileState.Skipped; - f.Progress = 1; - } - Notify(job); - return; - } - - foreach (var f in inputs) - f.State = FileState.Running; - Notify(job); - var progress = new Progress(p => - { - // Reports arrive on the thread pool, possibly out of order: progress only moves forward. - var value = Math.Clamp(p, 0, 1); - if (value <= inputs[0].Progress) - return; - foreach (var f in inputs) - f.Progress = value; - Notify(job); - }); - - try - { - // A Shapefile goes into the archive with its companion files (.shx, .dbf, .prj, ...): the .shp alone is no map. - var files = new List(); - var packed = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var source in inputs.Select(f => f.SourcePath)) - { - foreach (var file in new[] { source }.Concat(CompanionFiles.Of(source))) - { - if (packed.Add(Path.GetFullPath(file))) - files.Add(file); - } - } - await _combiner!.CombineAsync(files, output, format.Id, job.Preset, workDir, progress, job.CancellationToken); - inputs[0].Outputs.Add(output); - foreach (var f in inputs) - { - f.State = FileState.Done; - f.Progress = 1; - } - } - catch (OperationCanceledException) - { - allocator.Discard(); - foreach (var f in inputs) - f.State = FileState.Cancelled; - } - catch (Exception ex) - { - allocator.Discard(); - _log.LogWarning(ex, "Packing {Count} file(s) into {Output} failed", inputs.Count, output); - foreach (var f in inputs) - MarkFailed(f, ex.Message); - } - Notify(job); - - static void MarkFailed(FileResult f, string detail) - { - f.State = FileState.Failed; - f.ErrorKey = "error.conversion_failed"; - f.ErrorDetail = detail; - f.Progress = 1; - } - } - /// A file (not a folder) with nothing in it. Combining still packs such a file as it is. private static bool IsEmptyFile(string path) { @@ -614,46 +214,6 @@ private static bool IsEmptyFile(string path) } } - /// The creation and modification dates of , or null when they can't be read. - private static (DateTime Created, DateTime Modified)? DatesOf(string source) - { - try - { - return (File.GetCreationTimeUtc(source), File.GetLastWriteTimeUtc(source)); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return null; - } - } - - /// Gives the outputs the creation and modification dates of the source (). - private void CopyDates((DateTime Created, DateTime Modified) dates, IEnumerable outputs) - { - try - { - var (created, modified) = dates; - foreach (var output in outputs) - { - if (File.Exists(output)) - { - File.SetCreationTimeUtc(output, created); - File.SetLastWriteTimeUtc(output, modified); - } - else if (Directory.Exists(output)) - { - Directory.SetCreationTimeUtc(output, created); - Directory.SetLastWriteTimeUtc(output, modified); - } - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // The conversion itself worked: keep the files, only the dates are new. - _log.LogWarning(ex, "Could not copy the source's dates to {Outputs}", string.Join(", ", outputs)); - } - } - /// /// Converts one source file along its planned route. /// With false, results stay in the work directory as @@ -718,6 +278,10 @@ private async Task> RunFileAsync( route = planner.Plan(source.Id, target, frames, shaping: true) ?? route; } + // Through an office suite only now (rerouted above), and not checked for macros before the job ran. + if (CheckActiveContentLate(file, source.Id, route) is { } refusal) + return Fail(file, refusal, null); + // Rotate / flip: only an engine that turns images does it. Without one the file would come out unturned // (a PDF or a video re-encoded "as rotated"); with two in the route (EPS → PNG → JPG) it would turn twice. var turnAt = job.Preset.Image.TurnsOrMirrors ? route.FirstTransformStep() : -1; @@ -782,7 +346,7 @@ private async Task> RunFileAsync( .Distinct(StringComparer.OrdinalIgnoreCase).ToList(); Notify(job); }, file.SourcePath); - outputs.AddRange(await step.Converter.ConvertAsync(stepInfo, progress, ct)); + outputs.AddRange(await ConvertWithTimeoutAsync(step.Converter, stepInfo, progress, ct)); } finally { @@ -870,45 +434,6 @@ IReadOnlyList Fail(FileResult f, string key, string? detail) } } - /// - /// Packs the files one source was converted to into one ZIP (named by the preset's output rule, so the conflict - /// policy applies to it) and returns it; empty when the rule says to skip an existing ZIP. Files keep their names - /// inside, a clash gets "name (2).ext", and a ZIP that can't be completed is deleted. - /// - private IReadOnlyList PackZip(ConversionJob job, FileResult file, int index, IReadOnlyList files, CancellationToken ct) - { - var allocator = CreateFinalAllocator(job, file.SourcePath, index, oneFile: true); - if (allocator.Allocate("zip") is not { } zipPath) - return []; - try - { - using (var stream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16)) - using (var archive = new System.IO.Compression.ZipArchive(stream, System.IO.Compression.ZipArchiveMode.Create)) - { - var used = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var path in files) - { - ct.ThrowIfCancellationRequested(); - var name = Path.GetFileName(path); - for (var n = 2; !used.Add(name); n++) - name = $"{Path.GetFileNameWithoutExtension(path)} ({n}){Path.GetExtension(path)}"; - System.IO.Compression.ZipFileExtensions.CreateEntryFromFile(archive, path, name, System.IO.Compression.CompressionLevel.Optimal); - } - } - } - catch - { - try - { - File.Delete(zipPath); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } - allocator.Discard(); - throw; - } - return [zipPath]; - } - /// /// Puts the source in place of when both have the same format and the output is not /// smaller (). True when it did. @@ -943,229 +468,6 @@ private SemaphoreSlim GateFor(IConverter converter) => return new SemaphoreSlim(max, max); }); - /// The output is one file whatever happens (a merged PDF, a multi-page TIFF, an archive). - private FinalAllocator CreateFinalAllocator(ConversionJob job, string sourcePath, int index, bool oneFile = false) => - new(this, oneFile ? ForOneFile(job.Preset.Output) : job.Preset.Output, - new OutputPathResolver.Tokens(sourcePath, job.PresetDisplayName, index, job.CreatedAt)); - - /// - /// The rule for a single output file: with it is saved next to - /// the source instead of in the subfolder. - /// - private static OutputRule ForOneFile(OutputRule rule) - { - if (!rule.SubfolderOnlyForSeveralFiles || rule.Location != OutputLocation.Subfolder) - return rule; - var single = rule.Clone(); - single.Location = OutputLocation.SameFolder; - return single; - } - - /// - /// With : moves the one file a source became out of the - /// subfolder to the folder next to the source, keeping its name (the conflict policy applies there), and removes - /// the subfolder if that left it empty. A file that can't be moved (the policy skips an existing file, or the file - /// is in use) stays in the subfolder. - /// - private string NextToSource(ConversionJob job, FileResult file, int index, string output, FinalAllocator allocator) - { - var rule = ForOneFile(job.Preset.Output); - if (ReferenceEquals(rule, job.Preset.Output) || !File.Exists(output) - // Pandoc writes a document's pictures into "_files" beside it, outside the allocator: they stay together. - || Directory.Exists(Path.Combine(Path.GetDirectoryName(output)!, Path.GetFileNameWithoutExtension(output) + "_files"))) - return output; - // The name was already made from the pattern and renames: it is kept as it is. - rule.FileNamePattern = Path.GetFileNameWithoutExtension(output); - rule.Renames = []; - - lock (_reservedOutputs) - { - var tokens = new OutputPathResolver.Tokens(file.SourcePath, job.PresetDisplayName, index, job.CreatedAt); - var target = OutputPathResolver.Resolve(rule, tokens, Path.GetExtension(output), - p => !PathsEqual(p, output) && (File.Exists(p) || Directory.Exists(p) || _reservedOutputs.ContainsKey(p))); - if (target is null || PathsEqual(target, output)) - return output; - try - { - // Over an existing file only when the policy says so: one that appeared since is never replaced. - File.Move(output, target, overwrite: rule.Conflict == ConflictPolicy.Overwrite); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - _log.LogWarning(ex, "Could not move {Output} next to its source", output); - return output; - } - allocator.Moved(output, target); - // Another file of this or a running job may still be written into the same subfolder: RemoveIfEmpty waits. - RemoveIfEmpty(Path.GetDirectoryName(output)!); - return target; - } - - static bool PathsEqual(string a, string b) => - string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), Platform.FileSystemPaths.Comparison); - } - - /// How many conversions hold an output path, and whether more than one ever did. - private sealed class Reservation - { - public int Holders = 1; - - /// - /// Handed to a second conversion too: "Overwrite" gives two files that become the same name the same path. Then - /// neither may delete it when it fails: the other one's finished file may be there. - /// - public bool Shared; - } - - /// Takes (under the lock); true if nobody held it. - private bool Reserve(string path) - { - if (_reservedOutputs.TryGetValue(path, out var reservation)) - { - reservation.Holders++; - reservation.Shared = true; - return false; - } - _reservedOutputs[path] = new Reservation(); - return true; - } - - /// - /// "Replace the original" (): the finished takes the - /// source's place and the source goes to the Recycle Bin / Trash. The path of the result is returned. The result is - /// first moved next to the source under a temporary name (a copy when it was saved on another drive), so a failure - /// there leaves the source alone; then the source goes to the Trash; then the result is renamed within the folder. - /// Anything that stops it keeps the source, with a note saying why. - /// - /// "Only if smaller" copied the source as the result: nothing changed, so the source stays - /// where it is and the copy goes. - private string ReplaceOriginal(ConversionJob job, FileResult file, string sourceFormat, string output, bool copiedSource) - { - var source = file.SourcePath; - if (!job.Preset.Output.ReplaceOriginal || job.Fetcher is not null || !File.Exists(output) - || FormatRegistry.Detect(output) is not { } format || format.Id != sourceFormat - || string.Equals(Path.GetFullPath(output), Path.GetFullPath(source), Platform.FileSystemPaths.Comparison)) - return output; - // One format can cover different kinds of file (TXT: .bat, .py, .log; PPT: .pot; AIFF: .aifc), and the result - // takes the original's name: only the same extension, or another spelling of it (.jpeg / .jpg), replaces. - if (!SameKindOfFile(Path.GetExtension(output), Path.GetExtension(source))) - return output; - if (copiedSource) - { - try - { - File.Delete(output); - file.NoticeKey = "notice.original_unchanged"; - return source; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return output; - } - } - if (_moveToTrash is null) - return Kept(new Platform.TrashRefusal("trash.no_recycle_bin"), output); - - var staged = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(source))!, - $".{Path.GetFileName(source)}.{Guid.NewGuid().ToString("N")[..8]}.filee-tmp"); - try - { - File.Move(output, staged); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - _log.LogWarning(ex, "Could not move {Output} next to {Source}", output, source); - return Kept(new Platform.TrashRefusal("trash.cannot_place", ex.Message), output); - } - - if (_moveToTrash(source) is { } refusal) - { - // The result goes back to where it was saved. - try - { - File.Move(staged, output); - return Kept(refusal, output); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - _log.LogWarning(ex, "Could not move {Staged} back to {Output}", staged, output); - return Kept(refusal, staged); - } - } - try - { - File.Move(staged, source); - return source; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // The original is in the Trash already: the result goes back to the name it was saved under. - _log.LogWarning(ex, "Could not rename {Staged} to {Source}", staged, source); - file.NoticeKey = "notice.original_in_trash"; - file.NoticeReasonKey = "trash.cannot_place"; - file.NoticeDetail = ex.Message; - try - { - File.Move(staged, output); - return output; - } - catch (Exception again) when (again is IOException or UnauthorizedAccessException) - { - _log.LogWarning(again, "Could not move {Staged} back to {Output}", staged, output); - return staged; - } - } - - string Kept(Platform.TrashRefusal why, string result) - { - _log.LogInformation("Kept the original {Source}: {Reason} {Detail}", source, why.ReasonKey, why.Detail); - file.NoticeKey = "notice.original_kept"; - file.NoticeReasonKey = why.ReasonKey; - file.NoticeDetail = why.Detail; - return result; - } - } - - /// Extensions that only spell the same kind of file differently ("Replace the original" may swap them). - private static readonly string[][] Spellings = - [ - [".jpg", ".jpeg", ".jpe", ".jfif"], [".tiff", ".tif"], [".html", ".htm"], [".md", ".markdown"], [".yaml", ".yml"], - [".mpg", ".mpeg"], [".wav", ".wave"], [".aiff", ".aif"], [".ogg", ".oga"], [".3gp", ".3gpp"], [".m2ts", ".mts"], - [".mov", ".qt"], [".tex", ".latex"], - ]; - - private static bool SameKindOfFile(string a, string b) => - string.Equals(a, b, StringComparison.OrdinalIgnoreCase) - || Spellings.Any(s => s.Contains(a, StringComparer.OrdinalIgnoreCase) && s.Contains(b, StringComparer.OrdinalIgnoreCase)); - - /// Lets go of (under the lock): free once nobody holds it. - private void Unreserve(string path) - { - if (_reservedOutputs.TryGetValue(path, out var reservation) && --reservation.Holders <= 0) - _reservedOutputs.Remove(path); - } - - private bool HasReservationIn(string folder) => - _reservedOutputs.Keys.Any(p => Platform.FileSystemPaths.Comparer.Equals(Path.GetDirectoryName(Path.GetFullPath(p)), folder)); - - /// - /// Removes a folder this queue made for outputs once nothing is in it and no running conversion is to write into it - /// (under the lock). Never a folder that was there before, and never one with anything inside. - /// - private void RemoveIfEmpty(string folder) - { - folder = Path.GetFullPath(folder); - if (!_madeFolders.Contains(folder) || HasReservationIn(folder)) - return; - try - { - if (Directory.Exists(folder) && !Directory.EnumerateFileSystemEntries(folder).Any()) - Directory.Delete(folder); - _madeFolders.Remove(folder); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } - } - private void Notify(ConversionJob job) { try @@ -1201,197 +503,10 @@ private async Task WorkLoopAsync() } } - /// Where every job has its work folder, named by the job's . - public static string DefaultWorkRoot => Path.Combine(Path.GetTempPath(), "Filee"); - - /// - /// Deletes the work folders of jobs a crash, a forced quit or a power cut kept from removing them: intermediate - /// PDFs, TIFF pages and video frames can take gigabytes, and the OS doesn't empty its temp folder by itself. Only a - /// job's folder nothing was written in for goes, so a job still running in another Filee (the - /// command line) keeps its own; files from a browser have a sweep of their own (WebDrop.DeleteStale). - /// - public static void DeleteStaleWorkFolders(string workRoot, TimeSpan age) - { - try - { - var root = new DirectoryInfo(workRoot); - // A temp folder everyone can write in (/tmp on Linux) may hold a link to somewhere else: never follow one. - if (!root.Exists || root.LinkTarget is not null) - return; - foreach (var folder in root.EnumerateDirectories()) - { - if (!IsJobId(folder.Name) || folder.LinkTarget is not null) - continue; - try - { - if (DateTime.UtcNow - LastWrite(folder) > age) - TryDelete(folder.FullName); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Something in it can't be read: it stays, and the other folders are still looked at. - } - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - } - - static bool IsJobId(string name) => name.Length == 10 && name.All(char.IsAsciiHexDigitLower); - - // A folder's own time changes only when an entry right inside it does: a long job writes deeper down. Links - // inside are not followed, and a folder that can't be read throws (the job's folder then stays). - static DateTime LastWrite(DirectoryInfo folder) - { - var last = folder.LastWriteTimeUtc; - var everything = new EnumerationOptions - { - RecurseSubdirectories = true, - AttributesToSkip = FileAttributes.ReparsePoint, - IgnoreInaccessible = false, - }; - foreach (var entry in folder.EnumerateFileSystemInfos("*", everything)) - { - if (entry.LastWriteTimeUtc > last) - last = entry.LastWriteTimeUtc; - } - return last; - } - } - - private static void TryDelete(string dir) - { - try - { - if (Directory.Exists(dir)) - Directory.Delete(dir, recursive: true); - } - catch (IOException) { /* a viewer may still hold a temp file; the OS cleans temp eventually */ } - catch (UnauthorizedAccessException) { } - } - public async ValueTask DisposeAsync() { _channel.Writer.TryComplete(); await _worker; } - /// Allocates final output paths according to the preset's . - private sealed class FinalAllocator(JobQueue queue, OutputRule rule, OutputPathResolver.Tokens tokens) : IOutputAllocator - { - /// - /// Every path handed out, and whether it is this conversion's alone: nothing was there before (not a file it - /// overwrites) and no other conversion had it. - /// - private readonly List<(string Path, bool Own)> _allocated = []; - - /// How many paths were handed out: an output and the picture folder that goes with it are two. - public int Handed => _allocated.Count; - - public string? Allocate(string extension, string? suffix = null) - { - // Reserve under a lock so two parallel files never pick the same name. - lock (queue._reservedOutputs) - { - var path = OutputPathResolver.Resolve(rule, tokens, extension, - p => File.Exists(p) || Directory.Exists(p) || queue._reservedOutputs.ContainsKey(p), suffix); - if (path is null) - return null; - - var nothingThere = !File.Exists(path) && !Directory.Exists(path); - _allocated.Add((path, queue.Reserve(path) && nothingThere)); - var folder = Path.GetFullPath(Path.GetDirectoryName(path)!); - if (!Directory.Exists(folder)) - { - Directory.CreateDirectory(folder); - queue._madeFolders.Add(folder); - } - // An empty extension asks for a folder (archive extraction): the converter creates it. - return path; - } - } - - /// The one output was moved to (next to its source): its name goes with it. - public void Moved(string from, string to) - { - lock (queue._reservedOutputs) - { - var index = _allocated.FindIndex(a => string.Equals(a.Path, from, StringComparison.OrdinalIgnoreCase)); - if (index < 0) - return; - // Finished and in its place: nothing to undo there any more. - _allocated[index] = (to, false); - queue.Unreserve(from); - queue.Reserve(to); - } - } - - /// - /// Frees the names handed out but not in (the outputs the engine returned, which stay - /// taken until the job ends). - /// - public void Release(IEnumerable except) - { - var kept = except.ToHashSet(StringComparer.OrdinalIgnoreCase); - lock (queue._reservedOutputs) - { - foreach (var (path, _) in _allocated) - { - if (!kept.Contains(path)) - queue.Unreserve(path); - } - _allocated.Clear(); - } - } - - /// - /// Undoes a conversion that failed or was cancelled: deletes what it started writing at paths that were its - /// alone (never a file it was to overwrite, nor one another conversion had too), so no half-written file is left - /// where the user expects a finished one; frees every name, so trying again gets the same one instead of - /// "name (2)"; and removes a folder it made for them if that left it empty. - /// - public void Discard() - { - lock (queue._reservedOutputs) - { - foreach (var (path, own) in _allocated) - { - if (own && queue._reservedOutputs.TryGetValue(path, out var reservation) && !reservation.Shared) - TryDeleteOutput(path); - queue.Unreserve(path); - } - foreach (var folder in _allocated.Select(a => Path.GetDirectoryName(Path.GetFullPath(a.Path))!) - .Distinct(Platform.FileSystemPaths.Comparer).ToList()) - queue.RemoveIfEmpty(folder); - _allocated.Clear(); - } - } - - private void TryDeleteOutput(string path) - { - try - { - if (File.Exists(path)) - File.Delete(path); - else if (Directory.Exists(path)) - Directory.Delete(path, recursive: true); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - queue._log.LogWarning(ex, "Could not delete the unfinished output {Output}", path); - } - } - } - - /// Allocates unique paths inside the job's work directory for intermediate files. - private sealed class TempAllocator(string workDir, string baseName) : IOutputAllocator - { - public string? Allocate(string extension, string? suffix = null) - { - var dir = Path.Combine(workDir, Guid.NewGuid().ToString("N")[..8]); - Directory.CreateDirectory(dir); - var ext = extension.TrimStart('.'); - return Path.Combine(dir, ext.Length == 0 ? $"{baseName}{suffix}" : $"{baseName}{suffix}.{ext}"); - } - } } diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index 049f6fb23..ed56b3512 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -102,6 +102,17 @@ static IReadOnlyList ResolveShortcuts(IReadOnlyList paths, Func< /// void SetStartWithSystem(bool enabled, string executablePath); + /// + /// True when the system itself keeps Filee's startup entry from running although it is there: on Windows the user + /// (or a tool) turned it off in Task Manager → Startup apps, which Filee's own switch doesn't show (#450). + /// + bool StartupDisabledBySystem => false; + + /// Turns a startup entry the system disabled () on again. Only on request. + void ReenableStartup() + { + } + /// Adds or removes the file manager context-menu entry ("Convert with Filee") and Send To shortcut. void SetContextMenu(bool enabled, string executablePath, string label); diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 21e1defef..e6f853310 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -261,6 +261,15 @@ public sealed class AppSettings /// public double UiScale { get; set; } = 1; + /// + /// Documents with macros or other active content (Settings → Security and limits). Missing in settings from before + /// it: the defaults apply, so it needs no schema step. + /// + public SecuritySettings Security { get; set; } = new(); + + /// Picture size and memory, files converted at once and time per step (Settings → Security and limits). + public LimitSettings Limits { get; set; } = new(); + /// /// Returns itself, or a copy with the app-wide output settings applied: it saves to /// when the preset's location is , and keeps the diff --git a/src/Filee.Core/Settings/SecuritySettings.cs b/src/Filee.Core/Settings/SecuritySettings.cs new file mode 100644 index 000000000..4f7ef506b --- /dev/null +++ b/src/Filee.Core/Settings/SecuritySettings.cs @@ -0,0 +1,99 @@ +// Settings → Security and limits: what Filee does with documents that carry macros or other active content, and the +// limits that keep one huge or stuck file from taking over the computer (image size and memory, files converted at +// once, time per conversion step). + +using System.Text.Json.Serialization; +using Filee.Core.Presets; + +namespace Filee.Core.Settings; + +/// What Filee does with a document that has macros, ActiveX controls, external links or DDE fields. +public enum ActiveContentPolicy +{ + /// + /// Show what was found and let the user decide. Watch folders and the command line can't ask: they convert, and + /// the file's result says what it contained. + /// + Ask, + + /// Convert without asking. + Convert, + + /// Never convert such documents. + Refuse, +} + +/// Settings → Security and limits: documents with active content. +/// +/// Nothing in a document is ever run: LibreOffice's conversion mode doesn't execute document macros, and Filee also +/// starts it with macros disabled and links never updated (LibreOfficeSecurity in Filee.Engines). +/// +public sealed class SecuritySettings +{ + /// What happens to a document with active content. A value from a newer Filee reads as . + [JsonConverter(typeof(LenientEnumJsonConverter))] + public ActiveContentPolicy ActiveContent { get; set; } = ActiveContentPolicy.Ask; + + public SecuritySettings Clone() => (SecuritySettings)MemberwiseClone(); +} + +/// +/// Settings → Security and limits: resource limits. 0 means "automatic" everywhere: no limit of Filee's own (the +/// engine's defaults, one file per processor core, no time limit). Values outside a range are taken as its nearest end. +/// +public sealed class LimitSettings +{ + /// Largest picture Filee opens, in megapixels (width × height); 0 for no limit. Guards against "image bombs". + public int MaxImageMegapixels { get; set; } = 500; + + /// Longest side of a picture in pixels; 0 for ImageMagick's default. + public int MaxImageSide { get; set; } + + /// Memory ImageMagick may use for pictures, in MB, before it pages to disk; 0 for its default. + public int MaxImageMemoryMb { get; set; } + + /// Disk space ImageMagick may page pictures to, in MB; 0 for its default. + public int MaxImageDiskMb { get; set; } + + /// Files of one job converted at the same time; 0 for one per processor core. + public int MaxParallelFiles { get; set; } + + /// + /// Minutes a built-in engine may spend on one conversion step before the file fails as stuck; 0 for no limit. + /// Engines that run another program (LibreOffice, FFmpeg, Ghostscript, …) keep their own time limits. + /// + public int StepTimeoutMinutes { get; set; } = 15; + + public const int MaxMegapixels = 100_000; + public const int MaxSide = 1_000_000; + public const int MaxMegabytes = 1_048_576; + public const int MaxFiles = 64; + public const int MaxTimeoutMinutes = 1440; + + /// within its range; 0 for no limit. + [JsonIgnore] + public int EffectiveMaxImageMegapixels => Math.Clamp(MaxImageMegapixels, 0, MaxMegapixels); + + /// within its range; 0 for the default. + [JsonIgnore] + public int EffectiveMaxImageSide => Math.Clamp(MaxImageSide, 0, MaxSide); + + /// within its range; 0 for the default. + [JsonIgnore] + public int EffectiveMaxImageMemoryMb => Math.Clamp(MaxImageMemoryMb, 0, MaxMegabytes); + + /// within its range; 0 for the default. + [JsonIgnore] + public int EffectiveMaxImageDiskMb => Math.Clamp(MaxImageDiskMb, 0, MaxMegabytes); + + /// How many files of a job convert at once: , or one per processor core. + [JsonIgnore] + public int EffectiveParallelFiles => MaxParallelFiles <= 0 ? Environment.ProcessorCount : Math.Min(MaxParallelFiles, MaxFiles); + + /// The step time limit, or null for none. + [JsonIgnore] + public TimeSpan? EffectiveStepTimeout => + StepTimeoutMinutes <= 0 ? null : TimeSpan.FromMinutes(Math.Min(StepTimeoutMinutes, MaxTimeoutMinutes)); + + public LimitSettings Clone() => (LimitSettings)MemberwiseClone(); +} diff --git a/src/Filee.Core/Settings/SettingsMigrations.cs b/src/Filee.Core/Settings/SettingsMigrations.cs new file mode 100644 index 000000000..8d02bdbc3 --- /dev/null +++ b/src/Filee.Core/Settings/SettingsMigrations.cs @@ -0,0 +1,917 @@ +// Upgrades settings, presets and toolbar profiles written by older versions of Filee, one step per schema bump. + +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using Filee.Core.History; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Settings; + +/// Upgrades settings written by older versions. Add one step per schema bump. +internal static class SettingsMigrations +{ + public static void Apply(AppSettings settings) + { + // v2: engines "pandoc-hwpx" (HWPX without Hancom Office, placed before "hancom" so HWPX saving needs + // no approval dialog) and "pandoc" (Markdown) were added. + if (settings.SchemaVersion < 2) + { + InsertBefore(settings.EnginePriority, "pandoc-hwpx", "hancom"); + InsertBefore(settings.EnginePriority, "pandoc", "libreoffice"); + } + + // v3: the Hancom Office engine was removed (rhwp converts HWP <-> HWPX without the approval dialog of 한글) + // and "pandoc-hwpx" became "hwpx-writer" (DOCX is read without Pandoc). + if (settings.SchemaVersion < 3) + { + settings.EnginePriority.Remove("hancom"); + var index = settings.EnginePriority.IndexOf("pandoc-hwpx"); + if (index >= 0) + settings.EnginePriority[index] = "hwpx-writer"; + } + + // v4: built-in "markdown" engine (Markdig, no Pandoc needed); the "text" toolbar profile is added by + // ApplyToLibrary. + if (settings.SchemaVersion < 4) + InsertBefore(settings.EnginePriority, "markdown", "hwpx-writer"); + + // v5: Microsoft Word automation was removed (Filee no longer uses software installed on the system) and the + // built-in "spreadsheet" engine (XLSX <-> CSV) was added; profiles are split by ApplyToLibrary. + if (settings.SchemaVersion < 5) + { + settings.EnginePriority.Remove("word"); + InsertBefore(settings.EnginePriority, "spreadsheet", "hwpx-writer"); + } + + // v6: engines for the rest of the format catalog. Built-in ones go before Pandoc, optional downloads + // before LibreOffice (the fallback that stays last). + if (settings.SchemaVersion < 6) + { + InsertBefore(settings.EnginePriority, "ooxml", "hwpx-writer"); + foreach (var id in new[] { "docx-writer", "pdf-text", "email", "vector", "icns", "font", "cad", "archive", "ebook", "ffmpeg" }) + InsertBefore(settings.EnginePriority, id, "pandoc"); + foreach (var id in new[] { "ghostscript", "calibre" }) + InsertBefore(settings.EnginePriority, id, "libreoffice"); + } + + // v12: built-in "subtitle" engine (SRT, ASS, WebVTT, SAMI, LRC, TTML, ...), before FFmpeg. + if (settings.SchemaVersion < 12) + InsertBefore(settings.EnginePriority, "subtitle", "ffmpeg"); + + // v14: built-in "data" engine (JSON, YAML, TOML, XML, CSV, vCard), next to the e-mail engine. + if (settings.SchemaVersion < 14 && !settings.EnginePriority.Contains("data")) + { + var email = settings.EnginePriority.IndexOf("email"); + if (email >= 0) + settings.EnginePriority.Insert(email + 1, "data"); + else + InsertBefore(settings.EnginePriority, "data", "pandoc"); + } + + // v15: built-in "model3d" engine (3D models: GLB, glTF, STL, OBJ, 3MF, PLY), after the CAD engine (else before + // the archive engine, else before Pandoc like the other built-in engines). + if (settings.SchemaVersion < 15 && !settings.EnginePriority.Contains("model3d")) + { + var cad = settings.EnginePriority.IndexOf("cad"); + if (cad >= 0) + settings.EnginePriority.Insert(cad + 1, "model3d"); + else + InsertBefore(settings.EnginePriority, "model3d", settings.EnginePriority.Contains("archive") ? "archive" : "pandoc"); + } + + // v25: built-in "geo" engine (GeoJSON, KML, KMZ, GPX, Shapefile and CSV points), after the 3D models engine (else + // before the archive engine, else before Pandoc). + if (settings.SchemaVersion < 25 && !settings.EnginePriority.Contains("geo")) + { + var models = settings.EnginePriority.IndexOf("model3d"); + if (models >= 0) + settings.EnginePriority.Insert(models + 1, "geo"); + else + InsertBefore(settings.EnginePriority, "geo", settings.EnginePriority.Contains("archive") ? "archive" : "pandoc"); + } + + // v18: built-in "pdf-slides" engine (PDF → PPTX, a picture of each page per slide), after "pdf-text". + if (settings.SchemaVersion < 18 && !settings.EnginePriority.Contains("pdf-slides")) + { + var text = settings.EnginePriority.IndexOf("pdf-text"); + if (text >= 0) + settings.EnginePriority.Insert(text + 1, "pdf-slides"); + else + InsertBefore(settings.EnginePriority, "pdf-slides", "pandoc"); + } + + // v21: built-in "wic" engine (JPEG XR through the Windows Imaging Component), after the other built-in engines: + // no other engine reads JPEG XR, and higher up it would push the HWPX writer out of the ranks that win + // ties. (Schema 21 may be renumbered if another change takes it first.) + if (settings.SchemaVersion < 21) + InsertBefore(settings.EnginePriority, "wic", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc"); + + if (settings.Triggers.Count == 0) + settings.Triggers = TriggerGesture.Defaults(); + + settings.SchemaVersion = AppSettings.CurrentSchemaVersion; + } + + /// Upgrades presets and profiles written with settings schema . + /// True when something was added. + public static bool ApplyToLibrary(int from, List presets, List profiles) + { + var changed = false; + // v4: Markdown / TXT / HTML files get their own donut instead of the "mixed files" fallback. Skipped when + // the user already routes Markdown through a profile of their own. + if (from < 4 && !profiles.Any(p => p.Id == "text" || (!p.IsFallback && p.Extensions.Contains("md", StringComparer.OrdinalIgnoreCase)))) + { + var text = BuiltInData.TextProfile(); + var ids = presets.Select(p => p.Id).ToHashSet(); + text.PresetIds = text.PresetIds.Where(ids.Contains).ToList(); + var fallback = profiles.FindIndex(p => p.IsFallback); + profiles.Insert(fallback < 0 ? profiles.Count : fallback, text); + changed = true; + } + + // v5: spreadsheets and presentations get their own donuts (with XLSX / CSV targets) instead of sharing the + // documents donut, and the PDF donut drops targets only Word could produce (PDF → DOCX / HWPX / TXT). + if (from < 5) + { + foreach (var preset in BuiltInData.SpreadsheetPresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + + var office = profiles.FirstOrDefault(p => p.Id == "office"); + var insertAt = office is null ? profiles.FindIndex(p => p.IsFallback) : profiles.IndexOf(office) + 1; + var ids = presets.Select(p => p.Id).ToHashSet(); + foreach (var split in new[] { BuiltInData.SpreadsheetProfile(), BuiltInData.PresentationProfile() }) + { + if (profiles.Any(p => p.Id == split.Id)) + continue; + split.PresetIds = split.PresetIds.Where(ids.Contains).ToList(); + // The new donut takes over these extensions from the built-in documents donut, but not from + // profiles the user made. + changed |= office?.Extensions.RemoveAll(split.Extensions.Contains) > 0; + if (profiles.Any(p => !p.IsFallback && p.Extensions.Intersect(split.Extensions, StringComparer.OrdinalIgnoreCase).Any())) + continue; + insertAt = insertAt < 0 ? profiles.Count : insertAt; + profiles.Insert(insertAt++, split); + changed = true; + } + + if (profiles.FirstOrDefault(p => p.Id == "pdf") is { } pdf) + changed |= pdf.PresetIds.RemoveAll(id => id is "to-docx" or "to-hwpx" or "to-txt") > 0; + } + + // v6: the rest of the format catalog (video, audio, e-books, archives, vector, CAD, fonts, more image and + // office formats). Built-in donuts learn their new extensions unless another donut already has them, the + // new donuts follow "text", PDF → DOCX is back on the PDF donut (built in now) and the mixed-files donut + // gets "compress into one ZIP". + if (from < 6) + changed |= AddCatalog(presets, profiles); + + // v7: conversions that already worked through another format but were on no donut. HWP as a target (through + // HWPX), PDF → Markdown / EPUB (through DOCX), Word → PNG, HWP → JPG / EPUB (through PDF / DOCX). + if (from < 7) + { + foreach (var preset in BuiltInData.HwpPresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + foreach (var (profile, preset, after) in Version7Offers) + changed |= Offer(presets, profiles, profile, preset, after); + } + + // v8: Settings → General has a default save location. "Same folder as the source" was the default until now, + // so presets still on it follow the new setting, which starts as the same folder: nothing moves until the + // user picks another default. + if (from < 8) + { + foreach (var preset in presets.Where(p => p.Output.Location == OutputLocation.SameFolder)) + { + preset.Output.Location = OutputLocation.Default; + changed = true; + } + } + + // v9: PDF → one tall image (all pages stacked), on the PDF donut after JPG (issue #50), and a HEIC preset + // (written with FFmpeg), on no donut like AVIF. + if (from < 9) + { + foreach (var id in new[] { "pdf-long-image", "to-heic" }) + { + if (presets.Any(p => p.Id == id)) + continue; + presets.Add(BuiltInData.CreatePresets().Single(p => p.Id == id)); + changed = true; + } + changed |= Offer(presets, profiles, "pdf", "pdf-long-image", "to-jpg"); + } + + // v10: "Remove background" (signatures, stamps, logos → transparent PNG), on no donut like HEIC: users add it + // in Settings → Toolbar. + if (from < 10 && presets.All(p => p.Id != "remove-background")) + { + presets.Add(BuiltInData.CreatePresets().Single(p => p.Id == "remove-background")); + changed = true; + } + + // v11: more document formats (Org, AsciiDoc, Typst, ... through Pandoc; flat OpenDocument, templates, + // ClarisWorks, ... through LibreOffice; KEPUB built in). Built-in donuts learn the new extensions unless + // another donut already has them, and the e-books donut gets KEPUB after MOBI. + if (from < 11) + { + if (presets.All(p => p.Id != "to-kepub")) + { + presets.Add(BuiltInData.CreatePresets().Single(p => p.Id == "to-kepub")); + changed = true; + changed |= Offer(presets, profiles, "ebooks", "to-kepub", "to-mobi"); + } + changed |= LearnExtensions(profiles, out _); + } + + // v12: HEVC / AV1 video, ALAC and the new audio formats (WavPack, TTA, APE, DSD, ...), and a subtitles donut + // after the audio donut. The video donut offers "MP4 HEVC" and "Extract subtitles". + if (from < 12) + changed |= AddCodecsAndSubtitles(presets, profiles); + + // v13: more image formats (APNG, MNG, QOI, DDS, DICOM, OpenEXR, …, Fujifilm's .hif): the images donut learns + // their extensions unless another donut already has them. + if (from < 13) + changed |= LearnExtensions(profiles, out _); + + // v14: structured data (JSON, YAML, TOML, XML, vCard) gets its own donut after "fonts", Outlook MSG joins + // the documents donut next to EML, and the spreadsheets donut gets JSON after CSV. + if (from < 14) + changed |= AddData(presets, profiles); + + // v15: 3D models (GLB, glTF, STL, OBJ, 3MF, PLY) get presets and their own donut after "data" (else after + // "fonts", else before the mixed-files one), with the extensions no donut of the user takes yet. + if (from < 15) + changed |= AddModels(presets, profiles); + + // v16: raster → SVG tracing ("Trace to SVG" in colour, "Line art to SVG" in black), on the images donut after + // PDF. + if (from < 16) + { + foreach (var preset in BuiltInData.TracePresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + changed |= Offer(presets, profiles, "images", "trace-svg", "to-pdf"); + changed |= Offer(presets, profiles, "images", "trace-line-art", "trace-svg"); + } + + // v17: rotate / flip presets and the "Rotate" / "Flip" groups that fan them out, on no donut, after "Remove + // background" as in a new library. Present already but saved since by a Filee before 17 (it reads and writes + // an older schema, so back and forth between versions comes here again): that one dropped what it didn't + // know, the rotation and the group, and left a preset that re-encodes unturned. Those parts are put back, the + // user's own changes (name, quality, file names) stay. + if (from < 17) + { + var at = presets.FindIndex(p => p.Id == "remove-background"); + at = at < 0 ? presets.Count : at + 1; + foreach (var tool in BuiltInData.ImageToolPresets()) + { + if (presets.FirstOrDefault(p => p.Id == tool.Id) is not { } existing) + { + presets.Insert(Math.Min(at++, presets.Count), tool); + changed = true; + } + else if (tool.IsGroup && !existing.IsGroup) + { + existing.Kind = PresetKind.Group; + existing.SubPresetIds = [.. tool.SubPresetIds]; + existing.TargetFormat = tool.TargetFormat; + changed = true; + } + else if (!tool.IsGroup && !existing.Image.TurnsOrMirrors) + { + existing.Image.Rotation = tool.Image.Rotation; + existing.Image.Flip = tool.Image.Flip; + changed = true; + } + } + } + + // v18: PDF → PPTX ("PPTX (slides)", a picture of each page per slide), on the PDF donut after DOCX. The default + // PDF donut is full: one the user never changed takes it in place of TIFF, as a new library has it; a donut + // the user changed gets it when it has room. + if (from < 18) + changed |= AddPdfSlides(presets, profiles); + // v19: "Extract frames" (every frame of a video as PNG or JPG pictures in a folder), on no donut, after "PPTX + // (slides)" or "Split PDF" as in a new library: a long video gives thousands of files, so users add them in + // Settings → Toolbar. + if (from < 19) + { + var at = presets.FindIndex(p => p.Id == "pdf-slides"); + if (at < 0) + at = presets.FindIndex(p => p.Id == "pdf-split"); + at = at < 0 ? presets.Count : at + 1; + foreach (var preset in BuiltInData.FramePresets()) + { + if (presets.Any(p => p.Id == preset.Id)) + continue; + presets.Insert(Math.Min(at++, presets.Count), preset); + changed = true; + } + } + // v20: "Compress PDF" (PDF → a smaller PDF), after "Split PDF" as in a new library, on no donut: the PDF donut + // is full. Present already but saved since by a Filee before 20 (back and forth between versions): that + // one dropped the compression level and left a preset that copies the pages, so the level is put back. + if (from < 20) + { + foreach (var compress in BuiltInData.PdfCompressionPresets()) + { + if (presets.FirstOrDefault(p => p.Id == compress.Id) is { } existing) + { + if (existing.Pdf.Compression == PdfCompression.None && existing.TargetFormat == compress.TargetFormat) + { + existing.Pdf.Compression = compress.Pdf.Compression; + changed = true; + } + continue; + } + var split = presets.FindIndex(p => p.Id == "pdf-split"); + presets.Insert(split < 0 ? presets.Count : split + 1, compress); + changed = true; + } + } + // v21: JPEG XR (.jxr, .wdp, .hdp; HDR screenshots of Windows): the images donut learns the extensions unless + // another donut already has them. + if (from < 21) + changed |= LearnExtensions(profiles, out _, ["jxr", "wdp", "hdp"]); + // v22: FBX models (read only) open from the 3D donut: it learns ".fbx" unless a donut of the user already + // takes it. Other extensions the user removed from a built-in donut stay removed. + if (from < 22) + changed |= LearnExtensions(profiles, out _, ["fbx"]); + // v23: MHTML web archives (.mhtml, .mht; read only) open from the text donut next to HTML, unless a donut of + // the user already takes them. + if (from < 23) + changed |= LearnExtensions(profiles, out _, ["mhtml", "mht"]); + // v24: macro-enabled PowerPoint templates (.potm) open from the presentation donut next to POTX, unless a donut + // of the user already takes them. + if (from < 24) + changed |= LearnExtensions(profiles, out _, ["potm"]); + // v25: geospatial vector data (Shapefile, GeoJSON, KML, KMZ, GPX) gets presets and its own donut after "models" + // (else after "data", else after "fonts"), with the extensions no donut of the user takes yet. + if (from < 25) + changed |= AddGeo(presets, profiles); + // v26: interactive "Crop" tool, after "Remove background" as in a new library, on no donut: users add it in + // Settings → Toolbar. Present already but saved since by a Filee before 26: that one read the unknown + // kind as a Group, dropped the target format and left an empty group. Kind and target format are put back, + // the user's own changes (name, file name pattern) stay. + if (from < 26) + { + if (BuiltInData.CreatePresets().FirstOrDefault(p => p.Id == "tool-crop") is { } crop) + { + if (presets.FirstOrDefault(p => p.Id == "tool-crop") is not { } existing) + { + var at = presets.FindIndex(p => p.Id == "remove-background"); + presets.Insert(at < 0 ? presets.Count : at + 1, crop); + changed = true; + } + else if (existing.Kind != PresetKind.Tool) + { + existing.Kind = PresetKind.Tool; + existing.TargetFormat = crop.TargetFormat; + existing.SubPresetIds = []; + changed = true; + } + } + } + // v27: OFD documents (.ofd; read only: e-invoices, official documents) open from the office donut, unless a + // donut of the user already takes them. + if (from < 27) + changed |= LearnExtensions(profiles, out _, ["ofd"]); + // v28: "One ZIP" on the archives donut after "Extract", to pack several archives into one. A donut + // the user filled up, or a library without the preset, stays as it is. + if (from < 28) + changed |= Offer(presets, profiles, "archives", "zip-all", "extract"); + // v29: "Extract live video" (the video of a Live / Motion Photo as MP4), after "Extract frames" as in a new + // library, on no donut: the images donut is full. Present already but saved since by a Filee before 29: + // that one dropped the option and left a plain MP4 preset, so it is put back. The images donut learns + // ".livp" (iPhone Live Photos downloaded from iCloud) unless a donut of the user already takes it. + if (from < 29) + { + foreach (var motion in BuiltInData.MotionPhotoPresets()) + { + if (presets.FirstOrDefault(p => p.Id == motion.Id) is { } existing) + { + if (!existing.Media.ExtractMotionPhoto && existing.TargetFormat == motion.TargetFormat) + { + existing.Media.ExtractMotionPhoto = true; + changed = true; + } + continue; + } + var at = presets.FindIndex(p => p.Id == "extract-frames-jpg"); + presets.Insert(at < 0 ? presets.Count : at + 1, motion); + changed = true; + } + changed |= LearnExtensions(profiles, out _, ["livp"]); + } + // v30: every donut gets a back face, which Tab (or the mouse wheel) flips the open donut to. A built-in donut + // gets its default one (with the presets the library still has), a donut the user made none; no front + // changes. Also at a later schema when a donut has no back face at all, not even an empty one: a Filee + // before 30 saved the library since and dropped what it didn't know, so the built-in back faces come + // back. One the user emptied is saved empty and stays so. + if (from < 30 || profiles.Any(p => p.BackPresetIds is null)) + changed |= AddBackFaces(presets, profiles); + // v31: "Live Photo (iPhone)" and "Motion Photo (Android)" (a clip of a video as a moving photo), after "Extract + // Live Photo video" as in a new library, and on the back of the video donut after "Extract frames" when it + // has room and the user hasn't put them elsewhere on it. Present already but saved since by a Filee before + // 31: that one dropped the option and left a plain JPG preset, so it is put back. + if (from < 31) + { + if (profiles.FirstOrDefault(p => p.Id == "video") is { BackPresetIds: { } back }) + { + var missing = BuiltInData.LivePhotoPresets().Select(p => p.Id).Where(id => !back.Contains(id)).ToList(); + if (missing.Count > 0 && back.Count + missing.Count <= ToolbarProfile.MaxSlices) + { + var after = back.IndexOf("extract-frames-jpg"); + back.InsertRange(after < 0 ? back.Count(id => id != "zip-all") : after + 1, missing); + changed = true; + } + } + foreach (var live in BuiltInData.LivePhotoPresets()) + { + if (presets.FirstOrDefault(p => p.Id == live.Id) is { } existing) + { + if (existing.Media.LivePhoto == LivePhotoKind.None && existing.TargetFormat == live.TargetFormat) + { + existing.Media.LivePhoto = live.Media.LivePhoto; + changed = true; + } + continue; + } + var at = presets.FindLastIndex(p => p.Id is "extract-motion" or "live-photo-iphone" or "extract-frames-jpg"); + presets.Insert(at < 0 ? presets.Count : at + 1, live); + changed = true; + } + } + // v32: logs and script source, Video CD DAT and Workbench archives learn their donuts; custom claims stay put. + if (from < 32) + changed |= LearnExtensions(profiles, out _, ["log", "py", "pac", "bat", "command", "dat", "wbpz"]); + // v33: HAR network logs and raw H.265 / H.264 camera streams learn their donuts (custom claims stay put), and + // "Trim transparent borders" comes in after "Remove background". + if (from < 33) + { + changed |= LearnExtensions(profiles, out _, ["har", "h265", "265", "hevc", "h264", "264"]); + // "Trim transparent borders" after "Remove background", in the library and on the images donut's back face + // when it has room and the user hasn't put it there. + if (BuiltInData.TrimPresets().Single() is var trim && presets.All(p => p.Id != trim.Id)) + { + var at = presets.FindIndex(p => p.Id == "remove-background"); + presets.Insert(at < 0 ? presets.Count : at + 1, trim); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "images") is { BackPresetIds: { } back } images + && !back.Contains("trim-transparent") && !images.PresetIds.Contains("trim-transparent") + && back.Count < ToolbarProfile.MaxSlices) + { + var after = back.IndexOf("remove-background"); + back.Insert(after < 0 ? back.Count(id => id != "zip-all") : after + 1, "trim-transparent"); + changed = true; + } + } + // v34: Lua scripts and CSS style sheets learn the text donut; custom claims stay put. + if (from < 34) + changed |= LearnExtensions(profiles, out _, ["lua", "css"]); + // v35: HP-GL plot files learn their donut; custom claims stay put. + if (from < 35) + changed |= LearnExtensions(profiles, out _, ["plt", "hpgl", "hpg", "hgl"]); + // v36: iCalendar (.ics, .ical) on the data donut, and "iCalendar" (CSV → ICS) after "vCard" on the back of the + // spreadsheets donut when it has room; custom claims stay put. + if (from < 36) + { + changed |= LearnExtensions(profiles, out _, ["ics", "ical"]); + changed |= AddCalendarPreset(presets, profiles); + } + // v37: everyday tools (compress image / video / audio, video from images, extract audio, merge videos / audio, + // remove metadata), after "Motion Photo (Android)" as in a new library, and on the back faces of the + // built-in donuts before "One ZIP" where they have room and the user hasn't put them there already. + // "Extract audio" replaces the plain M4A slice on the video donut's back. + if (from < 37) + changed |= AddMediaTools(presets, profiles); + // v38: Tajima embroidery designs (.dst) learn the vector donut; custom claims stay put. + if (from < 38) + changed |= LearnExtensions(profiles, out _, ["dst"]); + // v39: PowerShell scripts (.ps1) learn the text donut; custom claims stay put. + if (from < 39) + changed |= LearnExtensions(profiles, out _, ["ps1"]); + // v40: "Searchable PDF" (text recognition), after "Compress PDF" as in a new library, on no donut. + if (from < 40 && !presets.Any(p => p.Id == "searchable-pdf")) + { + var at = presets.FindIndex(p => p.Id == "compress-pdf"); + presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.OcrPresets().Single()); + changed = true; + } + // v41: PNG/JPEG download aliases learn the images donut; custom claims stay put. + if (from < 41) + changed |= LearnExtensions(profiles, out _, ["png_large", "jpg_large"]); + // v42: the print studio for XLSX → PDF (a tool), after "Crop" as in a new library, and first on the back of the + // spreadsheets donut when it has room and the user hasn't put it on either face. + if (from < 42) + { + const string id = "tool-sheet-studio"; + if (presets.All(p => p.Id != id)) + { + var at = presets.FindIndex(p => p.Id == "tool-crop"); + presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.CreatePresets().Single(p => p.Id == id)); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "spreadsheets") is { BackPresetIds: { } back } spreadsheets + && !back.Contains(id) && !spreadsheets.PresetIds.Contains(id) && back.Count < ToolbarProfile.MaxSlices) + { + back.Insert(0, id); + changed = true; + } + } + // v43: FITS astronomy pictures (.fits, .fts) learn the images donut; custom claims stay put. (Schema 43 may be + // renumbered if another change takes it first.) + if (from < 43) + changed |= LearnExtensions(profiles, out _, ["fits", "fts"]); + // v44: CRI USM game videos (.usm) learn the video donut; custom claims stay put. + if (from < 44) + changed |= LearnExtensions(profiles, out _, ["usm"]); + // v45: "Cut a clip" (a part of a video as a file of its own), after "Motion Photo (Android)" as in a new library, + // and first on the back of the video donut in place of FLAC (which stays in the library), when the user + // hasn't put it elsewhere on the donut and the face has room. + // Present already but saved since by a Filee before 45: that one dropped the tool's name, which made it a + // crop, so it is put back. The user's own changes (name, file name pattern) stay. + if (from < 45) + changed |= AddClipTool(presets, profiles); + // v46: one "Edit" slice on the back of the PDF donut fans out the common lossless page tools. Existing + // customised backs keep their layout; the group is offered at the start when there is room. + if (from < 46) + { + var ids = new[] { "tool-pdf-crop", "tool-pdf-pages", "tool-pdf-add", "pdf-rotate-left", "pdf-rotate-right", "pdf-edit" }; + var defaults = BuiltInData.CreatePresets(); + foreach (var id in ids.Where(id => presets.All(p => p.Id != id))) + { + var item = defaults.Single(p => p.Id == id); + var at = defaults.FindIndex(p => p.Id == id); + var after = defaults.Take(at).Select(p => presets.FindIndex(existing => existing.Id == p.Id)).LastOrDefault(i => i >= 0, -1); + presets.Insert(after + 1, item); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "pdf") is { BackPresetIds: { } back } pdf + && !pdf.PresetIds.Contains("pdf-edit") && !back.Contains("pdf-edit") + && back.Count < ToolbarProfile.MaxSlices) + { + back.Insert(0, "pdf-edit"); + changed = true; + } + } + return changed; + } + + private static bool AddCalendarPreset(List presets, List profiles) + { + var changed = false; + if (presets.All(p => p.Id != "to-ics")) + { + var preset = BuiltInData.DataPresets().Single(p => p.Id == "to-ics"); + var after = presets.FindIndex(p => p.Id == "to-vcf"); + presets.Insert(after < 0 ? presets.Count : after + 1, preset); + changed = true; + } + + if (profiles.FirstOrDefault(p => p.Id == "spreadsheets") is { BackPresetIds: { } back } + && !back.Contains("to-ics") && back.Count < ToolbarProfile.MaxSlices) + { + var after = back.IndexOf("to-vcf"); + var beforeZip = back.IndexOf("zip-all"); + back.Insert(after >= 0 ? after + 1 : beforeZip >= 0 ? beforeZip : back.Count, "to-ics"); + changed = true; + } + return changed; + } + + /// Where schema 37 offers its tools: (donut, presets for its back face). + private static readonly (string Profile, string[] Presets)[] Version37Offers = + [ + ("images", ["compress-image", "remove-metadata"]), + ("pdf", ["remove-metadata"]), + ("video", ["compress-video", "merge-videos", "extract-audio"]), + ("audio", ["compress-audio", "merge-audio", "remove-metadata"]), + ]; + + private static bool AddMediaTools(List presets, List profiles) + { + var changed = false; + var at = presets.FindLastIndex(p => p.Id is "motion-photo-android" or "live-photo-iphone" or "extract-motion"); + at = at < 0 ? presets.Count : at + 1; + foreach (var tool in BuiltInData.MediaToolPresets()) + { + if (presets.Any(p => p.Id == tool.Id)) + continue; + presets.Insert(Math.Min(at++, presets.Count), tool); + changed = true; + } + foreach (var (profileId, ids) in Version37Offers) + { + if (profiles.FirstOrDefault(p => p.Id == profileId) is not { BackPresetIds: { } back } profile) + continue; + // "Extract audio" writes the same M4A as the plain M4A slice, copying the sound where it can: it takes that + // slice's place on the video donut's back, or comes in with the other tools when there is room. + if (profileId == "video" && !back.Contains("extract-audio") && !profile.PresetIds.Contains("extract-audio") + && presets.Any(p => p.Id == "extract-audio")) + { + var m4a = back.IndexOf("to-m4a"); + if (m4a >= 0) + { + back[m4a] = "extract-audio"; + changed = true; + } + } + foreach (var id in ids) + { + if (back.Contains(id) || profile.PresetIds.Contains(id) || back.Count >= ToolbarProfile.MaxSlices + || presets.All(p => p.Id != id)) + continue; + InsertBefore(back, id, "zip-all"); + changed = true; + } + } + return changed; + } + + /// + /// Gives each built-in donut without a back face ( null) its default one, + /// with only the presets in . Donuts the user made are left for + /// to give an empty one. Returns true when one was added. + /// + internal static bool AddBackFaces(List presets, IEnumerable profiles) + { + var defaults = BuiltInData.CreateProfiles().ToDictionary(p => p.Id); + var ids = presets.Select(p => p.Id).ToHashSet(); + var changed = false; + foreach (var profile in profiles.Where(p => p.BackPresetIds is null && defaults.ContainsKey(p.Id))) + { + profile.BackPresetIds = (defaults[profile.Id].BackPresetIds ?? []).Where(ids.Contains).ToList(); + changed = true; + } + return changed; + } + + private static bool AddClipTool(List presets, List profiles) + { + var changed = false; + var clip = BuiltInData.ClipPresets().Single(); + if (presets.FirstOrDefault(p => p.Id == clip.Id) is { } existing) + { + if (!existing.IsClipTool) + { + existing.Kind = PresetKind.Tool; + existing.Tool = Preset.ClipTool; + existing.TargetFormat = clip.TargetFormat; + existing.SubPresetIds = []; + changed = true; + } + } + else + { + var at = presets.FindLastIndex(p => p.Id is "motion-photo-android" or "live-photo-iphone" or "extract-motion"); + presets.Insert(at < 0 ? presets.Count : at + 1, clip); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "video") is { BackPresetIds: { } back } video + && !back.Contains(clip.Id) && !video.PresetIds.Contains(clip.Id)) + { + changed |= back.Remove("to-flac"); + if (back.Count < ToolbarProfile.MaxSlices) + { + back.Insert(0, clip.Id); + changed = true; + } + } + return changed; + } + + private static bool AddGeo(List presets, List profiles) + { + var changed = false; + foreach (var preset in BuiltInData.GeoPresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + // Only the geospatial extensions: others a user removed from a built-in donut stay removed. + changed |= LearnExtensions(profiles, out var claimed, BuiltInData.GeoProfile().Extensions); + if (profiles.Any(p => p.Id == "geo")) + return changed; + + var geo = BuiltInData.GeoProfile(); + // Only the extensions no donut of the user takes yet. + geo.Extensions = geo.Extensions.Where(claimed.Add).ToList(); + if (geo.Extensions.Count == 0) + return changed; + var ids = presets.Select(p => p.Id).ToHashSet(); + geo.PresetIds = geo.PresetIds.Where(ids.Contains).ToList(); + var after = profiles.FindIndex(p => p.Id == "models"); + if (after < 0) + after = profiles.FindIndex(p => p.Id == "data"); + if (after < 0) + after = profiles.FindIndex(p => p.Id == "fonts"); + var insertAt = after >= 0 ? after + 1 : profiles.FindIndex(p => p.IsFallback); + profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, geo); + return true; + } + + private static bool AddPdfSlides(List presets, List profiles) + { + var changed = false; + if (presets.All(p => p.Id != "pdf-slides")) + { + var at = presets.FindIndex(p => p.Id == "pdf-split"); + presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.PdfSlidesPresets().Single()); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "pdf") is { } pdf && pdf.PresetIds.SequenceEqual(BuiltInData.PdfDonutBefore18)) + { + pdf.PresetIds = [.. BuiltInData.PdfDonut]; + return true; + } + return Offer(presets, profiles, "pdf", "pdf-slides", "to-docx") || changed; + } + + private static bool AddData(List presets, List profiles) + { + var changed = false; + foreach (var preset in BuiltInData.DataPresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + changed |= LearnExtensions(profiles, out var claimed); + + if (profiles.All(p => p.Id != "data")) + { + var data = BuiltInData.DataProfile(); + // Only the extensions no donut of the user takes yet. + data.Extensions = data.Extensions.Where(claimed.Add).ToList(); + if (data.Extensions.Count > 0) + { + var presetIds = presets.Select(p => p.Id).ToHashSet(); + data.PresetIds = data.PresetIds.Where(presetIds.Contains).ToList(); + var fonts = profiles.FindIndex(p => p.Id == "fonts"); + var insertAt = fonts >= 0 ? fonts + 1 : profiles.FindIndex(p => p.IsFallback); + profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, data); + changed = true; + } + } + + changed |= Offer(presets, profiles, "spreadsheets", "to-json", "to-csv"); + return changed; + } + + private static bool AddCodecsAndSubtitles(List presets, List profiles) + { + var changed = false; + foreach (var preset in BuiltInData.CodecAndSubtitlePresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + changed |= LearnExtensions(profiles, out var claimed); + + if (profiles.All(p => p.Id != "subtitles")) + { + var subtitles = BuiltInData.SubtitleProfile(); + subtitles.Extensions = subtitles.Extensions.Where(claimed.Add).ToList(); + if (subtitles.Extensions.Count > 0) + { + var ids = presets.Select(p => p.Id).ToHashSet(); + subtitles.PresetIds = subtitles.PresetIds.Where(ids.Contains).ToList(); + var audio = profiles.FindIndex(p => p.Id == "audio"); + var insertAt = audio >= 0 ? audio + 1 : profiles.FindIndex(p => p.IsFallback); + profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, subtitles); + changed = true; + } + } + + changed |= Offer(presets, profiles, "video", "mp4-hevc", "mp4-720"); + changed |= Offer(presets, profiles, "video", "extract-subtitles", "to-mp3"); + return changed; + } + + /// + /// Adds the default extensions of the built-in donuts that no donut of the user takes yet. + /// + /// Every extension a donut takes afterwards. + /// Learn only these (the ones a version added), not others the user removed from a donut since. + private static bool LearnExtensions(List profiles, out HashSet claimed, IReadOnlyCollection? only = null) + { + var changed = false; + // Extensions checked on a mixed-selection profile only limit that profile; they don't claim files. + claimed = profiles.Where(p => !p.IsFallback).SelectMany(p => p.Extensions).ToHashSet(StringComparer.OrdinalIgnoreCase); + var defaults = BuiltInData.CreateProfiles().ToDictionary(p => p.Id); + foreach (var profile in profiles.Where(p => !p.IsFallback && defaults.ContainsKey(p.Id))) + { + foreach (var extension in defaults[profile.Id].Extensions.Where(e => only is null || only.Contains(e)).Where(claimed.Add)) + { + profile.Extensions.Add(extension); + changed = true; + } + } + return changed; + } + + private static bool AddModels(List presets, List profiles) + { + var changed = false; + foreach (var preset in BuiltInData.ModelPresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + changed |= LearnExtensions(profiles, out var claimed); + if (profiles.Any(p => p.Id == "models")) + return changed; + + var models = BuiltInData.ModelProfile(); + // Only the extensions no donut of the user takes yet. + models.Extensions = models.Extensions.Where(claimed.Add).ToList(); + if (models.Extensions.Count == 0) + return changed; + var ids = presets.Select(p => p.Id).ToHashSet(); + models.PresetIds = models.PresetIds.Where(ids.Contains).ToList(); + var after = profiles.FindIndex(p => p.Id == "data"); + if (after < 0) + after = profiles.FindIndex(p => p.Id == "fonts"); + var insertAt = after >= 0 ? after + 1 : profiles.FindIndex(p => p.IsFallback); + profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, models); + return true; + } + + private static bool AddCatalog(List presets, List profiles) + { + var changed = false; + foreach (var preset in BuiltInData.CatalogPresets().Where(p => presets.All(existing => existing.Id != p.Id))) + { + presets.Add(preset); + changed = true; + } + var presetIds = presets.Select(p => p.Id).ToHashSet(); + changed |= LearnExtensions(profiles, out var claimed); + + var text = profiles.FindIndex(p => p.Id == "text"); + var insertAt = text >= 0 ? text + 1 : profiles.FindIndex(p => p.IsFallback); + insertAt = insertAt < 0 ? profiles.Count : insertAt; + foreach (var profile in BuiltInData.CatalogProfiles().Where(p => profiles.All(existing => existing.Id != p.Id))) + { + // Only the extensions no donut of the user takes yet. + profile.Extensions = profile.Extensions.Where(claimed.Add).ToList(); + if (profile.Extensions.Count == 0) + continue; + profile.PresetIds = profile.PresetIds.Where(presetIds.Contains).ToList(); + profiles.Insert(insertAt++, profile); + changed = true; + } + + changed |= Offer(presets, profiles, "pdf", "to-docx", "to-jpg"); + changed |= Offer(presets, profiles, profiles.FirstOrDefault(p => p.IsFallback)?.Id ?? "mixed", "zip-all", "merge-pdf"); + return changed; + } + + /// Presets that settings schema 7 puts on the built-in donuts: (donut, preset, after which preset). + private static readonly (string Profile, string Preset, string After)[] Version7Offers = + [ + ("pdf", "to-md", "to-txt"), ("pdf", "to-epub", "to-md"), ("pdf", "to-hwp", "to-hwpx"), + ("office", "to-hwp", "to-hwpx"), ("office", "to-png", "to-epub"), + ("hwp", "to-hwp", "to-hwpx"), ("hwp", "to-jpg", "to-png"), ("hwp", "to-epub", "to-md"), + ("spreadsheets", "to-hwp", "to-hwpx"), ("presentations", "to-hwp", "to-hwpx"), + ("text", "to-hwp", "to-hwpx"), ("ebooks", "to-hwp", "to-hwpx"), + ]; + + /// + /// Puts a preset on a donut after (or at the end), unless the donut is missing, full or + /// has it already. Returns true when it was added. + /// + private static bool Offer(List presets, List profiles, string profileId, string presetId, string? after) + { + if (profiles.FirstOrDefault(p => p.Id == profileId) is not { } profile + || profile.PresetIds.Contains(presetId) || profile.PresetIds.Count >= ToolbarProfile.MaxSlices + || presets.All(p => p.Id != presetId)) + return false; + var index = after is null ? -1 : profile.PresetIds.IndexOf(after); + profile.PresetIds.Insert(index < 0 ? profile.PresetIds.Count : index + 1, presetId); + return true; + } + + private static void InsertBefore(List list, string id, string before) + { + if (list.Contains(id)) + return; + var index = list.IndexOf(before); + if (index < 0) + list.Add(id); + else + list.Insert(index, id); + } +} diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 10a8c0a97..27ba0c8ca 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -250,6 +250,9 @@ public int Import(string path) /// Removes dangling preset references and guarantees at least one mixed-selection profile. private void EnsureConsistency() { + // "security": null in a file edited by hand reads as the defaults rather than failing later. + Settings.Security ??= new SecuritySettings(); + Settings.Limits ??= new LimitSettings(); var ids = Presets.Select(p => p.Id).ToHashSet(); foreach (var profile in Profiles) { @@ -314,909 +317,3 @@ private void Write(string path, T value, JsonTypeInfo type) } } } - -/// Upgrades settings written by older versions. Add one step per schema bump. -internal static class SettingsMigrations -{ - public static void Apply(AppSettings settings) - { - // v2: engines "pandoc-hwpx" (HWPX without Hancom Office, placed before "hancom" so HWPX saving needs - // no approval dialog) and "pandoc" (Markdown) were added. - if (settings.SchemaVersion < 2) - { - InsertBefore(settings.EnginePriority, "pandoc-hwpx", "hancom"); - InsertBefore(settings.EnginePriority, "pandoc", "libreoffice"); - } - - // v3: the Hancom Office engine was removed (rhwp converts HWP <-> HWPX without the approval dialog of 한글) - // and "pandoc-hwpx" became "hwpx-writer" (DOCX is read without Pandoc). - if (settings.SchemaVersion < 3) - { - settings.EnginePriority.Remove("hancom"); - var index = settings.EnginePriority.IndexOf("pandoc-hwpx"); - if (index >= 0) - settings.EnginePriority[index] = "hwpx-writer"; - } - - // v4: built-in "markdown" engine (Markdig, no Pandoc needed); the "text" toolbar profile is added by - // ApplyToLibrary. - if (settings.SchemaVersion < 4) - InsertBefore(settings.EnginePriority, "markdown", "hwpx-writer"); - - // v5: Microsoft Word automation was removed (Filee no longer uses software installed on the system) and the - // built-in "spreadsheet" engine (XLSX <-> CSV) was added; profiles are split by ApplyToLibrary. - if (settings.SchemaVersion < 5) - { - settings.EnginePriority.Remove("word"); - InsertBefore(settings.EnginePriority, "spreadsheet", "hwpx-writer"); - } - - // v6: engines for the rest of the format catalog. Built-in ones go before Pandoc, optional downloads - // before LibreOffice (the fallback that stays last). - if (settings.SchemaVersion < 6) - { - InsertBefore(settings.EnginePriority, "ooxml", "hwpx-writer"); - foreach (var id in new[] { "docx-writer", "pdf-text", "email", "vector", "icns", "font", "cad", "archive", "ebook", "ffmpeg" }) - InsertBefore(settings.EnginePriority, id, "pandoc"); - foreach (var id in new[] { "ghostscript", "calibre" }) - InsertBefore(settings.EnginePriority, id, "libreoffice"); - } - - // v12: built-in "subtitle" engine (SRT, ASS, WebVTT, SAMI, LRC, TTML, ...), before FFmpeg. - if (settings.SchemaVersion < 12) - InsertBefore(settings.EnginePriority, "subtitle", "ffmpeg"); - - // v14: built-in "data" engine (JSON, YAML, TOML, XML, CSV, vCard), next to the e-mail engine. - if (settings.SchemaVersion < 14 && !settings.EnginePriority.Contains("data")) - { - var email = settings.EnginePriority.IndexOf("email"); - if (email >= 0) - settings.EnginePriority.Insert(email + 1, "data"); - else - InsertBefore(settings.EnginePriority, "data", "pandoc"); - } - - // v15: built-in "model3d" engine (3D models: GLB, glTF, STL, OBJ, 3MF, PLY), after the CAD engine (else before - // the archive engine, else before Pandoc like the other built-in engines). - if (settings.SchemaVersion < 15 && !settings.EnginePriority.Contains("model3d")) - { - var cad = settings.EnginePriority.IndexOf("cad"); - if (cad >= 0) - settings.EnginePriority.Insert(cad + 1, "model3d"); - else - InsertBefore(settings.EnginePriority, "model3d", settings.EnginePriority.Contains("archive") ? "archive" : "pandoc"); - } - - // v25: built-in "geo" engine (GeoJSON, KML, KMZ, GPX, Shapefile and CSV points), after the 3D models engine (else - // before the archive engine, else before Pandoc). - if (settings.SchemaVersion < 25 && !settings.EnginePriority.Contains("geo")) - { - var models = settings.EnginePriority.IndexOf("model3d"); - if (models >= 0) - settings.EnginePriority.Insert(models + 1, "geo"); - else - InsertBefore(settings.EnginePriority, "geo", settings.EnginePriority.Contains("archive") ? "archive" : "pandoc"); - } - - // v18: built-in "pdf-slides" engine (PDF → PPTX, a picture of each page per slide), after "pdf-text". - if (settings.SchemaVersion < 18 && !settings.EnginePriority.Contains("pdf-slides")) - { - var text = settings.EnginePriority.IndexOf("pdf-text"); - if (text >= 0) - settings.EnginePriority.Insert(text + 1, "pdf-slides"); - else - InsertBefore(settings.EnginePriority, "pdf-slides", "pandoc"); - } - - // v21: built-in "wic" engine (JPEG XR through the Windows Imaging Component), after the other built-in engines: - // no other engine reads JPEG XR, and higher up it would push the HWPX writer out of the ranks that win - // ties. (Schema 21 may be renumbered if another change takes it first.) - if (settings.SchemaVersion < 21) - InsertBefore(settings.EnginePriority, "wic", settings.EnginePriority.Contains("ffmpeg") ? "ffmpeg" : "pandoc"); - - if (settings.Triggers.Count == 0) - settings.Triggers = TriggerGesture.Defaults(); - - settings.SchemaVersion = AppSettings.CurrentSchemaVersion; - } - - /// Upgrades presets and profiles written with settings schema . - /// True when something was added. - public static bool ApplyToLibrary(int from, List presets, List profiles) - { - var changed = false; - // v4: Markdown / TXT / HTML files get their own donut instead of the "mixed files" fallback. Skipped when - // the user already routes Markdown through a profile of their own. - if (from < 4 && !profiles.Any(p => p.Id == "text" || (!p.IsFallback && p.Extensions.Contains("md", StringComparer.OrdinalIgnoreCase)))) - { - var text = BuiltInData.TextProfile(); - var ids = presets.Select(p => p.Id).ToHashSet(); - text.PresetIds = text.PresetIds.Where(ids.Contains).ToList(); - var fallback = profiles.FindIndex(p => p.IsFallback); - profiles.Insert(fallback < 0 ? profiles.Count : fallback, text); - changed = true; - } - - // v5: spreadsheets and presentations get their own donuts (with XLSX / CSV targets) instead of sharing the - // documents donut, and the PDF donut drops targets only Word could produce (PDF → DOCX / HWPX / TXT). - if (from < 5) - { - foreach (var preset in BuiltInData.SpreadsheetPresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - - var office = profiles.FirstOrDefault(p => p.Id == "office"); - var insertAt = office is null ? profiles.FindIndex(p => p.IsFallback) : profiles.IndexOf(office) + 1; - var ids = presets.Select(p => p.Id).ToHashSet(); - foreach (var split in new[] { BuiltInData.SpreadsheetProfile(), BuiltInData.PresentationProfile() }) - { - if (profiles.Any(p => p.Id == split.Id)) - continue; - split.PresetIds = split.PresetIds.Where(ids.Contains).ToList(); - // The new donut takes over these extensions from the built-in documents donut, but not from - // profiles the user made. - changed |= office?.Extensions.RemoveAll(split.Extensions.Contains) > 0; - if (profiles.Any(p => !p.IsFallback && p.Extensions.Intersect(split.Extensions, StringComparer.OrdinalIgnoreCase).Any())) - continue; - insertAt = insertAt < 0 ? profiles.Count : insertAt; - profiles.Insert(insertAt++, split); - changed = true; - } - - if (profiles.FirstOrDefault(p => p.Id == "pdf") is { } pdf) - changed |= pdf.PresetIds.RemoveAll(id => id is "to-docx" or "to-hwpx" or "to-txt") > 0; - } - - // v6: the rest of the format catalog (video, audio, e-books, archives, vector, CAD, fonts, more image and - // office formats). Built-in donuts learn their new extensions unless another donut already has them, the - // new donuts follow "text", PDF → DOCX is back on the PDF donut (built in now) and the mixed-files donut - // gets "compress into one ZIP". - if (from < 6) - changed |= AddCatalog(presets, profiles); - - // v7: conversions that already worked through another format but were on no donut. HWP as a target (through - // HWPX), PDF → Markdown / EPUB (through DOCX), Word → PNG, HWP → JPG / EPUB (through PDF / DOCX). - if (from < 7) - { - foreach (var preset in BuiltInData.HwpPresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - foreach (var (profile, preset, after) in Version7Offers) - changed |= Offer(presets, profiles, profile, preset, after); - } - - // v8: Settings → General has a default save location. "Same folder as the source" was the default until now, - // so presets still on it follow the new setting, which starts as the same folder: nothing moves until the - // user picks another default. - if (from < 8) - { - foreach (var preset in presets.Where(p => p.Output.Location == OutputLocation.SameFolder)) - { - preset.Output.Location = OutputLocation.Default; - changed = true; - } - } - - // v9: PDF → one tall image (all pages stacked), on the PDF donut after JPG (issue #50), and a HEIC preset - // (written with FFmpeg), on no donut like AVIF. - if (from < 9) - { - foreach (var id in new[] { "pdf-long-image", "to-heic" }) - { - if (presets.Any(p => p.Id == id)) - continue; - presets.Add(BuiltInData.CreatePresets().Single(p => p.Id == id)); - changed = true; - } - changed |= Offer(presets, profiles, "pdf", "pdf-long-image", "to-jpg"); - } - - // v10: "Remove background" (signatures, stamps, logos → transparent PNG), on no donut like HEIC: users add it - // in Settings → Toolbar. - if (from < 10 && presets.All(p => p.Id != "remove-background")) - { - presets.Add(BuiltInData.CreatePresets().Single(p => p.Id == "remove-background")); - changed = true; - } - - // v11: more document formats (Org, AsciiDoc, Typst, ... through Pandoc; flat OpenDocument, templates, - // ClarisWorks, ... through LibreOffice; KEPUB built in). Built-in donuts learn the new extensions unless - // another donut already has them, and the e-books donut gets KEPUB after MOBI. - if (from < 11) - { - if (presets.All(p => p.Id != "to-kepub")) - { - presets.Add(BuiltInData.CreatePresets().Single(p => p.Id == "to-kepub")); - changed = true; - changed |= Offer(presets, profiles, "ebooks", "to-kepub", "to-mobi"); - } - changed |= LearnExtensions(profiles, out _); - } - - // v12: HEVC / AV1 video, ALAC and the new audio formats (WavPack, TTA, APE, DSD, ...), and a subtitles donut - // after the audio donut. The video donut offers "MP4 HEVC" and "Extract subtitles". - if (from < 12) - changed |= AddCodecsAndSubtitles(presets, profiles); - - // v13: more image formats (APNG, MNG, QOI, DDS, DICOM, OpenEXR, …, Fujifilm's .hif): the images donut learns - // their extensions unless another donut already has them. - if (from < 13) - changed |= LearnExtensions(profiles, out _); - - // v14: structured data (JSON, YAML, TOML, XML, vCard) gets its own donut after "fonts", Outlook MSG joins - // the documents donut next to EML, and the spreadsheets donut gets JSON after CSV. - if (from < 14) - changed |= AddData(presets, profiles); - - // v15: 3D models (GLB, glTF, STL, OBJ, 3MF, PLY) get presets and their own donut after "data" (else after - // "fonts", else before the mixed-files one), with the extensions no donut of the user takes yet. - if (from < 15) - changed |= AddModels(presets, profiles); - - // v16: raster → SVG tracing ("Trace to SVG" in colour, "Line art to SVG" in black), on the images donut after - // PDF. - if (from < 16) - { - foreach (var preset in BuiltInData.TracePresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - changed |= Offer(presets, profiles, "images", "trace-svg", "to-pdf"); - changed |= Offer(presets, profiles, "images", "trace-line-art", "trace-svg"); - } - - // v17: rotate / flip presets and the "Rotate" / "Flip" groups that fan them out, on no donut, after "Remove - // background" as in a new library. Present already but saved since by a Filee before 17 (it reads and writes - // an older schema, so back and forth between versions comes here again): that one dropped what it didn't - // know, the rotation and the group, and left a preset that re-encodes unturned. Those parts are put back, the - // user's own changes (name, quality, file names) stay. - if (from < 17) - { - var at = presets.FindIndex(p => p.Id == "remove-background"); - at = at < 0 ? presets.Count : at + 1; - foreach (var tool in BuiltInData.ImageToolPresets()) - { - if (presets.FirstOrDefault(p => p.Id == tool.Id) is not { } existing) - { - presets.Insert(Math.Min(at++, presets.Count), tool); - changed = true; - } - else if (tool.IsGroup && !existing.IsGroup) - { - existing.Kind = PresetKind.Group; - existing.SubPresetIds = [.. tool.SubPresetIds]; - existing.TargetFormat = tool.TargetFormat; - changed = true; - } - else if (!tool.IsGroup && !existing.Image.TurnsOrMirrors) - { - existing.Image.Rotation = tool.Image.Rotation; - existing.Image.Flip = tool.Image.Flip; - changed = true; - } - } - } - - // v18: PDF → PPTX ("PPTX (slides)", a picture of each page per slide), on the PDF donut after DOCX. The default - // PDF donut is full: one the user never changed takes it in place of TIFF, as a new library has it; a donut - // the user changed gets it when it has room. - if (from < 18) - changed |= AddPdfSlides(presets, profiles); - // v19: "Extract frames" (every frame of a video as PNG or JPG pictures in a folder), on no donut, after "PPTX - // (slides)" or "Split PDF" as in a new library: a long video gives thousands of files, so users add them in - // Settings → Toolbar. - if (from < 19) - { - var at = presets.FindIndex(p => p.Id == "pdf-slides"); - if (at < 0) - at = presets.FindIndex(p => p.Id == "pdf-split"); - at = at < 0 ? presets.Count : at + 1; - foreach (var preset in BuiltInData.FramePresets()) - { - if (presets.Any(p => p.Id == preset.Id)) - continue; - presets.Insert(Math.Min(at++, presets.Count), preset); - changed = true; - } - } - // v20: "Compress PDF" (PDF → a smaller PDF), after "Split PDF" as in a new library, on no donut: the PDF donut - // is full. Present already but saved since by a Filee before 20 (back and forth between versions): that - // one dropped the compression level and left a preset that copies the pages, so the level is put back. - if (from < 20) - { - foreach (var compress in BuiltInData.PdfCompressionPresets()) - { - if (presets.FirstOrDefault(p => p.Id == compress.Id) is { } existing) - { - if (existing.Pdf.Compression == PdfCompression.None && existing.TargetFormat == compress.TargetFormat) - { - existing.Pdf.Compression = compress.Pdf.Compression; - changed = true; - } - continue; - } - var split = presets.FindIndex(p => p.Id == "pdf-split"); - presets.Insert(split < 0 ? presets.Count : split + 1, compress); - changed = true; - } - } - // v21: JPEG XR (.jxr, .wdp, .hdp; HDR screenshots of Windows): the images donut learns the extensions unless - // another donut already has them. - if (from < 21) - changed |= LearnExtensions(profiles, out _, ["jxr", "wdp", "hdp"]); - // v22: FBX models (read only) open from the 3D donut: it learns ".fbx" unless a donut of the user already - // takes it. Other extensions the user removed from a built-in donut stay removed. - if (from < 22) - changed |= LearnExtensions(profiles, out _, ["fbx"]); - // v23: MHTML web archives (.mhtml, .mht; read only) open from the text donut next to HTML, unless a donut of - // the user already takes them. - if (from < 23) - changed |= LearnExtensions(profiles, out _, ["mhtml", "mht"]); - // v24: macro-enabled PowerPoint templates (.potm) open from the presentation donut next to POTX, unless a donut - // of the user already takes them. - if (from < 24) - changed |= LearnExtensions(profiles, out _, ["potm"]); - // v25: geospatial vector data (Shapefile, GeoJSON, KML, KMZ, GPX) gets presets and its own donut after "models" - // (else after "data", else after "fonts"), with the extensions no donut of the user takes yet. - if (from < 25) - changed |= AddGeo(presets, profiles); - // v26: interactive "Crop" tool, after "Remove background" as in a new library, on no donut: users add it in - // Settings → Toolbar. Present already but saved since by a Filee before 26: that one read the unknown - // kind as a Group, dropped the target format and left an empty group. Kind and target format are put back, - // the user's own changes (name, file name pattern) stay. - if (from < 26) - { - if (BuiltInData.CreatePresets().FirstOrDefault(p => p.Id == "tool-crop") is { } crop) - { - if (presets.FirstOrDefault(p => p.Id == "tool-crop") is not { } existing) - { - var at = presets.FindIndex(p => p.Id == "remove-background"); - presets.Insert(at < 0 ? presets.Count : at + 1, crop); - changed = true; - } - else if (existing.Kind != PresetKind.Tool) - { - existing.Kind = PresetKind.Tool; - existing.TargetFormat = crop.TargetFormat; - existing.SubPresetIds = []; - changed = true; - } - } - } - // v27: OFD documents (.ofd; read only: e-invoices, official documents) open from the office donut, unless a - // donut of the user already takes them. - if (from < 27) - changed |= LearnExtensions(profiles, out _, ["ofd"]); - // v28: "One ZIP" on the archives donut after "Extract", to pack several archives into one. A donut - // the user filled up, or a library without the preset, stays as it is. - if (from < 28) - changed |= Offer(presets, profiles, "archives", "zip-all", "extract"); - // v29: "Extract live video" (the video of a Live / Motion Photo as MP4), after "Extract frames" as in a new - // library, on no donut: the images donut is full. Present already but saved since by a Filee before 29: - // that one dropped the option and left a plain MP4 preset, so it is put back. The images donut learns - // ".livp" (iPhone Live Photos downloaded from iCloud) unless a donut of the user already takes it. - if (from < 29) - { - foreach (var motion in BuiltInData.MotionPhotoPresets()) - { - if (presets.FirstOrDefault(p => p.Id == motion.Id) is { } existing) - { - if (!existing.Media.ExtractMotionPhoto && existing.TargetFormat == motion.TargetFormat) - { - existing.Media.ExtractMotionPhoto = true; - changed = true; - } - continue; - } - var at = presets.FindIndex(p => p.Id == "extract-frames-jpg"); - presets.Insert(at < 0 ? presets.Count : at + 1, motion); - changed = true; - } - changed |= LearnExtensions(profiles, out _, ["livp"]); - } - // v30: every donut gets a back face, which Tab (or the mouse wheel) flips the open donut to. A built-in donut - // gets its default one (with the presets the library still has), a donut the user made none; no front - // changes. Also at a later schema when a donut has no back face at all, not even an empty one: a Filee - // before 30 saved the library since and dropped what it didn't know, so the built-in back faces come - // back. One the user emptied is saved empty and stays so. - if (from < 30 || profiles.Any(p => p.BackPresetIds is null)) - changed |= AddBackFaces(presets, profiles); - // v31: "Live Photo (iPhone)" and "Motion Photo (Android)" (a clip of a video as a moving photo), after "Extract - // Live Photo video" as in a new library, and on the back of the video donut after "Extract frames" when it - // has room and the user hasn't put them elsewhere on it. Present already but saved since by a Filee before - // 31: that one dropped the option and left a plain JPG preset, so it is put back. - if (from < 31) - { - if (profiles.FirstOrDefault(p => p.Id == "video") is { BackPresetIds: { } back }) - { - var missing = BuiltInData.LivePhotoPresets().Select(p => p.Id).Where(id => !back.Contains(id)).ToList(); - if (missing.Count > 0 && back.Count + missing.Count <= ToolbarProfile.MaxSlices) - { - var after = back.IndexOf("extract-frames-jpg"); - back.InsertRange(after < 0 ? back.Count(id => id != "zip-all") : after + 1, missing); - changed = true; - } - } - foreach (var live in BuiltInData.LivePhotoPresets()) - { - if (presets.FirstOrDefault(p => p.Id == live.Id) is { } existing) - { - if (existing.Media.LivePhoto == LivePhotoKind.None && existing.TargetFormat == live.TargetFormat) - { - existing.Media.LivePhoto = live.Media.LivePhoto; - changed = true; - } - continue; - } - var at = presets.FindLastIndex(p => p.Id is "extract-motion" or "live-photo-iphone" or "extract-frames-jpg"); - presets.Insert(at < 0 ? presets.Count : at + 1, live); - changed = true; - } - } - // v32: logs and script source, Video CD DAT and Workbench archives learn their donuts; custom claims stay put. - if (from < 32) - changed |= LearnExtensions(profiles, out _, ["log", "py", "pac", "bat", "command", "dat", "wbpz"]); - // v33: HAR network logs and raw H.265 / H.264 camera streams learn their donuts (custom claims stay put), and - // "Trim transparent borders" comes in after "Remove background". - if (from < 33) - { - changed |= LearnExtensions(profiles, out _, ["har", "h265", "265", "hevc", "h264", "264"]); - // "Trim transparent borders" after "Remove background", in the library and on the images donut's back face - // when it has room and the user hasn't put it there. - if (BuiltInData.TrimPresets().Single() is var trim && presets.All(p => p.Id != trim.Id)) - { - var at = presets.FindIndex(p => p.Id == "remove-background"); - presets.Insert(at < 0 ? presets.Count : at + 1, trim); - changed = true; - } - if (profiles.FirstOrDefault(p => p.Id == "images") is { BackPresetIds: { } back } images - && !back.Contains("trim-transparent") && !images.PresetIds.Contains("trim-transparent") - && back.Count < ToolbarProfile.MaxSlices) - { - var after = back.IndexOf("remove-background"); - back.Insert(after < 0 ? back.Count(id => id != "zip-all") : after + 1, "trim-transparent"); - changed = true; - } - } - // v34: Lua scripts and CSS style sheets learn the text donut; custom claims stay put. - if (from < 34) - changed |= LearnExtensions(profiles, out _, ["lua", "css"]); - // v35: HP-GL plot files learn their donut; custom claims stay put. - if (from < 35) - changed |= LearnExtensions(profiles, out _, ["plt", "hpgl", "hpg", "hgl"]); - // v36: iCalendar (.ics, .ical) on the data donut, and "iCalendar" (CSV → ICS) after "vCard" on the back of the - // spreadsheets donut when it has room; custom claims stay put. - if (from < 36) - { - changed |= LearnExtensions(profiles, out _, ["ics", "ical"]); - changed |= AddCalendarPreset(presets, profiles); - } - // v37: everyday tools (compress image / video / audio, video from images, extract audio, merge videos / audio, - // remove metadata), after "Motion Photo (Android)" as in a new library, and on the back faces of the - // built-in donuts before "One ZIP" where they have room and the user hasn't put them there already. - // "Extract audio" replaces the plain M4A slice on the video donut's back. - if (from < 37) - changed |= AddMediaTools(presets, profiles); - // v38: Tajima embroidery designs (.dst) learn the vector donut; custom claims stay put. - if (from < 38) - changed |= LearnExtensions(profiles, out _, ["dst"]); - // v39: PowerShell scripts (.ps1) learn the text donut; custom claims stay put. - if (from < 39) - changed |= LearnExtensions(profiles, out _, ["ps1"]); - // v40: "Searchable PDF" (text recognition), after "Compress PDF" as in a new library, on no donut. - if (from < 40 && !presets.Any(p => p.Id == "searchable-pdf")) - { - var at = presets.FindIndex(p => p.Id == "compress-pdf"); - presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.OcrPresets().Single()); - changed = true; - } - // v41: PNG/JPEG download aliases learn the images donut; custom claims stay put. - if (from < 41) - changed |= LearnExtensions(profiles, out _, ["png_large", "jpg_large"]); - // v42: the print studio for XLSX → PDF (a tool), after "Crop" as in a new library, and first on the back of the - // spreadsheets donut when it has room and the user hasn't put it on either face. - if (from < 42) - { - const string id = "tool-sheet-studio"; - if (presets.All(p => p.Id != id)) - { - var at = presets.FindIndex(p => p.Id == "tool-crop"); - presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.CreatePresets().Single(p => p.Id == id)); - changed = true; - } - if (profiles.FirstOrDefault(p => p.Id == "spreadsheets") is { BackPresetIds: { } back } spreadsheets - && !back.Contains(id) && !spreadsheets.PresetIds.Contains(id) && back.Count < ToolbarProfile.MaxSlices) - { - back.Insert(0, id); - changed = true; - } - } - // v43: FITS astronomy pictures (.fits, .fts) learn the images donut; custom claims stay put. (Schema 43 may be - // renumbered if another change takes it first.) - if (from < 43) - changed |= LearnExtensions(profiles, out _, ["fits", "fts"]); - // v44: CRI USM game videos (.usm) learn the video donut; custom claims stay put. - if (from < 44) - changed |= LearnExtensions(profiles, out _, ["usm"]); - // v45: "Cut a clip" (a part of a video as a file of its own), after "Motion Photo (Android)" as in a new library, - // and first on the back of the video donut in place of FLAC (which stays in the library), when the user - // hasn't put it elsewhere on the donut and the face has room. - // Present already but saved since by a Filee before 45: that one dropped the tool's name, which made it a - // crop, so it is put back. The user's own changes (name, file name pattern) stay. - if (from < 45) - changed |= AddClipTool(presets, profiles); - // v46: one "Edit" slice on the back of the PDF donut fans out the common lossless page tools. Existing - // customised backs keep their layout; the group is offered at the start when there is room. - if (from < 46) - { - var ids = new[] { "tool-pdf-crop", "tool-pdf-pages", "tool-pdf-add", "pdf-rotate-left", "pdf-rotate-right", "pdf-edit" }; - var defaults = BuiltInData.CreatePresets(); - foreach (var id in ids.Where(id => presets.All(p => p.Id != id))) - { - var item = defaults.Single(p => p.Id == id); - var at = defaults.FindIndex(p => p.Id == id); - var after = defaults.Take(at).Select(p => presets.FindIndex(existing => existing.Id == p.Id)).LastOrDefault(i => i >= 0, -1); - presets.Insert(after + 1, item); - changed = true; - } - if (profiles.FirstOrDefault(p => p.Id == "pdf") is { BackPresetIds: { } back } pdf - && !pdf.PresetIds.Contains("pdf-edit") && !back.Contains("pdf-edit") - && back.Count < ToolbarProfile.MaxSlices) - { - back.Insert(0, "pdf-edit"); - changed = true; - } - } - return changed; - } - - private static bool AddCalendarPreset(List presets, List profiles) - { - var changed = false; - if (presets.All(p => p.Id != "to-ics")) - { - var preset = BuiltInData.DataPresets().Single(p => p.Id == "to-ics"); - var after = presets.FindIndex(p => p.Id == "to-vcf"); - presets.Insert(after < 0 ? presets.Count : after + 1, preset); - changed = true; - } - - if (profiles.FirstOrDefault(p => p.Id == "spreadsheets") is { BackPresetIds: { } back } - && !back.Contains("to-ics") && back.Count < ToolbarProfile.MaxSlices) - { - var after = back.IndexOf("to-vcf"); - var beforeZip = back.IndexOf("zip-all"); - back.Insert(after >= 0 ? after + 1 : beforeZip >= 0 ? beforeZip : back.Count, "to-ics"); - changed = true; - } - return changed; - } - - /// Where schema 37 offers its tools: (donut, presets for its back face). - private static readonly (string Profile, string[] Presets)[] Version37Offers = - [ - ("images", ["compress-image", "remove-metadata"]), - ("pdf", ["remove-metadata"]), - ("video", ["compress-video", "merge-videos", "extract-audio"]), - ("audio", ["compress-audio", "merge-audio", "remove-metadata"]), - ]; - - private static bool AddMediaTools(List presets, List profiles) - { - var changed = false; - var at = presets.FindLastIndex(p => p.Id is "motion-photo-android" or "live-photo-iphone" or "extract-motion"); - at = at < 0 ? presets.Count : at + 1; - foreach (var tool in BuiltInData.MediaToolPresets()) - { - if (presets.Any(p => p.Id == tool.Id)) - continue; - presets.Insert(Math.Min(at++, presets.Count), tool); - changed = true; - } - foreach (var (profileId, ids) in Version37Offers) - { - if (profiles.FirstOrDefault(p => p.Id == profileId) is not { BackPresetIds: { } back } profile) - continue; - // "Extract audio" writes the same M4A as the plain M4A slice, copying the sound where it can: it takes that - // slice's place on the video donut's back, or comes in with the other tools when there is room. - if (profileId == "video" && !back.Contains("extract-audio") && !profile.PresetIds.Contains("extract-audio") - && presets.Any(p => p.Id == "extract-audio")) - { - var m4a = back.IndexOf("to-m4a"); - if (m4a >= 0) - { - back[m4a] = "extract-audio"; - changed = true; - } - } - foreach (var id in ids) - { - if (back.Contains(id) || profile.PresetIds.Contains(id) || back.Count >= ToolbarProfile.MaxSlices - || presets.All(p => p.Id != id)) - continue; - InsertBefore(back, id, "zip-all"); - changed = true; - } - } - return changed; - } - - /// - /// Gives each built-in donut without a back face ( null) its default one, - /// with only the presets in . Donuts the user made are left for - /// to give an empty one. Returns true when one was added. - /// - internal static bool AddBackFaces(List presets, IEnumerable profiles) - { - var defaults = BuiltInData.CreateProfiles().ToDictionary(p => p.Id); - var ids = presets.Select(p => p.Id).ToHashSet(); - var changed = false; - foreach (var profile in profiles.Where(p => p.BackPresetIds is null && defaults.ContainsKey(p.Id))) - { - profile.BackPresetIds = (defaults[profile.Id].BackPresetIds ?? []).Where(ids.Contains).ToList(); - changed = true; - } - return changed; - } - - private static bool AddClipTool(List presets, List profiles) - { - var changed = false; - var clip = BuiltInData.ClipPresets().Single(); - if (presets.FirstOrDefault(p => p.Id == clip.Id) is { } existing) - { - if (!existing.IsClipTool) - { - existing.Kind = PresetKind.Tool; - existing.Tool = Preset.ClipTool; - existing.TargetFormat = clip.TargetFormat; - existing.SubPresetIds = []; - changed = true; - } - } - else - { - var at = presets.FindLastIndex(p => p.Id is "motion-photo-android" or "live-photo-iphone" or "extract-motion"); - presets.Insert(at < 0 ? presets.Count : at + 1, clip); - changed = true; - } - if (profiles.FirstOrDefault(p => p.Id == "video") is { BackPresetIds: { } back } video - && !back.Contains(clip.Id) && !video.PresetIds.Contains(clip.Id)) - { - changed |= back.Remove("to-flac"); - if (back.Count < ToolbarProfile.MaxSlices) - { - back.Insert(0, clip.Id); - changed = true; - } - } - return changed; - } - - private static bool AddGeo(List presets, List profiles) - { - var changed = false; - foreach (var preset in BuiltInData.GeoPresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - // Only the geospatial extensions: others a user removed from a built-in donut stay removed. - changed |= LearnExtensions(profiles, out var claimed, BuiltInData.GeoProfile().Extensions); - if (profiles.Any(p => p.Id == "geo")) - return changed; - - var geo = BuiltInData.GeoProfile(); - // Only the extensions no donut of the user takes yet. - geo.Extensions = geo.Extensions.Where(claimed.Add).ToList(); - if (geo.Extensions.Count == 0) - return changed; - var ids = presets.Select(p => p.Id).ToHashSet(); - geo.PresetIds = geo.PresetIds.Where(ids.Contains).ToList(); - var after = profiles.FindIndex(p => p.Id == "models"); - if (after < 0) - after = profiles.FindIndex(p => p.Id == "data"); - if (after < 0) - after = profiles.FindIndex(p => p.Id == "fonts"); - var insertAt = after >= 0 ? after + 1 : profiles.FindIndex(p => p.IsFallback); - profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, geo); - return true; - } - - private static bool AddPdfSlides(List presets, List profiles) - { - var changed = false; - if (presets.All(p => p.Id != "pdf-slides")) - { - var at = presets.FindIndex(p => p.Id == "pdf-split"); - presets.Insert(at < 0 ? presets.Count : at + 1, BuiltInData.PdfSlidesPresets().Single()); - changed = true; - } - if (profiles.FirstOrDefault(p => p.Id == "pdf") is { } pdf && pdf.PresetIds.SequenceEqual(BuiltInData.PdfDonutBefore18)) - { - pdf.PresetIds = [.. BuiltInData.PdfDonut]; - return true; - } - return Offer(presets, profiles, "pdf", "pdf-slides", "to-docx") || changed; - } - - private static bool AddData(List presets, List profiles) - { - var changed = false; - foreach (var preset in BuiltInData.DataPresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - changed |= LearnExtensions(profiles, out var claimed); - - if (profiles.All(p => p.Id != "data")) - { - var data = BuiltInData.DataProfile(); - // Only the extensions no donut of the user takes yet. - data.Extensions = data.Extensions.Where(claimed.Add).ToList(); - if (data.Extensions.Count > 0) - { - var presetIds = presets.Select(p => p.Id).ToHashSet(); - data.PresetIds = data.PresetIds.Where(presetIds.Contains).ToList(); - var fonts = profiles.FindIndex(p => p.Id == "fonts"); - var insertAt = fonts >= 0 ? fonts + 1 : profiles.FindIndex(p => p.IsFallback); - profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, data); - changed = true; - } - } - - changed |= Offer(presets, profiles, "spreadsheets", "to-json", "to-csv"); - return changed; - } - - private static bool AddCodecsAndSubtitles(List presets, List profiles) - { - var changed = false; - foreach (var preset in BuiltInData.CodecAndSubtitlePresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - changed |= LearnExtensions(profiles, out var claimed); - - if (profiles.All(p => p.Id != "subtitles")) - { - var subtitles = BuiltInData.SubtitleProfile(); - subtitles.Extensions = subtitles.Extensions.Where(claimed.Add).ToList(); - if (subtitles.Extensions.Count > 0) - { - var ids = presets.Select(p => p.Id).ToHashSet(); - subtitles.PresetIds = subtitles.PresetIds.Where(ids.Contains).ToList(); - var audio = profiles.FindIndex(p => p.Id == "audio"); - var insertAt = audio >= 0 ? audio + 1 : profiles.FindIndex(p => p.IsFallback); - profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, subtitles); - changed = true; - } - } - - changed |= Offer(presets, profiles, "video", "mp4-hevc", "mp4-720"); - changed |= Offer(presets, profiles, "video", "extract-subtitles", "to-mp3"); - return changed; - } - - /// - /// Adds the default extensions of the built-in donuts that no donut of the user takes yet. - /// - /// Every extension a donut takes afterwards. - /// Learn only these (the ones a version added), not others the user removed from a donut since. - private static bool LearnExtensions(List profiles, out HashSet claimed, IReadOnlyCollection? only = null) - { - var changed = false; - // Extensions checked on a mixed-selection profile only limit that profile; they don't claim files. - claimed = profiles.Where(p => !p.IsFallback).SelectMany(p => p.Extensions).ToHashSet(StringComparer.OrdinalIgnoreCase); - var defaults = BuiltInData.CreateProfiles().ToDictionary(p => p.Id); - foreach (var profile in profiles.Where(p => !p.IsFallback && defaults.ContainsKey(p.Id))) - { - foreach (var extension in defaults[profile.Id].Extensions.Where(e => only is null || only.Contains(e)).Where(claimed.Add)) - { - profile.Extensions.Add(extension); - changed = true; - } - } - return changed; - } - - private static bool AddModels(List presets, List profiles) - { - var changed = false; - foreach (var preset in BuiltInData.ModelPresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - changed |= LearnExtensions(profiles, out var claimed); - if (profiles.Any(p => p.Id == "models")) - return changed; - - var models = BuiltInData.ModelProfile(); - // Only the extensions no donut of the user takes yet. - models.Extensions = models.Extensions.Where(claimed.Add).ToList(); - if (models.Extensions.Count == 0) - return changed; - var ids = presets.Select(p => p.Id).ToHashSet(); - models.PresetIds = models.PresetIds.Where(ids.Contains).ToList(); - var after = profiles.FindIndex(p => p.Id == "data"); - if (after < 0) - after = profiles.FindIndex(p => p.Id == "fonts"); - var insertAt = after >= 0 ? after + 1 : profiles.FindIndex(p => p.IsFallback); - profiles.Insert(insertAt < 0 ? profiles.Count : insertAt, models); - return true; - } - - private static bool AddCatalog(List presets, List profiles) - { - var changed = false; - foreach (var preset in BuiltInData.CatalogPresets().Where(p => presets.All(existing => existing.Id != p.Id))) - { - presets.Add(preset); - changed = true; - } - var presetIds = presets.Select(p => p.Id).ToHashSet(); - changed |= LearnExtensions(profiles, out var claimed); - - var text = profiles.FindIndex(p => p.Id == "text"); - var insertAt = text >= 0 ? text + 1 : profiles.FindIndex(p => p.IsFallback); - insertAt = insertAt < 0 ? profiles.Count : insertAt; - foreach (var profile in BuiltInData.CatalogProfiles().Where(p => profiles.All(existing => existing.Id != p.Id))) - { - // Only the extensions no donut of the user takes yet. - profile.Extensions = profile.Extensions.Where(claimed.Add).ToList(); - if (profile.Extensions.Count == 0) - continue; - profile.PresetIds = profile.PresetIds.Where(presetIds.Contains).ToList(); - profiles.Insert(insertAt++, profile); - changed = true; - } - - changed |= Offer(presets, profiles, "pdf", "to-docx", "to-jpg"); - changed |= Offer(presets, profiles, profiles.FirstOrDefault(p => p.IsFallback)?.Id ?? "mixed", "zip-all", "merge-pdf"); - return changed; - } - - /// Presets that settings schema 7 puts on the built-in donuts: (donut, preset, after which preset). - private static readonly (string Profile, string Preset, string After)[] Version7Offers = - [ - ("pdf", "to-md", "to-txt"), ("pdf", "to-epub", "to-md"), ("pdf", "to-hwp", "to-hwpx"), - ("office", "to-hwp", "to-hwpx"), ("office", "to-png", "to-epub"), - ("hwp", "to-hwp", "to-hwpx"), ("hwp", "to-jpg", "to-png"), ("hwp", "to-epub", "to-md"), - ("spreadsheets", "to-hwp", "to-hwpx"), ("presentations", "to-hwp", "to-hwpx"), - ("text", "to-hwp", "to-hwpx"), ("ebooks", "to-hwp", "to-hwpx"), - ]; - - /// - /// Puts a preset on a donut after (or at the end), unless the donut is missing, full or - /// has it already. Returns true when it was added. - /// - private static bool Offer(List presets, List profiles, string profileId, string presetId, string? after) - { - if (profiles.FirstOrDefault(p => p.Id == profileId) is not { } profile - || profile.PresetIds.Contains(presetId) || profile.PresetIds.Count >= ToolbarProfile.MaxSlices - || presets.All(p => p.Id != presetId)) - return false; - var index = after is null ? -1 : profile.PresetIds.IndexOf(after); - profile.PresetIds.Insert(index < 0 ? profile.PresetIds.Count : index + 1, presetId); - return true; - } - - private static void InsertBefore(List list, string id, string before) - { - if (list.Contains(id)) - return; - var index = list.IndexOf(before); - if (index < 0) - list.Add(id); - else - list.Insert(index, id); - } -} diff --git a/src/Filee.Engines/Archives/ArchiveConverter.cs b/src/Filee.Engines/Archives/ArchiveConverter.cs index af277d170..8f821401e 100644 --- a/src/Filee.Engines/Archives/ArchiveConverter.cs +++ b/src/Filee.Engines/Archives/ArchiveConverter.cs @@ -11,7 +11,7 @@ namespace Filee.Engines.Archives; /// The archive engine. -public sealed class ArchiveConverter : IConverter, IFileCombiner +public sealed class ArchiveConverter : IConverter, IFileCombiner, IHasOwnTimeout { /// Archive formats Filee writes (plus ). private static readonly string[] ArchiveTargets = ["zip", "7z", "tar", "tgz", "tbz2", "txz"]; diff --git a/src/Filee.Engines/Ebooks/CalibreConverter.cs b/src/Filee.Engines/Ebooks/CalibreConverter.cs index 931dc21ab..c417d983d 100644 --- a/src/Filee.Engines/Ebooks/CalibreConverter.cs +++ b/src/Filee.Engines/Ebooks/CalibreConverter.cs @@ -17,7 +17,7 @@ namespace Filee.Engines.Ebooks; /// Conversions through calibre's ebook-convert. -public sealed partial class CalibreConverter : IConverter +public sealed partial class CalibreConverter : IConverter, IHasOwnTimeout { private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(10); diff --git a/src/Filee.Engines/Filee.Engines.csproj b/src/Filee.Engines/Filee.Engines.csproj index b3121143c..d346cbbcf 100644 --- a/src/Filee.Engines/Filee.Engines.csproj +++ b/src/Filee.Engines/Filee.Engines.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs index 513f3967a..56cd17bc8 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs @@ -251,12 +251,27 @@ void Add(Dictionary> source, List target) Add(footers, section.Footers); // "Different first page": 한글 can only hide the header/footer on the first page, which is what an empty - // (or missing) first-page header means in Word. + // (or missing) first-page header means in Word. A first-page header with its own content (a letterhead, #416) + // can't be a header there: the regular one is hidden on that page, and the first-page header is a text box at + // the header's place, in front of the empty header area, rather than the regular header showing instead. + List? firstPageHeader = null; if (sectPr?.Element(W + "titlePg") is { } titlePg && Val(titlePg) is not ("0" or "false")) { - section.HideFirstHeader = IsBlank(headers.GetValueOrDefault("first")); + var firstHeader = headers.GetValueOrDefault("first"); + section.HideFirstHeader = IsBlank(firstHeader) || !SameText(firstHeader, headers.GetValueOrDefault("default")); + if (!IsBlank(firstHeader) && section.HideFirstHeader) + firstPageHeader = firstHeader; section.HideFirstFooter = IsBlank(footers.GetValueOrDefault("first")); } + + // Word pushes the body down when the header is taller than the space the margins leave it (#416); 한글 keeps the + // header area as set, and the header runs into the body. The area grows to the header's estimated height. + var tallest = section.Headers.Select(h => EstimatedHeight(h.Blocks, page.TextWidth)) + .Append(firstPageHeader is null ? 0 : EstimatedHeight(firstPageHeader, page.TextWidth)).Max(); + if (tallest > page.Header) + section.Page = page with { Header = tallest }; + if (firstPageHeader is not null) + section.Blocks.Insert(0, FirstPageHeaderBox(firstPageHeader, section.Page!)); if (int.TryParse(Attr(sectPr?.Element(W + "pgNumType"), "start"), out var start)) section.StartPageNumber = start; @@ -285,6 +300,73 @@ private List HeaderFooterBlocks(XElement reference) return blocks; } + /// + /// A tiny paragraph holding in a borderless text box at the header's place on the page: + /// Word's first-page header, which 한글 has no header type for. + /// + private static HParagraph FirstPageHeaderBox(List blocks, HPage page) + { + var box = new HTextBox + { + Width = page.TextWidth, + Height = page.Header, + Anchor = new HAnchor("PAPER", "LEFT", page.Left + page.Gutter, "PAPER", "TOP", page.Top, "IN_FRONT_OF_TEXT", AllowOverlap: true), + Line = HBorder.None, + Padding = new HInsets(0, 0, 0, 0), + }; + box.Blocks.AddRange(blocks); + var holder = new HParagraph { MarkFormat = new HCharFormat(Size: 100) }; + holder.Inlines.Add(box); + return holder; + } + + private static bool SameText(List? a, List? b) => + a is not null && b is not null && Text(a) == Text(b); + + private static string Text(List blocks) => + string.Join("\n", blocks.OfType().Select(p => HDocumentWalker.Text(p.Inlines))); + + /// + /// A generous estimate of how tall lay out (HWPX units), from their font sizes, line + /// spacing, line count at about half an em per character, paragraph spacing, inline pictures and table rows. Only + /// used to grow a header area that is too small: a little too much just leaves some space. + /// + internal static int EstimatedHeight(IEnumerable blocks, int textWidth) + { + const double defaultSize = 10; // points + double total = 0; + foreach (var block in blocks) + { + switch (block) + { + case HParagraph paragraph: + var texts = paragraph.Inlines.OfType().ToList(); + var size = texts.Select(t => (t.Format.Size ?? defaultSize * 100) / 100.0).DefaultIfEmpty(defaultSize).Max(); + var perLine = Math.Max(1, textWidth / 100.0 / (size * 0.5)); + var lines = Math.Max(1, Math.Ceiling(texts.Sum(t => t.Text.Length) / perLine) + + paragraph.Inlines.Count(i => i is HLineBreak)); + var lineHeight = paragraph.Format.LineSpacing switch + { + { Kind: HLineSpacingKind.Percent, Value: var percent } => size * percent / 100.0, + { Kind: HLineSpacingKind.Fixed, Value: var fixedHeight } => fixedHeight / 100.0, + { Kind: HLineSpacingKind.AtLeast, Value: var least } => Math.Max(least / 100.0, size * 1.3), + _ => size * 1.6, // 한글's default line spacing + }; + var pictures = paragraph.Inlines.OfType().Where(i => i.Anchor is null).Select(i => (i.Height ?? 0) / 100.0) + .DefaultIfEmpty(0).Max(); + total += Math.Max(lines * lineHeight, pictures) + + (paragraph.Format.Before ?? 0) / 100.0 + (paragraph.Format.After ?? 0) / 100.0; + break; + case HTable table: + total += table.Rows.Sum(r => Math.Max((r.Height ?? 0) / 100.0, + r.Cells.Select(c => EstimatedHeight(c.Blocks, textWidth / Math.Max(1, table.ColumnCount)) / 100.0).DefaultIfEmpty(0).Max())); + break; + } + } + // A few points between the header and the body, as Word leaves. + return (int)Math.Round((total + 4) * 100); + } + private static bool IsBlank(List? blocks) => blocks is null || blocks.All(b => b is HParagraph p && p.Inlines.All(i => i is HText t && string.IsNullOrWhiteSpace(t.Text) || i is HBookmark)); diff --git a/src/Filee.Engines/Hwp/Hwpx/MarkdownReader.cs b/src/Filee.Engines/Hwp/Hwpx/MarkdownReader.cs index aa1d9fde3..9e59a75cf 100644 --- a/src/Filee.Engines/Hwp/Hwpx/MarkdownReader.cs +++ b/src/Filee.Engines/Hwp/Hwpx/MarkdownReader.cs @@ -4,6 +4,9 @@ // superscript / inserted / marked text, code, lists (1. / a. / i. / A. / I. with the start number, nested levels), // task lists, block quotes, pipe and grid tables with spans, definition lists, links, images, footnotes and math. // The YAML front matter's "title" becomes the document title. Raw HTML is skipped, except
. +// Code blocks and block quotes are one-cell boxes, as Markdown renderers show them (#406): code shaded and framed in +// a monospace font without paragraph spacing, quotes with a bar on the left. Blocks inside a list item line up with +// the item's text. using Markdig; using Markdig.Extensions.DefinitionLists; @@ -31,6 +34,27 @@ internal sealed class MarkdownReader private static readonly HCharFormat Plain = default; private static readonly HCharFormat CodeFormat = new(Shade: "#F1F0F5"); + + /// + /// Code block text: a monospace font at 9 pt. Where Consolas isn't installed, rhwp draws it with the installed + /// monospace fallback (RhwpInput.PdfOptions). + /// + private static readonly HCharFormat CodeBlockFormat = new(Font: "Consolas", Size: 900); + + /// Code block lines: no space between them, a little more than single line spacing. + private static readonly HParaFormat CodeLineFormat = new(Align: HAlign.Left, Before: 0, After: 0, + LineSpacing: new HLineSpacing(HLineSpacingKind.Percent, 130)); + + private const string CodeFill = "#F6F8FA"; + private static readonly HBorder CodeFrame = new(HBorderStyle.Solid, 0.12, "#D0D7DE"); + private const string QuoteFill = "#F7F7F9"; + private static readonly HBorder QuoteBar = new(HBorderStyle.Solid, 1.0, "#C9CED6"); + + /// Padding inside code and quote boxes (HWP units: 1.5 mm left and right, 1 mm top and bottom). + private static readonly HInsets BoxPadding = new(425, 425, 283, 283); + + /// Indent per list level of blocks inside a list item, so they line up with the item's text. + private const int ListIndent = 2000; private readonly string _baseFolder; private readonly string? _resourceRoot; @@ -83,13 +107,13 @@ private void Block(Block block, List output, Context context) List(list, output, context); break; case QuoteBlock quote: - Blocks(quote, output, context with { Indent = context.Indent + QuoteIndent }); + output.Add(QuoteBox(quote, context)); break; case MathBlock math: CodeLines(math, output, context, Plain with { Italic = true }); break; case CodeBlock code: - CodeLines(code, output, context, CodeFormat); + output.Add(CodeBox(code, context)); break; case ThematicBreakBlock: output.Add(NewParagraph(context)); @@ -140,6 +164,54 @@ private static void CodeLines(LeafBlock code, List output, Context conte } } + /// + /// A code block as one shaded, framed cell (#406): every line its own paragraph in a monospace font, without + /// paragraph spacing, so the block reads as one piece rather than a stack of paragraphs. + /// + private static HTable CodeBox(LeafBlock code, Context context) + { + var cell = new HCell + { + Fill = CodeFill, + Borders = new HBorders(CodeFrame, CodeFrame, CodeFrame, CodeFrame), + }; + foreach (var line in code.Lines.ToString().Replace("\r\n", "\n").TrimEnd('\n').Split('\n')) + { + var paragraph = new HParagraph { Format = CodeLineFormat }; + if (line.Length > 0) + paragraph.Inlines.Add(new HText(line, CodeBlockFormat)); + cell.Blocks.Add(paragraph); + } + return Box(cell, context); + } + + /// A block quote as one cell with a bar on its left and a light background (#406). + private HTable QuoteBox(QuoteBlock quote, Context context) + { + var cell = new HCell + { + Fill = QuoteFill, + Borders = new HBorders(QuoteBar, HBorder.None, HBorder.None, HBorder.None), + }; + // Inside the box the text starts at its left edge; nested quotes get a box of their own. + Blocks(quote, cell.Blocks, context with { Indent = 0 }); + if (cell.Blocks.Count == 0) + cell.Blocks.Add(new HParagraph()); + return Box(cell, context); + } + + /// A one-cell table without outer borders holding , at the context's indent. + private static HTable Box(HCell cell, Context context) + { + var none = new HBorders(HBorder.None, HBorder.None, HBorder.None, HBorder.None); + // The padding is the table's: a margin all cells share reads back as the table's (HwpxReader). + var table = new HTable { ColumnCount = 1, Indent = context.Indent, Borders = none, CellMargin = BoxPadding }; + var row = new HRow(); + row.Cells.Add(cell); + table.Rows.Add(row); + return table; + } + private static HNumbering Numbering(ListBlock list) { string[] bullets = ["●", "○", "■"]; @@ -191,7 +263,8 @@ private void List(ListBlock list, List output, Context context) List(nested, output, context with { ListLevel = context.ListLevel + 1 }); break; default: - Block(block, output, context); + // A code block, quote or table of the item lines up with its text (#406). + Block(block, output, context with { Indent = context.Indent + ListIndent * (context.ListLevel + 1) }); first = false; break; } diff --git a/src/Filee.Engines/Hwp/RhwpConverter.cs b/src/Filee.Engines/Hwp/RhwpConverter.cs index 5cc9f8b91..c3a23624f 100644 --- a/src/Filee.Engines/Hwp/RhwpConverter.cs +++ b/src/Filee.Engines/Hwp/RhwpConverter.cs @@ -21,7 +21,7 @@ namespace Filee.Engines.Hwp; /// Renders HWP and HWPX documents to PDF and converts between the two 한글 formats. -public sealed class RhwpConverter : IConverter, ITextShapingCheck +public sealed class RhwpConverter : IConverter, ITextShapingCheck, IHasOwnTimeout { // rhwp renders text slowly: about 1 s per KB of section XML (some 4 s per page of a novel) with rhwp 0.8.7 on an // i7-1185G7, so a 640-page book takes some 48 minutes; export-hwpx and convert take seconds for the same book. The diff --git a/src/Filee.Engines/Infrastructure/CopyVersions.cs b/src/Filee.Engines/Infrastructure/CopyVersions.cs index b8c942988..5c0fe5440 100644 --- a/src/Filee.Engines/Infrastructure/CopyVersions.cs +++ b/src/Filee.Engines/Infrastructure/CopyVersions.cs @@ -65,7 +65,12 @@ public static bool IsOutdated(string id, string folder) => private static string Read(string id, string program) { if (id == "libreoffice" && OperatingSystem.IsWindows()) - return File.ReadAllText(program); + { + // LibreOffice 26 dropped MsiProductVersion from version.ini; soffice.exe next to it still says it. + var soffice = Path.Combine(Path.GetDirectoryName(program)!, "soffice.exe"); + var product = File.Exists(soffice) ? FileVersionInfo.GetVersionInfo(soffice).ProductVersion : null; + return File.ReadAllText(program) + (product is null ? "" : $"\nLibreOffice {product}"); + } var arguments = id == "ffmpeg" ? "-hide_banner -version" : "--version"; try { diff --git a/src/Filee.Engines/Infrastructure/EngineInstaller.cs b/src/Filee.Engines/Infrastructure/EngineInstaller.cs index 7b7b50f2a..b60d35829 100644 --- a/src/Filee.Engines/Infrastructure/EngineInstaller.cs +++ b/src/Filee.Engines/Infrastructure/EngineInstaller.cs @@ -369,6 +369,14 @@ private static async Task UnpackMsiAsync(string id, string msi, string target, s _ => $"Unpacking {id} failed (msiexec exit code {process.ExitCode}, log: {log}).", }); } + // The verbose log is only worth keeping when unpacking failed (the error names it); else it would pile up in %TEMP%. + try + { + File.Delete(log); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + } } /// diff --git a/src/Filee.Engines/Infrastructure/ProcessRunner.cs b/src/Filee.Engines/Infrastructure/ProcessRunner.cs index d3671a806..7d7daa145 100644 --- a/src/Filee.Engines/Infrastructure/ProcessRunner.cs +++ b/src/Filee.Engines/Infrastructure/ProcessRunner.cs @@ -11,6 +11,9 @@ public sealed record ProcessResult(int ExitCode, string StandardOutput, string S /// Helper for starting external tools safely. public static class ProcessRunner { + /// How long output may take to drain after the program exited. + private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(30); + /// /// Starts with the given arguments (no shell, arguments are escaped), /// waits for it to exit and kills the whole process tree on timeout or cancellation. @@ -68,8 +71,14 @@ public static async Task RunAsync( throw new TimeoutException($"{Path.GetFileName(executable)} did not finish within {timeout.TotalSeconds:0} s."); } - // Make sure the async output readers have drained. - process.WaitForExit(); + // Make sure the async output readers have drained. A child the program left running can keep the pipes open + // (soffice.bin after soffice.exe exits), so stop waiting after a while rather than block the conversion forever: + // the program has exited, only the rest of its output is lost. + var drained = Task.Run(() => process.WaitForExit(), CancellationToken.None); + _ = drained.ContinueWith(t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + if (await Task.WhenAny(drained, Task.Delay(DrainTimeout, CancellationToken.None)) != drained) + lock (stderr) + stderr.AppendLine($"(output not complete: a process {Path.GetFileName(executable)} started still holds it)"); AppLocalRuntime.ThrowIfMissing(executable, process.ExitCode); return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); } diff --git a/src/Filee.Engines/Infrastructure/SecurityAndLimits.cs b/src/Filee.Engines/Infrastructure/SecurityAndLimits.cs new file mode 100644 index 000000000..afeee334d --- /dev/null +++ b/src/Filee.Engines/Infrastructure/SecurityAndLimits.cs @@ -0,0 +1,30 @@ +// Applies Settings → Security and limits to the engines and the job queue. Shared by the app (at start-up and whenever +// the settings are saved) and the command line, so both convert under the same limits. + +using Filee.Core.Conversion; +using Filee.Core.Settings; +using Filee.Engines.Magick; +using Filee.Engines.Office; + +namespace Filee.Engines.Infrastructure; + +/// Puts the security and limit settings into effect. +public static class SecurityAndLimits +{ + private static readonly ActiveContentScanner Scanner = new(); + + /// + /// Applies : ImageMagick's limits (process-wide), files at once and the step time limit of + /// , and what it does with documents that carry active content. + /// + public static void Apply(AppSettings settings, JobQueue queue) + { + var limits = settings.Limits ?? new LimitSettings(); + MagickLimits.Apply(limits.EffectiveMaxImageMegapixels, limits.EffectiveMaxImageSide, + limits.EffectiveMaxImageMemoryMb, limits.EffectiveMaxImageDiskMb); + queue.ParallelFiles = limits.EffectiveParallelFiles; + queue.StepTimeout = limits.EffectiveStepTimeout; + queue.ActiveContentScanner ??= Scanner; + queue.ActiveContentPolicy = (settings.Security ?? new SecuritySettings()).ActiveContent; + } +} diff --git a/src/Filee.Engines/Magick/ImageReader.cs b/src/Filee.Engines/Magick/ImageReader.cs index d88b51858..d3b755df2 100644 --- a/src/Filee.Engines/Magick/ImageReader.cs +++ b/src/Filee.Engines/Magick/ImageReader.cs @@ -1,10 +1,12 @@ // Reads source images with ImageMagick, handling the formats that need more than "open the file": camera RAW -// (LibRaw settings), layered PSD/PSB/XCF (one flattened picture instead of one file per layer) and Windows +// (LibRaw settings), layered PSD/PSB/XCF (one flattened picture instead of one file per layer; a PSD whose composite +// lacks its layers' transparency is composed from the layers) and Windows // metafiles (rendered at the preset's DPI), APNG (Filee's own reader composes the frames) and uncompressed DICOM // (Filee's own reader, as ImageMagick's Q8 build reads only 8-bit DICOM), FITS (Filee's own reader, stretched and // debayered as the preset says) and the photo of an iPhone LIVP. Used by every engine that takes a raster image as input. using System.Buffers.Binary; +using Filee.Core.Conversion; using Filee.Core.Presets; using ImageMagick; using ImageMagick.Formats; @@ -34,10 +36,17 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr var images = new MagickImageCollection(); var settings = Settings(formatId, preset); + // Settings → Security and limits: a picture too large is refused from its header, before it is decoded. + MagickLimits.Check(path, formatId, settings); try { images.Read(path, settings); } + catch (MagickResourceLimitErrorException ex) + { + images.Dispose(); + throw new ConversionRefusedException("error.image_too_large", ex.Message); + } catch (MagickException ex) when (formatId == "dcm") { images.Dispose(); @@ -58,6 +67,15 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr throw; } + // #468: Photoshop's composite (frame 0) can lack the transparency its layers have (a file saved without it in + // the merged image): then the layers are composed onto a transparent canvas, as Photoshop shows them. + if (formatId is "psd" or "psb" && images.Count == 1 && !images[0].HasAlpha + && FlattenPsdLayers(path, formatId, images[0].Width, images[0].Height) is { } layered) + { + images.Dispose(); + return [layered]; + } + // The metafile was rendered at this density, but ImageMagick doesn't record it on the image: without it, // an EMF placed in a PDF would come out at 96 dpi instead of its real size. if (formatId is "emf" or "wmf" && settings.Density is { } density) @@ -150,6 +168,29 @@ internal static MagickReadSettings Settings(string formatId, Preset preset) /// their offsets, blend mode as compose operator and hidden layers marked , but /// not the canvas size, which comes from the file header. /// + /// + /// The visible layers of a PSD / PSB composed onto a transparent × + /// canvas at their offsets, with their blend modes. Null when the file has no layers, or none of them has + /// transparency: then the composite is right. + /// + internal static MagickImage? FlattenPsdLayers(string path, string formatId, uint width, uint height) + { + using var all = new MagickImageCollection(); + all.Read(path, new MagickReadSettings { Format = formatId == "psb" ? MagickFormat.Psb : MagickFormat.Psd }); + if (all.Count < 2 || !all.Skip(1).Any(layer => layer.HasAlpha)) + return null; + var canvas = new MagickImage(MagickColors.Transparent, width, height); + foreach (var layer in all.Skip(1)) + { + if (layer.Compose == CompositeOperator.No) + continue; // hidden layer + var compose = layer.Compose == CompositeOperator.Undefined ? CompositeOperator.Over : layer.Compose; + canvas.Composite(layer, layer.Page.X, layer.Page.Y, compose); + } + canvas.Page = new MagickGeometry(0, 0, width, height); + return canvas; + } + private static MagickImage FlattenXcf(MagickImageCollection layers, (uint Width, uint Height)? canvasSize) { var (width, height) = canvasSize ?? (layers[0].Page.Width is > 0 ? layers[0].Page.Width : layers[0].Width, diff --git a/src/Filee.Engines/Magick/MagickLimits.cs b/src/Filee.Engines/Magick/MagickLimits.cs new file mode 100644 index 000000000..7c680665b --- /dev/null +++ b/src/Filee.Engines/Magick/MagickLimits.cs @@ -0,0 +1,77 @@ +// Settings → Security and limits for pictures: the largest picture Filee opens (megapixels, checked from the file's +// header before anything is decoded, so an "image bomb" of a few kilobytes that unpacks to gigapixels is refused), the +// longest side, and how much memory and disk ImageMagick may use. ImageMagick's own limits are process-wide. + +using Filee.Core.Conversion; +using ImageMagick; + +namespace Filee.Engines.Magick; + +/// Resource limits for ImageMagick and the picture size check. +public static class MagickLimits +{ + // ImageMagick's defaults, to go back to when a limit is set to "automatic" again. + private static readonly ulong DefaultWidth = ResourceLimits.Width; + private static readonly ulong DefaultHeight = ResourceLimits.Height; + private static readonly ulong DefaultMemory = ResourceLimits.Memory; + private static readonly ulong DefaultDisk = ResourceLimits.Disk; + + /// Formats whose size is chosen when they are drawn (the preset's DPI), not stored in the file. + private static readonly HashSet Drawn = new(StringComparer.OrdinalIgnoreCase) + { + "svg", "svgz", "pdf", "eps", "ps", "ai", "emf", "wmf", "emz", "wmz", + }; + + /// Largest picture opened, in megapixels; 0 for no limit. + public static int MaxMegapixels { get; private set; } + + /// Applies the limits (each 0 for ImageMagick's default, or no limit of Filee's own). + public static void Apply(int maxMegapixels, int maxSide, int memoryMb, int diskMb) + { + MaxMegapixels = Math.Max(0, maxMegapixels); + ResourceLimits.Width = maxSide > 0 ? (ulong)maxSide : DefaultWidth; + ResourceLimits.Height = maxSide > 0 ? (ulong)maxSide : DefaultHeight; + ResourceLimits.Memory = memoryMb > 0 ? (ulong)memoryMb << 20 : DefaultMemory; + ResourceLimits.Disk = diskMb > 0 ? (ulong)diskMb << 20 : DefaultDisk; + } + + /// + /// Refuses when one of its pictures is larger than , reading only + /// the header. A file whose header can't be read is left to the reader, which reports it in its own words. + /// + /// error.image_too_large. + public static void Check(string path, string formatId, IMagickReadSettings? settings = null) => + Check(path, formatId, settings, MaxMegapixels); + + /// with a given limit (tests: the real one is process-wide). + internal static void Check(string path, string formatId, IMagickReadSettings? settings, int maxMegapixels) + { + if (maxMegapixels <= 0 || Drawn.Contains(formatId)) + return; + IEnumerable pictures; + try + { + pictures = (settings is null ? MagickImageInfo.ReadCollection(path) : MagickImageInfo.ReadCollection(path, settings)).ToList(); + } + catch (MagickException) + { + return; + } + foreach (var picture in pictures) + CheckSize(picture.Width, picture.Height, maxMegapixels); + } + + /// Refuses a picture of × over . + /// error.image_too_large. + public static void CheckSize(ulong width, ulong height) => CheckSize(width, height, MaxMegapixels); + + internal static void CheckSize(ulong width, ulong height, int maxMegapixels) + { + if (maxMegapixels <= 0) + return; + var megapixels = (double)width * height / 1_000_000; + if (megapixels > maxMegapixels) + throw new ConversionRefusedException("error.image_too_large", + $"{width} × {height} = {megapixels:0} MP > {maxMegapixels} MP"); + } +} diff --git a/src/Filee.Engines/Media/FfmpegConverter.cs b/src/Filee.Engines/Media/FfmpegConverter.cs index ddd5a6f0d..e7e4d1f75 100644 --- a/src/Filee.Engines/Media/FfmpegConverter.cs +++ b/src/Filee.Engines/Media/FfmpegConverter.cs @@ -24,7 +24,7 @@ namespace Filee.Engines.Media; /// Converts video and audio files with Filee's own copy of FFmpeg. /// Receives which hardware encoders work and why a hardware encode was redone in software. -public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer, IMediaJoiner +public sealed partial class FfmpegConverter(ILogger? logger = null) : IConverter, IImageTransformer, IMediaJoiner, IHasOwnTimeout { private static readonly ConcurrentDictionary Versions = new(); diff --git a/src/Filee.Engines/Ocr/TesseractConverter.cs b/src/Filee.Engines/Ocr/TesseractConverter.cs index cd504ab30..139ba75c2 100644 --- a/src/Filee.Engines/Ocr/TesseractConverter.cs +++ b/src/Filee.Engines/Ocr/TesseractConverter.cs @@ -26,7 +26,7 @@ namespace Filee.Engines.Ocr; /// Scans and pictures → searchable PDF through Tesseract. -public sealed class TesseractConverter : IConverter +public sealed class TesseractConverter : IConverter, IHasOwnTimeout { private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(10); diff --git a/src/Filee.Engines/Office/ActiveContentScanner.cs b/src/Filee.Engines/Office/ActiveContentScanner.cs new file mode 100644 index 000000000..38cc33845 --- /dev/null +++ b/src/Filee.Engines/Office/ActiveContentScanner.cs @@ -0,0 +1,496 @@ +// Finds active content in office documents before LibreOffice opens them: macros (and whether one starts by itself), +// script libraries, ActiveX controls, links to outside content, DDE fields and embedded objects. Read-only and quick: +// it looks at package parts and records, never lays out the document. +// +// * Office Open XML (DOCX/DOCM, XLSX/XLSM, PPTX/PPTM, …): macro parts by content type, VBA module names and their +// source (decompressed per MS-OVBA 2.4.1) for AutoOpen / Document_Open / Workbook_Open, ActiveX parts, every +// external relationship but plain hyperlinks (a remote template is a known way to pull in macros), DDE fields. +// * OpenDocument: Basic modules, Python / JavaScript / BeanShell libraries, event bindings ("on load" starts a macro by +// itself), links to outside files or addresses, DDE connections. Charts and other embedded objects are left out: +// almost every document with a chart has one. +// * Office 97–2003 (DOC, XLS): the VBA storage with its modules, and Excel 4.0 macro sheets (BOUNDSHEET records). +// PowerPoint 97–2003 keeps its VBA project inside the document stream and is not checked. +// * RTF: embedded OLE objects (\objdata) and links updated on load (\objupdate). +// +// LibreOffice's conversion mode doesn't run document macros; this check lets the user decide before a document that +// carries them is opened at all (see JobQueue.ScreenActiveContentAsync). + +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using Filee.Core.Conversion; +using OpenMcdf; + +namespace Filee.Engines.Office; + +/// Finds macros, ActiveX controls, external links, DDE fields and embedded objects in office documents. +public sealed partial class ActiveContentScanner : IActiveContentScanner +{ + /// Largest VBA project read (the whole storage is loaded into memory). + private const long MaxVbaProject = 64L << 20; + + /// How much of an RTF file is searched for embedded objects. + private const int MaxRtfChars = 32 << 20; + + /// Relationship types of plain hyperlinks: clicking one opens it, loading the document doesn't. + private static readonly string[] HyperlinkTypes = ["/hyperlink"]; + + private static readonly byte[] OleSignature = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + + /// + public ActiveContentReport? Scan(string path, string formatId) + { + var findings = new List(); + var head = ReadHead(path, 8); + if (head.Length >= 2 && head[0] == 'P' && head[1] == 'K') + { + using var zip = ZipFile.OpenRead(path); + if (zip.GetEntry("[Content_Types].xml") is not null) + ScanOoxml(zip, findings); + else if (IsOpenDocument(zip)) + ScanOpenDocument(zip, findings); + } + else if (head.AsSpan().SequenceEqual(OleSignature)) + { + ScanCompoundFile(path, findings); + } + else if (head.Length >= 5 && Encoding.ASCII.GetString(head, 0, 5) == "{\\rtf") + { + ScanRtf(path, findings); + } + var distinct = findings.Distinct().ToList(); + return distinct.Count == 0 ? null : new ActiveContentReport(path, distinct); + } + + private static byte[] ReadHead(string path, int count) + { + using var stream = File.OpenRead(path); + var buffer = new byte[count]; + var read = stream.ReadAtLeast(buffer, count, throwOnEndOfStream: false); + return buffer[..read]; + } + + // ───────────────────────── Office Open XML ───────────────────────── + + private static void ScanOoxml(ZipArchive zip, List findings) + { + foreach (var part in MacroParts(zip)) + { + if (part.EndsWith("vbaProject.bin", StringComparison.OrdinalIgnoreCase) && zip.GetEntry(part.TrimStart('/')) is { } vba) + { + if (vba.Length > MaxVbaProject) + { + findings.Add(new(ActiveContentKind.Macro, part)); + continue; + } + using var buffer = new MemoryStream(); + using (var entry = vba.Open()) + entry.CopyTo(buffer); + buffer.Position = 0; + using var root = RootStorage.Open(buffer, StorageModeFlags.LeaveOpen); + if (!AddVbaModules(root, findings)) + findings.Add(new(ActiveContentKind.Macro, part)); + } + else + { + // Excel 4.0 macro sheets and Word's VBA data: macros without a VBA project. + findings.Add(new(ActiveContentKind.Macro, part)); + } + } + + foreach (var entry in zip.Entries) + { + var name = entry.FullName; + if (name.Contains("/activeX/", StringComparison.OrdinalIgnoreCase) && name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + findings.Add(new(ActiveContentKind.ActiveX, Path.GetFileNameWithoutExtension(name))); + else if (name.Contains("/embeddings/", StringComparison.OrdinalIgnoreCase)) + findings.Add(new(ActiveContentKind.EmbeddedObject, Path.GetFileName(name))); + else if (name.EndsWith(".rels", StringComparison.OrdinalIgnoreCase)) + AddExternalRelationships(entry, findings); + else if (IsTextPart(name)) + AddDdeFields(entry, findings); + } + } + + /// Parts whose content type is a macro type (). + private static List MacroParts(ZipArchive zip) + { + var parts = new List(); + using var stream = zip.GetEntry("[Content_Types].xml")!.Open(); + using var reader = XmlReader.Create(stream, SafeXml); + var defaults = new Dictionary(StringComparer.OrdinalIgnoreCase); + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + continue; + var type = reader.GetAttribute("ContentType"); + if (type is null || !OoxmlConverter.MacroContentTypes.Contains(type)) + continue; + if (reader.LocalName == "Override" && reader.GetAttribute("PartName") is { } part) + parts.Add(part); + else if (reader.LocalName == "Default" && reader.GetAttribute("Extension") is { } extension) + defaults[extension] = type; + } + // A Default by extension ("bin" → vbaProject) covers every part with that extension. + parts.AddRange(zip.Entries + .Where(e => defaults.ContainsKey(Path.GetExtension(e.FullName).TrimStart('.'))) + .Select(e => "/" + e.FullName)); + return parts.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static void AddExternalRelationships(ZipArchiveEntry rels, List findings) + { + using var stream = rels.Open(); + using var reader = XmlReader.Create(stream, SafeXml); + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element || reader.LocalName != "Relationship" + || !string.Equals(reader.GetAttribute("TargetMode"), "External", StringComparison.OrdinalIgnoreCase)) + continue; + var type = reader.GetAttribute("Type") ?? ""; + if (HyperlinkTypes.Any(h => type.EndsWith(h, StringComparison.OrdinalIgnoreCase))) + continue; + var shortType = type[(type.LastIndexOf('/') + 1)..]; + findings.Add(new(ActiveContentKind.ExternalLink, shortType, reader.GetAttribute("Target"))); + } + } + + /// Document, header, footer, sheet and external-link parts: where DDE fields and links live. + private static bool IsTextPart(string name) => + name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + && (name.StartsWith("word/", StringComparison.OrdinalIgnoreCase) || name.StartsWith("xl/worksheets/", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("xl/externalLinks/", StringComparison.OrdinalIgnoreCase)); + + private static void AddDdeFields(ZipArchiveEntry part, List findings) + { + using var stream = part.Open(); + using var reader = XmlReader.Create(stream, SafeXml); + var inInstruction = false; + var formula = false; + while (reader.Read()) + { + switch (reader.NodeType) + { + case XmlNodeType.Element when reader.LocalName == "ddeLink": + findings.Add(new(ActiveContentKind.Dde, reader.GetAttribute("ddeService") ?? "DDE", + reader.GetAttribute("ddeTopic"))); + break; + case XmlNodeType.Element when reader.LocalName == "fldSimple" && reader.GetAttribute("w:instr") is { } simple: + AddDdeInstruction(simple, findings); + break; + case XmlNodeType.Element: + inInstruction = reader.LocalName == "instrText"; + formula = reader.LocalName == "f"; + break; + case XmlNodeType.Text when inInstruction: + AddDdeInstruction(reader.Value, findings); + break; + case XmlNodeType.Text when formula && DdeFormula().Match(reader.Value) is { Success: true } match: + findings.Add(new(ActiveContentKind.Dde, match.Groups[1].Value, match.Value)); + break; + case XmlNodeType.EndElement: + inInstruction = formula = false; + break; + } + } + } + + private static void AddDdeInstruction(string instruction, List findings) + { + var text = instruction.Trim(); + if (text.StartsWith("DDEAUTO", StringComparison.OrdinalIgnoreCase) || text.StartsWith("DDE ", StringComparison.OrdinalIgnoreCase)) + findings.Add(new(ActiveContentKind.Dde, text.Split(' ', 2)[0].ToUpperInvariant(), Shorten(text))); + } + + /// An Excel formula that asks another program for data: =cmd|'/c calc'!A0. + [GeneratedRegex(@"([A-Za-z0-9_.]+)\|'[^']*'!", RegexOptions.CultureInvariant)] + private static partial Regex DdeFormula(); + + // ───────────────────────── OpenDocument ───────────────────────── + + private static bool IsOpenDocument(ZipArchive zip) + { + if (zip.GetEntry("mimetype") is not { } mimetype) + return zip.GetEntry("META-INF/manifest.xml") is not null && zip.GetEntry("content.xml") is not null; + using var reader = new StreamReader(mimetype.Open(), Encoding.ASCII); + return reader.ReadToEnd().StartsWith("application/vnd.oasis.opendocument", StringComparison.Ordinal); + } + + private static void ScanOpenDocument(ZipArchive zip, List findings) + { + foreach (var entry in zip.Entries) + { + var parts = entry.FullName.Split('/'); + // Basic//.xml, next to the library's script-lb.xml. + if (parts is ["Basic", var library, var file] && file.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + && !file.StartsWith("script-l", StringComparison.OrdinalIgnoreCase) && !file.StartsWith("dialog-l", StringComparison.OrdinalIgnoreCase)) + findings.Add(new(ActiveContentKind.Macro, $"{library}.{Path.GetFileNameWithoutExtension(file)}")); + else if (parts is ["Scripts", var language, ..] && entry.Length > 0 && !entry.FullName.EndsWith('/')) + findings.Add(new(ActiveContentKind.Script, $"{language}: {string.Join('/', parts[2..])}")); + } + foreach (var name in new[] { "content.xml", "styles.xml" }) + { + if (zip.GetEntry(name) is { } part) + ScanOpenDocumentXml(part, findings); + } + } + + private static void ScanOpenDocumentXml(ZipArchiveEntry part, List findings) + { + using var stream = part.Open(); + using var reader = XmlReader.Create(stream, SafeXml); + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + continue; + switch (reader.LocalName) + { + case "event-listener": + var eventName = reader.GetAttribute("event-name", "urn:oasis:names:tc:opendocument:xmlns:script:1.0") ?? ""; + var macro = reader.GetAttribute("href", "http://www.w3.org/1999/xlink") + ?? reader.GetAttribute("macro-name", "urn:oasis:names:tc:opendocument:xmlns:script:1.0"); + var onLoad = eventName.EndsWith(":load", StringComparison.OrdinalIgnoreCase) + || eventName.Contains("load-finished", StringComparison.OrdinalIgnoreCase); + findings.Add(new(onLoad ? ActiveContentKind.AutoRunMacro : ActiveContentKind.Macro, eventName, macro)); + break; + case "dde-connection-decl" or "dde-link" or "dde-source": + findings.Add(new(ActiveContentKind.Dde, reader.GetAttribute("dde-application", "urn:oasis:names:tc:opendocument:xmlns:office:1.0") ?? reader.LocalName, + reader.GetAttribute("dde-topic", "urn:oasis:names:tc:opendocument:xmlns:office:1.0"))); + break; + default: + if (reader.GetAttribute("href", "http://www.w3.org/1999/xlink") is { } href && IsOutside(href) + && reader.LocalName is "image" or "object" or "object-ole" or "section-source" or "table-source" or "plugin" or "applet" or "frame") + findings.Add(new(ActiveContentKind.ExternalLink, reader.LocalName, Shorten(href))); + break; + } + } + } + + /// + /// An address outside the package: a web, FTP, SMB or file address, a network or drive path, or a path that leaves + /// the package (../). Parts of the package (Pictures/…, ./Object 1), data: pictures and + /// LibreOffice's internal references are not. + /// + internal static bool IsOutside(string href) => OutsideAddress().IsMatch(href); + + [GeneratedRegex(@"^(?:(?:https?|ftp|file|smb|webdav|dav)s?:|\\\\|//|\.\./|[A-Za-z]:[\\/])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex OutsideAddress(); + + // ───────────────────────── Office 97–2003 ───────────────────────── + + private static void ScanCompoundFile(string path, List findings) + { + using var root = RootStorage.OpenRead(path); + // Word keeps its VBA project in "Macros", Excel in "_VBA_PROJECT_CUR". + foreach (var name in new[] { "Macros", "_VBA_PROJECT_CUR" }) + { + if (root.TryOpenStorage(name, out var project) && !AddVbaModules(project, findings)) + findings.Add(new(ActiveContentKind.Macro, name)); + } + if (root.TryOpenStream("Workbook", out var workbook) || root.TryOpenStream("Book", out workbook)) + { + using (workbook) + AddExcel4MacroSheets(workbook, findings); + } + } + + /// + /// Excel 4.0 (XLM) macro sheets: BOUNDSHEET records whose sheet type is 1. They run without any VBA project and are + /// a common way to hide macros. + /// + internal static void AddExcel4MacroSheets(Stream workbook, List findings) + { + var header = new byte[4]; + while (workbook.ReadAtLeast(header, 4, throwOnEndOfStream: false) == 4) + { + var type = BitConverter.ToUInt16(header, 0); + var length = BitConverter.ToUInt16(header, 2); + if (type == 0x0085 && length >= 8) + { + var data = new byte[length]; + if (workbook.ReadAtLeast(data, length, throwOnEndOfStream: false) < length) + break; + if (data[5] == 0x01) + findings.Add(new(ActiveContentKind.Macro, "Excel 4.0 macro sheet", SheetName(data))); + } + else if (type == 0x000A) + { + break; // EOF of the workbook globals: every BOUNDSHEET comes before it. + } + else + { + workbook.Seek(length, SeekOrigin.Current); + } + } + + static string SheetName(byte[] data) + { + int count = data[6]; + var unicode = data.Length > 7 && (data[7] & 1) == 1; + var start = 8; + var bytes = Math.Min(unicode ? count * 2 : count, data.Length - start); + return bytes <= 0 ? "" : (unicode ? Encoding.Unicode : Encoding.Latin1).GetString(data, start, bytes); + } + } + + // ───────────────────────── VBA (MS-OVBA) ───────────────────────── + + /// Procedures Office runs by itself when a document opens, is created or closes. + [GeneratedRegex(@"\b(?:Sub|Function)\s+(AutoOpen|Auto_Open|AutoExec|AutoNew|AutoClose|Auto_Close|Document_Open|Document_New|Document_Close|DocumentOpen|Workbook_Open|Workbook_Activate|Workbook_BeforeClose|Auto_Activate)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex AutoRunProcedure(); + + /// + /// Adds one finding per VBA module of the project in (a storage holding "VBA"), with an + /// auto-run finding for every procedure Office starts by itself. False when the project can't be read. + /// + private static bool AddVbaModules(Storage storage, List findings) + { + if (!storage.TryOpenStorage("VBA", out var vba) || !vba.TryOpenStream("dir", out var dirStream)) + return false; + byte[] dir; + using (dirStream) + dir = Decompress(ReadAll(dirStream), 0); + var modules = ModuleOffsets(dir); + if (modules.Count == 0) + return false; + foreach (var (streamName, offset) in modules) + { + findings.Add(new(ActiveContentKind.Macro, streamName)); + if (!vba.TryOpenStream(streamName, out var moduleStream)) + continue; + string source; + using (moduleStream) + { + var bytes = ReadAll(moduleStream); + if (offset >= bytes.Length) + continue; + source = Encoding.Latin1.GetString(Decompress(bytes, (int)offset)); + } + foreach (Match match in AutoRunProcedure().Matches(source)) + findings.Add(new(ActiveContentKind.AutoRunMacro, match.Groups[1].Value, streamName)); + } + return true; + } + + /// Module stream name → offset of its compressed source, from the decompressed "dir" stream (MS-OVBA 2.3.4.2). + internal static List<(string Stream, uint Offset)> ModuleOffsets(byte[] dir) + { + var modules = new List<(string, uint)>(); + string? streamName = null; + var position = 0; + while (position + 6 <= dir.Length) + { + var id = BitConverter.ToUInt16(dir, position); + var size = BitConverter.ToUInt32(dir, position + 2); + // PROJECTVERSION says 4 but is followed by 6 bytes (MS-OVBA 2.3.4.2.1.11). + var length = id == 0x0009 ? 6u : size; + var data = position + 6; + if (data + length > dir.Length) + break; + if (id == 0x001A) + streamName = Encoding.Latin1.GetString(dir, data, (int)length); + else if (id == 0x0031 && length == 4 && streamName is not null) + modules.Add((streamName, BitConverter.ToUInt32(dir, data))); + position = data + (int)length; + } + return modules; + } + + /// Decompresses an MS-OVBA compressed container starting at (2.4.1). + internal static byte[] Decompress(byte[] data, int start) + { + var output = new List(data.Length * 2); + if (start >= data.Length || data[start] != 0x01) + return []; + var position = start + 1; + while (position + 2 <= data.Length) + { + var header = BitConverter.ToUInt16(data, position); + var chunkEnd = Math.Min(data.Length, position + (header & 0x0FFF) + 3); + var compressed = (header & 0x8000) != 0; + position += 2; + var chunkStart = output.Count; + if (!compressed) + { + var raw = Math.Min(4096, data.Length - position); + output.AddRange(data.AsSpan(position, raw).ToArray()); + position += raw; + continue; + } + while (position < chunkEnd) + { + var flags = data[position++]; + for (var bit = 0; bit < 8 && position < chunkEnd; bit++) + { + if ((flags & (1 << bit)) == 0) + { + output.Add(data[position++]); + continue; + } + if (position + 2 > chunkEnd) + { + position = chunkEnd; + break; + } + var token = BitConverter.ToUInt16(data, position); + position += 2; + var difference = output.Count - chunkStart; + var bitCount = 4; + while ((1 << bitCount) < difference && bitCount < 12) + bitCount++; + var lengthMask = 0xFFFF >> bitCount; + var copyLength = (token & lengthMask) + 3; + var offset = (token >> (16 - bitCount)) + 1; + var from = output.Count - offset; + if (from < 0) + return [.. output]; + for (var i = 0; i < copyLength; i++) + output.Add(output[from + i]); + } + } + } + return [.. output]; + } + + private static byte[] ReadAll(Stream stream) + { + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return buffer.ToArray(); + } + + // ───────────────────────── RTF ───────────────────────── + + private static void ScanRtf(string path, List findings) + { + using var reader = new StreamReader(path, Encoding.Latin1); + var buffer = new char[Math.Min(MaxRtfChars, (int)Math.Min(int.MaxValue, new FileInfo(path).Length))]; + var text = new string(buffer, 0, reader.ReadBlock(buffer, 0, buffer.Length)); + if (text.Contains(@"\objdata", StringComparison.Ordinal)) + findings.Add(new(ActiveContentKind.EmbeddedObject, @"\objdata", ObjectClass(text))); + if (text.Contains(@"\objupdate", StringComparison.Ordinal) || text.Contains(@"\objautlink", StringComparison.Ordinal)) + findings.Add(new(ActiveContentKind.ExternalLink, @"\objupdate")); + + // The class of the first object ("Equation.3", "Package"), when the file names it. + static string? ObjectClass(string text) + { + var match = Regex.Match(text, @"\\objclass\s+([^}\\]+)"); + return match.Success ? match.Groups[1].Value.Trim() : null; + } + } + + // ───────────────────────── Helpers ───────────────────────── + + /// No DTDs, no external entities: a document must not make the check itself load anything. + private static readonly XmlReaderSettings SafeXml = new() + { + DtdProcessing = DtdProcessing.Ignore, + XmlResolver = null, + IgnoreComments = true, + IgnoreWhitespace = true, + }; + + private static string Shorten(string text) => text.Length <= 200 ? text : text[..200] + "…"; +} diff --git a/src/Filee.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs index 663aff94c..e3779e5ab 100644 --- a/src/Filee.Engines/Office/LibreOfficeConverter.cs +++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs @@ -16,6 +16,8 @@ // * soffice reports no progress: ProgressEstimate keeps the progress ring moving while it runs. // * Its ZIP reader refuses some containers other readers open (Tencent Docs exports, issue #141), saying only "source // file could not be loaded": an Office Open XML / OpenDocument file is tried once more as a repacked copy. +// * Every profile is locked down before each run (LibreOfficeSecurity): macros never run, links are never updated. +// JobQueue checks documents for active content before a step through here (IOpensDocumentsInOfficeSuite). using Filee.Core.Conversion; using Filee.Core.Formats; @@ -29,7 +31,7 @@ namespace Filee.Engines.Office; /// Word processor, spreadsheet, presentation and drawing conversions (and HWP / HWPX) through LibreOffice. -public sealed class LibreOfficeConverter : IConverter +public sealed class LibreOfficeConverter : IConverter, IOpensDocumentsInOfficeSuite, IHasOwnTimeout { private const int Workers = 2; private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3); @@ -100,11 +102,23 @@ public EngineStatus GetStatus() return _soffice is null ? EngineStatus.Unavailable("engine.reason.not_installed") : !EngineEnvironment.UsesFileesCopy("libreoffice") - ? EngineStatus.Available(_soffice, _hasHwpFilter ? "H2Orestart" : null) + ? EngineStatus.Available(_soffice, OtherCopyVersion()) : EngineStatus.Available(_soffice, EngineVersions.Component("libreoffice") + (_hasHwpFilter ? $" · H2Orestart {EngineVersions.Component("h2orestart")}" : "")); } + /// + /// "26.8 · H2Orestart" for a LibreOffice Filee didn't download. The version only on Windows: there it is read from + /// files (cached); elsewhere it would mean starting LibreOffice. + /// + private string? OtherCopyVersion() + { + var version = OperatingSystem.IsWindows() && Path.GetDirectoryName(_soffice) is { } folder + ? CopyVersions.Installed("libreoffice", folder)?.ToString(2) + : null; + return !_hasHwpFilter ? version : version is null ? "H2Orestart" : $"{version} · H2Orestart"; + } + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) { var soffice = _soffice ?? throw new InvalidOperationException("LibreOffice was not found."); @@ -273,8 +287,11 @@ public async Task WarmUpAsync(CancellationToken cancellationToken = default private string ProfileDirectory(int slot) => Path.Combine(_env.DataDirectory, "libreoffice-profiles", $"worker-{slot}"); - /// LibreOffice has started with this profile before (it creates the "user" folder). - private bool IsPrepared(int slot) => Directory.Exists(Path.Combine(ProfileDirectory(slot), "user")); + /// + /// LibreOffice has started with this profile before. It creates "user/config"; the "user" folder alone may only hold + /// the settings wrote before the first start. + /// + private bool IsPrepared(int slot) => Directory.Exists(Path.Combine(ProfileDirectory(slot), "user", "config")); private async Task TakeProfileAsync(CancellationToken cancellationToken) { @@ -294,6 +311,16 @@ private async Task RunAsync(string soffice, int slot, string filt { var profileDir = ProfileDirectory(slot); Directory.CreateDirectory(profileDir); + try + { + LibreOfficeSecurity.Apply(profileDir); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or System.Xml.XmlException) + { + // Defense in depth only: the conversion mode doesn't run macros either way. A profile whose settings file + // can't be read or written still converts, and the log says why it wasn't locked down. + _logger.LogWarning("LibreOffice profile {Profile} could not be locked down: {Message}", profileDir, e.Message); + } var args = new List { "--headless", "--norestore", "--nologo", "--nodefault", "--nolockcheck", "--nofirststartwizard", diff --git a/src/Filee.Engines/Office/LibreOfficeSecurity.cs b/src/Filee.Engines/Office/LibreOfficeSecurity.cs new file mode 100644 index 000000000..8798e1897 --- /dev/null +++ b/src/Filee.Engines/Office/LibreOfficeSecurity.cs @@ -0,0 +1,86 @@ +// Locks down the LibreOffice profiles Filee converts with, before every run: macros never run, links and outside data +// are never updated while a document loads, and VBA code from Office files is not made executable. +// +// LibreOffice's conversion mode (--convert-to) already doesn't execute document macros (tested with LibreOffice 26.8: +// neither an "on load" Basic macro nor a Basic function called from a cell ran, even at the lowest security level). +// This is defense in depth against a change of that behaviour and against documents that pull in outside content. +// +// The settings go into the profile's user/registrymodifications.xcu: LibreOffice reads it at start and keeps the values +// when it writes the file back. Writing it into a profile LibreOffice hasn't created yet works too. Paths and values were +// checked against the configuration schema of LibreOffice 26.8 (share/registry/main.xcd): +// * Common/Security/Scripting: MacroSecurityLevel 3 (only macros from trusted locations, of which Filee's profiles have +// none), DisableMacrosExecution true (Basic, BeanShell, JavaScript and Python), BlockUntrustedRefererLinks true. +// * Writer/Content/Update Link 2 and Calc/Content/Update Link 1: never update links when loading (the two applications +// number "never" differently). +// * Writer|Calc/Filter/Import/VBA Executable false: VBA code in Office files is kept as text only. + +using System.Xml.Linq; + +namespace Filee.Engines.Office; + +/// Writes Filee's security settings into a LibreOffice user profile. +public static class LibreOfficeSecurity +{ + private static readonly XNamespace Oor = "http://openoffice.org/2001/registry"; + + /// The settings: configuration path, property, value. + internal static readonly (string Path, string Property, string Value)[] Settings = + [ + ("/org.openoffice.Office.Common/Security/Scripting", "MacroSecurityLevel", "3"), + ("/org.openoffice.Office.Common/Security/Scripting", "DisableMacrosExecution", "true"), + ("/org.openoffice.Office.Common/Security/Scripting", "BlockUntrustedRefererLinks", "true"), + ("/org.openoffice.Office.Writer/Content/Update", "Link", "2"), + ("/org.openoffice.Office.Calc/Content/Update", "Link", "1"), + ("/org.openoffice.Office.Writer/Filter/Import/VBA", "Executable", "false"), + ("/org.openoffice.Office.Calc/Filter/Import/VBA", "Executable", "false"), + ]; + + /// The settings file of a profile (-env:UserInstallation folder). + public static string SettingsFile(string profileDirectory) => + Path.Combine(profileDirectory, "user", "registrymodifications.xcu"); + + /// + /// Makes sure the profile in carries : creates the settings + /// file when there is none, else replaces only Filee's items and keeps every other one. + /// + public static void Apply(string profileDirectory) + { + var file = SettingsFile(profileDirectory); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + XDocument document; + if (File.Exists(file)) + { + document = XDocument.Load(file, LoadOptions.PreserveWhitespace); + if (Carries(document)) + return; + } + else + { + document = new XDocument(new XDeclaration("1.0", "UTF-8", null), + new XElement(Oor + "items", + new XAttribute(XNamespace.Xmlns + "oor", Oor.NamespaceName), + new XAttribute(XNamespace.Xmlns + "xs", "http://www.w3.org/2001/XMLSchema"), + new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"))); + } + + var root = document.Root!; + foreach (var (path, property, _) in Settings) + root.Elements("item").Where(item => Is(item, path, property)).Remove(); + foreach (var (path, property, value) in Settings) + root.Add(new XElement("item", new XAttribute(Oor + "path", path), + new XElement("prop", new XAttribute(Oor + "name", property), new XAttribute(Oor + "op", "fuse"), + new XElement("value", value)))); + + var temporary = file + ".filee-tmp"; + document.Save(temporary, SaveOptions.DisableFormatting); + File.Move(temporary, file, overwrite: true); + } + + /// True when every setting is there with Filee's value. + internal static bool Carries(XDocument document) => + Settings.All(setting => document.Root?.Elements("item").Any(item => + Is(item, setting.Path, setting.Property) && (string?)item.Element("prop")?.Element("value") == setting.Value) == true); + + private static bool Is(XElement item, string path, string property) => + (string?)item.Attribute(Oor + "path") == path && (string?)item.Element("prop")?.Attribute(Oor + "name") == property; +} diff --git a/src/Filee.Engines/Office/PandocConverter.cs b/src/Filee.Engines/Office/PandocConverter.cs index 91f67a83b..ad0fc7923 100644 --- a/src/Filee.Engines/Office/PandocConverter.cs +++ b/src/Filee.Engines/Office/PandocConverter.cs @@ -17,7 +17,7 @@ namespace Filee.Engines.Office; /// Markdown, HTML, DOCX, ODT, RTF and markup language conversions through Pandoc. -public sealed class PandocConverter : IConverter +public sealed class PandocConverter : IConverter, IHasOwnTimeout { private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2); diff --git a/src/Filee.Engines/Pdf/PdfDocumentReader.cs b/src/Filee.Engines/Pdf/PdfDocumentReader.cs index e3fbe1d0c..c62bd4ac3 100644 --- a/src/Filee.Engines/Pdf/PdfDocumentReader.cs +++ b/src/Filee.Engines/Pdf/PdfDocumentReader.cs @@ -206,6 +206,9 @@ private sealed class PageContent(int index, double width, double height) public List Items { get; set; } = []; public bool Scanned { get; set; } public string? ScanPath { get; set; } + + /// Pictures covering most of a page with visible text on it: page backgrounds, kept behind the text (#433). + public List<(string Path, PdfRectangle Bounds)> Backgrounds { get; } = []; } // ───────────────────────── Document ───────────────────────── @@ -435,7 +438,10 @@ private static string CleanText(string text) // ───────────────────────── Pictures ───────────────────────── - /// Pictures of a text page. A picture covering the page behind text is the scan under an OCR layer: skipped. + /// + /// Pictures of a text page. A picture covering most of the page is its background (#433) and goes behind the text; + /// only with the OCR layer as the page's text (PdfOptions.Ocr) is it the scan under that layer, and skipped. + /// private IEnumerable Pictures(Page page, PageContent content, PdfRectangle crop) { var result = new List(); @@ -448,7 +454,8 @@ private IEnumerable Pictures(Page page, PageContent content, PdfRectangle bounds = new PdfRectangle(Math.Max(0, bounds.Left), Math.Max(0, bounds.Bottom), Math.Min(content.Width, bounds.Right), Math.Min(content.Height, bounds.Top)); if (bounds.Width < 6 || bounds.Height < 6 || image.IsImageMask) continue; - if (bounds.Width * bounds.Height > 0.85 * content.Width * content.Height) + var background = bounds.Width * bounds.Height > 0.85 * content.Width * content.Height; + if (background && _ocrLayer) continue; string? path = null; @@ -468,7 +475,9 @@ private IEnumerable Pictures(Page page, PageContent content, PdfRectangle rendered ??= Render(content.Index); path = Crop(rendered, bounds, content); } - if (path is not null) + if (path is not null && background) + content.Backgrounds.Add((path, bounds)); + else if (path is not null) result.Add(new Item { PicturePath = path, PictureBounds = bounds }); } } @@ -656,6 +665,20 @@ private HDocument Build(List pages, string? title) } else { + // A page background (#433): floating behind the text where it was on the page, so it never pushes text. + foreach (var (path, bounds) in page.Backgrounds) + { + var picture = new HImage(path) + { + Width = Hwp(bounds.Width), + Height = Hwp(bounds.Height), + Anchor = new HAnchor("PAPER", "LEFT", Hwp(bounds.Left), "PAPER", "TOP", Hwp(page.Height - bounds.Top), + "BEHIND_TEXT", AllowOverlap: true), + }; + var holder = new HParagraph { MarkFormat = new HCharFormat(Size: 100) }; + holder.Inlines.Add(picture); + blocks.Add(holder); + } Item? previous = null; foreach (var item in page.Items) { diff --git a/src/Filee.Engines/Pdf/PdfMergeCheck.cs b/src/Filee.Engines/Pdf/PdfMergeCheck.cs new file mode 100644 index 000000000..e7ed6a4d9 --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfMergeCheck.cs @@ -0,0 +1,81 @@ +// #377: a merged PDF whose pages came out blank although their sources aren't. Every page of the merged file is drawn +// small with PDFium (fast: a few milliseconds each); only a blank one is compared with its source page. Whatever made +// it blank (resources, optional content or form fields the page import didn't carry over), the check sees the result. + +using PDFtoImage; +using SkiaSharp; + +#pragma warning disable CA1416 // PDFium ships for every desktop platform Filee targets. + +namespace Filee.Engines.Pdf; + +/// Finds pages a merge lost. +internal static class PdfMergeCheck +{ + /// Resolution of the check: enough to tell a blank page from one with text on it. + private const int Dpi = 18; + + /// + /// The index of the first page of that is blank while its page in + /// (in order) is not, or null when there is none or the files can't be drawn. + /// + public static int? FindLostPage(IReadOnlyList sources, string merged, CancellationToken cancellationToken) + { + try + { + var options = new RenderOptions(Dpi: Dpi, WithAnnotations: true, WithFormFill: true, BackgroundColor: SKColors.White); + var mergedBytes = File.ReadAllBytes(merged); + var blank = new List(); + var index = 0; + foreach (var bitmap in Conversion.ToImages(mergedBytes, null, options)) + { + cancellationToken.ThrowIfCancellationRequested(); + using (bitmap) + { + if (IsBlank(bitmap)) + blank.Add(index); + } + index++; + } + if (blank.Count == 0) + return null; + + // Which source page each merged page came from. + var origin = new List<(string File, int Page)>(); + foreach (var source in sources) + { + var count = Conversion.GetPageCount(File.ReadAllBytes(source), null); + for (var page = 0; page < count; page++) + origin.Add((source, page)); + } + foreach (var page in blank.Where(p => p < origin.Count)) + { + cancellationToken.ThrowIfCancellationRequested(); + using var original = Conversion.ToImage(File.ReadAllBytes(origin[page].File), origin[page].Page, null, options); + if (!IsBlank(original)) + return page; + } + return null; + } + catch (Exception e) when (e is not OperationCanceledException and not OutOfMemoryException) + { + // A file PDFium can't draw (encrypted, damaged): no verdict, the merge stays as it is. + return null; + } + } + + /// True when every pixel is (nearly) white. + internal static bool IsBlank(SKBitmap bitmap) + { + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + var pixel = bitmap.GetPixel(x, y); + if (pixel.Red < 245 || pixel.Green < 245 || pixel.Blue < 245) + return false; + } + } + return true; + } +} diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs index acfd7051e..95ae128a9 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -49,15 +49,42 @@ public Task FinishAsync(string path, PdfOptions options, CancellationToken cance public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, CancellationToken cancellationToken) => Task.Run(() => { - using var output = new PdfDocument(); - foreach (var input in inputPaths) + using (var output = new PdfDocument()) { - cancellationToken.ThrowIfCancellationRequested(); - using var source = Open(input, PdfDocumentOpenMode.Import); - foreach (var page in source.Pages) - output.AddPage(page); + foreach (var input in inputPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + using var source = Open(input, PdfDocumentOpenMode.Import); + foreach (var page in source.Pages) + output.AddPage(page); + } + output.Save(outputPath); + } + + // #377: some PDFs come out of PDFsharp's page import with pages that are blank in the merged file but not + // in their source. Then PdfPig merges them instead, and its file is kept when it lost no page; else + // PDFsharp's stays, as before. + if (PdfMergeCheck.FindLostPage(inputPaths, outputPath, cancellationToken) is null) + return; + var fallback = outputPath + ".pdfpig"; + try + { + File.WriteAllBytes(fallback, UglyToad.PdfPig.Writer.PdfMerger.Merge([.. inputPaths])); + if (PdfMergeCheck.FindLostPage(inputPaths, fallback, cancellationToken) is null) + { + File.Move(fallback, outputPath, overwrite: true); + return; + } + } + catch (Exception e) when (e is not OperationCanceledException and not OutOfMemoryException) + { + // An encrypted or damaged source PdfPig can't merge: PDFsharp's file stays. + } + finally + { + if (File.Exists(fallback)) + File.Delete(fallback); } - output.Save(outputPath); }, cancellationToken); /// Places every frame of an image (TIFF pages, ...) on its own PDF page. diff --git a/src/Filee.Engines/Vector/GhostscriptConverter.cs b/src/Filee.Engines/Vector/GhostscriptConverter.cs index 06f06f5a2..295401fd1 100644 --- a/src/Filee.Engines/Vector/GhostscriptConverter.cs +++ b/src/Filee.Engines/Vector/GhostscriptConverter.cs @@ -14,7 +14,7 @@ namespace Filee.Engines.Vector; /// EPS, PS and PostScript-based AI ↔ PDF through Ghostscript. -public sealed class GhostscriptConverter : IConverter, IImageTransformer +public sealed class GhostscriptConverter : IConverter, IImageTransformer, IHasOwnTimeout { private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(5); diff --git a/src/Filee.Platform.Windows/WindowsPlatformServices.cs b/src/Filee.Platform.Windows/WindowsPlatformServices.cs index f38b99068..978b313e9 100644 --- a/src/Filee.Platform.Windows/WindowsPlatformServices.cs +++ b/src/Filee.Platform.Windows/WindowsPlatformServices.cs @@ -240,6 +240,38 @@ public void SetStartWithSystem(bool enabled, string executablePath) CheckStartupEntry(command, key.GetValue(AppName)); } + /// Where Task Manager → Startup apps keeps "disabled" for an entry of the Run key (a REG_BINARY per name). + private const string StartupApprovedKey = @"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; + + /// + public bool StartupDisabledBySystem + { + get + { + using var run = Registry.CurrentUser.OpenSubKey(RunKey); + if (run?.GetValue(AppName) is null) + return false; + using var approved = Registry.CurrentUser.OpenSubKey(StartupApprovedKey); + return IsDisabledFlag(approved?.GetValue(AppName)); + } + } + + /// + /// Task Manager's flag: the first byte is even (2, 6) for "enabled" and odd (3, 7) for "disabled", followed by the + /// time it was disabled. No value means enabled. + /// + internal static bool IsDisabledFlag(object? value) => value is byte[] { Length: > 0 } bytes && (bytes[0] & 1) == 1; + + /// + public void ReenableStartup() + { + using var approved = Registry.CurrentUser.CreateSubKey(StartupApprovedKey); + // What Task Manager writes for "Enabled": 2 and eleven zero bytes. + approved.SetValue(AppName, EnabledFlag, RegistryValueKind.Binary); + } + + internal static readonly byte[] EnabledFlag = [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + /// Throws unless is the command written. internal static void CheckStartupEntry(string written, object? readBack) { diff --git a/src/Filee.Setup/Filee.Setup.csproj b/src/Filee.Setup/Filee.Setup.csproj index 470a05d73..7dba9e0cb 100644 --- a/src/Filee.Setup/Filee.Setup.csproj +++ b/src/Filee.Setup/Filee.Setup.csproj @@ -18,6 +18,11 @@ true + + false true en Filee Setup diff --git a/src/Filee.Setup/Installation/CetCheck.cs b/src/Filee.Setup/Installation/CetCheck.cs new file mode 100644 index 000000000..cb52ad31b --- /dev/null +++ b/src/Filee.Setup/Installation/CetCheck.cs @@ -0,0 +1,89 @@ +// The start-up failure the README's note from #486 describes: on Windows 10 that hasn't been updated for a long time, +// on a processor with Hardware-enforced Stack Protection (CET), Windows turns the protection on for .NET programs but +// lacks a part the runtime needs, and Filee stops at start without a window or a message. Setup itself is built with +// CETCompat=false, so it runs there. +// +// After installing on Windows 10, Setup starts the installed command line once ("filee-cli.exe version", no window). +// When it dies with the fail-fast status a shadow stack violation raises (0xC0000409), Setup turns the protection off +// for Filee's two programs only, as the README's workaround does, and notes it in the uninstall entry so the uninstaller +// removes the exception again. Everywhere else nothing changes: Filee keeps the protection. + +using Filee.Setup.Platform; + +namespace Filee.Setup.Installation; + +/// Finds the CET start-up failure of #486 and applies the per-program exception. +internal static class CetCheck +{ + /// STATUS_STACK_BUFFER_OVERRUN: the status of a fail-fast, which a shadow stack violation raises. + internal const int FailFastStatus = unchecked((int)0xC0000409); + + /// Windows 11's first build: the bug is in old Windows 10 builds only. + private const int Windows11Build = 22000; + + /// The installed programs that need the exception. + internal static readonly string[] Programs = [SetupNames.AppExe, CliExe]; + + /// The command line program next to Filee.exe. + internal const string CliExe = "filee-cli.exe"; + + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan PowerShellTimeout = TimeSpan.FromSeconds(60); + + /// True on the Windows builds #486 happens on (Windows 10). + internal static bool IsAffected(Version windows) => windows.Major == 10 && windows.Build < Windows11Build; + + /// True when the probe's exit code is the CET failure of #486 on this Windows. + internal static bool IsCetFailure(int exitCode, Version windows) => exitCode == FailFastStatus && IsAffected(windows); + + /// + /// Starts the installed command line once; on the CET failure turns the protection off for Filee's programs + /// (needs administrator rights, which a machine-wide install has). Returns true when the exception is in place. + /// + internal static bool Run(SetupContext context, string folder, bool elevated) + { + var cli = Path.Combine(folder, CliExe); + var windows = context.WindowsVersion ?? Environment.OSVersion.Version; + if (context.Sandbox || !IsAffected(windows) || !File.Exists(cli)) + return false; + var exitCode = context.System.RunAndWait(cli, "version", ProbeTimeout); + if (exitCode == 0) + return false; + if (!IsCetFailure(exitCode, windows)) + { + context.Log.Info($"filee-cli.exe version ended with {exitCode:X8} (not the CET start-up failure of #486)"); + return false; + } + if (!elevated) + { + context.Log.Info("Filee can't start on this Windows 10 (CET, #486): install the Windows updates, or see the README for the " + + "Set-ProcessMitigation workaround (it needs administrator rights, which this install for one user doesn't have)"); + return false; + } + var result = context.System.RunAndWait(PowerShell, Command("-Disable UserShadowStack"), PowerShellTimeout); + context.Log.Info(result == 0 + ? "Filee can't start on this Windows 10 with Hardware-enforced Stack Protection (#486): turned it off for Filee.exe and filee-cli.exe" + : $"Turning Hardware-enforced Stack Protection off for Filee failed (PowerShell exit code {result})"); + return result == 0; + } + + /// Removes the exception again (uninstall). + internal static void Remove(SetupContext context) + { + if (context.Sandbox) + return; + var result = context.System.RunAndWait(PowerShell, Command("-Remove"), PowerShellTimeout); + context.Log.Info(result == 0 + ? "Removed the Hardware-enforced Stack Protection exception for Filee.exe and filee-cli.exe" + : $"Removing the Hardware-enforced Stack Protection exception failed (PowerShell exit code {result})"); + } + + /// Windows PowerShell, part of Windows (Set-ProcessMitigation is in its ProcessMitigations module). + private static string PowerShell => + Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + + /// The PowerShell arguments that run Set-ProcessMitigation with for each program. + internal static string Command(string what) => + "-NoLogo -NoProfile -NonInteractive -Command \"" + + string.Join("; ", Programs.Select(p => $"Set-ProcessMitigation -Name '{p}' {what}")) + "\""; +} diff --git a/src/Filee.Setup/Installation/Installer.cs b/src/Filee.Setup/Installation/Installer.cs index 70a74b2ed..2d9101c67 100644 --- a/src/Filee.Setup/Installation/Installer.cs +++ b/src/Filee.Setup/Installation/Installer.cs @@ -255,8 +255,15 @@ private void Register(string folder, InstallChoices choices, bool update, Stack< // The uninstall entry: the Inno Setup key, rewritten (its "Inno Setup: …" values and unins000.exe are gone). var before = Snapshot(hive, SetupNames.UninstallKey); undo.Push(("uninstall entry", () => Restore(hive, SetupNames.UninstallKey, before))); + // An exception an earlier Setup made (#486) stays noted: Filee starts now because of it, so the check below passes. + var shadowStackOff = Registry.Get(hive, SetupNames.UninstallKey, SetupNames.ShadowStackOffValue) is not null; WriteUninstallEntry(hive, folder, choices); + // #486: Filee stops at start on old Windows 10 with Hardware-enforced Stack Protection; then it is turned off for + // Filee's programs only, and the uninstall entry notes it so the uninstaller turns it back on. + if (CetCheck.Run(context, folder, elevated: choices.Scope == InstallScope.Machine) || shadowStackOff) + Registry.SetNumber(hive, SetupNames.UninstallKey, SetupNames.ShadowStackOffValue, 1); + // Shortcuts. var startMenu = Path.Combine(Paths.StartMenu(choices.Scope), SetupNames.ShortcutName); var desktop = Path.Combine(Paths.Desktop(choices.Scope), SetupNames.ShortcutName); diff --git a/src/Filee.Setup/Installation/SetupContext.cs b/src/Filee.Setup/Installation/SetupContext.cs index 33c790e5c..3e7dfb87d 100644 --- a/src/Filee.Setup/Installation/SetupContext.cs +++ b/src/Filee.Setup/Installation/SetupContext.cs @@ -5,7 +5,9 @@ namespace Filee.Setup.Installation; /// A /SANDBOX run: Filee is never started. -public sealed record SetupContext(ISetupRegistry Registry, ISetupSystem System, SetupPaths Paths, SetupLog Log, bool Sandbox = false); +/// The Windows version (tests set it); null for the one Setup runs on. +public sealed record SetupContext(ISetupRegistry Registry, ISetupSystem System, SetupPaths Paths, SetupLog Log, bool Sandbox = false, + Version? WindowsVersion = null); /// What the user chose (or the command line said). /// The install folder. diff --git a/src/Filee.Setup/Installation/SetupNames.cs b/src/Filee.Setup/Installation/SetupNames.cs index 96513e34d..a4f015494 100644 --- a/src/Filee.Setup/Installation/SetupNames.cs +++ b/src/Filee.Setup/Installation/SetupNames.cs @@ -41,6 +41,12 @@ public static class SetupNames public const string TasksValue = "Filee Setup: Tasks"; public const string LanguageValue = "Filee Setup: Language"; public const string ScopeValue = "Filee Setup: Scope"; + + /// + /// Set when Setup turned Hardware-enforced Stack Protection off for Filee's programs (#486, CetCheck): the + /// uninstaller removes that exception again. + /// + public const string ShadowStackOffValue = "Filee Setup: Shadow stack off"; public const string InnoTasksValue = "Inno Setup: Selected Tasks"; public const string InnoAppPathValue = "Inno Setup: App Path"; public const string InnoVersionValue = "Inno Setup: Setup Version"; diff --git a/src/Filee.Setup/Installation/Uninstaller.cs b/src/Filee.Setup/Installation/Uninstaller.cs index 94bdd1e3e..2193b9ddc 100644 --- a/src/Filee.Setup/Installation/Uninstaller.cs +++ b/src/Filee.Setup/Installation/Uninstaller.cs @@ -75,6 +75,9 @@ public IReadOnlyList Run(string folder, InstallRecord record, bool delet FileSwap.DeleteTree(work, System, Log); } + // Setup turned Hardware-enforced Stack Protection off for Filee's programs (#486): turn it back on. + if (Registry.Get(hive, SetupNames.UninstallKey, SetupNames.ShadowStackOffValue) is not null) + CetCheck.Remove(context); Registry.DeleteKey(hive, SetupNames.UninstallKey); progress?.Report(new SetupProgress(SetupStep.Removing, 0.95)); if (deleteSettings) diff --git a/tests/Filee.App.Tests/DonutAccessibilityTests.cs b/tests/Filee.App.Tests/DonutAccessibilityTests.cs new file mode 100644 index 000000000..1bd596bdf --- /dev/null +++ b/tests/Filee.App.Tests/DonutAccessibilityTests.cs @@ -0,0 +1,113 @@ +// The donut for screen readers and the keyboard: every slice is a list item with its label and the reason it's +// disabled, a screen reader can select and invoke slices, and the editor's keyboard chooses, edits and moves them. + +using Avalonia.Automation.Peers; +using Avalonia.Automation.Provider; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Threading; +using Filee.App.Controls; + +namespace Filee.App.Tests; + +[Collection("Motion")] +public class DonutAccessibilityTests +{ + private static (DonutMenu Donut, Window Window) Open(bool editMode = false) + { + TestServices.EnsureInitialized("en"); + var donut = new DonutMenu + { + Items = + [ + new DonutItem { Id = "png", Label = "PNG" }, + new DonutItem { Id = "pdf", Label = "Scan", Caption = "PDF" }, + new DonutItem { Id = "hwp", Label = "HWP", IsEnabled = false, DisabledReason = "Needs rhwp" }, + new DonutItem { Id = "group", Label = "More", Children = [new DonutItem { Id = "webp", Label = "WEBP" }] }, + ], + IsEditMode = editMode, + }; + var window = new Window { Content = donut, SizeToContent = SizeToContent.WidthAndHeight }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (donut, window); + } + + [AvaloniaFact] + public void Screen_readers_find_every_slice_with_its_name_and_why_it_is_disabled() + { + var (donut, window) = Open(); + + var peer = ControlAutomationPeer.CreatePeerForElement(donut); + var slices = peer.GetChildren(); + + Assert.Equal(AutomationControlType.List, peer.GetAutomationControlType()); + Assert.Equal(["PNG", "Scan, PDF", "HWP", "More"], slices.Select(s => s.GetName())); + Assert.All(slices, s => Assert.Equal(AutomationControlType.ListItem, s.GetAutomationControlType())); + Assert.False(slices[2].IsEnabled()); + Assert.Equal("Needs rhwp", slices[2].GetHelpText()); + Assert.True(slices[0].GetBoundingRectangle().Width > 0); + window.Close(); + } + + [AvaloniaFact] + public void A_slice_a_screen_reader_selects_becomes_the_keyboards() + { + var (donut, window) = Open(); + var peer = ControlAutomationPeer.CreatePeerForElement(donut); + var slices = peer.GetChildren(); + + ((ISelectionItemProvider)slices[1]).Select(); + + Assert.Equal(1, donut.ActiveIndex); + Assert.True(((ISelectionItemProvider)slices[1]).IsSelected); + Assert.Same(slices[1], Assert.Single(((ISelectionProvider)peer).GetSelection())); + window.Close(); + } + + [AvaloniaFact] + public void The_editors_keyboard_chooses_edits_and_moves_slices() + { + var (donut, window) = Open(editMode: true); + var edited = new List(); + var moved = new List<(int From, int To)>(); + donut.SegmentContextRequested += (_, index) => edited.Add(index); + donut.SegmentMoveRequested += (_, move) => moved.Add(move); + + Assert.True(donut.HandleEditKey(Key.Right)); + Assert.True(donut.HandleEditKey(Key.Right)); + Assert.Equal(1, donut.ActiveIndex); + Assert.True(donut.HandleEditKey(Key.Enter)); + Assert.True(donut.HandleEditKey(Key.Left, KeyModifiers.Control)); + Assert.False(donut.HandleEditKey(Key.Tab)); // left to the page: the focus moves on + + Assert.Equal([1], edited); + Assert.Equal([(1, 0)], moved); + Assert.Equal(0, donut.ActiveIndex); + window.Close(); + } + + [AvaloniaFact] + public void Outside_the_editor_the_editors_keys_do_nothing() + { + var (donut, window) = Open(); + + Assert.False(donut.HandleEditKey(Key.Right)); + + window.Close(); + } + + [AvaloniaFact] + public void High_contrast_draws_the_ring_in_the_themes_colours() + { + var (donut, window) = Open(); + + donut.Classes.Add("high-contrast"); + RenderTests.Pump(); + + Assert.True(donut.IsHighContrast); + RenderTests.Save(window, "donut-high-contrast-en.png"); + window.Close(); + } +} diff --git a/tests/Filee.App.Tests/LocalizationResourceTests.cs b/tests/Filee.App.Tests/LocalizationResourceTests.cs index 87f3a3593..2beb76e73 100644 --- a/tests/Filee.App.Tests/LocalizationResourceTests.cs +++ b/tests/Filee.App.Tests/LocalizationResourceTests.cs @@ -126,4 +126,35 @@ public void DynamicResource_in_xaml_resolves_and_follows_language_changes() Assert.Equal("オフ", toggle.OffContent); window.Close(); } + + [AvaloniaFact] + public void A_language_switch_replaces_the_texts_at_once_and_the_same_language_leaves_them() + { + // Written one by one, each of the thousand texts made every DynamicResource in every open window look its key + // up again: a switch took seconds with a few windows open. + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var merged = Application.Current!.Resources.MergedDictionaries; + ResourceDictionary Texts() => merged.OfType().Single(d => d.ContainsKey("general.title")); + var label = new TextBlock(); + label.Bind(TextBlock.TextProperty, label.GetResourceObservable("general.title")); + var window = new Window { Content = label }; + window.Show(); + try + { + var english = Texts(); + loc.SetLanguage("en"); + Assert.Same(english, Texts()); + + loc.SetLanguage("ko"); + Assert.NotSame(english, Texts()); + Assert.Equal(loc["general.title"], label.Text); + Assert.NotEqual("General", label.Text); + } + finally + { + window.Close(); + TestServices.EnsureInitialized("en"); + } + } } diff --git a/tests/Filee.App.Tests/RenderTests.cs b/tests/Filee.App.Tests/RenderTests.cs index c9388726a..68b916d49 100644 --- a/tests/Filee.App.Tests/RenderTests.cs +++ b/tests/Filee.App.Tests/RenderTests.cs @@ -1,6 +1,7 @@ // Renders real windows headlessly (Skia) to catch XAML / binding errors and to produce screenshots // in bin/.../screenshots for visual review. +using System.Globalization; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Headless; @@ -14,6 +15,7 @@ using Filee.App.ViewModels.Pages; using Filee.App.Views; using Filee.App.Views.Pages; +using Filee.Core.Conversion; using Filee.Core.Localization; using Filee.Core.Profiles; using Filee.Core.Settings; @@ -50,6 +52,7 @@ public class RenderTests { "home", "pt-BR" }, { "general", "pt-BR" }, { "triggers", "pt-BR" }, { "toolbar", "pt-BR" }, { "presets", "pt-BR" }, { "theme", "pt-BR" }, { "engines", "pt-BR" }, { "about", "pt-BR" }, { "watch", "pt-BR" }, { "home", "ne" }, { "general", "ne" }, { "toolbar", "ne" }, { "presets", "ne" }, { "engines", "ne" }, + { "security", "en" }, { "security", "ko" }, { "security", "ar" }, { "security", "ja" }, { "security", "ru" }, }; [AvaloniaTheory] @@ -1385,6 +1388,7 @@ public void Preset_editor_keeps_rotation_visible_for_pdf_and_drops_it_for_target public void Preset_editor_saves_every_frame_only_for_png_and_jpg() { TestServices.EnsureInitialized("en"); + using var culture = new CultureScope("en-US"); // "100,000": numbers follow the regional format var loc = AppHost.Get(); var preset = new Filee.Core.Presets.Preset { TargetFormat = "jpg" }; @@ -1412,6 +1416,7 @@ public void Preset_editor_saves_every_frame_only_for_png_and_jpg() public void Preset_editor_shows_playback_speed_only_for_targets_that_keep_animations() { TestServices.EnsureInitialized("ko"); + using var culture = new CultureScope("ko-KR"); // "0.25×": numbers follow the regional format var loc = AppHost.Get(); var preset = new Filee.Core.Presets.Preset { TargetFormat = "gif", Name = "Slow GIF" }; var vm = new PresetEditorViewModel(preset, loc); @@ -2442,6 +2447,37 @@ public void Clip_window_renders(string language, bool dark) }); } + [AvaloniaTheory] + [InlineData("en")] + [InlineData("ko")] + [InlineData("ar")] + public void The_active_content_warning_lists_what_each_document_carries(string language) + { + TestServices.EnsureInitialized(language); + IReadOnlyList reports = + [ + new(@"C:\Users\me\Downloads\invoice.docm", + [ + new(ActiveContentKind.Macro, "NewMacros"), + new(ActiveContentKind.AutoRunMacro, "AutoOpen", "NewMacros"), + new(ActiveContentKind.ExternalLink, "attachedTemplate", "http://example.com/template.dotm"), + ]), + new(@"C:\Users\me\Downloads\budget.xls", [new(ActiveContentKind.Macro, "Macro1", "Excel 4.0")]), + ]; + var vm = new ActiveContentViewModel(reports, AppHost.Get()); + var window = new ActiveContentWindow { DataContext = vm }; + window.Show(); + Pump(); + + Assert.Equal(2, vm.Files.Count); + Assert.True(vm.Files[0].StartsByItself); + Assert.False(vm.Files[1].StartsByItself); + Assert.Equal(ActiveContentDecision.Cancel, window.Result); // closing the window cancels + Assert.False(window.Remember); + Save(window, $"active-content-{language}.png"); + window.Close(); + } + internal static void Pump() { // Let layout, bindings and a few animation frames run. @@ -2452,6 +2488,16 @@ internal static void Pump() } } + /// The regional format until disposed: numbers in labels follow it, not the UI language. + private sealed class CultureScope : IDisposable + { + private readonly CultureInfo _before = CultureInfo.CurrentCulture; + + public CultureScope(string name) => CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(name); + + public void Dispose() => CultureInfo.CurrentCulture = _before; + } + internal static void Save(TopLevel window, string name) { // Disposed at the end, so the frame stays alive while Skia encodes it: a collection in between (the idle memory diff --git a/tests/Filee.App.Tests/UpdateSigningTests.cs b/tests/Filee.App.Tests/UpdateSigningTests.cs new file mode 100644 index 000000000..04f30095d --- /dev/null +++ b/tests/Filee.App.Tests/UpdateSigningTests.cs @@ -0,0 +1,171 @@ +// The checks that tie an update to Filee's maker beyond the release's own checksum file: the checksum file's signature +// with the update key (FileeUpdatePublicKey) and the installer's publisher pinned in the build (FileeExpectedPublisher). + +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Filee.App.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.App.Tests; + +public class UpdateSigningTests +{ + private const string Base = "https://example.invalid/releases/download/v9.9.9/"; + private const string Publisher = "CN=Filee Test Publisher"; + private static readonly byte[] Installer = Encoding.ASCII.GetBytes("the installer"); + + [Fact] + public void Signature_is_accepted_in_both_encodings_and_only_for_the_signed_bytes() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()); + var data = Encoding.ASCII.GetBytes("abc Filee-9.9.9-win-Setup.exe\n"); + var der = Convert.ToBase64String(key.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + var p1363 = Convert.ToBase64String(key.SignData(data, HashAlgorithmName.SHA256)); + + Assert.True(UpdateService.VerifySignature(publicKey, data, der)); + Assert.True(UpdateService.VerifySignature(publicKey, data, p1363 + "\n")); // a trailing newline from the file + Assert.False(UpdateService.VerifySignature(publicKey, [.. data, (byte)' '], der)); + Assert.False(UpdateService.VerifySignature(Convert.ToBase64String(other.ExportSubjectPublicKeyInfo()), data, der)); + Assert.False(UpdateService.VerifySignature(publicKey, data, "not base64!")); + Assert.False(UpdateService.VerifySignature("not a key", data, der)); + } + + [Fact] + public async Task Without_a_key_the_signature_is_not_asked_for() + { + var release = Release(sums: Sums(), signature: null); + var updates = Updates(release, publicKey: null); + + var path = await updates.DownloadInstallerAsync(TempFolder(), null, TestContext.Current.CancellationToken); + + Assert.True(File.Exists(path)); + Assert.DoesNotContain(release.Requested, url => url.EndsWith(".sig", StringComparison.Ordinal)); + } + + [Fact] + public async Task With_a_key_a_valid_signature_lets_the_installer_through() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var sums = Sums(); + var updates = Updates(Release(sums, Sign(key, sums)), Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); + + var path = await updates.DownloadInstallerAsync(TempFolder(), null, TestContext.Current.CancellationToken); + + Assert.Equal(Installer, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task With_a_key_a_missing_signature_is_refused() + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var updates = Updates(Release(Sums(), signature: null), Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); + + var error = await Assert.ThrowsAsync(() => + updates.DownloadInstallerAsync(TempFolder(), null, TestContext.Current.CancellationToken)); + + Assert.Contains(".sig", error.Message); + } + + [Fact] + public async Task With_a_key_a_replaced_checksum_file_is_refused() + { + // Someone who can replace release files replaces the installer and its checksum line, but can't sign. + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var original = Sums(); + var replaced = Encoding.ASCII.GetBytes(Convert.ToHexStringLower(SHA256.HashData(Installer)) + " Filee-9.9.9-win-Setup.exe\nextra\n"); + var updates = Updates(Release(replaced, Sign(key, original)), Convert.ToBase64String(key.ExportSubjectPublicKeyInfo())); + var folder = TempFolder(); + + await Assert.ThrowsAsync(() => + updates.DownloadInstallerAsync(folder, null, TestContext.Current.CancellationToken)); + + Assert.Empty(Directory.EnumerateFiles(folder)); // nothing was downloaded + } + + [Fact] + public void Unsigned_copy_requires_the_pinned_publisher() + { + var path = Path.Combine(TempFolder(), "Filee-9.9.9-win-Setup.exe"); + File.WriteAllText(path, "setup"); + var updates = new UpdateService(NullLogger.Instance, Release(Sums(), null)) + { + OwnProgram = "Filee.exe", + ExpectedPublisher = Publisher, + SignerOf = (file, _) => file == "Filee.exe" ? null : Publisher, + }; + + updates.CheckPublisher(path); + + Assert.True(File.Exists(path)); + } + + [Theory] + [InlineData("CN=Someone Else")] + [InlineData(null)] + public void Unsigned_copy_with_a_pinned_publisher_throws_away_other_installers(string? signer) + { + var path = Path.Combine(TempFolder(), "Filee-9.9.9-win-Setup.exe"); + File.WriteAllText(path, "setup"); + var updates = new UpdateService(NullLogger.Instance, Release(Sums(), null)) + { + OwnProgram = "Filee.exe", + ExpectedPublisher = Publisher, + SignerOf = (file, _) => file == "Filee.exe" ? null : signer, + }; + + Assert.Throws(() => updates.CheckPublisher(path)); + Assert.False(File.Exists(path)); + } + + private static byte[] Sums() => + Encoding.ASCII.GetBytes(Convert.ToHexStringLower(SHA256.HashData(Installer)) + " Filee-9.9.9-win-Setup.exe\n"); + + private static string Sign(ECDsa key, byte[] data) => + Convert.ToBase64String(key.SignData(data, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)); + + private static FakeRelease Release(byte[] sums, string? signature) + { + var files = new Dictionary + { + [Base + "Filee-9.9.9-win-Setup.exe"] = Installer, + [Base + "Filee-9.9.9-SHA256SUMS.txt"] = sums, + }; + if (signature is not null) + files[Base + "Filee-9.9.9-SHA256SUMS.txt.sig"] = Encoding.ASCII.GetBytes(signature); + return new FakeRelease(files); + } + + private static UpdateService Updates(FakeRelease release, string? publicKey) + { + var updates = new UpdateService(NullLogger.Instance, release) { UpdatePublicKey = publicKey }; + updates.UseRelease(new UpdateService.ReleaseFile("Filee-9.9.9-win-Setup.exe", Base + "Filee-9.9.9-win-Setup.exe", Installer.Length), + new UpdateService.ReleaseFile("Filee-9.9.9-SHA256SUMS.txt", Base + "Filee-9.9.9-SHA256SUMS.txt", 100)); + return updates; + } + + private static string TempFolder() + { + var folder = Path.Combine(Path.GetTempPath(), "filee-update-signing-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + return folder; + } + + /// Release files by address; anything else is 404. Records what was asked for. + private sealed class FakeRelease(Dictionary files) : HttpMessageHandler + { + public List Requested { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri!.ToString(); + lock (Requested) + Requested.Add(url); + return Task.FromResult(files.TryGetValue(url, out var bytes) + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) } + : new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } +} diff --git a/tests/Filee.Core.Tests/JobQueueSecurityTests.cs b/tests/Filee.Core.Tests/JobQueueSecurityTests.cs new file mode 100644 index 000000000..e1c878781 --- /dev/null +++ b/tests/Filee.Core.Tests/JobQueueSecurityTests.cs @@ -0,0 +1,245 @@ +// Settings → Security and limits in the queue: documents with active content before a step through an office suite +// (ask once per job, convert, skip, cancel, refuse; never ask an unattended job), and the step time limit for built-in +// engines, which engines with a time limit of their own don't get. + +using System.Diagnostics; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class JobQueueSecurityTests +{ + private static readonly Preset ToPdf = new() { TargetFormat = "pdf" }; + + [Fact] + public async Task Refuse_fails_documents_with_active_content_and_converts_the_others() + { + using var dir = new TempDir(); + var macro = dir.File("macro.docx"); + var clean = dir.File("clean.docx"); + await using var queue = Queue(new FakeScanner(), ActiveContentPolicy.Refuse); + + var job = await Run(queue, [macro, clean]); + + Assert.Equal(FileState.Failed, File(job, macro).State); + Assert.Equal("error.active_content_refused", File(job, macro).ErrorKey); + Assert.Equal(FileState.Done, File(job, clean).State); + Assert.Equal(JobState.CompletedWithErrors, job.State); + } + + [Fact] + public async Task Ask_asks_once_for_the_whole_job_and_trust_converts() + { + using var dir = new TempDir(); + var first = dir.File("macro-1.docx"); + var second = dir.File("macro-2.docx"); + var asked = new List(); + await using var queue = Queue(new FakeScanner(), ActiveContentPolicy.Ask, (_, reports, _) => + { + asked.Add(reports.Count); + return Task.FromResult(ActiveContentDecision.Trust); + }); + + var job = await Run(queue, [first, second]); + + Assert.Equal([2], asked); + Assert.All(job.Files, f => Assert.Equal(FileState.Done, f.State)); + Assert.All(job.Files, f => Assert.Equal("notice.active_content", f.NoticeKey)); + Assert.All(job.Files, f => Assert.Contains(f.ActiveContent, x => x.Kind == ActiveContentKind.AutoRunMacro)); + } + + [Fact] + public async Task Skip_leaves_the_documents_out() + { + using var dir = new TempDir(); + var macro = dir.File("macro.docx"); + var clean = dir.File("clean.docx"); + await using var queue = Queue(new FakeScanner(), ActiveContentPolicy.Ask, (_, _, _) => Task.FromResult(ActiveContentDecision.Skip)); + + var job = await Run(queue, [macro, clean]); + + Assert.Equal(FileState.Skipped, File(job, macro).State); + Assert.Equal("notice.active_content_skipped", File(job, macro).NoticeKey); + Assert.False(System.IO.File.Exists(Path.Combine(dir.Path, "macro.pdf"))); + Assert.Equal(FileState.Done, File(job, clean).State); + Assert.Equal(JobState.Completed, job.State); + } + + [Fact] + public async Task Cancel_cancels_the_job() + { + using var dir = new TempDir(); + await using var queue = Queue(new FakeScanner(), ActiveContentPolicy.Ask, (_, _, _) => Task.FromResult(ActiveContentDecision.Cancel)); + + var job = await Run(queue, [dir.File("macro.docx"), dir.File("clean.docx")]); + + Assert.Equal(JobState.Cancelled, job.State); + Assert.Empty(job.Outputs); + } + + [Fact] + public async Task A_prompt_that_fails_leaves_the_documents_out() + { + using var dir = new TempDir(); + await using var queue = Queue(new FakeScanner(), ActiveContentPolicy.Ask, + (_, _, _) => throw new InvalidOperationException("no window")); + + var job = await Run(queue, [dir.File("macro.docx")]); + + Assert.Equal(FileState.Skipped, job.Files.Single().State); + } + + [Fact] + public async Task An_unattended_job_never_asks() + { + using var dir = new TempDir(); + var asked = 0; + await using var queue = Queue(new FakeScanner(), ActiveContentPolicy.Ask, (_, _, _) => + { + asked++; + return Task.FromResult(ActiveContentDecision.Cancel); + }); + + var job = new ConversionJob([dir.File("macro.docx")], ToPdf, "Test", unattended: true); + await queue.RunAsync(job); + + Assert.Equal(0, asked); + Assert.Equal(FileState.Done, job.Files.Single().State); + Assert.Equal("notice.active_content", job.Files.Single().NoticeKey); + } + + [Fact] + public async Task A_route_without_an_office_suite_is_not_checked() + { + using var dir = new TempDir(); + var scanner = new FakeScanner(); + var builtIn = new FakeConverter("hwpx-writer", new ConversionEdge("docx", "pdf")); + await using var queue = new JobQueue(new ConverterCatalog([builtIn])) + { + ActiveContentScanner = scanner, + ActiveContentPolicy = ActiveContentPolicy.Refuse, + }; + + var job = await Run(queue, [dir.File("macro.docx")]); + + Assert.Equal(0, scanner.Scans); + Assert.Equal(FileState.Done, job.Files.Single().State); + } + + [Fact] + public async Task The_step_time_limit_stops_a_stuck_built_in_engine() + { + using var dir = new TempDir(); + await using var queue = new JobQueue(new ConverterCatalog([new StuckConverter("magick", new ConversionEdge("jpg", "png"))])) + { + StepTimeout = TimeSpan.FromMilliseconds(300), + }; + var clock = Stopwatch.StartNew(); + + var job = await Run(queue, [dir.File("a.jpg")], new Preset { TargetFormat = "png" }); + + Assert.Equal(FileState.Failed, job.Files.Single().State); + Assert.Equal("error.conversion_timeout", job.Files.Single().ErrorKey); + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(10), $"took {clock.Elapsed}"); + } + + [Fact] + public async Task Engines_with_their_own_time_limit_keep_it() + { + using var dir = new TempDir(); + var external = new SlowExternalConverter("ffmpeg", TimeSpan.FromMilliseconds(600), new ConversionEdge("mp4", "webm")); + await using var queue = new JobQueue(new ConverterCatalog([external])) { StepTimeout = TimeSpan.FromMilliseconds(100) }; + + var job = await Run(queue, [dir.File("a.mp4")], new Preset { TargetFormat = "webm" }); + + Assert.Equal(FileState.Done, job.Files.Single().State); + } + + [Fact] + public void Parallel_files_is_at_least_one() + { + var queue = new JobQueue(new ConverterCatalog([])) { ParallelFiles = 0 }; + Assert.Equal(1, queue.ParallelFiles); + } + + private static JobQueue Queue(IActiveContentScanner scanner, ActiveContentPolicy policy, ActiveContentPrompt? prompt = null) => + new(new ConverterCatalog([new OfficeSuiteConverter(new ConversionEdge("docx", "pdf"))])) + { + ActiveContentScanner = scanner, + ActiveContentPolicy = policy, + ActiveContentPrompt = prompt, + }; + + private static async Task Run(JobQueue queue, IReadOnlyList sources, Preset? preset = null) + { + var job = new ConversionJob(sources, preset ?? ToPdf, "Test"); + await queue.RunAsync(job); + return job; + } + + private static FileResult File(ConversionJob job, string source) => job.Files.Single(f => f.SourcePath == source); + + /// Finds an auto-run macro in every file whose name contains "macro". + private sealed class FakeScanner : IActiveContentScanner + { + private int _scans; + public int Scans => _scans; + + public ActiveContentReport? Scan(string path, string formatId) + { + Interlocked.Increment(ref _scans); + return Path.GetFileName(path).Contains("macro", StringComparison.Ordinal) + ? new ActiveContentReport(path, [new(ActiveContentKind.Macro, "Module1"), new(ActiveContentKind.AutoRunMacro, "AutoOpen", "Module1")]) + : null; + } + } + + /// An office suite (LibreOffice) for the queue. + private sealed class OfficeSuiteConverter(params ConversionEdge[] edges) : IConverter, IOpensDocumentsInOfficeSuite, IHasOwnTimeout + { + private readonly FakeConverter _writer = new("libreoffice", edges); + public string Id => "libreoffice"; + public string DisplayName => "LibreOffice"; + public IReadOnlyList Edges => edges; + public int MaxParallelism => 0; + public EngineStatus GetStatus() => EngineStatus.Available(); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + _writer.ConvertAsync(step, progress, cancellationToken); + } + + /// A built-in engine stuck on a file: it never looks at the token. + private sealed class StuckConverter(string id, params ConversionEdge[] edges) : IConverter + { + public string Id => id; + public string DisplayName => id; + public IReadOnlyList Edges => edges; + public int MaxParallelism => 0; + public EngineStatus GetStatus() => EngineStatus.Available(); + + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + await Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None); + return []; + } + } + + /// An engine that runs another program under its own time limit, and takes a while. + private sealed class SlowExternalConverter(string id, TimeSpan duration, params ConversionEdge[] edges) : IConverter, IHasOwnTimeout + { + private readonly FakeConverter _writer = new(id, edges); + public string Id => id; + public string DisplayName => id; + public IReadOnlyList Edges => edges; + public int MaxParallelism => 0; + public EngineStatus GetStatus() => EngineStatus.Available(); + + public async Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) + { + await Task.Delay(duration, cancellationToken); + return await _writer.ConvertAsync(step, progress, cancellationToken); + } + } +} diff --git a/tests/Filee.Core.Tests/SecuritySettingsTests.cs b/tests/Filee.Core.Tests/SecuritySettingsTests.cs new file mode 100644 index 000000000..a74e24527 --- /dev/null +++ b/tests/Filee.Core.Tests/SecuritySettingsTests.cs @@ -0,0 +1,97 @@ +// Settings → Security and limits: defaults, the ranges values are taken into, and settings files from before the page +// (or edited by hand) reading as the defaults. + +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class SecuritySettingsTests +{ + [Fact] + public void Defaults_ask_and_keep_generous_limits() + { + var settings = new AppSettings(); + + Assert.Equal(ActiveContentPolicy.Ask, settings.Security.ActiveContent); + Assert.Equal(500, settings.Limits.EffectiveMaxImageMegapixels); + Assert.Equal(0, settings.Limits.EffectiveMaxImageMemoryMb); + Assert.Equal(Environment.ProcessorCount, settings.Limits.EffectiveParallelFiles); + Assert.Equal(TimeSpan.FromMinutes(15), settings.Limits.EffectiveStepTimeout); + } + + [Fact] + public void Values_outside_a_range_are_taken_as_its_nearest_end() + { + var limits = new LimitSettings + { + MaxImageMegapixels = -5, + MaxImageSide = int.MaxValue, + MaxParallelFiles = 1000, + StepTimeoutMinutes = 100_000, + }; + + Assert.Equal(0, limits.EffectiveMaxImageMegapixels); + Assert.Equal(LimitSettings.MaxSide, limits.EffectiveMaxImageSide); + Assert.Equal(LimitSettings.MaxFiles, limits.EffectiveParallelFiles); + Assert.Equal(TimeSpan.FromMinutes(LimitSettings.MaxTimeoutMinutes), limits.EffectiveStepTimeout); + } + + [Fact] + public void Zero_means_automatic() + { + var limits = new LimitSettings { MaxParallelFiles = 0, StepTimeoutMinutes = 0 }; + + Assert.Equal(Environment.ProcessorCount, limits.EffectiveParallelFiles); + Assert.Null(limits.EffectiveStepTimeout); + } + + [Fact] + public void A_settings_file_from_before_the_page_reads_as_the_defaults() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "settings.json"), """{ "schemaVersion": 46, "language": "en" }"""); + + var store = new UserDataStore(dir.Path); + store.Load(); + + Assert.Equal(ActiveContentPolicy.Ask, store.Settings.Security.ActiveContent); + Assert.Equal(15, store.Settings.Limits.StepTimeoutMinutes); + } + + [Fact] + public void Null_sections_edited_by_hand_read_as_the_defaults() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "settings.json"), + """{ "schemaVersion": 46, "security": null, "limits": null }"""); + + var store = new UserDataStore(dir.Path); + store.Load(); + + Assert.NotNull(store.Settings.Security); + Assert.NotNull(store.Settings.Limits); + } + + [Fact] + public void Settings_round_trip_and_an_unknown_policy_reads_as_ask() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Settings.Security.ActiveContent = ActiveContentPolicy.Refuse; + store.Settings.Limits.MaxImageMegapixels = 123; + store.SaveSettings(); + + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + Assert.Equal(ActiveContentPolicy.Refuse, reloaded.Settings.Security.ActiveContent); + Assert.Equal(123, reloaded.Settings.Limits.MaxImageMegapixels); + + // A value from a newer Filee. + var path = Path.Combine(dir.Path, "settings.json"); + File.WriteAllText(path, File.ReadAllText(path).Replace("\"refuse\"", "\"sandbox\"", StringComparison.OrdinalIgnoreCase)); + var newer = new UserDataStore(dir.Path); + newer.Load(); + Assert.Equal(ActiveContentPolicy.Ask, newer.Settings.Security.ActiveContent); + } +} diff --git a/tests/Filee.Engines.Tests/ActiveContentScannerTests.cs b/tests/Filee.Engines.Tests/ActiveContentScannerTests.cs new file mode 100644 index 000000000..73cddafab --- /dev/null +++ b/tests/Filee.Engines.Tests/ActiveContentScannerTests.cs @@ -0,0 +1,260 @@ +// ActiveContentScanner on documents built here: a DOCM with a real VBA project (compound file, compressed per MS-OVBA) +// whose module starts by itself, external relationships and DDE fields, OpenDocument Basic libraries and "on load" +// events, an Excel 4.0 macro sheet in an XLS, and an RTF with an embedded object. Clean documents report nothing. + +using System.IO.Compression; +using System.Text; +using Filee.Core.Conversion; +using Filee.Engines.Office; +using OpenMcdf; + +namespace Filee.Engines.Tests; + +public class ActiveContentScannerTests +{ + private readonly ActiveContentScanner _scanner = new(); + + [Fact] + public void A_plain_document_carries_nothing() + { + var path = Zip("plain.docx", Ooxml(document: Paragraph("Hello"))); + + Assert.Null(_scanner.Scan(path, "docx")); + } + + [Fact] + public void A_vba_project_names_its_modules_and_the_ones_that_start_by_themselves() + { + var parts = Ooxml(document: Paragraph("Hello"), macroPart: true); + var path = Zip("macro.docm", parts, ("word/vbaProject.bin", VbaProject( + ("ThisDocument", "Attribute VB_Name = \"ThisDocument\"\r\nSub Document_Open()\r\n MsgBox \"Hello\"\r\nEnd Sub\r\n"), + ("Module1", "Attribute VB_Name = \"Module1\"\r\nSub Helper()\r\nEnd Sub\r\n")))); + + var report = _scanner.Scan(path, "docm")!; + + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.Macro, Name: "ThisDocument" }); + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.Macro, Name: "Module1" }); + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.AutoRunMacro, Name: "Document_Open", Detail: "ThisDocument" }); + Assert.DoesNotContain(report.Findings, f => f is { Kind: ActiveContentKind.AutoRunMacro, Detail: "Module1" }); + Assert.True(report.HasCode); + } + + [Fact] + public void A_remote_template_and_a_dde_field_are_found_but_hyperlinks_are_not() + { + // A harmless DDE link to a workbook: the field type is what counts. (A command line in it makes Windows + // Defender remove the test file as Exploit:O97M/DDEDownloader.) + var parts = Ooxml(document: Paragraph("Hello") + + " DDEAUTO Excel \"Budget.xlsx\" \"R1C1\" "); + parts["word/_rels/settings.xml.rels"] = Rels( + ("rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/attachedTemplate", "https://attacker.invalid/t.dotm", true)); + parts["word/_rels/document.xml.rels"] = Rels( + ("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", "https://example.com", true)); + var path = Zip("links.docx", parts); + + var report = _scanner.Scan(path, "docx")!; + + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.ExternalLink, Name: "attachedTemplate", Detail: "https://attacker.invalid/t.dotm" }); + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.Dde, Name: "DDEAUTO" }); + Assert.DoesNotContain(report.Findings, f => f.Detail == "https://example.com"); + Assert.False(report.HasCode); + } + + [Fact] + public void An_opendocument_basic_library_and_an_on_load_event_are_found() + { + var content = "" + + "" + + "" + + "" + + "" + + ""; + var path = Zip("macro.odt", new Dictionary + { + ["mimetype"] = "application/vnd.oasis.opendocument.text", + ["content.xml"] = content, + ["META-INF/manifest.xml"] = "", + ["Basic/script-lc.xml"] = "", + ["Basic/Standard/script-lb.xml"] = "", + ["Basic/Standard/Module1.xml"] = "", + }); + + var report = _scanner.Scan(path, "odt")!; + + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.Macro, Name: "Standard.Module1" }); + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.AutoRunMacro, Name: "dom:load" }); + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.ExternalLink, Detail: "http://tracker.invalid/pixel.png" }); + Assert.DoesNotContain(report.Findings, f => f.Detail == "Pictures/inside.png"); + } + + [Fact] + public void An_excel_4_macro_sheet_is_found_in_an_xls() + { + var path = Path.Combine(Folder(), "xlm.xls"); + using (var root = RootStorage.Create(path, OpenMcdf.Version.V3, StorageModeFlags.None)) + using (var workbook = root.CreateStream("Workbook")) + { + var records = new MemoryStream(); + Record(records, 0x0809, new byte[16]); // BOF + Record(records, 0x0085, [0, 0, 0, 0, 0, 0x01, 6, 0, .. "Auto_O"u8.ToArray()]); // BOUNDSHEET: macro sheet + Record(records, 0x0085, [0, 0, 0, 0, 0, 0x00, 6, 0, .. "Sheet1"u8.ToArray()]); // BOUNDSHEET: worksheet + Record(records, 0x000A, []); // EOF + workbook.Write(records.ToArray()); + } + + var report = _scanner.Scan(path, "xls")!; + + var sheet = Assert.Single(report.Findings); + Assert.Equal(ActiveContentKind.Macro, sheet.Kind); + Assert.Equal("Auto_O", sheet.Detail); + } + + [Fact] + public void An_rtf_embedded_object_is_found() + { + var path = Path.Combine(Folder(), "object.rtf"); + File.WriteAllText(path, @"{\rtf1\ansi{\object\objemb\objupdate{\*\objclass Equation.3}{\*\objdata 0105000002000000}}}"); + + var report = _scanner.Scan(path, "rtf")!; + + Assert.Contains(report.Findings, f => f is { Kind: ActiveContentKind.EmbeddedObject, Detail: "Equation.3" }); + Assert.Contains(report.Findings, f => f.Kind == ActiveContentKind.ExternalLink); + } + + [Theory] + [InlineData("http://example.com/a.png", true)] + [InlineData("file:///C:/secret.txt", true)] + [InlineData(@"\\server\share\x.png", true)] + [InlineData("../outside.png", true)] + [InlineData(@"C:\Users\x.png", true)] + [InlineData("Pictures/inside.png", false)] + [InlineData("./Object 1", false)] + [InlineData("data:image/png;base64,AAAA", false)] + [InlineData("vnd.sun.star.GraphicObject:1000", false)] + public void Outside_addresses(string href, bool outside) => Assert.Equal(outside, ActiveContentScanner.IsOutside(href)); + + [Fact] + public void Copy_tokens_decompress_per_ms_ovba() + { + // "abc" as literals, then a copy token (offset 3, length 6): "abcabcabc". + byte[] container = [0x01, 0x05, 0xB0, 0x08, (byte)'a', (byte)'b', (byte)'c', 0x03, 0x20]; + + Assert.Equal("abcabcabc", Encoding.ASCII.GetString(ActiveContentScanner.Decompress(container, 0))); + } + + // ───────────────────────── Builders ───────────────────────── + + private static string Folder() + { + var folder = Path.Combine(Path.GetTempPath(), "filee-active-content", Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(folder); + return folder; + } + + private static string Paragraph(string text) => $"{text}"; + + private static Dictionary Ooxml(string document, bool macroPart = false) => new() + { + ["[Content_Types].xml"] = "" + + "" + + "" + + "" + + (macroPart ? "" : "") + + "", + ["_rels/.rels"] = Rels(("rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "word/document.xml", false)), + ["word/document.xml"] = "" + + document + "", + }; + + private static string Rels(params (string Id, string Type, string Target, bool External)[] relationships) => + "" + + string.Concat(relationships.Select(r => + $"")) + + ""; + + private static string Zip(string name, IReadOnlyDictionary parts, params (string Name, byte[] Data)[] binary) + { + var path = Path.Combine(Folder(), name); + using var zip = ZipFile.Open(path, ZipArchiveMode.Create); + foreach (var (part, text) in parts) + { + using var writer = new StreamWriter(zip.CreateEntry(part).Open(), new UTF8Encoding(false)); + writer.Write(text); + } + foreach (var (part, data) in binary) + { + using var stream = zip.CreateEntry(part).Open(); + stream.Write(data); + } + return path; + } + + /// A vbaProject.bin: a "VBA" storage with the "dir" stream and one stream per module (MS-OVBA). + private static byte[] VbaProject(params (string Name, string Source)[] modules) + { + var dir = new MemoryStream(); + // PROJECTVERSION: its size field says 4, but 6 bytes follow (the reader must not trip over it). + DirRecord(dir, 0x0009, [], sizeOverride: 4); + dir.Write(new byte[6]); + const int cacheSize = 12; // stand-in for the p-code ("performance cache") before the source + foreach (var (name, _) in modules) + { + DirRecord(dir, 0x0019, Encoding.ASCII.GetBytes(name)); + DirRecord(dir, 0x001A, Encoding.ASCII.GetBytes(name)); + DirRecord(dir, 0x0032, Encoding.Unicode.GetBytes(name)); + DirRecord(dir, 0x0031, BitConverter.GetBytes(cacheSize)); + DirRecord(dir, 0x002B, []); + } + + var buffer = new MemoryStream(); + using (var root = RootStorage.Create(buffer, OpenMcdf.Version.V3, StorageModeFlags.LeaveOpen)) + { + var vba = root.CreateStorage("VBA"); + using (var stream = vba.CreateStream("dir")) + stream.Write(Compress(dir.ToArray())); + foreach (var (name, source) in modules) + { + using var stream = vba.CreateStream(name); + stream.Write(new byte[cacheSize]); + stream.Write(Compress(Encoding.ASCII.GetBytes(source))); + } + } + return buffer.ToArray(); + } + + private static void DirRecord(Stream dir, ushort id, byte[] data, uint? sizeOverride = null) + { + dir.Write(BitConverter.GetBytes(id)); + dir.Write(BitConverter.GetBytes(sizeOverride ?? (uint)data.Length)); + dir.Write(data); + } + + private static void Record(Stream stream, ushort type, byte[] data) + { + stream.Write(BitConverter.GetBytes(type)); + stream.Write(BitConverter.GetBytes((ushort)data.Length)); + stream.Write(data); + } + + /// MS-OVBA compression with literals only (valid, just not smaller): enough for small test data. + private static byte[] Compress(byte[] data) + { + var output = new List { 0x01 }; + for (var start = 0; start < data.Length; start += 3000) + { + var chunk = data.Skip(start).Take(3000).ToArray(); + var payload = new List(); + for (var i = 0; i < chunk.Length; i += 8) + { + payload.Add(0x00); // eight literals follow + payload.AddRange(chunk.Skip(i).Take(8)); + } + var header = (ushort)(0xB000 | (payload.Count + 2 - 3)); + output.AddRange(BitConverter.GetBytes(header)); + output.AddRange(payload); + } + return [.. output]; + } +} diff --git a/tests/Filee.Engines.Tests/HardeningAndQualityTests.cs b/tests/Filee.Engines.Tests/HardeningAndQualityTests.cs new file mode 100644 index 000000000..c74775cb0 --- /dev/null +++ b/tests/Filee.Engines.Tests/HardeningAndQualityTests.cs @@ -0,0 +1,242 @@ +// The LibreOffice profile lock-down, the picture size limit, and the quality fixes: PSD layers with transparency +// (#468), Markdown code blocks and quotes (#406), Word's first-page header and tall headers (#416, #506), PDF page +// backgrounds behind text (#433) and merged pages that came out blank (#377). + +using System.Xml.Linq; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Hwp.Hwpx; +using Filee.Engines.Hwp.Hwpx.Docx; +using Filee.Engines.Magick; +using Filee.Engines.Office; +using Filee.Engines.Pdf; +using ImageMagick; +using ImageMagick.Drawing; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using SkiaSharp; + +namespace Filee.Engines.Tests; + +public class HardeningAndQualityTests +{ + private static string Folder() + { + var folder = Path.Combine(Path.GetTempPath(), "filee-hardening", Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(folder); + return folder; + } + + // ───────────────────────── LibreOffice profile ───────────────────────── + + [Fact] + public void A_new_profile_gets_every_setting() + { + var profile = Folder(); + + LibreOfficeSecurity.Apply(profile); + + var document = XDocument.Load(LibreOfficeSecurity.SettingsFile(profile)); + Assert.True(LibreOfficeSecurity.Carries(document)); + Assert.Equal(LibreOfficeSecurity.Settings.Length, document.Root!.Elements("item").Count()); + } + + [Fact] + public void An_existing_profile_keeps_its_own_items_and_gets_ours_back() + { + var profile = Folder(); + var file = LibreOfficeSecurity.SettingsFile(profile); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllText(file, """ + + + 100 + 0 + + """); + + LibreOfficeSecurity.Apply(profile); + + var items = XDocument.Load(file).Root!.Elements("item").ToList(); + Assert.Contains(items, i => i.ToString().Contains("OpenCLMinimumDataSize")); + var level = Assert.Single(items, i => i.ToString().Contains("MacroSecurityLevel")); + Assert.Equal("3", level.Element("prop")!.Element("value")!.Value); + } + + // ───────────────────────── Picture limits ───────────────────────── + + [Fact] + public void A_picture_over_the_limit_is_refused_from_its_header() + { + var folder = Folder(); + var path = Path.Combine(folder, "wide.png"); + using (var image = new MagickImage(MagickColors.White, 2000, 1500)) + image.Write(path, MagickFormat.Png); + + var refused = Assert.Throws(() => MagickLimits.Check(path, "png", null, maxMegapixels: 2)); + Assert.Equal("error.image_too_large", refused.ErrorKey); + MagickLimits.Check(path, "png", null, maxMegapixels: 3); // 3 MP is enough + MagickLimits.Check(path, "png", null, maxMegapixels: 0); // no limit + MagickLimits.Check(path, "svg", null, maxMegapixels: 1); // drawn at the preset's DPI: not checked + } + + // ───────────────────────── #468 PSD ───────────────────────── + + [Fact] + public void A_psd_whose_composite_lacks_its_layers_transparency_is_composed_from_the_layers() + { + var folder = Folder(); + var path = Path.Combine(folder, "layers.psd"); + using (var layers = new MagickImageCollection()) + { + // ImageMagick writes the first image as the composite: opaque white, as a PSD saved without transparency. + layers.Add(new MagickImage(MagickColors.White, 40, 40)); + var layer = new MagickImage(MagickColors.Transparent, 40, 40); + new Drawables().FillColor(MagickColors.Red).Rectangle(0, 0, 19, 39).Draw(layer); + layers.Add(layer); + layers.Write(path, MagickFormat.Psd); + } + + using var read = ImageReader.Read(path, "psd", new Preset()); + + var picture = Assert.Single(read); + Assert.True(picture.HasAlpha); + using var pixels = picture.GetPixels(); + Assert.Equal(0, pixels.GetPixel(30, 20).ToColor()!.A); // right half: transparent in the layer + Assert.Equal(255, pixels.GetPixel(5, 20).ToColor()!.A); // left half: red + } + + // ───────────────────────── #406 Markdown ───────────────────────── + + [Fact] + public void Markdown_code_blocks_and_quotes_become_boxes_and_line_up_with_their_list_item() + { + const string markdown = """ + 1. Install: + + ``` + npm install filee + cd project + ``` + + > Quoted text + """; + + var blocks = MarkdownReader.Read(markdown, Folder()).Sections.Single().Blocks; + + var tables = blocks.OfType().ToList(); + Assert.Equal(2, tables.Count); + var code = tables[0].Rows.Single().Cells.Single(); + Assert.NotNull(code.Fill); + var lines = code.Blocks.OfType().ToList(); + Assert.Equal(2, lines.Count); + Assert.All(lines, l => Assert.Equal(0, l.Format.Before)); + Assert.Equal("Consolas", lines[0].Inlines.OfType().Single().Format.Font); + Assert.True(tables[0].Indent > 0); // inside the list item + + var quote = tables[1].Rows.Single().Cells.Single(); + Assert.NotEqual(HBorderStyle.None, quote.Borders!.Value.Left.Style); + Assert.Equal(HBorderStyle.None, quote.Borders!.Value.Right.Style); + Assert.Equal(0, tables[1].Indent); + } + + // ───────────────────────── #416 / #506 Word headers ───────────────────────── + + [Fact] + public void A_first_page_header_of_its_own_is_shown_on_the_first_page_and_a_tall_header_gets_room() + { + var folder = Folder(); + var path = Path.Combine(folder, "headers.docx"); + const string w = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\""; + static string P(string text) => $"{text}"; + OpcWriter.Save(path, new Dictionary + { + ["[Content_Types].xml"] = "" + + "" + + "" + + "" + + "", + ["_rels/.rels"] = "" + + "", + ["word/_rels/document.xml.rels"] = "" + + "" + + "", + ["word/header1.xml"] = $"{P("Line one")}{P("Line two")}{P("Line three")}{P("Line four")}", + ["word/header2.xml"] = $"{P("Letterhead")}", + ["word/document.xml"] = $"{P("Body")}" + + "" + + "", + }, new Dictionary()); + + var section = DocxReader.Read(path, Path.Combine(folder, "media")).Sections.Single(); + + Assert.True(section.HideFirstHeader); + var box = section.Blocks.OfType().First().Inlines.OfType().Single(); + Assert.Contains("Letterhead", string.Concat(box.Blocks.OfType().SelectMany(p => p.Inlines).OfType().Select(t => t.Text))); + // Four lines don't fit the half inch Word's margins leave: the header area grows. + Assert.True(section.Page!.Header > HwpxUnits.FromTwips(720), $"header area {section.Page.Header}"); + } + + // ───────────────────────── #433 PDF background ───────────────────────── + + [Fact] + public void A_picture_covering_a_text_page_goes_behind_the_text() + { + var folder = Folder(); + var picture = Path.Combine(folder, "background.png"); + using (var image = new MagickImage(new MagickColor("#DDEEFF"), 600, 840)) + image.Write(picture, MagickFormat.Png); + var builder = new PdfBuilder().NewPage(); + var font = builder.Font(); + var pdf = builder.Picture(picture, 0, 0, 595, 842) + .Line("Visible text on the background", 14, font, 72, 120) + .Save(Path.Combine(folder, "background.pdf")); + + var read = PdfDocumentReader.Read(pdf, Path.Combine(folder, "media")); + + var images = read.Sections.SelectMany(s => s.Blocks).OfType().SelectMany(p => p.Inlines).OfType().ToList(); + Assert.Contains(images, i => i.Anchor is { Wrap: "BEHIND_TEXT" }); + } + + // ───────────────────────── #377 merge check ───────────────────────── + + [Fact] + public void A_merged_page_that_is_blank_while_its_source_is_not_is_found() + { + var folder = Folder(); + var source = Path.Combine(folder, "source.pdf"); + Pdf(source, "text on page one", "text on page two"); + var lost = Path.Combine(folder, "lost.pdf"); + Pdf(lost, "text on page one", null); + var good = Path.Combine(folder, "good.pdf"); + Pdf(good, "text on page one", "text on page two"); + + Assert.Equal(1, PdfMergeCheck.FindLostPage([source], lost, CancellationToken.None)); + Assert.Null(PdfMergeCheck.FindLostPage([source], good, CancellationToken.None)); + + // A page with a black bar for every non-null entry, a blank page for null (no fonts needed). + static void Pdf(string path, params string?[] pages) + { + using var document = new PdfDocument(); + foreach (var text in pages) + { + var page = document.AddPage(); + if (text is null) + continue; + using var graphics = XGraphics.FromPdfPage(page); + graphics.DrawRectangle(XBrushes.Black, 72, 72, 300, 120); + } + document.Save(path); + } + } + + [Fact] + public void A_white_bitmap_is_blank_and_one_dark_pixel_is_not() + { + using var bitmap = new SKBitmap(20, 20); + bitmap.Erase(SKColors.White); + Assert.True(PdfMergeCheck.IsBlank(bitmap)); + bitmap.SetPixel(10, 10, SKColors.Black); + Assert.False(PdfMergeCheck.IsBlank(bitmap)); + } +} diff --git a/tests/Filee.Engines.Tests/OfdTests.cs b/tests/Filee.Engines.Tests/OfdTests.cs index 59963eedb..b778653f0 100644 --- a/tests/Filee.Engines.Tests/OfdTests.cs +++ b/tests/Filee.Engines.Tests/OfdTests.cs @@ -3,6 +3,7 @@ // with the frame and labels, values on the page, QR code and seal pictures), a WPS document (page size only in // Document.xml, decimal colours, wrapped body text over two pages), and hostile packages. +using System.Globalization; using System.IO.Compression; using System.Text; using System.Xml.Linq; @@ -65,10 +66,10 @@ private static string MakeInvoice(string folder) var template = new StringBuilder(); void Label(string text, double x, double y, double size = 3) { - template.Append($"""{text}"""); + template.Append(string.Create(CultureInfo.InvariantCulture, $"""{text}""")); } void Rule(double x0, double y0, double x1, double y1) => - template.Append($"""M 0 0 L {x1 - x0} {y1 - y0}"""); + template.Append(string.Create(CultureInfo.InvariantCulture, $"""M 0 0 L {x1 - x0} {y1 - y0}""")); Label("电子发票(普通发票)", 75, 12, 6); Label("发票号码:", 140, 18); Label("开票日期:", 140, 24); @@ -112,7 +113,7 @@ void Rule(double x0, double y0, double x1, double y1) => var page = new StringBuilder(); void Value(string text, double x, double y, int font = 4, double size = 3.5, string extra = "") => - page.Append($"""{text}"""); + page.Append(string.Create(CultureInfo.InvariantCulture, $"""{text}""")); page.Append(""""""); Value("25442000000123456789", 155.5, 18, 5, extra: " DeltaX=\"g 19 2.1\""); Value("2025年03月18日", 155.5, 24); @@ -260,7 +261,7 @@ private static string MakeWps(string folder) Text(zip, "Doc_0/Res.xml", $""""""); string Line(string text, double x, double y, int font = 3, double size = 5.6, string extra = "") => - $"""{text}"""; + string.Create(CultureInfo.InvariantCulture, $"""{text}"""); var first = new StringBuilder(); first.Append(Line("医院信息化建设方案", 67.15, 40, 4, 8.5, " Weight=\"700\"")); // A paragraph of three lines on a 156.8 mm measure (28 characters): the first indented by two characters. @@ -334,7 +335,7 @@ private static string MakeOfd(string folder, string page, string template = "", } private static string TextObject(string text, double x, double y, double size = 3.175, int font = 3, string code = "", double width = 60) => - $"""{text}"""; + string.Create(CultureInfo.InvariantCulture, $"""{text}"""); [Fact] public void A_block_drawn_by_one_text_code_keeps_its_lines_and_a_short_delta_list_goes_on() @@ -509,7 +510,7 @@ public async Task Columns_of_a_list_keep_their_rows_level_and_the_page_its_heigh { var template = new StringBuilder(); void Rule(double x0, double y0, double x1, double y1) => - template.Append($"""M 0 0 L {x1 - x0} {y1 - y0}"""); + template.Append(string.Create(CultureInfo.InvariantCulture, $"""M 0 0 L {x1 - x0} {y1 - y0}""")); Rule(5, 30, 205, 30); Rule(5, 37, 205, 37); Rule(5, 110, 205, 110); diff --git a/tests/Filee.Engines.Tests/OwnCopyTests.cs b/tests/Filee.Engines.Tests/OwnCopyTests.cs index 64486f274..88f9aff6a 100644 --- a/tests/Filee.Engines.Tests/OwnCopyTests.cs +++ b/tests/Filee.Engines.Tests/OwnCopyTests.cs @@ -98,9 +98,33 @@ public void A_tool_on_the_path_is_found_when_filee_has_no_copy() [InlineData("ghostscript", "10.04.0\r\n", "10.4")] [InlineData("libreoffice", "[Version]\nAllLanguages=en-US\nMsiProductVersion=25.2.4.3\n", "25.2")] [InlineData("libreoffice", "LibreOffice 26.2.6.2 98d5f8\n", "26.2")] + // LibreOffice 26.8's version.ini has no MsiProductVersion; Installed adds soffice.exe's product version to it. + [InlineData("libreoffice", "[Version]\nUpdateID=LibreOffice_26_en-US_de\nVendor=The Document Foundation\n", null)] + [InlineData("libreoffice", "[Version]\nUpdateID=LibreOffice_26_en-US_de\nVendor=The Document Foundation\nLibreOffice 26.8.1.1", "26.8")] public void Versions_are_read_from_each_program(string id, string output, string? expected) => Assert.Equal(expected is null ? null : Version.Parse(expected), CopyVersions.ParseVersion(id, output)); + [Fact] + public void A_libreoffice_without_msi_version_is_told_by_its_program() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "version.ini is read on Windows only"); + // Any program with a version resource stands in for soffice.exe. + var program = Environment.ProcessPath!; + var folder = Directory.CreateTempSubdirectory("filee-lo-version-").FullName; + try + { + File.WriteAllText(Path.Combine(folder, "version.ini"), "[Version]\nVendor=The Document Foundation\n"); + File.Copy(program, Path.Combine(folder, "soffice.exe")); + var product = System.Diagnostics.FileVersionInfo.GetVersionInfo(program); + + Assert.Equal(new Version(product.ProductMajorPart, product.ProductMinorPart), CopyVersions.Installed("libreoffice", folder)); + } + finally + { + Directory.Delete(folder, true); + } + } + [Fact] public void Expected_versions_come_from_engines_json() => Assert.NotNull(CopyVersions.Expected("ffmpeg")); diff --git a/tests/Filee.Engines.Tests/SpreadsheetFormatTests.cs b/tests/Filee.Engines.Tests/SpreadsheetFormatTests.cs index 738fce0d6..9dd0e4301 100644 --- a/tests/Filee.Engines.Tests/SpreadsheetFormatTests.cs +++ b/tests/Filee.Engines.Tests/SpreadsheetFormatTests.cs @@ -5,6 +5,7 @@ using System.IO.Compression; using System.Text; +using System.Text.RegularExpressions; using System.Xml.Linq; using Filee.Core.Conversion; using Filee.Core.Presets; @@ -598,22 +599,29 @@ public async Task LibreOffice_reads_our_ods_and_we_read_its_ods_and_xls() foreach (var (name, book) in books) { var sales = book.Sheets[0]; + // An ODS by LibreOffice keeps the text Calc showed (text:p), with the separators of Calc's locale + // ("1.350.000원", "12,8%" on a German system): compare those without the separators. + Func shown = name == "ODS by LibreOffice" ? WithoutSeparators : text => text; Assert.True(new[] { "매출", "메모", "넓은 표" }.SequenceEqual(book.Sheets.Select(s => s.Name)), name); - Assert.True(sales.Get(2, 1)!.Text == "1,350,000원", $"{name}: {sales.Get(2, 1)!.Text}"); - Assert.True(sales.Get(2, 3)!.Text == "12.8%", $"{name}: {sales.Get(2, 3)!.Text}"); + Assert.True(shown(sales.Get(2, 1)!.Text) == shown("1,350,000원"), $"{name}: {sales.Get(2, 1)!.Text}"); + Assert.True(shown(sales.Get(2, 3)!.Text) == shown("12.8%"), $"{name}: {sales.Get(2, 3)!.Text}"); // LibreOffice shows built-in dates in its own locale's order: compare the value. Assert.True(sales.Get(2, 2)!.Value == October1, $"{name}: {sales.Get(2, 2)!.Value}"); - Assert.True(sales.Get(4, 1)!.Text == "2,220,000원", $"{name}: {sales.Get(4, 1)!.Text}"); + Assert.True(shown(sales.Get(4, 1)!.Text) == shown("2,220,000원"), $"{name}: {sales.Get(4, 1)!.Text}"); Assert.True(new CellRange(0, 0, 0, 3) == Assert.Single(sales.Merges), name); Assert.True(sales.Get(1, 0)!.Style.Align == CellAlign.Center, name); - // LibreOffice measures character widths with its own fonts, so widths through ODS move by up to a fifth; - // XLS keeps Excel's own unit. - var tolerance = name.StartsWith("XLS", StringComparison.Ordinal) ? 0.01 : 0.25; - Assert.True(Math.Abs(sales.ColumnWidth(0) - 14) <= 14 * tolerance, $"{name}: {sales.ColumnWidth(0)}"); + // LibreOffice measures character widths with its own fonts, so widths it writes move by up to a fifth. XLS + // keeps Excel's unit, but for Korean text LibreOffice measures with its locale's default East Asian font + // (Microsoft YaHei on a German system: 15.27 for 14). The ratio of the columns stays. + Assert.True(Math.Abs(sales.ColumnWidth(0) - 14) <= 14 * 0.25, $"{name}: {sales.ColumnWidth(0)}"); Assert.True(Math.Abs(sales.ColumnWidth(0) / sales.ColumnWidth(1) - 14.0 / 12) < 0.02, $"{name}: {sales.ColumnWidth(0)} / {sales.ColumnWidth(1)}"); } } + /// "1.350.000원" and "1,350,000원" as "1350000원", "12,8 %" and "12.8%" as "12.8%". + private static string WithoutSeparators(string text) => + Regex.Replace(Regex.Replace(text, @"(?<=\d)[.,\u00A0\u202F' ](?=\d{3}(?!\d))", ""), @"(?<=\d),(?=\d)", ".").Replace(" %", "%"); + private async Task LibreOfficeAsync(IConverter libreOffice, string input, string from, string to) { var output = Path.Combine(Path.GetDirectoryName(input)!, $"{Path.GetFileNameWithoutExtension(input)}.lo-{from}.{to}"); diff --git a/tests/Filee.Setup.Tests/CetCheckTests.cs b/tests/Filee.Setup.Tests/CetCheckTests.cs new file mode 100644 index 000000000..8186c2f64 --- /dev/null +++ b/tests/Filee.Setup.Tests/CetCheckTests.cs @@ -0,0 +1,122 @@ +// #486: Setup turns Hardware-enforced Stack Protection off for Filee's programs only when the installed command line +// dies with the CET failure on Windows 10, and the uninstaller turns it back on. Nothing runs PowerShell for real. + +using Filee.Setup.Installation; +using Filee.Setup.Platform; + +namespace Filee.Setup.Tests; + +public class CetCheckTests +{ + private static readonly Version OldWindows10 = new(10, 0, 17763); + private static readonly Version Windows11 = new(10, 0, 26100); + + [Theory] + [InlineData(unchecked((int)0xC0000409), 17763, true)] + [InlineData(unchecked((int)0xC0000409), 19045, true)] + [InlineData(unchecked((int)0xC0000409), 22000, false)] // Windows 11 has the fix + [InlineData(1, 17763, false)] // an ordinary error + [InlineData(-1, 17763, false)] // didn't start or timed out + public void Only_the_fail_fast_status_on_windows_10_is_the_cet_failure(int exitCode, int build, bool expected) => + Assert.Equal(expected, CetCheck.IsCetFailure(exitCode, new Version(10, 0, build))); + + [Fact] + public void The_exception_is_made_and_noted_when_filee_cannot_start() + { + using var bench = Bench(OldWindows10, cliExitCode: CetCheck.FailFastStatus); + + Assert.True(bench.Install().Succeeded); + + Assert.Contains("run filee-cli.exe version", bench.System.Calls); + Assert.Contains(bench.System.Calls, c => c.StartsWith("run powershell.exe", StringComparison.Ordinal) + && c.Contains("Set-ProcessMitigation -Name 'Filee.exe' -Disable UserShadowStack") + && c.Contains("Set-ProcessMitigation -Name 'filee-cli.exe' -Disable UserShadowStack")); + Assert.NotNull(bench.Registry.Get(Hive.LocalMachine, SetupNames.UninstallKey, SetupNames.ShadowStackOffValue)); + } + + [Theory] + [InlineData(0)] // Filee starts: nothing to do + [InlineData(1)] // fails for another reason: not this workaround's business + public void Nothing_changes_when_filee_starts_or_fails_otherwise(int exitCode) + { + using var bench = Bench(OldWindows10, exitCode); + + Assert.True(bench.Install().Succeeded); + + Assert.DoesNotContain(bench.System.Calls, c => c.StartsWith("run powershell.exe", StringComparison.Ordinal)); + Assert.Null(bench.Registry.Get(Hive.LocalMachine, SetupNames.UninstallKey, SetupNames.ShadowStackOffValue)); + } + + [Fact] + public void Windows_11_is_not_even_probed() + { + using var bench = Bench(Windows11, CetCheck.FailFastStatus); + + Assert.True(bench.Install().Succeeded); + + Assert.DoesNotContain("run filee-cli.exe version", bench.System.Calls); + Assert.DoesNotContain(bench.System.Calls, c => c.StartsWith("run powershell.exe", StringComparison.Ordinal)); + } + + [Fact] + public void An_install_for_one_user_cannot_make_the_exception() + { + // Set-ProcessMitigation writes to HKLM: without administrator rights the log points to the README instead. + using var bench = Bench(OldWindows10, CetCheck.FailFastStatus); + var folder = bench.Paths.DefaultInstallFolder(InstallScope.User); + + Assert.True(bench.Install(bench.Choices() with { Folder = folder, Scope = InstallScope.User }).Succeeded); + + Assert.DoesNotContain(bench.System.Calls, c => c.StartsWith("run powershell.exe", StringComparison.Ordinal)); + } + + [Fact] + public void An_update_keeps_the_note_although_filee_starts_now() + { + // The first install made the exception; with it, the command line starts, but the note must stay for uninstalling. + using var bench = Bench(OldWindows10, CetCheck.FailFastStatus); + Assert.True(bench.Install().Succeeded); + bench.System.Run = (_, _) => 0; + + Assert.True(bench.Install(previous: PreviousInstall.Find(bench.Registry, InstallScope.Machine)).Succeeded); + + Assert.NotNull(bench.Registry.Get(Hive.LocalMachine, SetupNames.UninstallKey, SetupNames.ShadowStackOffValue)); + } + + [Fact] + public void Uninstalling_removes_the_exception_it_made() + { + using var bench = Bench(OldWindows10, CetCheck.FailFastStatus); + Assert.True(bench.Install().Succeeded); + bench.System.Calls.Clear(); + + new Uninstaller(bench.Context).Run(bench.InstallFolder, InstallRecord.Read(bench.InstallFolder)!, deleteSettings: false, + bench.Temp.Sub("trash")); + + Assert.Contains(bench.System.Calls, c => c.StartsWith("run powershell.exe", StringComparison.Ordinal) + && c.Contains("Set-ProcessMitigation -Name 'Filee.exe' -Remove")); + } + + [Fact] + public void Uninstalling_leaves_mitigations_alone_when_setup_made_none() + { + using var bench = Bench(OldWindows10, 0); + Assert.True(bench.Install().Succeeded); + bench.System.Calls.Clear(); + + new Uninstaller(bench.Context).Run(bench.InstallFolder, InstallRecord.Read(bench.InstallFolder)!, deleteSettings: false, + bench.Temp.Sub("trash")); + + Assert.DoesNotContain(bench.System.Calls, c => c.StartsWith("run powershell.exe", StringComparison.Ordinal)); + } + + /// A bench on whose installed "filee-cli.exe version" ends with . + private static SetupBench Bench(Version windows, int cliExitCode) + { + var bench = new SetupBench(); + bench.Context = bench.Context with { WindowsVersion = windows }; + bench.System.Run = (executable, arguments) => + Path.GetFileName(executable) == CetCheck.CliExe && arguments == "version" ? cliExitCode : 0; + return bench; + } +} diff --git a/tests/Filee.Setup.Tests/TestSupport.cs b/tests/Filee.Setup.Tests/TestSupport.cs index e600671d5..672a970da 100644 --- a/tests/Filee.Setup.Tests/TestSupport.cs +++ b/tests/Filee.Setup.Tests/TestSupport.cs @@ -130,7 +130,7 @@ public SetupBench(string version = "2.0.0", Action? app = nu public MemoryRegistry Registry { get; } = new(); public FakeSystem System { get; } = new(); public SetupLog Log { get; } = new(null); - public SetupContext Context { get; } + public SetupContext Context { get; set; } public string AppFolder { get; } public string SetupExe { get; private set; } public TempFolder Temp => _temp;