diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e115822..659c4a5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.47.2
- name: Reject incomplete implementation markers
@@ -38,8 +38,8 @@ jobs:
runs-on: windows-latest
timeout-minutes: 35
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
@@ -97,8 +97,8 @@ jobs:
runs-on: windows-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 1c7664c..07f3849 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -19,13 +19,13 @@ jobs:
contents: read
security-events: write
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
cache-dependency-path: "**/packages.lock.json"
- - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: csharp
build-mode: manual
@@ -33,7 +33,7 @@ jobs:
- run: >-
dotnet build src/Snaply.App/Snaply.App.csproj
-c Release -p:Platform=x64 --no-restore
- - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
category: /language:csharp
@@ -44,11 +44,11 @@ jobs:
contents: read
security-events: write
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: actions
build-mode: none
- - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
category: /language:actions
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 4d0a9e2..1287477 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: moderate
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index c3fcf0e..205a9a3 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -31,14 +31,14 @@ jobs:
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
architecture: ${{ matrix.architecture }}
cache: true
cache-dependency-path: "**/packages.lock.json"
- - uses: microsoft/setup-WinAppCli@64bc6fd4c3e4c69e2de589dfc34684470891b8b7
+ - uses: microsoft/setup-WinAppCli@b93bbddc1f7abc061ca0d3a8119e3a0c7dd71495
with:
version: v0.3.1
- run: dotnet restore Snaply.slnx --locked-mode
@@ -104,8 +104,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
@@ -131,8 +131,8 @@ jobs:
runs-on: windows-latest
timeout-minutes: 30
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
diff --git a/.github/workflows/nuget-audit.yml b/.github/workflows/nuget-audit.yml
index e62ff6d..4b51c51 100644
--- a/.github/workflows/nuget-audit.yml
+++ b/.github/workflows/nuget-audit.yml
@@ -26,8 +26,8 @@ jobs:
runs-on: windows-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index aeaeaac..8d00b19 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -53,7 +53,7 @@ jobs:
if ([string]::IsNullOrWhiteSpace($env:MSIX_PUBLISHER)) {
throw "The MSIX_PUBLISHER repository variable is required."
}
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.REF }}
fetch-depth: 0
@@ -79,7 +79,7 @@ jobs:
if ("v$($props.Project.PropertyGroup.Version)" -ne $env:TAG) {
throw "The release tag does not match the project version."
}
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
@@ -104,7 +104,7 @@ jobs:
-pv '${{ inputs.tag_name }}'.TrimStart('v') `
-ps P4suta `
-nsb https://github.com/P4suta/Snaply
- - uses: taiki-e/install-action@4684b8405694ae9dd42c9f39ba901a70ae83f4a3 # v2.82.9
+ - uses: taiki-e/install-action@c44f6b046f1c29ae5918b1e0bfdbb2f1813836fd # v2.84.1
with:
tool: osv-scanner@2.3.6
- name: Scan release SBOM
@@ -130,10 +130,10 @@ jobs:
HAVE_SIGNING: ${{ secrets.ES_USERNAME != '' && secrets.ES_PASSWORD != '' && secrets.CREDENTIAL_ID != '' && secrets.ES_TOTP_SECRET != '' }}
SIGNER_SUBJECT_CONTAINS: ${{ vars.SIGNER_SUBJECT_CONTAINS }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.REF }}
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
@@ -238,10 +238,10 @@ jobs:
permissions:
contents: read
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.REF }}
- - uses: microsoft/setup-WinAppCli@64bc6fd4c3e4c69e2de589dfc34684470891b8b7
+ - uses: microsoft/setup-WinAppCli@b93bbddc1f7abc061ca0d3a8119e3a0c7dd71495
with:
version: v0.3.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -292,10 +292,10 @@ jobs:
SIGNER_SUBJECT_CONTAINS: ${{ vars.SIGNER_SUBJECT_CONTAINS }}
GH_TOKEN: ${{ github.token }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.REF }}
- - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
cache: true
@@ -331,21 +331,21 @@ jobs:
}
}
- name: Attest build provenance
- uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
+ uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: |
build/release/package/*.zip
build/release/package/*.msixbundle
build/release/package/SHA256SUMS.txt
- name: Attest SBOM
- uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
+ uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: |
build/release/package/*.zip
build/release/package/*.msixbundle
sbom-path: build/release/package/snaply.spdx.json
- name: Publish release
- uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
+ uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ inputs.tag_name }}
draft: false
diff --git a/.github/workflows/sbom-monitor.yml b/.github/workflows/sbom-monitor.yml
index 447c564..8bff11f 100644
--- a/.github/workflows/sbom-monitor.yml
+++ b/.github/workflows/sbom-monitor.yml
@@ -16,7 +16,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download latest release SBOM
id: release
shell: bash
@@ -29,7 +29,7 @@ jobs:
gh release download "$tag" --pattern 'snaply.spdx.json' --dir sbom
echo "available=true" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- - uses: taiki-e/install-action@4684b8405694ae9dd42c9f39ba901a70ae83f4a3 # v2.82.9
+ - uses: taiki-e/install-action@c44f6b046f1c29ae5918b1e0bfdbb2f1813836fd # v2.84.1
if: steps.release.outputs.available == 'true'
with:
tool: osv-scanner@2.3.6
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 61b3cb7..712ab28 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -19,7 +19,7 @@ jobs:
id-token: write
security-events: write
steps:
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
@@ -27,6 +27,6 @@ jobs:
results_file: results.sarif
results_format: sarif
publish_results: true
- - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
+ - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: results.sarif
diff --git a/README.md b/README.md
index 6a30787..14dfd29 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,35 @@
# Snaply
-Snaply captures a region, window, or the complete virtual desktop and automatically presents it on a randomized, image-aware gradient background.
+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.
-After each capture, Snaply opens a preview, copies one PNG to the clipboard, and saves the same image to `Pictures\Screenshots\Snaply`. Copy, Save As, and Open Folder remain available from the preview.
+After each capture, Snaply opens a preview, copies a PNG to the clipboard, and
+saves the same PNG to `Pictures\Screenshots\Snaply`.
-## Use
+## Usage
-1. Choose Region, Window, or Desktop from Capture.
-2. For Region, drag across one or more displays. For Window, choose from the Windows picker.
-3. Use Copy, Save As, Open Folder, Fit, or 100% as needed.
-
-Keyboard shortcuts: `Ctrl+N`, `Ctrl+C`, `Ctrl+Shift+S`, `Ctrl+0`, `Ctrl+1`, and `Esc`.
+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`.
+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.
-Snaply supports Windows 11 24H2 or later on x64 and ARM64 in English, Japanese, and Simplified Chinese.
+## License
-Snaply works entirely on the local machine. It has no telemetry, network access, background service, tray process, global hotkey, or updater.
+Apache-2.0. See [LICENSE](LICENSE).
diff --git a/src/Snaply.App/Controls/ZoomableImage.xaml.cs b/src/Snaply.App/Controls/ZoomableImage.xaml.cs
index eb814b1..0613b2e 100644
--- a/src/Snaply.App/Controls/ZoomableImage.xaml.cs
+++ b/src/Snaply.App/Controls/ZoomableImage.xaml.cs
@@ -12,18 +12,16 @@
namespace Snaply.Controls;
///
-/// A self-contained zoom/pan image viewer. The image is laid out
-/// so at scale 1.0 it already fits the viewport (the "fit" baseline); the element's
-/// Composition / then scale/translate from
-/// there. Mouse-wheel zooms about the cursor, left-drag pans, double-tap resets to fit, and a
-/// floating fit-to-view button (bottom-right) resets the zoom.
+/// 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 (wheel, Fit, double-tap) glides via GPU-composited spring animations, so rapid consecutive
-/// notches retarget smoothly from the current in-flight value instead of snapping. Panning writes
-/// the offset directly so the drag stays 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.
+/// 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
{
@@ -34,8 +32,8 @@ internal sealed partial class ZoomableImage : UserControl
typeof(ZoomableImage),
new PropertyMetadata(null, OnSourceChanged));
- // Zoom limits and per-notch multiplier. Scale is relative to the fit baseline (1.0), which is
- // also the floor: Fit is as small as it gets — the wheel only zooms in, never below Fit.
+ // 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;
diff --git a/src/Snaply.App/ImageExportService.cs b/src/Snaply.App/ImageExportService.cs
index 13004a1..c3ddfd9 100644
--- a/src/Snaply.App/ImageExportService.cs
+++ b/src/Snaply.App/ImageExportService.cs
@@ -91,7 +91,10 @@ internal static async Task CopyAsync(RenderedImage image, CancellationToken canc
internal void OpenCaptureDirectory()
{
Directory.CreateDirectory(_captureDirectory);
- Process.Start(new ProcessStartInfo("explorer.exe", _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)
{
UseShellExecute = true,
});
diff --git a/src/Snaply.App/MainPage.xaml b/src/Snaply.App/MainPage.xaml
index 2a25c06..7e3a2ec 100644
--- a/src/Snaply.App/MainPage.xaml
+++ b/src/Snaply.App/MainPage.xaml
@@ -12,14 +12,14 @@
mc:Ignorable="d">
-
+
-
+
@@ -46,25 +46,22 @@
-
+
-
+
-
+
-
+
-
+
-
+
{
// Each successful auto-save bumps SavedTick; play the folder→green-check flip.
diff --git a/src/Snaply.App/MainWindow.xaml.cs b/src/Snaply.App/MainWindow.xaml.cs
index b57eec6..2c0bb0b 100644
--- a/src/Snaply.App/MainWindow.xaml.cs
+++ b/src/Snaply.App/MainWindow.xaml.cs
@@ -16,7 +16,9 @@ internal MainWindow(MainViewModel viewModel, ScreenCaptureService capture)
AppWindow.SetIcon("Assets/AppIcon.ico");
nint handle = WinRT.Interop.WindowNative.GetWindowHandle(this);
- double scale = GetDpiForWindow(handle) / 96d;
+ // GetDpiForWindow returns 0 on failure; fall back to 96 (100%) so the window is never sized to 0×0.
+ uint dpi = GetDpiForWindow(handle);
+ double scale = (dpi == 0 ? 96u : dpi) / 96d;
AppWindow.Resize(new SizeInt32(
checked((int)Math.Round(1100 * scale)),
checked((int)Math.Round(720 * scale))));
diff --git a/src/Snaply.App/ResourceText.cs b/src/Snaply.App/ResourceText.cs
index e9c28dc..2dc29f6 100644
--- a/src/Snaply.App/ResourceText.cs
+++ b/src/Snaply.App/ResourceText.cs
@@ -1,4 +1,5 @@
using Microsoft.Windows.ApplicationModel.Resources;
+using Serilog;
namespace Snaply;
@@ -6,5 +7,18 @@ internal static class ResourceText
{
private static readonly ResourceLoader Loader = new();
- internal static string Get(string key) => Loader.GetString(key);
+ // MRT Core returns an empty string for a missing key (it does not throw). An empty UI label or
+ // accessible name is a silent failure, so log it and fall back to the key name — a visibly wrong
+ // label beats an invisibly absent one, and the warning points straight at the offending key.
+ internal static string Get(string key)
+ {
+ string value = Loader.GetString(key);
+ if (string.IsNullOrEmpty(value))
+ {
+ Log.Warning("Missing resource string {ResourceKey}", key);
+ return key;
+ }
+
+ return value;
+ }
}
diff --git a/src/Snaply.App/Strings/en-US/Resources.resw b/src/Snaply.App/Strings/en-US/Resources.resw
index c9ddc37..cb5dda7 100644
--- a/src/Snaply.App/Strings/en-US/Resources.resw
+++ b/src/Snaply.App/Strings/en-US/Resources.resw
@@ -2,41 +2,15 @@
text/microsoft-resx
2.0
- Capture region
- Capture screenshot
Capture region
Capture window
Capture desktop
Region
Window
Entire desktop
- Copy
- Save as
- Open folder
- Fit
- 100%
- Screenshot preview area
+ Open folder
Beautified screenshot preview
- Capture status
- Capture in progress
- Capture a screenshot
- Choose a region, window, or the entire virtual desktop.
- Save
- PNG image
- Ready.
- Select a region.
- Select a window or display.
- Capturing the desktop.
- Saved to Pictures\Screenshots\Snaply and copied.
- Saved, but clipboard copy failed. Copy is available.
- Copied, but automatic save failed. Save as is available.
- Preview ready. Automatic save and copy failed; both remain available.
- Copied.
- Saved.
- Capture cancelled.
Couldn't capture the selected content.
- Couldn't copy the screenshot.
- Couldn't save the screenshot.
Couldn't open the screenshots folder.
Drag to select · Esc to cancel
Cancel
diff --git a/src/Snaply.App/Strings/ja-JP/Resources.resw b/src/Snaply.App/Strings/ja-JP/Resources.resw
index a8929ec..3ba0c2c 100644
--- a/src/Snaply.App/Strings/ja-JP/Resources.resw
+++ b/src/Snaply.App/Strings/ja-JP/Resources.resw
@@ -2,41 +2,15 @@
text/microsoft-resx
2.0
- 領域を撮影
- スクリーンショットを撮影
領域を撮影
ウィンドウを撮影
デスクトップを撮影
領域
ウィンドウ
デスクトップ全体
- コピー
- 名前を付けて保存
- フォルダーを開く
- 全体表示
- 100%
- スクリーンショットのプレビュー領域
+ フォルダーを開く
加工済みスクリーンショットのプレビュー
- 撮影状態
- 撮影中
- スクリーンショットを撮影
- 領域、ウィンドウ、または仮想デスクトップ全体を選択します。
- 保存
- PNG画像
- 準備完了。
- 領域を選択してください。
- ウィンドウまたはディスプレイを選択してください。
- デスクトップを撮影しています。
- Pictures\Screenshots\Snaplyへ保存し、コピーしました。
- 保存しましたが、コピーに失敗しました。コピーを再実行できます。
- コピーしましたが、自動保存に失敗しました。名前を付けて保存できます。
- プレビューを作成しました。自動保存とコピーは失敗しましたが、どちらも再実行できます。
- コピーしました。
- 保存しました。
- 撮影をキャンセルしました。
選択した内容を撮影できませんでした。
- スクリーンショットをコピーできませんでした。
- スクリーンショットを保存できませんでした。
スクリーンショットフォルダーを開けませんでした。
ドラッグして選択 · Escでキャンセル
キャンセル
diff --git a/src/Snaply.App/Strings/zh-Hans/Resources.resw b/src/Snaply.App/Strings/zh-Hans/Resources.resw
index dc34afc..c9037ac 100644
--- a/src/Snaply.App/Strings/zh-Hans/Resources.resw
+++ b/src/Snaply.App/Strings/zh-Hans/Resources.resw
@@ -2,41 +2,15 @@
text/microsoft-resx
2.0
- 捕获区域
- 捕获屏幕截图
捕获区域
捕获窗口
捕获桌面
区域
窗口
整个桌面
- 复制
- 另存为
- 打开文件夹
- 适应
- 100%
- 屏幕截图预览区域
+ 打开文件夹
美化后的屏幕截图预览
- 捕获状态
- 正在捕获
- 捕获屏幕截图
- 选择区域、窗口或整个虚拟桌面。
- 保存
- PNG图像
- 已就绪。
- 请选择区域。
- 请选择窗口或显示器。
- 正在捕获桌面。
- 已保存到 Pictures\Screenshots\Snaply 并复制。
- 已保存,但复制失败。可以重新复制。
- 已复制,但自动保存失败。可以使用另存为。
- 预览已就绪。自动保存和复制失败,但都可以重试。
- 已复制。
- 已保存。
- 已取消捕获。
无法捕获所选内容。
- 无法复制屏幕截图。
- 无法保存屏幕截图。
无法打开屏幕截图文件夹。
拖动以选择 · Esc取消
取消
diff --git a/src/Snaply.App/ViewModels/MainViewModel.cs b/src/Snaply.App/ViewModels/MainViewModel.cs
index 1815b66..0309533 100644
--- a/src/Snaply.App/ViewModels/MainViewModel.cs
+++ b/src/Snaply.App/ViewModels/MainViewModel.cs
@@ -12,7 +12,6 @@ internal sealed partial class MainViewModel : ObservableObject, IDisposable
private readonly ScreenCaptureService _capture;
private readonly ImageExportService _export;
private CancellationTokenSource? _operation;
- private RenderedImage? _image;
[ObservableProperty]
internal partial WriteableBitmap? Preview { get; set; }
@@ -29,9 +28,6 @@ internal sealed partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty]
internal partial string ErrorMessage { get; set; } = string.Empty;
- [ObservableProperty]
- internal partial string StatusMessage { get; set; } = ResourceText.Get("StatusReady");
-
// 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]
@@ -57,12 +53,6 @@ internal async Task CaptureAsync(CaptureMode mode)
LastCaptureMode = mode;
HasError = false;
IsBusy = true;
- StatusMessage = ResourceText.Get(mode switch
- {
- CaptureMode.Region => "StatusCapturingRegion",
- CaptureMode.Window => "StatusCapturingWindow",
- _ => "StatusCapturingDesktop",
- });
using var operation = new CancellationTokenSource();
_operation = operation;
@@ -71,37 +61,25 @@ internal async Task CaptureAsync(CaptureMode mode)
using CapturedFrame? frame = await _capture.CaptureAsync(mode, operation.Token);
if (frame is null)
{
- StatusMessage = ResourceText.Get("StatusCancelled");
return;
}
RenderedImage image = await BeautifyRenderer.RenderAsync(frame, operation.Token);
WriteableBitmap preview = await UpdatePreviewAsync(Preview, image, operation.Token);
- _image = image;
Preview = preview;
HasImage = true;
Task save = TrySaveAutomaticallyAsync(image, operation.Token);
Task copy = TryCopyAsync(image, operation.Token);
await Task.WhenAll(save, copy);
- bool saved = await save;
- bool copied = await copy;
- if (saved)
+ if (await save)
{
SavedTick++;
}
-
- StatusMessage = ResourceText.Get((saved, copied) switch
- {
- (true, true) => "StatusSavedCopied",
- (true, false) => "StatusSavedOnly",
- (false, true) => "StatusCopiedOnly",
- _ => "StatusExportFailed",
- });
}
catch (OperationCanceledException)
{
- StatusMessage = ResourceText.Get("StatusCancelled");
+ // Cancellation (Esc, or the window picker dismissed) is a normal outcome — nothing to surface.
}
catch (Exception exception)
{
@@ -211,7 +189,6 @@ private void ShowError(string key)
{
ErrorMessage = ResourceText.Get(key);
HasError = true;
- StatusMessage = ErrorMessage;
}
private static void LogFailure(string operation, Exception exception) =>
diff --git a/src/Snaply.App/ui-tests.ps1 b/src/Snaply.App/ui-tests.ps1
index dfbee22..fdf194b 100644
--- a/src/Snaply.App/ui-tests.ps1
+++ b/src/Snaply.App/ui-tests.ps1
@@ -409,23 +409,13 @@ function Wait-CaptureComplete {
foreach ($id in @(
'CaptureButton',
- 'CopyButton',
- 'SaveAsButton',
- 'OpenFolderButton',
- 'PreviewScroller',
- 'StatusText'
+ 'OpenFolderButton'
)) {
Test-Ui "$id exists" {
Wait-AppElement $id | Out-Null
}
}
-Test-Ui 'Copy disabled before capture' {
- Wait-AppElement CopyButton IsEnabled $false | Out-Null
-}
-Test-Ui 'Save As disabled before capture' {
- Wait-AppElement SaveAsButton IsEnabled $false | Out-Null
-}
Test-Ui 'Open Folder enabled before capture' {
Wait-AppElement OpenFolderButton IsEnabled $true | Out-Null
}
@@ -490,15 +480,7 @@ Test-Ui 'Region capture completes' {
}
Wait-AppElement CaptureButton IsEnabled $true 20000 | Out-Null
- try {
- Wait-AppElement PreviewImage IsOffscreen $false 3000 | Out-Null
- }
- catch {
- $statusElement = Get-AppElement StatusText
- $status = $statusElement.GetCurrentPattern(
- [System.Windows.Automation.TextPattern]::Pattern).DocumentRange.GetText(-1)
- throw "Region capture produced no preview. Status: $status"
- }
+ Wait-AppElement PreviewImage IsOffscreen $false 3000 | Out-Null
}
Test-Ui 'Window picker cancellation recovers' {
@@ -612,12 +594,6 @@ Test-Ui 'Desktop capture completes' {
Invoke-CaptureMode DesktopCaptureItem
Wait-CaptureComplete
}
-Test-Ui 'Copy enabled after capture' {
- Wait-AppElement CopyButton IsEnabled $true | Out-Null
-}
-Test-Ui 'Save As enabled after capture' {
- Wait-AppElement SaveAsButton IsEnabled $true | Out-Null
-}
Test-Ui 'Automatic save created a PNG' {
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {
@@ -637,39 +613,11 @@ Test-Ui 'Automatic save created a PNG' {
throw 'No automatic save appeared.'
}
}
-Test-Ui 'Copy places a bitmap on the clipboard' {
- (Get-AppElement 'CopyButton').
- GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
- Invoke()
- Start-Sleep -Milliseconds 200
+Test-Ui 'Capture places a bitmap on the clipboard' {
if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
throw 'Clipboard contains no image.'
}
}
-Test-Ui 'Save As writes the retained PNG' {
- $saveAsPath = Join-Path $artifacts 'save-as.png'
- Remove-Item -LiteralPath $saveAsPath -Force -ErrorAction SilentlyContinue
- (Get-AppElement 'SaveAsButton').
- GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
- Invoke()
- Start-Sleep -Seconds 1
- [System.Windows.Forms.SendKeys]::SendWait($saveAsPath)
- [System.Windows.Forms.SendKeys]::SendWait('{ENTER}')
-
- $deadline = [DateTime]::UtcNow.AddSeconds(10)
- while (-not (Test-Path -LiteralPath $saveAsPath) -and [DateTime]::UtcNow -lt $deadline) {
- Start-Sleep -Milliseconds 100
- }
- if (-not (Test-Path -LiteralPath $saveAsPath)) {
- throw 'Save As did not create the file.'
- }
-
- $header = [System.IO.File]::ReadAllBytes($saveAsPath)[0..7]
- $signature = [BitConverter]::ToString($header).Replace('-', '')
- if ($signature -ne '89504E470D0A1A0A') {
- throw 'Save As output is not a PNG.'
- }
-}
Test-Ui 'Open Folder opens the automatic-save directory' {
(Get-AppElement 'OpenFolderButton').
GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).
diff --git a/tests/Snaply.Tests/ResourceParityTests.cs b/tests/Snaply.Tests/ResourceParityTests.cs
index 6b56c17..af0e5e5 100644
--- a/tests/Snaply.Tests/ResourceParityTests.cs
+++ b/tests/Snaply.Tests/ResourceParityTests.cs
@@ -27,6 +27,36 @@ public void Every_supported_language_has_the_same_resource_keys()
Regex.IsMatch(key, @"(^|[_.])(Cli|Mcp|Hotkey)($|[_.])", RegexOptions.IgnoreCase));
}
+ [Fact]
+ public void Keys_referenced_from_code_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 root = Path.Combine(AppContext.BaseDirectory, "Strings");
+ foreach (string file in Directory.GetFiles(root, "Resources.resw", SearchOption.AllDirectories))
+ {
+ HashSet keys = XDocument.Load(file)
+ .Root!
+ .Elements("data")
+ .Select(element => (string)element.Attribute("name")!)
+ .ToHashSet(StringComparer.Ordinal);
+ Assert.All(requiredKeys, key => Assert.Contains(key, keys));
+ }
+ }
+
[Fact]
public void Resources_are_unique_and_non_empty()
{
diff --git a/tests/Snaply.Tests/Snaply.Tests.csproj b/tests/Snaply.Tests/Snaply.Tests.csproj
index 3806f33..33a52a0 100644
--- a/tests/Snaply.Tests/Snaply.Tests.csproj
+++ b/tests/Snaply.Tests/Snaply.Tests.csproj
@@ -1,35 +1,35 @@
-
-
-
- net10.0
- enable
- enable
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Snaply.Tests/packages.lock.json b/tests/Snaply.Tests/packages.lock.json
index 8aeda61..50eba82 100644
--- a/tests/Snaply.Tests/packages.lock.json
+++ b/tests/Snaply.Tests/packages.lock.json
@@ -21,12 +21,12 @@
},
"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": {
@@ -69,8 +69,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.Testing.Extensions.Telemetry": {
"type": "Transitive",
@@ -104,16 +104,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.Win32.Registry": {
@@ -121,11 +120,6 @@
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
},
- "Newtonsoft.Json": {
- "type": "Transitive",
- "resolved": "13.0.3",
- "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
- },
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.27.0",