This commit is contained in:
2026-08-08 18:00:34 +02:00
commit 41e307b5c1
28 changed files with 4728 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
// About me: Bundles the extension into dist/ as the folder layout Vortex loads:
// a single CommonJS index.js plus info.json and the game artwork. `vortex-api` is
// left external because Vortex provides it to extensions at runtime.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as esbuild from 'esbuild';
import { encodePng, renderGameArt } from '../src/gameart.ts';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = resolve(projectRoot, 'dist');
const pkg = JSON.parse(readFileSync(resolve(projectRoot, 'package.json'), 'utf8'));
mkdirSync(distDir, { recursive: true });
await esbuild.build({
entryPoints: [resolve(projectRoot, 'src/index.ts')],
outfile: resolve(distDir, 'index.js'),
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node18',
external: ['vortex-api'],
legalComments: 'none',
});
// The bundle is CommonJS, but this repo is an ESM package; without this marker
// node would read dist/index.js as ESM and fail on `module.exports`.
writeFileSync(resolve(distDir, 'package.json'), `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`);
const info = {
name: 'Game: Cyberpunk 2077 (Linux/Proton)',
author: 'local',
version: pkg.version,
description: pkg.description,
gameId: 'cyberpunk2077linux',
};
writeFileSync(resolve(distDir, 'info.json'), `${JSON.stringify(info, null, 2)}\n`);
// The tile artwork is generated, never copied from Vortex or Nexus, so the build
// renders it straight from source. It is deterministic, so this is reproducible.
writeFileSync(resolve(distDir, 'gameart.png'), encodePng(renderGameArt()));
console.log(`built ${resolve(distDir, 'index.js')}`);
+19
View File
@@ -0,0 +1,19 @@
// About me: Writes assets/gameart.png from the generated artwork in src/gameart.ts.
// Kept as a separate step from the build so the committed image only changes when
// someone deliberately regenerates it.
//
// Usage: node scripts/make-gameart.mjs [outputPath]
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { encodePng, renderGameArt } from '../src/gameart.ts';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const output = resolve(projectRoot, process.argv[2] ?? 'assets/gameart.png');
mkdirSync(dirname(output), { recursive: true });
const png = encodePng(renderGameArt());
writeFileSync(output, png);
console.log(`wrote ${output} (${png.length} bytes)`);
+118
View File
@@ -0,0 +1,118 @@
// About me: Writes the missing Steam libraryfolders.vdf into a Wine prefix from
// the Linux side, before Vortex is ever started. Vortex core scans Steam at
// startup and reads C:\Program Files (x86)\Steam\config\libraryfolders.vdf; when
// SteamTinkerLaunch has not created it, the unhandled ENOENT shows up as
// "unrecoverable error" and Vortex dies. Running this once repairs the prefix.
//
// Usage: node scripts/repair-prefix.mjs [--prefix <pfx>] [--force] [--dry-run]
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { findSteamLibraries, findSteamRoots } from '../src/discovery.ts';
import {
PREFIX_STEAM_DIR,
buildShimVdf,
dedupeByCanonicalPath,
ensureSteamShim,
} from '../src/steamShim.ts';
const DEFAULT_PREFIX = resolve(homedir(), '.config/steamtinkerlaunch/vortex/compatdata/pfx');
function parseArgs(argv) {
const args = { prefix: DEFAULT_PREFIX, force: false, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--prefix') {
args.prefix = resolve(argv[i + 1] ?? '');
i += 1;
} else if (argv[i] === '--force') {
args.force = true;
} else if (argv[i] === '--dry-run') {
args.dryRun = true;
} else {
throw new Error(`unknown argument: ${argv[i]}`);
}
}
return args;
}
/**
* Resolves the paths Vortex would see inside the prefix to real Linux paths:
* Z: is the Linux root, C: is the prefix's drive_c.
*/
function toRealPath(prefix, winPath) {
const normalised = winPath.replace(/\\/g, '/');
if (/^[zZ]:/.test(normalised)) {
return normalised.slice(2) || '/';
}
if (/^[cC]:/.test(normalised)) {
return resolve(prefix, 'drive_c', normalised.slice(3));
}
return normalised;
}
function prefixFileAccess(prefix) {
return {
exists: (p) => existsSync(toRealPath(prefix, p)),
readFile: (p) => readFileSync(toRealPath(prefix, p), 'utf8'),
readDir: (p) => {
try {
return readdirSync(toRealPath(prefix, p));
} catch {
return [];
}
},
};
}
const args = parseArgs(process.argv.slice(2));
if (!existsSync(args.prefix)) {
console.error(`prefix not found: ${args.prefix}`);
process.exit(1);
}
const files = prefixFileAccess(args.prefix);
// Only the Wine-side (Z:) view is useful here: the shim has to contain paths
// Vortex can open from inside the prefix.
const libraries = dedupeByCanonicalPath(
[
...new Set(
findSteamRoots(files)
.filter((root) => root.startsWith('Z:'))
.flatMap((root) => findSteamLibraries(files, root)),
),
],
(candidate) => realpathSync(toRealPath(args.prefix, candidate)),
);
console.log(`prefix: ${args.prefix}`);
console.log(`steam dir: ${toRealPath(args.prefix, PREFIX_STEAM_DIR)}`);
console.log(`libraries: ${libraries.length === 0 ? '(none found)' : ''}`);
for (const library of libraries) {
console.log(` ${library}`);
}
if (args.dryRun) {
console.log('--- shim that would be written ---');
console.log(buildShimVdf(libraries));
process.exit(0);
}
const writer = {
makeDir: (p) => mkdirSync(toRealPath(args.prefix, p), { recursive: true }),
writeFile: (p, contents) => writeFileSync(toRealPath(args.prefix, p), contents, 'utf8'),
};
const result = ensureSteamShim(files, writer, libraries, { overwrite: args.force });
console.log(`result: ${result.action} -> ${toRealPath(args.prefix, result.path)}`);
if (result.action === 'skipped') {
console.error(
libraries.length === 0
? 'no Steam libraries found under /home; pass --prefix for the right prefix or check your Steam install'
: `no Steam directory at ${toRealPath(args.prefix, PREFIX_STEAM_DIR)}; is this the Vortex prefix?`,
);
process.exit(1);
}
+54
View File
@@ -0,0 +1,54 @@
// About me: Loads the built bundle the way Vortex does - require() with a stubbed
// `vortex-api` - and drives registerGame/registerInstaller against a fake context.
// This catches packaging mistakes (bad export shape, missing external) that unit
// tests on the source cannot see. Run with: node scripts/smoke-load.cjs
const Module = require('node:module');
const path = require('node:path');
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'vortex-api') {
return { log: (level, message) => console.log(` [vortex-api ${level}] ${message}`) };
}
return originalLoad.call(this, request, parent, isMain);
};
const extension = require(path.resolve(__dirname, '../dist/index.js'));
const main = extension.default ?? extension;
const registered = { games: [], installers: [] };
const context = {
registerGame: (game) => registered.games.push(game),
registerInstaller: (id, priority, testSupported, install) =>
registered.installers.push({ id, priority, testSupported, install }),
once: (cb) => cb(),
api: {},
};
const result = main(context);
console.log('main() returned:', result);
const game = registered.games[0];
console.log('game id:', game.id);
console.log('executable:', game.executable());
console.log('queryModPath:', JSON.stringify(game.queryModPath('')));
console.log('queryPath():', game.queryPath());
const installer = registered.installers[0];
installer
.testSupported(['a.archive'], game.id)
.then((supported) => {
console.log('testSupported:', JSON.stringify(supported));
return installer.install(
['Cool Mod v1\\archive\\pc\\mod\\cool.archive', 'Cool Mod v1\\readme.txt'],
'Z:\\staging\\Cool Mod-1234.installing',
game.id,
);
})
.then((res) => {
console.log('install instructions:', JSON.stringify(res.instructions, null, 2));
})
.catch((err) => {
console.error('smoke failed:', err);
process.exitCode = 1;
});