#!/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 { 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 { 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(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 = { "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 { 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 { 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 { 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; }