Reorganize software catalog

This commit is contained in:
2026-08-25 14:55:38 +03:00
parent 31f83db3a5
commit a51120a5b4
457 changed files with 4905 additions and 1215 deletions
+92
View File
@@ -0,0 +1,92 @@
param(
[Parameter(Mandatory = $true)]
[string]$RuntimeDirectory,
[switch]$EnableNetFx3,
[string]$NirCmdArchive = "",
[string]$ScreenshotDirectory = "C:\codex\catalog-screenshots",
[switch]$Elevated
)
$ErrorActionPreference = "Stop"
if (-not $Elevated) {
$taskName = "CodexScreenshotDependencies"
$taskArguments = "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`" -RuntimeDirectory `"$RuntimeDirectory`""
if ($EnableNetFx3) {
$taskArguments += " -EnableNetFx3"
}
if ($NirCmdArchive.Length -gt 0) {
$taskArguments += " -NirCmdArchive `"$NirCmdArchive`" -ScreenshotDirectory `"$ScreenshotDirectory`""
}
$taskArguments += " -Elevated"
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $taskArguments
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -AllowStartIfOnBatteries
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
try {
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddMinutes(30)
do {
Start-Sleep -Seconds 1
$task = Get-ScheduledTask -TaskName $taskName
} while ($task.State -eq "Running" -and (Get-Date) -lt $deadline)
if ($task.State -eq "Running") {
throw "Dependency installation timed out."
}
$result = (Get-ScheduledTaskInfo -TaskName $taskName).LastTaskResult
if ($result -ne 0) {
throw "Dependency installation failed with task result $result."
}
}
finally {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
exit 0
}
$systemDirectory = if (Test-Path "$env:WINDIR\SysWOW64") {
"$env:WINDIR\SysWOW64"
} else {
"$env:WINDIR\System32"
}
$vbRuntime = Get-ChildItem -LiteralPath $RuntimeDirectory -Recurse -File -Filter "msvbvm50.dll" | Select-Object -First 1
if ($null -ne $vbRuntime) {
Copy-Item -LiteralPath $vbRuntime.FullName -Destination $systemDirectory -Force
}
foreach ($control in Get-ChildItem -LiteralPath $RuntimeDirectory -Recurse -File -Filter "*.ocx") {
$destination = Join-Path $systemDirectory $control.Name
Copy-Item -LiteralPath $control.FullName -Destination $destination -Force
$registration = Start-Process -FilePath (Join-Path $systemDirectory "regsvr32.exe") -ArgumentList "/s", $destination -Wait -PassThru
if ($registration.ExitCode -ne 0) {
throw "Failed to register $($control.Name): regsvr32 exit code $($registration.ExitCode)"
}
}
if ($EnableNetFx3) {
Enable-WindowsOptionalFeature -Online -FeatureName NetFx3 -All -NoRestart | Out-Null
}
if ($NirCmdArchive.Length -gt 0) {
$temporaryDirectory = Join-Path $env:TEMP ("codex-nircmd-" + [guid]::NewGuid().ToString("N"))
try {
Expand-Archive -LiteralPath $NirCmdArchive -DestinationPath $temporaryDirectory -Force
$nirCmd = Get-ChildItem -LiteralPath $temporaryDirectory -Recurse -File -Filter "nircmd.exe" | Select-Object -First 1
if ($null -eq $nirCmd) {
throw "nircmd.exe was not found in $NirCmdArchive"
}
New-Item -ItemType Directory -Force -Path $ScreenshotDirectory | Out-Null
Copy-Item -LiteralPath $nirCmd.FullName -Destination (Join-Path $ScreenshotDirectory "nircmd.exe") -Force
}
finally {
Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
}
+75
View File
@@ -0,0 +1,75 @@
param(
[Parameter(Mandatory = $true)]
[string]$Executable,
[Parameter(Mandatory = $true)]
[string]$Name,
[string]$OutputDirectory = "C:\codex\screenshots",
[string]$Arguments = "",
[string]$WorkingDirectory = "",
[string]$InitialKeys = "",
[string]$ScreenshotTool = "",
[ValidateRange(1, 60)]
[int]$WaitSeconds = 8
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$taskName = "CodexScreenshot"
$screenshotScript = Join-Path $PSScriptRoot "screenshotApp.ps1"
$resultPath = Join-Path $OutputDirectory "$Name.json"
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
Remove-Item -Force -ErrorAction SilentlyContinue $resultPath, (Join-Path $OutputDirectory "$Name.png")
$taskArguments = @(
"-NoProfile"
"-ExecutionPolicy Bypass"
"-File `"$screenshotScript`""
"-Executable `"$Executable`""
"-Name `"$Name`""
"-OutputDirectory `"$OutputDirectory`""
"-WaitSeconds $WaitSeconds"
)
if ($Arguments.Length -gt 0) {
$escapedArguments = $Arguments.Replace('"', '\"')
$taskArguments += "-Arguments `"$escapedArguments`""
}
if ($WorkingDirectory.Length -gt 0) {
$taskArguments += "-WorkingDirectory `"$WorkingDirectory`""
}
if ($InitialKeys.Length -gt 0) {
$taskArguments += "-InitialKeys `"$InitialKeys`""
}
if ($ScreenshotTool.Length -gt 0) {
$taskArguments += "-ScreenshotTool `"$ScreenshotTool`""
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument ($taskArguments -join " ")
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -AllowStartIfOnBatteries
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Force | Out-Null
try {
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds($WaitSeconds + 30)
while (-not (Test-Path $resultPath) -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 500
}
if (-not (Test-Path $resultPath)) {
throw "Screenshot task timed out."
}
Get-Content -Raw -Encoding UTF8 $resultPath
}
finally {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
+534
View File
@@ -0,0 +1,534 @@
param(
[Parameter(Mandatory = $true)]
[string]$Executable,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[string]$Arguments = "",
[string]$WorkingDirectory = "",
[string]$InitialKeys = "",
[string]$ScreenshotTool = "",
[ValidateRange(1, 60)]
[int]$WaitSeconds = 8
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
Add-Type -AssemblyName System.Drawing
Add-Type -AssemblyName System.Windows.Forms
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public static class ScreenshotWindowApi {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct Rect {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[DllImport("user32.dll")]
public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
[DllImport("user32.dll")]
public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr hWnd, out Rect rect);
[DllImport("dwmapi.dll")]
public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attribute, out Rect value, int size);
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool IsIconic(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool IsZoomed(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")]
public static extern bool SetWindowPos(IntPtr hWnd, IntPtr insertAfter, int x, int y, int width, int height, uint flags);
[DllImport("user32.dll")]
public static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
}
"@
function Close-StaleWindows {
[ScreenshotWindowApi]::EnumWindows({
param($handle, $parameter)
if ([ScreenshotWindowApi]::IsWindowVisible($handle)) {
$title = [System.Text.StringBuilder]::new(1024)
[void][ScreenshotWindowApi]::GetWindowText($handle, $title, $title.Capacity)
if ($title.ToString() -match '^slged\.exe\s+-') {
[void][ScreenshotWindowApi]::PostMessage($handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
}
}
return $true
}, [IntPtr]::Zero) | Out-Null
}
function Get-ProcessTreeIds {
param([int]$RootProcessId)
$ids = [System.Collections.Generic.HashSet[int]]::new()
[void]$ids.Add($RootProcessId)
do {
$changed = $false
foreach ($process in Get-CimInstance Win32_Process) {
if ($ids.Contains([int]$process.ParentProcessId) -and $ids.Add([int]$process.ProcessId)) {
$changed = $true
}
}
} while ($changed)
return ,$ids
}
function Get-ProcessIds {
$ids = [System.Collections.Generic.HashSet[int]]::new()
foreach ($item in Get-Process -ErrorAction SilentlyContinue) {
[void]$ids.Add([int]$item.Id)
}
return ,$ids
}
function Stop-NewProcesses {
param([System.Collections.Generic.HashSet[int]]$BaselineProcessIds)
$currentSessionId = (Get-Process -Id $PID).SessionId
$protectedNames = @(
"csrss", "dwm", "explorer", "fontdrvhost", "powershell", "sihost",
"StartMenuExperienceHost", "taskhostw", "TextInputHost", "winlogon"
)
foreach ($item in Get-Process -ErrorAction SilentlyContinue) {
if (
$item.Id -ne $PID -and
$item.SessionId -eq $currentSessionId -and
-not $BaselineProcessIds.Contains([int]$item.Id) -and
$protectedNames -notcontains $item.ProcessName
) {
taskkill.exe /PID $item.Id /T /F 2>$null | Out-Null
}
}
}
function Stop-StaleCatalogProcesses {
param([string]$ExecutablePath)
$match = [regex]::Match($ExecutablePath, '^(.*\\work)\\', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if (-not $match.Success) {
return
}
$workRoot = $match.Groups[1].Value.TrimEnd('\') + '\'
foreach ($item in Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) {
if ($item.ExecutablePath -and $item.ExecutablePath.StartsWith($workRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
taskkill.exe /PID $item.ProcessId /T /F 2>$null | Out-Null
}
}
}
function Get-ProgramWindows {
param([System.Collections.Generic.HashSet[int]]$ProcessIds)
$windows = [System.Collections.Generic.List[object]]::new()
[ScreenshotWindowApi]::EnumWindows({
param($handle, $parameter)
if (-not [ScreenshotWindowApi]::IsWindowVisible($handle)) {
return $true
}
[uint32]$windowProcessId = 0
[void][ScreenshotWindowApi]::GetWindowThreadProcessId($handle, [ref]$windowProcessId)
if (-not $ProcessIds.Contains([int]$windowProcessId)) {
return $true
}
$title = [System.Text.StringBuilder]::new(1024)
[void][ScreenshotWindowApi]::GetWindowText($handle, $title, $title.Capacity)
$fullRect = [ScreenshotWindowApi+Rect]::new()
if (-not [ScreenshotWindowApi]::GetWindowRect($handle, [ref]$fullRect)) {
return $true
}
$rect = [ScreenshotWindowApi+Rect]::new()
$dwmResult = [ScreenshotWindowApi]::DwmGetWindowAttribute(
$handle,
9,
[ref]$rect,
[System.Runtime.InteropServices.Marshal]::SizeOf([type][ScreenshotWindowApi+Rect])
)
if ($dwmResult -ne 0) {
$rect = $fullRect
}
$width = $rect.Right - $rect.Left
$height = $rect.Bottom - $rect.Top
if ($width -le 1 -or $height -le 1) {
return $true
}
$windows.Add([pscustomobject]@{
Handle = $handle
ProcessId = [int]$windowProcessId
Title = $title.ToString()
Left = $rect.Left
Top = $rect.Top
Width = $width
Height = $height
Area = $width * $height
CropLeft = $rect.Left - $fullRect.Left
CropTop = $rect.Top - $fullRect.Top
})
return $true
}, [IntPtr]::Zero) | Out-Null
return $windows
}
function Crop-WindowShadow {
param(
[string]$Path,
[int]$Left,
[int]$Top,
[int]$Width,
[int]$Height
)
$source = [System.Drawing.Bitmap]::FromFile($Path)
try {
if ($Left -eq 0 -and $Top -eq 0 -and $source.Width -eq $Width -and $source.Height -eq $Height) {
return
}
if ($Left -lt 0 -or $Top -lt 0 -or $Left + $Width -gt $source.Width -or $Top + $Height -gt $source.Height) {
return
}
$cropped = [System.Drawing.Bitmap]::new($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($cropped)
try {
$destination = [System.Drawing.Rectangle]::new(0, 0, $Width, $Height)
$graphics.DrawImage($source, $destination, $Left, $Top, $Width, $Height, [System.Drawing.GraphicsUnit]::Pixel)
$temporaryPath = "$Path.cropped.png"
$cropped.Save($temporaryPath, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$graphics.Dispose()
$cropped.Dispose()
}
}
finally {
$source.Dispose()
}
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
}
function Clear-LowAlphaBorder {
param(
[string]$Path,
[byte]$AlphaThreshold = 51,
[int]$BorderWidth = 16
)
$bitmap = [System.Drawing.Bitmap]::FromFile($Path)
try {
$horizontalBorder = [Math]::Min($BorderWidth, [Math]::Floor($bitmap.Height / 2))
$verticalBorder = [Math]::Min($BorderWidth, [Math]::Floor($bitmap.Width / 2))
$rectangle = [System.Drawing.Rectangle]::new(0, 0, $bitmap.Width, $bitmap.Height)
$bitmapData = $bitmap.LockBits(
$rectangle,
[System.Drawing.Imaging.ImageLockMode]::ReadWrite,
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb
)
try {
$stride = [Math]::Abs($bitmapData.Stride)
$pixels = [byte[]]::new($stride * $bitmap.Height)
[System.Runtime.InteropServices.Marshal]::Copy($bitmapData.Scan0, $pixels, 0, $pixels.Length)
for ($y = 0; $y -lt $bitmap.Height; $y++) {
$isHorizontalBorder = $y -lt $horizontalBorder -or $y -ge $bitmap.Height - $horizontalBorder
for ($x = 0; $x -lt $bitmap.Width; $x++) {
if (-not $isHorizontalBorder -and $x -ge $verticalBorder -and $x -lt $bitmap.Width - $verticalBorder) {
continue
}
$alphaOffset = $y * $stride + $x * 4 + 3
if ($pixels[$alphaOffset] -lt $AlphaThreshold) {
$pixels[$alphaOffset] = 0
}
}
}
[System.Runtime.InteropServices.Marshal]::Copy($pixels, 0, $bitmapData.Scan0, $pixels.Length)
}
finally {
$bitmap.UnlockBits($bitmapData)
}
$temporaryPath = "$Path.alpha.png"
$bitmap.Save($temporaryPath, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$bitmap.Dispose()
}
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
}
function Save-Result {
param([hashtable]$Result)
$Result | ConvertTo-Json -Depth 5 | Set-Content -Encoding UTF8 (Join-Path $OutputDirectory "$Name.json")
}
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$process = $null
$baselineProcessIds = $null
try {
Stop-StaleCatalogProcesses -ExecutablePath $Executable
Close-StaleWindows
$baselineProcessIds = Get-ProcessIds
$startParameters = @{
FilePath = $Executable
WorkingDirectory = $(if ($WorkingDirectory.Length -gt 0) { $WorkingDirectory } else { Split-Path $Executable })
PassThru = $true
}
if ($Arguments.Length -gt 0) {
$startParameters.ArgumentList = $Arguments
}
$process = Start-Process @startParameters
$deadline = (Get-Date).AddSeconds($WaitSeconds)
$windows = @()
do {
Start-Sleep -Milliseconds 500
$processIds = Get-ProcessTreeIds -RootProcessId $process.Id
$windows = @(Get-ProgramWindows -ProcessIds $processIds)
} while ($windows.Count -eq 0 -and (Get-Date) -lt $deadline)
if ($windows.Count -eq 0) {
$reason = if ($process.HasExited) { "process-exited" } else { "no-window" }
Save-Result @{
success = $false
reason = $reason
exitCode = $(if ($process.HasExited) { $process.ExitCode } else { $null })
}
exit 1
}
$settleDeadline = (Get-Date).AddSeconds(3)
do {
Start-Sleep -Milliseconds 500
$processIds = Get-ProcessTreeIds -RootProcessId $process.Id
$settledWindows = @(Get-ProgramWindows -ProcessIds $processIds)
if ($settledWindows.Count -gt 0) {
$windows = $settledWindows
}
} while ((Get-Date) -lt $settleDeadline)
$preferredTitle = ""
if ($InitialKeys.Length -gt 0) {
foreach ($initialKey in $InitialKeys.Split(',')) {
$rawKey = $initialKey.Trim()
$key = $rawKey.ToUpperInvariant()
if ($key.StartsWith("SELECT-TITLE=")) {
$preferredTitle = $rawKey.Substring(13)
}
elseif ($key.StartsWith("TEXT=")) {
$initialWindow = $windows | Sort-Object Area -Descending | Select-Object -First 1
[void][ScreenshotWindowApi]::SetForegroundWindow($initialWindow.Handle)
[System.Windows.Forms.SendKeys]::SendWait($rawKey.Substring(5))
}
elseif ($key -eq "CLOSE-SMALL") {
$dialog = $windows | Sort-Object Area | Select-Object -First 1
[void][ScreenshotWindowApi]::PostMessage($dialog.Handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
}
elseif ($key -eq "CLOSE-LARGEST") {
$dialog = $windows | Sort-Object Area -Descending | Select-Object -First 1
[void][ScreenshotWindowApi]::PostMessage($dialog.Handle, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero)
}
else {
$initialWindow = $windows | Sort-Object Area -Descending | Select-Object -First 1
[void][ScreenshotWindowApi]::SetForegroundWindow($initialWindow.Handle)
$sendKeys = switch ($key) {
"ALT+C" { "%c" }
"ALT+N" { "%n" }
"ALT+Y" { "%y" }
"ALT+F4" { "%{F4}" }
default { "{$key}" }
}
[System.Windows.Forms.SendKeys]::SendWait($sendKeys)
}
Start-Sleep -Seconds 2
$processIds = Get-ProcessTreeIds -RootProcessId $process.Id
$dismissedWindows = @(Get-ProgramWindows -ProcessIds $processIds)
if ($dismissedWindows.Count -gt 0) {
$windows = $dismissedWindows
}
}
}
$window = if ($preferredTitle.Length -gt 0) {
$windows | Where-Object Title -Like "*$preferredTitle*" | Sort-Object Area -Descending | Select-Object -First 1
}
else {
$windows | Sort-Object Area -Descending | Select-Object -First 1
}
if ($null -eq $window) {
throw "No window matched preferred title: $preferredTitle"
}
if ([ScreenshotWindowApi]::IsIconic($window.Handle)) {
[void][ScreenshotWindowApi]::ShowWindow($window.Handle, 9)
}
if (-not [ScreenshotWindowApi]::IsZoomed($window.Handle)) {
[void][ScreenshotWindowApi]::SetWindowPos($window.Handle, [IntPtr]::Zero, 20, 20, 0, 0, 0x0015)
Start-Sleep -Milliseconds 300
$processIds = Get-ProcessTreeIds -RootProcessId $process.Id
$movedWindows = @(Get-ProgramWindows -ProcessIds $processIds)
$movedWindow = $movedWindows | Where-Object Handle -eq $window.Handle | Select-Object -First 1
if ($null -ne $movedWindow) {
$window = $movedWindow
$windows = $movedWindows
}
}
[void][ScreenshotWindowApi]::SetForegroundWindow($window.Handle)
Close-StaleWindows
Start-Sleep -Milliseconds 500
$screenshotPath = Join-Path $OutputDirectory "$Name.png"
$borderInset = 2
if ($ScreenshotTool.Length -gt 0 -and (Test-Path -LiteralPath $ScreenshotTool)) {
$screenshotToolName = [System.IO.Path]::GetFileName($ScreenshotTool)
if ($screenshotToolName -ieq "winapp.exe") {
$winappExitCode = 1
$winappOutput = @()
for ($attempt = 1; $attempt -le 2; $attempt++) {
Remove-Item -LiteralPath $screenshotPath -Force -ErrorAction SilentlyContinue
$winappOutput = @(& $ScreenshotTool ui screenshot -w $window.Handle.ToInt64() --output $screenshotPath --json 2>&1)
$winappExitCode = $LASTEXITCODE
if ($winappExitCode -eq 0) {
break
}
Start-Sleep -Milliseconds 500
}
if ($winappExitCode -ne 0) {
Remove-Item -LiteralPath $screenshotPath -Force -ErrorAction SilentlyContinue
$winappOutput = @(& $ScreenshotTool ui screenshot -a $window.ProcessId --output $screenshotPath --json 2>&1)
$winappExitCode = $LASTEXITCODE
}
if ($winappExitCode -ne 0) {
$winappDetails = ($winappOutput | Out-String).Trim()
throw "winapp CLI failed for window '$($window.Title)' with exit code ${winappExitCode}: $winappDetails"
}
}
else {
[void][ScreenshotWindowApi]::SetForegroundWindow($window.Handle)
Start-Sleep -Milliseconds 200
& $ScreenshotTool savescreenshotwin $screenshotPath
}
$screenshotDeadline = (Get-Date).AddSeconds(3)
while (
(-not (Test-Path -LiteralPath $screenshotPath) -or (Get-Item -LiteralPath $screenshotPath).Length -eq 0) -and
(Get-Date) -lt $screenshotDeadline
) {
Start-Sleep -Milliseconds 100
}
if (-not (Test-Path -LiteralPath $screenshotPath) -or (Get-Item -LiteralPath $screenshotPath).Length -eq 0) {
throw "Window screenshot tool failed for window '$($window.Title)'."
}
if ($screenshotToolName -ieq "winapp.exe") {
Clear-LowAlphaBorder -Path $screenshotPath
}
else {
Crop-WindowShadow `
-Path $screenshotPath `
-Left ($window.CropLeft + $borderInset) `
-Top ($window.CropTop + $borderInset) `
-Width ($window.Width - 2 * $borderInset) `
-Height ($window.Height - 2 * $borderInset)
}
}
else {
$bitmap = [System.Drawing.Bitmap]::new($window.Width, $window.Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.CopyFromScreen($window.Left, $window.Top, 0, 0, $bitmap.Size)
$bitmap.Save($screenshotPath, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$graphics.Dispose()
$bitmap.Dispose()
}
Crop-WindowShadow `
-Path $screenshotPath `
-Left $borderInset `
-Top $borderInset `
-Width ($window.Width - 2 * $borderInset) `
-Height ($window.Height - 2 * $borderInset)
}
Save-Result @{
success = $true
title = $window.Title
width = $window.Width
height = $window.Height
windows = @($windows | ForEach-Object {
@{
processId = $_.ProcessId
title = $_.Title
width = $_.Width
height = $_.Height
}
})
}
}
catch {
Save-Result @{
success = $false
reason = "error"
error = $_.Exception.Message
}
exit 1
}
finally {
if ($null -ne $process -and -not $process.HasExited) {
taskkill.exe /PID $process.Id /T /F 2>$null | Out-Null
}
if ($null -ne $baselineProcessIds) {
Stop-NewProcesses -BaselineProcessIds $baselineProcessIds
}
}
+192
View File
@@ -0,0 +1,192 @@
param(
[Parameter(Mandatory = $true)]
[string]$Archive,
[Parameter(Mandatory = $true)]
[string]$Slug,
[string]$RootDirectory = "C:\codex\catalog-screenshots",
[string]$InitialKeys = "",
[ValidateRange(1, 60)]
[int]$WaitSeconds = 8
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$workDirectory = Join-Path $RootDirectory "work\$Slug"
$outputDirectory = Join-Path $RootDirectory "results"
$runner = Join-Path $PSScriptRoot "runScreenshotTask.ps1"
$javaExecutable = "C:\codex\jre\bin\javaw.exe"
function Get-NormalizedName {
param([string]$Value)
return ($Value.ToLowerInvariant() -replace '[^a-z0-9]', '')
}
function Get-CandidateScore {
param(
[System.IO.FileInfo]$File,
[string]$ProgramSlug
)
$stem = Get-NormalizedName $File.BaseName
$slugName = Get-NormalizedName $ProgramSlug
$score = [Math]::Log([Math]::Max($File.Length, 1), 2)
if ($stem -eq $slugName) {
$score += 100
}
foreach ($token in ($ProgramSlug -split '[-_]')) {
if ($token.Length -ge 3 -and $stem.Contains((Get-NormalizedName $token))) {
$score += 15
}
}
$relativePath = $File.FullName.Substring($workDirectory.Length).TrimStart('\')
$score -= ([regex]::Matches($relativePath, '\\').Count * 2)
if ($File.BaseName -match '^(unins|uninstall|unwise|remove|register|regsvr)') {
$score -= 200
}
if ($File.BaseName -match '(setup|install|autorun|update|updater)') {
$score -= 50
}
return $score
}
function Stop-WorkDirectoryProcesses {
$directoryPrefix = $workDirectory.TrimEnd('\') + '\'
for ($attempt = 0; $attempt -lt 3; $attempt++) {
foreach ($item in Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) {
if ($item.ExecutablePath -and $item.ExecutablePath.StartsWith($directoryPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
taskkill.exe /PID $item.ProcessId /T /F 2>$null | Out-Null
}
}
Start-Sleep -Milliseconds 300
}
}
New-Item -ItemType Directory -Force -Path $workDirectory, $outputDirectory | Out-Null
try {
Expand-Archive -LiteralPath $Archive -DestinationPath $workDirectory -Force
$candidates = @(
Get-ChildItem -LiteralPath $workDirectory -Recurse -File -Filter "*.exe" |
ForEach-Object {
[pscustomobject]@{
File = $_
Score = Get-CandidateScore -File $_ -ProgramSlug $Slug
}
} |
Sort-Object @{ Expression = "Score"; Descending = $true }, @{ Expression = { $_.File.Length }; Descending = $true }
)
$arguments = ""
$executable = $null
$workingDirectory = $null
if ($candidates.Count -gt 0) {
$selected = $candidates[0].File
$executable = $selected.FullName
$workingDirectory = $selected.DirectoryName
}
else {
$candidates = @(
Get-ChildItem -LiteralPath $workDirectory -Recurse -File -Filter "*.jar" |
Where-Object { $_.DirectoryName -notmatch '\\lib($|\\)' } |
ForEach-Object {
[pscustomobject]@{
File = $_
Score = Get-CandidateScore -File $_ -ProgramSlug $Slug
}
} |
Sort-Object @{ Expression = "Score"; Descending = $true }, @{ Expression = { $_.File.Length }; Descending = $true }
)
if ($candidates.Count -eq 0 -or -not (Test-Path -LiteralPath $javaExecutable)) {
[pscustomobject]@{
success = $false
reason = "no-executable"
candidates = @()
} | ConvertTo-Json -Depth 5 -Compress
exit 1
}
$selected = $candidates[0].File
$executable = $javaExecutable
$workingDirectory = $selected.DirectoryName
$arguments = "-jar `"$($selected.FullName)`""
}
$installer = $selected.Extension -ieq ".exe" -and $selected.BaseName -match '(setup|install|autorun)'
if ($installer) {
[pscustomobject]@{
success = $false
reason = "installer-only"
selected = $selected.FullName.Substring($workDirectory.Length).TrimStart('\')
installer = $true
candidates = @($candidates | Select-Object -First 10 | ForEach-Object {
[pscustomobject]@{
path = $_.File.FullName.Substring($workDirectory.Length).TrimStart('\')
size = $_.File.Length
score = [Math]::Round($_.Score, 2)
}
})
} | ConvertTo-Json -Depth 5 -Compress
exit 1
}
$winapp = "C:\codex\winapp\winapp.exe"
$screenshotTool = if (Test-Path -LiteralPath $winapp) {
$winapp
}
else {
Join-Path $RootDirectory "nircmd.exe"
}
$runnerParameters = @{
Executable = $executable
Name = $Slug
OutputDirectory = $outputDirectory
WaitSeconds = $WaitSeconds
Arguments = $arguments
WorkingDirectory = $workingDirectory
InitialKeys = $InitialKeys
ScreenshotTool = $screenshotTool
}
$rawResult = & $runner @runnerParameters
$result = $rawResult | ConvertFrom-Json
[pscustomobject]@{
success = [bool]$result.success
reason = $result.reason
title = $result.title
error = $result.error
selected = $selected.FullName.Substring($workDirectory.Length).TrimStart('\')
installer = $installer
width = $result.width
height = $result.height
windows = $result.windows
candidates = @($candidates | Select-Object -First 10 | ForEach-Object {
[pscustomobject]@{
path = $_.File.FullName.Substring($workDirectory.Length).TrimStart('\')
size = $_.File.Length
score = [Math]::Round($_.Score, 2)
}
})
} | ConvertTo-Json -Depth 7 -Compress
}
catch {
[pscustomobject]@{
success = $false
reason = "error"
error = $_.Exception.Message
candidates = @()
} | ConvertTo-Json -Depth 5 -Compress
exit 1
}
finally {
Stop-WorkDirectoryProcesses
Remove-Item -LiteralPath $workDirectory -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $Archive -Force -ErrorAction SilentlyContinue
}
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { access, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { readCatalogIndex } from "../scripts/catalogIndex.js";
type ProgramFile = {
path?: unknown;
platform?: unknown;
};
type ProgramVersion = {
status?: unknown;
files?: ProgramFile[];
};
type ProgramIndex = {
type?: unknown;
name?: unknown;
screenshots?: unknown;
versions?: ProgramVersion[];
};
type Program = {
archive: string;
directory: string;
indexPath: string;
name: string;
slug: string;
};
type RemoteResult = {
success?: boolean;
reason?: string;
title?: string;
error?: string;
selected?: string;
installer?: boolean;
width?: number;
height?: number;
windows?: Array<{ title?: string }>;
};
type Failure = {
name: string;
path: string;
reason: string;
details: string;
};
type Options = {
catalogDirectory: string;
host: string;
importDirectory?: string;
limit?: number;
program?: string;
replace: boolean;
waitSeconds: number;
write: boolean;
};
const remoteRoot = "C:\\codex\\catalog-screenshots";
const remoteScpRoot = "C:/codex/catalog-screenshots";
const programsWithoutStandaloneGui = new Set([
"fardes",
"farobex",
"filescomparer",
"hex2vkp",
"midletsigner",
"openall",
"sifs",
"slfc",
"txt2wmlc",
"vmo2wav",
"vsofs",
"wav-amr-converter",
"zeesiemens",
]);
const errorTitle = /error|exception|failed|failure|cannot|can't|not found|access violation|achtung|ошибка|предупреждение/i;
const installerTitle = /setup|installer|installshield|installation|self-extract|winrar|установк|мастер установки|самораспаковывающ/i;
const initialKeys = new Map([
[
"brew-graphicpatch-creator",
"ENTER,TEXT=C:\\codex\\catalog-screenshots\\work\\brew-graphicpatch-creator\\brewrc.exe,ENTER",
],
["brew-apploader", "CLOSE-SMALL,CLOSE-SMALL"],
["efs-explorer-lite", "ENTER"],
["flash-imager", "ENTER"],
["flex-table-edit", "ENTER"],
["gagin", "CLOSE-SMALL,CLOSE-SMALL"],
["freia", "SELECT-TITLE=Engine"],
["initmap", "CLOSE-LARGEST"],
["s65-key-definer", "ENTER"],
["siemens-screenshot", "ENTER"],
]);
function usage(): void {
console.log(`Usage: pnpm screenshots [options] [catalog-directory]
Capture one screenshot for each Windows program.
--host HOST Windows SSH host (default: win)
--import DIR import reviewed SLUG.png files without connecting to Windows
--program SLUG process one program directory
--limit COUNT stop after COUNT programs
--replace recapture only existing img/SLUG.png screenshots
--wait SECONDS wait for a program window (default: 8)
--write save successful screenshots and update index.md
--help show this help message`);
}
function parseArguments(args: string[]): Options {
const targets: string[] = [];
let host = "win";
let importDirectory: string | undefined;
let limit: number | undefined;
let program: string | undefined;
let replace = false;
let waitSeconds = 8;
let write = false;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--host") {
host = args[++index] ?? "";
} else if (argument === "--import") {
importDirectory = path.resolve(args[++index] ?? "");
} else if (argument === "--limit") {
limit = Number(args[++index]);
} else if (argument === "--program") {
program = args[++index];
} else if (argument === "--replace") {
replace = true;
} else if (argument === "--wait") {
waitSeconds = Number(args[++index]);
} else if (argument === "--write") {
write = true;
} else if (argument === "--help" || argument === "-h") {
usage();
process.exit(0);
} else if (argument.startsWith("-")) {
throw new Error(`Unknown option: ${argument}`);
} else {
targets.push(argument);
}
}
if (targets.length > 1) {
throw new Error("Expected at most one catalog directory");
}
if (host.length === 0) {
throw new Error("--host must not be empty");
}
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
throw new Error("--limit must be a positive integer");
}
if (!Number.isInteger(waitSeconds) || waitSeconds < 1 || waitSeconds > 60) {
throw new Error("--wait must be an integer between 1 and 60");
}
return {
catalogDirectory: path.resolve(targets[0] ?? "catalog"),
host,
importDirectory,
limit,
program,
replace,
waitSeconds,
write,
};
}
async function run(command: string, args: string[], allowFailure = false): Promise<string> {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.on("error", reject);
child.on("close", (code) => {
const output = Buffer.concat(stdout).toString("utf8");
const error = Buffer.concat(stderr).toString("utf8").trim();
if (code === 0 || allowFailure) {
resolve(output);
} else {
reject(new Error(`${command} exited with code ${code}${error.length > 0 ? `: ${error}` : ""}`));
}
});
});
}
async function findIndexes(directory: string, result: string[]): Promise<void> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isFile() && entry.name === "index.md") {
result.push(entryPath);
} else if (entry.isDirectory() && entry.name !== "files" && entry.name !== "img") {
await findIndexes(entryPath, result);
}
}
}
function selectArchive(data: ProgramIndex, indexPath: string): string | undefined {
const versions = data.versions ?? [];
const orderedVersions = [
...versions.filter((entry) => entry.status === "current"),
...versions.filter((entry) => entry.status !== "current"),
];
for (const version of orderedVersions) {
const files = version.files ?? [];
const file =
files.find((entry) => entry.platform === "win32" || entry.platform === "win64") ??
files.find((entry) => entry.platform === "java");
if (typeof file?.path === "string") {
return path.resolve(path.dirname(indexPath), file.path);
}
}
return undefined;
}
async function collectPrograms(options: Options): Promise<{ programs: Program[]; failures: Failure[] }> {
const indexes: string[] = [];
const programs: Program[] = [];
const failures: Failure[] = [];
await findIndexes(options.catalogDirectory, indexes);
for (const indexPath of indexes.sort()) {
const { metadata } = await readCatalogIndex<ProgramIndex>(indexPath);
if (metadata.type !== "program") {
continue;
}
const directory = path.dirname(indexPath);
const slug = path.basename(directory);
if (programsWithoutStandaloneGui.has(slug)) {
continue;
}
if (options.program !== undefined && slug !== options.program) {
continue;
}
if (typeof metadata.name !== "string" || metadata.name.length === 0) {
throw new Error(`${indexPath}: name is missing`);
}
const expectedScreenshot = `img/${slug}.png`;
const screenshots = Array.isArray(metadata.screenshots) ? metadata.screenshots : [];
if (options.replace) {
if (!screenshots.includes(expectedScreenshot)) {
continue;
}
} else if (screenshots.length > 0) {
continue;
}
const archive = selectArchive(metadata, indexPath);
if (archive === undefined) {
failures.push({
name: metadata.name,
path: path.relative(process.cwd(), indexPath),
reason: "No desktop file",
details: "The current version has no file with platform: win32, win64, or java",
});
continue;
}
programs.push({ archive, directory, indexPath, name: metadata.name, slug });
}
if (options.limit !== undefined) {
programs.splice(options.limit);
}
return { programs, failures };
}
function parseRemoteResult(output: string): RemoteResult {
const start = output.indexOf("{");
const end = output.lastIndexOf("}");
if (start === -1 || end === -1) {
throw new Error(`Windows worker returned no JSON: ${output.trim()}`);
}
return JSON.parse(output.slice(start, end + 1)) as RemoteResult;
}
function failureReason(result: RemoteResult, program: Program): { reason: string; details: string } | undefined {
if (result.success !== true) {
const reasons: Record<string, string> = {
"no-executable": "No desktop executable",
"installer-only": "Installer only",
"process-exited": "Process exited without a window",
"no-window": "No window appeared",
error: "Launch error",
};
return {
reason: reasons[result.reason ?? ""] ?? "Unknown error",
details: result.error ?? result.reason ?? "",
};
}
if (result.installer === true) {
return { reason: "Installer only", details: result.selected ?? "" };
}
const titles = [result.title, ...(result.windows ?? []).map((window) => window.title)].filter(
(title): title is string => typeof title === "string",
);
const failedTitle = titles.find((title) => errorTitle.test(title));
if (failedTitle !== undefined) {
return { reason: "Error window", details: failedTitle };
}
const setupTitle = program.slug === "java-midlet-installer" ? undefined : titles.find((title) => installerTitle.test(title));
if (setupTitle !== undefined) {
return { reason: "Installer opened", details: setupTitle };
}
return undefined;
}
async function addScreenshot(program: Program, screenshotPath: string, replace: boolean): Promise<void> {
const imageDirectory = path.join(program.directory, "img");
const imagePath = path.join(imageDirectory, `${program.slug}.png`);
await mkdir(imageDirectory, { recursive: true });
await run("scp", [`${screenshotPath}`, imagePath]);
if (replace) {
return;
}
const source = await readFile(program.indexPath, "utf8");
const replacement = `screenshots:\n - img/${program.slug}.png`;
if (!source.includes("screenshots: []")) {
throw new Error(`${program.indexPath}: expected screenshots: []`);
}
await writeFile(program.indexPath, source.replace("screenshots: []", replacement), "utf8");
}
async function importScreenshots(programs: Program[], directory: string): Promise<number> {
let imported = 0;
for (const program of programs) {
const sourcePath = path.join(directory, `${program.slug}.png`);
try {
await access(sourcePath);
} catch {
continue;
}
const imageDirectory = path.join(program.directory, "img");
const imagePath = path.join(imageDirectory, `${program.slug}.png`);
await mkdir(imageDirectory, { recursive: true });
await copyFile(sourcePath, imagePath);
const source = await readFile(program.indexPath, "utf8");
if (!source.includes("screenshots: []")) {
throw new Error(`${program.indexPath}: expected screenshots: []`);
}
await writeFile(
program.indexPath,
source.replace("screenshots: []", `screenshots:\n - img/${program.slug}.png`),
"utf8",
);
imported += 1;
console.log(`Imported: ${program.name}`);
}
return imported;
}
function escapeTable(value: string): string {
return value.replaceAll("|", "\\|").replaceAll("\n", " ").trim();
}
async function main(): Promise<void> {
const options = parseArguments(process.argv.slice(2));
const temporaryDirectory = "/tmp/codex/catalog-screenshots";
await mkdir(temporaryDirectory, { recursive: true });
const { programs, failures } = await collectPrograms(options);
if (options.importDirectory !== undefined) {
const imported = await importScreenshots(programs, options.importDirectory);
console.log(`Done. Imported: ${imported}`);
return;
}
let captured = 0;
const tools = ["screenshotApp.ps1", "runScreenshotTask.ps1", "screenshotArchive.ps1"];
await run("ssh", [
options.host,
`powershell -NoProfile -Command "New-Item -ItemType Directory -Force -Path '${remoteRoot}\\input','${remoteRoot}\\results' | Out-Null"`,
]);
await run("scp", tools.map((file) => path.resolve("tools", file)).concat(`${options.host}:${remoteScpRoot}/`));
console.log(`Programs to process: ${programs.length}`);
for (const [index, program] of programs.entries()) {
const prefix = `[${index + 1}/${programs.length}] ${program.name}`;
const remoteArchive = `${remoteRoot}\\input\\${program.slug}.zip`;
try {
await run("scp", [program.archive, `${options.host}:${remoteScpRoot}/input/${program.slug}.zip`]);
const remoteCommand = [
"powershell",
"-NoProfile",
"-ExecutionPolicy Bypass",
`-File ${remoteRoot}\\screenshotArchive.ps1`,
`-Archive ${remoteArchive}`,
`-Slug ${program.slug}`,
`-RootDirectory ${remoteRoot}`,
`-WaitSeconds ${options.waitSeconds}`,
...(initialKeys.has(program.slug) ? [`-InitialKeys ${initialKeys.get(program.slug)}`] : []),
].join(" ");
const output = await run("ssh", [options.host, remoteCommand], true);
const result = parseRemoteResult(output);
const failure = failureReason(result, program);
if (failure !== undefined) {
failures.push({
name: program.name,
path: path.relative(process.cwd(), program.indexPath),
...failure,
});
console.log(`${prefix}: failed (${failure.reason})`);
continue;
}
if (options.write) {
await addScreenshot(program, `${options.host}:${remoteScpRoot}/results/${program.slug}.png`, options.replace);
}
captured += 1;
console.log(`${prefix}: ${options.write ? "saved" : "captured"} (${result.title ?? result.selected ?? "window"})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push({
name: program.name,
path: path.relative(process.cwd(), program.indexPath),
reason: "Tool error",
details: message,
});
console.log(`${prefix}: failed (${message})`);
}
}
const table = [
"| Program | Index | Reason | Details |",
"|---|---|---|---|",
...failures.map(
(failure) =>
`| ${escapeTable(failure.name)} | ${escapeTable(failure.path)} | ${escapeTable(failure.reason)} | ${escapeTable(failure.details)} |`,
),
"",
].join("\n");
const reportPath = path.join(temporaryDirectory, "failures.md");
await writeFile(reportPath, table, "utf8");
console.log(`Done. Captured: ${captured}; failures: ${failures.length}`);
console.log(`Failure report: ${reportPath}`);
}
try {
await main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
process.exitCode = 1;
}