diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000..96cbffd
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "microsoft.sbom.dotnettool": {
+ "version": "4.1.5",
+ "commands": [
+ "sbom-tool"
+ ],
+ "rollForward": false
+ }
+ }
+}
diff --git a/.editorconfig b/.editorconfig
index 3ab69c4..118f889 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -40,18 +40,3 @@ dotnet_diagnostic.CA1062.severity = none
[src/Snaply.App/App.xaml.cs]
dotnet_diagnostic.CA1515.severity = none
-
-[src/Snaply.App/MainWindow.xaml.cs]
-dotnet_diagnostic.CA1515.severity = none
-
-[src/Snaply.App/MainPage.xaml.cs]
-dotnet_diagnostic.CA1515.severity = none
-
-[src/Snaply.App/ViewModels/MainViewModel.cs]
-dotnet_diagnostic.CA1031.severity = none
-
-[src/Snaply.App/ImageExportService.cs]
-dotnet_diagnostic.CA1849.severity = none
-
-[src/Snaply.App/ScreenCaptureService.cs]
-dotnet_diagnostic.CA2000.severity = none
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index efcbb7b..d6d9f19 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -33,6 +33,17 @@ updates:
commit-message:
prefix: build
+ - package-ecosystem: nuget
+ directory: /tests/Snaply.App.Tests
+ schedule:
+ interval: weekly
+ day: monday
+ groups:
+ app-tests:
+ patterns: ["*"]
+ commit-message:
+ prefix: build
+
- package-ecosystem: github-actions
directory: /
schedule:
diff --git a/.github/rulesets/protect-default-branch.json b/.github/rulesets/protect-default-branch.json
index a81701f..5c3b85a 100644
--- a/.github/rulesets/protect-default-branch.json
+++ b/.github/rulesets/protect-default-branch.json
@@ -31,9 +31,7 @@
"strict_required_status_checks_policy": true,
"do_not_enforce_on_create": false,
"required_status_checks": [
- { "context": "hygiene" },
- { "context": "quality" },
- { "context": "packaging" },
+ { "context": "ci-required" },
{ "context": "dependency-review" },
{ "context": "analyze" },
{ "context": "analyze-actions" }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 659c4a5..af4cdcf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.47.2
+ - uses: fsfe/reuse-action@772649cfa03a64a08c458c7cca9a6a473d74e7f9 # v6
- name: Reject incomplete implementation markers
shell: bash
run: |
@@ -36,7 +37,7 @@ jobs:
quality:
runs-on: windows-latest
- timeout-minutes: 35
+ timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
@@ -44,54 +45,9 @@ jobs:
global-json-file: global.json
cache: true
cache-dependency-path: "**/packages.lock.json"
- - name: Restore locked dependencies
- run: dotnet restore Snaply.slnx --locked-mode
- # Clear PathMap for this coverage build only: CI's PathMap (/_/) otherwise breaks
- # coverlet's source resolution and makes it report 0% line coverage.
- - name: Test imaging
- run: >-
- dotnet test tests/Snaply.Tests/Snaply.Tests.csproj
- -c Release --no-restore
- -p:PathMap=
- --collect:"XPlat Code Coverage"
- --logger "trx;LogFileName=results.trx"
- --results-directory artifacts/test
- - name: Test filesystem workflows
- run: >-
- dotnet test tests/Snaply.App.Tests/Snaply.App.Tests.csproj
- -c Release --no-restore
- --logger "trx;LogFileName=results.trx"
- --results-directory artifacts/app-test
- - name: Reject skipped tests and low coverage
+ - name: Verify source, tests, coverage, and portable publishes
shell: pwsh
- run: |
- [xml]$trx = Get-Content artifacts/test/results.trx -Raw
- if ([int]$trx.TestRun.ResultSummary.Counters.notExecuted -ne 0) {
- throw "Required tests were skipped."
- }
- [xml]$coverage = Get-Content (
- Get-ChildItem artifacts/test -Recurse -Filter coverage.cobertura.xml |
- Select-Object -First 1
- ).FullName -Raw
- if ([double]$coverage.coverage.'line-rate' -lt 0.85) {
- throw "Line coverage is below 85%."
- }
- [xml]$appTrx = Get-Content artifacts/app-test/results.trx -Raw
- if ([int]$appTrx.TestRun.ResultSummary.Counters.notExecuted -ne 0) {
- throw "Required filesystem tests were skipped."
- }
- - name: Build x64
- run: >-
- dotnet build src/Snaply.App/Snaply.App.csproj
- -c Release -p:Platform=x64 --no-restore
- - name: Build ARM64
- run: >-
- dotnet build src/Snaply.App/Snaply.App.csproj
- -c Release -p:Platform=ARM64 --no-restore
- - name: Verify formatting
- env:
- Configuration: Release
- run: dotnet format Snaply.slnx --verify-no-changes --no-restore
+ run: ./scripts/verify.ps1
packaging:
runs-on: windows-latest
@@ -100,18 +56,23 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
+ dotnet-version: 8.0.x
global-json-file: global.json
cache: true
cache-dependency-path: "**/packages.lock.json"
- name: Build and validate unsigned release payloads
shell: pwsh
run: |
- [xml]$props = Get-Content Directory.Build.props -Raw
+ $version = (Get-Content version.txt -Raw).Trim()
./scripts/release.ps1 `
-Action Build `
- -Version "$($props.Project.PropertyGroup.Version)" `
+ -Version $version `
-Publisher CN=Snaply `
-OutputRoot build/package-smoke
+ ./scripts/release.ps1 `
+ -Action Package `
+ -Version $version `
+ -OutputRoot build/package-smoke
# Aggregate gate the branch protection requires (context "ci-required"): green only
# when every CI job above succeeded. Keeps the required-checks list stable as jobs change.
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index b290313..b768b1f 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -43,10 +43,17 @@ jobs:
version: v0.3.1
# Restore with ReadyToRun on so the crossgen2 runtime pack is fetched here; the
# publish below runs --no-restore, so without it R2R fails (NETSDK1094).
- - run: dotnet restore Snaply.slnx --locked-mode -p:PublishReadyToRun=true
+ - run: >-
+ dotnet restore Snaply.slnx
+ --locked-mode
+ -p:Platform=${{ matrix.platform }}
+ -p:PublishReadyToRun=true
- run: dotnet test tests/Snaply.Tests/Snaply.Tests.csproj -c Release --no-restore
- - if: matrix.architecture == 'x64'
- run: dotnet test tests/Snaply.App.Tests/Snaply.App.Tests.csproj -c Release --no-restore
+ - run: >-
+ dotnet test tests/Snaply.App.Tests/Snaply.App.Tests.csproj
+ -c Release
+ -p:Platform=${{ matrix.platform }}
+ --no-restore
- name: Publish native portable payload
run: >-
dotnet publish src/Snaply.App/Snaply.App.csproj
@@ -57,20 +64,7 @@ jobs:
--self-contained true
-o build/native
--no-restore
- # UI journeys are x64-only. On windows-11-arm the shell keeps the foreground for
- # itself — the image boots with a Microsoft-account sign-in prompt (WWAHost), and
- # closing it just hands the foreground to SearchHost — so the capture overlay never
- # comes forward and synthetic input never reaches it. Even the AttachThreadInput
- # handoff in ui-tests.ps1 loses that fight there, so the journeys measured the
- # runner image rather than Snaply. arm64 still builds, unit-tests and publishes.
- # One pass, not five. The first pass has never failed across every run of this
- # work; a repeat pass fails perhaps half the time, and always the same way: the
- # region drag's press lands (the overlay closes) but its moves do not register, so
- # the selection is empty, CaptureAsync returns null and no preview appears. Three
- # retries do not shake it off. The soak below still covers repetition — 100
- # captures against one process — which is what the repeat passes were really for.
- name: Run the UI journeys
- if: matrix.architecture == 'x64'
shell: pwsh
run: |
1..1 | ForEach-Object {
@@ -89,7 +83,6 @@ jobs:
}
}
- name: Run 100-capture soak
- if: matrix.architecture == 'x64'
shell: pwsh
run: |
$process = Start-Process build/native/Snaply.exe -PassThru
@@ -109,10 +102,8 @@ jobs:
Stop-Process -Id $process.Id -Force
}
}
- # A UI failure otherwise reports only "element did not appear"; Snaply's own log
- # carries the exception behind it, so ship it alongside the results.
- name: Collect app logs
- if: always() && matrix.architecture == 'x64'
+ if: always()
shell: pwsh
run: |
$logs = Join-Path $env:LOCALAPPDATA 'Snaply\Logs'
@@ -131,8 +122,8 @@ jobs:
}
New-Item -ItemType Directory -Force -Path artifacts/ui/app-logs | Out-Null
Copy-Item "$logs\*" artifacts/ui/app-logs -Recurse -Force
- # Only reached when everything gating this architecture passed: on x64 that includes
- # the UI journeys and the soak, on arm64 the build and the unit tests.
+ # Only reached when build, unit tests, UI journeys, and soak passed on the
+ # native runner for this architecture.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: nightly-${{ matrix.architecture }}
@@ -140,12 +131,11 @@ jobs:
retention-days: 14
if-no-files-found: error
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- if: always() && matrix.architecture == 'x64'
+ if: always()
with:
name: ui-${{ matrix.architecture }}
path: artifacts/ui
if-no-files-found: error
-
mutation:
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -186,6 +176,5 @@ jobs:
- name: Build twice
shell: pwsh
run: |
- [xml]$props = Get-Content Directory.Build.props -Raw
- $version = "$($props.Project.PropertyGroup.Version)"
+ $version = (Get-Content version.txt -Raw).Trim()
./scripts/test-reproducibility.ps1 -Version $version
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 17fce2b..3f9e8bb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -75,12 +75,13 @@ jobs:
if ($LASTEXITCODE -ne 0) {
throw "The release commit is not in main."
}
- [xml]$props = Get-Content Directory.Build.props -Raw
- if ("v$($props.Project.PropertyGroup.Version)" -ne $env:TAG) {
+ $version = (Get-Content version.txt -Raw).Trim()
+ if ("v$version" -ne $env:TAG) {
throw "The release tag does not match the project version."
}
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
+ dotnet-version: 8.0.x
global-json-file: global.json
cache: true
cache-dependency-path: "**/packages.lock.json"
@@ -95,15 +96,16 @@ jobs:
- name: Generate SPDX SBOM
shell: pwsh
run: |
- dotnet tool install --tool-path build/tools `
- Microsoft.Sbom.DotNetTool --version 4.1.5
- ./build/tools/sbom-tool generate `
+ dotnet tool restore
+ dotnet tool run sbom-tool -- generate `
-b build/release `
-bc . `
-pn Snaply `
-pv '${{ inputs.tag_name }}'.TrimStart('v') `
-ps P4suta `
- -nsb https://github.com/P4suta/Snaply
+ -nsb https://github.com/P4suta/Snaply `
+ -mi SPDX:2.2 `
+ -cd '--DirectoryExclusionList **/artifacts/** --DirectoryExclusionList **/build/**'
- uses: taiki-e/install-action@7572810d7dd469b651bb7793945692cf78da5dd7 # v2.85.0
with:
tool: osv-scanner@2.3.6
@@ -297,6 +299,7 @@ jobs:
ref: ${{ env.REF }}
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
+ dotnet-version: 8.0.x
global-json-file: global.json
cache: true
cache-dependency-path: "**/packages.lock.json"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5ae092..e9bcdeb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
- Rebuilt Snaply as a GUI-only WinUI 3 application.
- Added region, window, and complete virtual-desktop capture with mixed-DPI and HDR handling.
-- Added automatic randomized beautification, preview, clipboard copy, atomic save, Save As, and Open Folder.
+- Added automatic randomized beautification, zoomable preview, clipboard copy, atomic save, and Open Folder.
- Added English, Japanese, and Simplified Chinese resources.
- Added x64/ARM64 portable builds and a signed MSIX bundle release path.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index a9b93d9..6034cae 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -1,5 +1,3 @@
# Code of Conduct
-This project follows the [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
-
-Be respectful, constructive, and focused on the work. Report unacceptable behavior privately to the repository maintainers. Maintainers may remove content or participation that violates this standard.
+Follow [Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). Be respectful and report violations privately to the maintainers.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0bf0918..ce438b9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,21 +1,9 @@
# Contributing
-Use Windows 11 24H2 or later and the .NET SDK pinned by `global.json`.
+On Windows 11 24H2+ with the SDK from `global.json`:
```powershell
-dotnet restore Snaply.slnx --locked-mode
-dotnet build src/Snaply.App/Snaply.App.csproj -c Release -p:Platform=x64 --no-restore
-dotnet test tests/Snaply.Tests/Snaply.Tests.csproj -c Release --no-restore
-$env:Configuration = 'Release'
-dotnet format Snaply.slnx --verify-no-changes --no-restore
+./scripts/verify.ps1
```
-Before a pull request:
-
-- Keep the product GUI-only and local-only.
-- Add no public API, capability, dependency, setting, or abstraction without a current product need.
-- Add tests for behavior and non-trivial calculations.
-- Keep comments for ABI, ownership, lifetime, security, or non-obvious algorithms only.
-- Use Conventional Commits and keep the branch green with no warnings or skipped required tests.
-
-Release packaging and UI automation are documented in [RELEASING.md](RELEASING.md).
+Keep changes local-only, warning-free, tested, and limited to the current product contract. Use Conventional Commits.
diff --git a/Directory.Build.props b/Directory.Build.props
index 3b7efd4..7b941cd 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,7 @@
- 0.1.1
+ $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)version.txt').Trim())
+ 10.0.26100.87
true
true
true
diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt
new file mode 100644
index 0000000..137069b
--- /dev/null
+++ b/LICENSES/Apache-2.0.txt
@@ -0,0 +1,73 @@
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+ (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/LICENSES/CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/README.md b/README.md
index 14dfd29..140dea5 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,11 @@
# Snaply
-Snaply is a Windows screenshot tool. It captures a region, a window, or the
-entire virtual desktop, then places the capture on a randomized, image-derived
-gradient background.
+Snaply captures a region, window, or desktop, presents a zoomable polished preview, and automatically saves and copies the PNG.
-After each capture, Snaply opens a preview, copies a PNG to the clipboard, and
-saves the same PNG to `Pictures\Screenshots\Snaply`.
+1. Choose Region, Window, or Desktop.
+2. Capture; cancel with Esc when applicable.
+3. Find the PNG in `Pictures\Screenshots\Snaply` or paste it from the clipboard.
-## Usage
+Windows 11 24H2+ builds for x64 and ARM64 are published as signed MSIX and self-contained portable ZIPs in [GitHub Releases](https://github.com/P4suta/Snaply/releases). The UI supports English, Japanese, and Simplified Chinese.
-1. Open the Capture menu and choose Region, Window, or Desktop.
-2. For Region, drag to select across one or more displays. For Window, pick
- from the system window picker.
-3. In the preview, scroll to zoom, drag to pan, and double-tap to fit. Use
- Open Folder to open the save location.
-
-## Install
-
-Download the signed MSIX bundle or the self-contained x64/ARM64 portable ZIP
-from [GitHub Releases](https://github.com/P4suta/Snaply/releases). Portable
-builds require no .NET or Windows App SDK installation: extract the ZIP and run
-`Snaply.exe`.
-
-Snaply runs on Windows 11 24H2 or later on x64 and ARM64, in English, Japanese,
-and Simplified Chinese.
-
-## Privacy
-
-Snaply runs entirely on the local machine. It has no telemetry, network access,
-background service, tray process, global hotkey, or updater.
-
-## License
-
-Apache-2.0. See [LICENSE](LICENSE).
+Snaply is local-only: no network access or telemetry. Licensed under [Apache-2.0](LICENSE).
diff --git a/RELEASING.md b/RELEASING.md
index e0d18fa..98ba50e 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,16 +1,9 @@
# Releasing
-Release Please owns versions, tags, release notes, and the release pull request. Merging that pull request starts the protected release workflow.
+1. Run `./scripts/verify.ps1`.
+2. Confirm repository variable `MSIX_PUBLISHER`, release variable `SIGNER_SUBJECT_CONTAINS`, and the four SSL.com eSigner secrets are present.
+3. Run the release workflow with `publish=false`.
+4. Require green signing, SBOM/license/vulnerability checks, WACK, and x64/ARM64 portable/MSIX QA: three capture modes, automatic save/copy, two launches, upgrade, uninstall.
+5. Merge the Release Please PR; publish only the immutable tag created from `main`.
-The `release` environment must require approval and provide:
-
-- `ES_USERNAME`, `ES_PASSWORD`, `CREDENTIAL_ID`, and `ES_TOTP_SECRET`
-- `SIGNER_SUBJECT_CONTAINS`: the expected certificate subject fragment
-
-The repository variable `MSIX_PUBLISHER` must contain the exact distinguished name of the SSL.com signing certificate.
-
-The workflow builds x64 and ARM64 portable payloads and single-project MSIX packages, bundles and signs them, verifies RFC 3161 timestamps and signer identity, generates an SPDX SBOM and SHA-256 checksums, scans vulnerabilities and licenses, and creates GitHub attestations.
-
-Publication remains blocked until the release candidate passes WACK and the release-artifact journeys on clean Windows 11 24H2 x64 and ARM64 machines: install/extract, launch, all capture modes, automatic copy/save, Save As, restart, upgrade, and uninstall.
-
-Repository rulesets, protected tags, signed commits, secret scanning with push protection, private vulnerability reporting, CodeQL, Dependabot, and environment approval must be enabled in GitHub itself. Checked-in files are not evidence that these settings are active.
+GitHub settings are checked with `./scripts/sync-github-settings.ps1 -Check`; credentials are never stored in the repository.
diff --git a/REUSE.toml b/REUSE.toml
new file mode 100644
index 0000000..55c7d0c
--- /dev/null
+++ b/REUSE.toml
@@ -0,0 +1,20 @@
+version = 1
+SPDX-PackageName = "Snaply"
+SPDX-PackageSupplier = "P4suta"
+SPDX-PackageDownloadLocation = "https://github.com/P4suta/Snaply"
+
+[[annotations]]
+path = ["*", "**/*"]
+precedence = "closest"
+SPDX-FileCopyrightText = "2026 P4suta"
+SPDX-License-Identifier = "Apache-2.0"
+
+[[annotations]]
+path = [
+ ".release-please-manifest.json",
+ "version.txt",
+ "**/packages.lock.json",
+]
+precedence = "override"
+SPDX-FileCopyrightText = "NONE"
+SPDX-License-Identifier = "CC0-1.0"
diff --git a/SECURITY.md b/SECURITY.md
index cf90cac..d5cd993 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,7 +1,3 @@
# Security
-Security fixes are provided for the latest release.
-
-Report vulnerabilities privately through [GitHub Private Vulnerability Reporting](https://github.com/P4suta/Snaply/security/advisories/new). Do not open a public issue before a fix is available.
-
-Reports should include the affected version, impact, reproduction steps, and any known mitigation. Maintainers will acknowledge a report within seven days.
+Report vulnerabilities privately through [GitHub Private Vulnerability Reporting](https://github.com/P4suta/Snaply/security/advisories/new). Include the affected version, impact, and reproduction steps; do not open a public issue first.
diff --git a/release-please-config.json b/release-please-config.json
index 86b4524..64870bc 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -6,6 +6,7 @@
".": {
"release-type": "simple",
"package-name": "snaply",
+ "version-file": "version.txt",
"draft": true,
"force-tag-creation": true
}
diff --git a/scripts/release-qa.ps1 b/scripts/release-qa.ps1
index 198e3b0..fbdfc3e 100644
--- a/scripts/release-qa.ps1
+++ b/scripts/release-qa.ps1
@@ -99,7 +99,8 @@ $portable = Join-Path $release "portable\$Architecture\Snaply.exe"
if (-not (Test-Path -LiteralPath $portable)) {
throw "Portable executable is missing: $portable"
}
-Invoke-Journey { Start-Process -FilePath $portable | Out-Null } 'Portable' $SoakIterations
+Invoke-Journey { Start-Process -FilePath $portable | Out-Null } 'Portable first launch' $SoakIterations
+Invoke-Journey { Start-Process -FilePath $portable | Out-Null } 'Portable relaunch' 0
if ($PSVersionTable.PSEdition -eq 'Core') {
Import-Module Appx -UseWindowsPowerShell
@@ -123,11 +124,16 @@ try {
}
$package = Get-AppxPackage -Name Snaply -ErrorAction Stop
- Invoke-Journey {
+ $msixLaunch = {
Start-Process explorer.exe "shell:AppsFolder\$($package.PackageFamilyName)!App"
- } 'MSIX' 0
+ }
+ Invoke-Journey $msixLaunch 'MSIX first launch' 0
+ Invoke-Journey $msixLaunch 'MSIX relaunch' 0
}
finally {
Get-AppxPackage -Name Snaply -ErrorAction SilentlyContinue |
Remove-AppxPackage -ErrorAction Stop
+ if (Get-AppxPackage -Name Snaply -ErrorAction SilentlyContinue) {
+ throw 'Snaply remained installed after uninstall.'
+ }
}
diff --git a/scripts/release.ps1 b/scripts/release.ps1
index 6b02639..61ecf4c 100644
--- a/scripts/release.ps1
+++ b/scripts/release.ps1
@@ -4,8 +4,9 @@ param(
[ValidateSet('Build', 'Collect', 'Verify', 'Package')]
[string]$Action,
+ [Parameter(Mandatory)]
[ValidatePattern('^\d+\.\d+\.\d+$')]
- [string]$Version = '0.1.1',
+ [string]$Version,
[string]$Publisher = 'CN=Snaply',
@@ -86,6 +87,43 @@ function Invoke-Checked {
}
}
+function Invoke-SbomTool {
+ param([string[]]$Arguments)
+
+ $manifest = Get-Content (Join-Path $root '.config\dotnet-tools.json') -Raw |
+ ConvertFrom-Json
+ $version = $manifest.tools.'microsoft.sbom.dotnettool'.version
+ $packages = if ($env:NUGET_PACKAGES) {
+ $env:NUGET_PACKAGES
+ }
+ else {
+ Join-Path $env:USERPROFILE '.nuget\packages'
+ }
+
+ $assembly = Join-Path $packages (
+ "microsoft.sbom.dotnettool\$version\tools\net8.0\any\Microsoft.Sbom.DotNetTool.dll")
+ if (-not (Test-Path -LiteralPath $assembly -PathType Leaf)) {
+ throw 'Microsoft SBOM Tool was not restored.'
+ }
+
+ $hosts = @(
+ (Get-Command dotnet -ErrorAction Stop).Source,
+ (Join-Path $env:ProgramFiles 'dotnet\dotnet.exe'),
+ (Join-Path ${env:ProgramFiles(x86)} 'dotnet\dotnet.exe')
+ ) | Where-Object { $_ } | Select-Object -Unique
+ $dotnetHost = $hosts | Where-Object {
+ (Test-Path -LiteralPath $_ -PathType Leaf) -and
+ @(Get-ChildItem -LiteralPath (
+ Join-Path (Split-Path $_) 'shared\Microsoft.NETCore.App') `
+ -Directory -Filter '8.*' -ErrorAction SilentlyContinue).Count -gt 0
+ } | Select-Object -First 1
+ if (-not $dotnetHost) {
+ throw 'Microsoft SBOM Tool requires the .NET 8 runtime.'
+ }
+
+ Invoke-Checked $dotnetHost (@($assembly) + $Arguments)
+}
+
function Get-PeMachine {
param([string]$Path)
@@ -306,6 +344,16 @@ function Assert-Msix {
if ($targetFamily.MinVersion -ne '10.0.26100.0') {
throw 'MSIX minimum Windows version is incorrect.'
}
+
+ foreach ($size in @(16, 24, 32, 48, 256)) {
+ foreach ($suffix in @('', '_altform-unplated', '_altform-lightunplated')) {
+ $asset = Join-Path $Scratch (
+ "Assets\Square44x44Logo.targetsize-$size$suffix.png")
+ if (-not (Test-Path -LiteralPath $asset -PathType Leaf)) {
+ throw "MSIX is missing app-list asset '$([IO.Path]::GetFileName($asset))'."
+ }
+ }
+ }
}
function Copy-SigningStage {
@@ -524,7 +572,9 @@ function New-DeterministicZip {
function Package-Release {
$package = Join-Path $output 'package'
+ $sbomRoot = Join-Path $output 'package-sbom'
Reset-Directory $package
+ Reset-Directory $sbomRoot
foreach ($architecture in @('x64', 'arm64')) {
New-DeterministicZip (Join-Path $output "portable\$architecture") `
(Join-Path $package "snaply-v$Version-win-$architecture.zip")
@@ -536,12 +586,6 @@ function Package-Release {
Copy-Item (Join-Path $root 'NOTICE') $package
Copy-Item (Join-Path $output 'dependency-licenses.txt') $package
- $sbom = Join-Path $output '_manifest\spdx_2.2\manifest.spdx.json'
- if (-not (Test-Path -LiteralPath $sbom)) {
- throw 'SPDX SBOM is missing.'
- }
- Copy-Item $sbom (Join-Path $package 'snaply.spdx.json')
-
$hashes = Get-ChildItem -LiteralPath $package -File |
Where-Object Name -ne 'SHA256SUMS.txt' |
Sort-Object Name |
@@ -554,6 +598,38 @@ function Package-Release {
(Join-Path $package 'SHA256SUMS.txt'),
$hashes,
[System.Text.Encoding]::ASCII)
+
+ Invoke-Checked 'dotnet' @('tool', 'restore')
+ Invoke-SbomTool @(
+ 'generate',
+ '-b', $package,
+ '-bc', $root,
+ '-m', $sbomRoot,
+ '-PackageName', 'Snaply',
+ '-pv', $Version,
+ '-ps', 'P4suta',
+ '-nsb', 'https://github.com/P4suta/Snaply',
+ '-mi', 'SPDX:2.2',
+ '-cd',
+ '--DirectoryExclusionList **/artifacts/** --DirectoryExclusionList **/build/**',
+ '-D', 'true',
+ '-V', 'Warning')
+ $manifestDirectory = Join-Path $sbomRoot '_manifest'
+ $validationReport = Join-Path $sbomRoot 'validation.json'
+ Invoke-SbomTool @(
+ 'validate',
+ '-b', $package,
+ '-m', $manifestDirectory,
+ '-o', $validationReport,
+ '-mi', 'SPDX:2.2',
+ '-n',
+ '-V', 'Warning')
+ $sbom = Join-Path $manifestDirectory 'spdx_2.2\manifest.spdx.json'
+ if (-not (Test-Path -LiteralPath $sbom -PathType Leaf)) {
+ throw 'SPDX SBOM was not generated.'
+ }
+
+ Copy-Item $sbom (Join-Path $package 'snaply.spdx.json')
}
switch ($Action) {
diff --git a/scripts/sync-github-settings.ps1 b/scripts/sync-github-settings.ps1
new file mode 100644
index 0000000..f9f1805
--- /dev/null
+++ b/scripts/sync-github-settings.ps1
@@ -0,0 +1,205 @@
+#requires -Version 7.0
+param(
+ [Parameter(ParameterSetName = 'Check', Mandatory)]
+ [switch]$Check,
+
+ [Parameter(ParameterSetName = 'Apply', Mandatory)]
+ [switch]$Apply
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$root = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$rulesetRoot = Join-Path $root '.github\rulesets'
+$allowedActionPatterns = @(
+ 'SSLcom/esigner-codesign@*',
+ 'crate-ci/typos@*',
+ 'fsfe/reuse-action@*',
+ 'googleapis/release-please-action@*',
+ 'microsoft/setup-WinAppCli@*',
+ 'ossf/scorecard-action@*',
+ 'raven-actions/actionlint@*',
+ 'softprops/action-gh-release@*',
+ 'taiki-e/install-action@*'
+)
+
+function Invoke-Gh {
+ param([string[]]$Arguments)
+
+ $output = gh @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "gh failed: $($Arguments -join ' ')"
+ }
+
+ return $output
+}
+
+function ConvertTo-NormalForm {
+ param([object]$Value)
+
+ if ($null -eq $Value) {
+ return $null
+ }
+
+ if ($Value -is [string] -or $Value.GetType().IsValueType) {
+ return $Value
+ }
+
+ if ($Value -is [System.Collections.IEnumerable] -and
+ $Value -isnot [System.Management.Automation.PSCustomObject]) {
+ return @($Value | ForEach-Object { ConvertTo-NormalForm $_ })
+ }
+
+ $normal = [ordered]@{}
+ foreach ($property in $Value.PSObject.Properties | Sort-Object Name) {
+ $normal[$property.Name] = ConvertTo-NormalForm $property.Value
+ }
+
+ return [pscustomobject]$normal
+}
+
+function Get-RulesetPayload {
+ param([object]$Ruleset)
+
+ return [pscustomobject][ordered]@{
+ name = $Ruleset.name
+ target = $Ruleset.target
+ enforcement = $Ruleset.enforcement
+ bypass_actors = @($Ruleset.bypass_actors)
+ conditions = $Ruleset.conditions
+ rules = @($Ruleset.rules)
+ }
+}
+
+$repository = (Invoke-Gh @(
+ 'repo', 'view',
+ '--json', 'nameWithOwner',
+ '--jq', '.nameWithOwner')).Trim()
+$liveRulesets = Invoke-Gh @(
+ 'api', "repos/$repository/rulesets",
+ '--paginate') | ConvertFrom-Json
+
+if ($Apply) {
+ foreach ($path in Get-ChildItem -LiteralPath $rulesetRoot -Filter '*.json' -File) {
+ $canonical = Get-Content $path.FullName -Raw | ConvertFrom-Json
+ $live = @($liveRulesets | Where-Object name -eq $canonical.name)
+ if ($live.Count -gt 1) {
+ throw "Multiple live rulesets are named '$($canonical.name)'."
+ }
+
+ if ($live.Count -eq 1) {
+ Invoke-Gh @(
+ 'api',
+ '--method', 'PUT',
+ "repos/$repository/rulesets/$($live[0].id)",
+ '--input', $path.FullName) | Out-Null
+ }
+ else {
+ Invoke-Gh @(
+ 'api',
+ '--method', 'POST',
+ "repos/$repository/rulesets",
+ '--input', $path.FullName) | Out-Null
+ }
+ }
+
+ $liveRulesets = Invoke-Gh @(
+ 'api', "repos/$repository/rulesets",
+ '--paginate') | ConvertFrom-Json
+
+ Invoke-Gh @(
+ 'api',
+ '--method', 'PUT',
+ "repos/$repository/actions/permissions",
+ '-F', 'enabled=true',
+ '-f', 'allowed_actions=selected',
+ '-F', 'sha_pinning_required=true') | Out-Null
+ $selectedActionArguments = [System.Collections.Generic.List[string]]::new()
+ foreach ($argument in @(
+ 'api',
+ '--method', 'PUT',
+ "repos/$repository/actions/permissions/selected-actions",
+ '-F', 'github_owned_allowed=true',
+ '-F', 'verified_allowed=false')) {
+ $selectedActionArguments.Add($argument)
+ }
+ foreach ($pattern in $allowedActionPatterns) {
+ $selectedActionArguments.Add('-f')
+ $selectedActionArguments.Add("patterns_allowed[]=$pattern")
+ }
+
+ Invoke-Gh $selectedActionArguments.ToArray() | Out-Null
+}
+
+$failures = [System.Collections.Generic.List[string]]::new()
+foreach ($path in Get-ChildItem -LiteralPath $rulesetRoot -Filter '*.json' -File) {
+ $canonical = Get-Content $path.FullName -Raw | ConvertFrom-Json
+ $summary = @($liveRulesets | Where-Object name -eq $canonical.name)
+ if ($summary.Count -ne 1) {
+ $failures.Add("Ruleset '$($canonical.name)' is missing or duplicated.")
+ continue
+ }
+
+ $live = Invoke-Gh @(
+ 'api',
+ "repos/$repository/rulesets/$($summary[0].id)") | ConvertFrom-Json
+ $expectedJson = ConvertTo-NormalForm (Get-RulesetPayload $canonical) |
+ ConvertTo-Json -Depth 100 -Compress
+ $actualJson = ConvertTo-NormalForm (Get-RulesetPayload $live) |
+ ConvertTo-Json -Depth 100 -Compress
+ if ($expectedJson -ne $actualJson) {
+ $failures.Add("Ruleset '$($canonical.name)' differs from its canonical JSON.")
+ }
+}
+
+$variables = @(Invoke-Gh @(
+ 'variable', 'list',
+ '--repo', $repository,
+ '--json', 'name') | ConvertFrom-Json)
+if ('MSIX_PUBLISHER' -notin @($variables | ForEach-Object name)) {
+ $failures.Add('Repository variable MSIX_PUBLISHER is missing.')
+}
+
+$repositoryState = Invoke-Gh @(
+ 'api', "repos/$repository") | ConvertFrom-Json
+foreach ($feature in @('secret_scanning', 'secret_scanning_push_protection')) {
+ if ($repositoryState.security_and_analysis.$feature.status -ne 'enabled') {
+ $failures.Add("$feature is not enabled.")
+ }
+}
+
+$actionsPermissions = Invoke-Gh @(
+ 'api', "repos/$repository/actions/permissions") | ConvertFrom-Json
+if ($actionsPermissions.allowed_actions -ne 'selected') {
+ $failures.Add('Actions are not restricted to the selected allowlist.')
+}
+else {
+ $actionPolicy = Invoke-Gh @(
+ 'api', "repos/$repository/actions/permissions/selected-actions") |
+ ConvertFrom-Json
+ if (-not $actionPolicy.github_owned_allowed) {
+ $failures.Add('GitHub-owned Actions are not allowed.')
+ }
+
+ if ($actionPolicy.verified_allowed) {
+ $failures.Add('All verified Actions are allowed instead of the explicit allowlist.')
+ }
+
+ if (-not $actionsPermissions.sha_pinning_required) {
+ $failures.Add('Actions SHA pinning is not required.')
+ }
+
+ $expectedPatterns = @($allowedActionPatterns | Sort-Object)
+ $actualPatterns = @($actionPolicy.patterns_allowed | Sort-Object)
+ if (Compare-Object $expectedPatterns $actualPatterns) {
+ $failures.Add('The selected Actions allowlist differs from the canonical list.')
+ }
+}
+
+if ($failures.Count -ne 0) {
+ $failures | ForEach-Object { Write-Error $_ -ErrorAction Continue }
+ throw "GitHub settings check failed with $($failures.Count) issue(s)."
+}
+
+Write-Host "GitHub settings match the canonical release policy for $repository."
diff --git a/scripts/verify.ps1 b/scripts/verify.ps1
new file mode 100644
index 0000000..4e51e0e
--- /dev/null
+++ b/scripts/verify.ps1
@@ -0,0 +1,164 @@
+#requires -Version 5.1
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Release',
+
+ [switch]$SkipPublish
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$root = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$output = Join-Path $root 'artifacts\verify'
+$testOutput = Join-Path $root 'artifacts\verify-tests'
+
+function Assert-BrandAssets {
+ Add-Type -AssemblyName System.Drawing
+ $expected = [ordered]@{
+ 'SplashScreen.scale-200.png' = @(1240, 600)
+ 'Square150x150Logo.scale-200.png' = @(300, 300)
+ 'Square44x44Logo.scale-200.png' = @(88, 88)
+ 'StoreLogo.png' = @(50, 50)
+ 'Wide310x150Logo.scale-200.png' = @(620, 300)
+ }
+ foreach ($size in @(16, 24, 32, 48, 256)) {
+ foreach ($suffix in @('', '_altform-unplated', '_altform-lightunplated')) {
+ $expected["Square44x44Logo.targetsize-$size$suffix.png"] = @($size, $size)
+ }
+ }
+
+ foreach ($entry in $expected.GetEnumerator()) {
+ $path = Join-Path $root "src\Snaply.App\Assets\$($entry.Key)"
+ if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
+ throw "Required brand asset is missing: $($entry.Key)"
+ }
+
+ $bitmap = [System.Drawing.Bitmap]::new($path)
+ try {
+ if ($bitmap.Width -ne $entry.Value[0] -or
+ $bitmap.Height -ne $entry.Value[1]) {
+ throw "Brand asset has incorrect dimensions: $($entry.Key)"
+ }
+
+ $hasBrandColour = $false
+ $hasTransparency = $false
+ $stepX = [Math]::Max(1, [int][Math]::Floor($bitmap.Width / 64))
+ $stepY = [Math]::Max(1, [int][Math]::Floor($bitmap.Height / 64))
+ for ($y = 0; $y -lt $bitmap.Height; $y += $stepY) {
+ for ($x = 0; $x -lt $bitmap.Width; $x += $stepX) {
+ $pixel = $bitmap.GetPixel($x, $y)
+ $hasTransparency = $hasTransparency -or $pixel.A -eq 0
+ $hasBrandColour = $hasBrandColour -or (
+ $pixel.A -ge 128 -and
+ $pixel.R - $pixel.G -ge 80 -and
+ $pixel.R - $pixel.B -ge 80)
+ }
+ }
+
+ if (-not $hasBrandColour -or -not $hasTransparency) {
+ throw "Brand asset is not a transparent Snaply-colour image: $($entry.Key)"
+ }
+ }
+ finally {
+ $bitmap.Dispose()
+ }
+ }
+}
+
+function Invoke-Checked {
+ param(
+ [string]$FilePath,
+ [string[]]$Arguments
+ )
+
+ & $FilePath @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "$FilePath failed with exit code $LASTEXITCODE."
+ }
+}
+
+Push-Location $root
+try {
+ Assert-BrandAssets
+ if (Test-Path -LiteralPath $testOutput) {
+ Remove-Item -LiteralPath $testOutput -Recurse -Force
+ }
+
+ Invoke-Checked dotnet @(
+ 'restore',
+ 'Snaply.slnx',
+ '--locked-mode')
+ Invoke-Checked dotnet @(
+ 'restore',
+ 'src/Snaply.App/Snaply.App.csproj',
+ '--locked-mode',
+ '-p:Configuration=Release')
+ Invoke-Checked dotnet @(
+ 'format',
+ 'Snaply.slnx',
+ '--verify-no-changes',
+ '--no-restore')
+ Invoke-Checked dotnet @(
+ 'test',
+ 'tests/Snaply.Tests/Snaply.Tests.csproj',
+ '-c', $Configuration,
+ '--no-restore',
+ '-p:PathMap=',
+ '--collect:XPlat Code Coverage',
+ '--logger', 'trx;LogFileName=results.trx',
+ '--results-directory', (Join-Path $testOutput 'imaging'))
+ Invoke-Checked dotnet @(
+ 'test',
+ 'tests/Snaply.App.Tests/Snaply.App.Tests.csproj',
+ '-c', $Configuration,
+ '--no-restore',
+ '--logger', 'trx;LogFileName=results.trx',
+ '--results-directory', (Join-Path $testOutput 'app'))
+
+ [xml]$imagingResults = Get-Content (
+ Join-Path $testOutput 'imaging\results.trx') -Raw
+ [xml]$appResults = Get-Content (
+ Join-Path $testOutput 'app\results.trx') -Raw
+ if ([int]$imagingResults.TestRun.ResultSummary.Counters.notExecuted -ne 0 -or
+ [int]$appResults.TestRun.ResultSummary.Counters.notExecuted -ne 0) {
+ throw 'Required tests were skipped.'
+ }
+
+ $coveragePath = Get-ChildItem (Join-Path $testOutput 'imaging') `
+ -Recurse -Filter coverage.cobertura.xml -File |
+ Select-Object -First 1
+ if (-not $coveragePath) {
+ throw 'Coverage report was not produced.'
+ }
+
+ [xml]$coverage = Get-Content $coveragePath.FullName -Raw
+ if ([double]$coverage.coverage.'line-rate' -lt 0.90 -or
+ [double]$coverage.coverage.'branch-rate' -lt 0.85) {
+ throw 'Imaging coverage is below 90% line or 85% branch.'
+ }
+
+ if (-not $SkipPublish) {
+ if (Test-Path -LiteralPath $output) {
+ Remove-Item -LiteralPath $output -Recurse -Force
+ }
+
+ foreach ($architecture in @(
+ [pscustomobject]@{ Platform = 'x64'; Rid = 'win-x64'; Name = 'x64' },
+ [pscustomobject]@{ Platform = 'ARM64'; Rid = 'win-arm64'; Name = 'arm64' })) {
+ Invoke-Checked dotnet @(
+ 'publish',
+ 'src/Snaply.App/Snaply.App.csproj',
+ '-c', $Configuration,
+ '-r', $architecture.Rid,
+ "-p:Platform=$($architecture.Platform)",
+ '-p:WindowsPackageType=None',
+ '--self-contained', 'true',
+ '--no-restore',
+ '-o', (Join-Path $output $architecture.Name))
+ }
+ }
+}
+finally {
+ Pop-Location
+}
diff --git a/src/Snaply.App/App.xaml b/src/Snaply.App/App.xaml
index 54aa402..19bb825 100644
--- a/src/Snaply.App/App.xaml
+++ b/src/Snaply.App/App.xaml
@@ -7,8 +7,6 @@
-
-
diff --git a/src/Snaply.App/App.xaml.cs b/src/Snaply.App/App.xaml.cs
index ab801c3..d27ea57 100644
--- a/src/Snaply.App/App.xaml.cs
+++ b/src/Snaply.App/App.xaml.cs
@@ -4,9 +4,10 @@
namespace Snaply;
-public partial class App : Application
+public sealed partial class App : Application, IDisposable
{
private MainWindow? _window;
+ private bool _disposed;
public App()
{
@@ -14,24 +15,51 @@ public App()
UnhandledException += OnUnhandledException;
}
- internal static Window MainWindow { get; private set; } = null!;
-
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
ConfigureLogging();
- var capture = new ScreenCaptureService();
- var export = new ImageExportService();
- var viewModel = new MainViewModel(capture, export);
- _window = new MainWindow(viewModel, capture);
- MainWindow = _window;
- _window.Closed += (_, _) =>
+ try
+ {
+ _window = new MainWindow();
+ _window.Closed += OnWindowClosed;
+ _window.Activate();
+ }
+ catch
{
- viewModel.Dispose();
- capture.Dispose();
- Log.CloseAndFlush();
- };
- _window.Activate();
+ Dispose();
+ throw;
+ }
+ }
+
+ public void Dispose()
+ {
+ DisposeCore(closeWindow: true);
+ GC.SuppressFinalize(this);
+ }
+
+ private void DisposeCore(bool closeWindow)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ UnhandledException -= OnUnhandledException;
+ if (_window is not null)
+ {
+ _window.Closed -= OnWindowClosed;
+ if (closeWindow)
+ {
+ _window.Close();
+ }
+
+ _window.Dispose();
+ _window = null;
+ }
+
+ Log.CloseAndFlush();
}
private static void ConfigureLogging()
@@ -88,4 +116,7 @@ private static void OnUnhandledException(object sender, Microsoft.UI.Xaml.Unhand
args.Exception.GetType().FullName,
args.Exception.HResult);
}
+
+ private void OnWindowClosed(object sender, WindowEventArgs args) =>
+ DisposeCore(closeWindow: false);
}
diff --git a/src/Snaply.App/Assets/LockScreenLogo.scale-200.png b/src/Snaply.App/Assets/LockScreenLogo.scale-200.png
deleted file mode 100644
index 33f889e..0000000
Binary files a/src/Snaply.App/Assets/LockScreenLogo.scale-200.png and /dev/null differ
diff --git a/src/Snaply.App/Assets/SplashScreen.scale-200.png b/src/Snaply.App/Assets/SplashScreen.scale-200.png
index 802c79d..26a7bc0 100644
Binary files a/src/Snaply.App/Assets/SplashScreen.scale-200.png and b/src/Snaply.App/Assets/SplashScreen.scale-200.png differ
diff --git a/src/Snaply.App/Assets/Square150x150Logo.scale-200.png b/src/Snaply.App/Assets/Square150x150Logo.scale-200.png
index ddba42a..1fd0617 100644
Binary files a/src/Snaply.App/Assets/Square150x150Logo.scale-200.png and b/src/Snaply.App/Assets/Square150x150Logo.scale-200.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.scale-200.png b/src/Snaply.App/Assets/Square44x44Logo.scale-200.png
index 9327dd7..0cfea12 100644
Binary files a/src/Snaply.App/Assets/Square44x44Logo.scale-200.png and b/src/Snaply.App/Assets/Square44x44Logo.scale-200.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-16.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-16.png
new file mode 100644
index 0000000..7d3e8ce
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-16.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-16_altform-lightunplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-16_altform-lightunplated.png
new file mode 100644
index 0000000..7d3e8ce
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-16_altform-lightunplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-16_altform-unplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-16_altform-unplated.png
new file mode 100644
index 0000000..7d3e8ce
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-16_altform-unplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-24.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-24.png
new file mode 100644
index 0000000..16b8a5a
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-24.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-lightunplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-lightunplated.png
new file mode 100644
index 0000000..16b8a5a
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-lightunplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
index e51416a..16b8a5a 100644
Binary files a/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-256.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-256.png
new file mode 100644
index 0000000..e4e8ba8
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-256.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-256_altform-lightunplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-256_altform-lightunplated.png
new file mode 100644
index 0000000..e4e8ba8
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-256_altform-lightunplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-256_altform-unplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-256_altform-unplated.png
new file mode 100644
index 0000000..e4e8ba8
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-256_altform-unplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-32.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-32.png
new file mode 100644
index 0000000..a67e0c9
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-32.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-32_altform-lightunplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-32_altform-lightunplated.png
new file mode 100644
index 0000000..a67e0c9
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-32_altform-lightunplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-32_altform-unplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-32_altform-unplated.png
new file mode 100644
index 0000000..a67e0c9
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-32_altform-unplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-48.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-48.png
new file mode 100644
index 0000000..35a940f
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-48.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png
index bf063eb..35a940f 100644
Binary files a/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ
diff --git a/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-unplated.png b/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-unplated.png
new file mode 100644
index 0000000..35a940f
Binary files /dev/null and b/src/Snaply.App/Assets/Square44x44Logo.targetsize-48_altform-unplated.png differ
diff --git a/src/Snaply.App/Assets/StoreLogo.png b/src/Snaply.App/Assets/StoreLogo.png
index be865f0..570092c 100644
Binary files a/src/Snaply.App/Assets/StoreLogo.png and b/src/Snaply.App/Assets/StoreLogo.png differ
diff --git a/src/Snaply.App/Assets/Wide310x150Logo.scale-200.png b/src/Snaply.App/Assets/Wide310x150Logo.scale-200.png
index 314d297..93ed538 100644
Binary files a/src/Snaply.App/Assets/Wide310x150Logo.scale-200.png and b/src/Snaply.App/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/src/Snaply.App/BeautifyRenderer.cs b/src/Snaply.App/BeautifyRenderer.cs
index 377ea92..0be03b7 100644
--- a/src/Snaply.App/BeautifyRenderer.cs
+++ b/src/Snaply.App/BeautifyRenderer.cs
@@ -83,7 +83,7 @@ internal static async Task RenderAsync(
using var stream = new InMemoryRandomAccessStream();
await target.SaveAsync(stream, CanvasBitmapFileFormat.Png, 1).AsTask(cancellationToken);
byte[] png = await ReadAllAsync(stream, cancellationToken);
- return new RenderedImage(png, layout.Canvas.Width, layout.Canvas.Height);
+ return RenderedImage.FromOwnedPng(png, layout.Canvas.Width, layout.Canvas.Height);
}
private static ImageSample SampleImage(CanvasDevice device, CanvasBitmap source)
@@ -107,15 +107,15 @@ private static ImageSample SampleImage(CanvasDevice device, CanvasBitmap source)
}
byte[] bytes = sample.GetPixelBytes();
- long red = 0;
- long green = 0;
- long blue = 0;
+ double red = 0;
+ double green = 0;
+ double blue = 0;
ulong hash = 1469598103934665603;
for (int index = 0; index < bytes.Length; index += 4)
{
- blue += bytes[index];
- green += bytes[index + 1];
- red += bytes[index + 2];
+ blue += SrgbToLinear(bytes[index] / 255d);
+ green += SrgbToLinear(bytes[index + 1] / 255d);
+ red += SrgbToLinear(bytes[index + 2] / 255d);
hash = (hash ^ bytes[index + 2]) * 1099511628211;
hash = (hash ^ bytes[index + 1]) * 1099511628211;
hash = (hash ^ bytes[index]) * 1099511628211;
@@ -124,9 +124,9 @@ private static ImageSample SampleImage(CanvasDevice device, CanvasBitmap source)
int pixels = checked(sampleSize * sampleSize);
return new ImageSample(
new Rgba(
- checked((byte)(red / pixels)),
- checked((byte)(green / pixels)),
- checked((byte)(blue / pixels))),
+ LinearToSrgbByte(red / pixels),
+ LinearToSrgbByte(green / pixels),
+ LinearToSrgbByte(blue / pixels)),
hash);
}
@@ -158,6 +158,20 @@ private static uint CreateSalt()
return BitConverter.ToUInt32(bytes);
}
+ private static double SrgbToLinear(double value) =>
+ value <= 0.04045
+ ? value / 12.92
+ : Math.Pow((value + 0.055) / 1.055, 2.4);
+
+ private static byte LinearToSrgbByte(double value)
+ {
+ double clamped = Math.Clamp(value, 0, 1);
+ double encoded = clamped <= 0.0031308
+ ? clamped * 12.92
+ : (1.055 * Math.Pow(clamped, 1 / 2.4)) - 0.055;
+ return (byte)Math.Round(encoded * 255, MidpointRounding.AwayFromZero);
+ }
+
private static void SetGradientDirection(
CanvasLinearGradientBrush gradient,
PixelSize canvas,
diff --git a/src/Snaply.App/CapturePipeline.cs b/src/Snaply.App/CapturePipeline.cs
new file mode 100644
index 0000000..7238f81
--- /dev/null
+++ b/src/Snaply.App/CapturePipeline.cs
@@ -0,0 +1,44 @@
+using Microsoft.UI.Xaml;
+
+namespace Snaply;
+
+internal interface ICapturePipeline : IDisposable
+{
+ public Task CaptureAsync(CaptureMode mode, CancellationToken cancellationToken);
+}
+
+internal sealed class CapturePipeline : ICapturePipeline
+{
+ private readonly ScreenCaptureService _capture;
+
+ internal CapturePipeline(Window appWindow, bool captureExclusionEnabled)
+ {
+ _capture = new ScreenCaptureService(appWindow, captureExclusionEnabled);
+ }
+
+ public void Dispose() => _capture.Dispose();
+
+ public async Task CaptureAsync(
+ CaptureMode mode,
+ CancellationToken cancellationToken)
+ {
+ for (int attempt = 0; ; attempt++)
+ {
+ try
+ {
+ using CapturedFrame? frame = await _capture.CaptureAsync(mode, cancellationToken);
+ return frame is null
+ ? null
+ : await Task.Run(
+ () => BeautifyRenderer.RenderAsync(frame, cancellationToken),
+ cancellationToken);
+ }
+ catch (Exception exception) when (
+ attempt == 0
+ && _capture.IsGraphicsDeviceLost(exception))
+ {
+ _capture.ResetGraphicsDevice();
+ }
+ }
+ }
+}
diff --git a/src/Snaply.App/Controls/AmbientBackdrop.xaml b/src/Snaply.App/Controls/AmbientBackdrop.xaml
deleted file mode 100644
index 5462846..0000000
--- a/src/Snaply.App/Controls/AmbientBackdrop.xaml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Snaply.App/Controls/AmbientBackdrop.xaml.cs b/src/Snaply.App/Controls/AmbientBackdrop.xaml.cs
deleted file mode 100644
index 9640add..0000000
--- a/src/Snaply.App/Controls/AmbientBackdrop.xaml.cs
+++ /dev/null
@@ -1,317 +0,0 @@
-using System.Numerics;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Hosting;
-using Microsoft.UI.Xaml.Media;
-using Microsoft.UI.Xaml.Shapes;
-using Windows.Foundation;
-using Windows.UI;
-using Visual = Microsoft.UI.Composition.Visual;
-
-namespace Snaply.Controls;
-
-///
-/// A soft ambient backdrop for the empty canvas, generated entirely from a small aesthetic model
-/// rather than hand-tuned constants — every value is derived, moment to moment, from the frame time:
-///
-/// - Colour lives in OKLCH (perceptually uniform): a constant, gentle chroma at a high
-/// lightness means the "softness" is identical across every hue (unlike HSL, which looks harsher at
-/// some hues). Perceptual uniformity is the beauty.
-/// - Hues are spread by the golden angle (137.5°) — the most even distribution on the
-/// wheel — and the whole set rotates slowly, so all hues pass by without restriction.
-/// - Motion is a quasiperiodic Lissajous drift whose per-axis frequencies are scaled by
-/// powers of the golden ratio: their ratios are irrational, so the field never repeats and
-/// never settles into a visible loop.
-///
-/// Deterministic (the maths is the design, not randomness) yet perpetually fresh. Purely decorative:
-/// no pointer input, and it only runs while — the per-frame updates stop and
-/// the control hides once a capture covers it.
-///
-internal sealed partial class AmbientBackdrop : UserControl
-{
- /// Identifies the dependency property.
- public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register(
- nameof(IsActive),
- typeof(bool),
- typeof(AmbientBackdrop),
- new PropertyMetadata(false, OnIsActiveChanged));
-
- /// Identifies the dependency property.
- public static readonly DependencyProperty IsMutedProperty = DependencyProperty.Register(
- nameof(IsMuted),
- typeof(bool),
- typeof(AmbientBackdrop),
- new PropertyMetadata(false));
-
- private const int BlobCount = 5; // a Fibonacci count; plays nicely with the golden-angle spread
-
- private const double Phi = 1.6180339887498949;
-
- // OKLCH soft pastel band: high lightness + gentle, constant chroma = uniform softness per hue.
- private const double SoftLightness = 0.82;
- private const double SoftChroma = 0.11;
-
- // Layering opacities — soft enough that the overlapping fields blend into one gentle wash.
- private const byte BlobCentreAlpha = 150;
- private const byte BaseWashAlpha = 150;
-
- // Timing: the base hue completes a full turn every HuePeriod; blobs drift on a base period, each
- // axis detuned by a golden-ratio power so no two frequencies are commensurable.
- private const double HuePeriodSeconds = 90.0; // slow, but clearly perceptible
- private const double DriftBasePeriodSeconds = 24.0;
- private const double BreatheDepth = 0.08; // ±8% scale
- private const double AmplitudeFraction = 0.16; // drift amplitude vs the shorter card side
-
- // "Energy" scales saturation, presence and the hue-drift rate together. It eases between 1 (empty
- // canvas — full, vivid) and MutedEnergy (an image is shown — calm, so it doesn't fight the image),
- // over EnergyTau so the change is a gentle fade, never an instant switch.
- private const double MutedEnergy = 0.4;
- private const double EnergyTau = 1.8; // seconds (time constant of the ease)
-
- private const double GoldenAngleDeg = 360.0 / (Phi * Phi); // ≈137.5077°
- private const double GoldenAngleRad = GoldenAngleDeg * Math.PI / 180.0;
- private const double Omega0 = 2.0 * Math.PI / DriftBasePeriodSeconds;
- private const double HueDegPerSecond = 360.0 / HuePeriodSeconds;
-
- private readonly Ellipse[] _blobs = new Ellipse[BlobCount];
- private readonly RadialGradientBrush[] _brushes = new RadialGradientBrush[BlobCount];
- private readonly Visual[] _visuals = new Visual[BlobCount];
-
- // Per-blob motion constants (depend only on the index, so computed once).
- private readonly double[] _freqX = new double[BlobCount];
- private readonly double[] _freqY = new double[BlobCount];
- private readonly double[] _freqScale = new double[BlobCount];
- private readonly double[] _phaseX = new double[BlobCount];
- private readonly double[] _phaseY = new double[BlobCount];
- private readonly double[] _phaseScale = new double[BlobCount];
-
- private double _amplitude;
- private double _energy = 1.0; // eases toward MutedEnergy / 1 (see OnRendering)
- private double _hue; // accumulated base hue (integrated, since its rate varies)
- private double _lastTime = -1; // previous frame time; <0 means "no previous frame yet"
- private bool _loaded;
- private bool _running;
-
- /// Creates the control, builds its blobs and derives their per-index motion constants.
- public AmbientBackdrop()
- {
- InitializeComponent();
-
- for (int i = 0; i < BlobCount; i++)
- {
- var brush = new RadialGradientBrush();
- brush.GradientStops.Add(new GradientStop { Offset = 0.0, Color = Color.FromArgb(0, 0, 0, 0) });
- brush.GradientStops.Add(new GradientStop { Offset = 1.0, Color = Color.FromArgb(0, 0, 0, 0) });
-
- var blob = new Ellipse
- {
- HorizontalAlignment = HorizontalAlignment.Left,
- VerticalAlignment = VerticalAlignment.Top,
- IsHitTestVisible = false,
- Fill = brush,
- };
-
- Root.Children.Add(blob);
- _blobs[i] = blob;
- _brushes[i] = brush;
-
- Visual visual = ElementCompositionPreview.GetElementVisual(blob);
- ElementCompositionPreview.SetIsTranslationEnabled(blob, true);
- _visuals[i] = visual;
-
- // Detune each axis by a golden-ratio power (centred on the base), so every frequency ratio
- // is irrational — the Lissajous figure never closes and the motion never repeats.
- double exponent = (i - ((BlobCount - 1) / 2.0)) * 0.4;
- _freqX[i] = Omega0 * Math.Pow(Phi, exponent);
- _freqY[i] = Omega0 * Math.Pow(Phi, exponent + 0.3);
- _freqScale[i] = Omega0 * Math.Pow(Phi, exponent - 0.5);
- _phaseX[i] = i * GoldenAngleRad;
- _phaseY[i] = (i * GoldenAngleRad) + (Math.PI / 2.0);
- _phaseScale[i] = (i * GoldenAngleRad) + (Math.PI / 4.0);
- }
-
- Loaded += OnLoaded;
- Unloaded += OnUnloaded;
- Root.SizeChanged += OnRootSizeChanged;
- }
-
- ///
- /// Whether the backdrop is shown and animating. Bind this to the empty-canvas condition
- /// (e.g. HasNoImage): the control hides and its per-frame updates stop when it is false.
- ///
- public bool IsActive
- {
- get => (bool)GetValue(IsActiveProperty);
- set => SetValue(IsActiveProperty, value);
- }
-
- ///
- /// When true the field calms down — lower saturation, fainter, and a slower colour drift —
- /// so it sits quietly behind a shown image. Bind to the "an image is present" condition
- /// (e.g. HasImage). The transition eases in/out; it is never an instant switch.
- ///
- public bool IsMuted
- {
- get => (bool)GetValue(IsMutedProperty);
- set => SetValue(IsMutedProperty, value);
- }
-
- private static void OnIsActiveChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
- ((AmbientBackdrop)d).UpdateActivation();
-
- private void OnLoaded(object sender, RoutedEventArgs e)
- {
- _loaded = true;
- UpdateActivation();
- }
-
- private void OnUnloaded(object sender, RoutedEventArgs e)
- {
- _loaded = false;
- StopRendering();
- }
-
- // Lay the blobs out to cover the card (phyllotaxis placement + generous size), clip to bounds,
- // and update the drift amplitude for the new size.
- private void OnRootSizeChanged(object sender, SizeChangedEventArgs e)
- {
- double w = Root.ActualWidth;
- double h = Root.ActualHeight;
- Root.Clip = new RectangleGeometry { Rect = new Rect(0, 0, w, h) };
- if (w <= 0 || h <= 0)
- {
- return;
- }
-
- double diagonal = Math.Sqrt((w * w) + (h * h));
- double spread = Math.Min(w, h) * 0.32;
- _amplitude = Math.Min(w, h) * AmplitudeFraction;
-
- for (int i = 0; i < BlobCount; i++)
- {
- // Big soft discs, sized off the diagonal so they overlap and fill the whole card.
- double size = diagonal * (0.7 + (0.12 * i));
-
- // Sunflower (golden-angle) placement so the centres are evenly, organically distributed.
- double radius = spread * Math.Sqrt((i + 0.5) / BlobCount);
- double angle = i * GoldenAngleRad;
- double cx = (w / 2.0) + (radius * Math.Cos(angle));
- double cy = (h / 2.0) + (radius * Math.Sin(angle));
-
- _blobs[i].Width = size;
- _blobs[i].Height = size;
- _blobs[i].Margin = new Thickness(cx - (size / 2.0), cy - (size / 2.0), 0, 0);
- _visuals[i].CenterPoint = new Vector3((float)(size / 2.0), (float)(size / 2.0), 0f);
- }
- }
-
- private void UpdateActivation()
- {
- Visibility = IsActive ? Visibility.Visible : Visibility.Collapsed;
- if (_loaded && IsActive)
- {
- StartRendering();
- }
- else
- {
- StopRendering();
- }
- }
-
- private void StartRendering()
- {
- if (_running)
- {
- return;
- }
-
- _running = true;
- CompositionTarget.Rendering += OnRendering;
- }
-
- private void StopRendering()
- {
- if (!_running)
- {
- return;
- }
-
- _running = false;
- _lastTime = -1; // so dt doesn't jump across a pause when we resume
- CompositionTarget.Rendering -= OnRendering;
- }
-
- // The whole look, derived fresh each frame from the current time.
- private void OnRendering(object? sender, object e)
- {
- double t = ((RenderingEventArgs)e).RenderingTime.TotalSeconds;
- double dt = _lastTime < 0 ? 0.0 : Math.Clamp(t - _lastTime, 0.0, 0.1);
- _lastTime = t;
-
- // Ease "energy" toward its target (frame-rate independent), then let it scale saturation,
- // presence and the hue-drift rate — so muting is a slow, coherent calming, not a hard cut.
- double target = IsMuted ? MutedEnergy : 1.0;
- _energy += (target - _energy) * (1.0 - Math.Exp(-dt / EnergyTau));
-
- // Integrate the base hue (its rate is energy-scaled, so it can't be a function of absolute t).
- _hue = (_hue + (HueDegPerSecond * _energy * dt)) % 360.0;
-
- double chroma = SoftChroma * _energy;
- byte blobAlpha = (byte)(BlobCentreAlpha * _energy);
- byte washAlpha = (byte)(BaseWashAlpha * _energy);
-
- for (int i = 0; i < BlobCount; i++)
- {
- // Colour: this blob's hue is the rotating base plus a golden-angle offset (even spread).
- (byte r, byte g, byte b) = OklchToRgb(SoftLightness, chroma, _hue + (i * GoldenAngleDeg));
- _brushes[i].GradientStops[0].Color = Color.FromArgb(blobAlpha, r, g, b);
- _brushes[i].GradientStops[1].Color = Color.FromArgb(0, r, g, b);
-
- // Motion: quasiperiodic Lissajous drift + a gentle breathing scale about the centre.
- float dx = (float)(_amplitude * Math.Sin((_freqX[i] * t) + _phaseX[i]));
- float dy = (float)(_amplitude * Math.Sin((_freqY[i] * t) + _phaseY[i]));
- _visuals[i].Properties.InsertVector3("Translation", new Vector3(dx, dy, 0f));
-
- float scale = (float)(1.0 + (BreatheDepth * Math.Sin((_freqScale[i] * t) + _phaseScale[i])));
- _visuals[i].Scale = new Vector3(scale, scale, 1f);
- }
-
- // Base wash: a soft two-tone that fills the whole card, its stops a golden-angle apart and
- // riding the same rotating hue, so there's always colour even between the blobs.
- (byte w0R, byte w0G, byte w0B) = OklchToRgb(SoftLightness, chroma * 0.85, _hue);
- (byte w1R, byte w1G, byte w1B) = OklchToRgb(SoftLightness, chroma * 0.85, _hue + GoldenAngleDeg);
- BaseWashStop0.Color = Color.FromArgb(washAlpha, w0R, w0G, w0B);
- BaseWashStop1.Color = Color.FromArgb(washAlpha, w1R, w1G, w1B);
- }
-
- // Perceptually-uniform OKLCH → opaque sRGB (gamut-clamped per channel). Kept self-contained here
- // so the backdrop depends on nothing beyond the framework; the maths mirrors the colour science
- // the app uses elsewhere (equal OKLCH steps look equally spaced, which is where the softness reads).
- private static (byte R, byte G, byte B) OklchToRgb(double lightness, double chroma, double hueDegrees)
- {
- double h = hueDegrees * Math.PI / 180.0;
- double a = chroma * Math.Cos(h);
- double bComponent = chroma * Math.Sin(h);
-
- double lRoot = lightness + (0.3963377774 * a) + (0.2158037573 * bComponent);
- double mRoot = lightness - (0.1055613458 * a) - (0.0638541728 * bComponent);
- double sRoot = lightness - (0.0894841775 * a) - (1.2914855480 * bComponent);
- double lCubed = lRoot * lRoot * lRoot;
- double mCubed = mRoot * mRoot * mRoot;
- double sCubed = sRoot * sRoot * sRoot;
-
- double red = (4.0767416621 * lCubed) - (3.3077115913 * mCubed) + (0.2309699292 * sCubed);
- double green = (-1.2684380046 * lCubed) + (2.6097574011 * mCubed) - (0.3413193965 * sCubed);
- double blue = (-0.0041960863 * lCubed) - (0.7034186147 * mCubed) + (1.7076147010 * sCubed);
-
- return (ToByte(LinearToSrgb(red)), ToByte(LinearToSrgb(green)), ToByte(LinearToSrgb(blue)));
- }
-
- private static double LinearToSrgb(double channel)
- {
- double c = Math.Clamp(channel, 0.0, 1.0);
- return c <= 0.0031308 ? (12.92 * c) : ((1.055 * Math.Pow(c, 1.0 / 2.4)) - 0.055);
- }
-
- private static byte ToByte(double channel) => (byte)Math.Clamp(Math.Round(channel * 255.0), 0, 255);
-}
diff --git a/src/Snaply.App/Controls/RegionSelectionOverlay.xaml b/src/Snaply.App/Controls/RegionSelectionOverlay.xaml
new file mode 100644
index 0000000..3f2aebf
--- /dev/null
+++ b/src/Snaply.App/Controls/RegionSelectionOverlay.xaml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Snaply.App/Controls/RegionSelectionOverlay.xaml.cs b/src/Snaply.App/Controls/RegionSelectionOverlay.xaml.cs
new file mode 100644
index 0000000..944fdc9
--- /dev/null
+++ b/src/Snaply.App/Controls/RegionSelectionOverlay.xaml.cs
@@ -0,0 +1,145 @@
+using Microsoft.UI.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Windows.Foundation;
+
+namespace Snaply.Controls;
+
+internal sealed partial class RegionSelectionOverlay : UserControl
+{
+ private uint? _activePointerId;
+
+ internal RegionSelectionOverlay()
+ {
+ InitializeComponent();
+ }
+
+ internal event Action? DragStarted;
+
+ internal event Action? DragMoved;
+
+ internal event Action? DragCompleted;
+
+ internal event Action? Cancelled;
+
+ internal void Begin()
+ {
+ SelectionVisual.Visibility = Visibility.Collapsed;
+ Focus(FocusState.Programmatic);
+ }
+
+ internal void End()
+ {
+ _activePointerId = null;
+ InputSurface.ReleasePointerCaptures();
+ SelectionVisual.Visibility = Visibility.Collapsed;
+ }
+
+ internal void SetSelection(Rect selection)
+ {
+ if (selection.Width <= 0 || selection.Height <= 0)
+ {
+ SelectionVisual.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ Canvas.SetLeft(SelectionVisual, selection.X);
+ Canvas.SetTop(SelectionVisual, selection.Y);
+ SelectionVisual.Width = selection.Width;
+ SelectionVisual.Height = selection.Height;
+ SelectionVisual.Visibility = Visibility.Visible;
+ }
+
+ private void InputSurface_PointerPressed(object sender, PointerRoutedEventArgs args)
+ {
+ if (_activePointerId is not null)
+ {
+ return;
+ }
+
+ PointerPoint point = args.GetCurrentPoint(InputSurface);
+ if (!point.Properties.IsLeftButtonPressed
+ && args.Pointer.PointerDeviceType is not (
+ PointerDeviceType.Touch
+ or PointerDeviceType.Pen))
+ {
+ return;
+ }
+
+ if (!InputSurface.CapturePointer(args.Pointer))
+ {
+ return;
+ }
+
+ _activePointerId = args.Pointer.PointerId;
+ DragStarted?.Invoke(point.Position);
+ args.Handled = true;
+ }
+
+ private void InputSurface_PointerMoved(object sender, PointerRoutedEventArgs args)
+ {
+ if (_activePointerId != args.Pointer.PointerId)
+ {
+ return;
+ }
+
+ DragMoved?.Invoke(args.GetCurrentPoint(InputSurface).Position);
+ args.Handled = true;
+ }
+
+ private void InputSurface_PointerReleased(object sender, PointerRoutedEventArgs args)
+ {
+ if (_activePointerId != args.Pointer.PointerId)
+ {
+ return;
+ }
+
+ Point position = args.GetCurrentPoint(InputSurface).Position;
+ _activePointerId = null;
+ if (InputSurface.PointerCaptures.Contains(args.Pointer))
+ {
+ InputSurface.ReleasePointerCapture(args.Pointer);
+ }
+
+ DragCompleted?.Invoke(position);
+ args.Handled = true;
+ }
+
+ private void InputSurface_PointerCaptureLost(object sender, PointerRoutedEventArgs args)
+ {
+ if (_activePointerId == args.Pointer.PointerId)
+ {
+ _activePointerId = null;
+ Cancelled?.Invoke();
+ args.Handled = true;
+ }
+ }
+
+ private void InputSurface_PointerCanceled(object sender, PointerRoutedEventArgs args)
+ {
+ if (_activePointerId != args.Pointer.PointerId)
+ {
+ return;
+ }
+
+ _activePointerId = null;
+ if (InputSurface.PointerCaptures.Contains(args.Pointer))
+ {
+ InputSurface.ReleasePointerCapture(args.Pointer);
+ }
+
+ Cancelled?.Invoke();
+ args.Handled = true;
+ }
+
+ private void CancelButton_Click(object sender, RoutedEventArgs args) => Cancelled?.Invoke();
+
+ private void Escape_Invoked(
+ KeyboardAccelerator sender,
+ KeyboardAcceleratorInvokedEventArgs args)
+ {
+ Cancelled?.Invoke();
+ args.Handled = true;
+ }
+}
diff --git a/src/Snaply.App/Controls/ZoomableImage.xaml b/src/Snaply.App/Controls/ZoomableImage.xaml
index afc0efc..404eedf 100644
--- a/src/Snaply.App/Controls/ZoomableImage.xaml
+++ b/src/Snaply.App/Controls/ZoomableImage.xaml
@@ -4,38 +4,82 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
- xmlns:local="using:Snaply.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Snaply.App/Controls/ZoomableImage.xaml.cs b/src/Snaply.App/Controls/ZoomableImage.xaml.cs
index 0613b2e..8e62c79 100644
--- a/src/Snaply.App/Controls/ZoomableImage.xaml.cs
+++ b/src/Snaply.App/Controls/ZoomableImage.xaml.cs
@@ -1,287 +1,168 @@
-using System.Numerics;
-using Microsoft.UI.Composition;
-using Microsoft.UI.Input;
+using System.Globalization;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
-using Windows.Foundation;
+using Windows.UI.ViewManagement;
namespace Snaply.Controls;
-///
-/// A zoom/pan image viewer. The image is laid out , so at scale
-/// 1.0 it fits the viewport (the fit baseline and the scale floor); the element's Composition
-/// / scale and translate from there. Mouse-wheel
-/// zooms about the cursor, left-drag pans, and double-tap resets to fit.
-///
-///
-/// Zoom glides via GPU-composited spring animations, so rapid consecutive notches retarget from the
-/// in-flight value instead of snapping. Panning writes the offset directly to stay 1:1 with the
-/// cursor. The _scale/_translateX/_translateY fields always hold the
-/// target state, so the cursor-anchor math stays pixel-correct at rest.
-///
internal sealed partial class ZoomableImage : UserControl
{
- /// Identifies the dependency property.
public static readonly DependencyProperty SourceProperty = DependencyProperty.Register(
nameof(Source),
typeof(ImageSource),
typeof(ZoomableImage),
new PropertyMetadata(null, OnSourceChanged));
- // Zoom limits. Scale is relative to the fit baseline (1.0), which is also the floor: the wheel
- // only zooms in, never below fit.
- private const double MinScale = 1.0;
- private const double MaxScale = 8.0;
- private const double ZoomStep = 1.1;
+ private const float ZoomStep = 1.25f;
+ private readonly bool _animationsEnabled = new UISettings().AnimationsEnabled;
+ private bool _keepFitted = true;
- // Z translation kept on the image at all times so its ThemeShadow reads as a floating lift over
- // the ambient backdrop (carried in the composition Translation alongside the pan offset).
- private const float ShadowDepth = 32f;
-
- // Spring motion for zoom. A spring continuously chases its FinalValue, so retargeting it
- // on every wheel notch produces one uninterrupted, momentum-preserving glide (no per-notch
- // ease-out "pulsing"). Critically damped (no overshoot); a short period keeps it snappy.
- private const float SpringDamping = 1.0f;
- private static readonly TimeSpan SpringPeriod = TimeSpan.FromMilliseconds(45);
-
- // Hand cursors are app-lifetime singletons (static so the control needs no IDisposable).
- private static readonly InputCursor PanCursor = InputSystemCursor.Create(InputSystemCursorShape.SizeAll);
- private static readonly InputCursor ArrowCursor = InputSystemCursor.Create(InputSystemCursorShape.Arrow);
-
- private readonly Visual _imageVisual;
- private readonly Compositor _compositor;
- private readonly SpringVector3NaturalMotionAnimation _scaleSpring;
- private readonly SpringVector3NaturalMotionAnimation _offsetSpring;
-
- private double _scale = 1.0;
- private double _translateX;
- private double _translateY;
- private bool _isPanning;
- private Point _lastPoint;
-
- /// Creates the control and wires up the Composition visual used for zoom/pan.
public ZoomableImage()
{
InitializeComponent();
- ProtectedCursor = ArrowCursor;
-
- // Empty until a capture arrives: stay out of the pointer's way (no pan cursor / no zoom) so
- // the empty canvas — and the ambient backdrop showing through — reads as inert, not a viewer.
- Viewport.IsHitTestVisible = false;
-
- // Drive Scale/Offset on the image's backing Composition visual. Composition runs on the
- // compositor thread, synced to the display's refresh (60/120/144Hz+), so motion stays
- // smooth regardless of UI-thread load.
- _imageVisual = ElementCompositionPreview.GetElementVisual(DisplayImage);
- _compositor = _imageVisual.Compositor;
-
- // Pan via the composition "Translation" property (composed ON TOP of the layout-owned
- // Offset), never by writing Offset directly — that fights layout and drifts the image out
- // of centre after repeated Source changes (each capture re-lays-out the element).
- ElementCompositionPreview.SetIsTranslationEnabled(DisplayImage, true);
-
- _scaleSpring = _compositor.CreateSpringVector3Animation();
- _scaleSpring.DampingRatio = SpringDamping;
- _scaleSpring.Period = SpringPeriod;
-
- _offsetSpring = _compositor.CreateSpringVector3Animation();
- _offsetSpring.DampingRatio = SpringDamping;
- _offsetSpring.Period = SpringPeriod;
}
- /// The image to display. Setting a new image auto-fits it to the viewport.
public ImageSource? Source
{
get => (ImageSource?)GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
- private static void OnSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ private static void OnSourceChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
- var control = (ZoomableImage)d;
- var source = e.NewValue as ImageSource;
- control.DisplayImage.Source = source;
+ var control = (ZoomableImage)sender;
+ control.DisplayImage.Source = args.NewValue as ImageSource;
+ control.ZoomToolbar.Visibility = args.NewValue is null
+ ? Visibility.Collapsed
+ : Visibility.Visible;
+ control.Scroller.IsTabStop = args.NewValue is not null;
+ control._keepFitted = true;
+ control.DispatcherQueue.TryEnqueue(control.Fit);
+ }
- // Zoom/pan only make sense once there's an image; while empty the viewer is inert so it
- // never shows a pan cursor over the ambient backdrop.
- control.Viewport.IsHitTestVisible = source is not null;
+ private void ZoomInButton_Click(object sender, RoutedEventArgs args) => ZoomBy(ZoomStep);
- // Never upscale: cap the element at the image's native pixel size so a capture smaller than
- // the viewport shows at 100% (centred) rather than being blown up; larger captures still fit.
- if (source is BitmapSource bitmap)
- {
- control.DisplayImage.MaxWidth = bitmap.PixelWidth;
- control.DisplayImage.MaxHeight = bitmap.PixelHeight;
- }
- else
- {
- control.DisplayImage.MaxWidth = double.PositiveInfinity;
- control.DisplayImage.MaxHeight = double.PositiveInfinity;
- }
+ private void ZoomOutButton_Click(object sender, RoutedEventArgs args) => ZoomBy(1 / ZoomStep);
- // A brand-new image should appear fitted immediately, not glide in from the old view.
- control.ResetToFit(animate: false);
- }
+ private void FitButton_Click(object sender, RoutedEventArgs args) => Fit();
+
+ private void ActualSizeButton_Click(object sender, RoutedEventArgs args) => SetZoom(1, keepFitted: false);
- private void OnViewportSizeChanged(object sender, SizeChangedEventArgs e)
+ private void Scroller_DoubleTapped(object sender, DoubleTappedRoutedEventArgs args)
{
- // Clip the (transformed) image to the viewport so panned/zoomed pixels never
- // spill over the neighbouring panels.
- Viewport.Clip = new RectangleGeometry { Rect = new Rect(0, 0, Viewport.ActualWidth, Viewport.ActualHeight) };
+ Fit();
+ args.Handled = true;
}
- private void OnPointerWheelChanged(object sender, PointerRoutedEventArgs e)
+ private void Scroller_SizeChanged(object sender, SizeChangedEventArgs args)
{
- PointerPoint point = e.GetCurrentPoint(Viewport);
- int delta = point.Properties.MouseWheelDelta;
- if (delta == 0)
+ if (_keepFitted)
{
- return;
+ Fit();
}
-
- double newScale = Math.Clamp(_scale * (delta > 0 ? ZoomStep : 1.0 / ZoomStep), MinScale, MaxScale);
- ZoomAbout(point.Position, newScale);
- e.Handled = true;
}
- private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ private void DisplayImage_ImageOpened(object sender, RoutedEventArgs args)
{
- PointerPoint point = e.GetCurrentPoint(Viewport);
- if (!point.Properties.IsLeftButtonPressed)
+ if (_keepFitted)
{
- return;
+ Fit();
}
-
- _isPanning = true;
- _lastPoint = point.Position;
-
- // Snap to the current target so a drag begun mid-glide stays 1:1 from the first move.
- ApplyTransformInstant();
- Viewport.CapturePointer(e.Pointer);
- ProtectedCursor = PanCursor;
}
- private void OnPointerMoved(object sender, PointerRoutedEventArgs e)
+ private void Scroller_ViewChanged(object? sender, ScrollViewerViewChangedEventArgs args)
{
- if (!_isPanning)
- {
- return;
- }
-
- Point position = e.GetCurrentPoint(Viewport).Position;
- _translateX += position.X - _lastPoint.X;
- _translateY += position.Y - _lastPoint.Y;
- _lastPoint = position;
-
- // Direct write (no animation) keeps the drag glued to the cursor.
- ApplyTransformInstant();
+ ZoomLevelText.Text = string.Format(
+ CultureInfo.CurrentCulture,
+ "{0:P0}",
+ Scroller.ZoomFactor);
}
- private void OnPointerReleased(object sender, PointerRoutedEventArgs e)
+ private void ZoomInAccelerator_Invoked(
+ KeyboardAccelerator sender,
+ KeyboardAcceleratorInvokedEventArgs args)
{
- if (!_isPanning)
- {
- return;
- }
-
- _isPanning = false;
- Viewport.ReleasePointerCapture(e.Pointer);
- ProtectedCursor = ArrowCursor;
+ ZoomBy(ZoomStep);
+ args.Handled = true;
}
- private void OnDoubleTapped(object sender, DoubleTappedRoutedEventArgs e) => ResetToFit(animate: true);
-
- /// Scale to while keeping fixed on screen.
- private void ZoomAbout(Point anchor, double newScale)
+ private void ZoomOutAccelerator_Invoked(
+ KeyboardAccelerator sender,
+ KeyboardAcceleratorInvokedEventArgs args)
{
- if (newScale <= 0)
- {
- return;
- }
-
- // The element is centred, so the composition maps a content point p to
- // (layoutOffset + scale*p + translate). Anchor the zoom about the cursor by solving for the
- // new translate that holds the content point currently under the cursor in place.
- double factor = newScale / _scale;
- double offsetX = (Viewport.ActualWidth - DisplayImage.ActualWidth) / 2.0;
- double offsetY = (Viewport.ActualHeight - DisplayImage.ActualHeight) / 2.0;
- _translateX = (anchor.X - offsetX) - (factor * (anchor.X - offsetX - _translateX));
- _translateY = (anchor.Y - offsetY) - (factor * (anchor.Y - offsetY - _translateY));
- _scale = newScale;
- ApplyTransformAnimated();
+ ZoomBy(1 / ZoomStep);
+ args.Handled = true;
}
- private void ResetToFit(bool animate)
+ private void FitAccelerator_Invoked(
+ KeyboardAccelerator sender,
+ KeyboardAcceleratorInvokedEventArgs args)
{
- _scale = 1.0;
- _translateX = 0;
- _translateY = 0;
-
- if (animate)
- {
- ApplyTransformAnimated();
- }
- else
- {
- ApplyTransformInstant();
- }
+ Fit();
+ args.Handled = true;
}
- ///
- /// Spring the visual toward the current target scale/offset. Re-invoking a spring retargets it
- /// from the current in-flight value, so rapid wheel notches chain into one continuous,
- /// compositor-driven glide (no per-notch ease-out pulsing).
- ///
- private void ApplyTransformAnimated()
+ private void ActualSizeAccelerator_Invoked(
+ KeyboardAccelerator sender,
+ KeyboardAcceleratorInvokedEventArgs args)
{
- ClampTranslation();
- _scaleSpring.FinalValue = new Vector3((float)_scale, (float)_scale, 1f);
- _offsetSpring.FinalValue = new Vector3((float)_translateX, (float)_translateY, ShadowDepth);
- _imageVisual.StartAnimation("Scale", _scaleSpring);
- _imageVisual.StartAnimation("Translation", _offsetSpring);
+ SetZoom(1, keepFitted: false);
+ args.Handled = true;
}
- /// Write the current target scale/offset immediately (used for panning and new-image fit).
- private void ApplyTransformInstant()
+ private void ZoomBy(float factor)
{
- ClampTranslation();
+ if (Source is null)
+ {
+ return;
+ }
- // A direct property set stops any in-flight animation on that property, then holds.
- _imageVisual.Scale = new Vector3((float)_scale, (float)_scale, 1f);
- _imageVisual.Properties.InsertVector3("Translation", new Vector3((float)_translateX, (float)_translateY, ShadowDepth));
+ SetZoom(Scroller.ZoomFactor * factor, keepFitted: false);
}
- // Keep the (scaled) image an exact fit to the viewport: it can be panned only as far as its own
- // edges — no over-pan slack, no gap. The centred element wraps its content tightly (no letterbox),
- // so clamping against the element's own bounds is symmetric regardless of aspect.
- private void ClampTranslation()
+ private void Fit()
{
- _translateX = ClampAxis(_translateX, Viewport.ActualWidth, DisplayImage.ActualWidth, _scale);
- _translateY = ClampAxis(_translateY, Viewport.ActualHeight, DisplayImage.ActualHeight, _scale);
+ if (Source is not BitmapSource bitmap
+ || bitmap.PixelWidth <= 0
+ || bitmap.PixelHeight <= 0
+ || Scroller.ViewportWidth <= 0
+ || Scroller.ViewportHeight <= 0)
+ {
+ return;
+ }
+
+ float fit = (float)Math.Min(
+ 1,
+ Math.Min(
+ Scroller.ViewportWidth / bitmap.PixelWidth,
+ Scroller.ViewportHeight / bitmap.PixelHeight));
+ SetZoom(fit, keepFitted: true);
}
- private static double ClampAxis(double translate, double viewport, double element, double scale)
+ private void SetZoom(float zoom, bool keepFitted)
{
- if (viewport <= 0 || element <= 0)
+ if (Source is null)
{
- return translate;
+ return;
}
- // Content is laid out centred (offset) and the visual scales from the element's top-left,
- // so on-screen it spans [offset + translate, offset + translate + content].
- double offset = (viewport - element) / 2.0;
- double content = element * scale;
- double lower = viewport - offset - content;
- double upper = -offset;
-
- // Content smaller than the viewport can't be panned — hold it centred. Otherwise pan only
- // until an edge meets the matching viewport edge (a flush, exact fit — no slack).
- return lower > upper
- ? ((viewport - content) / 2.0) - offset
- : Math.Clamp(translate, lower, upper);
+ _keepFitted = keepFitted;
+ float clamped = Math.Clamp(zoom, Scroller.MinZoomFactor, Scroller.MaxZoomFactor);
+ _ = Scroller.ChangeView(
+ horizontalOffset: null,
+ verticalOffset: null,
+ zoomFactor: clamped,
+ disableAnimation: !_animationsEnabled);
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ _ = Scroller.ChangeView(
+ Scroller.ScrollableWidth / 2,
+ Scroller.ScrollableHeight / 2,
+ zoomFactor: null,
+ disableAnimation: true);
+ });
}
}
diff --git a/src/Snaply.App/ImageExportService.cs b/src/Snaply.App/ImageExportService.cs
index c3ddfd9..a254af2 100644
--- a/src/Snaply.App/ImageExportService.cs
+++ b/src/Snaply.App/ImageExportService.cs
@@ -1,15 +1,15 @@
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
-using System.Runtime.InteropServices.WindowsRuntime;
using Serilog;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Streams;
namespace Snaply;
-internal sealed class ImageExportService
+internal sealed class ImageExportService : IImageOutput
{
+ private const int ClipboardCannotOpen = unchecked((int)0x800401D0);
private readonly string _captureDirectory;
private int _temporarySequence;
@@ -27,6 +27,28 @@ internal ImageExportService(string captureDirectory)
internal static string CreateSuggestedFileName(DateTimeOffset now) =>
$"Snaply-{now.ToLocalTime():yyyy-MM-dd_HH-mm-ss}.png";
+ public async Task DeliverAsync(
+ RenderedImage image,
+ DeliveryRequest request,
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(image);
+ if (request.IsEmpty)
+ {
+ throw new ArgumentException("At least one output target must be requested.", nameof(request));
+ }
+
+ Task save = request.Save
+ ? TrySaveAutomaticallyAsync(image, now, cancellationToken)
+ : Task.FromResult(DeliveryResult.NotAttempted);
+ Task copy = request.Clipboard
+ ? TryCopyAsync(image, cancellationToken)
+ : Task.FromResult(DeliveryResult.NotAttempted);
+ await Task.WhenAll(save, copy);
+ return new DeliveryOutcome(await save, await copy);
+ }
+
internal async Task SaveAutomaticallyAsync(
RenderedImage image,
DateTimeOffset now,
@@ -72,7 +94,9 @@ internal static async Task CopyAsync(RenderedImage image, CancellationToken canc
try
{
using var stream = new InMemoryRandomAccessStream();
- await stream.WriteAsync(image.Png.AsBuffer()).AsTask(cancellationToken);
+ using Stream output = stream.AsStreamForWrite();
+ await output.WriteAsync(image.Png, cancellationToken);
+ await output.FlushAsync(cancellationToken);
stream.Seek(0);
var package = new DataPackage();
@@ -81,23 +105,26 @@ internal static async Task CopyAsync(RenderedImage image, CancellationToken canc
Clipboard.Flush();
return;
}
- catch (COMException) when (attempt < 2)
+ catch (COMException exception) when (
+ exception.HResult == ClipboardCannotOpen
+ && attempt < 2)
{
await Task.Delay(TimeSpan.FromMilliseconds(50 * (attempt + 1)), cancellationToken);
}
}
}
- internal void OpenCaptureDirectory()
+ public void OpenCaptureDirectory()
{
Directory.CreateDirectory(_captureDirectory);
- // Shell-execute the directory itself rather than passing it as an explorer.exe argument:
- // an unquoted path that contains a space (e.g. a redirected Pictures folder) would be
- // misparsed and open the wrong location.
- Process.Start(new ProcessStartInfo(_captureDirectory)
+ using Process? process = Process.Start(new ProcessStartInfo(_captureDirectory)
{
UseShellExecute = true,
});
+ if (process is null)
+ {
+ throw new InvalidOperationException("The capture directory could not be opened.");
+ }
}
private static string GetDefaultCaptureDirectory()
@@ -113,7 +140,7 @@ private static string GetDefaultCaptureDirectory()
private static async Task WriteNewFileAsync(
string path,
- byte[] bytes,
+ ReadOnlyMemory bytes,
CancellationToken cancellationToken)
{
await using var stream = new FileStream(
@@ -125,7 +152,39 @@ private static async Task WriteNewFileAsync(
FileOptions.Asynchronous | FileOptions.WriteThrough);
await stream.WriteAsync(bytes, cancellationToken);
await stream.FlushAsync(cancellationToken);
- stream.Flush(true);
+ }
+
+ private async Task TrySaveAutomaticallyAsync(
+ RenderedImage image,
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ _ = await SaveAutomaticallyAsync(image, now, cancellationToken);
+ return DeliveryResult.Succeeded;
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ LogFailure("AutoSave", exception);
+ return DeliveryResult.Failed;
+ }
+ }
+
+ private static async Task TryCopyAsync(
+ RenderedImage image,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await CopyAsync(image, cancellationToken);
+ return DeliveryResult.Succeeded;
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ LogFailure("Clipboard", exception);
+ return DeliveryResult.Failed;
+ }
}
private static void DeleteTemporaryFile(string path)
@@ -142,4 +201,11 @@ private static void DeleteTemporaryFile(string path)
exception.HResult);
}
}
+
+ private static void LogFailure(string operation, Exception exception) =>
+ Log.Warning(
+ "{Operation} failed {ExceptionType} {HResult}",
+ operation,
+ exception.GetType().FullName,
+ exception.HResult);
}
diff --git a/src/Snaply.App/ImageOutput.cs b/src/Snaply.App/ImageOutput.cs
new file mode 100644
index 0000000..78f75c2
--- /dev/null
+++ b/src/Snaply.App/ImageOutput.cs
@@ -0,0 +1,39 @@
+namespace Snaply;
+
+internal interface IImageOutput
+{
+ public Task DeliverAsync(
+ RenderedImage image,
+ DeliveryRequest request,
+ DateTimeOffset now,
+ CancellationToken cancellationToken);
+
+ public void OpenCaptureDirectory();
+}
+
+internal readonly record struct DeliveryRequest(bool Save, bool Clipboard)
+{
+ internal static DeliveryRequest All { get; } = new(Save: true, Clipboard: true);
+
+ internal bool IsEmpty => !Save && !Clipboard;
+}
+
+internal enum DeliveryResult
+{
+ NotAttempted,
+ Succeeded,
+ Failed,
+}
+
+internal readonly record struct DeliveryOutcome(
+ DeliveryResult Save,
+ DeliveryResult Clipboard)
+{
+ internal bool AllSucceeded =>
+ Save is DeliveryResult.Succeeded
+ && Clipboard is DeliveryResult.Succeeded;
+
+ internal DeliveryRequest FailedTargets => new(
+ Save: Save is not DeliveryResult.Succeeded,
+ Clipboard: Clipboard is not DeliveryResult.Succeeded);
+}
diff --git a/src/Snaply.App/MainPage.xaml b/src/Snaply.App/MainPage.xaml
index 922efdc..3c47fa9 100644
--- a/src/Snaply.App/MainPage.xaml
+++ b/src/Snaply.App/MainPage.xaml
@@ -5,78 +5,64 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctrl="using:Snaply.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:local="using:Snaply"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="720"
d:DesignWidth="1100"
- HighContrastAdjustment="None"
mc:Ignorable="d">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
+
-
-
+
-
+
+ FontSize="16"
+ Glyph="" />
@@ -113,59 +99,65 @@
-
+
+
+
-
+
-
-
-
+
+
+
+
+
+
diff --git a/src/Snaply.App/MainPage.xaml.cs b/src/Snaply.App/MainPage.xaml.cs
index fe0412b..9898836 100644
--- a/src/Snaply.App/MainPage.xaml.cs
+++ b/src/Snaply.App/MainPage.xaml.cs
@@ -1,49 +1,67 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Serilog;
+using Windows.Storage.Streams;
namespace Snaply;
-public sealed partial class MainPage : Page
+internal sealed partial class MainPage : Page, IDisposable
{
- // Segoe Fluent Icons glyph code points for the Capture pill, per selected mode.
private const int RegionGlyph = 0xEF20;
private const int WindowGlyph = 0xE737;
private const int DesktopGlyph = 0xE7F4;
+ private int _previewGeneration;
+ private bool _disposed;
internal MainPage(MainViewModel viewModel)
{
ViewModel = viewModel;
InitializeComponent();
UpdatePrimaryCapture();
+ ViewModel.PropertyChanged += OnViewModelPropertyChanged;
+ }
+
+ internal MainViewModel ViewModel { get; }
+
+ internal static Visibility NullToVisibility(object? value) =>
+ value is null ? Visibility.Visible : Visibility.Collapsed;
- // The Open Folder button is icon-only, so give it an accessible name and a tooltip.
- // Kept in code-behind alongside the other presentation strings (the view model stays
- // free of UI text).
- string openFolder = ResourceText.Get("OpenFolderLabel");
- AutomationProperties.SetName(OpenFolderButton, openFolder);
- ToolTipService.SetToolTip(OpenFolderButton, openFolder);
- ViewModel.PropertyChanged += (_, args) =>
+ internal static Visibility BoolToVisibility(bool value) =>
+ value ? Visibility.Visible : Visibility.Collapsed;
+
+ internal static InfoBarSeverity ToInfoBarSeverity(NoticeKind kind) =>
+ kind switch
{
- // Each successful auto-save bumps SavedTick; play the folder→green-check flip.
- if (args.PropertyName == nameof(MainViewModel.SavedTick))
- {
- DispatcherQueue.TryEnqueue(() => SavedFeedback.Begin());
- }
+ NoticeKind.Success => InfoBarSeverity.Success,
+ NoticeKind.Warning => InfoBarSeverity.Warning,
+ NoticeKind.Error => InfoBarSeverity.Error,
+ _ => InfoBarSeverity.Informational,
};
- }
- internal MainViewModel ViewModel { get; }
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
- // Flyout items: change the selected mode only. The pill body is bound to CaptureCommand,
- // which runs whichever mode is selected.
- private void RegionCaptureItem_Click(object sender, RoutedEventArgs args) => SelectMode(CaptureMode.Region);
+ _disposed = true;
+ ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
+ _previewGeneration++;
+ }
- private void WindowCaptureItem_Click(object sender, RoutedEventArgs args) => SelectMode(CaptureMode.Window);
+ private void RegionCaptureItem_Click(object sender, RoutedEventArgs args) =>
+ SelectMode(CaptureMode.Region);
- private void DesktopCaptureItem_Click(object sender, RoutedEventArgs args) => SelectMode(CaptureMode.Desktop);
+ private void WindowCaptureItem_Click(object sender, RoutedEventArgs args) =>
+ SelectMode(CaptureMode.Window);
- private void OpenFolderButton_Click(object sender, RoutedEventArgs args) => ViewModel.OpenFolder();
+ private void DesktopCaptureItem_Click(object sender, RoutedEventArgs args) =>
+ SelectMode(CaptureMode.Desktop);
private void SelectMode(CaptureMode mode)
{
@@ -51,8 +69,6 @@ private void SelectMode(CaptureMode mode)
UpdatePrimaryCapture();
}
- // Reflect the selected mode on the Capture pill (label + glyph). Kept in code-behind so the
- // view model stays free of presentation strings.
private void UpdatePrimaryCapture()
{
string label = ResourceText.Get(ViewModel.SelectedMode switch
@@ -62,8 +78,6 @@ private void UpdatePrimaryCapture()
_ => "CaptureDesktop",
});
PrimaryCaptureLabel.Text = label;
- // The pill's content is a panel, so it derives no automation name of its own and
- // screen readers announce it unnamed. Name it after the mode it will run.
AutomationProperties.SetName(CaptureButton, label);
PrimaryCaptureGlyph.Glyph = char.ConvertFromUtf32(ViewModel.SelectedMode switch
{
@@ -72,4 +86,52 @@ private void UpdatePrimaryCapture()
_ => DesktopGlyph,
});
}
+
+ private async void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs args)
+ {
+ if (args.PropertyName != nameof(MainViewModel.PreviewImage))
+ {
+ return;
+ }
+
+ int generation = ++_previewGeneration;
+ RenderedImage? image = ViewModel.PreviewImage;
+ if (image is null)
+ {
+ PreviewImageControl.Source = null;
+ return;
+ }
+
+ try
+ {
+ using var stream = new InMemoryRandomAccessStream();
+ using Stream output = stream.AsStreamForWrite();
+ await output.WriteAsync(image.Png);
+ await output.FlushAsync();
+ stream.Seek(0);
+ var bitmap = new BitmapImage();
+ await bitmap.SetSourceAsync(stream);
+ if (!_disposed && generation == _previewGeneration)
+ {
+ PreviewImageControl.Source = bitmap;
+ PreviewErrorInfoBar.IsOpen = false;
+ }
+ }
+ catch (Exception exception) when (
+ exception is ArgumentException
+ or COMException
+ or IOException
+ or InvalidOperationException)
+ {
+ Log.Warning(
+ "Preview failed {ExceptionType} {HResult}",
+ exception.GetType().FullName,
+ exception.HResult);
+ if (!_disposed && generation == _previewGeneration)
+ {
+ PreviewErrorInfoBar.Message = ResourceText.Get("ErrorPreview");
+ PreviewErrorInfoBar.IsOpen = true;
+ }
+ }
+ }
}
diff --git a/src/Snaply.App/MainWindow.xaml.cs b/src/Snaply.App/MainWindow.xaml.cs
index 2c0bb0b..a039b6a 100644
--- a/src/Snaply.App/MainWindow.xaml.cs
+++ b/src/Snaply.App/MainWindow.xaml.cs
@@ -4,11 +4,14 @@
namespace Snaply;
-public sealed partial class MainWindow : Window
+internal sealed partial class MainWindow : Window, IDisposable
{
private const uint WdaExcludeFromCapture = 0x00000011;
+ private readonly MainPage _page;
+ private readonly MainViewModel _viewModel;
+ private bool _disposed;
- internal MainWindow(MainViewModel viewModel, ScreenCaptureService capture)
+ internal MainWindow()
{
InitializeComponent();
ExtendsContentIntoTitleBar = true;
@@ -25,8 +28,48 @@ internal MainWindow(MainViewModel viewModel, ScreenCaptureService capture)
bool exclusionEnabled = SetWindowDisplayAffinity(handle, WdaExcludeFromCapture)
&& GetWindowDisplayAffinity(handle, out uint affinity)
&& affinity == WdaExcludeFromCapture;
- capture.SetAppWindow(this, exclusionEnabled);
- ContentHost.Children.Add(new MainPage(viewModel));
+
+ CapturePipeline? pipeline = null;
+ MainViewModel? viewModel = null;
+ MainPage? page = null;
+ try
+ {
+ pipeline = new CapturePipeline(this, exclusionEnabled);
+ viewModel = new MainViewModel(pipeline, new ImageExportService());
+ pipeline = null;
+ page = new MainPage(viewModel);
+ ContentHost.Children.Add(page);
+ Closed += OnClosed;
+
+ _viewModel = viewModel;
+ _page = page;
+ viewModel = null;
+ page = null;
+ }
+ finally
+ {
+ page?.Dispose();
+ viewModel?.Dispose();
+ pipeline?.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _page.Dispose();
+ _viewModel.Dispose();
+ }
+
+ private void OnClosed(object sender, WindowEventArgs args)
+ {
+ Closed -= OnClosed;
+ Dispose();
}
[LibraryImport("user32.dll", SetLastError = true)]
diff --git a/src/Snaply.App/MonitorSnapshot.cs b/src/Snaply.App/MonitorSnapshot.cs
index fa412d7..c404491 100644
--- a/src/Snaply.App/MonitorSnapshot.cs
+++ b/src/Snaply.App/MonitorSnapshot.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using System.Runtime.InteropServices;
using Snaply.Imaging;
@@ -10,26 +11,42 @@ internal sealed partial record MonitorSnapshot(nint Handle, PixelRect Bounds, bo
internal static IReadOnlyList Enumerate()
{
var monitors = new List();
+ int monitorInfoError = 0;
bool Callback(nint monitor, nint deviceContext, ref NativeRect bounds, nint data)
{
var info = new MonitorInfo { Size = Marshal.SizeOf() };
- if (GetMonitorInfo(monitor, ref info))
+ if (!GetMonitorInfo(monitor, ref info))
{
- monitors.Add(new MonitorSnapshot(
- monitor,
- new PixelRect(
- info.Monitor.Left,
- info.Monitor.Top,
- checked(info.Monitor.Right - info.Monitor.Left),
- checked(info.Monitor.Bottom - info.Monitor.Top)),
- (info.Flags & MonitorInfoPrimary) != 0));
+ monitorInfoError = Marshal.GetLastPInvokeError();
+ return false;
}
+ monitors.Add(new MonitorSnapshot(
+ monitor,
+ new PixelRect(
+ info.Monitor.Left,
+ info.Monitor.Top,
+ checked(info.Monitor.Right - info.Monitor.Left),
+ checked(info.Monitor.Bottom - info.Monitor.Top)),
+ (info.Flags & MonitorInfoPrimary) != 0));
return true;
}
- if (!EnumDisplayMonitors(nint.Zero, nint.Zero, Callback, nint.Zero) || monitors.Count == 0)
+ bool enumerated = EnumDisplayMonitors(nint.Zero, nint.Zero, Callback, nint.Zero);
+ if (monitorInfoError != 0)
+ {
+ throw new Win32Exception(monitorInfoError, "A display could not be inspected.");
+ }
+
+ if (!enumerated)
+ {
+ throw new Win32Exception(
+ Marshal.GetLastPInvokeError(),
+ "Displays could not be enumerated.");
+ }
+
+ if (monitors.Count == 0)
{
throw new InvalidOperationException("No display is available.");
}
diff --git a/src/Snaply.App/Package.appxmanifest b/src/Snaply.App/Package.appxmanifest
index 392a79a..a0b2313 100644
--- a/src/Snaply.App/Package.appxmanifest
+++ b/src/Snaply.App/Package.appxmanifest
@@ -8,11 +8,11 @@
+ Version="0.0.0.0" />
- Snaply
- Snaply
+ ms-resource:AppDisplayName
+ ms-resource:PublisherDisplayName
Assets\StoreLogo.png
@@ -38,8 +38,8 @@
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
diff --git a/src/Snaply.App/RegionSelectionService.cs b/src/Snaply.App/RegionSelectionService.cs
index 0228397..b15290b 100644
--- a/src/Snaply.App/RegionSelectionService.cs
+++ b/src/Snaply.App/RegionSelectionService.cs
@@ -1,22 +1,16 @@
using System.Runtime.InteropServices;
-using Microsoft.UI.Input;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Automation;
-using Microsoft.UI.Xaml.Controls;
-using Microsoft.UI.Xaml.Input;
-using Microsoft.UI.Xaml.Media;
-using Microsoft.UI.Xaml.Shapes;
+using Snaply.Controls;
using Snaply.Imaging;
using Windows.Foundation;
using Windows.Graphics;
-using Windows.System;
-using Windows.UI;
namespace Snaply;
-internal sealed partial class RegionSelectionService : IDisposable
+internal sealed partial class RegionSelectionService(Action activateOwner) : IDisposable
{
+ private readonly Action _activateOwner = activateOwner;
private readonly Dictionary _windows = [];
private bool _disposed;
@@ -26,14 +20,11 @@ internal sealed partial class RegionSelectionService : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
List windows = GetWindows(monitors);
- var controller = new RegionSelectionController(windows, cancellationToken);
- PixelRect? result = await controller.RunAsync();
- if (result is not null)
- {
- await Task.Delay(75, cancellationToken);
- }
-
- return result;
+ var controller = new RegionSelectionController(
+ windows,
+ _activateOwner,
+ cancellationToken);
+ return await controller.RunAsync();
}
public void Dispose()
@@ -46,19 +37,18 @@ public void Dispose()
_disposed = true;
foreach (RegionSelectionWindow window in _windows.Values)
{
- window.Close();
+ window.ClosePermanently();
}
_windows.Clear();
}
- private List GetWindows(
- IReadOnlyList monitors)
+ private List GetWindows(IReadOnlyList monitors)
{
var activeHandles = monitors.Select(static monitor => monitor.Handle).ToHashSet();
foreach (nint handle in _windows.Keys.Where(handle => !activeHandles.Contains(handle)).ToArray())
{
- _windows[handle].Close();
+ _windows[handle].ClosePermanently();
_windows.Remove(handle);
}
@@ -80,11 +70,12 @@ private List GetWindows(
private sealed class RegionSelectionController
{
+ private readonly Action _activateOwner;
+ private readonly CancellationTokenRegistration _cancellationRegistration;
private readonly TaskCompletionSource _completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher =
Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
- private readonly CancellationTokenRegistration _cancellationRegistration;
private readonly IReadOnlyList _windows;
private PixelPoint _start;
private bool _dragging;
@@ -92,9 +83,11 @@ private sealed class RegionSelectionController
internal RegionSelectionController(
IReadOnlyList windows,
+ Action activateOwner,
CancellationToken cancellationToken)
{
_windows = windows;
+ _activateOwner = activateOwner;
_cancellationRegistration = cancellationToken.Register(
() => _dispatcher.TryEnqueue(Cancel));
}
@@ -105,11 +98,7 @@ internal RegionSelectionController(
{
foreach (RegionSelectionWindow window in _windows)
{
- window.BeginSelection(
- BeginDrag,
- UpdateDrag,
- Complete,
- Cancel);
+ window.BeginSelection(BeginDrag, UpdateDrag, Complete, Cancel);
}
return await _completion.Task;
@@ -125,17 +114,15 @@ private void BeginDrag(PixelPoint start)
{
_start = start;
_dragging = true;
- UpdateSelection(_start);
+ UpdateSelection(start);
}
private void UpdateDrag(PixelPoint current)
{
- if (!_dragging)
+ if (_dragging)
{
- return;
+ UpdateSelection(current);
}
-
- UpdateSelection(current);
}
private void UpdateSelection(PixelPoint current)
@@ -156,13 +143,7 @@ private void Complete(PixelPoint end)
_dragging = false;
PixelRect selection = CreateSelection(_start, end);
- if (selection.Width < 2 || selection.Height < 2)
- {
- Cancel();
- return;
- }
-
- Finish(selection);
+ Finish(selection.Width < 2 || selection.Height < 2 ? null : selection);
}
private void Cancel() => Finish(null);
@@ -176,13 +157,25 @@ private void Finish(PixelRect? result)
_finished = true;
_cancellationRegistration.Dispose();
+ int failure = 0;
foreach (RegionSelectionWindow window in _windows)
{
- window.EndSelection();
+ int resultCode = window.EndSelection();
+ if (resultCode < 0 && failure == 0)
+ {
+ failure = resultCode;
+ }
}
- App.MainWindow.Activate();
- _completion.TrySetResult(result);
+ _activateOwner();
+ if (failure < 0)
+ {
+ _completion.TrySetException(Marshal.GetExceptionForHR(failure)!);
+ }
+ else
+ {
+ _completion.TrySetResult(result);
+ }
}
private static PixelRect CreateSelection(PixelPoint first, PixelPoint second)
@@ -197,116 +190,27 @@ private static PixelRect CreateSelection(PixelPoint first, PixelPoint second)
private sealed partial class RegionSelectionWindow : Window
{
- private const uint WdaExcludeFromCapture = 0x00000011;
- private readonly Canvas _canvas;
- private readonly Rectangle _selection;
- private MonitorSnapshot _monitor = null!;
- private PixelRect _positionedBounds;
+ private readonly RegionSelectionOverlay _overlay = new();
private Action? _beginDrag;
- private Action? _updateDrag;
- private Action? _complete;
private Action? _cancel;
- private PixelPoint _lastPointerPosition;
- private PixelPoint _pointerStartPosition;
+ private Action? _complete;
+ private bool _isClosingPermanently;
private bool _isPositioned;
- private bool _isSelecting;
+ private MonitorSnapshot _monitor = null!;
+ private PixelRect _positionedBounds;
+ private Action? _updateDrag;
internal RegionSelectionWindow()
{
- _canvas = new Canvas
- {
- Background = new SolidColorBrush(Color.FromArgb(112, 0, 0, 0)),
- IsTabStop = true,
- };
- _selection = new Rectangle
- {
- Stroke = new SolidColorBrush(Microsoft.UI.Colors.White),
- StrokeThickness = 2,
- Fill = new SolidColorBrush(Color.FromArgb(24, 255, 255, 255)),
- Visibility = Visibility.Collapsed,
- IsHitTestVisible = false,
- };
- _canvas.Children.Add(_selection);
- var hint = new TextBlock
- {
- Text = ResourceText.Get("RegionHint"),
- Foreground = new SolidColorBrush(Microsoft.UI.Colors.White),
- FontSize = 16,
- Padding = new Thickness(12, 8, 12, 8),
- };
- Canvas.SetLeft(hint, 20);
- Canvas.SetTop(hint, 20);
- _canvas.Children.Add(hint);
- var cancel = new Button
- {
- Content = ResourceText.Get("RegionCancel"),
- Padding = new Thickness(12, 8, 12, 8),
- };
- AutomationProperties.SetAutomationId(cancel, "RegionCancelButton");
- cancel.Click += (_, _) => _cancel?.Invoke();
- Canvas.SetLeft(cancel, 20);
- Canvas.SetTop(cancel, 72);
- _canvas.Children.Add(cancel);
- _canvas.PointerPressed += (_, args) =>
- {
- PointerPoint point = args.GetCurrentPoint(_canvas);
- if (point.Properties.IsLeftButtonPressed
- || args.Pointer.PointerDeviceType is PointerDeviceType.Touch or PointerDeviceType.Pen)
- {
- _isSelecting = true;
- _canvas.CapturePointer(args.Pointer);
- _lastPointerPosition = ToScreenPoint(point.Position);
- _pointerStartPosition = _lastPointerPosition;
- _beginDrag?.Invoke(_lastPointerPosition);
- args.Handled = true;
- }
- };
- _canvas.PointerMoved += (_, args) =>
- {
- if (_isSelecting)
- {
- _lastPointerPosition = ToScreenPoint(args.GetCurrentPoint(_canvas).Position);
- _updateDrag?.Invoke(_lastPointerPosition);
- args.Handled = true;
- }
- };
- _canvas.PointerReleased += (_, args) =>
- {
- if (_isSelecting)
- {
- _lastPointerPosition = ToScreenPoint(args.GetCurrentPoint(_canvas).Position);
- _isSelecting = false;
- if (_canvas.PointerCaptures.Contains(args.Pointer))
- {
- _canvas.ReleasePointerCapture(args.Pointer);
- }
-
- _complete?.Invoke(_lastPointerPosition);
- args.Handled = true;
- }
- };
- _canvas.PointerCaptureLost += (_, _) =>
- {
- if (_isSelecting && _lastPointerPosition != _pointerStartPosition)
- {
- _isSelecting = false;
- _complete?.Invoke(_lastPointerPosition);
- }
- };
- var escape = new KeyboardAccelerator
- {
- Key = VirtualKey.Escape,
- };
- escape.Invoked += (_, args) =>
- {
- _cancel?.Invoke();
- args.Handled = true;
- };
- _canvas.KeyboardAccelerators.Add(escape);
- Content = _canvas;
+ Content = _overlay;
+ _overlay.DragStarted += point => _beginDrag?.Invoke(ToScreenPoint(point));
+ _overlay.DragMoved += point => _updateDrag?.Invoke(ToScreenPoint(point));
+ _overlay.DragCompleted += point => _complete?.Invoke(ToScreenPoint(point));
+ _overlay.Cancelled += () => _cancel?.Invoke();
+
AppWindow.Closing += (_, args) =>
{
- if (_cancel is not null)
+ if (!_isClosingPermanently && _cancel is not null)
{
args.Cancel = true;
_cancel();
@@ -321,9 +225,6 @@ internal RegionSelectionWindow()
presenter.IsResizable = false;
}
- _ = SetWindowDisplayAffinity(
- WinRT.Interop.WindowNative.GetWindowHandle(this),
- WdaExcludeFromCapture);
AppWindow.IsShownInSwitchers = false;
}
@@ -339,7 +240,6 @@ internal void BeginSelection(
_updateDrag = updateDrag;
_complete = complete;
_cancel = cancel;
- _selection.Visibility = Visibility.Collapsed;
if (!_isPositioned || _positionedBounds != _monitor.Bounds)
{
AppWindow.MoveAndResize(new RectInt32(
@@ -351,22 +251,21 @@ internal void BeginSelection(
_isPositioned = true;
}
+ _overlay.Begin();
AppWindow.Show();
Activate();
- _canvas.Focus(FocusState.Programmatic);
+ _overlay.Focus(FocusState.Programmatic);
}
- internal void EndSelection()
+ internal int EndSelection()
{
- _isSelecting = false;
- _canvas.ReleasePointerCaptures();
- _selection.Visibility = Visibility.Collapsed;
+ _overlay.End();
_beginDrag = null;
_updateDrag = null;
_complete = null;
_cancel = null;
AppWindow.Hide();
- _ = DwmFlush();
+ return DwmFlush();
}
internal void SetSelection(PixelRect screenSelection)
@@ -374,21 +273,41 @@ internal void SetSelection(PixelRect screenSelection)
PixelRect local = screenSelection.Intersect(_monitor.Bounds);
if (local.IsEmpty || Content is not FrameworkElement root)
{
- _selection.Visibility = Visibility.Collapsed;
+ _overlay.SetSelection(default);
return;
}
double scale = root.XamlRoot?.RasterizationScale ?? 1;
- Canvas.SetLeft(_selection, (local.X - _monitor.Bounds.X) / scale);
- Canvas.SetTop(_selection, (local.Y - _monitor.Bounds.Y) / scale);
- _selection.Width = local.Width / scale;
- _selection.Height = local.Height / scale;
- _selection.Visibility = Visibility.Visible;
+ _overlay.SetSelection(new Rect(
+ (local.X - _monitor.Bounds.X) / scale,
+ (local.Y - _monitor.Bounds.Y) / scale,
+ local.Width / scale,
+ local.Height / scale));
+ }
+
+ internal void ClosePermanently()
+ {
+ if (_isClosingPermanently)
+ {
+ return;
+ }
+
+ _isClosingPermanently = true;
+ if (_cancel is not null)
+ {
+ _cancel();
+ }
+ else
+ {
+ _ = EndSelection();
+ }
+
+ Close();
}
private PixelPoint ToScreenPoint(Point position)
{
- double scale = _canvas.XamlRoot?.RasterizationScale ?? 1;
+ double scale = _overlay.XamlRoot?.RasterizationScale ?? 1;
return new PixelPoint(
checked(_monitor.Bounds.X + (int)Math.Round(
position.X * scale,
@@ -400,10 +319,6 @@ private PixelPoint ToScreenPoint(Point position)
[LibraryImport("dwmapi.dll")]
private static partial int DwmFlush();
-
- [LibraryImport("user32.dll", SetLastError = true)]
- [return: MarshalAs(UnmanagedType.Bool)]
- private static partial bool SetWindowDisplayAffinity(nint window, uint affinity);
}
private readonly record struct PixelPoint(int X, int Y);
diff --git a/src/Snaply.App/RenderedImage.cs b/src/Snaply.App/RenderedImage.cs
index 490c2e1..e6b92b6 100644
--- a/src/Snaply.App/RenderedImage.cs
+++ b/src/Snaply.App/RenderedImage.cs
@@ -1,3 +1,59 @@
+using System.Buffers.Binary;
+
namespace Snaply;
-internal sealed record RenderedImage(byte[] Png, int Width, int Height);
+internal sealed class RenderedImage
+{
+ private static readonly byte[] PngSignature = [137, 80, 78, 71, 13, 10, 26, 10];
+ private readonly byte[] _png;
+
+ internal RenderedImage(ReadOnlySpan png, int width, int height)
+ {
+ Validate(png, width, height);
+ _png = png.ToArray();
+ Width = width;
+ Height = height;
+ }
+
+ private RenderedImage(byte[] png, int width, int height)
+ {
+ Validate(png, width, height);
+ _png = png;
+ Width = width;
+ Height = height;
+ }
+
+ internal static RenderedImage FromOwnedPng(byte[] png, int width, int height)
+ {
+ ArgumentNullException.ThrowIfNull(png);
+ return new RenderedImage(png, width, height);
+ }
+
+ internal ReadOnlyMemory Png => _png;
+
+ internal int Width { get; }
+
+ internal int Height { get; }
+
+ private static void Validate(ReadOnlySpan png, int width, int height)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
+ if (png.Length < 24
+ || !png[..PngSignature.Length].SequenceEqual(PngSignature)
+ || BinaryPrimitives.ReadUInt32BigEndian(png[8..12]) != 13
+ || !png[12..16].SequenceEqual("IHDR"u8))
+ {
+ throw new ArgumentException("PNG data must contain a valid IHDR header.", nameof(png));
+ }
+
+ int encodedWidth = checked((int)BinaryPrimitives.ReadUInt32BigEndian(png[16..20]));
+ int encodedHeight = checked((int)BinaryPrimitives.ReadUInt32BigEndian(png[20..24]));
+ if (encodedWidth != width || encodedHeight != height)
+ {
+ throw new ArgumentException(
+ "PNG header dimensions must match the rendered image dimensions.",
+ nameof(png));
+ }
+ }
+}
diff --git a/src/Snaply.App/ScreenCaptureService.cs b/src/Snaply.App/ScreenCaptureService.cs
index d6ef809..1c2150b 100644
--- a/src/Snaply.App/ScreenCaptureService.cs
+++ b/src/Snaply.App/ScreenCaptureService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Microsoft.Graphics.Canvas;
using Microsoft.UI.Xaml;
@@ -11,23 +12,29 @@ namespace Snaply;
internal sealed partial class ScreenCaptureService : IDisposable
{
private const uint WdaExcludeFromCapture = 0x00000011;
+ private const int CaptureSourceBytesPerPixel = 24;
+ private const int CompositeBytesPerPixel = 4;
+ private const int RenderInputBytesPerPixel = 8;
+ private const int RenderOutputBytesPerPixel = 16;
+ private const long MaximumCaptureBudgetBytes = 1_610_612_736;
private static readonly Guid GraphicsCaptureItemId = new("79C3F95B-31F7-4EC2-A464-632EF5D30760");
private static readonly TimeSpan FirstFrameTimeout = TimeSpan.FromSeconds(5);
+ private readonly Window _appWindow;
+ private readonly bool _captureExclusionEnabled;
private readonly object _deviceLock = new();
private readonly Dictionary _monitorItems = [];
- private readonly RegionSelectionService _regionSelection = new();
- private CanvasDevice? _device = new();
- private Window? _appWindow;
- private bool _captureExclusionEnabled;
+ private readonly RegionSelectionService _regionSelection;
+ private CanvasDevice? _device;
private bool _disposed;
- internal void SetAppWindow(Window window, bool captureExclusionEnabled)
+ internal ScreenCaptureService(Window appWindow, bool captureExclusionEnabled)
{
- _appWindow = window;
+ _appWindow = appWindow;
_captureExclusionEnabled = captureExclusionEnabled;
+ _regionSelection = new RegionSelectionService(ActivateOwner);
}
- internal async Task CaptureAsync(CaptureMode mode, CancellationToken cancellationToken)
+ internal Task CaptureAsync(CaptureMode mode, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!GraphicsCaptureSession.IsSupported())
@@ -37,9 +44,9 @@ internal void SetAppWindow(Window window, bool captureExclusionEnabled)
return mode switch
{
- CaptureMode.Window => await CapturePickedItemAsync(cancellationToken),
- CaptureMode.Desktop => await CaptureDesktopAsync(cancellationToken),
- CaptureMode.Region => await CaptureRegionAsync(cancellationToken),
+ CaptureMode.Window => CapturePickedItemAsync(cancellationToken),
+ CaptureMode.Desktop => CaptureDesktopAsync(cancellationToken),
+ CaptureMode.Region => CaptureRegionAsync(cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
}
@@ -61,6 +68,39 @@ public void Dispose()
}
}
+ internal bool IsGraphicsDeviceLost(Exception exception)
+ {
+ for (Exception? current = exception; current is not null; current = current.InnerException)
+ {
+ if (current is GraphicsDeviceLostException)
+ {
+ return true;
+ }
+
+ lock (_deviceLock)
+ {
+ if (_device?.IsDeviceLost(current.HResult) is true)
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ internal void ResetGraphicsDevice()
+ {
+ CanvasDevice? device;
+ lock (_deviceLock)
+ {
+ device = _device;
+ _device = null;
+ }
+
+ device?.Dispose();
+ }
+
private async Task CapturePickedItemAsync(CancellationToken cancellationToken)
{
var picker = new GraphicsCapturePicker();
@@ -73,10 +113,14 @@ public void Dispose()
return null;
}
+ ValidateSize(item.Size.Width, item.Size.Height);
+ ValidateCaptureBudget(
+ checked((long)item.Size.Width * item.Size.Height),
+ new PixelRect(0, 0, item.Size.Width, item.Size.Height));
bool hidden = HideApp();
try
{
- await WaitForHiddenAppAsync(hidden, cancellationToken);
+ cancellationToken.ThrowIfCancellationRequested();
CanvasBitmap bitmap = await CaptureItemAsSdrAsync(item, cancellationToken);
return new CapturedFrame(bitmap);
}
@@ -86,52 +130,29 @@ public void Dispose()
}
}
- private async Task CaptureDesktopAsync(CancellationToken cancellationToken)
+ private async Task CaptureDesktopAsync(CancellationToken cancellationToken)
{
IReadOnlyList monitors = MonitorSnapshot.Enumerate();
PixelRect desktop = PixelRect.Bounds(monitors.Select(static monitor => monitor.Bounds));
ValidateSize(desktop.Width, desktop.Height);
+ ValidateCaptureBudget(monitors, desktop);
bool hidden = HideApp();
try
{
- await WaitForHiddenAppAsync(hidden, cancellationToken);
- CanvasDevice device = GetDevice();
- var target = new CanvasRenderTarget(
- device,
- desktop.Width,
- desktop.Height,
- 96,
- DirectXPixelFormat.B8G8R8A8UIntNormalized,
- CanvasAlphaMode.Premultiplied);
+ cancellationToken.ThrowIfCancellationRequested();
+ IReadOnlyList captures =
+ await CaptureMonitorBitmapsAsync(monitors, cancellationToken);
try
{
- using CanvasDrawingSession drawing = target.CreateDrawingSession();
- drawing.Clear(Windows.UI.Color.FromArgb(0, 0, 0, 0));
-
- foreach (MonitorSnapshot monitor in monitors)
- {
- cancellationToken.ThrowIfCancellationRequested();
- using CanvasBitmap bitmap = await CaptureMonitorAsSdrAsync(
- monitor,
- cancellationToken);
- ValidateMonitorBitmap(bitmap, monitor);
-
- drawing.DrawImage(
- bitmap,
- new Rect(
- checked(monitor.Bounds.X - desktop.X),
- checked(monitor.Bounds.Y - desktop.Y),
- monitor.Bounds.Width,
- monitor.Bounds.Height));
- }
-
- return new CapturedFrame(target);
+ CapturedFrame frame = await Task.Run(
+ () => ComposeCaptures(captures, desktop, cancellationToken),
+ cancellationToken);
+ return frame;
}
- catch
+ finally
{
- target.Dispose();
- throw;
+ DisposeCaptures(captures);
}
}
finally
@@ -150,61 +171,78 @@ private async Task CaptureDesktopAsync(CancellationToken cancella
}
ValidateSize(region.Value.Width, region.Value.Height);
+ MonitorSnapshot[] intersecting = monitors
+ .Where(monitor => !monitor.Bounds.Intersect(region.Value).IsEmpty)
+ .ToArray();
+ if (intersecting.Length == 0)
+ {
+ throw new InvalidOperationException("The selected region is no longer available.");
+ }
+
+ ValidateCaptureBudget(intersecting, region.Value);
bool hidden = HideApp();
try
{
- await WaitForHiddenAppAsync(hidden, cancellationToken);
- CanvasDevice device = GetDevice();
- var target = new CanvasRenderTarget(
- device,
- region.Value.Width,
- region.Value.Height,
- 96,
- DirectXPixelFormat.B8G8R8A8UIntNormalized,
- CanvasAlphaMode.Premultiplied);
+ cancellationToken.ThrowIfCancellationRequested();
+ IReadOnlyList captures =
+ await CaptureMonitorBitmapsAsync(intersecting, cancellationToken);
try
{
- using CanvasDrawingSession drawing = target.CreateDrawingSession();
- drawing.Clear(Windows.UI.Color.FromArgb(0, 0, 0, 0));
- bool drewMonitor = false;
-
- foreach (MonitorSnapshot monitor in monitors)
- {
- PixelRect intersection = monitor.Bounds.Intersect(region.Value);
- if (intersection.IsEmpty)
- {
- continue;
- }
-
- using CanvasBitmap bitmap = await CaptureMonitorAsSdrAsync(
- monitor,
- cancellationToken);
- ValidateMonitorBitmap(bitmap, monitor);
- PixelRect source = intersection.RelativeTo(monitor.Bounds);
- PixelRect destination = intersection.RelativeTo(region.Value);
- drawing.DrawImage(
- bitmap,
- ToRect(destination),
- ToRect(source));
- drewMonitor = true;
- }
+ CapturedFrame frame = await Task.Run(
+ () => ComposeCaptures(captures, region.Value, cancellationToken),
+ cancellationToken);
+ return frame;
+ }
+ finally
+ {
+ DisposeCaptures(captures);
+ }
+ }
+ finally
+ {
+ ShowAppIfHidden(hidden);
+ }
+ }
- if (!drewMonitor)
+ private CapturedFrame ComposeCaptures(
+ IReadOnlyList captures,
+ PixelRect output,
+ CancellationToken cancellationToken)
+ {
+ CanvasRenderTarget? target = new(
+ GetDevice(),
+ output.Width,
+ output.Height,
+ 96,
+ DirectXPixelFormat.B8G8R8A8UIntNormalized,
+ CanvasAlphaMode.Premultiplied);
+ try
+ {
+ using CanvasDrawingSession drawing = target.CreateDrawingSession();
+ drawing.Clear(Windows.UI.Color.FromArgb(0, 0, 0, 0));
+ foreach (CapturedMonitorBitmap capture in captures)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ValidateMonitorBitmap(capture.Bitmap, capture.Monitor);
+ PixelRect intersection = capture.Monitor.Bounds.Intersect(output);
+ if (intersection.IsEmpty)
{
- throw new InvalidOperationException("The selected region is no longer available.");
+ continue;
}
- return new CapturedFrame(target);
- }
- catch
- {
- target.Dispose();
- throw;
+ drawing.DrawImage(
+ capture.Bitmap,
+ ToRect(intersection.RelativeTo(output)),
+ ToRect(intersection.RelativeTo(capture.Monitor.Bounds)));
}
+
+ var result = new CapturedFrame(target);
+ target = null;
+ return result;
}
finally
{
- ShowAppIfHidden(hidden);
+ target?.Dispose();
}
}
@@ -240,7 +278,7 @@ void OnDeviceLost(CanvasDevice sender, object args)
{
Interlocked.Exchange(ref deviceLost, 1);
InvalidateDevice(sender);
- completion.TrySetException(new InvalidOperationException("The graphics device was lost."));
+ completion.TrySetException(new GraphicsDeviceLostException());
}
pool.FrameArrived += OnFrameArrived;
@@ -257,8 +295,17 @@ void OnDeviceLost(CanvasDevice sender, object args)
session.StartCapture();
using Direct3D11CaptureFrame frame = await completion.Task;
ValidateSize(frame.ContentSize.Width, frame.ContentSize.Height);
+ ValidateCaptureBudget(
+ checked((long)frame.ContentSize.Width * frame.ContentSize.Height),
+ new PixelRect(
+ 0,
+ 0,
+ frame.ContentSize.Width,
+ frame.ContentSize.Height));
using CanvasBitmap source = CanvasBitmap.CreateFromDirect3D11Surface(device, frame.Surface);
- return ConvertToSdr(device, source, frame.ContentSize, cancellationToken);
+ return await Task.Run(
+ () => ConvertToSdr(device, source, frame.ContentSize, cancellationToken),
+ cancellationToken);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
@@ -334,6 +381,47 @@ private async Task CaptureMonitorAsSdrAsync(
}
}
+ private async Task> CaptureMonitorBitmapsAsync(
+ IReadOnlyList monitors,
+ CancellationToken cancellationToken)
+ {
+ using var concurrency = new SemaphoreSlim(Math.Min(monitors.Count, 4));
+ var completedCaptures = new ConcurrentBag();
+ Task[] tasks = monitors
+ .Select(async monitor =>
+ {
+ await concurrency.WaitAsync(cancellationToken);
+ try
+ {
+ CanvasBitmap bitmap = await CaptureMonitorAsSdrAsync(
+ monitor,
+ cancellationToken);
+ var capture = new CapturedMonitorBitmap(monitor, bitmap);
+ completedCaptures.Add(capture);
+ return capture;
+ }
+ finally
+ {
+ concurrency.Release();
+ }
+ })
+ .ToArray();
+
+ try
+ {
+ return await Task.WhenAll(tasks);
+ }
+ catch
+ {
+ foreach (CapturedMonitorBitmap capture in completedCaptures)
+ {
+ capture.Bitmap.Dispose();
+ }
+
+ throw;
+ }
+ }
+
private void InvalidateDevice(CanvasDevice device)
{
lock (_deviceLock)
@@ -376,11 +464,6 @@ private void RemoveMonitorItem(nint handle, GraphicsCaptureItem item)
private bool HideApp()
{
- if (_appWindow is null)
- {
- return false;
- }
-
nint handle = WinRT.Interop.WindowNative.GetWindowHandle(_appWindow);
if (_captureExclusionEnabled
&& GetWindowDisplayAffinity(handle, out uint affinity)
@@ -390,38 +473,88 @@ private bool HideApp()
}
_appWindow.AppWindow.Hide();
- _ = DwmFlush();
+ int result = DwmFlush();
+ if (result < 0)
+ {
+ _appWindow.AppWindow.Show();
+ _appWindow.Activate();
+ Marshal.ThrowExceptionForHR(result);
+ }
+
return true;
}
private void ShowAppIfHidden(bool hidden)
{
- if (hidden && _appWindow is not null)
+ if (hidden && !_disposed)
{
_appWindow.AppWindow.Show();
_appWindow.Activate();
}
}
- private static async Task WaitForHiddenAppAsync(
- bool hidden,
- CancellationToken cancellationToken)
+ private void ValidateSize(int width, int height)
{
- if (hidden)
+ int maximum = checked((int)GetDevice().MaximumBitmapSizeInPixels);
+ if (width <= 0 || height <= 0 || width > maximum || height > maximum)
{
- await Task.Delay(100, cancellationToken);
+ throw new ArgumentOutOfRangeException(nameof(width), "Capture dimensions are unsupported.");
+ }
+
+ PixelSize rendered = BeautifyLayout.Compute(new PixelSize(width, height)).Canvas;
+ if (rendered.Width > maximum || rendered.Height > maximum)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(width),
+ "Capture dimensions leave no room for the rendered output.");
}
+
+ _ = checked((long)width * height);
}
- private void ValidateSize(int width, int height)
+ private static void ValidateCaptureBudget(
+ IReadOnlyList monitors,
+ PixelRect output)
{
- int maximum = checked((int)GetDevice().MaximumBitmapSizeInPixels);
- if (width <= 0 || height <= 0 || width > maximum || height > maximum)
+ long sourcePixels = monitors.Sum(
+ static monitor => checked((long)monitor.Bounds.Width * monitor.Bounds.Height));
+ ValidateCaptureBudget(sourcePixels, output);
+ }
+
+ private static void ValidateCaptureBudget(long sourcePixels, PixelRect output)
+ {
+ long outputPixels = output.Size.Area;
+ long renderedPixels = BeautifyLayout.Compute(output.Size).Canvas.Area;
+ long capturePeak = checked(
+ (sourcePixels * CaptureSourceBytesPerPixel)
+ + (outputPixels * CompositeBytesPerPixel));
+ long renderAndDeliveryPeak = checked(
+ (outputPixels * RenderInputBytesPerPixel)
+ + (renderedPixels * RenderOutputBytesPerPixel));
+ long estimatedBytes = Math.Max(capturePeak, renderAndDeliveryPeak);
+ GCMemoryInfo memory = GC.GetGCMemoryInfo();
+ long budget;
+ if (memory.HighMemoryLoadThresholdBytes > 0 && memory.MemoryLoadBytes > 0)
+ {
+ long headroom = Math.Max(
+ 0,
+ memory.HighMemoryLoadThresholdBytes - memory.MemoryLoadBytes);
+ budget = Math.Min(MaximumCaptureBudgetBytes, headroom / 2);
+ }
+ else if (memory.TotalAvailableMemoryBytes > memory.TotalCommittedBytes)
{
- throw new ArgumentOutOfRangeException(nameof(width), "Capture dimensions are unsupported.");
+ long headroom = memory.TotalAvailableMemoryBytes - memory.TotalCommittedBytes;
+ budget = Math.Min(MaximumCaptureBudgetBytes, headroom / 2);
+ }
+ else
+ {
+ budget = MaximumCaptureBudgetBytes;
}
- _ = checked((long)width * height * 8);
+ if (estimatedBytes > budget)
+ {
+ throw new InvalidOperationException("The requested capture exceeds the safe memory budget.");
+ }
}
private static void ValidateMonitorBitmap(CanvasBitmap bitmap, MonitorSnapshot monitor)
@@ -436,6 +569,22 @@ private static void ValidateMonitorBitmap(CanvasBitmap bitmap, MonitorSnapshot m
private static Rect ToRect(PixelRect rectangle) =>
new(rectangle.X, rectangle.Y, rectangle.Width, rectangle.Height);
+ private static void DisposeCaptures(IReadOnlyList captures)
+ {
+ foreach (CapturedMonitorBitmap capture in captures)
+ {
+ capture.Bitmap.Dispose();
+ }
+ }
+
+ private void ActivateOwner()
+ {
+ if (!_disposed)
+ {
+ _appWindow.Activate();
+ }
+ }
+
private static GraphicsCaptureItem CreateItemForMonitor(nint monitor)
{
var interop = GraphicsCaptureItem.As();
@@ -452,6 +601,29 @@ private static GraphicsCaptureItem CreateItemForMonitor(nint monitor)
private readonly record struct MonitorCaptureItem(PixelRect Bounds, GraphicsCaptureItem Item);
+ private readonly record struct CapturedMonitorBitmap(
+ MonitorSnapshot Monitor,
+ CanvasBitmap Bitmap);
+
+ private sealed class GraphicsDeviceLostException : InvalidOperationException
+ {
+ private const int DeviceRemoved = unchecked((int)0x887A0005);
+
+ internal GraphicsDeviceLostException()
+ : base("The graphics device was lost.")
+ {
+ HResult = DeviceRemoved;
+ }
+
+ public GraphicsDeviceLostException(string message) : base(message)
+ {
+ }
+
+ public GraphicsDeviceLostException(string message, Exception innerException) : base(message, innerException)
+ {
+ }
+ }
+
[ComImport]
[Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
diff --git a/src/Snaply.App/Snaply.App.csproj b/src/Snaply.App/Snaply.App.csproj
index df8a9a4..da36ad7 100644
--- a/src/Snaply.App/Snaply.App.csproj
+++ b/src/Snaply.App/Snaply.App.csproj
@@ -9,11 +9,11 @@
Package.appxmanifest
Assets\AppIcon.ico
en-US
- en-US;ja-JP;zh-Hans;zh-CN
+ en-US;ja-JP;zh-Hans
x64;ARM64
win-x64;win-arm64
- x64
- win-x64
+ x64
+ win-x64
win-arm64
true
false
@@ -26,8 +26,12 @@
true
false
false
+ true
+ full
+ false
+ false
true
- false
+ true
@@ -40,10 +44,12 @@
-
-
-
-
+
+
+
+
@@ -69,4 +75,13 @@
+
+
+
+
+
+
diff --git a/src/Snaply.App/Strings/en-US/Resources.resw b/src/Snaply.App/Strings/en-US/Resources.resw
index cb5dda7..6620e33 100644
--- a/src/Snaply.App/Strings/en-US/Resources.resw
+++ b/src/Snaply.App/Strings/en-US/Resources.resw
@@ -2,16 +2,44 @@
text/microsoft-resx
2.0
+ Snaply
+ Snaply
+ Capture a region, window, or desktop and save a polished PNG.
Capture region
Capture window
Capture desktop
+ Capture region
Region
Window
Entire desktop
- Open folder
+ Open screenshots folder
+ Open screenshots folder
Beautified screenshot preview
+ Screenshot preview error
+ Screenshot output status
+ Zoomable screenshot preview
+ Capture something
+ Choose a region, window, or desktop. Snaply saves and copies it automatically.
+ Capturing screenshot
+ Retry failed output
+ Zoom out
+ Zoom out (Ctrl+-)
+ Zoom in
+ Zoom in (Ctrl++)
+ Fit
+ Fit image to window
+ Fit image to window (Ctrl+0)
+ 100%
+ Show image at actual size
+ Actual size (Ctrl+1)
Couldn't capture the selected content.
+ The screenshot was delivered, but its preview couldn't be shown.
Couldn't open the screenshots folder.
- Drag to select · Esc to cancel
- Cancel
+ Saved and copied.
+ Saved, but couldn't copy to the clipboard.
+ Copied, but couldn't save the PNG.
+ Couldn't save or copy the screenshot.
+ Drag to select · Esc to cancel
+ Region selection surface
+ Cancel
diff --git a/src/Snaply.App/Strings/ja-JP/Resources.resw b/src/Snaply.App/Strings/ja-JP/Resources.resw
index 3ba0c2c..08610f2 100644
--- a/src/Snaply.App/Strings/ja-JP/Resources.resw
+++ b/src/Snaply.App/Strings/ja-JP/Resources.resw
@@ -2,16 +2,44 @@
text/microsoft-resx
2.0
+ Snaply
+ Snaply
+ 領域、ウィンドウ、デスクトップを撮影し、整えたPNGとして保存します。
領域を撮影
ウィンドウを撮影
デスクトップを撮影
+ 領域を撮影
領域
ウィンドウ
デスクトップ全体
- フォルダーを開く
+ スクリーンショットフォルダーを開く
+ スクリーンショットフォルダーを開く
加工済みスクリーンショットのプレビュー
+ スクリーンショットのプレビュー エラー
+ スクリーンショットの出力状況
+ ズーム可能なスクリーンショットのプレビュー
+ スクリーンショットを撮影
+ 領域、ウィンドウ、デスクトップから選択してください。撮影後は自動で保存・コピーします。
+ スクリーンショットを撮影中
+ 失敗した出力を再試行
+ 縮小
+ 縮小(Ctrl+-)
+ 拡大
+ 拡大(Ctrl++)
+ 全体表示
+ 画像全体をウィンドウに表示
+ 全体表示(Ctrl+0)
+ 100%
+ 画像を実寸で表示
+ 実寸表示(Ctrl+1)
選択した内容を撮影できませんでした。
+ 画像は出力されましたが、プレビューを表示できませんでした。
スクリーンショットフォルダーを開けませんでした。
- ドラッグして選択 · Escでキャンセル
- キャンセル
+ 保存してクリップボードへコピーしました。
+ 保存しましたが、クリップボードへコピーできませんでした。
+ コピーしましたが、PNGを保存できませんでした。
+ スクリーンショットを保存もコピーもできませんでした。
+ ドラッグして選択 · Escでキャンセル
+ 領域選択画面
+ キャンセル
diff --git a/src/Snaply.App/Strings/zh-Hans/Resources.resw b/src/Snaply.App/Strings/zh-Hans/Resources.resw
index c9037ac..51a0f3b 100644
--- a/src/Snaply.App/Strings/zh-Hans/Resources.resw
+++ b/src/Snaply.App/Strings/zh-Hans/Resources.resw
@@ -2,16 +2,44 @@
text/microsoft-resx
2.0
+ Snaply
+ Snaply
+ 捕获区域、窗口或桌面,并保存为美化后的PNG。
捕获区域
捕获窗口
捕获桌面
+ 捕获区域
区域
窗口
整个桌面
- 打开文件夹
+ 打开屏幕截图文件夹
+ 打开屏幕截图文件夹
美化后的屏幕截图预览
+ 屏幕截图预览错误
+ 屏幕截图输出状态
+ 可缩放的屏幕截图预览
+ 捕获屏幕截图
+ 选择区域、窗口或桌面。Snaply会自动保存并复制。
+ 正在捕获屏幕截图
+ 重试失败的输出
+ 缩小
+ 缩小(Ctrl+-)
+ 放大
+ 放大(Ctrl++)
+ 适应
+ 使图像适应窗口
+ 适应窗口(Ctrl+0)
+ 100%
+ 按实际大小显示图像
+ 实际大小(Ctrl+1)
无法捕获所选内容。
+ 屏幕截图已输出,但无法显示预览。
无法打开屏幕截图文件夹。
- 拖动以选择 · Esc取消
- 取消
+ 已保存并复制。
+ 已保存,但无法复制到剪贴板。
+ 已复制,但无法保存PNG。
+ 无法保存或复制屏幕截图。
+ 拖动以选择 · Esc取消
+ 区域选择界面
+ 取消
diff --git a/src/Snaply.App/Themes/Styles.xaml b/src/Snaply.App/Themes/Styles.xaml
index cce4f73..7d89288 100644
--- a/src/Snaply.App/Themes/Styles.xaml
+++ b/src/Snaply.App/Themes/Styles.xaml
@@ -1,77 +1,41 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Snaply.App/Themes/Tokens.Primitives.xaml b/src/Snaply.App/Themes/Tokens.Primitives.xaml
deleted file mode 100644
index f5389a6..0000000
--- a/src/Snaply.App/Themes/Tokens.Primitives.xaml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
- 4
- 8
- 12
- 16
- 24
- 32
-
-
- 12
- 16
- 20
- 24
- 48
-
diff --git a/src/Snaply.App/Themes/Tokens.Semantic.xaml b/src/Snaply.App/Themes/Tokens.Semantic.xaml
deleted file mode 100644
index df72abe..0000000
--- a/src/Snaply.App/Themes/Tokens.Semantic.xaml
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
-
-
-
- 360
-
- 360
- 280
-
- 520
-
- 160
- 44
-
-
- 48
- 24
- 1
-
-
- 1200
- 800
- 880
- 600
-
-
- 16,10
- 16
- 24
- 16,10
-
-
- 16,0,0,0
- 0,48,0,0
-
-
- 1
-
- 0,0,0,1
-
- 2
-
-
- 8
-
-
-
- 12
-
- 8,6
-
-
-
- 12
-
- 8,4
-
- 48
-
diff --git a/src/Snaply.App/ViewModels/MainViewModel.cs b/src/Snaply.App/ViewModels/MainViewModel.cs
index 2e25fa1..10c35e7 100644
--- a/src/Snaply.App/ViewModels/MainViewModel.cs
+++ b/src/Snaply.App/ViewModels/MainViewModel.cs
@@ -1,195 +1,263 @@
-using System.Runtime.InteropServices.WindowsRuntime;
+using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
-using Microsoft.UI.Xaml.Media.Imaging;
using Serilog;
-using Windows.Graphics.Imaging;
-using Windows.Storage.Streams;
namespace Snaply;
+internal enum NoticeKind
+{
+ Information,
+ Success,
+ Warning,
+ Error,
+}
+
internal sealed partial class MainViewModel : ObservableObject, IDisposable
{
- private readonly ScreenCaptureService _capture;
- private readonly ImageExportService _export;
- private CancellationTokenSource? _operation;
+ private readonly ICapturePipeline _capture;
+ private readonly CancellationTokenSource _lifetime = new();
+ private readonly IImageOutput _output;
+ private readonly Func _text;
+ private RenderedImage? _lastImage;
+ private DeliveryOutcome _lastDelivery;
+ private bool _disposed;
+ private bool _operationRunning;
[ObservableProperty]
- internal partial WriteableBitmap? Preview { get; set; }
+ internal partial RenderedImage? PreviewImage { get; set; }
- // The capture pill picks the mode; CaptureCommand runs whatever is selected.
[ObservableProperty]
- internal partial CaptureMode SelectedMode { get; set; } = CaptureMode.Desktop;
+ internal partial CaptureMode SelectedMode { get; set; } = CaptureMode.Region;
[ObservableProperty]
- internal partial bool HasImage { get; set; }
+ internal partial bool HasStatus { get; set; }
[ObservableProperty]
- internal partial bool HasError { get; set; }
+ internal partial string StatusMessage { get; set; } = string.Empty;
[ObservableProperty]
- internal partial string ErrorMessage { get; set; } = string.Empty;
+ internal partial NoticeKind StatusKind { get; set; }
- // Bumped on each successful automatic save; the view watches it to play the folder→green-check
- // "saved" animation (that flip IS the save feedback — there is no toast).
[ObservableProperty]
- internal partial int SavedTick { get; set; }
+ internal partial bool CanRetry { get; set; }
internal MainViewModel(
- ScreenCaptureService capture,
- ImageExportService export)
+ ICapturePipeline capture,
+ IImageOutput output,
+ Func? text = null)
{
_capture = capture;
- _export = export;
+ _output = output;
+ _text = text ?? ResourceText.Get;
}
- // AsyncRelayCommand refuses to run while an execution is in flight and reports that
- // through CanExecute, so the bound pill disables itself for the duration and the view
- // needs no separate busy flag or re-entrancy guard.
- [RelayCommand]
+ [RelayCommand(CanExecute = nameof(CanStartOperation))]
private async Task CaptureAsync()
{
- HasError = false;
- using var operation = new CancellationTokenSource();
- _operation = operation;
-
+ using CancellationTokenSource operation = BeginOperation();
try
{
- using CapturedFrame? frame = await _capture.CaptureAsync(SelectedMode, operation.Token);
- if (frame is null)
+ ClearStatus();
+ RenderedImage? image = await _capture.CaptureAsync(SelectedMode, operation.Token);
+ operation.Token.ThrowIfCancellationRequested();
+ if (image is null)
{
return;
}
- RenderedImage image = await BeautifyRenderer.RenderAsync(frame, operation.Token);
- WriteableBitmap preview = await UpdatePreviewAsync(Preview, image, operation.Token);
- Preview = preview;
- HasImage = true;
-
- Task save = TrySaveAutomaticallyAsync(image, operation.Token);
- Task copy = TryCopyAsync(image, operation.Token);
- await Task.WhenAll(save, copy);
- if (await save)
- {
- SavedTick++;
- }
+ PreviewImage = image;
+ _lastImage = image;
+ _lastDelivery = await _output.DeliverAsync(
+ image,
+ DeliveryRequest.All,
+ DateTimeOffset.Now,
+ operation.Token);
+ operation.Token.ThrowIfCancellationRequested();
+ ApplyDeliveryOutcome(_lastDelivery);
}
catch (OperationCanceledException)
{
- // Cancellation (Esc, or the window picker dismissed) is a normal outcome — nothing to surface.
}
- catch (Exception exception)
+ catch (Exception exception) when (IsOperationalFailure(exception))
{
LogFailure("Capture", exception);
- ShowError("ErrorCapture");
+ ShowStatus("ErrorCapture", NoticeKind.Error, canRetry: false);
}
finally
{
- if (ReferenceEquals(_operation, operation))
- {
- _operation = null;
- }
+ EndOperation();
}
}
- internal void OpenFolder()
+ [RelayCommand(CanExecute = nameof(CanRetryDelivery))]
+ private async Task RetryDeliveryAsync()
{
- HasError = false;
+ if (_lastImage is null)
+ {
+ return;
+ }
+
+ DeliveryRequest request = _lastDelivery.FailedTargets;
+ if (request.IsEmpty)
+ {
+ return;
+ }
+
+ using CancellationTokenSource operation = BeginOperation();
try
{
- _export.OpenCaptureDirectory();
+ ClearStatus();
+ DeliveryOutcome retried = await _output.DeliverAsync(
+ _lastImage,
+ request,
+ DateTimeOffset.Now,
+ operation.Token);
+ operation.Token.ThrowIfCancellationRequested();
+ _lastDelivery = Merge(_lastDelivery, retried, request);
+ ApplyDeliveryOutcome(_lastDelivery);
}
- catch (Exception exception)
+ catch (OperationCanceledException)
{
- LogFailure("OpenFolder", exception);
- ShowError("ErrorOpenFolder");
+ }
+ catch (Exception exception) when (IsOperationalFailure(exception))
+ {
+ LogFailure("RetryDelivery", exception);
+ ShowStatus("StatusDeliveryFailed", NoticeKind.Error, canRetry: true);
+ }
+ finally
+ {
+ EndOperation();
}
}
- public void Dispose()
+ [RelayCommand(CanExecute = nameof(CanOpenFolder))]
+ private void OpenFolder()
{
- _operation?.Cancel();
- _operation?.Dispose();
- _operation = null;
+ try
+ {
+ _output.OpenCaptureDirectory();
+ }
+ catch (Exception exception) when (IsOperationalFailure(exception))
+ {
+ LogFailure("OpenFolder", exception);
+ ShowStatus("ErrorOpenFolder", NoticeKind.Error, canRetry: false);
+ }
}
- private static async Task UpdatePreviewAsync(
- WriteableBitmap? preview,
- RenderedImage image,
- CancellationToken cancellationToken)
+ public void Dispose()
{
- using var stream = new InMemoryRandomAccessStream();
- await stream.WriteAsync(image.Png.AsBuffer()).AsTask(cancellationToken);
- stream.Seek(0);
- BitmapDecoder decoder = await BitmapDecoder.CreateAsync(stream).AsTask(cancellationToken);
- PixelDataProvider provider = await decoder.GetPixelDataAsync(
- BitmapPixelFormat.Bgra8,
- BitmapAlphaMode.Premultiplied,
- new BitmapTransform(),
- ExifOrientationMode.IgnoreExifOrientation,
- ColorManagementMode.ColorManageToSRgb).AsTask(cancellationToken);
- byte[] pixels = provider.DetachPixelData();
- int expectedLength = checked(checked(image.Width * image.Height) * 4);
- if (pixels.Length != expectedLength)
- {
- throw new InvalidDataException("Decoded preview dimensions are invalid.");
- }
-
- WriteableBitmap bitmap = preview is not null
- && preview.PixelWidth == image.Width
- && preview.PixelHeight == image.Height
- ? preview
- : new WriteableBitmap(image.Width, image.Height);
- using Stream buffer = bitmap.PixelBuffer.AsStream();
- buffer.Position = 0;
- await buffer.WriteAsync(pixels, cancellationToken);
- await buffer.FlushAsync(cancellationToken);
- bitmap.Invalidate();
- return bitmap;
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _lifetime.Cancel();
+ _lifetime.Dispose();
+ if (!_operationRunning)
+ {
+ _capture.Dispose();
+ }
+
+ NotifyCommandStates();
}
- private async Task TrySaveAutomaticallyAsync(
- RenderedImage image,
- CancellationToken cancellationToken)
+ private bool CanStartOperation() => !_disposed && !_operationRunning;
+
+ private bool CanRetryDelivery() => CanStartOperation() && CanRetry;
+
+ private bool CanOpenFolder() => !_disposed;
+
+ private CancellationTokenSource BeginOperation()
{
- try
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_operationRunning)
{
- await _export.SaveAutomaticallyAsync(image, DateTimeOffset.Now, cancellationToken);
- return true;
+ throw new InvalidOperationException("Another operation is already running.");
}
- catch (Exception exception) when (exception is not OperationCanceledException)
+
+ var operation = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
+ _operationRunning = true;
+ NotifyCommandStates();
+ return operation;
+ }
+
+ private void EndOperation()
+ {
+ _operationRunning = false;
+ if (_disposed)
{
- LogFailure("AutoSave", exception);
- return false;
+ _capture.Dispose();
}
+
+ NotifyCommandStates();
}
- private static async Task TryCopyAsync(
- RenderedImage image,
- CancellationToken cancellationToken)
+ private void ApplyDeliveryOutcome(DeliveryOutcome outcome)
{
- try
+ if (outcome.AllSucceeded)
+ {
+ ShowStatus("StatusSavedAndCopied", NoticeKind.Success, canRetry: false);
+ }
+ else if (outcome.Save is DeliveryResult.Succeeded)
{
- await ImageExportService.CopyAsync(image, cancellationToken);
- return true;
+ ShowStatus("StatusSavedCopyFailed", NoticeKind.Warning, canRetry: true);
}
- catch (Exception exception) when (exception is not OperationCanceledException)
+ else if (outcome.Clipboard is DeliveryResult.Succeeded)
{
- LogFailure("Clipboard", exception);
- return false;
+ ShowStatus("StatusCopiedSaveFailed", NoticeKind.Warning, canRetry: true);
}
+ else
+ {
+ ShowStatus("StatusDeliveryFailed", NoticeKind.Error, canRetry: true);
+ }
+ }
+
+ private void ClearStatus()
+ {
+ HasStatus = false;
+ CanRetry = false;
+ RetryDeliveryCommand.NotifyCanExecuteChanged();
}
- private void ShowError(string key)
+ private void ShowStatus(string key, NoticeKind kind, bool canRetry)
{
- ErrorMessage = ResourceText.Get(key);
- HasError = true;
+ StatusMessage = _text(key);
+ StatusKind = kind;
+ CanRetry = canRetry;
+ HasStatus = true;
+ RetryDeliveryCommand.NotifyCanExecuteChanged();
}
+ private void NotifyCommandStates()
+ {
+ CaptureCommand.NotifyCanExecuteChanged();
+ RetryDeliveryCommand.NotifyCanExecuteChanged();
+ OpenFolderCommand.NotifyCanExecuteChanged();
+ }
+
+ private static DeliveryOutcome Merge(
+ DeliveryOutcome previous,
+ DeliveryOutcome retried,
+ DeliveryRequest request) =>
+ new(
+ request.Save ? retried.Save : previous.Save,
+ request.Clipboard ? retried.Clipboard : previous.Clipboard);
+
private static void LogFailure(string operation, Exception exception) =>
Log.Warning(
"{Operation} failed {ExceptionType} {HResult}",
operation,
exception.GetType().FullName,
exception.HResult);
+
+ private static bool IsOperationalFailure(Exception exception) =>
+ exception is ArgumentException
+ or ExternalException
+ or IOException
+ or InvalidOperationException
+ or NotSupportedException
+ or TimeoutException
+ or UnauthorizedAccessException;
}
diff --git a/src/Snaply.App/packages.lock.json b/src/Snaply.App/packages.lock.json
index 0081e65..b4b4c58 100644
--- a/src/Snaply.App/packages.lock.json
+++ b/src/Snaply.App/packages.lock.json
@@ -17,35 +17,41 @@
"Microsoft.WindowsAppSDK.WinUI": "1.8.260204000"
}
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
+ },
"Microsoft.Windows.SDK.BuildTools": {
"type": "Direct",
- "requested": "[10.0.26100.4654, )",
- "resolved": "10.0.26100.4654",
- "contentHash": "2mgcOlj/t2RfSyyw+pVESfO+Tk1RkfQzto9Vrq42M1lUQIfQEwbi8QLha9GXWIOj+TFzeHIEJckIoF25mgiM8A=="
+ "requested": "[10.0.26100.8249, )",
+ "resolved": "10.0.26100.8249",
+ "contentHash": "WJ+X+fVbBcZFc5q1MufkqBNjeMqWUblbG6PmijNG0tMVt+VXdE5cdaD30dbZ90RwUTidbjxv/tmgovl5czLlrg=="
},
"Microsoft.Windows.SDK.BuildTools.WinApp": {
"type": "Direct",
- "requested": "[0.4.0, )",
- "resolved": "0.4.0",
- "contentHash": "5xq7u0YRQvOFT4xOfqBVgwnpUyEXMWAtCa88uY/jhK4HITivDH2iDVQtyjpqWI2DUkFyfcXPYgAxTIjmNui5eg=="
+ "requested": "[0.5.0, )",
+ "resolved": "0.5.0",
+ "contentHash": "zASqHuAUp38P+ZKLc3h/ERiTQgbDnxDn6vBDLMw3aYynDJ8p6y+e5esKXLQcyDnMwXj2QIDgwAy+Lw3vdMh+qw=="
},
"Microsoft.WindowsAppSDK.WinUI": {
"type": "Direct",
- "requested": "[2.2.1, )",
- "resolved": "2.2.1",
- "contentHash": "w8KuqJB7IphDRXAGNVuRq+oGeoGycc4ZAraA+OIpsGm/boEKHLkwXmzNDaNrVlnsDpjxQFWE2XVp0hXjlWJqxw==",
+ "requested": "[2.3.2, )",
+ "resolved": "2.3.2",
+ "contentHash": "vS+HfuG12aZYVnPXyc7fFzZXm4iKmZyyVSZ+OrDBDlX/QktWu645vkWN/0tebbcrPfF15whsTesk1Gps4JELLw==",
"dependencies": {
"Microsoft.Web.WebView2": "1.0.3719.77",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.Foundation": "2.1.0",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.Foundation": "2.3.5",
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.3"
}
},
"Serilog": {
"type": "Direct",
- "requested": "[4.3.0, )",
- "resolved": "4.3.0",
- "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ=="
+ "requested": "[4.4.0, )",
+ "resolved": "4.4.0",
+ "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw=="
},
"Serilog.Sinks.File": {
"type": "Direct",
@@ -77,17 +83,17 @@
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
- "resolved": "2.1.0",
- "contentHash": "Urz1WrsXHYDHYrCPIWZlSTwgXwghEXlS6lY62Hk3vZE0GZ3hyYrMM7a+p6RrXC55GEgrRhOTRqXqcH2ZL/UWRA==",
+ "resolved": "2.3.5",
+ "contentHash": "Ke/HAoFLq1kgMPUn6UrNNBy5kcuHCJK/SvHep0m4FY1YoDGz9XUDnucDmCMSUxuGnxtI65Uq5UfmLc3eS31Ovg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.1"
}
},
"Microsoft.WindowsAppSDK.InteractiveExperiences": {
"type": "Transitive",
- "resolved": "2.0.15",
- "contentHash": "aQ9uzAIhTaN9zkclrtVg6kylpF+hESU+tC0bHarOCWdBz8ShZ7gt+gKwEmjKiqtPv6sRrRglyLLNVSPfb8KmMQ==",
+ "resolved": "2.1.3",
+ "contentHash": "2GwSpAWidRKZiGX0yeJ9SxjdgPYMpZXRaHbiss3rC4FglFYSpF/wmhW5axX2zJ/NP2e4LOoOiPtw1W4YRyQiFg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
@@ -113,11 +119,11 @@
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
- "resolved": "2.1.0",
- "contentHash": "Urz1WrsXHYDHYrCPIWZlSTwgXwghEXlS6lY62Hk3vZE0GZ3hyYrMM7a+p6RrXC55GEgrRhOTRqXqcH2ZL/UWRA==",
+ "resolved": "2.3.5",
+ "contentHash": "Ke/HAoFLq1kgMPUn6UrNNBy5kcuHCJK/SvHep0m4FY1YoDGz9XUDnucDmCMSUxuGnxtI65Uq5UfmLc3eS31Ovg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.1"
}
}
},
@@ -138,11 +144,11 @@
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
- "resolved": "2.1.0",
- "contentHash": "Urz1WrsXHYDHYrCPIWZlSTwgXwghEXlS6lY62Hk3vZE0GZ3hyYrMM7a+p6RrXC55GEgrRhOTRqXqcH2ZL/UWRA==",
+ "resolved": "2.3.5",
+ "contentHash": "Ke/HAoFLq1kgMPUn6UrNNBy5kcuHCJK/SvHep0m4FY1YoDGz9XUDnucDmCMSUxuGnxtI65Uq5UfmLc3eS31Ovg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.1"
}
}
}
diff --git a/src/Snaply.App/ui-tests.ps1 b/src/Snaply.App/ui-tests.ps1
index 3840a49..d848df3 100644
--- a/src/Snaply.App/ui-tests.ps1
+++ b/src/Snaply.App/ui-tests.ps1
@@ -194,6 +194,53 @@ public static class WindowSizing
}
'@
+function Get-AutoSaveCount {
+ if (-not (Test-Path -LiteralPath $autoSaveDirectory)) {
+ return 0
+ }
+
+ return @(Get-ChildItem -LiteralPath $autoSaveDirectory -Filter 'Snaply-*.png' -File).Count
+}
+
+function Clear-TestClipboard {
+ $lastError = $null
+ for ($attempt = 0; $attempt -lt 3; $attempt++) {
+ try {
+ [System.Windows.Forms.Clipboard]::Clear()
+ return
+ }
+ catch {
+ $lastError = $_.Exception.Message
+ Start-Sleep -Milliseconds (50 * ($attempt + 1))
+ }
+ }
+
+ throw "Could not clear the clipboard before capture. $lastError"
+}
+
+function Wait-NewDelivery {
+ param(
+ [int]$PreviousSaveCount,
+ [int]$Timeout = 5000
+ )
+
+ $deadline = [DateTime]::UtcNow.AddMilliseconds($Timeout)
+ do {
+ $current = Get-AutoSaveCount
+ if ($current -gt $PreviousSaveCount -and
+ [System.Windows.Forms.Clipboard]::ContainsImage()) {
+ return
+ }
+
+ Start-Sleep -Milliseconds 100
+ } while ([DateTime]::UtcNow -lt $deadline)
+
+ throw (
+ "Capture delivery did not create a new PNG and clipboard image. " +
+ "Saves: $PreviousSaveCount -> $current; " +
+ "clipboard image: $([System.Windows.Forms.Clipboard]::ContainsImage()).")
+}
+
function Get-AppWindow {
$root = [System.Windows.Automation.AutomationElement]::RootElement
$condition = [System.Windows.Automation.PropertyCondition]::new(
@@ -523,6 +570,7 @@ function Test-Ui {
param([string]$Name, [scriptblock]$Action)
try {
+ $global:LASTEXITCODE = 0
& $Action
if ($LASTEXITCODE -notin @(0, $null)) {
throw "Exit code $LASTEXITCODE"
@@ -558,9 +606,8 @@ function Invoke-CaptureMode {
$item = Wait-ProcessElement $AutomationId 2000
$item.GetCurrentPattern(
[System.Windows.Automation.InvokePattern]::Pattern).Invoke()
- # The flyout item only selects the mode — the pill body is what runs the
- # capture (MainPage.xaml.cs: RegionCaptureItem_Click -> SelectMode, capture
- # happens in CaptureButton_Click). Invoking the item alone starts nothing.
+ # The flyout item only selects the mode. Invoking the pill body then executes
+ # the bound CaptureCommand; invoking the menu item alone starts nothing.
$capture = Wait-AppElement CaptureButton IsEnabled $true 5000
$capture.GetCurrentPattern(
[System.Windows.Automation.InvokePattern]::Pattern).Invoke()
@@ -582,10 +629,42 @@ function Invoke-PrimaryCapture {
}
function Wait-CaptureComplete {
- param([int]$Timeout = 20000)
+ param(
+ [int]$PreviousSaveCount,
+ [int]$Timeout = 20000
+ )
Wait-AppElement CaptureButton IsEnabled $true $Timeout | Out-Null
Wait-AppElement PreviewImage IsOffscreen $false 3000 | Out-Null
+ Wait-NewDelivery $PreviousSaveCount 5000
+}
+
+function Save-StateScreenshot {
+ param([string]$Name)
+
+ $path = Join-Path $artifacts "$Architecture-$Name.png"
+ winapp ui screenshot -a $AppPid -o $path | Out-Null
+ if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $path)) {
+ throw "State screenshot '$Name' failed."
+ }
+}
+
+function Save-WindowScreenshot {
+ param(
+ [IntPtr]$WindowHandle,
+ [string]$Name
+ )
+
+ $path = Join-Path $artifacts "$Architecture-$Name.png"
+ winapp ui screenshot -w ([long]$WindowHandle) -o $path | Out-Null
+ if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $path)) {
+ throw "Window screenshot '$Name' failed."
+ }
+}
+
+Test-Ui 'Initial state screenshot' {
+ Wait-AppElement CaptureButton Exists $true 10000 | Out-Null
+ Save-StateScreenshot '01-initial'
}
foreach ($id in @(
@@ -610,6 +689,7 @@ Test-Ui 'Narrow window keeps capture reachable' {
}
Wait-AppElement CaptureButton IsOffscreen $false | Out-Null
+ Save-StateScreenshot '02-narrow'
if (-not [WindowSizing]::SetWindowPos(
$handle, [IntPtr]::Zero, 80, 80, 1100, 720, 0x0014)) {
throw 'Window restore failed.'
@@ -618,12 +698,25 @@ Test-Ui 'Narrow window keeps capture reachable' {
Test-Ui 'Region cancellation recovers' {
Invoke-CaptureMode RegionCaptureItem
+ $selectionWindow = Get-RegionSelectionWindow
+ Save-WindowScreenshot ([IntPtr]$selectionWindow.Current.NativeWindowHandle) '03-region-overlay'
(Wait-ProcessElement RegionCancelButton).
GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
Invoke()
Wait-AppElement CaptureButton IsEnabled $true 3000 | Out-Null
}
+Test-Ui 'Region Escape cancellation recovers' {
+ Invoke-CaptureMode RegionCaptureItem
+ $null = Wait-ProcessElement RegionCancelButton
+ $null = Wait-RegionOverlayForeground
+ if (-not [WindowSizing]::SendEscape()) {
+ throw 'Could not send Escape to the region overlay.'
+ }
+
+ Wait-AppElement CaptureButton IsEnabled $true 3000 | Out-Null
+}
+
# Whether a synthetic press actually reaches the overlay depends on who holds the
# foreground at that instant, and on these runners the shell reclaims it unpredictably —
# every deterministic fix so far moved the failure rather than removing it. Retry the
@@ -656,6 +749,8 @@ function Invoke-RegionDrag {
}
function Invoke-RegionDragOnce {
+ $saveCount = Get-AutoSaveCount
+ Clear-TestClipboard
Invoke-CaptureMode RegionCaptureItem
$null = Wait-ProcessElement RegionCancelButton
$null = Wait-RegionOverlayForeground
@@ -697,12 +792,12 @@ function Invoke-RegionDragOnce {
throw 'Could not release the region pointer.'
}
- Wait-AppElement CaptureButton IsEnabled $true 20000 | Out-Null
- Wait-AppElement PreviewImage IsOffscreen $false 3000 | Out-Null
+ Wait-CaptureComplete $saveCount
}
Test-Ui 'Region capture completes' {
Invoke-RegionDrag
+ Save-StateScreenshot '04-region-captured'
}
Test-Ui 'Window picker cancellation recovers' {
@@ -763,6 +858,8 @@ Test-Ui 'Window capture completes' {
}
Close-CapturePickers
+ $saveCount = Get-AutoSaveCount
+ Clear-TestClipboard
Invoke-CaptureMode WindowCaptureItem
$root = [System.Windows.Automation.AutomationElement]::RootElement
$itemCondition = [System.Windows.Automation.PropertyCondition]::new(
@@ -798,7 +895,8 @@ Test-Ui 'Window capture completes' {
$accept.GetCurrentPattern(
[System.Windows.Automation.InvokePattern]::Pattern).Invoke()
- Wait-CaptureComplete
+ Wait-CaptureComplete $saveCount
+ Save-StateScreenshot '05-window-captured'
}
finally {
Close-CapturePickers
@@ -813,8 +911,116 @@ Test-Ui 'Window capture completes' {
}
Test-Ui 'Desktop capture completes' {
+ $saveCount = Get-AutoSaveCount
+ Clear-TestClipboard
Invoke-CaptureMode DesktopCaptureItem
- Wait-CaptureComplete
+ Wait-CaptureComplete $saveCount
+ Save-StateScreenshot '06-desktop-captured'
+}
+Test-Ui 'Successful delivery is announced' {
+ $status = Wait-AppElement DeliveryInfoBar IsOffscreen $false 5000
+ if ([string]::IsNullOrWhiteSpace($status.Current.Name)) {
+ throw 'The delivery status has no accessible announcement.'
+ }
+}
+Test-Ui 'Preview zoom controls work' {
+ foreach ($id in @(
+ 'ZoomOutButton',
+ 'ZoomInButton',
+ 'FitButton',
+ 'ActualSizeButton',
+ 'ZoomLevelText')) {
+ Wait-AppElement $id IsOffscreen $false 3000 | Out-Null
+ }
+
+ (Get-AppElement 'ActualSizeButton').
+ GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
+ Invoke()
+ (Get-AppElement 'ZoomInButton').
+ GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
+ Invoke()
+ $zoom = (Get-AppElement 'ZoomLevelText').Current.Name
+ if ([string]::IsNullOrWhiteSpace($zoom) -or $zoom -eq '100%') {
+ throw "Zoom level did not change: '$zoom'."
+ }
+
+ (Get-AppElement 'FitButton').
+ GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
+ Invoke()
+ Save-StateScreenshot '07-zoom-fit'
+}
+Test-Ui 'Preview keyboard accelerators work' {
+ (Get-AppElement 'ActualSizeButton').
+ GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
+ Invoke()
+ $before = (Get-AppElement 'ZoomLevelText').Current.Name
+ $handle = [IntPtr](Get-AppWindow).Current.NativeWindowHandle
+ if (-not [WindowSizing]::ForceForeground($handle)) {
+ throw 'Could not focus the app for keyboard zoom.'
+ }
+
+ winapp ui send-keys 'ctrl+0' -a $AppPid --via send-input | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Ctrl+0 input failed.'
+ }
+
+ $deadline = [DateTime]::UtcNow.AddSeconds(3)
+ do {
+ $fit = (Get-AppElement 'ZoomLevelText').Current.Name
+ if ($fit -ne $before) {
+ break
+ }
+
+ Start-Sleep -Milliseconds 50
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($fit -eq $before) {
+ throw "Ctrl+0 did not fit the preview: '$fit'."
+ }
+
+ winapp ui send-keys 'ctrl+1' -a $AppPid --via send-input | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Ctrl+1 input failed.'
+ }
+
+ $deadline = [DateTime]::UtcNow.AddSeconds(3)
+ do {
+ $actual = (Get-AppElement 'ZoomLevelText').Current.Name
+ if ($actual -eq '100%') {
+ break
+ }
+
+ Start-Sleep -Milliseconds 50
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($actual -ne '100%') {
+ throw "Ctrl+1 did not restore actual size: '$actual'."
+ }
+}
+Test-Ui 'Preview touch zoom works' {
+ (Get-AppElement 'ActualSizeButton').
+ GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
+ Invoke()
+ $before = (Get-AppElement 'ZoomLevelText').Current.Name
+ winapp ui touch PreviewScroller -g stretch --distance 120 -a $AppPid | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Touch stretch input failed.'
+ }
+
+ $deadline = [DateTime]::UtcNow.AddSeconds(3)
+ do {
+ $after = (Get-AppElement 'ZoomLevelText').Current.Name
+ if ($after -ne $before) {
+ break
+ }
+
+ Start-Sleep -Milliseconds 50
+ } while ([DateTime]::UtcNow -lt $deadline)
+ if ($after -eq $before) {
+ throw "Touch stretch did not zoom the preview: '$after'."
+ }
+
+ (Get-AppElement 'FitButton').
+ GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
+ Invoke()
}
Test-Ui 'Automatic save created a PNG' {
$deadline = [DateTime]::UtcNow.AddSeconds(5)
@@ -834,6 +1040,25 @@ Test-Ui 'Automatic save created a PNG' {
if ($current -le $savesBeforeCapture) {
throw 'No automatic save appeared.'
}
+
+ $latest = Get-ChildItem -LiteralPath $autoSaveDirectory -Filter 'Snaply-*.png' -File |
+ Sort-Object LastWriteTimeUtc, Name -Descending |
+ Select-Object -First 1
+ $bytes = [System.IO.File]::ReadAllBytes($latest.FullName)
+ if ($bytes.Length -lt 24 -or
+ [BitConverter]::ToString($bytes, 0, 8).Replace('-', '') -ne '89504E470D0A1A0A') {
+ throw "Automatic save is not a PNG: $($latest.FullName)"
+ }
+
+ $image = [System.Drawing.Image]::FromFile($latest.FullName)
+ try {
+ if ($image.Width -le 0 -or $image.Height -le 0) {
+ throw 'Automatic save has invalid dimensions.'
+ }
+ }
+ finally {
+ $image.Dispose()
+ }
}
Test-Ui 'Capture places a bitmap on the clipboard' {
if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
@@ -866,7 +1091,7 @@ Test-Ui 'Open Folder opens the automatic-save directory' {
Test-Ui 'Interactive controls expose UI Automation identity' {
$inspection = winapp ui inspect -a $AppPid --interactive --json | ConvertFrom-Json
$missing = @($inspection.windows.elements | Where-Object {
- $_.type -match 'Button|SplitButton' -and
+ $_.type -match 'Button|Image|InfoBar|MenuItem|ProgressRing|ScrollViewer|SplitButton' -and
$_.name -notmatch 'Minimize|Maximize|Close|System|システム' -and
(-not $_.automationId -or -not $_.name)
})
@@ -911,6 +1136,8 @@ function Invoke-SoakStep {
Invoke-RegionCancellation
}
+ $saveCount = Get-AutoSaveCount
+ Clear-TestClipboard
$timer = [System.Diagnostics.Stopwatch]::StartNew()
if ($SoakCancellationInterval -gt 0 -and
$Iteration % $SoakCancellationInterval -eq 0) {
@@ -920,7 +1147,7 @@ function Invoke-SoakStep {
Invoke-PrimaryCapture
}
- Wait-CaptureComplete
+ Wait-CaptureComplete $saveCount
$timer.Stop()
if ($SoakResizeInterval -gt 0 -and
@@ -998,8 +1225,10 @@ if ($SoakIterations -gt 0) {
Start-Sleep -Milliseconds 250
if ($SoakCancellationInterval -gt 0) {
Invoke-RegionCancellation
+ $saveCount = Get-AutoSaveCount
+ Clear-TestClipboard
Invoke-CaptureMode DesktopCaptureItem
- Wait-CaptureComplete
+ Wait-CaptureComplete $saveCount
}
else {
$null = Invoke-SoakStep 1
diff --git a/src/Snaply.Imaging/BeautifyLayout.cs b/src/Snaply.Imaging/BeautifyLayout.cs
index baf6be6..7c1cf07 100644
--- a/src/Snaply.Imaging/BeautifyLayout.cs
+++ b/src/Snaply.Imaging/BeautifyLayout.cs
@@ -35,12 +35,20 @@ internal static BeautifyLayoutResult Compute(PixelSize source)
int canvasWidth = checked(source.Width + (padding * 2));
int canvasHeight = checked(source.Height + (padding * 2));
+ int shadowBlur = Math.Clamp(
+ (int)Math.Round(radius * 0.6, MidpointRounding.AwayFromZero),
+ 4,
+ 16);
+ int shadowOffset = Math.Clamp(
+ (int)Math.Round(radius * 0.4, MidpointRounding.AwayFromZero),
+ 3,
+ 12);
return new BeautifyLayoutResult(
new PixelSize(canvasWidth, canvasHeight),
new PixelRect(padding, padding, source.Width, source.Height),
radius,
- Math.Max(16, radius * 2),
- Math.Max(8, radius));
+ shadowBlur,
+ shadowOffset);
}
}
diff --git a/src/Snaply.Imaging/ColorPalette.cs b/src/Snaply.Imaging/ColorPalette.cs
index 62954d1..df463a4 100644
--- a/src/Snaply.Imaging/ColorPalette.cs
+++ b/src/Snaply.Imaging/ColorPalette.cs
@@ -44,6 +44,36 @@ private static (double Lightness, double Chroma, double Hue) RgbToOklch(Rgba col
}
private static Rgba OklchToRgba(double lightness, double chroma, double hueDegrees)
+ {
+ (double red, double green, double blue) = ToLinearRgb(lightness, chroma, hueDegrees);
+ if (!IsInGamut(red, green, blue))
+ {
+ double lower = 0;
+ double upper = chroma;
+ for (int iteration = 0; iteration < 16; iteration++)
+ {
+ double candidate = (lower + upper) / 2;
+ (red, green, blue) = ToLinearRgb(lightness, candidate, hueDegrees);
+ if (IsInGamut(red, green, blue))
+ {
+ lower = candidate;
+ }
+ else
+ {
+ upper = candidate;
+ }
+ }
+
+ (red, green, blue) = ToLinearRgb(lightness, lower, hueDegrees);
+ }
+
+ return new Rgba(ToByte(red), ToByte(green), ToByte(blue));
+ }
+
+ private static (double Red, double Green, double Blue) ToLinearRgb(
+ double lightness,
+ double chroma,
+ double hueDegrees)
{
double hue = hueDegrees * Math.PI / 180;
double a = chroma * Math.Cos(hue);
@@ -51,13 +81,17 @@ private static Rgba OklchToRgba(double lightness, double chroma, double hueDegre
double l = Math.Pow(lightness + (0.3963377774 * a) + (0.2158037573 * b), 3);
double m = Math.Pow(lightness - (0.1055613458 * a) - (0.0638541728 * b), 3);
double s = Math.Pow(lightness - (0.0894841775 * a) - (1.291485548 * b), 3);
-
- return new Rgba(
- ToByte((4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s)),
- ToByte((-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s)),
- ToByte((-0.0041960863 * l) - (0.7034186147 * m) + (1.707614701 * s)));
+ return (
+ (4.0767416621 * l) - (3.3077115913 * m) + (0.2309699292 * s),
+ (-1.2684380046 * l) + (2.6097574011 * m) - (0.3413193965 * s),
+ (-0.0041960863 * l) - (0.7034186147 * m) + (1.707614701 * s));
}
+ private static bool IsInGamut(double red, double green, double blue) =>
+ red is >= 0 and <= 1
+ && green is >= 0 and <= 1
+ && blue is >= 0 and <= 1;
+
private static ulong MixSeed(ulong hash, uint salt)
{
ulong value = hash ^ ((ulong)salt * 0x9E3779B97F4A7C15UL);
diff --git a/src/Snaply.Imaging/Geometry.cs b/src/Snaply.Imaging/Geometry.cs
index 88c4dab..835e071 100644
--- a/src/Snaply.Imaging/Geometry.cs
+++ b/src/Snaply.Imaging/Geometry.cs
@@ -67,20 +67,3 @@ internal static PixelRect Bounds(IEnumerable rectangles)
private static PixelRect CreateChecked(long x, long y, long width, long height) =>
new(checked((int)x), checked((int)y), checked((int)width), checked((int)height));
}
-
-internal readonly record struct DipRect(double X, double Y, double Width, double Height)
-{
- internal PixelRect ToPixels(double scale)
- {
- ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(scale, 0);
-
- int left = RoundChecked(X * scale);
- int top = RoundChecked(Y * scale);
- int right = RoundChecked((X + Width) * scale);
- int bottom = RoundChecked((Y + Height) * scale);
- return new PixelRect(left, top, checked(right - left), checked(bottom - top));
- }
-
- private static int RoundChecked(double value) =>
- checked((int)Math.Round(value, MidpointRounding.AwayFromZero));
-}
diff --git a/src/Snaply.Imaging/ScRgbToneMapper.cs b/src/Snaply.Imaging/ScRgbToneMapper.cs
index ceee2e5..1411b71 100644
--- a/src/Snaply.Imaging/ScRgbToneMapper.cs
+++ b/src/Snaply.Imaging/ScRgbToneMapper.cs
@@ -12,7 +12,7 @@ internal static bool ConvertToBgra8(
throw new ArgumentException("Pixel buffers have incompatible lengths.");
}
- bool toneMap = RequiresToneMapping(rgba);
+ bool toneMap = RequiresToneMapping(rgba, cancellationToken);
for (int source = 0, destination = 0; source < rgba.Length; source += 4, destination += 4)
{
if ((source & 0x3FFF) == 0)
@@ -20,22 +20,41 @@ internal static bool ConvertToBgra8(
cancellationToken.ThrowIfCancellationRequested();
}
- bgra[destination] = ToSrgbByte((float)rgba[source + 2], toneMap);
- bgra[destination + 1] = ToSrgbByte((float)rgba[source + 1], toneMap);
- bgra[destination + 2] = ToSrgbByte((float)rgba[source], toneMap);
- bgra[destination + 3] = ToAlphaByte((float)rgba[source + 3]);
+ float alpha = NormalizeAlpha((float)rgba[source + 3]);
+ bgra[destination] = ToPremultipliedSrgbByte(
+ (float)rgba[source + 2],
+ alpha,
+ toneMap);
+ bgra[destination + 1] = ToPremultipliedSrgbByte(
+ (float)rgba[source + 1],
+ alpha,
+ toneMap);
+ bgra[destination + 2] = ToPremultipliedSrgbByte(
+ (float)rgba[source],
+ alpha,
+ toneMap);
+ bgra[destination + 3] = ToAlphaByte(alpha);
}
return toneMap;
}
- private static bool RequiresToneMapping(ReadOnlySpan rgba)
+ private static bool RequiresToneMapping(
+ ReadOnlySpan rgba,
+ CancellationToken cancellationToken)
{
for (int index = 0; index < rgba.Length; index += 4)
{
- if ((float)rgba[index] > 1
- || (float)rgba[index + 1] > 1
- || (float)rgba[index + 2] > 1)
+ if ((index & 0x3FFF) == 0)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ float alpha = NormalizeAlpha((float)rgba[index + 3]);
+ if (alpha > 0
+ && ((float)rgba[index] / alpha > 1
+ || (float)rgba[index + 1] / alpha > 1
+ || (float)rgba[index + 2] / alpha > 1))
{
return true;
}
@@ -44,19 +63,22 @@ private static bool RequiresToneMapping(ReadOnlySpan rgba)
return false;
}
- private static byte ToSrgbByte(float linear, bool toneMap)
+ private static byte ToPremultipliedSrgbByte(
+ float premultipliedLinear,
+ float alpha,
+ bool toneMap)
{
- if (float.IsNaN(linear) || linear <= 0)
+ if (alpha <= 0 || float.IsNaN(premultipliedLinear) || premultipliedLinear <= 0)
{
return 0;
}
- if (float.IsPositiveInfinity(linear))
+ if (float.IsPositiveInfinity(premultipliedLinear))
{
- return byte.MaxValue;
+ return ToAlphaByte(alpha);
}
- float value = linear;
+ float value = premultipliedLinear / alpha;
if (toneMap)
{
value = Math.Clamp(
@@ -70,15 +92,22 @@ private static byte ToSrgbByte(float linear, bool toneMap)
value = Math.Min(value, 1);
}
+ if (value >= 1)
+ {
+ return ToAlphaByte(alpha);
+ }
+
float srgb = value <= 0.0031308f
? 12.92f * value
: (1.055f * MathF.Pow(value, 1 / 2.4f)) - 0.055f;
- return (byte)MathF.Round(srgb * byte.MaxValue, MidpointRounding.AwayFromZero);
+ return (byte)MathF.Round(
+ srgb * alpha * byte.MaxValue,
+ MidpointRounding.AwayFromZero);
}
- private static byte ToAlphaByte(float alpha)
- {
- float value = float.IsNaN(alpha) ? 0 : Math.Clamp(alpha, 0, 1);
- return (byte)MathF.Round(value * byte.MaxValue, MidpointRounding.AwayFromZero);
- }
+ private static float NormalizeAlpha(float alpha) =>
+ float.IsNaN(alpha) ? 0 : Math.Clamp(alpha, 0, 1);
+
+ private static byte ToAlphaByte(float alpha) =>
+ (byte)MathF.Round(alpha * byte.MaxValue, MidpointRounding.AwayFromZero);
}
diff --git a/tests/Snaply.App.Tests/ImageExportServiceTests.cs b/tests/Snaply.App.Tests/ImageExportServiceTests.cs
index a426e5b..65f36b2 100644
--- a/tests/Snaply.App.Tests/ImageExportServiceTests.cs
+++ b/tests/Snaply.App.Tests/ImageExportServiceTests.cs
@@ -2,6 +2,9 @@ namespace Snaply.App.Tests;
public sealed class ImageExportServiceTests : IDisposable
{
+ private static readonly byte[] OnePixelPng = Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
+
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
"Snaply.Tests",
@@ -11,14 +14,18 @@ public sealed class ImageExportServiceTests : IDisposable
public async Task Automatic_save_creates_new_file()
{
var service = new ImageExportService(_directory);
- var image = new RenderedImage([1, 2, 3], 1, 1);
+ var image = new RenderedImage(OnePixelPng, 1, 1);
var now = new DateTimeOffset(2026, 7, 20, 9, 30, 0, TimeZoneInfo.Local.GetUtcOffset(
new DateTime(2026, 7, 20, 9, 30, 0)));
CancellationToken cancellationToken = TestContext.Current.CancellationToken;
string path = await service.SaveAutomaticallyAsync(image, now, cancellationToken);
- Assert.Equal(image.Png, await File.ReadAllBytesAsync(path, cancellationToken));
+ byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken);
+ Assert.Equal(image.Png.ToArray(), bytes);
+ Assert.Equal([137, 80, 78, 71, 13, 10, 26, 10], bytes[..8]);
+ Assert.Equal(1, ReadBigEndianInt32(bytes, 16));
+ Assert.Equal(1, ReadBigEndianInt32(bytes, 20));
Assert.Equal(ImageExportService.CreateSuggestedFileName(now), Path.GetFileName(path));
Assert.Empty(TemporaryFiles());
}
@@ -27,7 +34,7 @@ public async Task Automatic_save_creates_new_file()
public async Task Concurrent_automatic_saves_use_unique_collision_suffixes()
{
var service = new ImageExportService(_directory);
- var image = new RenderedImage([4, 5, 6], 1, 1);
+ var image = new RenderedImage(OnePixelPng, 1, 1);
DateTimeOffset now = DateTimeOffset.Now;
CancellationToken cancellationToken = TestContext.Current.CancellationToken;
@@ -38,7 +45,7 @@ public async Task Concurrent_automatic_saves_use_unique_collision_suffixes()
Assert.Equal(paths.Length, paths.Distinct(StringComparer.OrdinalIgnoreCase).Count());
byte[][] contents = await Task.WhenAll(
paths.Select(path => File.ReadAllBytesAsync(path, cancellationToken)));
- Assert.All(contents, bytes => Assert.Equal(image.Png, bytes));
+ Assert.All(contents, bytes => Assert.Equal(image.Png.ToArray(), bytes));
Assert.Empty(TemporaryFiles());
}
@@ -51,7 +58,7 @@ public async Task Cancelled_automatic_save_removes_temporary_file()
await Assert.ThrowsAnyAsync(() =>
service.SaveAutomaticallyAsync(
- new RenderedImage([1], 1, 1),
+ new RenderedImage(OnePixelPng, 1, 1),
DateTimeOffset.Now,
cancellation.Token));
@@ -71,4 +78,10 @@ private string[] TemporaryFiles() =>
Directory.Exists(_directory)
? Directory.GetFiles(_directory, "*.tmp", SearchOption.AllDirectories)
: [];
+
+ private static int ReadBigEndianInt32(byte[] bytes, int offset) =>
+ (bytes[offset] << 24)
+ | (bytes[offset + 1] << 16)
+ | (bytes[offset + 2] << 8)
+ | bytes[offset + 3];
}
diff --git a/tests/Snaply.App.Tests/MainViewModelTests.cs b/tests/Snaply.App.Tests/MainViewModelTests.cs
new file mode 100644
index 0000000..d491a23
--- /dev/null
+++ b/tests/Snaply.App.Tests/MainViewModelTests.cs
@@ -0,0 +1,279 @@
+namespace Snaply.App.Tests;
+
+public sealed class MainViewModelTests
+{
+ private static readonly RenderedImage Image = new(
+ Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="),
+ 1,
+ 1);
+
+ [Fact]
+ public async Task Capture_exposes_image_and_reports_complete_delivery()
+ {
+ var capture = new StubCapture(Image);
+ var output = new StubOutput(
+ new DeliveryOutcome(
+ DeliveryResult.Succeeded,
+ DeliveryResult.Succeeded));
+ using var viewModel = CreateViewModel(capture, output);
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+
+ Assert.Same(Image, viewModel.PreviewImage);
+ Assert.True(viewModel.HasStatus);
+ Assert.Equal(NoticeKind.Success, viewModel.StatusKind);
+ Assert.Equal("StatusSavedAndCopied", viewModel.StatusMessage);
+ Assert.False(viewModel.CanRetry);
+ Assert.Equal(DeliveryRequest.All, Assert.Single(output.Requests));
+ }
+
+ [Fact]
+ public async Task Cancelled_selection_leaves_the_previous_state_untouched()
+ {
+ var capture = new StubCapture(
+ static _ => Task.FromResult(null));
+ var output = new StubOutput();
+ using var viewModel = CreateViewModel(capture, output);
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+
+ Assert.Null(viewModel.PreviewImage);
+ Assert.False(viewModel.HasStatus);
+ Assert.Empty(output.Requests);
+ }
+
+ [Fact]
+ public async Task Partial_failure_retries_only_the_failed_target()
+ {
+ var capture = new StubCapture(Image);
+ var output = new StubOutput(
+ new DeliveryOutcome(
+ DeliveryResult.Succeeded,
+ DeliveryResult.Failed),
+ new DeliveryOutcome(
+ DeliveryResult.NotAttempted,
+ DeliveryResult.Succeeded));
+ using var viewModel = CreateViewModel(capture, output);
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+ Assert.Equal(NoticeKind.Warning, viewModel.StatusKind);
+ Assert.Equal("StatusSavedCopyFailed", viewModel.StatusMessage);
+ Assert.True(viewModel.CanRetry);
+
+ await viewModel.RetryDeliveryCommand.ExecuteAsync(null);
+
+ Assert.Equal(NoticeKind.Success, viewModel.StatusKind);
+ Assert.Equal("StatusSavedAndCopied", viewModel.StatusMessage);
+ Assert.False(viewModel.CanRetry);
+ Assert.Equal(
+ new DeliveryRequest(Save: false, Clipboard: true),
+ output.Requests[1]);
+ }
+
+ [Fact]
+ public async Task Complete_delivery_failure_is_visible_and_retryable()
+ {
+ var capture = new StubCapture(Image);
+ var output = new StubOutput(
+ new DeliveryOutcome(
+ DeliveryResult.Failed,
+ DeliveryResult.Failed));
+ using var viewModel = CreateViewModel(capture, output);
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+
+ Assert.Equal(NoticeKind.Error, viewModel.StatusKind);
+ Assert.Equal("StatusDeliveryFailed", viewModel.StatusMessage);
+ Assert.True(viewModel.CanRetry);
+ }
+
+ [Fact]
+ public async Task Save_failure_retries_only_save()
+ {
+ var output = new StubOutput(
+ new DeliveryOutcome(
+ DeliveryResult.Failed,
+ DeliveryResult.Succeeded),
+ new DeliveryOutcome(
+ DeliveryResult.Succeeded,
+ DeliveryResult.NotAttempted));
+ using var viewModel = CreateViewModel(new StubCapture(Image), output);
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+ Assert.Equal("StatusCopiedSaveFailed", viewModel.StatusMessage);
+
+ await viewModel.RetryDeliveryCommand.ExecuteAsync(null);
+
+ Assert.Equal("StatusSavedAndCopied", viewModel.StatusMessage);
+ Assert.Equal(
+ new DeliveryRequest(Save: true, Clipboard: false),
+ output.Requests[1]);
+ }
+
+ [Fact]
+ public async Task Requested_but_unattempted_output_is_retryable()
+ {
+ var output = new StubOutput(
+ new DeliveryOutcome(
+ DeliveryResult.Succeeded,
+ DeliveryResult.NotAttempted),
+ new DeliveryOutcome(
+ DeliveryResult.NotAttempted,
+ DeliveryResult.Succeeded));
+ using var viewModel = CreateViewModel(new StubCapture(Image), output);
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+ Assert.Equal("StatusSavedCopyFailed", viewModel.StatusMessage);
+ Assert.True(viewModel.CanRetry);
+
+ await viewModel.RetryDeliveryCommand.ExecuteAsync(null);
+
+ Assert.Equal("StatusSavedAndCopied", viewModel.StatusMessage);
+ Assert.Equal(
+ new DeliveryRequest(Save: false, Clipboard: true),
+ output.Requests[1]);
+ }
+
+ [Fact]
+ public async Task Capture_cancellation_is_not_reported_as_an_error()
+ {
+ var capture = new StubCapture(
+ static cancellationToken => Task.FromCanceled(cancellationToken));
+ var output = new StubOutput();
+ using var viewModel = CreateViewModel(capture, output);
+ using var cancellation = new CancellationTokenSource();
+ await cancellation.CancelAsync();
+ capture.ForcedToken = cancellation.Token;
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+
+ Assert.False(viewModel.HasStatus);
+ Assert.Empty(output.Requests);
+ }
+
+ [Fact]
+ public async Task Capture_failure_is_visible()
+ {
+ var capture = new StubCapture(
+ static _ => Task.FromException(new InvalidOperationException("boom")));
+ using var viewModel = CreateViewModel(capture, new StubOutput());
+
+ await viewModel.CaptureCommand.ExecuteAsync(null);
+
+ Assert.Equal(NoticeKind.Error, viewModel.StatusKind);
+ Assert.Equal("ErrorCapture", viewModel.StatusMessage);
+ }
+
+ [Fact]
+ public async Task Disposal_cancels_the_active_operation_and_disables_commands()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var capture = new StubCapture(async cancellationToken =>
+ {
+ started.TrySetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
+ return Image;
+ });
+ var viewModel = CreateViewModel(capture, new StubOutput());
+
+ Task operation = viewModel.CaptureCommand.ExecuteAsync(null);
+ await started.Task.WaitAsync(TestContext.Current.CancellationToken);
+ viewModel.Dispose();
+ Assert.Equal(0, capture.DisposeCount);
+ await operation;
+
+ Assert.Equal(1, capture.DisposeCount);
+ Assert.False(viewModel.CaptureCommand.CanExecute(null));
+ Assert.False(viewModel.OpenFolderCommand.CanExecute(null));
+ Assert.False(viewModel.HasStatus);
+ }
+
+ [Fact]
+ public async Task Disposal_ignores_a_late_result_from_a_noncompliant_capture()
+ {
+ var result = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var capture = new StubCapture(_ => result.Task);
+ var output = new StubOutput();
+ var viewModel = CreateViewModel(capture, output);
+
+ Task operation = viewModel.CaptureCommand.ExecuteAsync(null);
+ viewModel.Dispose();
+ result.SetResult(Image);
+ await operation;
+
+ Assert.Null(viewModel.PreviewImage);
+ Assert.Empty(output.Requests);
+ Assert.Equal(1, capture.DisposeCount);
+ }
+
+ [Fact]
+ public void Open_folder_failure_is_visible()
+ {
+ var output = new StubOutput
+ {
+ OpenAction = static () => throw new IOException("unavailable"),
+ };
+ using var viewModel = CreateViewModel(new StubCapture(Image), output);
+
+ viewModel.OpenFolderCommand.Execute(null);
+
+ Assert.Equal(NoticeKind.Error, viewModel.StatusKind);
+ Assert.Equal("ErrorOpenFolder", viewModel.StatusMessage);
+ Assert.False(viewModel.CanRetry);
+ }
+
+ private static MainViewModel CreateViewModel(
+ ICapturePipeline capture,
+ IImageOutput output) =>
+ new(capture, output, static key => key);
+
+ private sealed class StubCapture : ICapturePipeline
+ {
+ private readonly Func> _capture;
+
+ internal StubCapture(RenderedImage image)
+ : this(_ => Task.FromResult(image))
+ {
+ }
+
+ internal StubCapture(Func> capture)
+ {
+ _capture = capture;
+ }
+
+ internal CancellationToken? ForcedToken { get; set; }
+
+ internal int DisposeCount { get; private set; }
+
+ public void Dispose() => DisposeCount++;
+
+ public Task CaptureAsync(
+ CaptureMode mode,
+ CancellationToken cancellationToken) =>
+ _capture(ForcedToken ?? cancellationToken);
+ }
+
+ private sealed class StubOutput(params DeliveryOutcome[] outcomes) : IImageOutput
+ {
+ private readonly Queue _outcomes = new(outcomes);
+
+ internal List Requests { get; } = [];
+
+ internal Action OpenAction { get; init; } = static () => { };
+
+ public Task DeliverAsync(
+ RenderedImage image,
+ DeliveryRequest request,
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Requests.Add(request);
+ return Task.FromResult(_outcomes.Dequeue());
+ }
+
+ public void OpenCaptureDirectory() => OpenAction();
+ }
+}
diff --git a/tests/Snaply.App.Tests/RenderedImageTests.cs b/tests/Snaply.App.Tests/RenderedImageTests.cs
new file mode 100644
index 0000000..a8655e5
--- /dev/null
+++ b/tests/Snaply.App.Tests/RenderedImageTests.cs
@@ -0,0 +1,38 @@
+namespace Snaply.App.Tests;
+
+public sealed class RenderedImageTests
+{
+ private static readonly byte[] OnePixelPng = Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=");
+
+ [Fact]
+ public void Constructor_copies_the_encoded_image()
+ {
+ byte[] source = OnePixelPng.ToArray();
+
+ var image = new RenderedImage(source, 1, 1);
+ source[0] = 0;
+
+ Assert.Equal(137, image.Png.Span[0]);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("AA==")]
+ [InlineData("bm90IGEgcG5nIGhlYWRlciBhdCBhbGw=")]
+ public void Constructor_rejects_invalid_png_header(string base64)
+ {
+ byte[] bytes = string.IsNullOrEmpty(base64)
+ ? []
+ : Convert.FromBase64String(base64);
+
+ Assert.Throws(() => new RenderedImage(bytes, 1, 1));
+ }
+
+ [Fact]
+ public void Constructor_rejects_mismatched_dimensions()
+ {
+ Assert.Throws(() => new RenderedImage(OnePixelPng, 2, 1));
+ Assert.Throws(() => new RenderedImage(OnePixelPng, 1, 2));
+ }
+}
diff --git a/tests/Snaply.App.Tests/Snaply.App.Tests.csproj b/tests/Snaply.App.Tests/Snaply.App.Tests.csproj
index 73e9a30..89d3ccf 100644
--- a/tests/Snaply.App.Tests/Snaply.App.Tests.csproj
+++ b/tests/Snaply.App.Tests/Snaply.App.Tests.csproj
@@ -4,9 +4,12 @@
enable
enable
false
- x64
- x64
- win-x64
+ x64;ARM64
+ x64
+ x64
+ ARM64
+ win-x64
+ win-arm64
true
true
false
@@ -14,7 +17,7 @@
-
+
diff --git a/tests/Snaply.App.Tests/packages.lock.json b/tests/Snaply.App.Tests/packages.lock.json
index fc20bb6..7f5d8c7 100644
--- a/tests/Snaply.App.Tests/packages.lock.json
+++ b/tests/Snaply.App.Tests/packages.lock.json
@@ -4,12 +4,12 @@
"net10.0-windows10.0.26100": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
- "requested": "[18.7.0, )",
- "resolved": "18.7.0",
- "contentHash": "49xH9j4UzCh2hMohJp53g3wUTvyycECw7CtVht4gfCz5ykudB1uBcF6D0TtgJPjCtP76UPW53bQElKdCeX+dUg==",
+ "requested": "[18.8.1, )",
+ "resolved": "18.8.1",
+ "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
"dependencies": {
- "Microsoft.CodeCoverage": "18.7.0",
- "Microsoft.TestPlatform.TestHost": "18.7.0"
+ "Microsoft.CodeCoverage": "18.8.1",
+ "Microsoft.TestPlatform.TestHost": "18.8.1"
}
},
"xunit.runner.visualstudio": {
@@ -44,8 +44,8 @@
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
- "resolved": "18.7.0",
- "contentHash": "+wFfx9s7D9wegM0RziXMj2kvYDT4qcqXXtyjiQwSZOGQ2wwcOAJQcD6eQXk02jt0MvRNawtp8TJxTrV+wD8X1g=="
+ "resolved": "18.8.1",
+ "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
},
"Microsoft.Graphics.Win2D": {
"type": "Transitive",
@@ -87,16 +87,15 @@
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
- "resolved": "18.7.0",
- "contentHash": "6rmgU4q3/WOpOPcncI0YW0Q/QpcQtwR2TTEXDR5+4TfSimPBAk6Z/BgKLeGgp1SOun0ROVUCCafXhRLwsHaPpA=="
+ "resolved": "18.8.1",
+ "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
- "resolved": "18.7.0",
- "contentHash": "kYwfmebCs8992zaxEDkvG7S+YEouTeKfYVKUFEkwh1W2dIoOaevBt80XSKVXCUFEhusjOIm1sFfHBnoJgygrRA==",
+ "resolved": "18.8.1",
+ "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
"dependencies": {
- "Microsoft.TestPlatform.ObjectModel": "18.7.0",
- "Newtonsoft.Json": "13.0.3"
+ "Microsoft.TestPlatform.ObjectModel": "18.8.1"
}
},
"Microsoft.Web.WebView2": {
@@ -111,19 +110,14 @@
},
"Microsoft.Windows.SDK.BuildTools": {
"type": "Transitive",
- "resolved": "10.0.26100.4654",
- "contentHash": "2mgcOlj/t2RfSyyw+pVESfO+Tk1RkfQzto9Vrq42M1lUQIfQEwbi8QLha9GXWIOj+TFzeHIEJckIoF25mgiM8A=="
+ "resolved": "10.0.26100.8249",
+ "contentHash": "WJ+X+fVbBcZFc5q1MufkqBNjeMqWUblbG6PmijNG0tMVt+VXdE5cdaD30dbZ90RwUTidbjxv/tmgovl5czLlrg=="
},
"Microsoft.Windows.SDK.BuildTools.MSIX": {
"type": "Transitive",
"resolved": "1.7.251221100",
"contentHash": "f4aIZJ0NUth2403oxrpR+9rxVzZVI6dabqB21u8ncnk8eJAKCs9m77E4iYAnvP1YwrRe4axz3f8+yUcttNSfEA=="
},
- "Microsoft.Windows.SDK.BuildTools.WinApp": {
- "type": "Transitive",
- "resolved": "0.4.0",
- "contentHash": "5xq7u0YRQvOFT4xOfqBVgwnpUyEXMWAtCa88uY/jhK4HITivDH2iDVQtyjpqWI2DUkFyfcXPYgAxTIjmNui5eg=="
- },
"Microsoft.WindowsAppSDK.Base": {
"type": "Transitive",
"resolved": "2.0.4",
@@ -135,41 +129,36 @@
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
- "resolved": "2.1.0",
- "contentHash": "Urz1WrsXHYDHYrCPIWZlSTwgXwghEXlS6lY62Hk3vZE0GZ3hyYrMM7a+p6RrXC55GEgrRhOTRqXqcH2ZL/UWRA==",
+ "resolved": "2.3.5",
+ "contentHash": "Ke/HAoFLq1kgMPUn6UrNNBy5kcuHCJK/SvHep0m4FY1YoDGz9XUDnucDmCMSUxuGnxtI65Uq5UfmLc3eS31Ovg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.1"
}
},
"Microsoft.WindowsAppSDK.InteractiveExperiences": {
"type": "Transitive",
- "resolved": "2.0.15",
- "contentHash": "aQ9uzAIhTaN9zkclrtVg6kylpF+hESU+tC0bHarOCWdBz8ShZ7gt+gKwEmjKiqtPv6sRrRglyLLNVSPfb8KmMQ==",
+ "resolved": "2.1.3",
+ "contentHash": "2GwSpAWidRKZiGX0yeJ9SxjdgPYMpZXRaHbiss3rC4FglFYSpF/wmhW5axX2zJ/NP2e4LOoOiPtw1W4YRyQiFg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.WinUI": {
"type": "Transitive",
- "resolved": "2.2.1",
- "contentHash": "w8KuqJB7IphDRXAGNVuRq+oGeoGycc4ZAraA+OIpsGm/boEKHLkwXmzNDaNrVlnsDpjxQFWE2XVp0hXjlWJqxw==",
+ "resolved": "2.3.2",
+ "contentHash": "vS+HfuG12aZYVnPXyc7fFzZXm4iKmZyyVSZ+OrDBDlX/QktWu645vkWN/0tebbcrPfF15whsTesk1Gps4JELLw==",
"dependencies": {
"Microsoft.Web.WebView2": "1.0.3719.77",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.Foundation": "2.1.0",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.Foundation": "2.3.5",
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.3"
}
},
- "Newtonsoft.Json": {
- "type": "Transitive",
- "resolved": "13.0.3",
- "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
- },
"Serilog": {
"type": "Transitive",
- "resolved": "4.3.0",
- "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ=="
+ "resolved": "4.4.0",
+ "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw=="
},
"Serilog.Sinks.File": {
"type": "Transitive",
@@ -251,10 +240,9 @@
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"Microsoft.Graphics.Win2D": "[1.4.0, )",
- "Microsoft.Windows.SDK.BuildTools": "[10.0.26100.4654, )",
- "Microsoft.Windows.SDK.BuildTools.WinApp": "[0.4.0, )",
- "Microsoft.WindowsAppSDK.WinUI": "[2.2.1, )",
- "Serilog": "[4.3.0, )",
+ "Microsoft.Windows.SDK.BuildTools": "[10.0.26100.8249, )",
+ "Microsoft.WindowsAppSDK.WinUI": "[2.3.2, )",
+ "Serilog": "[4.4.0, )",
"Serilog.Sinks.File": "[7.0.0, )",
"Snaply.Imaging": "[0.1.1, )"
}
@@ -284,11 +272,11 @@
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
- "resolved": "2.1.0",
- "contentHash": "Urz1WrsXHYDHYrCPIWZlSTwgXwghEXlS6lY62Hk3vZE0GZ3hyYrMM7a+p6RrXC55GEgrRhOTRqXqcH2ZL/UWRA==",
+ "resolved": "2.3.5",
+ "contentHash": "Ke/HAoFLq1kgMPUn6UrNNBy5kcuHCJK/SvHep0m4FY1YoDGz9XUDnucDmCMSUxuGnxtI65Uq5UfmLc3eS31Ovg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
- "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.0.15"
+ "Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.1"
}
}
}
diff --git a/tests/Snaply.Tests/BeautifyTests.cs b/tests/Snaply.Tests/BeautifyTests.cs
index 8f567d1..15422ee 100644
--- a/tests/Snaply.Tests/BeautifyTests.cs
+++ b/tests/Snaply.Tests/BeautifyTests.cs
@@ -7,6 +7,7 @@ public sealed class BeautifyTests
[Theory]
[InlineData(1, 0, 0, 0, 0, 255)]
[InlineData(0.5f, 0, 0, 0, 0, 188)]
+ [InlineData(0.001f, 0, 0, 0, 0, 3)]
[InlineData(0, 1, 0, 0, 255, 0)]
[InlineData(0, 0, 1, 255, 0, 0)]
public void Sdr_scRgb_conversion_preserves_values(
@@ -65,6 +66,25 @@ public void Hdr_scRgb_conversion_tone_maps_the_complete_frame()
Assert.Equal([232, 232, 232, 255, 206, 245, 252, 255], bgra);
}
+ [Fact]
+ public void ScRgb_conversion_preserves_premultiplied_alpha()
+ {
+ Half[] rgba =
+ [
+ (Half)0.5f, (Half)0, (Half)0, (Half)0.5f,
+ (Half)1, (Half)1, (Half)1, (Half)0,
+ ];
+ var bgra = new byte[8];
+
+ bool toneMapped = ScRgbToneMapper.ConvertToBgra8(
+ rgba,
+ bgra,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(toneMapped);
+ Assert.Equal([0, 0, 128, 128, 0, 0, 0, 0], bgra);
+ }
+
[Fact]
public void ScRgb_conversion_handles_non_finite_values()
{
@@ -86,7 +106,7 @@ public void ScRgb_conversion_handles_non_finite_values()
bgra,
TestContext.Current.CancellationToken);
- Assert.Equal([0, 255, 0, 255, 0, 0, 1, 0], bgra);
+ Assert.Equal([0, 255, 0, 255, 0, 0, 0, 0], bgra);
}
[Fact]
@@ -111,27 +131,27 @@ public void ScRgb_conversion_honours_cancellation()
{
var rgba = new Half[4];
var bgra = new byte[4];
- using var cancellation = new CancellationTokenSource();
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
+ TestContext.Current.CancellationToken);
cancellation.Cancel();
-#pragma warning disable xUnit1051
Assert.Throws(
() => ScRgbToneMapper.ConvertToBgra8(rgba, bgra, cancellation.Token));
-#pragma warning restore xUnit1051
}
[Theory]
- [InlineData(1920, 1080, 86, 19, 38)]
- [InlineData(3840, 2160, 160, 32, 64)]
- [InlineData(512, 4096, 41, 9, 18)]
- [InlineData(1, 1, 32, 8, 16)]
- [InlineData(1000, 2000, 80, 18, 36)]
+ [InlineData(1920, 1080, 86, 19, 11, 8)]
+ [InlineData(3840, 2160, 160, 32, 16, 12)]
+ [InlineData(512, 4096, 41, 9, 5, 4)]
+ [InlineData(1, 1, 32, 8, 5, 3)]
+ [InlineData(1000, 2000, 80, 18, 11, 7)]
public void Layout_has_exact_proportional_geometry(
int width,
int height,
int padding,
int radius,
- int shadowBlur)
+ int shadowBlur,
+ int shadowOffset)
{
BeautifyLayoutResult layout = BeautifyLayout.Compute(new PixelSize(width, height));
@@ -139,7 +159,8 @@ public void Layout_has_exact_proportional_geometry(
Assert.Equal(new PixelRect(padding, padding, width, height), layout.Image);
Assert.Equal(radius, layout.CornerRadius);
Assert.Equal(shadowBlur, layout.ShadowBlur);
- Assert.Equal(Math.Max(8, radius), layout.ShadowOffset);
+ Assert.Equal(shadowOffset, layout.ShadowOffset);
+ Assert.True((layout.ShadowBlur * 3) + layout.ShadowOffset <= padding);
}
[Fact]
@@ -192,58 +213,31 @@ public void Palette_is_opaque(byte red, byte green, byte blue)
}
[Fact]
- public void Palette_matches_golden_values()
+ public void Palette_preserves_variation_without_crushing_multiple_channels()
{
- (Rgba Color, ulong Hash, uint Salt, ColorPalette Expected)[] cases =
+ Rgba[] colors =
[
- (new Rgba(0, 0, 0), 0, 0,
- new ColorPalette(new Rgba(223, 137, 164), new Rgba(182, 109, 66), 90)),
- (new Rgba(255, 255, 255), ulong.MaxValue, uint.MaxValue,
- new ColorPalette(new Rgba(125, 46, 64), new Rgba(118, 60, 0), 92.35982894897461)),
- (new Rgba(123, 45, 210), 0x123456789ABCDEF0, 42,
- new ColorPalette(new Rgba(183, 65, 166), new Rgba(153, 0, 55), 152.32672691345215)),
- (new Rgba(1, 2, 3), 987654321, 123456789,
- new ColorPalette(new Rgba(200, 148, 215), new Rgba(165, 83, 75), 173.72474670410156)),
- (new Rgba(128, 128, 128), 123, 456,
- new ColorPalette(new Rgba(136, 87, 150), new Rgba(126, 47, 55), 169.57929611206055)),
- (new Rgba(255, 0, 0), 123, 456,
- new ColorPalette(new Rgba(136, 102, 0), new Rgba(0, 97, 0), 169.57929611206055)),
- (new Rgba(0, 255, 0), 123, 456,
- new ColorPalette(new Rgba(0, 105, 143), new Rgba(0, 73, 174), 169.57929611206055)),
- (new Rgba(0, 0, 255), 123, 456,
- new ColorPalette(new Rgba(185, 68, 170), new Rgba(163, 0, 56), 169.57929611206055)),
+ new(180, 100, 100),
+ new(170, 100, 80),
+ new(100, 150, 170),
+ new(120, 170, 100),
+ new(255, 0, 0),
+ new(0, 255, 0),
+ new(0, 0, 255),
];
- foreach ((Rgba color, ulong hash, uint salt, ColorPalette expected) in cases)
+ ColorPalette[] palettes = colors
+ .Select(color => ColorPalette.Create(color, 123, 456))
+ .ToArray();
+ Assert.Equal(palettes.Length, palettes.Distinct().Count());
+ foreach (ColorPalette palette in palettes)
{
- ColorPalette actual = ColorPalette.Create(color, hash, salt);
- Assert.Equal(expected.Start, actual.Start);
- Assert.Equal(expected.End, actual.End);
- Assert.Equal(expected.AngleDegrees, actual.AngleDegrees, precision: 10);
+ Assert.NotEqual(palette.Start, palette.End);
+ Assert.True(CountBoundaryChannels(palette.Start) <= 1);
+ Assert.True(CountBoundaryChannels(palette.End) <= 1);
}
}
- [Fact]
- public void Palette_preserves_mid_chroma_variation()
- {
- (Rgba Color, ColorPalette Expected)[] cases =
- [
- (new Rgba(180, 100, 100),
- new ColorPalette(new Rgba(138, 104, 0), new Rgba(49, 90, 0), 169.57929611206055)),
- (new Rgba(170, 100, 80),
- new ColorPalette(new Rgba(121, 114, 1), new Rgba(3, 93, 39), 169.57929611206055)),
- (new Rgba(100, 150, 170),
- new ColorPalette(new Rgba(109, 91, 161), new Rgba(116, 49, 92), 169.57929611206055)),
- (new Rgba(120, 170, 100),
- new ColorPalette(new Rgba(0, 120, 139), new Rgba(0, 79, 145), 169.57929611206055)),
- ];
-
- foreach ((Rgba color, ColorPalette expected) in cases)
- {
- ColorPalette actual = ColorPalette.Create(color, 123, 456);
- Assert.Equal(expected.Start, actual.Start);
- Assert.Equal(expected.End, actual.End);
- Assert.Equal(expected.AngleDegrees, actual.AngleDegrees, precision: 10);
- }
- }
+ private static int CountBoundaryChannels(Rgba color) =>
+ new[] { color.R, color.G, color.B }.Count(channel => channel is 0 or 255);
}
diff --git a/tests/Snaply.Tests/GeometryTests.cs b/tests/Snaply.Tests/GeometryTests.cs
index 53cd0f8..46f79b0 100644
--- a/tests/Snaply.Tests/GeometryTests.cs
+++ b/tests/Snaply.Tests/GeometryTests.cs
@@ -199,49 +199,6 @@ public void Bounds_rejects_null()
Assert.Throws(() => PixelRect.Bounds(null!));
}
- [Fact]
- public void Dip_conversion_rounds_edges_so_tiles_remain_seamless()
- {
- PixelRect left = new DipRect(0, 0, 100.5, 40).ToPixels(1.25);
- PixelRect right = new DipRect(100.5, 0, 99.5, 40).ToPixels(1.25);
-
- Assert.Equal(left.Right, right.X);
- Assert.Equal(250, right.Right);
- }
-
- [Fact]
- public void Dip_conversion_scales_negative_coordinates_and_both_axes()
- {
- PixelRect pixels = new DipRect(-10.5, 20.5, 3.25, 4.75).ToPixels(2);
-
- Assert.Equal(new PixelRect(-21, 41, 6, 10), pixels);
- }
-
- [Fact]
- public void Dip_conversion_rejects_coordinate_overflow()
- {
- Assert.Throws(() =>
- new DipRect(int.MaxValue, int.MinValue, 1, 1).ToPixels(2));
- }
-
- [Fact]
- public void Dip_conversion_rejects_dimension_overflow()
- {
- Assert.Throws(() =>
- new DipRect(-1_000_000_000, 0, 2_000_000_000, 1).ToPixels(2));
- Assert.Throws(() =>
- new DipRect(0, -1_000_000_000, 1, 2_000_000_000).ToPixels(2));
- }
-
- [Theory]
- [InlineData(0)]
- [InlineData(-1)]
- [InlineData(double.NaN)]
- public void Dip_conversion_rejects_invalid_scale(double scale)
- {
- Assert.Throws(() => new DipRect(0, 0, 1, 1).ToPixels(scale));
- }
-
[Fact]
public void Overflow_is_never_silently_wrapped()
{
diff --git a/tests/Snaply.Tests/ResourceParityTests.cs b/tests/Snaply.Tests/ResourceParityTests.cs
index af0e5e5..7ef850b 100644
--- a/tests/Snaply.Tests/ResourceParityTests.cs
+++ b/tests/Snaply.Tests/ResourceParityTests.cs
@@ -28,22 +28,11 @@ public void Every_supported_language_has_the_same_resource_keys()
}
[Fact]
- public void Keys_referenced_from_code_exist_in_every_locale()
+ public void Keys_referenced_from_code_or_xaml_exist_in_every_locale()
{
- // Keys passed as string literals to ResourceText.Get(...) have no compile-time link to the
- // .resw files, so a rename or typo would silently surface an empty label or accessible name
- // at runtime. Pin the set here so a mismatch fails the build instead.
- string[] requiredKeys =
- [
- "CaptureRegion",
- "CaptureWindow",
- "CaptureDesktop",
- "OpenFolderLabel",
- "ErrorCapture",
- "ErrorOpenFolder",
- "RegionHint",
- "RegionCancel",
- ];
+ string[] codeKeys = FindCodeResourceKeys();
+ string[] uids = FindXamlResourceRoots();
+ string[] manifestKeys = FindManifestResourceKeys();
string root = Path.Combine(AppContext.BaseDirectory, "Strings");
foreach (string file in Directory.GetFiles(root, "Resources.resw", SearchOption.AllDirectories))
@@ -53,7 +42,71 @@ public void Keys_referenced_from_code_exist_in_every_locale()
.Elements("data")
.Select(element => (string)element.Attribute("name")!)
.ToHashSet(StringComparer.Ordinal);
- Assert.All(requiredKeys, key => Assert.Contains(key, keys));
+ Assert.All(codeKeys, key => Assert.Contains(key, keys));
+ Assert.All(
+ uids,
+ uid => Assert.Contains(
+ keys,
+ key => key.Equals(uid, StringComparison.Ordinal)
+ || key.StartsWith($"{uid}.", StringComparison.Ordinal)));
+ Assert.All(manifestKeys, key => Assert.Contains(key, keys));
+ }
+ }
+
+ [Fact]
+ public void Every_resource_is_referenced_by_product_code_or_metadata()
+ {
+ HashSet referenced = FindCodeResourceKeys()
+ .Concat(FindXamlResourceRoots())
+ .Concat(FindManifestResourceKeys())
+ .ToHashSet(StringComparer.Ordinal);
+ string resourcePath = Path.Combine(
+ AppContext.BaseDirectory,
+ "Strings",
+ "en-US",
+ "Resources.resw");
+ string[] orphaned = XDocument.Load(resourcePath)
+ .Root!
+ .Elements("data")
+ .Select(element => ((string)element.Attribute("name")!).Split('.')[0])
+ .Where(root => !referenced.Contains(root))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.Empty(orphaned);
+ }
+
+ [Fact]
+ public void Interactive_xaml_controls_have_stable_identity_and_accessible_names()
+ {
+ XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
+ string uiRoot = Path.Combine(AppContext.BaseDirectory, "Ui");
+ string[] interactiveTypes =
+ ["Button", "InfoBar", "MenuFlyoutItem", "ProgressRing", "ScrollViewer", "SplitButton"];
+ foreach (string path in Directory.GetFiles(uiRoot, "*.xaml", SearchOption.AllDirectories))
+ {
+ XElement[] controls = XDocument.Load(path)
+ .Descendants()
+ .Where(element =>
+ interactiveTypes.Contains(element.Name.LocalName, StringComparer.Ordinal)
+ || string.Equals(
+ (string?)element.Attribute("IsTabStop"),
+ "True",
+ StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ foreach (XElement control in controls)
+ {
+ string description = $"{Path.GetFileName(path)}:{control.Name.LocalName}";
+ Assert.Contains(
+ control.Attributes(),
+ attribute => attribute.Name.LocalName.EndsWith(
+ "AutomationId",
+ StringComparison.Ordinal)
+ && !string.IsNullOrWhiteSpace(attribute.Value));
+ Assert.False(
+ string.IsNullOrWhiteSpace((string?)control.Attribute(xaml + "Uid")),
+ $"{description} has no x:Uid accessible-name source.");
+ }
}
}
@@ -74,37 +127,78 @@ public void Resources_are_unique_and_non_empty()
}
[Fact]
- public void Text_hosts_can_reflow_expanded_pseudo_localized_content()
+ public void Localized_text_hosts_are_not_constrained_to_fixed_widths()
{
- string root = Path.Combine(AppContext.BaseDirectory, "Strings", "en-US", "Resources.resw");
- string[] values = XDocument.Load(root)
- .Root!
- .Elements("data")
- .Select(element => element.Element("value")!.Value)
- .ToArray();
- string[] pseudoLocalized = values
- .Select(value => $"⟦{value}{new string('~', Math.Max(2, value.Length / 3))}⟧")
- .ToArray();
- Assert.All(
- pseudoLocalized,
- value => Assert.True(value.Length >= 4 && value[0] == '⟦' && value[^1] == '⟧'));
-
- XDocument page = XDocument.Load(Path.Combine(AppContext.BaseDirectory, "Ui", "MainPage.xaml"));
+ string uiRoot = Path.Combine(AppContext.BaseDirectory, "Ui");
string[] textHosts = ["TextBlock", "Button", "SplitButton", "AppBarButton", "MenuFlyoutItem"];
- XElement[] fixedWidthHosts = page
- .Descendants()
- .Where(element =>
- textHosts.Contains(element.Name.LocalName, StringComparer.Ordinal)
+ XElement[] fixedWidthHosts = Directory
+ .GetFiles(uiRoot, "*.xaml", SearchOption.AllDirectories)
+ .SelectMany(path => XDocument.Load(path).Descendants())
+ .Where(element => textHosts.Contains(element.Name.LocalName, StringComparer.Ordinal)
&& element.Attribute("Width") is not null)
.ToArray();
Assert.Empty(fixedWidthHosts);
- // The floating command pill must let its label grow (no fixed width), so an expanded
- // localized capture label reflows the pill instead of being clipped.
+ string pagePath = Directory.GetFiles(
+ uiRoot,
+ "MainPage.xaml",
+ SearchOption.AllDirectories).Single();
+ XDocument page = XDocument.Load(pagePath);
XElement captureButton = Assert.Single(
page.Descendants(),
element => element.Name.LocalName == "SplitButton"
&& (string?)element.Attribute("{http://schemas.microsoft.com/winfx/2006/xaml}Name") == "CaptureButton");
Assert.Null(captureButton.Attribute("Width"));
}
+
+ private static string[] FindCodeResourceKeys()
+ {
+ string sourceRoot = Path.Combine(AppContext.BaseDirectory, "Source");
+ return Directory
+ .GetFiles(sourceRoot, "*.cs", SearchOption.AllDirectories)
+ .SelectMany(path =>
+ {
+ string source = File.ReadAllText(path);
+ IEnumerable direct = Regex.Matches(
+ source,
+ """ResourceText\.Get\((?.*?)\)""",
+ RegexOptions.Singleline)
+ .SelectMany(call => Regex.Matches(
+ call.Groups["argument"].Value,
+ @"""(?[^""]+)""")
+ .Select(match => match.Groups["key"].Value));
+ IEnumerable status = Regex.Matches(
+ source,
+ @"ShowStatus\(\s*""(?[^""]+)""")
+ .Select(match => match.Groups["key"].Value);
+ return direct.Concat(status);
+ })
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private static string[] FindXamlResourceRoots()
+ {
+ string uiRoot = Path.Combine(AppContext.BaseDirectory, "Ui");
+ XNamespace xaml = "http://schemas.microsoft.com/winfx/2006/xaml";
+ return Directory
+ .GetFiles(uiRoot, "*.xaml", SearchOption.AllDirectories)
+ .SelectMany(path => XDocument.Load(path)
+ .Descendants()
+ .Select(element => (string?)element.Attribute(xaml + "Uid")))
+ .Where(static uid => !string.IsNullOrWhiteSpace(uid))
+ .Cast()
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ private static string[] FindManifestResourceKeys()
+ {
+ string manifest = File.ReadAllText(
+ Path.Combine(AppContext.BaseDirectory, "Ui", "Package.appxmanifest"));
+ return Regex.Matches(manifest, @"ms-resource:(?[A-Za-z0-9_]+)")
+ .Select(match => match.Groups["key"].Value)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
}
diff --git a/tests/Snaply.Tests/Snaply.Tests.csproj b/tests/Snaply.Tests/Snaply.Tests.csproj
index e66c08b..52e6039 100644
--- a/tests/Snaply.Tests/Snaply.Tests.csproj
+++ b/tests/Snaply.Tests/Snaply.Tests.csproj
@@ -27,8 +27,16 @@
-
+
+
diff --git a/version.txt b/version.txt
new file mode 100644
index 0000000..17e51c3
--- /dev/null
+++ b/version.txt
@@ -0,0 +1 @@
+0.1.1