# Photograph an application's own window at a size the Microsoft Store accepts.
# Generated by ship (8001e560); edit internal/generate/templates and run ship sync.
#
# Screenshots were once listed as something no script could do, and that was an
# assumption rather than a measurement. What a script cannot do is decide which
# document to open and whether the result is a good advertisement. What it can
# do is every mechanical part: launch the application, size the window so the
# visible frame is exactly the size asked for, bring it to the front, drive it
# into the state the shot is of, park the pointer off the frame, capture, and
# refuse if what came back is the wrong size.
#
# This file knows nothing about the product it photographs. What to launch and
# what to do to the window come from `shots.ps1` beside it, which is the repo's
# own half and is where the documents and the coordinates live.
#
# ...\screenshot.ps1 -Launch shell,C:\path\to\demo.dclx -Process segler-desktop `
# -Out C:\shots\01-window.png
# ...\screenshot.ps1 -Launch exe,C:\bin\xodt.exe,C:\docs\report.odt -Process xodt `
# -Out C:\shots\xodt-01.png -Do 'click 423,40'
#
# `-Launch` is how the application starts, and its first element names which of
# three ways:
#
# shell,<path> open the path through its association
# exe,<path>[,<arg>...] run an executable
# package,<aumid>[,<arg>...] run an installed package
#
# `shell` is what a person's machine does, and is the wrong one where an office
# suite would win the association. `package` reaches the packaged build by its
# `<PackageFamilyName>!<ApplicationId>`, which is the only way in to one: a
# package declares no execution alias, and `WindowsApps` refuses `Start-Process`
# with *Access is denied* even to its owner.
#
# A verb rather than a guess from the shape of the first element. A typo in an
# executable's path would otherwise be launched as a document, or looked for as
# an executable that is not there, and both answers are about the wrong thing.
#
# FOUR ACTIONS, IN THE ORDER GIVEN
#
# `-Do` takes them as one ordered list of strings, each a verb and its argument:
#
# -Do 'click 423,40','double 1020,384','key ctrl+a','type -0:38'
#
# `click X,Y` presses a control. `double X,Y` presses it twice inside the
# system's double-click interval, which is how a block of a document opens for
# typing. `type TEXT` types. `key NAME` sends one key, optionally with
# modifiers: `key ctrl+a`, `key return`, `key ctrl+plus`. X and Y are measured
# from the frame's top-left corner on a shot of the same size, so a coordinate
# read off an earlier shot is the coordinate to give.
#
# One parameter holding an ordered list, rather than the four repeatable flags
# `packaging/macos/screenshot.sh` has: PowerShell binds a parameter once, so a
# second `-Click` is an error rather than a second value, and four separate
# parameters could not say which of them came first.
#
# They exist because a listing wants more than a document at rest: a store
# reviewer reads a frame of an application with nothing selected as a frame of
# a viewer, and driving the window is the only way to get one that is not.
#
# WHAT IT REFUSES ON, AND WHY EACH ONE IS HERE
#
# **It checks the window actually reached the foreground.** `SetForegroundWindow`
# is advisory: Windows refuses it from a process that does not own the
# foreground, and it returns false rather than raising. The capture is
# `CopyFromScreen` over the window's rectangle, so a window that stayed behind
# is photographed as whatever is on top of it. That is not a hypothetical -
# Segler's `03-picture.png` of 2026-09-05 went to the store folder as a picture
# of a terminal, an Explorer ribbon and a sliver of the application, and it got
# there because the script called `SetForegroundWindow` and did not look. It
# sends the ALT tap that releases the foreground lock, retries, and refuses if
# the window is still not in front.
#
# **It waits for a window rather than for a number of seconds, and then waits
# for that window to be the one.** A cold start under a loaded runner takes
# longer than a cold start on a desk, and a fixed sleep is either wasted or
# short. But the first handle a process reports is not always the window a
# person sees: Segler's first cold launch on a runner reported one, this script
# sized and drove it, and the picture came back a third desktop wallpaper with
# the real window offset inside it and the correction never typed. So the
# handle has to be visible, titled, and the same on two reads before anything
# is done to it - and it is read again before the shutter, because actions sent
# to a window that is no longer the one is a photograph of the wrong thing
# reported as a success.
#
# **And it polls the geometry until it stops moving.** A cold start of the
# packaged application is still positioning itself seconds in, and a rect read
# during that produced a window sitting below where it was asked to be with the
# desktop showing along two edges. Two consecutive equal reads, or a refusal.
#
# TWO THINGS MEASURED RATHER THAN ASSUMED, BOTH OF THEM PIXELS
#
# `SetWindowPos` sizes the *window rect*, which carries an invisible resize
# border outside the visible frame: asking for 1366x768 gave a frame of
# 1352x761, which is under the Store's minimum. The visible frame is
# `DWMWA_EXTENDED_FRAME_BOUNDS`, and the difference measured is 14 by 7.
#
# And that frame's top edge is one pixel above what is actually drawn, so a
# capture at exactly the frame rect picks up a sliver of whatever is behind. It
# arrived as a strip of console text across the top of slipcase-desktop's first
# two attempts. The capture is two rows taller than needed and the top two are
# cropped.
#
# Author: David M. Anderson
# Built with AI assistance (Claude, Anthropic)
[CmdletBinding()]
param(
# How the application starts: a verb and its arguments. The header has the
# three.
[Parameter(Mandatory = $true)][string[]] $Launch,
# The processes to stop first, so the window in the frame is this run's and
# not a previous one's. The first is the one the window belongs to. No .exe.
[Parameter(Mandatory = $true)][string[]] $Process,
[Parameter(Mandatory = $true)][string] $Out,
# The Store's minimum for a desktop screenshot, and the default because a
# window this size looks like a window rather than like an advertisement.
[int] $Width = 1366,
[int] $Height = 768,
# Where to put the window. Anywhere it fits entirely on screen.
[int] $X = 200,
[int] $Y = 100,
# Seconds to wait for a window to appear before giving up.
[int] $Appear = 60,
# Seconds to wait after the window is sized before capturing. The default is
# enough for a document of a few pages; a large archive decoding its page
# images wants more.
[int] $Settle = 3,
# What to do to the window before the shutter, in the order given. The
# header lists the four verbs.
[string[]] $Do = @(),
# Photograph the window in this language. A listing in two languages needs
# a set in each, and a German listing showing an English window is a German
# listing of somebody else's application.
#
# It is set as POTEXT_LANG, which every application in this fleet reads
# before it asks the platform, and `Start-Process` hands this process's
# environment to what it starts.
#
# **The packaged launch is not known to carry it.** `-Launch package` goes
# through the shell's activation manager rather than starting a child here,
# and whether the variable survives that has not been measured. Until it
# has, a set in a second language is taken of the release build, which is
# what these frames are of anyway because a Store package cannot be
# launched off the Store.
[string] $Lang = ''
)
$ErrorActionPreference = 'Stop'
function Refuse([string] $message) { Write-Error "screenshot.ps1: $message" }
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
Add-Type -Namespace Shot -Name Win -MemberDefinition @'
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("dwmapi.dll")] public static extern int DwmGetWindowAttribute(IntPtr h, int attr, out RECT r, int size);
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern uint GetDoubleClickTime();
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
public static string TitleOf(IntPtr h) {
System.Text.StringBuilder title = new System.Text.StringBuilder(512);
GetWindowText(h, title, title.Capacity);
return title.ToString();
}
public struct RECT { public int Left, Top, Right, Bottom; }
// What has the foreground, said in full. A refusal that only knows the window
// is not ours sends the next person guessing, and the guesses are all
// plausible: the pointer parked on a hot corner, a shell window raised behind,
// an installer the image runs on a timer. The answer is one call away.
public static string WhatIsInFront() {
IntPtr h = GetForegroundWindow();
if (h == IntPtr.Zero) { return "nothing"; }
System.Text.StringBuilder title = new System.Text.StringBuilder(512);
GetWindowText(h, title, title.Capacity);
uint pid;
GetWindowThreadProcessId(h, out pid);
string name;
try { name = System.Diagnostics.Process.GetProcessById((int)pid).ProcessName; }
catch { name = "pid " + pid; }
return name + " \"" + title.ToString() + "\"";
}
// Typing goes in as Unicode on a synthetic key event rather than as a key
// code, so a line with punctuation in it needs no layout table and a machine
// on a German keyboard types what it was given. `keybd_event` cannot do this:
// it carries a virtual key, and a virtual key is a position on the current
// layout rather than a character.
//
// The union is declared as a union rather than as a keyboard-shaped struct
// with the difference padded out, and the size is asked for rather than
// written down. `SendInput` refuses any `cbSize` that is not its own, and the
// number is 40 on x64 and 28 on x86; a constant here is a constant that is
// wrong on one of them and silently does nothing, since the call reports how
// many events it sent and nobody was reading that either.
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT { public int dx, dy; public uint mouseData, dwFlags, time; public IntPtr extra; }
[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT { public ushort wVk, wScan; public uint dwFlags, time; public IntPtr extra; }
[StructLayout(LayoutKind.Explicit)]
public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }
[StructLayout(LayoutKind.Sequential)]
public struct INPUT { public uint type; public INPUTUNION u; }
[DllImport("user32.dll", SetLastError = true)] static extern uint SendInput(uint n, INPUT[] inputs, int size);
// A surrogate pair is two code units and goes in as two events, which is what
// iterating the string rather than the characters gives for free.
public static void TypeText(string text) {
int size = Marshal.SizeOf(typeof(INPUT));
foreach (char unit in text) {
INPUT[] pair = new INPUT[2];
for (int i = 0; i < 2; i++) {
pair[i].type = 1; // INPUT_KEYBOARD
pair[i].u.ki.wScan = unit;
pair[i].u.ki.dwFlags = (uint)(0x0004 | (i == 1 ? 0x0002 : 0)); // UNICODE | KEYUP
}
if (SendInput(2, pair, size) != 2) {
throw new Exception("SendInput would not send '" + unit + "'");
}
// The window redraws between characters, and faster than this drops
// them the way the Linux lane's xdotool does.
System.Threading.Thread.Sleep(40);
}
}
'@
# The visible frame, which is what everything below is measured against.
function Get-Frame([IntPtr] $h) {
$r = New-Object Shot.Win+RECT
[void][Shot.Win]::DwmGetWindowAttribute($h, 9, [ref]$r, 16)
return $r
}
# --- starting it ------------------------------------------------------------
Get-Process $Process -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 1
# The window belongs to the first named process; the rest are stopped because
# they would hold the document open or claim the window.
$window = $Process[0]
if ($Lang) {
$env:POTEXT_LANG = $Lang
Write-Host " language $Lang"
}
$verb = $Launch[0]
# Set by the exe branch only: the shell and a package hand the work to a
# launcher that exits at once, and its exit code says nothing about the
# application.
$started = $null
$launched = $null
$rest = @()
if ($Launch.Count -gt 1) { $rest = $Launch[1..($Launch.Count - 1)] }
switch ($verb) {
'shell' {
if ($rest.Count -ne 1) { Refuse "-Launch shell wants one path and was given $($rest.Count)" }
if (-not (Test-Path -LiteralPath $rest[0])) { Refuse "nothing at $($rest[0])" }
Start-Process (Resolve-Path -LiteralPath $rest[0]).Path
}
'exe' {
if ($rest.Count -lt 1) { Refuse '-Launch exe wants an executable' }
if (-not (Test-Path -LiteralPath $rest[0])) { Refuse "no executable at $($rest[0])" }
$exe = (Resolve-Path -LiteralPath $rest[0]).Path
# Kept, and its output with it. An executable that starts and stops
# leaves nothing behind otherwise, and the wait below then spends its
# whole minute on a process that was gone in the first second.
# `Start-Process` discards both streams unless asked.
$launched = Join-Path ([System.IO.Path]::GetTempPath()) "screenshot-launch-$PID"
$started = if ($rest.Count -gt 1) {
Start-Process $exe -ArgumentList $rest[1..($rest.Count - 1)] -PassThru `
-RedirectStandardError "$launched.err" -RedirectStandardOutput "$launched.out"
} else {
Start-Process $exe -PassThru `
-RedirectStandardError "$launched.err" -RedirectStandardOutput "$launched.out"
}
}
'package' {
if ($rest.Count -lt 1) { Refuse '-Launch package wants a PackageFamilyName!ApplicationId' }
# Quoted one argument at a time: a document path with a space in it
# arrives at the package as two arguments otherwise, and the package
# opens neither.
$target = "shell:appsFolder\$($rest[0])"
if ($rest.Count -gt 1) {
$quoted = @($rest[1..($rest.Count - 1)] | ForEach-Object { '"' + $_ + '"' })
Start-Process $target -ArgumentList $quoted
} else {
Start-Process $target
}
}
default {
Refuse "no launch called '$verb' - the three are shell, exe and package, each with its arguments after it, as in 'shell,C:\path\to\demo.dclx'"
}
}
# Polled rather than slept through. A fixed wait is either wasted time on a
# desk or too short on a loaded runner, and the second reads as "nothing opened
# it" when the truth is that it had not opened it yet.
#
# What counts as the window: visible, titled, and the same handle twice running.
# A handle that is none of those is a window on its way to being one.
function Get-Window {
$app = Get-Process $window -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $app) { return [IntPtr]::Zero }
$app.Refresh()
$h = $app.MainWindowHandle
if ($h -eq [IntPtr]::Zero) { return [IntPtr]::Zero }
if (-not [Shot.Win]::IsWindowVisible($h)) { return [IntPtr]::Zero }
if ([Shot.Win]::TitleOf($h) -eq '') { return [IntPtr]::Zero }
return $h
}
# What a launched executable left behind when it did not stay. Said in full:
# an exit code alone rarely names the reason and the streams usually do.
function SaidOnTheWayOut {
$said = @()
foreach ($stream in 'err', 'out') {
$file = "$launched.$stream"
if (Test-Path -LiteralPath $file) {
$text = (Get-Content -LiteralPath $file -Raw -ErrorAction SilentlyContinue)
if ($text) { $said += $text.Trim() }
}
}
if ($said.Count -eq 0) { return 'and said nothing on either stream' }
return "and said: $($said -join ' / ')"
}
$handle = [IntPtr]::Zero
$seen = [IntPtr]::Zero
$waited = 0
while ($waited -lt $Appear) {
# An executable that has exited will not grow a window, so the wait ends
# here rather than a minute later with nothing to show for it.
if ($null -ne $started -and $started.HasExited) {
Refuse "$($Launch[1]) exited $($started.ExitCode) after $waited second(s) without opening a window, $(SaidOnTheWayOut)"
}
$now = Get-Window
if ($now -ne [IntPtr]::Zero -and $now -eq $seen) { $handle = $now; break }
$seen = $now
Start-Sleep -Milliseconds 700
$waited += 0.7
}
if ($handle -eq [IntPtr]::Zero) {
# Which of the three things went wrong, said rather than left to be
# guessed: nothing started, something started and stopped, or it is running
# and has no window this script will accept.
$running = @(Get-Process $window -ErrorAction SilentlyContinue)
if ($running.Count -eq 0) {
$others = @(Get-Process -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowHandle -ne [IntPtr]::Zero } |
ForEach-Object { $_.ProcessName } | Sort-Object -Unique)
$seenNames = if ($others) { $others -join ', ' } else { 'none' }
if ($verb -eq 'shell') {
Refuse "no $window process after $Appear seconds - the shell opened $($rest[0]) with something else, or with nothing. Windows on screen belong to: $seenNames"
}
Refuse "no $window process after $Appear seconds - it started and stopped, or never started. Windows on screen belong to: $seenNames"
}
$p = $running[0]
$p.Refresh()
$h = $p.MainWindowHandle
$what = if ($h -eq [IntPtr]::Zero) {
'it reports no main window'
} elseif (-not [Shot.Win]::IsWindowVisible($h)) {
'its window is not visible'
} elseif ([Shot.Win]::TitleOf($h) -eq '') {
'its window has no title'
} else {
'its window never stopped changing'
}
Refuse "$window is running after $Appear seconds but $what, so there is nothing to photograph"
}
Write-Host " $window had a window after $waited second(s): $([Shot.Win]::TitleOf($handle))"
# One more look before anything is done to it. Nothing has been sized or driven
# yet, so a window replaced in this gap is simply the newer one; after the
# actions it would be a refusal instead.
Start-Sleep -Milliseconds 800
$again = Get-Window
if ($again -ne [IntPtr]::Zero -and $again -ne $handle) {
Write-Host ' it was replaced by another before anything was done to it'
$handle = $again
}
# --- placing it -------------------------------------------------------------
# The border measured on this platform, and the two spare rows for the crop.
$BORDER_W = 14
$BORDER_H = 7
$TOPMOST = [IntPtr](-1)
$NOTOPMOST = [IntPtr](-2)
[void][Shot.Win]::SetWindowPos(
$handle, $TOPMOST, $X, $Y, $Width + $BORDER_W, $Height + $BORDER_H + 2, 0)
# Bring it to the front and then look, rather than ask and assume. A tap of ALT
# releases the foreground lock that stops a background process raising a window;
# without it `SetForegroundWindow` returns false and the window stays where it
# was, which is how a picture of a terminal reached the store folder.
function Take-Foreground([int] $tries = 10) {
foreach ($try in 1..$tries) {
if ([Shot.Win]::GetForegroundWindow() -eq $handle) { return $true }
[Shot.Win]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[Shot.Win]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
[void][Shot.Win]::SetForegroundWindow($handle)
Start-Sleep -Milliseconds 400
}
return ([Shot.Win]::GetForegroundWindow() -eq $handle)
}
if (-not (Take-Foreground)) {
Refuse "the window would not come to the foreground - a capture now would photograph whatever is on top of it; $([Shot.Win]::WhatIsInFront()) has it"
}
# And wait for it to stop moving. Two consecutive equal reads of the frame, or
# a refusal: a rect read while a cold start is still positioning itself is a
# rect the capture will not find the window at.
$previous = $null
$still = $false
foreach ($try in 1..40) {
$now = Get-Frame $handle
if ($null -ne $previous -and
$now.Left -eq $previous.Left -and $now.Top -eq $previous.Top -and
$now.Right -eq $previous.Right -and $now.Bottom -eq $previous.Bottom) {
$still = $true
break
}
$previous = $now
Start-Sleep -Milliseconds 250
}
if (-not $still) { Refuse 'the window never stopped moving' }
# --- driving it -------------------------------------------------------------
# The keys `key` knows by name. Anything else is a single character, which is
# its own virtual key on a US layout; a name is how the ones that are not reach
# this. Windows spells the document-wide modifier ctrl, so `cmd` is refused by
# name rather than quietly translated - a recipe carried over from the Mac
# should be read and converted, not assumed to mean the same thing.
$KEYS = @{
'return' = 0x0D; 'enter' = 0x0D; 'tab' = 0x09; 'escape' = 0x1B; 'esc' = 0x1B
'space' = 0x20; 'backspace' = 0x08; 'delete' = 0x2E; 'home' = 0x24; 'end' = 0x23
'left' = 0x25; 'up' = 0x26; 'right' = 0x27; 'down' = 0x28
'pageup' = 0x21; 'pagedown' = 0x22
# The zoom pair, by name because the characters cannot be given: `+` is not
# a virtual key, `ctrl++` splits into an empty key, and the key that carries
# both is OEM_PLUS. `key ctrl+plus` four times is about 140%, which is what
# a Store listing wants - the Store renders screenshots small, and egui's
# default scale puts this fleet's text at around ten pixels in a thumbnail.
# It is egui's own shortcut, so it does nothing a person could not do, and
# eframe's persistence is off across the fleet so the next launch is at 100%.
'plus' = 0xBB; 'minus' = 0xBD
}
$MODIFIERS = @{ 'ctrl' = 0x11; 'control' = 0x11; 'alt' = 0x12; 'shift' = 0x10; 'win' = 0x5B }
$KEYUP = 2
function Send-Key([string] $name) {
$parts = $name.ToLower() -split '\+'
$key = $parts[-1]
# Sliced with Select-Object rather than with a range: `0..-1` in PowerShell
# counts down and yields 0 then -1, so an unmodified key would be read as
# its own modifier twice over.
$mods = @()
foreach ($m in @($parts | Select-Object -SkipLast 1)) {
if ($m -eq 'cmd' -or $m -eq 'command') {
Refuse "key '$name': Windows has no cmd - the modifier here is ctrl, and a recipe carried over from the Mac wants reading rather than translating"
}
if (-not $MODIFIERS.ContainsKey($m)) { Refuse "key '$name': no modifier called '$m'" }
$mods += $MODIFIERS[$m]
}
if ($KEYS.ContainsKey($key)) {
$vk = $KEYS[$key]
} elseif ($key.Length -eq 1) {
$vk = [int][char] $key.ToUpper()
} else {
Refuse "key '$name': no key called '$key', and it is not a single character"
}
foreach ($m in $mods) { [Shot.Win]::keybd_event($m, 0, 0, [UIntPtr]::Zero) }
[Shot.Win]::keybd_event($vk, 0, 0, [UIntPtr]::Zero)
[Shot.Win]::keybd_event($vk, 0, $KEYUP, [UIntPtr]::Zero)
# Released in reverse, and always: a modifier left down is read by every
# keystroke after it, so one stuck ctrl turns the text that follows into a
# run of shortcuts that type nothing and may do something else entirely.
[array]::Reverse($mods)
foreach ($m in $mods) { [Shot.Win]::keybd_event($m, 0, $KEYUP, [UIntPtr]::Zero) }
}
# A press is a move, then a down and an up a moment apart, which is what egui
# reads as a click. A double press is that twice, with the gap between the two
# comfortably inside the system's double-click interval - that interval is what
# winit measures to tell a second click from a first, and below it a block of a
# document opens for typing rather than merely being selected again.
$interval = [Shot.Win]::GetDoubleClickTime()
$MOUSEDOWN = 0x02
$MOUSEUP = 0x04
function Press-At([int] $x, [int] $y, [int] $times) {
$frame = Get-Frame $handle
[void][Shot.Win]::SetCursorPos(($frame.Left + $x), ($frame.Top + 2 + $y))
Start-Sleep -Milliseconds 300
for ($n = 1; $n -le $times; $n++) {
[Shot.Win]::mouse_event($MOUSEDOWN, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[Shot.Win]::mouse_event($MOUSEUP, 0, 0, 0, [UIntPtr]::Zero)
if ($n -lt $times) { Start-Sleep -Milliseconds ([int] ($interval / 4)) }
}
Start-Sleep -Milliseconds 700
}
function Get-Point([string] $verb, [string] $argument) {
$n = @($argument -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
if ($n.Count -ne 2) { Refuse "$verb wants X,Y and was given '$argument'" }
foreach ($v in $n) {
if ($v -notmatch '^-?\d+$') { Refuse "$verb wants two whole numbers and was given '$argument'" }
}
return @([int] $n[0], [int] $n[1])
}
# The actions, in the coordinates of the picture this writes. The window is in
# front and settled by now, which matters: the first click on an inactive window
# activates it and is swallowed, so a click sent any earlier would land nowhere
# and leave a frame that shows none of what it was for.
foreach ($action in $Do) {
$verb, $argument = $action -split ' ', 2
switch ($verb) {
'click' {
$p = Get-Point 'click' $argument
Press-At $p[0] $p[1] 1
Write-Host " clicked $($p[0]),$($p[1])"
}
'double' {
$p = Get-Point 'double' $argument
Press-At $p[0] $p[1] 2
Write-Host " double-clicked $($p[0]),$($p[1])"
}
'type' {
if ($null -eq $argument) { Refuse 'type wants text' }
[Shot.Win]::TypeText($argument)
Start-Sleep -Milliseconds 300
Write-Host " typed $argument"
}
'key' {
if ($null -eq $argument) { Refuse 'key wants a name' }
Send-Key $argument
Start-Sleep -Milliseconds 400
Write-Host " key $argument"
}
default {
Refuse "no action called '$verb' - the four are click, double, type and key, each with its argument after a space, as in 'click 423,40'"
}
}
}
# --- photographing it -------------------------------------------------------
# The pointer goes somewhere the window is not, because egui draws hover state
# and the capture keeps it. Measured on slipcase-desktop 2026-08-29: a retake
# landed with the mouse resting over a field, which came out highlighted and
# focus-ringed in a picture meant to show the application at rest, and with the
# scroll bar drawn because the pointer was inside the scroll area. Neither is
# wrong and both are noise a shopper reads as an interface doing something.
#
# Bottom right of the virtual screen rather than a constant: the window is
# placed near the top left, and a fixed 1900x1200 is off-screen on a smaller
# display, where Windows clamps it to an edge the window might occupy.
$away = [System.Windows.Forms.SystemInformation]::VirtualScreen
[System.Windows.Forms.Cursor]::Position =
New-Object System.Drawing.Point(($away.Right - 2), ($away.Bottom - 2))
# Long enough for the window to settle and repaint at its new size. egui draws
# on demand, and a capture taken during the resize catches a half-laid-out frame.
Start-Sleep -Seconds $Settle
# Losing the foreground between the last action and the shutter is taken back
# rather than refused on. A machine that runs its own errands - a shell window
# raised behind, a scheduled task, an image's own housekeeping - takes it for a
# moment and gives it up again, and a set of four that dies on the first is
# worse than a set that says what interrupted it. The guarantee is unchanged:
# the capture happens only with the window verified in front.
if ([Shot.Win]::GetForegroundWindow() -ne $handle) {
$thief = [Shot.Win]::WhatIsInFront()
if (-not (Take-Foreground)) {
Refuse "the window lost the foreground between settling and the capture; $thief has it"
}
Write-Host " $thief took the foreground and it was taken back"
}
# The window is read once more, because everything above was done to a handle
# and the capture is of a rectangle. An application that replaced its window
# between the first click and here has taken the actions with it, and the
# rectangle now holds whatever is at those coordinates - which came back once
# as a third of the desktop wallpaper with the real window offset inside it,
# and was reported as a success.
$current = Get-Window
if ($current -ne [IntPtr]::Zero -and $current -ne $handle) {
Refuse "the window was replaced between the actions and the capture: $([Shot.Win]::TitleOf($handle)) became $([Shot.Win]::TitleOf($current)), and what was driven is not what would be photographed"
}
$rect = Get-Frame $handle
$frameW = $rect.Right - $rect.Left
$frameH = $rect.Bottom - $rect.Top
if ($frameW -lt $Width -or $frameH -lt $Height + 2) {
Refuse "the visible frame came back ${frameW}x${frameH}, which cannot yield ${Width}x${Height} - either the desktop is too small to hold a window this size, or the resize border is not what this script measured"
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Out) | Out-Null
$full = New-Object System.Drawing.Bitmap($frameW, $frameH)
$graphics = [System.Drawing.Graphics]::FromImage($full)
$graphics.CopyFromScreen(
$rect.Left, $rect.Top, 0, 0, (New-Object System.Drawing.Size($frameW, $frameH)))
$graphics.Dispose()
$shot = $full.Clone(
(New-Object System.Drawing.Rectangle(0, 2, $Width, $Height)), $full.PixelFormat)
$shot.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
$shot.Dispose()
$full.Dispose()
[void][Shot.Win]::SetWindowPos($handle, $NOTOPMOST, 0, 0, 0, 0, 0x0003)
# Read back rather than trusted. A screenshot of the wrong size is refused at
# upload, and this is the one property of it a machine can check.
$written = [System.Drawing.Image]::FromFile((Resolve-Path $Out).Path)
$got = "$($written.Width)x$($written.Height)"
$written.Dispose()
if ($got -ne "${Width}x${Height}") {
Refuse "wrote $got and the Store was asked for ${Width}x${Height}"
}
Write-Host "wrote $Out - $got"
Write-Host 'look at it before it goes anywhere: a correct size is not a good screenshot'