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",