diff --git a/CHANGELOG.md b/CHANGELOG.md index 35eaadf..201848b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,19 @@ _No changes yet._ --- +## [v0.1.1] - 2026-04-26 + +Capture-flow correctness release. No schema, workflow, or security changes from v0.1.0. + +### Fixed +- Capture flow — exclude SnipIT's own widget / preview / tray windows from the capture target so they aren't baked into the frame ([RAN-15](https://github.com/RandomCodeSpace/snipIT/issues)). _The v0.1.0 release notes listed this fix prematurely; the change actually ships in v0.1.1 (see [RAN-68](https://github.com/RandomCodeSpace/snipIT/issues))._ +- Full-screen and window capture — route `Invoke-FullScreenCapture` and `Invoke-WindowCapture` through `Invoke-CaptureLoop` with a per-iteration capture factory, so the preview owns / disposes each bitmap and the chrome-hide runs every snapshot. Fixes the use-after-dispose blank/crash on iteration 2+ of the same capture session ([RAN-14](https://github.com/RandomCodeSpace/snipIT/issues)). + +### Security +- _No security-relevant fixes in v0.1.1._ + +--- + ## [v0.1.0] - 2026-04-26 First tagged release. Establishes the OpenSSF Best Practices `passing` baseline + supporting documentation surface for snipIT. @@ -42,13 +55,15 @@ First tagged release. Establishes the OpenSSF Best Practices `passing` baseline - `.bestpractices.json` — 5 SUGGESTED criteria flipped from `?` to `Met` with concrete in-repo evidence (`version_semver`, `version_tags`, `test_most`, `dynamic_analysis`, `dynamic_analysis_enable_assertions`) ([PR #6](https://github.com/RandomCodeSpace/snipIT/pull/6)); 4 `_url` fields retargeted to conventional paths (`README.md`, `CONTRIBUTING.md`, `SECURITY.md`) so the bestpractices.dev autofill bot detects them ([PR #7](https://github.com/RandomCodeSpace/snipIT/pull/7)). ### Fixed -- Capture flow — exclude SnipIT's own widget / preview / tray windows from the capture target so they aren't baked into the frame ([RAN-15](https://github.com/RandomCodeSpace/snipIT/issues)). - Color-bar interaction — update the active swatch in-place instead of rebuilding the bar; close `$pickColor` over the swatch handler so the closure resolves correctly at click time. +> **Correction (2026-04-26):** the original v0.1.0 release notes also listed a `Capture flow — exclude SnipIT's own widget / preview / tray windows ...` line attributed to [RAN-15](https://github.com/RandomCodeSpace/snipIT/issues). That fix was not actually in the v0.1.0 tree (the commit was never pushed before the tag was cut); it ships in [v0.1.1](#v011---2026-04-26) instead. The v0.1.0 git tag annotation and GitHub Release body are immutable per OSPS evidence policy and have not been edited; this CHANGELOG entry is the authoritative record. + ### Security - _No security-relevant fixes shipped under v0.1.0._ The OSS-CLI security stack landed in `.github/workflows/security.yml` is the gating channel for all future fixes; advisories will appear in this section under each release where they apply, alongside a GHSA link. --- -[Unreleased]: https://github.com/RandomCodeSpace/snipIT/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/RandomCodeSpace/snipIT/compare/v0.1.1...HEAD +[v0.1.1]: https://github.com/RandomCodeSpace/snipIT/releases/tag/v0.1.1 [v0.1.0]: https://github.com/RandomCodeSpace/snipIT/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 8c7808e..adbc9f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ A **material** Scorecard regression on a PR files a follow-up chore (`type:chore ## Gotchas - **Capture loop ownership.** `Invoke-CaptureLoop` (RAN-14 contract) takes ownership of each captured `System.Drawing.Bitmap` — the preview disposes it on close, the loop creates a fresh one for each iteration via the `CaptureFactory` closure. Do not dispose the bitmap inside the factory or pre-allocate one outside the loop. -- **SnipIT-window exclusion in capture.** RAN-15 fix (commit `bc216cc`) excludes the SnipIT widget / preview / tray windows from the capture targets. If you add a new top-level window, register it via `Hide-OwnSnipITWindowsForCapture` so it's not baked into the frame. +- **SnipIT-window exclusion in capture.** The RAN-15 fix (shipped in v0.1.1) excludes the SnipIT widget / preview / tray windows from the capture targets. If you add a new top-level window, register it via `Hide-OwnSnipITWindowsForCapture` so it's not baked into the frame. - **Per-monitor DPI.** Capture math is DPI-aware on virtual desktops with mixed scaling. Negative-origin layouts (monitor to the left of the primary) are handled in `Get-VirtualScreenBounds`; do not assume `(0,0)` is the top-left of the virtual desktop. - **Single-instance mutex.** A second launch shows a friendly notification and exits — *unless* `SNIPIT_TEST_MODE=1` is set (test-harness escape hatch). - **`actions/checkout@v4` vs SHA-pin.** Workflows in this repo MUST pin every action by commit SHA (Scorecard `Pinned-Dependencies`). Dependabot opens routine bumps; do not manually downgrade to a tag-ref. diff --git a/SnipIT.ps1 b/SnipIT.ps1 index a6a12bc..05e6d69 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -244,6 +244,76 @@ function Get-TrimmedRecent { return $arr[0..($MaxDepth - 1)] } +function Test-IsSelfWindowHandle { + # True when $Hwnd is one of SnipIT's own registered window handles. + # Used by the window-capture path to avoid snapshotting our own UI + # when the foreground window is SnipIT (tray balloon, widget, preview). + param( + [AllowNull()] $Hwnd, + [AllowNull()][AllowEmptyCollection()] $SelfWindowHandles + ) + if ($null -eq $Hwnd) { return $false } + if ($Hwnd -is [IntPtr] -and $Hwnd -eq [IntPtr]::Zero) { return $false } + if ($null -eq $SelfWindowHandles) { return $false } + foreach ($h in @($SelfWindowHandles)) { + if ($null -eq $h) { continue } + if ($h -is [IntPtr] -and $h -eq [IntPtr]::Zero) { continue } + if ($h -eq $Hwnd) { return $true } + } + return $false +} + +function Resolve-WindowCaptureTarget { + # Pure decision layer for active-window capture. Returns the HWND we + # should capture, or $null to signal "skip this target, caller should + # fall back (typically to the full virtual desktop)". + # + # - No foreground window ([IntPtr]::Zero) => $null + # - Foreground belongs to SnipIT => $null (self-capture guard) + # - Anything else => the HWND unchanged + param( + [AllowNull()] $ForegroundHwnd, + [AllowNull()][AllowEmptyCollection()] $SelfWindowHandles + ) + if ($null -eq $ForegroundHwnd) { return $null } + if ($ForegroundHwnd -is [IntPtr] -and $ForegroundHwnd -eq [IntPtr]::Zero) { return $null } + if (Test-IsSelfWindowHandle -Hwnd $ForegroundHwnd -SelfWindowHandles $SelfWindowHandles) { + return $null + } + return $ForegroundHwnd +} + +function Invoke-CaptureLoop { + # Pure orchestration for the capture/preview/"New snip" loop. + # + # Contract (important — this encodes the RAN-14 invariant): + # The preview window takes ownership of the capture it receives and + # disposes it on close. The loop therefore MUST call $CaptureFactory + # on every iteration to produce a fresh capture. A disposed capture + # is never passed back into $PreviewHandler. + # + # Parameters: + # CaptureFactory scriptblock () -> capture handle (or $null to abort the loop) + # PreviewHandler scriptblock ($capture) -> $true to loop again, $false to exit + # MaxIterations safety cap in case PreviewHandler always returns $true + # + # Returns the number of preview iterations actually run. + param( + [Parameter(Mandatory)] [scriptblock]$CaptureFactory, + [Parameter(Mandatory)] [scriptblock]$PreviewHandler, + [int]$MaxIterations = 32 + ) + $iterations = 0 + while ($iterations -lt $MaxIterations) { + $capture = & $CaptureFactory + if ($null -eq $capture) { break } + $iterations++ + $again = & $PreviewHandler $capture + if (-not $again) { break } + } + return $iterations +} + #endregion # Tests dot-source this script with -CoreOnly to load only the pure functions above. @@ -291,7 +361,12 @@ public static class ConsoleHider { '@ } $h = [ConsoleHider]::GetConsoleWindow() -if ($h -ne [IntPtr]::Zero) { [ConsoleHider]::ShowWindow($h, 0) | Out-Null } +if ($h -ne [IntPtr]::Zero) { + [ConsoleHider]::ShowWindow($h, 0) | Out-Null + # Track even though hidden — a future ShowWindow we don't control could + # bring it back, and the capture-target guard wants it in the self set. + $script:ConsoleHwnd = $h +} Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore @@ -362,6 +437,14 @@ public static class Native { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + // Visibility + show/hide for SnipIT-owned windows. We hide our chrome + // around CopyFromScreen so widget / preview UI doesn't get baked into + // captures, then SW_SHOWNA back without stealing focus. + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + public const int SW_HIDE = 0; + public const int SW_SHOWNA = 8; // show without activating (don't steal focus) + // DWM extended frame bounds (no drop shadow) [DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attr, out RECT rect, int size); @@ -546,6 +629,57 @@ function New-ScreenBitmap { return $bmp } +$script:SelfWindowHandles = New-Object System.Collections.Generic.List[IntPtr] + +function Register-SelfWindowHandle { + # Tracks an HWND we own (widget, preview, hotkey form, console) so the + # capture path can exclude it via Resolve-WindowCaptureTarget and the + # snapshot path can hide it via Hide-OwnSnipITWindowsForCapture. + param([IntPtr]$Hwnd) + if ($Hwnd -eq [IntPtr]::Zero) { return } + if (-not $script:SelfWindowHandles.Contains($Hwnd)) { + [void]$script:SelfWindowHandles.Add($Hwnd) + } +} + +function Unregister-SelfWindowHandle { + param([IntPtr]$Hwnd) + [void]$script:SelfWindowHandles.Remove($Hwnd) +} + +function Hide-OwnSnipITWindowsForCapture { + # Hides every registered SnipIT-owned hwnd that is currently visible so + # our widget / preview / etc. don't get baked into a desktop snapshot. + # Returns the list of hidden hwnds; pass it to + # Show-OwnSnipITWindowsForCapture to restore them without stealing focus. + [OutputType([System.Collections.Generic.List[IntPtr]])] + $hidden = New-Object System.Collections.Generic.List[IntPtr] + foreach ($h in @($script:SelfWindowHandles)) { + if ($h -eq [IntPtr]::Zero) { continue } + if (-not [Native]::IsWindowVisible($h)) { continue } + if ([Native]::ShowWindow($h, [Native]::SW_HIDE)) { $hidden.Add($h) } + } + if ($hidden.Count -gt 0) { + # Pump pending UI work and yield briefly so DWM composes a frame + # without our chrome before CopyFromScreen samples the desktop. + try { [System.Windows.Forms.Application]::DoEvents() } catch {} + Start-Sleep -Milliseconds 80 + } + # Wrap in a single-element array so PowerShell does not unroll the List + # across the output stream. + ,$hidden +} + +function Show-OwnSnipITWindowsForCapture { + param($Hidden) + if (-not $Hidden -or $Hidden.Count -eq 0) { return } + foreach ($h in $Hidden) { + # SW_SHOWNA = show without activating, so we don't yank focus from + # whatever window the user was on while the snapshot ran. + [void][Native]::ShowWindow([IntPtr]$h, [Native]::SW_SHOWNA) + } +} + function Convert-BitmapToBitmapSource { param([System.Drawing.Bitmap]$Bitmap) # DeleteObject lives on the main [Native] class defined at startup — no per-call JIT. @@ -663,8 +797,16 @@ function Set-MicaBackdrop { #region Smart Overlay (hover + drag + magnifier) ============================ function Show-SmartOverlay { - $vs = Get-VirtualScreenBounds - $snap = New-ScreenBitmap -X $vs.X -Y $vs.Y -Width $vs.Width -Height $vs.Height + $vs = Get-VirtualScreenBounds + # Hide SnipIT-owned chrome (widget, preview) before snapshotting the + # desktop, otherwise topmost SnipIT UI would get baked into the smart + # overlay's background image and into any region the user picks. + $hidden = Hide-OwnSnipITWindowsForCapture + try { + $snap = New-ScreenBitmap -X $vs.X -Y $vs.Y -Width $vs.Width -Height $vs.Height + } finally { + Show-OwnSnipITWindowsForCapture -Hidden $hidden + } $snapSrc = Convert-BitmapToBitmapSource $snap [xml]$xaml = @" @@ -2026,7 +2168,15 @@ function Show-PreviewWindow { $script:RequestNewSnip = $false $script:CurrentPreviewWindow = $win + $previewHelper = New-Object System.Windows.Interop.WindowInteropHelper $win + # SourceInitialized fires once the OS hwnd exists but before the window + # paints. Register here so a window-capture triggered with the preview + # in focus correctly falls back to the virtual desktop. + $win.Add_SourceInitialized({ + Register-SelfWindowHandle -Hwnd $previewHelper.Handle + }.GetNewClosure()) $win.Add_Closed({ + try { Unregister-SelfWindowHandle -Hwnd $previewHelper.Handle } catch {} $script:CurrentPreviewWindow = $null # Release the backing Bitmap — the frozen BitmapSource ($src) no longer depends on it. try { if ($Bitmap) { $Bitmap.Dispose() } } catch { Write-SnipDiag "Bitmap dispose failed" $_ } @@ -2117,37 +2267,66 @@ function Invoke-SmartCapture { function Invoke-FullScreenCapture { $vs = Get-VirtualScreenBounds - $bmp = New-ScreenBitmap -X $vs.X -Y $vs.Y -Width $vs.Width -Height $vs.Height - do { - $again = Show-PreviewWindow -Bitmap $bmp - if ($script:PendingCaptureType) { - $bmp.Dispose() - Invoke-PendingCapture; return + # Recreate the screenshot each iteration — the preview takes ownership of + # the bitmap and disposes it on close (see Invoke-CaptureLoop contract, + # RAN-14). Hide own chrome around each grab so the widget/preview isn't + # baked into the frame. + $factory = { + $hidden = Hide-OwnSnipITWindowsForCapture + try { + return New-ScreenBitmap -X $vs.X -Y $vs.Y -Width $vs.Width -Height $vs.Height + } finally { + Show-OwnSnipITWindowsForCapture -Hidden $hidden } - } while ($again) - $bmp.Dispose() + }.GetNewClosure() + $handler = { + param($bmp) + $again = Show-PreviewWindow -Bitmap $bmp + if ($script:PendingCaptureType) { return $false } + return $again + }.GetNewClosure() + $null = Invoke-CaptureLoop -CaptureFactory $factory -PreviewHandler $handler + if ($script:PendingCaptureType) { Invoke-PendingCapture } } function Invoke-WindowCapture { - # Capture the currently foreground window. If that's one of SnipIT's own - # windows (tray balloon clicked, etc.), fall back to the virtual desktop. - $hwnd = [Native]::GetForegroundWindow() - if ($hwnd -eq [IntPtr]::Zero) { return } + # Capture the currently foreground window. If that's a SnipIT-owned + # window (widget clicked, preview focused, hotkey form, etc.), the + # decision layer returns $null and we fall back to a full virtual- + # desktop capture instead of snapshotting ourselves. + $fg = [Native]::GetForegroundWindow() + $target = Resolve-WindowCaptureTarget -ForegroundHwnd $fg ` + -SelfWindowHandles $script:SelfWindowHandles + if ($null -eq $target) { + Invoke-FullScreenCapture + return + } $r = New-Object Native+RECT - $ok = ([Native]::DwmGetWindowAttribute($hwnd, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$r, 16) -eq 0) - if (-not $ok) { [Native]::GetWindowRect($hwnd, [ref]$r) | Out-Null } + $ok = ([Native]::DwmGetWindowAttribute($target, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$r, 16) -eq 0) + if (-not $ok) { [Native]::GetWindowRect($target, [ref]$r) | Out-Null } $w = $r.Right - $r.Left $h = $r.Bottom - $r.Top if ($w -le 0 -or $h -le 0) { return } - $bmp = New-ScreenBitmap -X $r.Left -Y $r.Top -Width $w -Height $h - do { - $again = Show-PreviewWindow -Bitmap $bmp - if ($script:PendingCaptureType) { - $bmp.Dispose() - Invoke-PendingCapture; return + # Recreate the screenshot each iteration — the preview owns and disposes + # the bitmap on close (see Invoke-CaptureLoop contract, RAN-14). Even when + # the target is foreign, our widget can be sitting on top of it (always- + # Topmost), so hide own chrome around every snapshot. + $factory = { + $hidden = Hide-OwnSnipITWindowsForCapture + try { + return New-ScreenBitmap -X $r.Left -Y $r.Top -Width $w -Height $h + } finally { + Show-OwnSnipITWindowsForCapture -Hidden $hidden } - } while ($again) - $bmp.Dispose() + }.GetNewClosure() + $handler = { + param($bmp) + $again = Show-PreviewWindow -Bitmap $bmp + if ($script:PendingCaptureType) { return $false } + return $again + }.GetNewClosure() + $null = Invoke-CaptureLoop -CaptureFactory $factory -PreviewHandler $handler + if ($script:PendingCaptureType) { Invoke-PendingCapture } } function Start-DelayedCapture { @@ -2234,10 +2413,19 @@ function Show-FloatingWidget { } }) $timer.Start() - $win.Add_Closed({ $timer.Stop(); $script:WidgetWindow = $null }) + $widgetHelper = New-Object System.Windows.Interop.WindowInteropHelper $win + $win.Add_Closed({ + $timer.Stop() + $script:WidgetWindow = $null + try { Unregister-SelfWindowHandle -Hwnd $widgetHelper.Handle } catch {} + }.GetNewClosure()) $script:WidgetWindow = $win $win.Show() + # Register after Show() so the OS hwnd exists. Used by the capture path + # to (a) skip the widget when it's foreground and (b) hide it before + # snapshotting the desktop. + Register-SelfWindowHandle -Hwnd $widgetHelper.Handle } #endregion @@ -2305,6 +2493,11 @@ public class HotkeyWindow : NativeWindow { $hotkeyForm.CreateControl() $null = $hotkeyForm.Handle +# Track the hotkey form and the (hidden) console window so the capture path +# treats them as SnipIT-owned. The form is invisible/off-screen but it can +# still become foreground momentarily after a hotkey fires. +Register-SelfWindowHandle -Hwnd $hotkeyForm.Handle +if ($script:ConsoleHwnd) { Register-SelfWindowHandle -Hwnd $script:ConsoleHwnd } $hkWin = New-Object HotkeyWindow $hotkeyForm $hkWin.Callback = [Action[int]]{ param([int]$id) diff --git a/Test-SnipIT.ps1 b/Test-SnipIT.ps1 index 5c50f56..27231b1 100644 --- a/Test-SnipIT.ps1 +++ b/Test-SnipIT.ps1 @@ -440,6 +440,200 @@ It 'maps when virtual screen starts below zero (top monitor above primary)' { ShouldBe $b.X 100; ShouldBe $b.Y 880 } +Describe 'Test-IsSelfWindowHandle (RAN-15 regression)' +It 'returns false for [IntPtr]::Zero regardless of self set' { + ShouldBeFalse (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::Zero) -SelfWindowHandles @([IntPtr]::new(5))) +} +It 'returns false for null hwnd' { + ShouldBeFalse (Test-IsSelfWindowHandle -Hwnd $null -SelfWindowHandles @([IntPtr]::new(5))) +} +It 'returns false when self set is null' { + ShouldBeFalse (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::new(42)) -SelfWindowHandles $null) +} +It 'returns false when self set is empty' { + ShouldBeFalse (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::new(42)) -SelfWindowHandles @()) +} +It 'returns true when the hwnd matches a single self entry' { + ShouldBeTrue (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::new(42)) -SelfWindowHandles @([IntPtr]::new(42))) +} +It 'returns true when the hwnd matches one of several self entries' { + $selves = @([IntPtr]::new(1), [IntPtr]::new(2), [IntPtr]::new(3)) + ShouldBeTrue (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::new(2)) -SelfWindowHandles $selves) +} +It 'returns false when no self entry matches' { + $selves = @([IntPtr]::new(1), [IntPtr]::new(2), [IntPtr]::new(3)) + ShouldBeFalse (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::new(99)) -SelfWindowHandles $selves) +} +It 'ignores null / zero entries in the self set' { + $selves = @($null, [IntPtr]::Zero, [IntPtr]::new(7)) + ShouldBeTrue (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::new(7)) -SelfWindowHandles $selves) + ShouldBeFalse (Test-IsSelfWindowHandle -Hwnd ([IntPtr]::Zero) -SelfWindowHandles $selves) +} + +Describe 'Resolve-WindowCaptureTarget (RAN-15 regression)' +It 'returns null when no foreground window (zero hwnd)' { + $r = Resolve-WindowCaptureTarget -ForegroundHwnd ([IntPtr]::Zero) -SelfWindowHandles @([IntPtr]::new(5)) + ShouldBeTrue ($null -eq $r) +} +It 'returns null when foreground is a SnipIT window' { + $self = [IntPtr]::new(111) + $r = Resolve-WindowCaptureTarget -ForegroundHwnd $self -SelfWindowHandles @($self) + ShouldBeTrue ($null -eq $r) +} +It 'returns null when foreground matches any entry in the self set' { + $selves = @([IntPtr]::new(10), [IntPtr]::new(20), [IntPtr]::new(30)) + $r = Resolve-WindowCaptureTarget -ForegroundHwnd ([IntPtr]::new(20)) -SelfWindowHandles $selves + ShouldBeTrue ($null -eq $r) +} +It 'returns the hwnd when foreground is a non-SnipIT window' { + $target = [IntPtr]::new(777) + $r = Resolve-WindowCaptureTarget -ForegroundHwnd $target -SelfWindowHandles @([IntPtr]::new(111)) + ShouldBe $r $target +} +It 'returns the hwnd when self set is empty' { + $target = [IntPtr]::new(777) + $r = Resolve-WindowCaptureTarget -ForegroundHwnd $target -SelfWindowHandles @() + ShouldBe $r $target +} +It 'returns the hwnd when self set is null (nothing registered yet)' { + $target = [IntPtr]::new(777) + $r = Resolve-WindowCaptureTarget -ForegroundHwnd $target -SelfWindowHandles $null + ShouldBe $r $target +} + +Describe 'Invoke-CaptureLoop (RAN-14 regression)' +# Counters live in a hashtable because scriptblocks invoked via `&` get a fresh +# scope, so a plain `$var++` inside the scriptblock would leak nothing back out. +It 'runs zero iterations when the factory returns null immediately' { + $state = @{ factory = 0; preview = 0 } + $iters = Invoke-CaptureLoop ` + -CaptureFactory { $state.factory++; $null }.GetNewClosure() ` + -PreviewHandler { $state.preview++; $false }.GetNewClosure() + ShouldBe $iters 0 + ShouldBe $state.factory 1 + ShouldBe $state.preview 0 +} +It 'runs exactly one iteration when preview returns $false' { + $state = @{ factory = 0; preview = 0 } + $iters = Invoke-CaptureLoop ` + -CaptureFactory { $state.factory++; [pscustomobject]@{ Id = $state.factory } }.GetNewClosure() ` + -PreviewHandler { $state.preview++; $false }.GetNewClosure() + ShouldBe $iters 1 + ShouldBe $state.factory 1 + ShouldBe $state.preview 1 +} +It 'calls the capture factory once per iteration (no bitmap reuse across New-snip)' { + # The preview window disposes the capture it receives, so every loop + # iteration must produce a fresh capture. This asserts the RAN-14 invariant. + $state = @{ factory = 0; preview = 0 } + $iters = Invoke-CaptureLoop ` + -CaptureFactory { $state.factory++; [pscustomobject]@{ Id = $state.factory } }.GetNewClosure() ` + -PreviewHandler { $state.preview++; $state.preview -lt 3 }.GetNewClosure() + ShouldBe $iters 3 + ShouldBe $state.factory 3 + ShouldBe $state.preview 3 +} +It 'never hands the same capture instance to the preview twice' { + $state = @{ + factory = 0 + preview = 0 + seen = (New-Object System.Collections.Generic.List[object]) + } + $null = Invoke-CaptureLoop ` + -CaptureFactory { $state.factory++; [pscustomobject]@{ Id = $state.factory } }.GetNewClosure() ` + -PreviewHandler { + param($cap) + foreach ($prev in $state.seen) { + if ([object]::ReferenceEquals($prev, $cap)) { + throw "Preview received a reused capture instance (Id=$($cap.Id)) on iteration $($state.preview)" + } + } + $state.seen.Add($cap) | Out-Null + $state.preview++ + $state.preview -lt 4 + }.GetNewClosure() + ShouldBe $state.seen.Count 4 +} +It 'exits immediately when factory returns null mid-loop' { + $state = @{ factory = 0; preview = 0 } + $iters = Invoke-CaptureLoop ` + -CaptureFactory { + $state.factory++ + if ($state.factory -eq 2) { return $null } + [pscustomobject]@{ Id = $state.factory } + }.GetNewClosure() ` + -PreviewHandler { $state.preview++; $true }.GetNewClosure() # always request another snip + ShouldBe $iters 1 + ShouldBe $state.factory 2 + ShouldBe $state.preview 1 +} +It 'honours MaxIterations safeguard against a preview that always requests more' { + $state = @{ factory = 0; preview = 0 } + $iters = Invoke-CaptureLoop -MaxIterations 5 ` + -CaptureFactory { $state.factory++; [pscustomobject]@{ Id = $state.factory } }.GetNewClosure() ` + -PreviewHandler { $state.preview++; $true }.GetNewClosure() + ShouldBe $iters 5 + ShouldBe $state.factory 5 + ShouldBe $state.preview 5 +} +It 'passes the capture from the current iteration into the preview handler' { + $state = @{ + factory = 0 + received = (New-Object System.Collections.Generic.List[int]) + } + $null = Invoke-CaptureLoop ` + -CaptureFactory { $state.factory++; [pscustomobject]@{ Id = $state.factory } }.GetNewClosure() ` + -PreviewHandler { + param($cap) + $state.received.Add($cap.Id) | Out-Null + $state.received.Count -lt 3 + }.GetNewClosure() + ShouldBe $state.received.Count 3 + ShouldBe $state.received[0] 1 + ShouldBe $state.received[1] 2 + ShouldBe $state.received[2] 3 +} + +Describe 'Full-screen and window capture New-snip paths (RAN-14 regression)' +# Structural guard: the pure Invoke-CaptureLoop tests above cover the contract, +# but the actual bug was at the call sites (Invoke-FullScreenCapture and +# Invoke-WindowCapture grabbed one bitmap outside the loop and passed that same +# disposed reference back into Show-PreviewWindow on iteration 2+). Inspect the +# source directly so this pattern cannot silently return. +$script:SnipITSource = Get-Content -Raw (Join-Path $PSScriptRoot 'SnipIT.ps1') +function Get-FunctionBody { + param([string]$Name) + $pattern = "(?ms)^function\s+$([regex]::Escape($Name))\s*\{(.*?)^\}" + $m = [regex]::Match($script:SnipITSource, $pattern) + if (-not $m.Success) { throw "Function '$Name' not found in SnipIT.ps1" } + return $m.Groups[1].Value +} +foreach ($fn in 'Invoke-FullScreenCapture', 'Invoke-WindowCapture') { + It "$fn routes New-snip through Invoke-CaptureLoop" { + $body = Get-FunctionBody -Name $fn + ShouldBeTrue ($body -match '\bInvoke-CaptureLoop\b') + } + It "$fn does not grab a bitmap outside the preview loop" { + # The RAN-14 bug pattern: `$bmp = New-ScreenBitmap ...` at the top of + # the function, followed by a do/while that reuses `$bmp` across + # iterations. The fix moves the New-ScreenBitmap call inside a + # per-iteration factory scriptblock, so the only New-ScreenBitmap + # occurrence in the body must sit inside a `{ ... }` block. + $body = Get-FunctionBody -Name $fn + # Iteratively strip innermost braces until none are left so nested + # scriptblocks (try/finally inside $factory = { ... }) collapse. + do { + $prev = $body + $body = [regex]::Replace($body, '(?s)\{[^{}]*\}', '') + } while ($body -ne $prev) + ShouldBeFalse ($body -match 'New-ScreenBitmap') + } + It ('{0} does not reuse $bmp across a do/while iteration' -f $fn) { + $body = Get-FunctionBody -Name $fn + ShouldBeFalse ($body -match '(?ms)\}\s*while\s*\(\s*\$again\s*\)') + } +} + Write-Host "" $total = $script:Pass + $script:Fail $color = if ($script:Fail -eq 0) { 'Green' } else { 'Red' }