458 lines
15 KiB
JavaScript
458 lines
15 KiB
JavaScript
#!/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;
|
|
}
|