Reorganize software catalog
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user