@gamecrate/cli 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gamecrate.js +2208 -1990
- package/dist/lib.js +2 -0
- package/dist/types/docker/icon.d.ts +7 -0
- package/dist/types/docker/spec.d.ts +2 -0
- package/dist/types/docker/window.d.ts +2 -0
- package/dist/types/image/refs.d.ts +3 -0
- package/dist/types/launch/prepare.d.ts +6 -1
- package/dist/types/types.d.ts +4 -0
- package/package.json +1 -1
package/dist/gamecrate.js
CHANGED
|
@@ -724,23 +724,27 @@ function applyPositionals(out, positional, games, help = false) {
|
|
|
724
724
|
}
|
|
725
725
|
const { sub, slots, rest } = routePositionals(out, first, positional, games);
|
|
726
726
|
const left = fillSlots(out, slots, rest);
|
|
727
|
-
if (!help
|
|
727
|
+
if (!help)
|
|
728
|
+
requireSubverbArgs(out);
|
|
729
|
+
if (left.length > 0) {
|
|
730
|
+
const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
|
|
731
|
+
throw usage(`unexpected argument ${left[0]}`, `gamecrate ${shape}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function requireSubverbArgs(out) {
|
|
735
|
+
if (out.subverb === "add" || out.subverb === "rm") {
|
|
728
736
|
if (out.game === undefined)
|
|
729
737
|
throw usage(`mods ${out.subverb} needs a game`);
|
|
730
738
|
if (out.subverb === "rm" && out.rest.length === 0)
|
|
731
739
|
throw usage("mods rm needs at least one mod id");
|
|
732
740
|
}
|
|
733
|
-
if (
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
if (out.subverb === "build" && out.game === undefined)
|
|
738
|
-
throw usage("steam build needs a game");
|
|
739
|
-
}
|
|
740
|
-
if (left.length > 0) {
|
|
741
|
-
const shape = sub ? `${sub.name} ${sub.usage}`.trim() : `${out.game} [profile]`;
|
|
742
|
-
throw usage(`unexpected argument ${left[0]}`, `gamecrate ${shape}`);
|
|
741
|
+
if (out.subcommand !== "steam")
|
|
742
|
+
return;
|
|
743
|
+
if (out.subverb === undefined) {
|
|
744
|
+
throw usage("steam needs a subverb", "gamecrate steam build <game>, or gamecrate steam login");
|
|
743
745
|
}
|
|
746
|
+
if (out.subverb === "build" && out.game === undefined)
|
|
747
|
+
throw usage("steam build needs a game");
|
|
744
748
|
}
|
|
745
749
|
function routePositionals(out, first, positional, games) {
|
|
746
750
|
const sub = SUBCOMMANDS.find((s) => s.name === first);
|
|
@@ -1033,2207 +1037,2240 @@ function requireGame(args, config) {
|
|
|
1033
1037
|
}
|
|
1034
1038
|
|
|
1035
1039
|
// src/image/refs.ts
|
|
1036
|
-
import { existsSync as existsSync8 } from "node:fs";
|
|
1040
|
+
import { existsSync as existsSync8, readlinkSync } from "node:fs";
|
|
1037
1041
|
import { mkdir as mkdir3, readdir as readdir7, rename, rm as rm3, symlink, unlink as unlink3 } from "node:fs/promises";
|
|
1038
1042
|
import { dirname as dirname6, join as join11 } from "node:path";
|
|
1039
1043
|
|
|
1040
1044
|
// src/cli/output.ts
|
|
1041
1045
|
import { closeSync, existsSync as existsSync2, lstatSync, mkdirSync as mkdirSync2, openSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
|
|
1042
|
-
import { basename as
|
|
1046
|
+
import { basename as basename3, join as join6, resolve as resolve4 } from "node:path";
|
|
1043
1047
|
|
|
1044
1048
|
// src/docker/run.ts
|
|
1045
1049
|
import { spawn } from "node:child_process";
|
|
1046
1050
|
import { createWriteStream, mkdirSync } from "node:fs";
|
|
1047
|
-
import { open, readdir, stat } from "node:fs/promises";
|
|
1048
|
-
import { join as
|
|
1051
|
+
import { open, readdir as readdir2, stat } from "node:fs/promises";
|
|
1052
|
+
import { join as join4 } from "node:path";
|
|
1049
1053
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
1050
1054
|
import { TextDecoder } from "node:util";
|
|
1051
1055
|
|
|
1052
1056
|
// src/docker/spec.ts
|
|
1053
1057
|
import { existsSync, realpathSync } from "node:fs";
|
|
1054
|
-
import { homedir, hostname } from "node:os";
|
|
1055
|
-
import { basename, join } from "node:path";
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1058
|
+
import { homedir as homedir2, hostname } from "node:os";
|
|
1059
|
+
import { basename as basename2, isAbsolute as isAbsolute2, join as join3, resolve as resolve3 } from "node:path";
|
|
1060
|
+
|
|
1061
|
+
// src/config/load.ts
|
|
1062
|
+
import { z as z2 } from "zod";
|
|
1063
|
+
import { access, readdir, readFile as readFile2 } from "node:fs/promises";
|
|
1064
|
+
import { homedir } from "node:os";
|
|
1065
|
+
import { basename, dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
1066
|
+
|
|
1067
|
+
// src/plugin.ts
|
|
1068
|
+
import { readFileSync, statSync } from "node:fs";
|
|
1069
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
1070
|
+
import { pathToFileURL } from "node:url";
|
|
1071
|
+
import { exports as exportsField, legacy } from "resolve.exports";
|
|
1072
|
+
var PLUGIN_API_VERSION = 2;
|
|
1073
|
+
var REQUIRED_FUNCTIONS = [
|
|
1074
|
+
"parseManifest",
|
|
1075
|
+
"renderModsConfig",
|
|
1076
|
+
"mergePrefs",
|
|
1077
|
+
"parseVersion"
|
|
1078
|
+
];
|
|
1079
|
+
function fail(spec, message, detail) {
|
|
1080
|
+
throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail);
|
|
1068
1081
|
}
|
|
1069
|
-
function
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
USER: identity.user,
|
|
1076
|
-
LOGNAME: identity.user
|
|
1077
|
-
};
|
|
1078
|
-
addGameFiles(mounts, plan);
|
|
1079
|
-
addStage(mounts, plan, modMounts);
|
|
1080
|
-
const proton = image?.launcher === "proton";
|
|
1081
|
-
const executable = image?.executable ?? game.executable;
|
|
1082
|
-
refuseProtonHeaded(plan.game, plan.mode, image);
|
|
1083
|
-
const command = proton ? ["run-headless-windows", winPath(join(game.gameFiles.container, basename(executable)))] : headed ? [executable] : [
|
|
1084
|
-
"xvfb-run",
|
|
1085
|
-
"-a",
|
|
1086
|
-
`--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
|
|
1087
|
-
executable
|
|
1088
|
-
];
|
|
1089
|
-
if (proton) {
|
|
1090
|
-
Object.assign(env, {
|
|
1091
|
-
SCREEN: `${settings.width}x${settings.height}x24`,
|
|
1092
|
-
DESKTOP: `${settings.width}x${settings.height}`,
|
|
1093
|
-
STEAM_COMPAT_DATA_PATH: `${CONTAINER_XDG_DIR}/proton`
|
|
1094
|
-
});
|
|
1095
|
-
}
|
|
1096
|
-
if (game.dataDir.mode === "arg") {
|
|
1097
|
-
const arg = validateDataDirArg(game.dataDir);
|
|
1098
|
-
const eq = arg.indexOf("=");
|
|
1099
|
-
command.push(proton ? `${arg.slice(0, eq + 1)}${winPath(arg.slice(eq + 1))}` : arg);
|
|
1100
|
-
} else
|
|
1101
|
-
Object.assign(env, game.dataDir.env);
|
|
1102
|
-
if (game.logFile.mode === "arg") {
|
|
1103
|
-
mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
|
|
1104
|
-
const log = `${CONTAINER_LOG_DIR}/Player.log`;
|
|
1105
|
-
command.push(game.logFile.arg, proton ? winPath(log) : log);
|
|
1106
|
-
}
|
|
1107
|
-
addScratch(mounts, env, plan, identity);
|
|
1108
|
-
if (headed)
|
|
1109
|
-
addSession(mounts, env, plan);
|
|
1110
|
-
const deviceCgroupRules = [];
|
|
1111
|
-
if (settings.input) {
|
|
1112
|
-
mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
|
|
1113
|
-
deviceCgroupRules.push("c 13:* rmw");
|
|
1082
|
+
function entryOf(dir) {
|
|
1083
|
+
let manifest;
|
|
1084
|
+
try {
|
|
1085
|
+
manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
1086
|
+
} catch {
|
|
1087
|
+
return resolve(dir, "index.js");
|
|
1114
1088
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
return
|
|
1121
|
-
image: game.image.ref,
|
|
1122
|
-
name: containerName(plan),
|
|
1123
|
-
labels: {
|
|
1124
|
-
"gamecrate.game": plan.game,
|
|
1125
|
-
"gamecrate.profile": plan.profile,
|
|
1126
|
-
...plan.instance === undefined ? {} : { "gamecrate.instance": plan.instance }
|
|
1127
|
-
},
|
|
1128
|
-
identity,
|
|
1129
|
-
env,
|
|
1130
|
-
mounts,
|
|
1131
|
-
devices,
|
|
1132
|
-
deviceCgroupRules,
|
|
1133
|
-
network: settings.network,
|
|
1134
|
-
memory: settings.memory,
|
|
1135
|
-
memorySwap: settings.memory,
|
|
1136
|
-
cpus: settings.cpus,
|
|
1137
|
-
pidsLimit: settings.pidsLimit,
|
|
1138
|
-
ulimits: ["core=0"],
|
|
1139
|
-
workdir: game.gameFiles.container,
|
|
1140
|
-
...headed && settings.display === "x11" ? { hostname: hostname() } : {},
|
|
1141
|
-
command,
|
|
1142
|
-
extraArgs: [...settings.dockerArgs ?? []]
|
|
1143
|
-
};
|
|
1089
|
+
let entry;
|
|
1090
|
+
try {
|
|
1091
|
+
entry = exportsField(manifest, ".", { conditions: ["bun"] })?.[0];
|
|
1092
|
+
} catch {}
|
|
1093
|
+
entry ??= legacy(manifest, { fields: ["module", "main"] });
|
|
1094
|
+
return resolve(dir, entry ?? "index.js");
|
|
1144
1095
|
}
|
|
1145
|
-
function
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1096
|
+
function packageDir(spec, from) {
|
|
1097
|
+
let dir = resolve(from);
|
|
1098
|
+
for (;; ) {
|
|
1099
|
+
const candidate = join(dir, "node_modules", spec);
|
|
1100
|
+
if (statSync(join(candidate, "package.json"), { throwIfNoEntry: false })?.isFile())
|
|
1101
|
+
return candidate;
|
|
1102
|
+
const parent = dirname(dir);
|
|
1103
|
+
if (parent === dir)
|
|
1104
|
+
return null;
|
|
1105
|
+
dir = parent;
|
|
1151
1106
|
}
|
|
1152
|
-
mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
|
|
1153
1107
|
}
|
|
1154
|
-
function
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1108
|
+
function locate(spec, from) {
|
|
1109
|
+
const expanded = expandHome(spec);
|
|
1110
|
+
let target;
|
|
1111
|
+
if (expanded.startsWith(".") || isAbsolute(expanded)) {
|
|
1112
|
+
target = resolve(from, expanded);
|
|
1113
|
+
} else {
|
|
1114
|
+
target = packageDir(expanded, from);
|
|
1115
|
+
if (target === null) {
|
|
1116
|
+
fail(spec, `cannot be resolved from ${from}`, "install it, or give a path starting with ./");
|
|
1117
|
+
}
|
|
1159
1118
|
}
|
|
1160
|
-
|
|
1119
|
+
return statSync(target, { throwIfNoEntry: false })?.isDirectory() ? entryOf(target) : target;
|
|
1161
1120
|
}
|
|
1162
|
-
function
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1121
|
+
function check(spec, value) {
|
|
1122
|
+
if (typeof value !== "object" || value === null)
|
|
1123
|
+
fail(spec, "has no default export");
|
|
1124
|
+
const plugin = value;
|
|
1125
|
+
if (plugin.apiVersion !== PLUGIN_API_VERSION) {
|
|
1126
|
+
fail(spec, `speaks apiVersion ${String(plugin.apiVersion)}, this build speaks ${PLUGIN_API_VERSION}`);
|
|
1166
1127
|
}
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1128
|
+
if (typeof plugin.game !== "string" || plugin.game === "")
|
|
1129
|
+
fail(spec, "declares no game name");
|
|
1130
|
+
const missing = REQUIRED_FUNCTIONS.filter((name) => typeof plugin[name] !== "function");
|
|
1131
|
+
if (missing.length > 0)
|
|
1132
|
+
fail(spec, `is missing ${missing.join(", ")}`);
|
|
1133
|
+
if (typeof plugin.defaults !== "object" || plugin.defaults === null) {
|
|
1134
|
+
fail(spec, "declares no defaults object");
|
|
1169
1135
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
|
|
1173
|
-
env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
|
|
1174
|
-
env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
|
|
1175
|
-
env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
|
|
1176
|
-
}
|
|
1177
|
-
function addSession(mounts, env, plan) {
|
|
1178
|
-
const { settings } = plan;
|
|
1179
|
-
if (settings.display === "x11")
|
|
1180
|
-
addX11(mounts, env);
|
|
1181
|
-
else
|
|
1182
|
-
addWayland(mounts, env);
|
|
1183
|
-
if (!settings.audio)
|
|
1184
|
-
return;
|
|
1185
|
-
for (const socket of audioSockets()) {
|
|
1186
|
-
mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
|
|
1136
|
+
if (typeof plugin.windowedPrefs !== "object" || plugin.windowedPrefs === null) {
|
|
1137
|
+
fail(spec, "declares no windowedPrefs object");
|
|
1187
1138
|
}
|
|
1188
|
-
|
|
1189
|
-
}
|
|
1190
|
-
function addX11(mounts, env) {
|
|
1191
|
-
const x11 = x11Session();
|
|
1192
|
-
if (!x11)
|
|
1193
|
-
return;
|
|
1194
|
-
mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
|
|
1195
|
-
env.DISPLAY = x11.display;
|
|
1196
|
-
env.XDG_SESSION_TYPE = "x11";
|
|
1197
|
-
env.SDL_VIDEODRIVER = "x11";
|
|
1198
|
-
env.QT_QPA_PLATFORM = "xcb";
|
|
1199
|
-
if (!x11.xauthority)
|
|
1200
|
-
return;
|
|
1201
|
-
mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
|
|
1202
|
-
env.XAUTHORITY = CONTAINER_XAUTHORITY;
|
|
1203
|
-
}
|
|
1204
|
-
function addWayland(mounts, env) {
|
|
1205
|
-
const wayland = waylandSocket();
|
|
1206
|
-
if (!wayland)
|
|
1207
|
-
return;
|
|
1208
|
-
mounts.push({ type: "bind", source: wayland.source, target: `${CONTAINER_RUNTIME_DIR}/${wayland.name}` });
|
|
1209
|
-
env.WAYLAND_DISPLAY = wayland.name;
|
|
1210
|
-
env.XDG_SESSION_TYPE = "wayland";
|
|
1211
|
-
env.SDL_VIDEODRIVER = "wayland";
|
|
1212
|
-
env.QT_QPA_PLATFORM = "wayland";
|
|
1139
|
+
return plugin;
|
|
1213
1140
|
}
|
|
1214
|
-
function
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1141
|
+
async function loadPlugins(specs, configFile) {
|
|
1142
|
+
const from = dirname(configFile);
|
|
1143
|
+
const out = new Map;
|
|
1144
|
+
for (const spec of specs) {
|
|
1145
|
+
const target = locate(spec, from);
|
|
1146
|
+
let module;
|
|
1147
|
+
try {
|
|
1148
|
+
module = await import(pathToFileURL(target).href);
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
fail(spec, `failed to load ${target}`, error instanceof Error ? error.message : String(error));
|
|
1151
|
+
}
|
|
1152
|
+
const plugin = check(spec, module.default);
|
|
1153
|
+
if (out.has(plugin.game))
|
|
1154
|
+
fail(spec, `also claims the game "${plugin.game}"`);
|
|
1155
|
+
out.set(plugin.game, plugin);
|
|
1156
|
+
}
|
|
1157
|
+
return out;
|
|
1217
1158
|
}
|
|
1218
|
-
function
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1159
|
+
function requirePlugin(plugins, game) {
|
|
1160
|
+
const plugin = plugins.get(game);
|
|
1161
|
+
if (plugin === undefined) {
|
|
1162
|
+
throw new GamecrateError(`no plugin provides the game "${game}"`, Exit.Config, `loaded plugins: ${[...plugins.keys()].join(", ") || "(none)"}`);
|
|
1163
|
+
}
|
|
1164
|
+
return plugin;
|
|
1221
1165
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`);
|
|
1256
|
-
return ["--tmpfs", `${mount.target}:${opts.join(",")}`];
|
|
1257
|
-
}
|
|
1258
|
-
if (!mount.source) {
|
|
1259
|
-
throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config);
|
|
1166
|
+
|
|
1167
|
+
// src/config/builtin.ts
|
|
1168
|
+
var DEFAULT_DATA_ROOT = "~/.local/share/gamecrate";
|
|
1169
|
+
var DEFAULT_SETTINGS = {
|
|
1170
|
+
width: 1920,
|
|
1171
|
+
height: 1080,
|
|
1172
|
+
devMode: true,
|
|
1173
|
+
runInBackground: true,
|
|
1174
|
+
resetModsConfigOnCrash: false,
|
|
1175
|
+
gpu: true,
|
|
1176
|
+
audio: true,
|
|
1177
|
+
input: false,
|
|
1178
|
+
network: "bridge",
|
|
1179
|
+
display: "x11",
|
|
1180
|
+
memory: "8g",
|
|
1181
|
+
cpus: 6,
|
|
1182
|
+
pidsLimit: 1024
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
// src/config/read.ts
|
|
1186
|
+
import { readFile } from "node:fs/promises";
|
|
1187
|
+
import { extname } from "node:path";
|
|
1188
|
+
import { parseTree } from "jsonc-parser";
|
|
1189
|
+
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
1190
|
+
|
|
1191
|
+
// src/config/jsonc.ts
|
|
1192
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
1193
|
+
function parseJsonc(text) {
|
|
1194
|
+
const errors = [];
|
|
1195
|
+
const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false });
|
|
1196
|
+
const first = errors[0];
|
|
1197
|
+
if (first !== undefined) {
|
|
1198
|
+
throw new GamecrateError("config is not valid JSON", Exit.Config, `${printParseErrorCode(first.error)} at offset ${first.offset}`);
|
|
1260
1199
|
}
|
|
1261
|
-
|
|
1262
|
-
if (mount.readonly)
|
|
1263
|
-
fields.push("readonly");
|
|
1264
|
-
return ["--mount", fields.map(csvField).join(",")];
|
|
1265
|
-
}
|
|
1266
|
-
function csvField(field) {
|
|
1267
|
-
if (!field.includes(",") && !field.includes('"'))
|
|
1268
|
-
return field;
|
|
1269
|
-
return `"${field.replaceAll('"', '""')}"`;
|
|
1200
|
+
return value;
|
|
1270
1201
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1202
|
+
|
|
1203
|
+
// src/config/read.ts
|
|
1204
|
+
var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
|
|
1205
|
+
function isYaml(path) {
|
|
1206
|
+
const suffix = extname(path).toLowerCase();
|
|
1207
|
+
if (suffix === ".yml" || suffix === ".yaml")
|
|
1208
|
+
return true;
|
|
1209
|
+
if (suffix === ".json" || suffix === ".jsonc")
|
|
1210
|
+
return false;
|
|
1211
|
+
throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
|
|
1273
1212
|
}
|
|
1274
|
-
function
|
|
1275
|
-
if (
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1213
|
+
function readConfigText(text, path) {
|
|
1214
|
+
if (!isYaml(path)) {
|
|
1215
|
+
try {
|
|
1216
|
+
return parseJsonc(text);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (error instanceof GamecrateError) {
|
|
1219
|
+
throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
|
|
1220
|
+
}
|
|
1221
|
+
throw error;
|
|
1222
|
+
}
|
|
1284
1223
|
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
while (end > 1 && path[end - 1] === "/")
|
|
1290
|
-
end--;
|
|
1291
|
-
return path.slice(0, end);
|
|
1292
|
-
}
|
|
1293
|
-
function glEnv(gpu) {
|
|
1294
|
-
if (!gpu)
|
|
1295
|
-
return { LIBGL_ALWAYS_SOFTWARE: "1", GALLIUM_DRIVER: "llvmpipe" };
|
|
1296
|
-
const env = { LIBGL_ALWAYS_SOFTWARE: "0", GALLIUM_DRIVER: "" };
|
|
1297
|
-
if (hasNvidia()) {
|
|
1298
|
-
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
|
|
1299
|
-
env.__NV_PRIME_RENDER_OFFLOAD = "1";
|
|
1224
|
+
try {
|
|
1225
|
+
return parseYaml(text);
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
|
|
1300
1228
|
}
|
|
1301
|
-
return env;
|
|
1302
|
-
}
|
|
1303
|
-
function hasNvidia() {
|
|
1304
|
-
return existsSync("/dev/nvidiactl") || existsSync("/etc/cdi/nvidia.yaml") || existsSync("/usr/share/vulkan/icd.d/nvidia_icd.json");
|
|
1305
|
-
}
|
|
1306
|
-
function x11Session() {
|
|
1307
|
-
const display = process.env.DISPLAY;
|
|
1308
|
-
if (!display)
|
|
1309
|
-
return null;
|
|
1310
|
-
const cookie = process.env.XAUTHORITY ?? join(homedir(), ".Xauthority");
|
|
1311
|
-
return { display, xauthority: existsSync(cookie) ? cookie : null };
|
|
1312
|
-
}
|
|
1313
|
-
function waylandSocket() {
|
|
1314
|
-
const display = process.env.WAYLAND_DISPLAY;
|
|
1315
|
-
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
1316
|
-
if (!display)
|
|
1317
|
-
return null;
|
|
1318
|
-
let source = null;
|
|
1319
|
-
if (display.startsWith("/"))
|
|
1320
|
-
source = display;
|
|
1321
|
-
else if (runtime)
|
|
1322
|
-
source = join(runtime, display);
|
|
1323
|
-
if (!source || !existsSync(source))
|
|
1324
|
-
return null;
|
|
1325
|
-
return { source, name: basename(source) };
|
|
1326
1229
|
}
|
|
1327
|
-
function
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
found.push({ source, name });
|
|
1230
|
+
async function readConfigFile(path) {
|
|
1231
|
+
let text;
|
|
1232
|
+
try {
|
|
1233
|
+
text = await readFile(path, "utf8");
|
|
1234
|
+
} catch (error) {
|
|
1235
|
+
if (error.code === "ENOENT")
|
|
1236
|
+
return;
|
|
1237
|
+
throw error;
|
|
1336
1238
|
}
|
|
1337
|
-
return
|
|
1239
|
+
return readConfigText(text, path);
|
|
1338
1240
|
}
|
|
1339
|
-
function
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1241
|
+
function orderedKeys(text, path, key) {
|
|
1242
|
+
if (isYaml(path)) {
|
|
1243
|
+
const node = parseDocument(text).get(key, true);
|
|
1244
|
+
if (!isMap(node))
|
|
1245
|
+
return [];
|
|
1246
|
+
return node.items.map((item) => String(item.key.value ?? item.key));
|
|
1344
1247
|
}
|
|
1248
|
+
const root = parseTree(text);
|
|
1249
|
+
const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
|
|
1250
|
+
const value = holder?.children?.[1];
|
|
1251
|
+
if (value?.type !== "object")
|
|
1252
|
+
return [];
|
|
1253
|
+
return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
|
|
1345
1254
|
}
|
|
1346
1255
|
|
|
1347
|
-
// src/
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
function
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1256
|
+
// src/config/validate.ts
|
|
1257
|
+
import { z } from "zod";
|
|
1258
|
+
var MODES2 = ["headed", "headless", "screenshot"];
|
|
1259
|
+
var HINTS = "\x00gamecrate/hints:";
|
|
1260
|
+
function obj(shape) {
|
|
1261
|
+
const known = Object.keys(shape);
|
|
1262
|
+
return z.strictObject(shape, {
|
|
1263
|
+
error: (issue) => issue.code === "unrecognized_keys" ? HINTS + JSON.stringify(issue.keys.map((key) => suggest(key, known) ?? null)) : "expected an object"
|
|
1355
1264
|
});
|
|
1356
1265
|
}
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
chunks.push(chunk);
|
|
1361
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
1362
|
-
}
|
|
1363
|
-
async function capture(argv) {
|
|
1266
|
+
function hintsFor(message) {
|
|
1267
|
+
if (!message.startsWith(HINTS))
|
|
1268
|
+
return [];
|
|
1364
1269
|
try {
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
collect2(proc.stderr),
|
|
1369
|
-
exited(proc)
|
|
1370
|
-
]);
|
|
1371
|
-
return { code, stdout, stderr };
|
|
1372
|
-
} catch (error) {
|
|
1373
|
-
return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
1270
|
+
return JSON.parse(message.slice(HINTS.length));
|
|
1271
|
+
} catch {
|
|
1272
|
+
return [];
|
|
1374
1273
|
}
|
|
1375
1274
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
const keep = async (stream) => {
|
|
1380
|
-
for await (const chunk of stream) {
|
|
1381
|
-
process.stderr.write(chunk);
|
|
1382
|
-
chunks.push(chunk);
|
|
1383
|
-
}
|
|
1384
|
-
};
|
|
1385
|
-
const [, , code] = await Promise.all([keep(proc.stdout), keep(proc.stderr), exited(proc)]);
|
|
1386
|
-
return { code, text: Buffer.concat(chunks).toString("utf8") };
|
|
1387
|
-
}
|
|
1388
|
-
var STDOUT_LOG = "stdout.log";
|
|
1389
|
-
var MARKER_POLL_MS = 200;
|
|
1390
|
-
async function runContainer(spec, opts) {
|
|
1391
|
-
const stopTimeout = opts.stopTimeoutSeconds ?? 10;
|
|
1392
|
-
mkdirSync(opts.logDir, { recursive: true });
|
|
1393
|
-
const sink = createWriteStream(join2(opts.logDir, STDOUT_LOG));
|
|
1394
|
-
const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
|
|
1395
|
-
let interrupted = false;
|
|
1396
|
-
const onSignal = () => {
|
|
1397
|
-
if (interrupted)
|
|
1275
|
+
function requiredWhen(key, when) {
|
|
1276
|
+
return (ctx) => {
|
|
1277
|
+
if (!when(ctx.value) || ctx.value[key] !== undefined)
|
|
1398
1278
|
return;
|
|
1399
|
-
|
|
1400
|
-
stopContainer(spec.name, stopTimeout);
|
|
1279
|
+
ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
|
|
1401
1280
|
};
|
|
1402
|
-
process.on("SIGINT", onSignal);
|
|
1403
|
-
process.on("SIGTERM", onSignal);
|
|
1404
|
-
const code = exited(proc);
|
|
1405
|
-
try {
|
|
1406
|
-
await Promise.all([
|
|
1407
|
-
tee(proc.stdout, sink, process.stdout),
|
|
1408
|
-
tee(proc.stderr, sink, process.stderr)
|
|
1409
|
-
]);
|
|
1410
|
-
const status = await code;
|
|
1411
|
-
return interrupted ? Exit.Interrupted : status;
|
|
1412
|
-
} finally {
|
|
1413
|
-
process.off("SIGINT", onSignal);
|
|
1414
|
-
process.off("SIGTERM", onSignal);
|
|
1415
|
-
await new Promise((resolve) => sink.end(resolve));
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
var STOP_TIMEOUT_SECONDS = 10;
|
|
1419
|
-
async function stopContainer(name, timeoutSeconds) {
|
|
1420
|
-
const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
|
|
1421
|
-
await exited(proc).catch(() => {});
|
|
1422
1281
|
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
}
|
|
1435
|
-
if (await scan(path, state, marker, carry))
|
|
1436
|
-
return true;
|
|
1437
|
-
}
|
|
1438
|
-
if (Date.now() >= deadline)
|
|
1439
|
-
return false;
|
|
1440
|
-
await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
async function staleSize(path, startedAt) {
|
|
1444
|
-
return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
|
|
1445
|
-
}
|
|
1446
|
-
async function scan(path, state, marker, carry) {
|
|
1447
|
-
const handle = await open(path, "r").catch(() => null);
|
|
1448
|
-
if (handle === null)
|
|
1449
|
-
return false;
|
|
1450
|
-
try {
|
|
1451
|
-
const { size } = await handle.stat();
|
|
1452
|
-
if (size < state.offset) {
|
|
1453
|
-
state.offset = 0;
|
|
1454
|
-
state.tail = "";
|
|
1455
|
-
state.decoder = new TextDecoder;
|
|
1456
|
-
}
|
|
1457
|
-
if (size <= state.offset)
|
|
1458
|
-
return false;
|
|
1459
|
-
const buffer = Buffer.alloc(size - state.offset);
|
|
1460
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
|
|
1461
|
-
state.offset += bytesRead;
|
|
1462
|
-
const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
|
|
1463
|
-
if (text.includes(marker))
|
|
1464
|
-
return true;
|
|
1465
|
-
state.tail = carry > 0 ? text.slice(-carry) : "";
|
|
1466
|
-
return false;
|
|
1467
|
-
} catch {
|
|
1468
|
-
return false;
|
|
1469
|
-
} finally {
|
|
1470
|
-
await handle.close().catch(() => {});
|
|
1471
|
-
}
|
|
1282
|
+
var str = z.string({ error: "expected a string" });
|
|
1283
|
+
var num = z.number({ error: "expected a number" });
|
|
1284
|
+
var bool = z.boolean({ error: "expected a boolean" });
|
|
1285
|
+
var strArray = z.array(z.string({ error: "expected an array of strings" }), {
|
|
1286
|
+
error: "expected an array of strings"
|
|
1287
|
+
});
|
|
1288
|
+
var strMap = z.record(z.string(), z.string({ error: "expected an object of string values" }), {
|
|
1289
|
+
error: "expected an object of string values"
|
|
1290
|
+
});
|
|
1291
|
+
function oneOf(values) {
|
|
1292
|
+
return z.enum(values, { error: `expected one of ${values.join(", ")}` });
|
|
1472
1293
|
}
|
|
1473
|
-
|
|
1474
|
-
const
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1294
|
+
var modeName = z.unknown().check((ctx) => {
|
|
1295
|
+
const value = ctx.value;
|
|
1296
|
+
if (typeof value === "string" && MODES2.includes(value))
|
|
1297
|
+
return;
|
|
1298
|
+
const hint = typeof value === "string" ? suggest(value, MODES2) : undefined;
|
|
1299
|
+
ctx.issues.push({
|
|
1300
|
+
code: "custom",
|
|
1301
|
+
message: `expected one of ${MODES2.join(", ")}`,
|
|
1302
|
+
input: value,
|
|
1303
|
+
...hint === undefined ? {} : { params: { suggestion: `did you mean "${hint}"?` } }
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
var settings = obj({
|
|
1307
|
+
width: num.optional(),
|
|
1308
|
+
height: num.optional(),
|
|
1309
|
+
devMode: bool.optional(),
|
|
1310
|
+
runInBackground: bool.optional(),
|
|
1311
|
+
resetModsConfigOnCrash: bool.optional(),
|
|
1312
|
+
gpu: bool.optional(),
|
|
1313
|
+
audio: bool.optional(),
|
|
1314
|
+
input: bool.optional(),
|
|
1315
|
+
network: oneOf(["none", "bridge", "host"]).optional(),
|
|
1316
|
+
display: oneOf(["x11", "wayland"]).optional(),
|
|
1317
|
+
memory: str.optional(),
|
|
1318
|
+
cpus: num.optional(),
|
|
1319
|
+
pidsLimit: num.optional(),
|
|
1320
|
+
prefsExtra: strMap.optional(),
|
|
1321
|
+
gameArgs: strArray.optional(),
|
|
1322
|
+
dockerArgs: strArray.optional()
|
|
1323
|
+
});
|
|
1324
|
+
var dynamicModEntry = obj({
|
|
1325
|
+
match: str,
|
|
1326
|
+
first: strArray.optional(),
|
|
1327
|
+
sort: oneOf(["alpha", "none"]).optional(),
|
|
1328
|
+
minMatches: num.optional()
|
|
1329
|
+
});
|
|
1330
|
+
var objectModEntry = obj({
|
|
1331
|
+
id: str,
|
|
1332
|
+
workshop: num.optional(),
|
|
1333
|
+
path: str.optional(),
|
|
1334
|
+
optional: bool.optional()
|
|
1335
|
+
});
|
|
1336
|
+
var modEntry = z.unknown().check((ctx) => {
|
|
1337
|
+
const value = ctx.value;
|
|
1338
|
+
if (typeof value === "string") {
|
|
1339
|
+
if (value.trim() === "")
|
|
1340
|
+
ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
|
|
1341
|
+
return;
|
|
1490
1342
|
}
|
|
1491
|
-
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
for await (const chunk of stream) {
|
|
1495
|
-
mirror.write(chunk);
|
|
1496
|
-
sink.write(chunk);
|
|
1343
|
+
if (!isObj(value)) {
|
|
1344
|
+
ctx.issues.push({ code: "custom", message: "expected a packageId string or an object", input: value });
|
|
1345
|
+
return;
|
|
1497
1346
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
import { readdir as readdir2, stat as stat2 } from "node:fs/promises";
|
|
1502
|
-
import { join as join3, relative } from "node:path";
|
|
1503
|
-
var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
|
|
1504
|
-
var ENTRY_LIMIT = 20000;
|
|
1505
|
-
async function scanBuildTimes(dir) {
|
|
1506
|
-
const state = { root: dir, times: { sourceTimes: [] }, budget: ENTRY_LIMIT };
|
|
1507
|
-
await walk(state, dir, false);
|
|
1508
|
-
return state.times;
|
|
1509
|
-
}
|
|
1510
|
-
async function walk(state, current, inAssemblies) {
|
|
1511
|
-
let entries;
|
|
1512
|
-
try {
|
|
1513
|
-
entries = await readdir2(current, { withFileTypes: true });
|
|
1514
|
-
} catch {
|
|
1347
|
+
const schema = value["match"] !== undefined ? dynamicModEntry : objectModEntry;
|
|
1348
|
+
const result = schema.safeParse(value);
|
|
1349
|
+
if (result.success)
|
|
1515
1350
|
return;
|
|
1351
|
+
for (const issue of result.error.issues)
|
|
1352
|
+
ctx.issues.push({ ...issue, input: value });
|
|
1353
|
+
});
|
|
1354
|
+
var profile = obj({
|
|
1355
|
+
mods: z.array(modEntry, { error: "expected an array" }).optional(),
|
|
1356
|
+
extends: str.optional(),
|
|
1357
|
+
exclude: strArray.optional(),
|
|
1358
|
+
includeBase: bool.optional(),
|
|
1359
|
+
autoDependencies: bool.optional(),
|
|
1360
|
+
settings: settings.optional(),
|
|
1361
|
+
instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
|
|
1362
|
+
error: "expected an object"
|
|
1363
|
+
}).optional(),
|
|
1364
|
+
alias: str.optional(),
|
|
1365
|
+
aliases: strArray.optional(),
|
|
1366
|
+
description: str.optional(),
|
|
1367
|
+
gameVersion: str.optional(),
|
|
1368
|
+
image: str.optional(),
|
|
1369
|
+
windowTitle: str.optional(),
|
|
1370
|
+
windowIcon: str.optional(),
|
|
1371
|
+
detach: bool.optional(),
|
|
1372
|
+
replace: bool.optional(),
|
|
1373
|
+
build: oneOf(["auto", "always", "never"]).optional()
|
|
1374
|
+
}).check((ctx) => {
|
|
1375
|
+
const v = ctx.value;
|
|
1376
|
+
if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
|
|
1377
|
+
ctx.issues.push({
|
|
1378
|
+
code: "custom",
|
|
1379
|
+
message: 'an alias profile cannot also declare "mods" or "extends"',
|
|
1380
|
+
input: v
|
|
1381
|
+
});
|
|
1516
1382
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1383
|
+
});
|
|
1384
|
+
var libraryEntry = obj({
|
|
1385
|
+
workshop: num.optional(),
|
|
1386
|
+
path: str.optional(),
|
|
1387
|
+
git: str.optional(),
|
|
1388
|
+
branch: str.optional(),
|
|
1389
|
+
tag: str.optional(),
|
|
1390
|
+
commit: str.optional(),
|
|
1391
|
+
subdir: str.optional()
|
|
1392
|
+
}).check((ctx) => {
|
|
1393
|
+
const v = ctx.value;
|
|
1394
|
+
const push = (message, path) => {
|
|
1395
|
+
ctx.issues.push({ code: "custom", message, input: v, ...path === undefined ? {} : { path } });
|
|
1396
|
+
};
|
|
1397
|
+
const sources = ["workshop", "path", "git"].filter((k) => v[k] !== undefined);
|
|
1398
|
+
if (sources.length === 0)
|
|
1399
|
+
push('library entry needs a "workshop" id, a "path", or a "git" url');
|
|
1400
|
+
if (sources.length > 1)
|
|
1401
|
+
push(`library entry takes only one of ${sources.join(", ")}`);
|
|
1402
|
+
const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
|
|
1403
|
+
if (refs.length > 1)
|
|
1404
|
+
push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
|
|
1405
|
+
if (v["git"] === undefined) {
|
|
1406
|
+
for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
|
|
1407
|
+
push(`"${key}" needs a "git" url`, [key]);
|
|
1526
1408
|
}
|
|
1527
|
-
if (entry.isFile())
|
|
1528
|
-
await record(state, path, entry.name, inAssemblies);
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
async function record(state, path, name, inAssemblies) {
|
|
1532
|
-
const lower = name.toLowerCase();
|
|
1533
|
-
const isSource = lower.endsWith(".cs");
|
|
1534
|
-
const isAssembly = inAssemblies && lower.endsWith(".dll");
|
|
1535
|
-
if (!isSource && !isAssembly)
|
|
1536
|
-
return;
|
|
1537
|
-
let mtimeMs;
|
|
1538
|
-
try {
|
|
1539
|
-
mtimeMs = (await stat2(path)).mtimeMs;
|
|
1540
|
-
} catch {
|
|
1541
|
-
return;
|
|
1542
1409
|
}
|
|
1543
|
-
const
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
times.sourceTimes.push(mtimeMs);
|
|
1547
|
-
if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
|
|
1548
|
-
times.newestSource = found;
|
|
1549
|
-
} else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
|
|
1550
|
-
times.newestAssembly = found;
|
|
1410
|
+
const subdir = v["subdir"];
|
|
1411
|
+
if (typeof subdir === "string" && (subdir.startsWith("/") || subdir.split("/").includes(".."))) {
|
|
1412
|
+
push('"subdir" must be a relative path inside the repo, with no ".." segment', ["subdir"]);
|
|
1551
1413
|
}
|
|
1414
|
+
});
|
|
1415
|
+
function repeats(entries, key) {
|
|
1416
|
+
const seen = new Set;
|
|
1417
|
+
const found = [];
|
|
1418
|
+
entries.forEach((entry, index) => {
|
|
1419
|
+
const name = entry?.[key];
|
|
1420
|
+
if (typeof name !== "string")
|
|
1421
|
+
return;
|
|
1422
|
+
if (seen.has(name))
|
|
1423
|
+
found.push({ index, name });
|
|
1424
|
+
else
|
|
1425
|
+
seen.add(name);
|
|
1426
|
+
});
|
|
1427
|
+
return found;
|
|
1552
1428
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
return source - assembly > SKEW_MS;
|
|
1556
|
-
}
|
|
1557
|
-
function decideStale(times) {
|
|
1558
|
-
const { newestSource, newestAssembly } = times;
|
|
1559
|
-
if (newestSource === undefined)
|
|
1560
|
-
return false;
|
|
1561
|
-
return newestAssembly === undefined || newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs);
|
|
1429
|
+
function describe(value) {
|
|
1430
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
1562
1431
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
assemblyMs: newestAssembly.mtimeMs,
|
|
1574
|
-
newerCount: times.sourceTimes.filter((t) => newerThan(t, newestAssembly.mtimeMs)).length
|
|
1575
|
-
};
|
|
1576
|
-
}
|
|
1577
|
-
var INDENT = " ".repeat("warning: ".length);
|
|
1578
|
-
function staleWarning(packageId, report, now = Date.now()) {
|
|
1579
|
-
const files = report.newerCount === 1 ? "1 source file" : `${report.newerCount} source files`;
|
|
1580
|
-
return [
|
|
1581
|
-
`${packageId} has ${files} newer than ${report.assembly}`,
|
|
1582
|
-
`${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
|
|
1583
|
-
`${INDENT}you are probably running a stale build`
|
|
1584
|
-
].join(`
|
|
1585
|
-
`);
|
|
1586
|
-
}
|
|
1587
|
-
function duration(ms) {
|
|
1588
|
-
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
1589
|
-
if (seconds < 60)
|
|
1590
|
-
return `${seconds}s`;
|
|
1591
|
-
if (seconds < 3600)
|
|
1592
|
-
return `${Math.floor(seconds / 60)}m`;
|
|
1593
|
-
if (seconds < 86400)
|
|
1594
|
-
return `${Math.floor(seconds / 3600)}h`;
|
|
1595
|
-
return `${Math.floor(seconds / 86400)}d`;
|
|
1596
|
-
}
|
|
1597
|
-
function ago(mtimeMs, now = Date.now()) {
|
|
1598
|
-
return `${duration(now - mtimeMs)} ago`;
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
// src/cli/output.ts
|
|
1602
|
-
function status(message) {
|
|
1603
|
-
process.stderr.write(line(message));
|
|
1604
|
-
}
|
|
1605
|
-
function warn(message) {
|
|
1606
|
-
process.stderr.write(line(`warning: ${message}`));
|
|
1607
|
-
}
|
|
1608
|
-
function redirectOutput(path, keep = false) {
|
|
1609
|
-
const fd = openSync(path, keep ? "a" : "w");
|
|
1610
|
-
const stdout = process.stdout.write;
|
|
1611
|
-
const stderr = process.stderr.write;
|
|
1612
|
-
let open = true;
|
|
1613
|
-
const write = (chunk, encodingOrCallback, callback) => {
|
|
1614
|
-
append(fd, chunk);
|
|
1615
|
-
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
1616
|
-
done?.();
|
|
1617
|
-
return true;
|
|
1618
|
-
};
|
|
1619
|
-
process.stdout.write = write;
|
|
1620
|
-
process.stderr.write = write;
|
|
1621
|
-
return {
|
|
1622
|
-
close() {
|
|
1623
|
-
if (!open)
|
|
1624
|
-
return;
|
|
1625
|
-
open = false;
|
|
1626
|
-
process.stdout.write = stdout;
|
|
1627
|
-
process.stderr.write = stderr;
|
|
1628
|
-
closeSync(fd);
|
|
1629
|
-
}
|
|
1432
|
+
var TAG_COMPONENT = /^\w[\w.-]*$/;
|
|
1433
|
+
function steamBuildRules(ctx) {
|
|
1434
|
+
const push = (message, path, suggestion) => {
|
|
1435
|
+
ctx.issues.push({
|
|
1436
|
+
code: "custom",
|
|
1437
|
+
message,
|
|
1438
|
+
path,
|
|
1439
|
+
input: ctx.value,
|
|
1440
|
+
...suggestion === undefined ? {} : { params: { suggestion } }
|
|
1441
|
+
});
|
|
1630
1442
|
};
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
}
|
|
1636
|
-
function line(message) {
|
|
1637
|
-
return message.endsWith(`
|
|
1638
|
-
`) ? message : `${message}
|
|
1639
|
-
`;
|
|
1640
|
-
}
|
|
1641
|
-
function reportProblems(problems) {
|
|
1642
|
-
if (problems.length === 0) {
|
|
1643
|
-
throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
|
|
1644
|
-
}
|
|
1645
|
-
const groups = new Map;
|
|
1646
|
-
for (const problem of problems) {
|
|
1647
|
-
const group = groups.get(problem.where);
|
|
1648
|
-
if (group)
|
|
1649
|
-
group.push(problem);
|
|
1650
|
-
else
|
|
1651
|
-
groups.set(problem.where, [problem]);
|
|
1443
|
+
const branches = ctx.value["branches"];
|
|
1444
|
+
const variants = ctx.value["variants"];
|
|
1445
|
+
if (Array.isArray(variants) && variants.length === 0) {
|
|
1446
|
+
push("steamBuild.variants cannot be empty", ["variants"]);
|
|
1652
1447
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
for (const
|
|
1657
|
-
|
|
1658
|
-
if (problem.suggestion)
|
|
1659
|
-
out.push(` did you mean ${problem.suggestion}?`);
|
|
1448
|
+
if (Array.isArray(branches)) {
|
|
1449
|
+
if (branches.length === 0)
|
|
1450
|
+
push("steamBuild.branches cannot be empty", ["branches"]);
|
|
1451
|
+
for (const dup of repeats(branches, "name")) {
|
|
1452
|
+
push(`duplicate branch name "${dup.name}"`, ["branches", dup.index, "name"]);
|
|
1660
1453
|
}
|
|
1454
|
+
branches.forEach((branch, index) => {
|
|
1455
|
+
const name = branch?.["name"];
|
|
1456
|
+
if (typeof name === "string" && !TAG_COMPONENT.test(name)) {
|
|
1457
|
+
push(`branch name "${name}" must match ${TAG_COMPONENT.source}`, ["branches", index, "name"]);
|
|
1458
|
+
}
|
|
1459
|
+
const tags = branch?.["tags"];
|
|
1460
|
+
if (!Array.isArray(tags))
|
|
1461
|
+
return;
|
|
1462
|
+
tags.forEach((tag, at) => {
|
|
1463
|
+
if (typeof tag !== "string" || TAG_COMPONENT.test(tag))
|
|
1464
|
+
return;
|
|
1465
|
+
push(`branch tag "${String(tag)}" must match ${TAG_COMPONENT.source}`, ["branches", index, "tags", at]);
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1661
1468
|
}
|
|
1662
|
-
|
|
1663
|
-
`));
|
|
1664
|
-
}
|
|
1665
|
-
function planWarnings(plan) {
|
|
1666
|
-
if (!plan.warnOnStale)
|
|
1667
|
-
return plan.warnings;
|
|
1668
|
-
const stale = plan.mods.filter((mod) => mod.staleReport !== undefined).map((mod) => staleWarning(mod.packageId, mod.staleReport));
|
|
1669
|
-
return [...plan.warnings, ...stale];
|
|
1670
|
-
}
|
|
1671
|
-
function planPayload(plan) {
|
|
1672
|
-
return {
|
|
1673
|
-
game: plan.game,
|
|
1674
|
-
profile: plan.profile,
|
|
1675
|
-
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
1676
|
-
mode: plan.mode,
|
|
1677
|
-
...plan.marker === undefined ? {} : { marker: plan.marker },
|
|
1678
|
-
timeoutSeconds: plan.timeoutSeconds,
|
|
1679
|
-
renderWaitSeconds: plan.renderWaitSeconds,
|
|
1680
|
-
profileDir: resolve(plan.profileDir),
|
|
1681
|
-
instanceDir: resolve(plan.instanceDir),
|
|
1682
|
-
containerName: containerName(plan),
|
|
1683
|
-
dataDirHost: resolve(plan.dataDirHost),
|
|
1684
|
-
stageDirHost: resolve(plan.stageDirHost),
|
|
1685
|
-
logsDirHost: resolve(plan.logsDirHost),
|
|
1686
|
-
mods: plan.mods.map((mod) => ({
|
|
1687
|
-
packageId: mod.packageId,
|
|
1688
|
-
kind: mod.kind,
|
|
1689
|
-
hostDir: resolve(mod.hostDir),
|
|
1690
|
-
containerDir: mod.containerDir,
|
|
1691
|
-
origin: mod.explicit ? "explicit" : "auto",
|
|
1692
|
-
stale: mod.stale === true,
|
|
1693
|
-
...mod.staleReport === undefined ? {} : { staleReport: mod.staleReport },
|
|
1694
|
-
...mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }
|
|
1695
|
-
})),
|
|
1696
|
-
warnings: planWarnings(plan)
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
function printPlan(plan, asJson) {
|
|
1700
|
-
const payload = planPayload(plan);
|
|
1701
|
-
if (asJson) {
|
|
1702
|
-
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
1703
|
-
`);
|
|
1469
|
+
if (!Array.isArray(variants))
|
|
1704
1470
|
return;
|
|
1471
|
+
for (const dup of repeats(variants, "name")) {
|
|
1472
|
+
push(`duplicate variant name "${dup.name}"`, ["variants", dup.index, "name"]);
|
|
1705
1473
|
}
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
` instance ${payload.instanceDir}`,
|
|
1711
|
-
` container ${payload.containerName}`,
|
|
1712
|
-
` data ${payload.dataDirHost}`,
|
|
1713
|
-
` stage ${payload.stageDirHost}`,
|
|
1714
|
-
` logs ${payload.logsDirHost}`
|
|
1715
|
-
];
|
|
1716
|
-
if (payload.marker !== undefined)
|
|
1717
|
-
out.push(` marker ${payload.marker}`);
|
|
1718
|
-
out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`, ` mods ${payload.mods.length}`);
|
|
1719
|
-
const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
|
|
1720
|
-
for (const mod of payload.mods) {
|
|
1721
|
-
const notes = [mod.kind, mod.origin];
|
|
1722
|
-
if (mod.stale)
|
|
1723
|
-
notes.push("stale");
|
|
1724
|
-
out.push(` ${mod.packageId.padEnd(width)} ${notes.join(" ")} ${mod.hostDir} -> ${mod.containerDir}`);
|
|
1725
|
-
}
|
|
1726
|
-
for (const warning of payload.warnings)
|
|
1727
|
-
out.push(` warning: ${warning}`);
|
|
1728
|
-
process.stdout.write(`${out.join(`
|
|
1729
|
-
`)}
|
|
1730
|
-
`);
|
|
1731
|
-
}
|
|
1732
|
-
function runTimestamp(now = new Date) {
|
|
1733
|
-
return now.toISOString().replaceAll(/[-:.]/g, "");
|
|
1734
|
-
}
|
|
1735
|
-
function openRunLog(logsDir, now) {
|
|
1736
|
-
const runsDir = join4(logsDir, "runs");
|
|
1737
|
-
mkdirSync2(runsDir, { recursive: true });
|
|
1738
|
-
const dir = uniqueRunDir(runsDir, runTimestamp(now));
|
|
1739
|
-
mkdirSync2(dir);
|
|
1740
|
-
linkCurrent(logsDir, dir);
|
|
1741
|
-
rotateRuns(logsDir, 10);
|
|
1742
|
-
return dir;
|
|
1743
|
-
}
|
|
1744
|
-
var encoder = new TextEncoder;
|
|
1745
|
-
function append(fd, chunk) {
|
|
1746
|
-
writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
|
1747
|
-
}
|
|
1748
|
-
function uniqueRunDir(runsDir, stamp) {
|
|
1749
|
-
let candidate = join4(runsDir, stamp);
|
|
1750
|
-
let n = 2;
|
|
1751
|
-
while (existsSync2(candidate)) {
|
|
1752
|
-
candidate = join4(runsDir, `${stamp}-${n}`);
|
|
1753
|
-
n++;
|
|
1754
|
-
}
|
|
1755
|
-
return candidate;
|
|
1756
|
-
}
|
|
1757
|
-
var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
|
|
1758
|
-
function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
|
|
1759
|
-
if (waitedMs < schedule.firstMs)
|
|
1760
|
-
return;
|
|
1761
|
-
if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
|
|
1762
|
-
return;
|
|
1763
|
-
return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
|
|
1764
|
-
}
|
|
1765
|
-
function runStartedAt(name) {
|
|
1766
|
-
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
|
|
1767
|
-
if (m === null)
|
|
1768
|
-
return;
|
|
1769
|
-
const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
|
|
1770
|
-
return Number.isNaN(at) ? undefined : at;
|
|
1771
|
-
}
|
|
1772
|
-
function currentLog(instanceDir) {
|
|
1773
|
-
return join4(instanceDir, "logs", "current", STDOUT_LOG);
|
|
1774
|
-
}
|
|
1775
|
-
function tailArgv(file, fromStart, livePid) {
|
|
1776
|
-
const argv = ["tail"];
|
|
1777
|
-
if (fromStart)
|
|
1778
|
-
argv.push("-n", "+1");
|
|
1779
|
-
if (livePid !== undefined)
|
|
1780
|
-
argv.push("-f", "--pid", String(livePid));
|
|
1781
|
-
argv.push(file);
|
|
1782
|
-
return argv;
|
|
1783
|
-
}
|
|
1784
|
-
function linkCurrent(logsDir, target) {
|
|
1785
|
-
const link = join4(logsDir, "current");
|
|
1786
|
-
try {
|
|
1787
|
-
lstatSync(link);
|
|
1788
|
-
unlinkSync(link);
|
|
1789
|
-
} catch {}
|
|
1790
|
-
symlinkSync(join4("runs", basename2(target)), link, "dir");
|
|
1791
|
-
}
|
|
1792
|
-
function rotateRuns(logsDir, keep) {
|
|
1793
|
-
const runsDir = join4(logsDir, "runs");
|
|
1794
|
-
if (keep < 1 || !existsSync2(runsDir))
|
|
1795
|
-
return [];
|
|
1796
|
-
const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
|
1797
|
-
const removed = dirs.slice(keep);
|
|
1798
|
-
for (const name of removed)
|
|
1799
|
-
rmSync(join4(runsDir, name), { recursive: true, force: true });
|
|
1800
|
-
return removed;
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
// src/launch/prepare.ts
|
|
1804
|
-
import { existsSync as existsSync3, readFileSync } from "node:fs";
|
|
1805
|
-
import { open as open2, readdir as readdir3, readFile, unlink, writeFile } from "node:fs/promises";
|
|
1806
|
-
import { join as join5 } from "node:path";
|
|
1807
|
-
import { setTimeout as sleep2 } from "node:timers/promises";
|
|
1808
|
-
async function inherit(argv) {
|
|
1809
|
-
const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
|
|
1810
|
-
const code = exited(proc);
|
|
1811
|
-
await Promise.all([
|
|
1812
|
-
forwardOutput(proc.stdout, process.stdout),
|
|
1813
|
-
forwardOutput(proc.stderr, process.stderr)
|
|
1814
|
-
]);
|
|
1815
|
-
return code;
|
|
1816
|
-
}
|
|
1817
|
-
async function imageDigest(ref) {
|
|
1818
|
-
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{.Id}}", ref]);
|
|
1819
|
-
const id = stdout.trim();
|
|
1820
|
-
return code === 0 && id.length > 0 ? id : null;
|
|
1821
|
-
}
|
|
1822
|
-
async function readFromImage(ref, path) {
|
|
1823
|
-
const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "cat", ref, path]);
|
|
1824
|
-
return code === 0 ? stdout : null;
|
|
1825
|
-
}
|
|
1826
|
-
async function imageLabel(ref, label) {
|
|
1827
|
-
const format = `{{index .Config.Labels "${label}"}}`;
|
|
1828
|
-
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
|
|
1829
|
-
const value = stdout.trim();
|
|
1830
|
-
if (code !== 0 || value.length === 0 || value === "<no value>")
|
|
1831
|
-
return null;
|
|
1832
|
-
return value;
|
|
1833
|
-
}
|
|
1834
|
-
async function acquireImage(game, config, pull) {
|
|
1835
|
-
const { image } = config;
|
|
1836
|
-
const present = await imageDigest(image.ref) !== null;
|
|
1837
|
-
if (image.acquire === "build")
|
|
1838
|
-
return await buildImage(game, image, present, pull);
|
|
1839
|
-
if (pull === "never") {
|
|
1840
|
-
if (present)
|
|
1474
|
+
variants.forEach((variant, index) => {
|
|
1475
|
+
const v = variant;
|
|
1476
|
+
const base = v?.["base"];
|
|
1477
|
+
if (base !== "xvfb" && base !== "proton")
|
|
1841
1478
|
return;
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
return;
|
|
1846
|
-
if (await inherit(["docker", "pull", image.ref]) !== 0) {
|
|
1847
|
-
if (present)
|
|
1479
|
+
const depot = v?.["depot"] ?? "linux";
|
|
1480
|
+
if (depot === "macos") {
|
|
1481
|
+
push("a macos depot cannot be runnable", ["variants", index, "base"], 'set base to "none"; no macos container runtime exists');
|
|
1848
1482
|
return;
|
|
1849
|
-
|
|
1850
|
-
|
|
1483
|
+
}
|
|
1484
|
+
const wants = depot === "windows" ? "proton" : "xvfb";
|
|
1485
|
+
if (base === wants)
|
|
1486
|
+
return;
|
|
1487
|
+
push(`a ${describe(depot)} depot cannot run on the "${base}" base`, ["variants", index, "base"], depot === "windows" ? 'set base to "proton"; it is the only base with wine' : 'set base to "xvfb", or set depot to "windows" if the image should run under wine');
|
|
1488
|
+
});
|
|
1851
1489
|
}
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1490
|
+
var steamBuildSchema = obj({
|
|
1491
|
+
branches: z.array(obj({
|
|
1492
|
+
name: str,
|
|
1493
|
+
password: bool.optional(),
|
|
1494
|
+
tags: strArray.optional(),
|
|
1495
|
+
executable: z.record(str, str).optional()
|
|
1496
|
+
}), {
|
|
1497
|
+
error: "expected an array"
|
|
1498
|
+
}),
|
|
1499
|
+
variants: z.array(obj({
|
|
1500
|
+
name: str,
|
|
1501
|
+
depot: oneOf(["linux", "windows", "macos"]).optional(),
|
|
1502
|
+
base: oneOf(["xvfb", "proton", "none"]),
|
|
1503
|
+
include: strArray,
|
|
1504
|
+
executable: str.optional()
|
|
1505
|
+
}), { error: "expected an array" })
|
|
1506
|
+
}).check(steamBuildRules);
|
|
1507
|
+
var game = obj({
|
|
1508
|
+
gameFiles: obj({ source: oneOf(["mount", "image"]), host: str.optional(), container: str }).check(requiredWhen("host", (v) => v["source"] === "mount")),
|
|
1509
|
+
dataDir: obj({
|
|
1510
|
+
container: str,
|
|
1511
|
+
mode: oneOf(["arg", "env"]),
|
|
1512
|
+
arg: str.optional(),
|
|
1513
|
+
env: strMap.optional()
|
|
1514
|
+
}).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("env", (v) => v["mode"] === "env")),
|
|
1515
|
+
modsDir: obj({ container: str, mask: strArray.optional() }),
|
|
1516
|
+
logFile: obj({ mode: oneOf(["arg", "copy-out"]), arg: str.optional(), from: str.optional() }).check(requiredWhen("arg", (v) => v["mode"] === "arg"), requiredWhen("from", (v) => v["mode"] === "copy-out")),
|
|
1517
|
+
image: obj({
|
|
1518
|
+
ref: str,
|
|
1519
|
+
acquire: oneOf(["pull", "build"]),
|
|
1520
|
+
context: str.optional(),
|
|
1521
|
+
updates: obj({ check: bool.optional(), everyHours: num.optional() }).optional()
|
|
1522
|
+
}).check(requiredWhen("context", (v) => v["acquire"] === "build")),
|
|
1523
|
+
executable: str,
|
|
1524
|
+
managed: strArray.optional(),
|
|
1525
|
+
steamAppId: num,
|
|
1526
|
+
workshopRoot: z.union([z.string(), z.null()], { error: "expected a string or null" }),
|
|
1527
|
+
scanRoots: z.array(obj({ path: str, maxDepth: num, exclude: strArray.optional() }), {
|
|
1528
|
+
error: "expected an array"
|
|
1529
|
+
}),
|
|
1530
|
+
manifest: obj({ file: str }),
|
|
1531
|
+
modsConfig: obj({ file: str }),
|
|
1532
|
+
prefs: obj({ file: str }),
|
|
1533
|
+
version: obj({ file: str }),
|
|
1534
|
+
steamBuild: steamBuildSchema,
|
|
1535
|
+
saveExtensions: strArray,
|
|
1536
|
+
core: str,
|
|
1537
|
+
dlc: strArray,
|
|
1538
|
+
preCore: strArray.optional(),
|
|
1539
|
+
base: strArray.optional(),
|
|
1540
|
+
library: z.record(z.string(), libraryEntry, { error: "expected an object" }).optional(),
|
|
1541
|
+
modes: z.array(modeName, { error: "expected a non-empty array" }).min(1, {
|
|
1542
|
+
error: "expected a non-empty array"
|
|
1543
|
+
}),
|
|
1544
|
+
aliases: strMap.optional(),
|
|
1545
|
+
settings: settings.optional(),
|
|
1546
|
+
ignoresWmDelete: bool.optional(),
|
|
1547
|
+
profiles: z.record(z.string(), profile, { error: "expected an object" })
|
|
1548
|
+
});
|
|
1549
|
+
var root = obj({
|
|
1550
|
+
plugins: strArray.optional(),
|
|
1551
|
+
dataRoot: str,
|
|
1552
|
+
defaults: obj({ settings: settings.optional() }).optional(),
|
|
1553
|
+
steamcmd: obj({ path: str.optional() }).optional(),
|
|
1554
|
+
games: z.unknown()
|
|
1555
|
+
});
|
|
1556
|
+
function validateConfig(cfg) {
|
|
1557
|
+
const problems = [];
|
|
1558
|
+
if (!isObj(cfg)) {
|
|
1559
|
+
problems.push({ where: "", message: "expected the config to be an object" });
|
|
1560
|
+
return { config: { dataRoot: "", games: {} }, problems };
|
|
1855
1561
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
if (
|
|
1859
|
-
|
|
1562
|
+
collect2(problems, "", root, cfg);
|
|
1563
|
+
const games = cfg["games"];
|
|
1564
|
+
if (!isObj(games)) {
|
|
1565
|
+
problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
|
|
1566
|
+
return { config: cfg, problems };
|
|
1860
1567
|
}
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
let entries;
|
|
1864
|
-
try {
|
|
1865
|
-
entries = await readdir3(dir);
|
|
1866
|
-
} catch {
|
|
1867
|
-
return null;
|
|
1568
|
+
for (const [name, entry] of Object.entries(games)) {
|
|
1569
|
+
collect2(problems, `/games/${esc(name)}`, game, entry);
|
|
1868
1570
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
return join5(dir, slnx);
|
|
1872
|
-
const csproj = entries.find((e) => e.endsWith(".csproj"));
|
|
1873
|
-
return csproj ? join5(dir, csproj) : null;
|
|
1571
|
+
crossReference(problems, games);
|
|
1572
|
+
return { config: cfg, problems };
|
|
1874
1573
|
}
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
const wanted = plan.mods.filter((m) => m.kind === "local" && (policy === "always" || m.stale === true));
|
|
1879
|
-
if (wanted.length === 0)
|
|
1574
|
+
function collect2(problems, prefix, schema, value) {
|
|
1575
|
+
const result = schema.safeParse(value);
|
|
1576
|
+
if (result.success)
|
|
1880
1577
|
return;
|
|
1881
|
-
for (const
|
|
1882
|
-
|
|
1883
|
-
|
|
1578
|
+
for (const issue of result.error.issues) {
|
|
1579
|
+
if (issue.code === "unrecognized_keys") {
|
|
1580
|
+
const hints = hintsFor(issue.message);
|
|
1581
|
+
issue.keys.forEach((key, i) => {
|
|
1582
|
+
const problem = {
|
|
1583
|
+
where: `${prefix}${pointer(issue.path)}/${esc(key)}`,
|
|
1584
|
+
message: `unknown key "${key}"`
|
|
1585
|
+
};
|
|
1586
|
+
const hint = hints[i];
|
|
1587
|
+
if (hint != null)
|
|
1588
|
+
problem.suggestion = `did you mean "${hint}"?`;
|
|
1589
|
+
problems.push(problem);
|
|
1590
|
+
});
|
|
1884
1591
|
continue;
|
|
1885
|
-
const code = await inherit(["dotnet", "build", target, "-v", "quiet", "--nologo"]);
|
|
1886
|
-
if (code !== 0) {
|
|
1887
|
-
throw new GamecrateError(`dotnet build failed for ${mod.packageId}`, Exit.Environment, target);
|
|
1888
1592
|
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
|
|
1900
|
-
or relaunch with --replace`);
|
|
1901
|
-
}
|
|
1902
|
-
const held = await readLock(path);
|
|
1903
|
-
if (held !== undefined && isRunning(held.pid, held.startedAt)) {
|
|
1904
|
-
throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
|
|
1905
|
-
or relaunch with --replace`);
|
|
1593
|
+
const key = issue.path.at(-1);
|
|
1594
|
+
const missing = typeof key === "string" && valueAt(value, issue.path) === undefined;
|
|
1595
|
+
const problem = {
|
|
1596
|
+
where: `${prefix}${pointer(issue.path)}`,
|
|
1597
|
+
message: missing ? `missing required key "${key}"` : issue.message
|
|
1598
|
+
};
|
|
1599
|
+
const hint = issue.params?.suggestion;
|
|
1600
|
+
if (hint !== undefined)
|
|
1601
|
+
problem.suggestion = hint;
|
|
1602
|
+
problems.push(problem);
|
|
1906
1603
|
}
|
|
1907
|
-
if (existsSync3(path))
|
|
1908
|
-
await unlink(path).catch(() => {});
|
|
1909
|
-
}
|
|
1910
|
-
async function unlinkHeld(path, pid, startedAt) {
|
|
1911
|
-
const held = await readLock(path);
|
|
1912
|
-
if (held === undefined)
|
|
1913
|
-
return;
|
|
1914
|
-
if (held.pid !== pid)
|
|
1915
|
-
return;
|
|
1916
|
-
if (startedAt !== undefined && held.startedAt !== startedAt)
|
|
1917
|
-
return;
|
|
1918
|
-
await unlink(path).catch(() => {});
|
|
1919
1604
|
}
|
|
1920
|
-
function
|
|
1921
|
-
|
|
1922
|
-
return {
|
|
1923
|
-
release: async () => {
|
|
1924
|
-
await unlinkHeld(path, process.pid);
|
|
1925
|
-
}
|
|
1926
|
-
};
|
|
1605
|
+
function pointer(path) {
|
|
1606
|
+
return path.map((segment) => `/${esc(String(segment))}`).join("");
|
|
1927
1607
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
detached: false,
|
|
1937
|
-
mode: plan.mode
|
|
1938
|
-
});
|
|
1939
|
-
return heldLock(plan);
|
|
1608
|
+
function valueAt(root_, path) {
|
|
1609
|
+
let current = root_;
|
|
1610
|
+
for (const segment of path) {
|
|
1611
|
+
if (current === null || typeof current !== "object")
|
|
1612
|
+
return;
|
|
1613
|
+
current = own(current, String(segment));
|
|
1614
|
+
}
|
|
1615
|
+
return current;
|
|
1940
1616
|
}
|
|
1941
|
-
function
|
|
1942
|
-
|
|
1617
|
+
function crossReference(p, games) {
|
|
1618
|
+
const containers = new Map;
|
|
1619
|
+
for (const [gameName, game_] of Object.entries(games)) {
|
|
1620
|
+
const where = `/games/${esc(gameName)}`;
|
|
1621
|
+
checkName(p, where, gameName, "game");
|
|
1622
|
+
if (!isObj(game_))
|
|
1623
|
+
continue;
|
|
1624
|
+
checkVariantNames(p, where, game_);
|
|
1625
|
+
const profiles = game_["profiles"];
|
|
1626
|
+
if (!isObj(profiles))
|
|
1627
|
+
continue;
|
|
1628
|
+
for (const [name, prof] of Object.entries(profiles)) {
|
|
1629
|
+
const w = `${where}/profiles/${esc(name)}`;
|
|
1630
|
+
checkName(p, w, name, "profile");
|
|
1631
|
+
if (isObj(prof))
|
|
1632
|
+
checkProfile(p, w, name, prof, profiles);
|
|
1633
|
+
}
|
|
1634
|
+
checkCollisions(p, where, gameName, profiles, containers);
|
|
1635
|
+
}
|
|
1943
1636
|
}
|
|
1944
|
-
|
|
1945
|
-
const
|
|
1637
|
+
function checkVariantNames(p, where, game_) {
|
|
1638
|
+
const steamBuild = game_["steamBuild"];
|
|
1639
|
+
const variants = isObj(steamBuild) ? steamBuild["variants"] : undefined;
|
|
1640
|
+
if (!Array.isArray(variants))
|
|
1946
1641
|
return;
|
|
1642
|
+
variants.forEach((variant, index) => {
|
|
1643
|
+
const name = isObj(variant) ? variant["name"] : undefined;
|
|
1644
|
+
if (typeof name !== "string")
|
|
1645
|
+
return;
|
|
1646
|
+
checkName(p, `${where}/steamBuild/variants/${index}/name`, name, "variant");
|
|
1947
1647
|
});
|
|
1948
|
-
|
|
1648
|
+
}
|
|
1649
|
+
function checkProfile(p, w, name, prof, profiles) {
|
|
1650
|
+
const names = Object.keys(profiles);
|
|
1651
|
+
checkExtends(p, w, prof, profiles, names);
|
|
1652
|
+
checkAlias(p, w, name, prof, profiles, names);
|
|
1653
|
+
checkAliases(p, w, prof, names);
|
|
1654
|
+
checkInstances(p, w, prof);
|
|
1655
|
+
}
|
|
1656
|
+
function checkExtends(p, w, prof, profiles, names) {
|
|
1657
|
+
const parent = prof["extends"];
|
|
1658
|
+
if (typeof parent !== "string" || resolves(profiles, parent))
|
|
1949
1659
|
return;
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1660
|
+
const prob = { where: `${w}/extends`, message: `extends unknown profile "${parent}"` };
|
|
1661
|
+
const hint = suggest(parent, names);
|
|
1662
|
+
if (hint)
|
|
1663
|
+
prob.suggestion = `did you mean "${hint}"?`;
|
|
1664
|
+
p.push(prob);
|
|
1665
|
+
}
|
|
1666
|
+
function checkAlias(p, w, name, prof, profiles, names) {
|
|
1667
|
+
const alias = prof["alias"];
|
|
1668
|
+
if (typeof alias !== "string")
|
|
1954
1669
|
return;
|
|
1670
|
+
if (!resolves(profiles, alias)) {
|
|
1671
|
+
const prob = { where: `${w}/alias`, message: `alias of unknown profile "${alias}"` };
|
|
1672
|
+
const hint = suggest(alias, [...names, "modless"]);
|
|
1673
|
+
if (hint)
|
|
1674
|
+
prob.suggestion = `did you mean "${hint}"?`;
|
|
1675
|
+
p.push(prob);
|
|
1676
|
+
}
|
|
1677
|
+
if (alias.toLowerCase() === name.toLowerCase()) {
|
|
1678
|
+
p.push({ where: `${w}/alias`, message: "a profile cannot alias itself" });
|
|
1955
1679
|
}
|
|
1956
1680
|
}
|
|
1957
|
-
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1681
|
+
function checkAliases(p, w, prof, names) {
|
|
1682
|
+
const aliases = prof["aliases"];
|
|
1683
|
+
if (!Array.isArray(aliases))
|
|
1684
|
+
return;
|
|
1685
|
+
for (const [i, entry] of aliases.entries()) {
|
|
1686
|
+
if (typeof entry !== "string")
|
|
1687
|
+
continue;
|
|
1688
|
+
const at = `${w}/aliases/${i}`;
|
|
1689
|
+
checkName(p, at, entry, "profile alias");
|
|
1690
|
+
if (names.some((k) => k.toLowerCase() === entry.toLowerCase())) {
|
|
1691
|
+
p.push({ where: at, message: `alias "${entry}" is already a profile name` });
|
|
1692
|
+
}
|
|
1962
1693
|
}
|
|
1963
|
-
await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
|
|
1964
|
-
await handle.close();
|
|
1965
1694
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
try {
|
|
1973
|
-
process.kill(record.pid, "SIGTERM");
|
|
1974
|
-
signalled = true;
|
|
1975
|
-
} catch {}
|
|
1695
|
+
function checkInstances(p, w, prof) {
|
|
1696
|
+
const instances = prof["instances"];
|
|
1697
|
+
if (!isObj(instances))
|
|
1698
|
+
return;
|
|
1699
|
+
for (const instance of Object.keys(instances)) {
|
|
1700
|
+
checkName(p, `${w}/instances/${esc(instance)}`, instance, "instance");
|
|
1976
1701
|
}
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1702
|
+
}
|
|
1703
|
+
function resolves(profiles, name) {
|
|
1704
|
+
if (name.toLowerCase() === "modless")
|
|
1705
|
+
return true;
|
|
1706
|
+
return profileKey({ profiles }, name) !== undefined;
|
|
1707
|
+
}
|
|
1708
|
+
function checkCollisions(p, where, gameName, profiles, containers) {
|
|
1709
|
+
const names = new Map;
|
|
1710
|
+
const aliasOwners = new Map;
|
|
1711
|
+
const prefix = `${gameName.toLowerCase()}-`;
|
|
1712
|
+
for (const [name, prof] of Object.entries(profiles)) {
|
|
1713
|
+
const w = `${where}/profiles/${esc(name)}`;
|
|
1714
|
+
const lower = name.toLowerCase();
|
|
1715
|
+
const twin = names.get(lower);
|
|
1716
|
+
if (twin !== undefined) {
|
|
1717
|
+
p.push({
|
|
1718
|
+
where: w,
|
|
1719
|
+
message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
|
|
1720
|
+
});
|
|
1721
|
+
continue;
|
|
1987
1722
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1723
|
+
names.set(lower, name);
|
|
1724
|
+
const clash = containers.get(prefix + lower);
|
|
1725
|
+
if (clash !== undefined) {
|
|
1726
|
+
p.push({ where: w, message: `container name collides with ${clash}` });
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
containers.set(prefix + lower, w);
|
|
1730
|
+
if (!isObj(prof))
|
|
1731
|
+
continue;
|
|
1732
|
+
checkAliasOwners(p, w, prof, name, aliasOwners);
|
|
1733
|
+
checkInstanceContainers(p, w, profiles, name, `${prefix}${lower}`, containers);
|
|
1991
1734
|
}
|
|
1992
|
-
return signalled ? "signalled" : "orphaned";
|
|
1993
1735
|
}
|
|
1994
|
-
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
|
|
1998
|
-
const running = up.stdout.trim().length > 0;
|
|
1999
|
-
const held = await readLock(path);
|
|
2000
|
-
if (!running && held === undefined && !existsSync3(path))
|
|
2001
|
-
return;
|
|
2002
|
-
if (held !== undefined) {
|
|
2003
|
-
status(`stopping ${held.container}`);
|
|
2004
|
-
await stopRun(held, path);
|
|
1736
|
+
function checkAliasOwners(p, w, prof, name, owners) {
|
|
1737
|
+
const aliases = prof["aliases"];
|
|
1738
|
+
if (!Array.isArray(aliases))
|
|
2005
1739
|
return;
|
|
1740
|
+
for (const [i, entry] of aliases.entries()) {
|
|
1741
|
+
if (typeof entry !== "string")
|
|
1742
|
+
continue;
|
|
1743
|
+
const owner = owners.get(entry.toLowerCase());
|
|
1744
|
+
if (owner !== undefined) {
|
|
1745
|
+
p.push({
|
|
1746
|
+
where: `${w}/aliases/${i}`,
|
|
1747
|
+
message: `alias "${entry}" is already declared by profile "${owner}"`
|
|
1748
|
+
});
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
owners.set(entry.toLowerCase(), name);
|
|
2006
1752
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
1753
|
+
}
|
|
1754
|
+
function checkInstanceContainers(p, w, profiles, name, prefix, containers) {
|
|
1755
|
+
const prof = profiles[name];
|
|
1756
|
+
if (!isObj(prof))
|
|
1757
|
+
return;
|
|
1758
|
+
const declared = prof["instances"];
|
|
1759
|
+
for (const instance of instanceNames(profiles, name, prof)) {
|
|
1760
|
+
const container = `${prefix}-${instance.toLowerCase()}`;
|
|
1761
|
+
const at = isObj(declared) && Object.hasOwn(declared, instance) ? `${w}/instances/${esc(instance)}` : w;
|
|
1762
|
+
const first = containers.get(container);
|
|
1763
|
+
if (first !== undefined) {
|
|
1764
|
+
p.push({
|
|
1765
|
+
where: at,
|
|
1766
|
+
message: `instance "${instance}" makes a container name that collides with ${first}`
|
|
1767
|
+
});
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
containers.set(container, at);
|
|
2010
1771
|
}
|
|
2011
1772
|
}
|
|
2012
|
-
|
|
2013
|
-
var START_TIME_SLACK_MS = 2000;
|
|
2014
|
-
function isRunning(pid, startedAt) {
|
|
1773
|
+
function instanceNames(profiles, name, prof) {
|
|
2015
1774
|
try {
|
|
2016
|
-
|
|
1775
|
+
const resolved = resolveProfile({ profiles }, name).instances;
|
|
1776
|
+
return isObj(resolved) ? Object.keys(resolved) : [];
|
|
2017
1777
|
} catch {
|
|
2018
|
-
|
|
1778
|
+
const declared = prof["instances"];
|
|
1779
|
+
return isObj(declared) ? Object.keys(declared) : [];
|
|
2019
1780
|
}
|
|
2020
|
-
if (startedAt === undefined)
|
|
2021
|
-
return true;
|
|
2022
|
-
const written = Date.parse(startedAt);
|
|
2023
|
-
if (Number.isNaN(written))
|
|
2024
|
-
return true;
|
|
2025
|
-
const began = processStart(pid);
|
|
2026
|
-
return began === undefined || began <= written + START_TIME_SLACK_MS;
|
|
2027
1781
|
}
|
|
2028
|
-
function
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
2032
|
-
const ticks = Number(fields[19]);
|
|
2033
|
-
const boot = bootTime();
|
|
2034
|
-
if (!Number.isFinite(ticks) || boot === undefined)
|
|
2035
|
-
return;
|
|
2036
|
-
return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
|
|
2037
|
-
} catch {
|
|
1782
|
+
function checkName(p, where, name, kind) {
|
|
1783
|
+
if (RESERVED_NAMES.includes(name.toLowerCase())) {
|
|
1784
|
+
p.push({ where, message: `"${name}" is a reserved name and cannot be used as a ${kind} name` });
|
|
2038
1785
|
return;
|
|
2039
1786
|
}
|
|
1787
|
+
if (!NAME_PATTERN.test(name)) {
|
|
1788
|
+
p.push({ where, message: `${kind} name "${name}" must match ${NAME_PATTERN.source}` });
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
function esc(segment) {
|
|
1792
|
+
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
1793
|
+
}
|
|
1794
|
+
function isObj(v) {
|
|
1795
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2040
1796
|
}
|
|
2041
|
-
function bootTime() {
|
|
2042
|
-
const line = readFileSync("/proc/stat", "utf8").split(`
|
|
2043
|
-
`).find((each) => each.startsWith("btime "));
|
|
2044
|
-
const seconds = Number(line?.slice("btime ".length));
|
|
2045
|
-
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
|
|
2046
|
-
}
|
|
2047
|
-
async function captureScreenshot(container, plan) {
|
|
2048
|
-
const name = `${plan.game}.png`;
|
|
2049
|
-
const target = `${CONTAINER_LOG_DIR}/${name}`;
|
|
2050
|
-
const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + " X=$(ls -d /tmp/xvfb-run.*/Xauthority 2>/dev/null | head -1);" + ' [ -n "$X" ] && export XAUTHORITY="$X";' + " M=$(command -v magick || command -v convert);" + ' [ -z "$M" ] && { echo "no imagemagick in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" | "$M" xwd:- ${target}`;
|
|
2051
|
-
const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
|
|
2052
|
-
const host = join5(plan.runDirHost, name);
|
|
2053
|
-
if (code !== 0 || !existsSync3(host))
|
|
2054
|
-
return null;
|
|
2055
|
-
return host;
|
|
2056
|
-
}
|
|
2057
|
-
async function writeLaunchRecord(plan, image) {
|
|
2058
|
-
const digest = await imageDigest(image);
|
|
2059
|
-
const line = JSON.stringify({
|
|
2060
|
-
at: new Date().toISOString(),
|
|
2061
|
-
game: plan.game,
|
|
2062
|
-
profile: plan.profile,
|
|
2063
|
-
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
2064
|
-
image,
|
|
2065
|
-
digest,
|
|
2066
|
-
mode: plan.mode,
|
|
2067
|
-
mods: plan.mods.map((m) => ({
|
|
2068
|
-
packageId: m.packageId,
|
|
2069
|
-
hostDir: m.hostDir,
|
|
2070
|
-
...m.worktree === undefined ? {} : { worktree: m.worktree }
|
|
2071
|
-
}))
|
|
2072
|
-
});
|
|
2073
|
-
const path = join5(plan.instanceDir, ".gamecrate", "launches.jsonl");
|
|
2074
|
-
await writeFile(path, `${line}
|
|
2075
|
-
`, { flag: "a" });
|
|
2076
|
-
}
|
|
2077
|
-
|
|
2078
|
-
// src/mods/modindex.ts
|
|
2079
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
2080
|
-
import { existsSync as existsSync7, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
|
|
2081
|
-
import { mkdir as mkdir2, readdir as readdir6, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
|
|
2082
|
-
import { homedir as homedir3 } from "node:os";
|
|
2083
|
-
import { dirname as dirname5, join as join10, relative as relative2, sep as sep2, resolve as resolvePath } from "node:path";
|
|
2084
|
-
import picomatch from "picomatch";
|
|
2085
1797
|
|
|
2086
1798
|
// src/config/load.ts
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
import { basename as basename3, dirname as dirname2, join as join7, resolve as resolve3 } from "node:path";
|
|
2091
|
-
|
|
2092
|
-
// src/plugin.ts
|
|
2093
|
-
import { readFileSync as readFileSync2, statSync } from "node:fs";
|
|
2094
|
-
import { dirname, isAbsolute, join as join6, resolve as resolve2 } from "node:path";
|
|
2095
|
-
import { pathToFileURL } from "node:url";
|
|
2096
|
-
import { exports as exportsField, legacy } from "resolve.exports";
|
|
2097
|
-
var PLUGIN_API_VERSION = 2;
|
|
2098
|
-
var REQUIRED_FUNCTIONS = [
|
|
2099
|
-
"parseManifest",
|
|
2100
|
-
"renderModsConfig",
|
|
2101
|
-
"mergePrefs",
|
|
2102
|
-
"parseVersion"
|
|
2103
|
-
];
|
|
2104
|
-
function fail(spec, message, detail) {
|
|
2105
|
-
throw new GamecrateError(`plugin "${spec}": ${message}`, Exit.Config, detail);
|
|
1799
|
+
function globalConfigDir() {
|
|
1800
|
+
const base = process.env["XDG_CONFIG_HOME"] ?? join2(homedir(), ".config");
|
|
1801
|
+
return join2(base, "gamecrate");
|
|
2106
1802
|
}
|
|
2107
|
-
function
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
1803
|
+
async function findGlobalConfig() {
|
|
1804
|
+
return probe(globalConfigDir(), "profiles");
|
|
1805
|
+
}
|
|
1806
|
+
async function probe(dir, stem) {
|
|
1807
|
+
const found = [];
|
|
1808
|
+
for (const suffix of CONFIG_SUFFIXES) {
|
|
1809
|
+
const file = join2(dir, `${stem}${suffix}`);
|
|
1810
|
+
try {
|
|
1811
|
+
await access(file);
|
|
1812
|
+
found.push(file);
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
if (error.code !== "ENOENT")
|
|
1815
|
+
throw error;
|
|
1816
|
+
}
|
|
2113
1817
|
}
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
1818
|
+
if (found.length > 1) {
|
|
1819
|
+
const rows = found.map((f) => ` ${basename(f)}`).join(`
|
|
1820
|
+
`);
|
|
1821
|
+
throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${rows}
|
|
1822
|
+
keep one`);
|
|
1823
|
+
}
|
|
1824
|
+
return found[0];
|
|
2120
1825
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
1826
|
+
var projectName = z2.custom((v) => typeof v === "string" && NAME_PATTERN.test(v), "expected a name");
|
|
1827
|
+
var projectStr = z2.string({ error: "expected a string" });
|
|
1828
|
+
var projectBool = z2.boolean({ error: "expected true or false" });
|
|
1829
|
+
var projectList = z2.custom((v) => Array.isArray(v) && v.every((entry) => typeof entry === "string"), "expected an array of strings");
|
|
1830
|
+
var projectSeconds = z2.custom((v) => Number.isSafeInteger(v) && v >= 0, "expected a whole number of seconds");
|
|
1831
|
+
function oneOf2(values) {
|
|
1832
|
+
return z2.enum(values, { error: `expected one of ${values.join(", ")}` });
|
|
1833
|
+
}
|
|
1834
|
+
var BUILD_POLICIES2 = ["auto", "always", "never"];
|
|
1835
|
+
var projectResolution = z2.string({ error: "expected dimensions like 1920x1080" }).check((ctx) => {
|
|
1836
|
+
try {
|
|
1837
|
+
parseResolution(ctx.value);
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
|
|
1840
|
+
}
|
|
1841
|
+
}).transform(parseResolution);
|
|
1842
|
+
var PROJECT_OBJECT = z2.strictObject({
|
|
1843
|
+
game: projectName.optional(),
|
|
1844
|
+
defaultProfile: projectName.optional(),
|
|
1845
|
+
profiles: z2.record(z2.string(), z2.unknown()).optional(),
|
|
1846
|
+
settings: z2.record(z2.string(), z2.unknown()).optional(),
|
|
1847
|
+
library: z2.record(z2.string(), z2.unknown()).optional(),
|
|
1848
|
+
detach: projectBool.optional(),
|
|
1849
|
+
mods: projectList.optional(),
|
|
1850
|
+
without: projectList.optional(),
|
|
1851
|
+
only: projectList.optional(),
|
|
1852
|
+
dockerArgs: projectList.optional(),
|
|
1853
|
+
gameArgs: projectList.optional(),
|
|
1854
|
+
worktree: projectList.optional(),
|
|
1855
|
+
use: projectList.optional(),
|
|
1856
|
+
marker: projectStr.optional(),
|
|
1857
|
+
instance: projectStr.optional(),
|
|
1858
|
+
log: projectStr.optional(),
|
|
1859
|
+
timeout: projectSeconds.optional(),
|
|
1860
|
+
renderWait: projectSeconds.optional(),
|
|
1861
|
+
dryRun: projectBool.optional(),
|
|
1862
|
+
printPlan: projectBool.optional(),
|
|
1863
|
+
json: projectBool.optional(),
|
|
1864
|
+
root: projectBool.optional(),
|
|
1865
|
+
noWorktree: projectBool.optional(),
|
|
1866
|
+
noStaleCheck: projectBool.optional(),
|
|
1867
|
+
replace: projectBool.optional(),
|
|
1868
|
+
mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
|
|
1869
|
+
pull: oneOf2(["always", "missing", "never"]).optional(),
|
|
1870
|
+
sort: oneOf2(["topo", "none"]).optional(),
|
|
1871
|
+
network: oneOf2(["none", "bridge", "host"]).optional(),
|
|
1872
|
+
build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES2)], {
|
|
1873
|
+
error: `expected one of ${BUILD_POLICIES2.join(", ")}`
|
|
1874
|
+
}).optional(),
|
|
1875
|
+
resolution: projectResolution.optional()
|
|
1876
|
+
}, { error: "expected an object" });
|
|
1877
|
+
var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
|
|
1878
|
+
const { game, profiles, settings, library } = ctx.value;
|
|
1879
|
+
if (game !== undefined)
|
|
1880
|
+
return;
|
|
1881
|
+
for (const [key, value] of [
|
|
1882
|
+
["profiles", profiles],
|
|
1883
|
+
["settings", settings],
|
|
1884
|
+
["library", library]
|
|
1885
|
+
]) {
|
|
1886
|
+
if (value === undefined)
|
|
1887
|
+
continue;
|
|
1888
|
+
ctx.issues.push({
|
|
1889
|
+
code: "custom",
|
|
1890
|
+
path: [key],
|
|
1891
|
+
message: "needs a top-level game: to say which game it belongs to",
|
|
1892
|
+
input: ctx.value
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
});
|
|
1896
|
+
async function findProjectConfig(start = process.cwd()) {
|
|
1897
|
+
let dir = resolve2(start);
|
|
2123
1898
|
for (;; ) {
|
|
2124
|
-
const
|
|
2125
|
-
if (
|
|
2126
|
-
return
|
|
2127
|
-
const parent =
|
|
1899
|
+
const file = await probe(dir, ".gamecrate");
|
|
1900
|
+
if (file !== undefined)
|
|
1901
|
+
return file;
|
|
1902
|
+
const parent = dirname2(dir);
|
|
2128
1903
|
if (parent === dir)
|
|
2129
|
-
return
|
|
1904
|
+
return;
|
|
2130
1905
|
dir = parent;
|
|
2131
1906
|
}
|
|
2132
1907
|
}
|
|
2133
|
-
function
|
|
2134
|
-
const
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
1908
|
+
async function loadProjectDefaults(start = process.cwd()) {
|
|
1909
|
+
const file = await findProjectConfig(start);
|
|
1910
|
+
if (file === undefined)
|
|
1911
|
+
return {};
|
|
1912
|
+
const text = await readFile2(file, "utf8");
|
|
1913
|
+
const defaults = validateProjectDefaults(readConfigText(text, file), file);
|
|
1914
|
+
const profiles = defaults.profiles;
|
|
1915
|
+
if (profiles !== undefined) {
|
|
1916
|
+
const order = orderedKeys(text, file, "profiles");
|
|
1917
|
+
if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
|
|
1918
|
+
throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
|
|
2142
1919
|
}
|
|
1920
|
+
defaults.profileOrder = order;
|
|
2143
1921
|
}
|
|
2144
|
-
|
|
1922
|
+
defaults.configPath = file;
|
|
1923
|
+
return defaults;
|
|
2145
1924
|
}
|
|
2146
|
-
function
|
|
2147
|
-
if (
|
|
2148
|
-
|
|
2149
|
-
const
|
|
2150
|
-
if (
|
|
2151
|
-
|
|
1925
|
+
function validateProjectDefaults(raw, file) {
|
|
1926
|
+
if (raw === null)
|
|
1927
|
+
return {};
|
|
1928
|
+
const result = PROJECT_SCHEMA.safeParse(raw);
|
|
1929
|
+
if (result.success)
|
|
1930
|
+
return result.data;
|
|
1931
|
+
const problems = [];
|
|
1932
|
+
for (const issue of result.error.issues) {
|
|
1933
|
+
if (issue.code === "unrecognized_keys") {
|
|
1934
|
+
for (const key of issue.keys)
|
|
1935
|
+
problems.push(` /${key}: unknown key`);
|
|
1936
|
+
continue;
|
|
1937
|
+
}
|
|
1938
|
+
problems.push(` /${issue.path.join("/")}: ${issue.message}`);
|
|
2152
1939
|
}
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
1940
|
+
throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
|
|
1941
|
+
`));
|
|
1942
|
+
}
|
|
1943
|
+
async function loadConfig(path, project) {
|
|
1944
|
+
const file = path ?? await findGlobalConfig() ?? join2(globalConfigDir(), "profiles.yml");
|
|
1945
|
+
const user = await readConfigFile(file);
|
|
1946
|
+
const specs = isObj(user) && user["plugins"] !== undefined ? user["plugins"] : [];
|
|
1947
|
+
if (!Array.isArray(specs) || specs.some((s) => typeof s !== "string")) {
|
|
1948
|
+
throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, " /plugins: expected an array of strings");
|
|
2160
1949
|
}
|
|
2161
|
-
|
|
2162
|
-
|
|
1950
|
+
const plugins = await loadPlugins(specs, file);
|
|
1951
|
+
const base = {
|
|
1952
|
+
dataRoot: DEFAULT_DATA_ROOT,
|
|
1953
|
+
defaults: { settings: structuredClone(DEFAULT_SETTINGS) },
|
|
1954
|
+
games: Object.fromEntries([...plugins].map(([name, plugin]) => [name, structuredClone(plugin.defaults)]))
|
|
1955
|
+
};
|
|
1956
|
+
const merged = user === undefined || user === null ? base : mergeUserConfig(base, user);
|
|
1957
|
+
const spliced = applyProject(merged, project);
|
|
1958
|
+
const { config, problems } = validateConfig(spliced);
|
|
1959
|
+
if (problems.length > 0) {
|
|
1960
|
+
const detail = problems.map((p) => {
|
|
1961
|
+
const hint = p.suggestion ? ` (${p.suggestion})` : "";
|
|
1962
|
+
return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
|
|
1963
|
+
}).join(`
|
|
1964
|
+
`);
|
|
1965
|
+
const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
|
|
1966
|
+
throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
|
|
2163
1967
|
}
|
|
2164
|
-
return
|
|
1968
|
+
return { config: expandPaths(config), plugins };
|
|
2165
1969
|
}
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
1970
|
+
function applyProject(config, project) {
|
|
1971
|
+
if (project === undefined)
|
|
1972
|
+
return config;
|
|
1973
|
+
const game = project.game;
|
|
1974
|
+
if (game === undefined)
|
|
1975
|
+
return config;
|
|
1976
|
+
if (project.profiles === undefined && project.settings === undefined && project.library === undefined) {
|
|
1977
|
+
return config;
|
|
1978
|
+
}
|
|
1979
|
+
const existing = own(config.games, game);
|
|
1980
|
+
if (existing === undefined) {
|
|
1981
|
+
throw new GamecrateError(`the project config names game "${game}", which is not configured`, Exit.Config, `known games: ${Object.keys(config.games).join(", ") || "none"}`);
|
|
1982
|
+
}
|
|
1983
|
+
const target = {
|
|
1984
|
+
...existing,
|
|
1985
|
+
profiles: { ...existing.profiles, ...project.profiles },
|
|
1986
|
+
...project.library === undefined ? {} : { library: spliceLibrary(existing.library, project.library) }
|
|
1987
|
+
};
|
|
1988
|
+
config.games[game] = target;
|
|
1989
|
+
if (project.settings !== undefined) {
|
|
1990
|
+
target.settings = deepMerge(target.settings ?? {}, project.settings);
|
|
1991
|
+
}
|
|
1992
|
+
return config;
|
|
1993
|
+
}
|
|
1994
|
+
function spliceLibrary(global, project) {
|
|
1995
|
+
const replaced = new Set(Object.keys(project).map((id) => id.toLowerCase()));
|
|
1996
|
+
const kept = Object.entries(global ?? {}).filter(([id]) => !replaced.has(id.toLowerCase()));
|
|
1997
|
+
return { ...Object.fromEntries(kept), ...project };
|
|
1998
|
+
}
|
|
1999
|
+
var GAME_SCOPED_KEYS = ["profiles", "settings", "library"];
|
|
2000
|
+
function origin(where, user, plugins, project) {
|
|
2001
|
+
if (!where.startsWith("/"))
|
|
2002
|
+
return "";
|
|
2003
|
+
const segments = where.slice(1).split("/").map((s) => s.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
2004
|
+
const [section, name, sub, ...rest] = segments;
|
|
2005
|
+
const repoGame = project?.game;
|
|
2006
|
+
const repoSection = GAME_SCOPED_KEYS.find((k) => k === sub);
|
|
2007
|
+
if (repoGame !== undefined && section === "games" && name === repoGame && repoSection !== undefined && valueAt2(project?.[repoSection], rest) !== undefined) {
|
|
2008
|
+
return " <- from the .gamecrate project config, not this file";
|
|
2181
2009
|
}
|
|
2182
|
-
|
|
2010
|
+
if (valueAt2(user, segments) !== undefined)
|
|
2011
|
+
return "";
|
|
2012
|
+
const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
|
|
2013
|
+
if (plugin === undefined)
|
|
2014
|
+
return " <- not in this file";
|
|
2015
|
+
return valueAt2(plugin.defaults, [sub, ...rest].filter((s) => s !== undefined)) === undefined ? ` <- not in this file, and the ${name} plugin's defaults do not supply it` : ` <- from the ${name} plugin's defaults, not this file`;
|
|
2183
2016
|
}
|
|
2184
|
-
function
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2017
|
+
function valueAt2(value, segments) {
|
|
2018
|
+
let current = value;
|
|
2019
|
+
for (const segment of segments) {
|
|
2020
|
+
if (Array.isArray(current))
|
|
2021
|
+
current = current[Number(segment)];
|
|
2022
|
+
else if (isObj(current))
|
|
2023
|
+
current = own(current, segment);
|
|
2024
|
+
else
|
|
2025
|
+
return;
|
|
2188
2026
|
}
|
|
2189
|
-
return
|
|
2027
|
+
return current;
|
|
2190
2028
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
height: 1080,
|
|
2197
|
-
devMode: true,
|
|
2198
|
-
runInBackground: true,
|
|
2199
|
-
resetModsConfigOnCrash: false,
|
|
2200
|
-
gpu: true,
|
|
2201
|
-
audio: true,
|
|
2202
|
-
input: false,
|
|
2203
|
-
network: "bridge",
|
|
2204
|
-
display: "x11",
|
|
2205
|
-
memory: "8g",
|
|
2206
|
-
cpus: 6,
|
|
2207
|
-
pidsLimit: 1024
|
|
2208
|
-
};
|
|
2209
|
-
|
|
2210
|
-
// src/config/read.ts
|
|
2211
|
-
import { readFile as readFile2 } from "node:fs/promises";
|
|
2212
|
-
import { extname } from "node:path";
|
|
2213
|
-
import { parseTree } from "jsonc-parser";
|
|
2214
|
-
import { isMap, parse as parseYaml, parseDocument } from "yaml";
|
|
2215
|
-
|
|
2216
|
-
// src/config/jsonc.ts
|
|
2217
|
-
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
2218
|
-
function parseJsonc(text) {
|
|
2219
|
-
const errors = [];
|
|
2220
|
-
const value = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: false });
|
|
2221
|
-
const first = errors[0];
|
|
2222
|
-
if (first !== undefined) {
|
|
2223
|
-
throw new GamecrateError("config is not valid JSON", Exit.Config, `${printParseErrorCode(first.error)} at offset ${first.offset}`);
|
|
2029
|
+
function resolveSettings(root, game, profile, ...overrides) {
|
|
2030
|
+
let out = structuredClone(DEFAULT_SETTINGS);
|
|
2031
|
+
for (const layer of [root.defaults?.settings, game.settings, profile.settings, ...overrides]) {
|
|
2032
|
+
if (layer)
|
|
2033
|
+
out = deepMerge(out, layer, true);
|
|
2224
2034
|
}
|
|
2225
|
-
return
|
|
2035
|
+
return out;
|
|
2226
2036
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
var CONFIG_SUFFIXES = [".yml", ".yaml", ".json", ".jsonc"];
|
|
2230
|
-
function isYaml(path) {
|
|
2231
|
-
const suffix = extname(path).toLowerCase();
|
|
2232
|
-
if (suffix === ".yml" || suffix === ".yaml")
|
|
2233
|
-
return true;
|
|
2234
|
-
if (suffix === ".json" || suffix === ".jsonc")
|
|
2235
|
-
return false;
|
|
2236
|
-
throw new GamecrateError(`config is not a format gamecrate reads: ${path}`, Exit.Config, `use one of ${CONFIG_SUFFIXES.join(", ")}`);
|
|
2037
|
+
function resolveProfile(game, name) {
|
|
2038
|
+
return resolveNamed(game, name, []);
|
|
2237
2039
|
}
|
|
2238
|
-
function
|
|
2239
|
-
if (
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
throw new GamecrateError(`${error.message}: ${path}`, error.code, error.detail);
|
|
2245
|
-
}
|
|
2246
|
-
throw error;
|
|
2247
|
-
}
|
|
2040
|
+
function resolveNamed(game, name, seen) {
|
|
2041
|
+
if (name.toLowerCase() === "modless")
|
|
2042
|
+
return { mods: [], exclude: [], includeBase: false };
|
|
2043
|
+
const key = profileKey(game, name);
|
|
2044
|
+
if (key === undefined) {
|
|
2045
|
+
throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game.profiles).join(", ") || "(none)"}, modless`);
|
|
2248
2046
|
}
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
} catch (error) {
|
|
2252
|
-
throw new GamecrateError(`config is invalid: ${path}`, Exit.Config, error.message);
|
|
2047
|
+
if (seen.includes(key)) {
|
|
2048
|
+
throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
|
|
2253
2049
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2050
|
+
const self = own(game.profiles, key);
|
|
2051
|
+
if (self.alias !== undefined)
|
|
2052
|
+
return resolveNamed(game, self.alias, [...seen, key]);
|
|
2053
|
+
const parent = self.extends !== undefined ? resolveNamed(game, self.extends, [...seen, key]) : {};
|
|
2054
|
+
const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
|
|
2055
|
+
const out = {
|
|
2056
|
+
mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
|
|
2057
|
+
exclude,
|
|
2058
|
+
settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true)
|
|
2059
|
+
};
|
|
2060
|
+
const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true);
|
|
2061
|
+
if (Object.keys(instances).length > 0)
|
|
2062
|
+
out.instances = instances;
|
|
2063
|
+
const includeBase = self.includeBase ?? parent.includeBase;
|
|
2064
|
+
if (includeBase !== undefined)
|
|
2065
|
+
out.includeBase = includeBase;
|
|
2066
|
+
const auto = self.autoDependencies ?? parent.autoDependencies;
|
|
2067
|
+
if (auto !== undefined)
|
|
2068
|
+
out.autoDependencies = auto;
|
|
2069
|
+
for (const field of ["detach", "replace", "build", "gameVersion", "image", "windowTitle", "windowIcon"]) {
|
|
2070
|
+
const value = self[field] ?? parent[field];
|
|
2071
|
+
if (value !== undefined)
|
|
2072
|
+
Object.assign(out, { [field]: value });
|
|
2263
2073
|
}
|
|
2264
|
-
return
|
|
2074
|
+
return out;
|
|
2265
2075
|
}
|
|
2266
|
-
function
|
|
2267
|
-
if (
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2076
|
+
function canonicalProfile(game, name) {
|
|
2077
|
+
if (name.toLowerCase() === "modless")
|
|
2078
|
+
return "modless";
|
|
2079
|
+
const seen = [];
|
|
2080
|
+
let current = name;
|
|
2081
|
+
for (;; ) {
|
|
2082
|
+
const key = profileKey(game, current);
|
|
2083
|
+
if (key === undefined || seen.includes(key))
|
|
2084
|
+
return key ?? current;
|
|
2085
|
+
const next = own(game.profiles, key)?.alias;
|
|
2086
|
+
if (next === undefined)
|
|
2087
|
+
return key;
|
|
2088
|
+
seen.push(key);
|
|
2089
|
+
current = next;
|
|
2272
2090
|
}
|
|
2273
|
-
const root = parseTree(text);
|
|
2274
|
-
const holder = root?.children?.find((child) => child.children?.[0]?.value === key);
|
|
2275
|
-
const value = holder?.children?.[1];
|
|
2276
|
-
if (value?.type !== "object")
|
|
2277
|
-
return [];
|
|
2278
|
-
return (value.children ?? []).map((prop) => String(prop.children?.[0]?.value));
|
|
2279
2091
|
}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
const
|
|
2287
|
-
return
|
|
2288
|
-
|
|
2092
|
+
function profileDataDir(root, game, profile) {
|
|
2093
|
+
return resolve2(expandHome(root.dataRoot), game, canonicalProfile(own(root.games, game), profile));
|
|
2094
|
+
}
|
|
2095
|
+
async function profileDirs(root, game, profile) {
|
|
2096
|
+
if (profile !== undefined)
|
|
2097
|
+
return [profileDataDir(root, game, profile)];
|
|
2098
|
+
const dir = join2(expandHome(root.dataRoot), game);
|
|
2099
|
+
return (await readdir(dir).catch(() => [])).map((name) => join2(dir, name));
|
|
2100
|
+
}
|
|
2101
|
+
function profileKey(game, name) {
|
|
2102
|
+
if (Object.hasOwn(game.profiles, name))
|
|
2103
|
+
return name;
|
|
2104
|
+
const lower = name.toLowerCase();
|
|
2105
|
+
const direct = Object.keys(game.profiles).find((k) => k.toLowerCase() === lower);
|
|
2106
|
+
if (direct !== undefined)
|
|
2107
|
+
return direct;
|
|
2108
|
+
return Object.keys(game.profiles).find((k) => (own(game.profiles, k)?.aliases ?? []).some((a) => typeof a === "string" && a.toLowerCase() === lower));
|
|
2109
|
+
}
|
|
2110
|
+
function subtract(mods, exclude) {
|
|
2111
|
+
if (exclude.length === 0)
|
|
2112
|
+
return mods;
|
|
2113
|
+
const patterns = exclude.map(globToRegExp);
|
|
2114
|
+
return mods.filter((entry) => {
|
|
2115
|
+
const ids = entryIds(entry);
|
|
2116
|
+
if (ids.length === 0)
|
|
2117
|
+
return true;
|
|
2118
|
+
return !ids.some((id) => patterns.some((re) => re.test(id)));
|
|
2289
2119
|
});
|
|
2290
2120
|
}
|
|
2291
|
-
function
|
|
2292
|
-
if (
|
|
2293
|
-
return [];
|
|
2294
|
-
|
|
2295
|
-
return
|
|
2296
|
-
|
|
2297
|
-
return [];
|
|
2298
|
-
}
|
|
2121
|
+
function entryIds(entry) {
|
|
2122
|
+
if (typeof entry === "string")
|
|
2123
|
+
return [entry, entry.replace(/^(workshop|path):/, "")];
|
|
2124
|
+
if ("id" in entry)
|
|
2125
|
+
return [entry.id];
|
|
2126
|
+
return [];
|
|
2299
2127
|
}
|
|
2300
|
-
function
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
return;
|
|
2304
|
-
ctx.issues.push({ code: "custom", message: `missing required key "${key}"`, path: [key], input: ctx.value });
|
|
2305
|
-
};
|
|
2128
|
+
function globToRegExp(pattern) {
|
|
2129
|
+
const body = pattern.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", ".*").replaceAll("?", ".");
|
|
2130
|
+
return new RegExp(`^${body}$`, "i");
|
|
2306
2131
|
}
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2132
|
+
function mergeUserConfig(base, user) {
|
|
2133
|
+
const out = deepMerge(base, user);
|
|
2134
|
+
const games = isObj(user) ? user["games"] : undefined;
|
|
2135
|
+
if (!isObj(games))
|
|
2136
|
+
return out;
|
|
2137
|
+
for (const name of Object.keys(games)) {
|
|
2138
|
+
const theirs = own(games, name);
|
|
2139
|
+
const steamBuild = isObj(theirs) ? theirs["steamBuild"] : undefined;
|
|
2140
|
+
const added = isObj(steamBuild) ? steamBuild["branches"] : undefined;
|
|
2141
|
+
const declared = own(base.games, name)?.steamBuild?.branches;
|
|
2142
|
+
const target = own(out.games, name);
|
|
2143
|
+
if (!Array.isArray(added) || !Array.isArray(declared) || target === undefined)
|
|
2144
|
+
continue;
|
|
2145
|
+
target.steamBuild.branches = concatBranches(declared, added);
|
|
2146
|
+
}
|
|
2147
|
+
return out;
|
|
2318
2148
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
const
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2149
|
+
function branchName(entry) {
|
|
2150
|
+
return isObj(entry) && typeof entry["name"] === "string" ? entry["name"] : undefined;
|
|
2151
|
+
}
|
|
2152
|
+
function concatBranches(declared, added) {
|
|
2153
|
+
const out = [...declared];
|
|
2154
|
+
const at = new Map;
|
|
2155
|
+
out.forEach((branch, index) => {
|
|
2156
|
+
const name = branchName(branch);
|
|
2157
|
+
if (name !== undefined && !at.has(name))
|
|
2158
|
+
at.set(name, index);
|
|
2329
2159
|
});
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
network: oneOf(["none", "bridge", "host"]).optional(),
|
|
2341
|
-
display: oneOf(["x11", "wayland"]).optional(),
|
|
2342
|
-
memory: str.optional(),
|
|
2343
|
-
cpus: num.optional(),
|
|
2344
|
-
pidsLimit: num.optional(),
|
|
2345
|
-
prefsExtra: strMap.optional(),
|
|
2346
|
-
gameArgs: strArray.optional(),
|
|
2347
|
-
dockerArgs: strArray.optional()
|
|
2348
|
-
});
|
|
2349
|
-
var dynamicModEntry = obj({
|
|
2350
|
-
match: str,
|
|
2351
|
-
first: strArray.optional(),
|
|
2352
|
-
sort: oneOf(["alpha", "none"]).optional(),
|
|
2353
|
-
minMatches: num.optional()
|
|
2354
|
-
});
|
|
2355
|
-
var objectModEntry = obj({
|
|
2356
|
-
id: str,
|
|
2357
|
-
workshop: num.optional(),
|
|
2358
|
-
path: str.optional(),
|
|
2359
|
-
optional: bool.optional()
|
|
2360
|
-
});
|
|
2361
|
-
var modEntry = z.unknown().check((ctx) => {
|
|
2362
|
-
const value = ctx.value;
|
|
2363
|
-
if (typeof value === "string") {
|
|
2364
|
-
if (value.trim() === "")
|
|
2365
|
-
ctx.issues.push({ code: "custom", message: "mod entry is empty", input: value });
|
|
2366
|
-
return;
|
|
2160
|
+
for (const entry of added) {
|
|
2161
|
+
const name = branchName(entry);
|
|
2162
|
+
const index = name === undefined ? undefined : at.get(name);
|
|
2163
|
+
if (index === undefined) {
|
|
2164
|
+
if (name !== undefined)
|
|
2165
|
+
at.set(name, out.length);
|
|
2166
|
+
out.push(entry);
|
|
2167
|
+
continue;
|
|
2168
|
+
}
|
|
2169
|
+
out[index] = deepMerge(out[index], entry);
|
|
2367
2170
|
}
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2171
|
+
return out;
|
|
2172
|
+
}
|
|
2173
|
+
function deepMerge(base, over, concatArrays = false) {
|
|
2174
|
+
if (Array.isArray(base) && Array.isArray(over)) {
|
|
2175
|
+
return concatArrays ? [...base, ...over] : [...over];
|
|
2371
2176
|
}
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
mods: z.array(modEntry, { error: "expected an array" }).optional(),
|
|
2381
|
-
extends: str.optional(),
|
|
2382
|
-
exclude: strArray.optional(),
|
|
2383
|
-
includeBase: bool.optional(),
|
|
2384
|
-
autoDependencies: bool.optional(),
|
|
2385
|
-
settings: settings.optional(),
|
|
2386
|
-
instances: z.record(z.string(), obj({ worktree: str.optional(), settings: settings.optional() }), {
|
|
2387
|
-
error: "expected an object"
|
|
2388
|
-
}).optional(),
|
|
2389
|
-
alias: str.optional(),
|
|
2390
|
-
aliases: strArray.optional(),
|
|
2391
|
-
description: str.optional(),
|
|
2392
|
-
gameVersion: str.optional(),
|
|
2393
|
-
image: str.optional(),
|
|
2394
|
-
detach: bool.optional(),
|
|
2395
|
-
replace: bool.optional(),
|
|
2396
|
-
build: oneOf(["auto", "always", "never"]).optional()
|
|
2397
|
-
}).check((ctx) => {
|
|
2398
|
-
const v = ctx.value;
|
|
2399
|
-
if (v.alias !== undefined && (v.extends !== undefined || v.mods !== undefined)) {
|
|
2400
|
-
ctx.issues.push({
|
|
2401
|
-
code: "custom",
|
|
2402
|
-
message: 'an alias profile cannot also declare "mods" or "extends"',
|
|
2403
|
-
input: v
|
|
2404
|
-
});
|
|
2177
|
+
if (isObj(base) && isObj(over)) {
|
|
2178
|
+
const out = { ...base };
|
|
2179
|
+
for (const [k, v] of Object.entries(over)) {
|
|
2180
|
+
if (v === undefined)
|
|
2181
|
+
continue;
|
|
2182
|
+
out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v;
|
|
2183
|
+
}
|
|
2184
|
+
return out;
|
|
2405
2185
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
push(`library entry takes only one of ${sources.join(", ")}`);
|
|
2425
|
-
const refs = ["branch", "tag", "commit"].filter((k) => v[k] !== undefined);
|
|
2426
|
-
if (refs.length > 1)
|
|
2427
|
-
push(`library entry takes only one of branch, tag or commit, got ${refs.join(", ")}`);
|
|
2428
|
-
if (v["git"] === undefined) {
|
|
2429
|
-
for (const key of [...refs, ...v["subdir"] === undefined ? [] : ["subdir"]]) {
|
|
2430
|
-
push(`"${key}" needs a "git" url`, [key]);
|
|
2186
|
+
return over;
|
|
2187
|
+
}
|
|
2188
|
+
function expandPaths(config) {
|
|
2189
|
+
config.dataRoot = expandHome(config.dataRoot);
|
|
2190
|
+
if (config.steamcmd?.path !== undefined)
|
|
2191
|
+
config.steamcmd.path = expandHome(config.steamcmd.path);
|
|
2192
|
+
for (const game of Object.values(config.games)) {
|
|
2193
|
+
if (game.gameFiles.host !== undefined)
|
|
2194
|
+
game.gameFiles.host = expandHome(game.gameFiles.host);
|
|
2195
|
+
if (game.image.context !== undefined)
|
|
2196
|
+
game.image.context = expandHome(game.image.context);
|
|
2197
|
+
if (game.workshopRoot !== null)
|
|
2198
|
+
game.workshopRoot = expandHome(game.workshopRoot);
|
|
2199
|
+
for (const root of game.scanRoots)
|
|
2200
|
+
root.path = expandHome(root.path);
|
|
2201
|
+
for (const entry of Object.values(game.library ?? {})) {
|
|
2202
|
+
if (entry.path !== undefined)
|
|
2203
|
+
entry.path = expandHome(entry.path);
|
|
2431
2204
|
}
|
|
2432
2205
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2206
|
+
return config;
|
|
2207
|
+
}
|
|
2208
|
+
function expandHome(p) {
|
|
2209
|
+
if (p === "~")
|
|
2210
|
+
return homedir();
|
|
2211
|
+
return p.startsWith("~/") ? join2(homedir(), p.slice(2)) : p;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// src/docker/spec.ts
|
|
2215
|
+
var CONTAINER_RUNTIME_DIR = "/tmp/xdg";
|
|
2216
|
+
var CONTAINER_LOG_DIR = "/logs";
|
|
2217
|
+
var X11_SOCKET_DIR = "/tmp/.X11-unix";
|
|
2218
|
+
var CONTAINER_XAUTHORITY = "/tmp/xauth";
|
|
2219
|
+
var CONTAINER_XDG_DIR = "/xdg";
|
|
2220
|
+
var RUNTIME_DIR_SIZE = "64m";
|
|
2221
|
+
var HOME_SIZE = "64m";
|
|
2222
|
+
var MASK_SIZE = "1m";
|
|
2223
|
+
function refuseProtonHeaded(game, mode, image) {
|
|
2224
|
+
if (image?.launcher !== "proton" || mode !== "headed")
|
|
2225
|
+
return;
|
|
2226
|
+
throw new GamecrateError(`${game}: a proton image only runs offscreen`, Exit.Config, 'relaunch with --mode headless, or use a variant whose gamecrate.launcher is "direct"');
|
|
2227
|
+
}
|
|
2228
|
+
function launchCommand(plan, executable, proton) {
|
|
2229
|
+
const { gameConfig: game, settings } = plan;
|
|
2230
|
+
if (proton) {
|
|
2231
|
+
return ["run-headless-windows", winPath(join3(game.gameFiles.container, basename2(executable)))];
|
|
2436
2232
|
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
const found = [];
|
|
2441
|
-
entries.forEach((entry, index) => {
|
|
2442
|
-
const name = entry?.[key];
|
|
2443
|
-
if (typeof name !== "string")
|
|
2444
|
-
return;
|
|
2445
|
-
if (seen.has(name))
|
|
2446
|
-
found.push({ index, name });
|
|
2447
|
-
else
|
|
2448
|
-
seen.add(name);
|
|
2449
|
-
});
|
|
2450
|
-
return found;
|
|
2233
|
+
if (plan.mode === "headed")
|
|
2234
|
+
return [executable];
|
|
2235
|
+
return ["xvfb-run", "-a", `--server-args=-screen 0 ${settings.width}x${settings.height}x24`, executable];
|
|
2451
2236
|
}
|
|
2452
|
-
function
|
|
2453
|
-
|
|
2237
|
+
function appendGameArgs(command, mounts, env, plan, proton) {
|
|
2238
|
+
const { gameConfig: game } = plan;
|
|
2239
|
+
if (game.dataDir.mode === "arg") {
|
|
2240
|
+
const arg = validateDataDirArg(game.dataDir);
|
|
2241
|
+
const eq = arg.indexOf("=");
|
|
2242
|
+
command.push(proton ? `${arg.slice(0, eq + 1)}${winPath(arg.slice(eq + 1))}` : arg);
|
|
2243
|
+
} else
|
|
2244
|
+
Object.assign(env, game.dataDir.env);
|
|
2245
|
+
if (game.logFile.mode !== "arg")
|
|
2246
|
+
return;
|
|
2247
|
+
mounts.push({ type: "bind", source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR });
|
|
2248
|
+
const log = `${CONTAINER_LOG_DIR}/Player.log`;
|
|
2249
|
+
command.push(game.logFile.arg, proton ? winPath(log) : log);
|
|
2454
2250
|
}
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
const
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2251
|
+
function buildRunSpec(plan, modMounts, identity, image) {
|
|
2252
|
+
const { gameConfig: game, settings } = plan;
|
|
2253
|
+
const headed = plan.mode === "headed";
|
|
2254
|
+
const mounts = [];
|
|
2255
|
+
const env = {
|
|
2256
|
+
HOME: identity.home,
|
|
2257
|
+
USER: identity.user,
|
|
2258
|
+
LOGNAME: identity.user
|
|
2259
|
+
};
|
|
2260
|
+
addGameFiles(mounts, plan);
|
|
2261
|
+
addStage(mounts, plan, modMounts);
|
|
2262
|
+
const proton = image?.launcher === "proton";
|
|
2263
|
+
const executable = image?.executable ?? game.executable;
|
|
2264
|
+
refuseProtonHeaded(plan.game, plan.mode, image);
|
|
2265
|
+
const command = launchCommand(plan, executable, proton);
|
|
2266
|
+
if (proton) {
|
|
2267
|
+
Object.assign(env, {
|
|
2268
|
+
SCREEN: `${settings.width}x${settings.height}x24`,
|
|
2269
|
+
DESKTOP: `${settings.width}x${settings.height}`,
|
|
2270
|
+
STEAM_COMPAT_DATA_PATH: `${CONTAINER_XDG_DIR}/proton`
|
|
2464
2271
|
});
|
|
2272
|
+
}
|
|
2273
|
+
appendGameArgs(command, mounts, env, plan, proton);
|
|
2274
|
+
addScratch(mounts, env, plan, identity);
|
|
2275
|
+
if (headed)
|
|
2276
|
+
addSession(mounts, env, plan);
|
|
2277
|
+
const deviceCgroupRules = [];
|
|
2278
|
+
if (settings.input) {
|
|
2279
|
+
mounts.push({ type: "bind", source: "/dev/input", target: "/dev/input", readonly: true });
|
|
2280
|
+
deviceCgroupRules.push("c 13:* rmw");
|
|
2281
|
+
}
|
|
2282
|
+
const devices = [];
|
|
2283
|
+
if (settings.gpu)
|
|
2284
|
+
devices.push("nvidia.com/gpu=all");
|
|
2285
|
+
Object.assign(env, glEnv(settings.gpu));
|
|
2286
|
+
command.push(...settings.gameArgs ?? []);
|
|
2287
|
+
return {
|
|
2288
|
+
image: game.image.ref,
|
|
2289
|
+
name: containerName(plan),
|
|
2290
|
+
labels: {
|
|
2291
|
+
"gamecrate.game": plan.game,
|
|
2292
|
+
"gamecrate.profile": plan.profile,
|
|
2293
|
+
...plan.instance === undefined ? {} : { "gamecrate.instance": plan.instance }
|
|
2294
|
+
},
|
|
2295
|
+
identity,
|
|
2296
|
+
env,
|
|
2297
|
+
mounts,
|
|
2298
|
+
devices,
|
|
2299
|
+
deviceCgroupRules,
|
|
2300
|
+
network: settings.network,
|
|
2301
|
+
memory: settings.memory,
|
|
2302
|
+
memorySwap: settings.memory,
|
|
2303
|
+
cpus: settings.cpus,
|
|
2304
|
+
pidsLimit: settings.pidsLimit,
|
|
2305
|
+
ulimits: ["core=0"],
|
|
2306
|
+
workdir: game.gameFiles.container,
|
|
2307
|
+
...headed && settings.display === "x11" ? { hostname: hostname() } : {},
|
|
2308
|
+
command,
|
|
2309
|
+
extraArgs: [...settings.dockerArgs ?? []]
|
|
2465
2310
|
};
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2311
|
+
}
|
|
2312
|
+
function addGameFiles(mounts, plan) {
|
|
2313
|
+
const { gameFiles } = plan.gameConfig;
|
|
2314
|
+
if (gameFiles.source !== "mount")
|
|
2315
|
+
return;
|
|
2316
|
+
if (!gameFiles.host) {
|
|
2317
|
+
throw new GamecrateError(`gameFiles.source is "mount" but no host path is set for ${plan.game}`, Exit.Config);
|
|
2470
2318
|
}
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
const name = branch?.["name"];
|
|
2479
|
-
if (typeof name === "string" && !TAG_COMPONENT.test(name)) {
|
|
2480
|
-
push(`branch name "${name}" must match ${TAG_COMPONENT.source}`, ["branches", index, "name"]);
|
|
2481
|
-
}
|
|
2482
|
-
const tags = branch?.["tags"];
|
|
2483
|
-
if (!Array.isArray(tags))
|
|
2484
|
-
return;
|
|
2485
|
-
tags.forEach((tag, at) => {
|
|
2486
|
-
if (typeof tag !== "string" || TAG_COMPONENT.test(tag))
|
|
2487
|
-
return;
|
|
2488
|
-
push(`branch tag "${String(tag)}" must match ${TAG_COMPONENT.source}`, ["branches", index, "tags", at]);
|
|
2489
|
-
});
|
|
2490
|
-
});
|
|
2319
|
+
mounts.push({ type: "bind", source: hostPath(gameFiles.host), target: gameFiles.container, readonly: true });
|
|
2320
|
+
}
|
|
2321
|
+
function addStage(mounts, plan, modMounts) {
|
|
2322
|
+
const game = plan.gameConfig;
|
|
2323
|
+
mounts.push({ type: "bind", source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true });
|
|
2324
|
+
for (const mount of modMounts) {
|
|
2325
|
+
mounts.push(mount.type === "bind" ? { ...mount, readonly: true } : mount);
|
|
2491
2326
|
}
|
|
2492
|
-
|
|
2327
|
+
mounts.push({ type: "bind", source: hostPath(plan.dataDirHost), target: game.dataDir.container });
|
|
2328
|
+
}
|
|
2329
|
+
function addScratch(mounts, env, plan, identity) {
|
|
2330
|
+
const { uid, gid } = identity;
|
|
2331
|
+
for (const target of plan.gameConfig.modsDir.mask ?? []) {
|
|
2332
|
+
mounts.push({ type: "tmpfs", target, size: MASK_SIZE, uid, gid, mode: "755" });
|
|
2333
|
+
}
|
|
2334
|
+
if (uid !== 0) {
|
|
2335
|
+
mounts.push({ type: "tmpfs", target: identity.home, size: HOME_SIZE, uid, gid, mode: "700" });
|
|
2336
|
+
}
|
|
2337
|
+
mounts.push({ type: "tmpfs", target: CONTAINER_RUNTIME_DIR, size: RUNTIME_DIR_SIZE, uid, gid, mode: "700" });
|
|
2338
|
+
env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR;
|
|
2339
|
+
mounts.push({ type: "bind", source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR });
|
|
2340
|
+
env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`;
|
|
2341
|
+
env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`;
|
|
2342
|
+
env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`;
|
|
2343
|
+
}
|
|
2344
|
+
function addSession(mounts, env, plan) {
|
|
2345
|
+
const { settings } = plan;
|
|
2346
|
+
if (settings.display === "x11")
|
|
2347
|
+
addX11(mounts, env);
|
|
2348
|
+
else
|
|
2349
|
+
addWayland(mounts, env);
|
|
2350
|
+
if (!settings.audio)
|
|
2493
2351
|
return;
|
|
2494
|
-
for (const
|
|
2495
|
-
push(
|
|
2352
|
+
for (const socket of audioSockets()) {
|
|
2353
|
+
mounts.push({ type: "bind", source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` });
|
|
2496
2354
|
}
|
|
2497
|
-
|
|
2498
|
-
const v = variant;
|
|
2499
|
-
const base = v?.["base"];
|
|
2500
|
-
if (base !== "xvfb" && base !== "proton")
|
|
2501
|
-
return;
|
|
2502
|
-
const depot = v?.["depot"] ?? "linux";
|
|
2503
|
-
if (depot === "macos") {
|
|
2504
|
-
push("a macos depot cannot be runnable", ["variants", index, "base"], 'set base to "none"; no macos container runtime exists');
|
|
2505
|
-
return;
|
|
2506
|
-
}
|
|
2507
|
-
const wants = depot === "windows" ? "proton" : "xvfb";
|
|
2508
|
-
if (base === wants)
|
|
2509
|
-
return;
|
|
2510
|
-
push(`a ${describe(depot)} depot cannot run on the "${base}" base`, ["variants", index, "base"], depot === "windows" ? 'set base to "proton"; it is the only base with wine' : 'set base to "xvfb", or set depot to "windows" if the image should run under wine');
|
|
2511
|
-
});
|
|
2355
|
+
env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`;
|
|
2512
2356
|
}
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2357
|
+
function addX11(mounts, env) {
|
|
2358
|
+
const x11 = x11Session();
|
|
2359
|
+
if (!x11)
|
|
2360
|
+
return;
|
|
2361
|
+
mounts.push({ type: "bind", source: X11_SOCKET_DIR, target: X11_SOCKET_DIR });
|
|
2362
|
+
env.DISPLAY = x11.display;
|
|
2363
|
+
env.XDG_SESSION_TYPE = "x11";
|
|
2364
|
+
env.SDL_VIDEODRIVER = "x11";
|
|
2365
|
+
env.QT_QPA_PLATFORM = "xcb";
|
|
2366
|
+
if (!x11.xauthority)
|
|
2367
|
+
return;
|
|
2368
|
+
mounts.push({ type: "bind", source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true });
|
|
2369
|
+
env.XAUTHORITY = CONTAINER_XAUTHORITY;
|
|
2370
|
+
}
|
|
2371
|
+
function addWayland(mounts, env) {
|
|
2372
|
+
const wayland = waylandSocket();
|
|
2373
|
+
if (!wayland)
|
|
2374
|
+
return;
|
|
2375
|
+
mounts.push({ type: "bind", source: wayland.source, target: `${CONTAINER_RUNTIME_DIR}/${wayland.name}` });
|
|
2376
|
+
env.WAYLAND_DISPLAY = wayland.name;
|
|
2377
|
+
env.XDG_SESSION_TYPE = "wayland";
|
|
2378
|
+
env.SDL_VIDEODRIVER = "wayland";
|
|
2379
|
+
env.QT_QPA_PLATFORM = "wayland";
|
|
2380
|
+
}
|
|
2381
|
+
function containerName(plan) {
|
|
2382
|
+
const base = `gamecrate-${plan.game}-${plan.profile}`;
|
|
2383
|
+
return plan.instance === undefined ? base : `${base}-${plan.instance}`;
|
|
2384
|
+
}
|
|
2385
|
+
function windowIcon(plan, configDir) {
|
|
2386
|
+
const named = resolveProfile(plan.gameConfig, plan.profile).windowIcon;
|
|
2387
|
+
if (named === undefined)
|
|
2388
|
+
return;
|
|
2389
|
+
const path = expandHome(named);
|
|
2390
|
+
return isAbsolute2(path) ? path : resolve3(configDir, path);
|
|
2391
|
+
}
|
|
2392
|
+
function windowTitle(plan) {
|
|
2393
|
+
const own = resolveProfile(plan.gameConfig, plan.profile).windowTitle;
|
|
2394
|
+
const base = own ?? `${plan.game} ${plan.profile}`;
|
|
2395
|
+
return plan.instance === undefined ? base : `${base} / ${plan.instance}`;
|
|
2396
|
+
}
|
|
2397
|
+
function toDockerArgs(spec) {
|
|
2398
|
+
const args = ["run", "--rm", "--init", "--name", spec.name];
|
|
2399
|
+
if (spec.hostname !== undefined)
|
|
2400
|
+
args.push("--hostname", spec.hostname);
|
|
2401
|
+
for (const [key, value] of Object.entries(spec.labels))
|
|
2402
|
+
args.push("--label", `${key}=${value}`);
|
|
2403
|
+
args.push("--user", `${spec.identity.uid}:${spec.identity.gid}`);
|
|
2404
|
+
for (const [key, value] of Object.entries(spec.env))
|
|
2405
|
+
args.push("--env", `${key}=${value}`);
|
|
2406
|
+
for (const mount of spec.mounts)
|
|
2407
|
+
args.push(...mountArgs(mount));
|
|
2408
|
+
for (const device of spec.devices)
|
|
2409
|
+
args.push("--device", device);
|
|
2410
|
+
for (const rule of spec.deviceCgroupRules)
|
|
2411
|
+
args.push("--device-cgroup-rule", rule);
|
|
2412
|
+
for (const ulimit of spec.ulimits)
|
|
2413
|
+
args.push("--ulimit", ulimit);
|
|
2414
|
+
args.push("--network", spec.network, "--memory", spec.memory, "--memory-swap", spec.memorySwap, "--cpus", String(spec.cpus), "--pids-limit", String(spec.pidsLimit), "--workdir", spec.workdir, ...spec.extraArgs, "--pull=never");
|
|
2415
|
+
const [entrypoint, ...rest] = spec.command;
|
|
2416
|
+
if (entrypoint !== undefined)
|
|
2417
|
+
args.push("--entrypoint", entrypoint);
|
|
2418
|
+
args.push(spec.image, ...rest);
|
|
2419
|
+
return args;
|
|
2420
|
+
}
|
|
2421
|
+
function mountArgs(mount) {
|
|
2422
|
+
if (mount.type === "tmpfs") {
|
|
2423
|
+
const opts = ["rw"];
|
|
2424
|
+
if (mount.uid !== undefined)
|
|
2425
|
+
opts.push(`uid=${mount.uid}`);
|
|
2426
|
+
if (mount.gid !== undefined)
|
|
2427
|
+
opts.push(`gid=${mount.gid}`);
|
|
2428
|
+
if (mount.mode)
|
|
2429
|
+
opts.push(`mode=${mount.mode}`);
|
|
2430
|
+
opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`);
|
|
2431
|
+
return ["--tmpfs", `${mount.target}:${opts.join(",")}`];
|
|
2584
2432
|
}
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
if (!isObj(games)) {
|
|
2588
|
-
problems.push({ where: "/games", message: 'missing required key "games", or it is not an object' });
|
|
2589
|
-
return { config: cfg, problems };
|
|
2433
|
+
if (!mount.source) {
|
|
2434
|
+
throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config);
|
|
2590
2435
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2436
|
+
const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`];
|
|
2437
|
+
if (mount.readonly)
|
|
2438
|
+
fields.push("readonly");
|
|
2439
|
+
return ["--mount", fields.map(csvField).join(",")];
|
|
2440
|
+
}
|
|
2441
|
+
function csvField(field) {
|
|
2442
|
+
if (!field.includes(",") && !field.includes('"'))
|
|
2443
|
+
return field;
|
|
2444
|
+
return `"${field.replaceAll('"', '""')}"`;
|
|
2445
|
+
}
|
|
2446
|
+
function winPath(unix) {
|
|
2447
|
+
return `Z:${unix.replaceAll("/", "\\")}`;
|
|
2448
|
+
}
|
|
2449
|
+
function validateDataDirArg(dataDir) {
|
|
2450
|
+
if (dataDir.container.includes("=")) {
|
|
2451
|
+
throw new GamecrateError(`container data path contains "=": ${dataDir.container}`, Exit.Config, "RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.");
|
|
2593
2452
|
}
|
|
2594
|
-
|
|
2595
|
-
|
|
2453
|
+
const parts = dataDir.arg.split("=");
|
|
2454
|
+
if (parts.length !== 2) {
|
|
2455
|
+
throw new GamecrateError(`dataDir.arg must contain exactly one "=": ${dataDir.arg}`, Exit.Config);
|
|
2456
|
+
}
|
|
2457
|
+
if (trimSlash(parts[1] ?? "") !== trimSlash(dataDir.container)) {
|
|
2458
|
+
throw new GamecrateError(`dataDir.arg points at ${parts[1]} but the mount target is ${dataDir.container}`, Exit.Config, "The engine would write to a path that is not the mounted data directory.");
|
|
2459
|
+
}
|
|
2460
|
+
return dataDir.arg;
|
|
2461
|
+
}
|
|
2462
|
+
function trimSlash(path) {
|
|
2463
|
+
let end = path.length;
|
|
2464
|
+
while (end > 1 && path[end - 1] === "/")
|
|
2465
|
+
end--;
|
|
2466
|
+
return path.slice(0, end);
|
|
2596
2467
|
}
|
|
2597
|
-
function
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
issue.keys.forEach((key, i) => {
|
|
2605
|
-
const problem = {
|
|
2606
|
-
where: `${prefix}${pointer(issue.path)}/${esc(key)}`,
|
|
2607
|
-
message: `unknown key "${key}"`
|
|
2608
|
-
};
|
|
2609
|
-
const hint = hints[i];
|
|
2610
|
-
if (hint != null)
|
|
2611
|
-
problem.suggestion = `did you mean "${hint}"?`;
|
|
2612
|
-
problems.push(problem);
|
|
2613
|
-
});
|
|
2614
|
-
continue;
|
|
2615
|
-
}
|
|
2616
|
-
const key = issue.path.at(-1);
|
|
2617
|
-
const missing = typeof key === "string" && valueAt(value, issue.path) === undefined;
|
|
2618
|
-
const problem = {
|
|
2619
|
-
where: `${prefix}${pointer(issue.path)}`,
|
|
2620
|
-
message: missing ? `missing required key "${key}"` : issue.message
|
|
2621
|
-
};
|
|
2622
|
-
const hint = issue.params?.suggestion;
|
|
2623
|
-
if (hint !== undefined)
|
|
2624
|
-
problem.suggestion = hint;
|
|
2625
|
-
problems.push(problem);
|
|
2468
|
+
function glEnv(gpu) {
|
|
2469
|
+
if (!gpu)
|
|
2470
|
+
return { LIBGL_ALWAYS_SOFTWARE: "1", GALLIUM_DRIVER: "llvmpipe" };
|
|
2471
|
+
const env = { LIBGL_ALWAYS_SOFTWARE: "0", GALLIUM_DRIVER: "" };
|
|
2472
|
+
if (hasNvidia()) {
|
|
2473
|
+
env.__GLX_VENDOR_LIBRARY_NAME = "nvidia";
|
|
2474
|
+
env.__NV_PRIME_RENDER_OFFLOAD = "1";
|
|
2626
2475
|
}
|
|
2476
|
+
return env;
|
|
2627
2477
|
}
|
|
2628
|
-
function
|
|
2629
|
-
return
|
|
2478
|
+
function hasNvidia() {
|
|
2479
|
+
return existsSync("/dev/nvidiactl") || existsSync("/etc/cdi/nvidia.yaml") || existsSync("/usr/share/vulkan/icd.d/nvidia_icd.json");
|
|
2630
2480
|
}
|
|
2631
|
-
function
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2481
|
+
function x11Session() {
|
|
2482
|
+
const display = process.env.DISPLAY;
|
|
2483
|
+
if (!display)
|
|
2484
|
+
return null;
|
|
2485
|
+
const cookie = process.env.XAUTHORITY ?? join3(homedir2(), ".Xauthority");
|
|
2486
|
+
return { display, xauthority: existsSync(cookie) ? cookie : null };
|
|
2487
|
+
}
|
|
2488
|
+
function waylandSocket() {
|
|
2489
|
+
const display = process.env.WAYLAND_DISPLAY;
|
|
2490
|
+
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
2491
|
+
if (!display)
|
|
2492
|
+
return null;
|
|
2493
|
+
let source = null;
|
|
2494
|
+
if (display.startsWith("/"))
|
|
2495
|
+
source = display;
|
|
2496
|
+
else if (runtime)
|
|
2497
|
+
source = join3(runtime, display);
|
|
2498
|
+
if (!source || !existsSync(source))
|
|
2499
|
+
return null;
|
|
2500
|
+
return { source, name: basename2(source) };
|
|
2501
|
+
}
|
|
2502
|
+
function audioSockets() {
|
|
2503
|
+
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
2504
|
+
if (!runtime)
|
|
2505
|
+
return [];
|
|
2506
|
+
const found = [];
|
|
2507
|
+
for (const name of ["pipewire-0", "pulse/native"]) {
|
|
2508
|
+
const source = join3(runtime, name);
|
|
2509
|
+
if (existsSync(source))
|
|
2510
|
+
found.push({ source, name });
|
|
2637
2511
|
}
|
|
2638
|
-
return
|
|
2512
|
+
return found;
|
|
2639
2513
|
}
|
|
2640
|
-
function
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
if (!isObj(game_))
|
|
2646
|
-
continue;
|
|
2647
|
-
checkVariantNames(p, where, game_);
|
|
2648
|
-
const profiles = game_["profiles"];
|
|
2649
|
-
if (!isObj(profiles))
|
|
2650
|
-
continue;
|
|
2651
|
-
for (const [name, prof] of Object.entries(profiles)) {
|
|
2652
|
-
const w = `${where}/profiles/${esc(name)}`;
|
|
2653
|
-
checkName(p, w, name, "profile");
|
|
2654
|
-
if (isObj(prof))
|
|
2655
|
-
checkProfile(p, w, name, prof, profiles);
|
|
2656
|
-
}
|
|
2657
|
-
checkCollisions(p, where, gameName, profiles, containers);
|
|
2514
|
+
function hostPath(path) {
|
|
2515
|
+
try {
|
|
2516
|
+
return realpathSync(path);
|
|
2517
|
+
} catch {
|
|
2518
|
+
return path;
|
|
2658
2519
|
}
|
|
2659
2520
|
}
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
return;
|
|
2665
|
-
variants.forEach((variant, index) => {
|
|
2666
|
-
const name = isObj(variant) ? variant["name"] : undefined;
|
|
2667
|
-
if (typeof name !== "string")
|
|
2668
|
-
return;
|
|
2669
|
-
checkName(p, `${where}/steamBuild/variants/${index}/name`, name, "variant");
|
|
2670
|
-
});
|
|
2521
|
+
|
|
2522
|
+
// src/docker/run.ts
|
|
2523
|
+
function spawnArgv(argv, stdio, detached = false) {
|
|
2524
|
+
return spawn(argv[0], argv.slice(1), { stdio, detached });
|
|
2671
2525
|
}
|
|
2672
|
-
function
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
checkInstances(p, w, prof);
|
|
2526
|
+
function exited(proc) {
|
|
2527
|
+
return new Promise((resolve, reject) => {
|
|
2528
|
+
proc.once("error", reject);
|
|
2529
|
+
proc.once("close", (code) => resolve(code ?? 1));
|
|
2530
|
+
});
|
|
2678
2531
|
}
|
|
2679
|
-
function
|
|
2680
|
-
const
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
const hint = suggest(parent, names);
|
|
2685
|
-
if (hint)
|
|
2686
|
-
prob.suggestion = `did you mean "${hint}"?`;
|
|
2687
|
-
p.push(prob);
|
|
2532
|
+
async function collect3(stream) {
|
|
2533
|
+
const chunks = [];
|
|
2534
|
+
for await (const chunk of stream)
|
|
2535
|
+
chunks.push(chunk);
|
|
2536
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2688
2537
|
}
|
|
2689
|
-
function
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2538
|
+
async function capture(argv) {
|
|
2539
|
+
try {
|
|
2540
|
+
const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
|
|
2541
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
2542
|
+
collect3(proc.stdout),
|
|
2543
|
+
collect3(proc.stderr),
|
|
2544
|
+
exited(proc)
|
|
2545
|
+
]);
|
|
2546
|
+
return { code, stdout, stderr };
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
return { code: 127, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
2699
2549
|
}
|
|
2700
|
-
|
|
2701
|
-
|
|
2550
|
+
}
|
|
2551
|
+
async function captureLive(argv, env) {
|
|
2552
|
+
const proc = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"], env });
|
|
2553
|
+
const chunks = [];
|
|
2554
|
+
const keep = async (stream) => {
|
|
2555
|
+
for await (const chunk of stream) {
|
|
2556
|
+
process.stderr.write(chunk);
|
|
2557
|
+
chunks.push(chunk);
|
|
2558
|
+
}
|
|
2559
|
+
};
|
|
2560
|
+
const [, , code] = await Promise.all([keep(proc.stdout), keep(proc.stderr), exited(proc)]);
|
|
2561
|
+
return { code, text: Buffer.concat(chunks).toString("utf8") };
|
|
2562
|
+
}
|
|
2563
|
+
var STDOUT_LOG = "stdout.log";
|
|
2564
|
+
var MARKER_POLL_MS = 200;
|
|
2565
|
+
async function runContainer(spec, opts) {
|
|
2566
|
+
const stopTimeout = opts.stopTimeoutSeconds ?? 10;
|
|
2567
|
+
mkdirSync(opts.logDir, { recursive: true });
|
|
2568
|
+
const sink = createWriteStream(join4(opts.logDir, STDOUT_LOG));
|
|
2569
|
+
const proc = spawnArgv(["docker", ...toDockerArgs(spec)], ["inherit", "pipe", "pipe"]);
|
|
2570
|
+
let interrupted = false;
|
|
2571
|
+
const onSignal = () => {
|
|
2572
|
+
if (interrupted)
|
|
2573
|
+
return;
|
|
2574
|
+
interrupted = true;
|
|
2575
|
+
stopContainer(spec.name, stopTimeout);
|
|
2576
|
+
};
|
|
2577
|
+
process.on("SIGINT", onSignal);
|
|
2578
|
+
process.on("SIGTERM", onSignal);
|
|
2579
|
+
const code = exited(proc);
|
|
2580
|
+
try {
|
|
2581
|
+
await Promise.all([
|
|
2582
|
+
tee(proc.stdout, sink, process.stdout),
|
|
2583
|
+
tee(proc.stderr, sink, process.stderr)
|
|
2584
|
+
]);
|
|
2585
|
+
const status = await code;
|
|
2586
|
+
return interrupted ? Exit.Interrupted : status;
|
|
2587
|
+
} finally {
|
|
2588
|
+
process.off("SIGINT", onSignal);
|
|
2589
|
+
process.off("SIGTERM", onSignal);
|
|
2590
|
+
await new Promise((resolve) => sink.end(resolve));
|
|
2702
2591
|
}
|
|
2703
2592
|
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2593
|
+
var STOP_TIMEOUT_SECONDS = 10;
|
|
2594
|
+
async function stopContainer(name, timeoutSeconds) {
|
|
2595
|
+
const proc = spawnArgv(["docker", "stop", "--timeout", String(timeoutSeconds), name], "ignore");
|
|
2596
|
+
await exited(proc).catch(() => {});
|
|
2597
|
+
}
|
|
2598
|
+
async function waitForMarker(sources, marker, timeoutSeconds) {
|
|
2599
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
2600
|
+
const carry = Math.max(marker.length - 1, 0);
|
|
2601
|
+
const seen = new Map;
|
|
2602
|
+
const startedAt = Date.now();
|
|
2603
|
+
while (true) {
|
|
2604
|
+
for (const path of await expandSources(sources)) {
|
|
2605
|
+
let state = seen.get(path);
|
|
2606
|
+
if (state === undefined) {
|
|
2607
|
+
state = { offset: await staleSize(path, startedAt), tail: "", decoder: new TextDecoder };
|
|
2608
|
+
seen.set(path, state);
|
|
2609
|
+
}
|
|
2610
|
+
if (await scan(path, state, marker, carry))
|
|
2611
|
+
return true;
|
|
2715
2612
|
}
|
|
2613
|
+
if (Date.now() >= deadline)
|
|
2614
|
+
return false;
|
|
2615
|
+
await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)));
|
|
2716
2616
|
}
|
|
2717
2617
|
}
|
|
2718
|
-
function
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2618
|
+
async function staleSize(path, startedAt) {
|
|
2619
|
+
return stat(path).then((info) => info.mtimeMs < startedAt ? info.size : 0, () => 0);
|
|
2620
|
+
}
|
|
2621
|
+
async function scan(path, state, marker, carry) {
|
|
2622
|
+
const handle = await open(path, "r").catch(() => null);
|
|
2623
|
+
if (handle === null)
|
|
2624
|
+
return false;
|
|
2625
|
+
try {
|
|
2626
|
+
const { size } = await handle.stat();
|
|
2627
|
+
if (size < state.offset) {
|
|
2628
|
+
state.offset = 0;
|
|
2629
|
+
state.tail = "";
|
|
2630
|
+
state.decoder = new TextDecoder;
|
|
2631
|
+
}
|
|
2632
|
+
if (size <= state.offset)
|
|
2633
|
+
return false;
|
|
2634
|
+
const buffer = Buffer.alloc(size - state.offset);
|
|
2635
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset);
|
|
2636
|
+
state.offset += bytesRead;
|
|
2637
|
+
const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true });
|
|
2638
|
+
if (text.includes(marker))
|
|
2639
|
+
return true;
|
|
2640
|
+
state.tail = carry > 0 ? text.slice(-carry) : "";
|
|
2641
|
+
return false;
|
|
2642
|
+
} catch {
|
|
2643
|
+
return false;
|
|
2644
|
+
} finally {
|
|
2645
|
+
await handle.close().catch(() => {});
|
|
2724
2646
|
}
|
|
2725
2647
|
}
|
|
2726
|
-
function
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
const names = new Map;
|
|
2733
|
-
const aliasOwners = new Map;
|
|
2734
|
-
const prefix = `${gameName.toLowerCase()}-`;
|
|
2735
|
-
for (const [name, prof] of Object.entries(profiles)) {
|
|
2736
|
-
const w = `${where}/profiles/${esc(name)}`;
|
|
2737
|
-
const lower = name.toLowerCase();
|
|
2738
|
-
const twin = names.get(lower);
|
|
2739
|
-
if (twin !== undefined) {
|
|
2740
|
-
p.push({
|
|
2741
|
-
where: w,
|
|
2742
|
-
message: `profile "${name}" differs from "${twin}" only in case, so both share one data directory`
|
|
2743
|
-
});
|
|
2648
|
+
async function expandSources(sources) {
|
|
2649
|
+
const out = [];
|
|
2650
|
+
for (const source of sources) {
|
|
2651
|
+
const info = await stat(source).catch(() => null);
|
|
2652
|
+
if (info === null) {
|
|
2653
|
+
out.push(source);
|
|
2744
2654
|
continue;
|
|
2745
2655
|
}
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
if (clash !== undefined) {
|
|
2749
|
-
p.push({ where: w, message: `container name collides with ${clash}` });
|
|
2656
|
+
if (!info.isDirectory()) {
|
|
2657
|
+
out.push(source);
|
|
2750
2658
|
continue;
|
|
2751
2659
|
}
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2660
|
+
const entries = await readdir2(source).catch(() => []);
|
|
2661
|
+
for (const entry of entries) {
|
|
2662
|
+
if (entry.toLowerCase().endsWith(".log"))
|
|
2663
|
+
out.push(join4(source, entry));
|
|
2664
|
+
}
|
|
2757
2665
|
}
|
|
2666
|
+
return out;
|
|
2758
2667
|
}
|
|
2759
|
-
function
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
for (const [i, entry] of aliases.entries()) {
|
|
2764
|
-
if (typeof entry !== "string")
|
|
2765
|
-
continue;
|
|
2766
|
-
const owner = owners.get(entry.toLowerCase());
|
|
2767
|
-
if (owner !== undefined) {
|
|
2768
|
-
p.push({
|
|
2769
|
-
where: `${w}/aliases/${i}`,
|
|
2770
|
-
message: `alias "${entry}" is already declared by profile "${owner}"`
|
|
2771
|
-
});
|
|
2772
|
-
continue;
|
|
2773
|
-
}
|
|
2774
|
-
owners.set(entry.toLowerCase(), name);
|
|
2668
|
+
async function tee(stream, sink, mirror) {
|
|
2669
|
+
for await (const chunk of stream) {
|
|
2670
|
+
mirror.write(chunk);
|
|
2671
|
+
sink.write(chunk);
|
|
2775
2672
|
}
|
|
2776
2673
|
}
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2674
|
+
|
|
2675
|
+
// src/mods/staleness.ts
|
|
2676
|
+
import { readdir as readdir3, stat as stat2 } from "node:fs/promises";
|
|
2677
|
+
import { join as join5, relative } from "node:path";
|
|
2678
|
+
var SKIP_DIRS = new Set([".git", ".retired", ".vs", "bin", "node_modules", "obj"]);
|
|
2679
|
+
var ENTRY_LIMIT = 20000;
|
|
2680
|
+
async function scanBuildTimes(dir) {
|
|
2681
|
+
const state = { root: dir, times: { sourceTimes: [] }, budget: ENTRY_LIMIT };
|
|
2682
|
+
await walk(state, dir, false);
|
|
2683
|
+
return state.times;
|
|
2684
|
+
}
|
|
2685
|
+
async function walk(state, current, inAssemblies) {
|
|
2686
|
+
let entries;
|
|
2687
|
+
try {
|
|
2688
|
+
entries = await readdir3(current, { withFileTypes: true });
|
|
2689
|
+
} catch {
|
|
2780
2690
|
return;
|
|
2781
|
-
|
|
2782
|
-
for (const
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
const
|
|
2786
|
-
if (
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
});
|
|
2691
|
+
}
|
|
2692
|
+
for (const entry of entries) {
|
|
2693
|
+
if (state.budget-- <= 0)
|
|
2694
|
+
return;
|
|
2695
|
+
const path = join5(current, entry.name);
|
|
2696
|
+
if (entry.isDirectory()) {
|
|
2697
|
+
if (!SKIP_DIRS.has(entry.name.toLowerCase())) {
|
|
2698
|
+
await walk(state, path, inAssemblies || entry.name === "Assemblies");
|
|
2699
|
+
}
|
|
2791
2700
|
continue;
|
|
2792
2701
|
}
|
|
2793
|
-
|
|
2702
|
+
if (entry.isFile())
|
|
2703
|
+
await record(state, path, entry.name, inAssemblies);
|
|
2794
2704
|
}
|
|
2795
2705
|
}
|
|
2796
|
-
function
|
|
2706
|
+
async function record(state, path, name, inAssemblies) {
|
|
2707
|
+
const lower = name.toLowerCase();
|
|
2708
|
+
const isSource = lower.endsWith(".cs");
|
|
2709
|
+
const isAssembly = inAssemblies && lower.endsWith(".dll");
|
|
2710
|
+
if (!isSource && !isAssembly)
|
|
2711
|
+
return;
|
|
2712
|
+
let mtimeMs;
|
|
2797
2713
|
try {
|
|
2798
|
-
|
|
2799
|
-
return isObj(resolved) ? Object.keys(resolved) : [];
|
|
2714
|
+
mtimeMs = (await stat2(path)).mtimeMs;
|
|
2800
2715
|
} catch {
|
|
2801
|
-
|
|
2802
|
-
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
const { times } = state;
|
|
2719
|
+
const found = { path: relative(state.root, path), mtimeMs };
|
|
2720
|
+
if (isSource) {
|
|
2721
|
+
times.sourceTimes.push(mtimeMs);
|
|
2722
|
+
if (mtimeMs > (times.newestSource?.mtimeMs ?? -1))
|
|
2723
|
+
times.newestSource = found;
|
|
2724
|
+
} else if (mtimeMs > (times.newestAssembly?.mtimeMs ?? -1)) {
|
|
2725
|
+
times.newestAssembly = found;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
var SKEW_MS = 1000;
|
|
2729
|
+
function newerThan(source, assembly) {
|
|
2730
|
+
return source - assembly > SKEW_MS;
|
|
2731
|
+
}
|
|
2732
|
+
function decideStale(times) {
|
|
2733
|
+
const { newestSource, newestAssembly } = times;
|
|
2734
|
+
if (newestSource === undefined)
|
|
2735
|
+
return false;
|
|
2736
|
+
return newestAssembly === undefined || newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs);
|
|
2737
|
+
}
|
|
2738
|
+
function staleReport(times) {
|
|
2739
|
+
const { newestSource, newestAssembly } = times;
|
|
2740
|
+
if (newestSource === undefined || newestAssembly === undefined)
|
|
2741
|
+
return null;
|
|
2742
|
+
if (!newerThan(newestSource.mtimeMs, newestAssembly.mtimeMs))
|
|
2743
|
+
return null;
|
|
2744
|
+
return {
|
|
2745
|
+
newestSource: newestSource.path,
|
|
2746
|
+
newestSourceMs: newestSource.mtimeMs,
|
|
2747
|
+
assembly: newestAssembly.path,
|
|
2748
|
+
assemblyMs: newestAssembly.mtimeMs,
|
|
2749
|
+
newerCount: times.sourceTimes.filter((t) => newerThan(t, newestAssembly.mtimeMs)).length
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
var INDENT = " ".repeat("warning: ".length);
|
|
2753
|
+
function staleWarning(packageId, report, now = Date.now()) {
|
|
2754
|
+
const files = report.newerCount === 1 ? "1 source file" : `${report.newerCount} source files`;
|
|
2755
|
+
return [
|
|
2756
|
+
`${packageId} has ${files} newer than ${report.assembly}`,
|
|
2757
|
+
`${INDENT}newest: ${report.newestSource} (${ago(report.newestSourceMs, now)})`,
|
|
2758
|
+
`${INDENT}you are probably running a stale build`
|
|
2759
|
+
].join(`
|
|
2760
|
+
`);
|
|
2761
|
+
}
|
|
2762
|
+
function duration(ms) {
|
|
2763
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
2764
|
+
if (seconds < 60)
|
|
2765
|
+
return `${seconds}s`;
|
|
2766
|
+
if (seconds < 3600)
|
|
2767
|
+
return `${Math.floor(seconds / 60)}m`;
|
|
2768
|
+
if (seconds < 86400)
|
|
2769
|
+
return `${Math.floor(seconds / 3600)}h`;
|
|
2770
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
2771
|
+
}
|
|
2772
|
+
function ago(mtimeMs, now = Date.now()) {
|
|
2773
|
+
return `${duration(now - mtimeMs)} ago`;
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
// src/cli/output.ts
|
|
2777
|
+
function status(message) {
|
|
2778
|
+
process.stderr.write(line(message));
|
|
2779
|
+
}
|
|
2780
|
+
function warn(message) {
|
|
2781
|
+
process.stderr.write(line(`warning: ${message}`));
|
|
2782
|
+
}
|
|
2783
|
+
function redirectOutput(path, keep = false) {
|
|
2784
|
+
const fd = openSync(path, keep ? "a" : "w");
|
|
2785
|
+
const stdout = process.stdout.write;
|
|
2786
|
+
const stderr = process.stderr.write;
|
|
2787
|
+
let open = true;
|
|
2788
|
+
const write = (chunk, encodingOrCallback, callback) => {
|
|
2789
|
+
append(fd, chunk);
|
|
2790
|
+
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
2791
|
+
done?.();
|
|
2792
|
+
return true;
|
|
2793
|
+
};
|
|
2794
|
+
process.stdout.write = write;
|
|
2795
|
+
process.stderr.write = write;
|
|
2796
|
+
return {
|
|
2797
|
+
close() {
|
|
2798
|
+
if (!open)
|
|
2799
|
+
return;
|
|
2800
|
+
open = false;
|
|
2801
|
+
process.stdout.write = stdout;
|
|
2802
|
+
process.stderr.write = stderr;
|
|
2803
|
+
closeSync(fd);
|
|
2804
|
+
}
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
async function forwardOutput(stream, target) {
|
|
2808
|
+
for await (const chunk of stream)
|
|
2809
|
+
target.write(chunk);
|
|
2810
|
+
}
|
|
2811
|
+
function line(message) {
|
|
2812
|
+
return message.endsWith(`
|
|
2813
|
+
`) ? message : `${message}
|
|
2814
|
+
`;
|
|
2815
|
+
}
|
|
2816
|
+
function reportProblems(problems) {
|
|
2817
|
+
if (problems.length === 0) {
|
|
2818
|
+
throw new GamecrateError("resolution failed with no reported detail", Exit.Resolution);
|
|
2819
|
+
}
|
|
2820
|
+
const groups = new Map;
|
|
2821
|
+
for (const problem of problems) {
|
|
2822
|
+
const group = groups.get(problem.where);
|
|
2823
|
+
if (group)
|
|
2824
|
+
group.push(problem);
|
|
2825
|
+
else
|
|
2826
|
+
groups.set(problem.where, [problem]);
|
|
2827
|
+
}
|
|
2828
|
+
const out = [];
|
|
2829
|
+
for (const [where, group] of groups) {
|
|
2830
|
+
out.push(` ${where}`);
|
|
2831
|
+
for (const problem of group) {
|
|
2832
|
+
out.push(` ${problem.message}`);
|
|
2833
|
+
if (problem.suggestion)
|
|
2834
|
+
out.push(` did you mean ${problem.suggestion}?`);
|
|
2835
|
+
}
|
|
2803
2836
|
}
|
|
2837
|
+
throw new GamecrateError(`${problems.length} problem${problems.length === 1 ? "" : "s"}`, Exit.Resolution, out.join(`
|
|
2838
|
+
`));
|
|
2839
|
+
}
|
|
2840
|
+
function planWarnings(plan) {
|
|
2841
|
+
if (!plan.warnOnStale)
|
|
2842
|
+
return plan.warnings;
|
|
2843
|
+
const stale = plan.mods.filter((mod) => mod.staleReport !== undefined).map((mod) => staleWarning(mod.packageId, mod.staleReport));
|
|
2844
|
+
return [...plan.warnings, ...stale];
|
|
2845
|
+
}
|
|
2846
|
+
function planPayload(plan) {
|
|
2847
|
+
return {
|
|
2848
|
+
game: plan.game,
|
|
2849
|
+
profile: plan.profile,
|
|
2850
|
+
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
2851
|
+
mode: plan.mode,
|
|
2852
|
+
...plan.marker === undefined ? {} : { marker: plan.marker },
|
|
2853
|
+
timeoutSeconds: plan.timeoutSeconds,
|
|
2854
|
+
renderWaitSeconds: plan.renderWaitSeconds,
|
|
2855
|
+
profileDir: resolve4(plan.profileDir),
|
|
2856
|
+
instanceDir: resolve4(plan.instanceDir),
|
|
2857
|
+
containerName: containerName(plan),
|
|
2858
|
+
dataDirHost: resolve4(plan.dataDirHost),
|
|
2859
|
+
stageDirHost: resolve4(plan.stageDirHost),
|
|
2860
|
+
logsDirHost: resolve4(plan.logsDirHost),
|
|
2861
|
+
mods: plan.mods.map((mod) => ({
|
|
2862
|
+
packageId: mod.packageId,
|
|
2863
|
+
kind: mod.kind,
|
|
2864
|
+
hostDir: resolve4(mod.hostDir),
|
|
2865
|
+
containerDir: mod.containerDir,
|
|
2866
|
+
origin: mod.explicit ? "explicit" : "auto",
|
|
2867
|
+
stale: mod.stale === true,
|
|
2868
|
+
...mod.staleReport === undefined ? {} : { staleReport: mod.staleReport },
|
|
2869
|
+
...mod.workshopId === undefined ? {} : { workshopId: mod.workshopId }
|
|
2870
|
+
})),
|
|
2871
|
+
warnings: planWarnings(plan)
|
|
2872
|
+
};
|
|
2804
2873
|
}
|
|
2805
|
-
function
|
|
2806
|
-
|
|
2807
|
-
|
|
2874
|
+
function printPlan(plan, asJson) {
|
|
2875
|
+
const payload = planPayload(plan);
|
|
2876
|
+
if (asJson) {
|
|
2877
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}
|
|
2878
|
+
`);
|
|
2808
2879
|
return;
|
|
2809
2880
|
}
|
|
2810
|
-
|
|
2811
|
-
|
|
2881
|
+
const title = payload.instance === undefined ? `${payload.game} ${payload.profile} (${payload.mode})` : `${payload.game} ${payload.profile} / ${payload.instance} (${payload.mode})`;
|
|
2882
|
+
const out = [
|
|
2883
|
+
title,
|
|
2884
|
+
` profile ${payload.profileDir}`,
|
|
2885
|
+
` instance ${payload.instanceDir}`,
|
|
2886
|
+
` container ${payload.containerName}`,
|
|
2887
|
+
` data ${payload.dataDirHost}`,
|
|
2888
|
+
` stage ${payload.stageDirHost}`,
|
|
2889
|
+
` logs ${payload.logsDirHost}`
|
|
2890
|
+
];
|
|
2891
|
+
if (payload.marker !== undefined)
|
|
2892
|
+
out.push(` marker ${payload.marker}`);
|
|
2893
|
+
out.push(` timeout ${payload.timeoutSeconds}s, render wait ${payload.renderWaitSeconds}s`, ` mods ${payload.mods.length}`);
|
|
2894
|
+
const width = Math.max(0, ...payload.mods.map((m) => m.packageId.length));
|
|
2895
|
+
for (const mod of payload.mods) {
|
|
2896
|
+
const notes = [mod.kind, mod.origin];
|
|
2897
|
+
if (mod.stale)
|
|
2898
|
+
notes.push("stale");
|
|
2899
|
+
out.push(` ${mod.packageId.padEnd(width)} ${notes.join(" ")} ${mod.hostDir} -> ${mod.containerDir}`);
|
|
2812
2900
|
}
|
|
2901
|
+
for (const warning of payload.warnings)
|
|
2902
|
+
out.push(` warning: ${warning}`);
|
|
2903
|
+
process.stdout.write(`${out.join(`
|
|
2904
|
+
`)}
|
|
2905
|
+
`);
|
|
2813
2906
|
}
|
|
2814
|
-
function
|
|
2815
|
-
return
|
|
2816
|
-
}
|
|
2817
|
-
function isObj(v) {
|
|
2818
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2907
|
+
function runTimestamp(now = new Date) {
|
|
2908
|
+
return now.toISOString().replaceAll(/[-:.]/g, "");
|
|
2819
2909
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
const
|
|
2824
|
-
|
|
2910
|
+
function openRunLog(logsDir, now) {
|
|
2911
|
+
const runsDir = join6(logsDir, "runs");
|
|
2912
|
+
mkdirSync2(runsDir, { recursive: true });
|
|
2913
|
+
const dir = uniqueRunDir(runsDir, runTimestamp(now));
|
|
2914
|
+
mkdirSync2(dir);
|
|
2915
|
+
linkCurrent(logsDir, dir);
|
|
2916
|
+
rotateRuns(logsDir, 10);
|
|
2917
|
+
return dir;
|
|
2825
2918
|
}
|
|
2826
|
-
|
|
2827
|
-
|
|
2919
|
+
var encoder = new TextEncoder;
|
|
2920
|
+
function append(fd, chunk) {
|
|
2921
|
+
writeSync(fd, typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
|
2828
2922
|
}
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
found.push(file);
|
|
2836
|
-
} catch (error) {
|
|
2837
|
-
if (error.code !== "ENOENT")
|
|
2838
|
-
throw error;
|
|
2839
|
-
}
|
|
2840
|
-
}
|
|
2841
|
-
if (found.length > 1) {
|
|
2842
|
-
const rows = found.map((f) => ` ${basename3(f)}`).join(`
|
|
2843
|
-
`);
|
|
2844
|
-
throw new GamecrateError(`two configs in ${dir}`, Exit.Config, `${rows}
|
|
2845
|
-
keep one`);
|
|
2923
|
+
function uniqueRunDir(runsDir, stamp) {
|
|
2924
|
+
let candidate = join6(runsDir, stamp);
|
|
2925
|
+
let n = 2;
|
|
2926
|
+
while (existsSync2(candidate)) {
|
|
2927
|
+
candidate = join6(runsDir, `${stamp}-${n}`);
|
|
2928
|
+
n++;
|
|
2846
2929
|
}
|
|
2847
|
-
return
|
|
2930
|
+
return candidate;
|
|
2848
2931
|
}
|
|
2849
|
-
var
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
return
|
|
2932
|
+
var WAIT_NOTICE = { firstMs: 2000, everyMs: 30000 };
|
|
2933
|
+
function waitNotice(waitedMs, lastNoticeMs, schedule = WAIT_NOTICE) {
|
|
2934
|
+
if (waitedMs < schedule.firstMs)
|
|
2935
|
+
return;
|
|
2936
|
+
if (lastNoticeMs > 0 && waitedMs - lastNoticeMs < schedule.everyMs)
|
|
2937
|
+
return;
|
|
2938
|
+
return waitedMs < 60000 ? `${Math.floor(waitedMs / 1000)}s` : `${Math.floor(waitedMs / 60000)}m`;
|
|
2856
2939
|
}
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
parseResolution(ctx.value);
|
|
2861
|
-
} catch (error) {
|
|
2862
|
-
ctx.issues.push({ code: "custom", message: error.message, input: ctx.value });
|
|
2863
|
-
}
|
|
2864
|
-
}).transform(parseResolution);
|
|
2865
|
-
var PROJECT_OBJECT = z2.strictObject({
|
|
2866
|
-
game: projectName.optional(),
|
|
2867
|
-
defaultProfile: projectName.optional(),
|
|
2868
|
-
profiles: z2.record(z2.string(), z2.unknown()).optional(),
|
|
2869
|
-
settings: z2.record(z2.string(), z2.unknown()).optional(),
|
|
2870
|
-
library: z2.record(z2.string(), z2.unknown()).optional(),
|
|
2871
|
-
detach: projectBool.optional(),
|
|
2872
|
-
mods: projectList.optional(),
|
|
2873
|
-
without: projectList.optional(),
|
|
2874
|
-
only: projectList.optional(),
|
|
2875
|
-
dockerArgs: projectList.optional(),
|
|
2876
|
-
gameArgs: projectList.optional(),
|
|
2877
|
-
worktree: projectList.optional(),
|
|
2878
|
-
use: projectList.optional(),
|
|
2879
|
-
marker: projectStr.optional(),
|
|
2880
|
-
instance: projectStr.optional(),
|
|
2881
|
-
log: projectStr.optional(),
|
|
2882
|
-
timeout: projectSeconds.optional(),
|
|
2883
|
-
renderWait: projectSeconds.optional(),
|
|
2884
|
-
dryRun: projectBool.optional(),
|
|
2885
|
-
printPlan: projectBool.optional(),
|
|
2886
|
-
json: projectBool.optional(),
|
|
2887
|
-
root: projectBool.optional(),
|
|
2888
|
-
noWorktree: projectBool.optional(),
|
|
2889
|
-
noStaleCheck: projectBool.optional(),
|
|
2890
|
-
replace: projectBool.optional(),
|
|
2891
|
-
mode: oneOf2(["headed", "headless", "screenshot"]).optional(),
|
|
2892
|
-
pull: oneOf2(["always", "missing", "never"]).optional(),
|
|
2893
|
-
sort: oneOf2(["topo", "none"]).optional(),
|
|
2894
|
-
network: oneOf2(["none", "bridge", "host"]).optional(),
|
|
2895
|
-
build: z2.union([z2.boolean().transform((on) => on ? "always" : "never"), z2.enum(BUILD_POLICIES2)], {
|
|
2896
|
-
error: `expected one of ${BUILD_POLICIES2.join(", ")}`
|
|
2897
|
-
}).optional(),
|
|
2898
|
-
resolution: projectResolution.optional()
|
|
2899
|
-
}, { error: "expected an object" });
|
|
2900
|
-
var PROJECT_SCHEMA = PROJECT_OBJECT.check((ctx) => {
|
|
2901
|
-
const { game, profiles, settings, library } = ctx.value;
|
|
2902
|
-
if (game !== undefined)
|
|
2940
|
+
function runStartedAt(name) {
|
|
2941
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(name);
|
|
2942
|
+
if (m === null)
|
|
2903
2943
|
return;
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
["settings", settings],
|
|
2907
|
-
["library", library]
|
|
2908
|
-
]) {
|
|
2909
|
-
if (value === undefined)
|
|
2910
|
-
continue;
|
|
2911
|
-
ctx.issues.push({
|
|
2912
|
-
code: "custom",
|
|
2913
|
-
path: [key],
|
|
2914
|
-
message: "needs a top-level game: to say which game it belongs to",
|
|
2915
|
-
input: ctx.value
|
|
2916
|
-
});
|
|
2917
|
-
}
|
|
2918
|
-
});
|
|
2919
|
-
async function findProjectConfig(start = process.cwd()) {
|
|
2920
|
-
let dir = resolve3(start);
|
|
2921
|
-
for (;; ) {
|
|
2922
|
-
const file = await probe(dir, ".gamecrate");
|
|
2923
|
-
if (file !== undefined)
|
|
2924
|
-
return file;
|
|
2925
|
-
const parent = dirname2(dir);
|
|
2926
|
-
if (parent === dir)
|
|
2927
|
-
return;
|
|
2928
|
-
dir = parent;
|
|
2929
|
-
}
|
|
2944
|
+
const at = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`);
|
|
2945
|
+
return Number.isNaN(at) ? undefined : at;
|
|
2930
2946
|
}
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
if (file === undefined)
|
|
2934
|
-
return {};
|
|
2935
|
-
const text = await readFile3(file, "utf8");
|
|
2936
|
-
const defaults = validateProjectDefaults(readConfigText(text, file), file);
|
|
2937
|
-
const profiles = defaults.profiles;
|
|
2938
|
-
if (profiles !== undefined) {
|
|
2939
|
-
const order = orderedKeys(text, file, "profiles");
|
|
2940
|
-
if (order.length !== Object.keys(profiles).length || order.some((key) => !Object.hasOwn(profiles, key))) {
|
|
2941
|
-
throw new GamecrateError(`config is invalid: ${file}`, Exit.Config, "duplicate profiles key");
|
|
2942
|
-
}
|
|
2943
|
-
defaults.profileOrder = order;
|
|
2944
|
-
}
|
|
2945
|
-
defaults.configPath = file;
|
|
2946
|
-
return defaults;
|
|
2947
|
+
function currentLog(instanceDir) {
|
|
2948
|
+
return join6(instanceDir, "logs", "current", STDOUT_LOG);
|
|
2947
2949
|
}
|
|
2948
|
-
function
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
if (
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
if (issue.code === "unrecognized_keys") {
|
|
2957
|
-
for (const key of issue.keys)
|
|
2958
|
-
problems.push(` /${key}: unknown key`);
|
|
2959
|
-
continue;
|
|
2960
|
-
}
|
|
2961
|
-
problems.push(` /${issue.path.join("/")}: ${issue.message}`);
|
|
2962
|
-
}
|
|
2963
|
-
throw new GamecrateError(`project config is invalid: ${file}`, Exit.Config, problems.join(`
|
|
2964
|
-
`));
|
|
2950
|
+
function tailArgv(file, fromStart, livePid) {
|
|
2951
|
+
const argv = ["tail"];
|
|
2952
|
+
if (fromStart)
|
|
2953
|
+
argv.push("-n", "+1");
|
|
2954
|
+
if (livePid !== undefined)
|
|
2955
|
+
argv.push("-f", "--pid", String(livePid));
|
|
2956
|
+
argv.push(file);
|
|
2957
|
+
return argv;
|
|
2965
2958
|
}
|
|
2966
|
-
|
|
2967
|
-
const
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
};
|
|
2979
|
-
const
|
|
2980
|
-
const
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
const detail = problems.map((p) => {
|
|
2984
|
-
const hint = p.suggestion ? ` (${p.suggestion})` : "";
|
|
2985
|
-
return ` ${p.where || "/"}: ${p.message}${hint}${origin(p.where, user, plugins, project)}`;
|
|
2986
|
-
}).join(`
|
|
2987
|
-
`);
|
|
2988
|
-
const from = plugins.size === 0 ? "" : ` (merged with defaults from: ${[...plugins.keys()].join(", ")})`;
|
|
2989
|
-
throw new GamecrateError(`config is invalid: ${file}${from}`, Exit.Config, detail);
|
|
2990
|
-
}
|
|
2991
|
-
return { config: expandPaths(config), plugins };
|
|
2959
|
+
function linkCurrent(logsDir, target) {
|
|
2960
|
+
const link = join6(logsDir, "current");
|
|
2961
|
+
try {
|
|
2962
|
+
lstatSync(link);
|
|
2963
|
+
unlinkSync(link);
|
|
2964
|
+
} catch {}
|
|
2965
|
+
symlinkSync(join6("runs", basename3(target)), link, "dir");
|
|
2966
|
+
}
|
|
2967
|
+
function rotateRuns(logsDir, keep) {
|
|
2968
|
+
const runsDir = join6(logsDir, "runs");
|
|
2969
|
+
if (keep < 1 || !existsSync2(runsDir))
|
|
2970
|
+
return [];
|
|
2971
|
+
const dirs = readdirSync(runsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
|
2972
|
+
const removed = dirs.slice(keep);
|
|
2973
|
+
for (const name of removed)
|
|
2974
|
+
rmSync(join6(runsDir, name), { recursive: true, force: true });
|
|
2975
|
+
return removed;
|
|
2992
2976
|
}
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
...existing,
|
|
3008
|
-
profiles: { ...existing.profiles, ...project.profiles },
|
|
3009
|
-
...project.library === undefined ? {} : { library: spliceLibrary(existing.library, project.library) }
|
|
3010
|
-
};
|
|
3011
|
-
config.games[game] = target;
|
|
3012
|
-
if (project.settings !== undefined) {
|
|
3013
|
-
target.settings = deepMerge(target.settings ?? {}, project.settings);
|
|
3014
|
-
}
|
|
3015
|
-
return config;
|
|
2977
|
+
|
|
2978
|
+
// src/launch/prepare.ts
|
|
2979
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
|
|
2980
|
+
import { open as open2, readdir as readdir4, readFile as readFile3, unlink, writeFile } from "node:fs/promises";
|
|
2981
|
+
import { join as join7 } from "node:path";
|
|
2982
|
+
import { setTimeout as sleep2 } from "node:timers/promises";
|
|
2983
|
+
async function inherit(argv) {
|
|
2984
|
+
const proc = spawnArgv(argv, ["ignore", "pipe", "pipe"]);
|
|
2985
|
+
const code = exited(proc);
|
|
2986
|
+
await Promise.all([
|
|
2987
|
+
forwardOutput(proc.stdout, process.stdout),
|
|
2988
|
+
forwardOutput(proc.stderr, process.stderr)
|
|
2989
|
+
]);
|
|
2990
|
+
return code;
|
|
3016
2991
|
}
|
|
3017
|
-
function
|
|
3018
|
-
const
|
|
3019
|
-
const
|
|
3020
|
-
return
|
|
2992
|
+
async function imageDigest(ref) {
|
|
2993
|
+
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", "{{.Id}}", ref]);
|
|
2994
|
+
const id = stdout.trim();
|
|
2995
|
+
return code === 0 && id.length > 0 ? id : null;
|
|
3021
2996
|
}
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
const [section, name, sub, ...rest] = segments;
|
|
3028
|
-
const repoGame = project?.game;
|
|
3029
|
-
const repoSection = GAME_SCOPED_KEYS.find((k) => k === sub);
|
|
3030
|
-
if (repoGame !== undefined && section === "games" && name === repoGame && repoSection !== undefined && valueAt2(project?.[repoSection], rest) !== undefined) {
|
|
3031
|
-
return " <- from the .gamecrate project config, not this file";
|
|
3032
|
-
}
|
|
3033
|
-
if (valueAt2(user, segments) !== undefined)
|
|
3034
|
-
return "";
|
|
3035
|
-
const plugin = section === "games" && name !== undefined ? plugins.get(name) : undefined;
|
|
3036
|
-
if (plugin === undefined)
|
|
3037
|
-
return " <- not in this file";
|
|
3038
|
-
return valueAt2(plugin.defaults, [sub, ...rest].filter((s) => s !== undefined)) === undefined ? ` <- not in this file, and the ${name} plugin's defaults do not supply it` : ` <- from the ${name} plugin's defaults, not this file`;
|
|
2997
|
+
async function repoDigest(ref) {
|
|
2998
|
+
const format = "{{range .RepoDigests}}{{.}}{{break}}{{end}}";
|
|
2999
|
+
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
|
|
3000
|
+
const at = stdout.trim().lastIndexOf("@");
|
|
3001
|
+
return code === 0 && at !== -1 ? stdout.trim().slice(at + 1) : null;
|
|
3039
3002
|
}
|
|
3040
|
-
function
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3003
|
+
async function readFromImage(ref, path) {
|
|
3004
|
+
const { code, stdout } = await capture(["docker", "run", "--rm", "--entrypoint", "cat", ref, path]);
|
|
3005
|
+
return code === 0 ? stdout : null;
|
|
3006
|
+
}
|
|
3007
|
+
async function imageLabel(ref, label) {
|
|
3008
|
+
const format = `{{index .Config.Labels "${label}"}}`;
|
|
3009
|
+
const { code, stdout } = await capture(["docker", "image", "inspect", "--format", format, ref]);
|
|
3010
|
+
const value = stdout.trim();
|
|
3011
|
+
if (code !== 0 || value.length === 0 || value === "<no value>")
|
|
3012
|
+
return null;
|
|
3013
|
+
return value;
|
|
3014
|
+
}
|
|
3015
|
+
async function acquireImage(game, config, pull) {
|
|
3016
|
+
const { image } = config;
|
|
3017
|
+
const present = await imageDigest(image.ref) !== null;
|
|
3018
|
+
if (image.acquire === "build")
|
|
3019
|
+
return await buildImage(game, image, present, pull);
|
|
3020
|
+
if (pull === "never") {
|
|
3021
|
+
if (present)
|
|
3048
3022
|
return;
|
|
3023
|
+
throw new GamecrateError(`--pull never but ${image.ref} is not present locally`, Exit.Environment);
|
|
3049
3024
|
}
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
out = deepMerge(out, layer, true);
|
|
3025
|
+
if (pull === "missing" && present)
|
|
3026
|
+
return;
|
|
3027
|
+
if (await inherit(["docker", "pull", image.ref]) !== 0) {
|
|
3028
|
+
if (present)
|
|
3029
|
+
return;
|
|
3030
|
+
throw new GamecrateError(`docker pull failed for ${image.ref}`, Exit.Environment);
|
|
3057
3031
|
}
|
|
3058
|
-
return out;
|
|
3059
3032
|
}
|
|
3060
|
-
function
|
|
3061
|
-
|
|
3062
|
-
}
|
|
3063
|
-
function resolveNamed(game, name, seen) {
|
|
3064
|
-
if (name.toLowerCase() === "modless")
|
|
3065
|
-
return { mods: [], exclude: [], includeBase: false };
|
|
3066
|
-
const key = profileKey(game, name);
|
|
3067
|
-
if (key === undefined) {
|
|
3068
|
-
throw new GamecrateError(`unknown profile "${name}"`, Exit.Resolution, `known profiles: ${Object.keys(game.profiles).join(", ") || "(none)"}, modless`);
|
|
3069
|
-
}
|
|
3070
|
-
if (seen.includes(key)) {
|
|
3071
|
-
throw new GamecrateError(`profile "${key}" inherits from itself`, Exit.Config, [...seen, key].join(" -> "));
|
|
3033
|
+
async function buildImage(game, image, present, pull) {
|
|
3034
|
+
if (image.context === undefined) {
|
|
3035
|
+
throw new GamecrateError(`${game} has image.acquire "build" but no context`, Exit.Config);
|
|
3072
3036
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
const exclude = [...parent.exclude ?? [], ...self.exclude ?? []];
|
|
3078
|
-
const out = {
|
|
3079
|
-
mods: subtract([...parent.mods ?? [], ...self.mods ?? []], exclude),
|
|
3080
|
-
exclude,
|
|
3081
|
-
settings: deepMerge(parent.settings ?? {}, self.settings ?? {}, true)
|
|
3082
|
-
};
|
|
3083
|
-
const instances = deepMerge(parent.instances ?? {}, self.instances ?? {}, true);
|
|
3084
|
-
if (Object.keys(instances).length > 0)
|
|
3085
|
-
out.instances = instances;
|
|
3086
|
-
const includeBase = self.includeBase ?? parent.includeBase;
|
|
3087
|
-
if (includeBase !== undefined)
|
|
3088
|
-
out.includeBase = includeBase;
|
|
3089
|
-
const auto = self.autoDependencies ?? parent.autoDependencies;
|
|
3090
|
-
if (auto !== undefined)
|
|
3091
|
-
out.autoDependencies = auto;
|
|
3092
|
-
for (const field of ["detach", "replace", "build", "gameVersion", "image"]) {
|
|
3093
|
-
const value = self[field] ?? parent[field];
|
|
3094
|
-
if (value !== undefined)
|
|
3095
|
-
Object.assign(out, { [field]: value });
|
|
3037
|
+
if (present && pull !== "always")
|
|
3038
|
+
return;
|
|
3039
|
+
if (await inherit(["docker", "build", "--tag", image.ref, image.context]) !== 0) {
|
|
3040
|
+
throw new GamecrateError(`docker build failed for ${image.ref}`, Exit.Environment);
|
|
3096
3041
|
}
|
|
3097
|
-
return out;
|
|
3098
3042
|
}
|
|
3099
|
-
function
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
const key = profileKey(game, current);
|
|
3106
|
-
if (key === undefined || seen.includes(key))
|
|
3107
|
-
return key ?? current;
|
|
3108
|
-
const next = own(game.profiles, key)?.alias;
|
|
3109
|
-
if (next === undefined)
|
|
3110
|
-
return key;
|
|
3111
|
-
seen.push(key);
|
|
3112
|
-
current = next;
|
|
3043
|
+
async function buildTarget(dir) {
|
|
3044
|
+
let entries;
|
|
3045
|
+
try {
|
|
3046
|
+
entries = await readdir4(dir);
|
|
3047
|
+
} catch {
|
|
3048
|
+
return null;
|
|
3113
3049
|
}
|
|
3050
|
+
const slnx = entries.find((e) => e.endsWith(".slnx"));
|
|
3051
|
+
if (slnx)
|
|
3052
|
+
return join7(dir, slnx);
|
|
3053
|
+
const csproj = entries.find((e) => e.endsWith(".csproj"));
|
|
3054
|
+
return csproj ? join7(dir, csproj) : null;
|
|
3114
3055
|
}
|
|
3115
|
-
function
|
|
3116
|
-
|
|
3056
|
+
async function buildLocalMods(plan, policy, refsNote) {
|
|
3057
|
+
if (policy === "never")
|
|
3058
|
+
return;
|
|
3059
|
+
const wanted = plan.mods.filter((m) => m.kind === "local" && (policy === "always" || m.stale === true));
|
|
3060
|
+
if (wanted.length === 0)
|
|
3061
|
+
return;
|
|
3062
|
+
const failed = [];
|
|
3063
|
+
for (const mod of wanted) {
|
|
3064
|
+
const target = await buildTarget(mod.hostDir);
|
|
3065
|
+
if (target === null)
|
|
3066
|
+
continue;
|
|
3067
|
+
const code = await inherit(["dotnet", "build", target, "-v", "quiet", "--nologo"]);
|
|
3068
|
+
if (code !== 0) {
|
|
3069
|
+
failed.push(`${mod.packageId} ${target}`);
|
|
3070
|
+
continue;
|
|
3071
|
+
}
|
|
3072
|
+
mod.stale = false;
|
|
3073
|
+
delete mod.staleReport;
|
|
3074
|
+
}
|
|
3075
|
+
if (failed.length === 0)
|
|
3076
|
+
return;
|
|
3077
|
+
const what = failed.length === 1 ? failed[0].split(" ")[0] : `${failed.length} mods`;
|
|
3078
|
+
throw new GamecrateError(`dotnet build failed for ${what}`, Exit.Environment, [...failed, refsNote].filter((part) => part !== undefined).join(`
|
|
3079
|
+
`));
|
|
3117
3080
|
}
|
|
3118
|
-
async function
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
const
|
|
3122
|
-
|
|
3081
|
+
async function clearLock(plan) {
|
|
3082
|
+
const path = lockPath(plan);
|
|
3083
|
+
const what = plan.instance === undefined ? plan.profile : `${plan.profile} (${plan.instance})`;
|
|
3084
|
+
const name = containerName(plan);
|
|
3085
|
+
const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
|
|
3086
|
+
if (up.stdout.trim().length > 0) {
|
|
3087
|
+
throw new GamecrateError(`${plan.game} ${what} is already running (container ${name})`, Exit.Refused, `stop it with: docker stop ${name}
|
|
3088
|
+
or relaunch with --replace`);
|
|
3089
|
+
}
|
|
3090
|
+
const held = await readLock(path);
|
|
3091
|
+
if (held !== undefined && isRunning(held.pid, held.startedAt)) {
|
|
3092
|
+
throw new GamecrateError(`${plan.game} ${what} is already running (pid ${held.pid})`, Exit.Refused, `if that is wrong, delete ${path}
|
|
3093
|
+
or relaunch with --replace`);
|
|
3094
|
+
}
|
|
3095
|
+
if (existsSync3(path))
|
|
3096
|
+
await unlink(path).catch(() => {});
|
|
3123
3097
|
}
|
|
3124
|
-
function
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3098
|
+
async function unlinkHeld(path, pid, startedAt) {
|
|
3099
|
+
const held = await readLock(path);
|
|
3100
|
+
if (held === undefined)
|
|
3101
|
+
return;
|
|
3102
|
+
if (held.pid !== pid)
|
|
3103
|
+
return;
|
|
3104
|
+
if (startedAt !== undefined && held.startedAt !== startedAt)
|
|
3105
|
+
return;
|
|
3106
|
+
await unlink(path).catch(() => {});
|
|
3132
3107
|
}
|
|
3133
|
-
function
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
return true;
|
|
3141
|
-
return !ids.some((id) => patterns.some((re) => re.test(id)));
|
|
3142
|
-
});
|
|
3108
|
+
function heldLock(plan) {
|
|
3109
|
+
const path = lockPath(plan);
|
|
3110
|
+
return {
|
|
3111
|
+
release: async () => {
|
|
3112
|
+
await unlinkHeld(path, process.pid);
|
|
3113
|
+
}
|
|
3114
|
+
};
|
|
3143
3115
|
}
|
|
3144
|
-
function
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3116
|
+
async function takeLock(plan) {
|
|
3117
|
+
await clearLock(plan);
|
|
3118
|
+
await writeLock(plan, {
|
|
3119
|
+
pid: process.pid,
|
|
3120
|
+
container: containerName(plan),
|
|
3121
|
+
game: plan.game,
|
|
3122
|
+
profile: plan.profile,
|
|
3123
|
+
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
3124
|
+
detached: false,
|
|
3125
|
+
mode: plan.mode
|
|
3126
|
+
});
|
|
3127
|
+
return heldLock(plan);
|
|
3150
3128
|
}
|
|
3151
|
-
function
|
|
3152
|
-
|
|
3153
|
-
return new RegExp(`^${body}$`, "i");
|
|
3129
|
+
function lockPath(plan) {
|
|
3130
|
+
return join7(plan.instanceDir, ".gamecrate", "lock");
|
|
3154
3131
|
}
|
|
3155
|
-
function
|
|
3156
|
-
const
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
const
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
if (!Array.isArray(added) || !Array.isArray(declared) || target === undefined)
|
|
3167
|
-
continue;
|
|
3168
|
-
target.steamBuild.branches = concatBranches(declared, added);
|
|
3132
|
+
async function readLock(path) {
|
|
3133
|
+
const text = await readFile3(path, "utf8").catch(() => {
|
|
3134
|
+
return;
|
|
3135
|
+
});
|
|
3136
|
+
if (text === undefined)
|
|
3137
|
+
return;
|
|
3138
|
+
try {
|
|
3139
|
+
const value = JSON.parse(text);
|
|
3140
|
+
return Number.isInteger(value?.pid) && value.pid > 0 ? value : undefined;
|
|
3141
|
+
} catch {
|
|
3142
|
+
return;
|
|
3169
3143
|
}
|
|
3170
|
-
return out;
|
|
3171
3144
|
}
|
|
3172
|
-
function
|
|
3173
|
-
|
|
3145
|
+
async function writeLock(plan, record) {
|
|
3146
|
+
const path = lockPath(plan);
|
|
3147
|
+
const handle = await open2(path, "wx").catch(() => null);
|
|
3148
|
+
if (handle === null) {
|
|
3149
|
+
throw new GamecrateError(`could not take the launch lock at ${path}`, Exit.Environment);
|
|
3150
|
+
}
|
|
3151
|
+
await handle.writeFile(JSON.stringify({ ...record, startedAt: new Date().toISOString() }));
|
|
3152
|
+
await handle.close();
|
|
3174
3153
|
}
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3154
|
+
var RELEASE_POLL_MS = 100;
|
|
3155
|
+
var DRAIN_ALLOWANCE_MS = 1e4;
|
|
3156
|
+
var STOP_RELEASE_WAIT_MS = STOP_TIMEOUT_SECONDS * 1000 + DRAIN_ALLOWANCE_MS;
|
|
3157
|
+
async function stopRun(record, lockFile) {
|
|
3158
|
+
let signalled = false;
|
|
3159
|
+
if (isRunning(record.pid, record.startedAt)) {
|
|
3160
|
+
try {
|
|
3161
|
+
process.kill(record.pid, "SIGTERM");
|
|
3162
|
+
signalled = true;
|
|
3163
|
+
} catch {}
|
|
3164
|
+
}
|
|
3165
|
+
if (!signalled)
|
|
3166
|
+
await stopContainer(record.container, STOP_TIMEOUT_SECONDS);
|
|
3167
|
+
const deadline = Date.now() + STOP_RELEASE_WAIT_MS;
|
|
3168
|
+
while (existsSync3(lockFile)) {
|
|
3169
|
+
const held = await readLock(lockFile);
|
|
3170
|
+
if (held === undefined)
|
|
3171
|
+
break;
|
|
3172
|
+
if (!isRunning(held.pid, held.startedAt)) {
|
|
3173
|
+
await unlinkHeld(lockFile, record.pid, record.startedAt);
|
|
3174
|
+
break;
|
|
3191
3175
|
}
|
|
3192
|
-
|
|
3176
|
+
if (Date.now() >= deadline)
|
|
3177
|
+
return "held";
|
|
3178
|
+
await sleep2(RELEASE_POLL_MS);
|
|
3193
3179
|
}
|
|
3194
|
-
return
|
|
3180
|
+
return signalled ? "signalled" : "orphaned";
|
|
3195
3181
|
}
|
|
3196
|
-
function
|
|
3197
|
-
|
|
3198
|
-
|
|
3182
|
+
async function replacePrevious(plan) {
|
|
3183
|
+
const name = containerName(plan);
|
|
3184
|
+
const path = lockPath(plan);
|
|
3185
|
+
const up = await capture(["docker", "ps", "--quiet", "--filter", `name=^${name}$`]);
|
|
3186
|
+
const running = up.stdout.trim().length > 0;
|
|
3187
|
+
const held = await readLock(path);
|
|
3188
|
+
if (!running && held === undefined && !existsSync3(path))
|
|
3189
|
+
return;
|
|
3190
|
+
if (held !== undefined) {
|
|
3191
|
+
status(`stopping ${held.container}`);
|
|
3192
|
+
await stopRun(held, path);
|
|
3193
|
+
return;
|
|
3199
3194
|
}
|
|
3200
|
-
if (
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
if (v === undefined)
|
|
3204
|
-
continue;
|
|
3205
|
-
out[k] = Object.hasOwn(out, k) ? deepMerge(out[k], v, concatArrays) : v;
|
|
3206
|
-
}
|
|
3207
|
-
return out;
|
|
3195
|
+
if (running) {
|
|
3196
|
+
status(`stopping ${name}`);
|
|
3197
|
+
await stopContainer(name, STOP_TIMEOUT_SECONDS);
|
|
3208
3198
|
}
|
|
3209
|
-
return over;
|
|
3210
3199
|
}
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
if (game.image.context !== undefined)
|
|
3219
|
-
game.image.context = expandHome(game.image.context);
|
|
3220
|
-
if (game.workshopRoot !== null)
|
|
3221
|
-
game.workshopRoot = expandHome(game.workshopRoot);
|
|
3222
|
-
for (const root of game.scanRoots)
|
|
3223
|
-
root.path = expandHome(root.path);
|
|
3224
|
-
for (const entry of Object.values(game.library ?? {})) {
|
|
3225
|
-
if (entry.path !== undefined)
|
|
3226
|
-
entry.path = expandHome(entry.path);
|
|
3227
|
-
}
|
|
3200
|
+
var CLOCK_TICKS_PER_SECOND = 100;
|
|
3201
|
+
var START_TIME_SLACK_MS = 2000;
|
|
3202
|
+
function isRunning(pid, startedAt) {
|
|
3203
|
+
try {
|
|
3204
|
+
process.kill(pid, 0);
|
|
3205
|
+
} catch {
|
|
3206
|
+
return false;
|
|
3228
3207
|
}
|
|
3229
|
-
|
|
3208
|
+
if (startedAt === undefined)
|
|
3209
|
+
return true;
|
|
3210
|
+
const written = Date.parse(startedAt);
|
|
3211
|
+
if (Number.isNaN(written))
|
|
3212
|
+
return true;
|
|
3213
|
+
const began = processStart(pid);
|
|
3214
|
+
return began === undefined || began <= written + START_TIME_SLACK_MS;
|
|
3230
3215
|
}
|
|
3231
|
-
function
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3216
|
+
function processStart(pid) {
|
|
3217
|
+
try {
|
|
3218
|
+
const stat = readFileSync2(`/proc/${pid}/stat`, "utf8");
|
|
3219
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
3220
|
+
const ticks = Number(fields[19]);
|
|
3221
|
+
const boot = bootTime();
|
|
3222
|
+
if (!Number.isFinite(ticks) || boot === undefined)
|
|
3223
|
+
return;
|
|
3224
|
+
return boot + ticks / CLOCK_TICKS_PER_SECOND * 1000;
|
|
3225
|
+
} catch {
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
function bootTime() {
|
|
3230
|
+
const line = readFileSync2("/proc/stat", "utf8").split(`
|
|
3231
|
+
`).find((each) => each.startsWith("btime "));
|
|
3232
|
+
const seconds = Number(line?.slice("btime ".length));
|
|
3233
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
|
|
3234
|
+
}
|
|
3235
|
+
async function captureScreenshot(container, plan) {
|
|
3236
|
+
const name = `${plan.game}.png`;
|
|
3237
|
+
const target = `${CONTAINER_LOG_DIR}/${name}`;
|
|
3238
|
+
const script = 'D=":$(ls /tmp/.X11-unix 2>/dev/null | head -1 | tr -d X)";' + ' [ "$D" = ":" ] && { echo "no X socket in the container" >&2; exit 1; };' + " X=$(ls -d /tmp/xvfb-run.*/Xauthority 2>/dev/null | head -1);" + ' [ -n "$X" ] && export XAUTHORITY="$X";' + " M=$(command -v magick || command -v convert);" + ' [ -z "$M" ] && { echo "no imagemagick in the container" >&2; exit 1; };' + ` import -display "$D" -window root ${target} 2>/dev/null` + ` || xwd -root -display "$D" | "$M" xwd:- ${target}`;
|
|
3239
|
+
const code = await inherit(["docker", "exec", container, "sh", "-c", script]);
|
|
3240
|
+
const host = join7(plan.runDirHost, name);
|
|
3241
|
+
if (code !== 0 || !existsSync3(host))
|
|
3242
|
+
return null;
|
|
3243
|
+
return host;
|
|
3244
|
+
}
|
|
3245
|
+
async function writeLaunchRecord(plan, image) {
|
|
3246
|
+
const digest = await imageDigest(image);
|
|
3247
|
+
const line = JSON.stringify({
|
|
3248
|
+
at: new Date().toISOString(),
|
|
3249
|
+
game: plan.game,
|
|
3250
|
+
profile: plan.profile,
|
|
3251
|
+
...plan.instance === undefined ? {} : { instance: plan.instance },
|
|
3252
|
+
image,
|
|
3253
|
+
digest,
|
|
3254
|
+
mode: plan.mode,
|
|
3255
|
+
mods: plan.mods.map((m) => ({
|
|
3256
|
+
packageId: m.packageId,
|
|
3257
|
+
hostDir: m.hostDir,
|
|
3258
|
+
...m.worktree === undefined ? {} : { worktree: m.worktree }
|
|
3259
|
+
}))
|
|
3260
|
+
});
|
|
3261
|
+
const path = join7(plan.instanceDir, ".gamecrate", "launches.jsonl");
|
|
3262
|
+
await writeFile(path, `${line}
|
|
3263
|
+
`, { flag: "a" });
|
|
3235
3264
|
}
|
|
3236
3265
|
|
|
3266
|
+
// src/mods/modindex.ts
|
|
3267
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
3268
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4, statSync as statSync3 } from "node:fs";
|
|
3269
|
+
import { mkdir as mkdir2, readdir as readdir6, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
|
|
3270
|
+
import { homedir as homedir3 } from "node:os";
|
|
3271
|
+
import { dirname as dirname5, join as join10, relative as relative2, sep as sep2, resolve as resolvePath } from "node:path";
|
|
3272
|
+
import picomatch from "picomatch";
|
|
3273
|
+
|
|
3237
3274
|
// src/mods/acf.ts
|
|
3238
3275
|
var MAX_DEPTH = 100;
|
|
3239
3276
|
function isSpace(c) {
|
|
@@ -3926,7 +3963,7 @@ function buildIdFor(output, branch) {
|
|
|
3926
3963
|
// src/mods/worktree.ts
|
|
3927
3964
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
3928
3965
|
import { existsSync as existsSync6, realpathSync as realpathSync2 } from "node:fs";
|
|
3929
|
-
import { isAbsolute as
|
|
3966
|
+
import { isAbsolute as isAbsolute3, resolve as resolve5, sep } from "node:path";
|
|
3930
3967
|
function inspect(dir) {
|
|
3931
3968
|
const r = spawnSync3("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-dir", "--git-common-dir", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
|
|
3932
3969
|
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
@@ -3942,12 +3979,12 @@ function canonical(p) {
|
|
|
3942
3979
|
try {
|
|
3943
3980
|
return realpathSync2(p);
|
|
3944
3981
|
} catch {
|
|
3945
|
-
return
|
|
3982
|
+
return resolve5(p);
|
|
3946
3983
|
}
|
|
3947
3984
|
}
|
|
3948
3985
|
function resolveWorktree(dir, source, order) {
|
|
3949
3986
|
const raw = expandHome(dir);
|
|
3950
|
-
const abs =
|
|
3987
|
+
const abs = isAbsolute3(raw) ? raw : resolve5(process.cwd(), raw);
|
|
3951
3988
|
if (!existsSync6(abs)) {
|
|
3952
3989
|
return { where: abs, message: `--worktree path does not exist`, suggestion: "check the path, or drop the flag" };
|
|
3953
3990
|
}
|
|
@@ -4524,6 +4561,14 @@ function refsRoot() {
|
|
|
4524
4561
|
function refsLink(game) {
|
|
4525
4562
|
return join11(refsRoot(), "current", game);
|
|
4526
4563
|
}
|
|
4564
|
+
function currentRefs(game) {
|
|
4565
|
+
try {
|
|
4566
|
+
const target = readlinkSync(refsLink(game));
|
|
4567
|
+
return `refs in use: ${target.replace(/^.*\/refs\//, "")}`;
|
|
4568
|
+
} catch {
|
|
4569
|
+
return;
|
|
4570
|
+
}
|
|
4571
|
+
}
|
|
4527
4572
|
var MARK2 = "@@gamecrate@@ ";
|
|
4528
4573
|
function extractScript(candidates, container) {
|
|
4529
4574
|
const quoted = candidates.map((c) => {
|
|
@@ -4676,7 +4721,7 @@ function profileRows(profile, spec, width, notes) {
|
|
|
4676
4721
|
// src/cli/mods.ts
|
|
4677
4722
|
import { existsSync as existsSync9 } from "node:fs";
|
|
4678
4723
|
import { mkdir as mkdir4, readFile as readFile8, readdir as readdir8, writeFile as writeFile4 } from "node:fs/promises";
|
|
4679
|
-
import { dirname as dirname9, join as join14, relative as relative3, resolve as
|
|
4724
|
+
import { dirname as dirname9, join as join14, relative as relative3, resolve as resolve6 } from "node:path";
|
|
4680
4725
|
|
|
4681
4726
|
// src/config/write.ts
|
|
4682
4727
|
import { chmod, readFile as readFile6, realpath, rename as rename2, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
|
|
@@ -5094,7 +5139,7 @@ function existingKey(library, id) {
|
|
|
5094
5139
|
}
|
|
5095
5140
|
async function discover(source, game, plugin, ctx) {
|
|
5096
5141
|
if (source.kind === "path") {
|
|
5097
|
-
const dir =
|
|
5142
|
+
const dir = resolve6(ctx.cwd, expandHome(source.value));
|
|
5098
5143
|
const id = await readId(dir, game.manifest.file, plugin);
|
|
5099
5144
|
return id === undefined ? [] : [{ id, entry: { path: dir } }];
|
|
5100
5145
|
}
|
|
@@ -5534,8 +5579,12 @@ async function resolveSteamBuildInput(game, config, overrides, cwd, configFile)
|
|
|
5534
5579
|
// src/image/tags.ts
|
|
5535
5580
|
function sanitizeVersion(raw, fallback) {
|
|
5536
5581
|
const first = raw.trim().split(/\s+/)[0] ?? "";
|
|
5537
|
-
const mapped = first.replaceAll(/[
|
|
5538
|
-
|
|
5582
|
+
const mapped = first.replaceAll(/[^\w.-]/g, "-");
|
|
5583
|
+
let end = mapped.length;
|
|
5584
|
+
while (end > 0 && mapped.charAt(end - 1) === "-")
|
|
5585
|
+
end -= 1;
|
|
5586
|
+
const trimmed = mapped.slice(0, end);
|
|
5587
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
5539
5588
|
}
|
|
5540
5589
|
function versionPrefixes(version) {
|
|
5541
5590
|
const parts = version.split(".");
|
|
@@ -6224,6 +6273,10 @@ async function checkImage(plan, problems, asShell) {
|
|
|
6224
6273
|
problems.push(marker);
|
|
6225
6274
|
return;
|
|
6226
6275
|
}
|
|
6276
|
+
await checkAbsentImage(plan, problems, where, problem);
|
|
6277
|
+
}
|
|
6278
|
+
async function checkAbsentImage(plan, problems, where, problem) {
|
|
6279
|
+
const image = plan.gameConfig.image;
|
|
6227
6280
|
if (image.ref.trim() === "") {
|
|
6228
6281
|
if (problem)
|
|
6229
6282
|
problems.push(problem);
|
|
@@ -6393,11 +6446,143 @@ function message2(error) {
|
|
|
6393
6446
|
import { readFileSync as readFileSync8 } from "node:fs";
|
|
6394
6447
|
import { basename as basename7 } from "node:path";
|
|
6395
6448
|
import { setTimeout as sleep6 } from "node:timers/promises";
|
|
6449
|
+
|
|
6450
|
+
// src/docker/icon.ts
|
|
6451
|
+
import { execFile } from "node:child_process";
|
|
6452
|
+
import { connect } from "node:net";
|
|
6453
|
+
import { promisify } from "node:util";
|
|
6454
|
+
var run2 = promisify(execFile);
|
|
6455
|
+
var CHANGE_PROPERTY = 18;
|
|
6456
|
+
var INTERN_ATOM = 16;
|
|
6457
|
+
var CARDINAL = 6;
|
|
6458
|
+
function padding(length) {
|
|
6459
|
+
return (4 - length % 4) % 4;
|
|
6460
|
+
}
|
|
6461
|
+
function displayNumber(display) {
|
|
6462
|
+
return display.replace(/^.*:/, "").split(".")[0] ?? "0";
|
|
6463
|
+
}
|
|
6464
|
+
async function cookieFor(display) {
|
|
6465
|
+
const wanted = displayNumber(display);
|
|
6466
|
+
try {
|
|
6467
|
+
const { stdout } = await run2("xauth", ["list"]);
|
|
6468
|
+
for (const line of stdout.split(`
|
|
6469
|
+
`)) {
|
|
6470
|
+
const match = /^\S+:(\d+)\s+MIT-MAGIC-COOKIE-1\s+([0-9a-f]+)$/.exec(line.trim());
|
|
6471
|
+
if (match && match[1] === wanted)
|
|
6472
|
+
return Buffer.from(match[2], "hex");
|
|
6473
|
+
}
|
|
6474
|
+
} catch {
|
|
6475
|
+
return null;
|
|
6476
|
+
}
|
|
6477
|
+
return null;
|
|
6478
|
+
}
|
|
6479
|
+
async function argbPixels(path, size) {
|
|
6480
|
+
for (const tool of ["magick", "convert"]) {
|
|
6481
|
+
try {
|
|
6482
|
+
const { stdout } = await run2(tool, [path, "-resize", `${size}x${size}!`, "-depth", "8", "RGBA:-"], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
|
|
6483
|
+
if (stdout.length === size * size * 4)
|
|
6484
|
+
return stdout;
|
|
6485
|
+
} catch {
|
|
6486
|
+
continue;
|
|
6487
|
+
}
|
|
6488
|
+
}
|
|
6489
|
+
return null;
|
|
6490
|
+
}
|
|
6491
|
+
async function openDisplay(display) {
|
|
6492
|
+
const cookie = await cookieFor(display);
|
|
6493
|
+
if (cookie === null)
|
|
6494
|
+
return null;
|
|
6495
|
+
const socket = connect(`/tmp/.X11-unix/X${displayNumber(display)}`);
|
|
6496
|
+
try {
|
|
6497
|
+
await new Promise((resolve, reject) => {
|
|
6498
|
+
socket.once("connect", resolve);
|
|
6499
|
+
socket.once("error", reject);
|
|
6500
|
+
});
|
|
6501
|
+
} catch {
|
|
6502
|
+
return null;
|
|
6503
|
+
}
|
|
6504
|
+
const name = Buffer.from("MIT-MAGIC-COOKIE-1");
|
|
6505
|
+
const head = Buffer.alloc(12);
|
|
6506
|
+
head.write("l", 0, "ascii");
|
|
6507
|
+
head.writeUInt16LE(11, 2);
|
|
6508
|
+
head.writeUInt16LE(name.length, 6);
|
|
6509
|
+
head.writeUInt16LE(cookie.length, 8);
|
|
6510
|
+
socket.write(Buffer.concat([
|
|
6511
|
+
head,
|
|
6512
|
+
name,
|
|
6513
|
+
Buffer.alloc(padding(name.length)),
|
|
6514
|
+
cookie,
|
|
6515
|
+
Buffer.alloc(padding(cookie.length))
|
|
6516
|
+
]));
|
|
6517
|
+
const reply = await new Promise((resolve) => socket.once("data", resolve));
|
|
6518
|
+
if (reply[0] !== 1) {
|
|
6519
|
+
socket.destroy();
|
|
6520
|
+
return null;
|
|
6521
|
+
}
|
|
6522
|
+
return socket;
|
|
6523
|
+
}
|
|
6524
|
+
function internAtom(socket, name) {
|
|
6525
|
+
const length = 8 + name.length + padding(name.length);
|
|
6526
|
+
const request = Buffer.alloc(length);
|
|
6527
|
+
request.writeUInt8(INTERN_ATOM, 0);
|
|
6528
|
+
request.writeUInt16LE(length / 4, 2);
|
|
6529
|
+
request.writeUInt16LE(name.length, 4);
|
|
6530
|
+
request.write(name, 8, "ascii");
|
|
6531
|
+
socket.write(request);
|
|
6532
|
+
return new Promise((resolve) => socket.once("data", (d) => resolve(d.readUInt32LE(8))));
|
|
6533
|
+
}
|
|
6534
|
+
function iconProperty(rgba, size) {
|
|
6535
|
+
const body = Buffer.alloc(8 + size * size * 4);
|
|
6536
|
+
body.writeUInt32LE(size, 0);
|
|
6537
|
+
body.writeUInt32LE(size, 4);
|
|
6538
|
+
for (let i = 0;i < size * size; i += 1) {
|
|
6539
|
+
const [r, g, b, a] = [rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2], rgba[i * 4 + 3]];
|
|
6540
|
+
body.writeUInt32LE((a << 24 | r << 16 | g << 8 | b) >>> 0, 8 + i * 4);
|
|
6541
|
+
}
|
|
6542
|
+
return body;
|
|
6543
|
+
}
|
|
6544
|
+
var ICON_SIZE = 64;
|
|
6545
|
+
async function setWindowIcon(windowId, iconPath) {
|
|
6546
|
+
const display = process.env.DISPLAY;
|
|
6547
|
+
if (display === undefined || display === "")
|
|
6548
|
+
return;
|
|
6549
|
+
const rgba = await argbPixels(iconPath, ICON_SIZE);
|
|
6550
|
+
if (rgba === null) {
|
|
6551
|
+
warn(`could not read ${iconPath}, so the window keeps its own icon`);
|
|
6552
|
+
return;
|
|
6553
|
+
}
|
|
6554
|
+
const socket = await openDisplay(display);
|
|
6555
|
+
if (socket === null) {
|
|
6556
|
+
warn("could not reach the X server to set the window icon");
|
|
6557
|
+
return;
|
|
6558
|
+
}
|
|
6559
|
+
try {
|
|
6560
|
+
const atom = await internAtom(socket, "_NET_WM_ICON");
|
|
6561
|
+
const body = iconProperty(rgba, ICON_SIZE);
|
|
6562
|
+
const request = Buffer.alloc(24);
|
|
6563
|
+
request.writeUInt8(CHANGE_PROPERTY, 0);
|
|
6564
|
+
request.writeUInt16LE((24 + body.length) / 4, 2);
|
|
6565
|
+
request.writeUInt32LE(Number.parseInt(windowId, 16), 4);
|
|
6566
|
+
request.writeUInt32LE(atom, 8);
|
|
6567
|
+
request.writeUInt32LE(CARDINAL, 12);
|
|
6568
|
+
request.writeUInt8(32, 16);
|
|
6569
|
+
request.writeUInt32LE(body.length / 4, 20);
|
|
6570
|
+
socket.write(Buffer.concat([request, body]));
|
|
6571
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
6572
|
+
} finally {
|
|
6573
|
+
socket.end();
|
|
6574
|
+
}
|
|
6575
|
+
}
|
|
6576
|
+
|
|
6577
|
+
// src/docker/window.ts
|
|
6396
6578
|
var WAIT_MS = 180000;
|
|
6397
6579
|
var POLL_MS = 500;
|
|
6398
6580
|
function newMatches(now, seen, executable) {
|
|
6399
6581
|
const wanted = basename7(executable).toLowerCase();
|
|
6400
|
-
return now.filter((w) => !seen.has(w.id) && w.wmClass
|
|
6582
|
+
return now.filter((w) => !seen.has(w.id) && classMatches(w.wmClass, wanted));
|
|
6583
|
+
}
|
|
6584
|
+
function classMatches(wmClass, wanted) {
|
|
6585
|
+
return wmClass.toLowerCase().split(".").some((part) => part.length >= 3 && (wanted.includes(part) || part.includes(wanted)));
|
|
6401
6586
|
}
|
|
6402
6587
|
function parseWindowPid(stdout) {
|
|
6403
6588
|
const match = /_NET_WM_PID\(CARDINAL\)\s*=\s*(\d+)/.exec(stdout);
|
|
@@ -6466,6 +6651,8 @@ async function adoptFirstMatch(seen, opts, stopped) {
|
|
|
6466
6651
|
if (!await adopt(match.id, opts))
|
|
6467
6652
|
continue;
|
|
6468
6653
|
await capture(["wmctrl", "-i", "-r", match.id, "-N", opts.title]);
|
|
6654
|
+
if (opts.icon !== undefined)
|
|
6655
|
+
await setWindowIcon(match.id, opts.icon);
|
|
6469
6656
|
if (opts.stripDelete)
|
|
6470
6657
|
await watchForClose(match.id, stopped, opts.onClosed);
|
|
6471
6658
|
return true;
|
|
@@ -7598,7 +7785,7 @@ function published2(value) {
|
|
|
7598
7785
|
}
|
|
7599
7786
|
|
|
7600
7787
|
// src/index.ts
|
|
7601
|
-
var VERSION = "2.
|
|
7788
|
+
var VERSION = "2.4.0";
|
|
7602
7789
|
async function main(argv) {
|
|
7603
7790
|
const supervised = supervisedDir(argv);
|
|
7604
7791
|
try {
|
|
@@ -7678,17 +7865,21 @@ async function dispatch(argv, args, config, plugins, defaults) {
|
|
|
7678
7865
|
case "wait":
|
|
7679
7866
|
return waitFor(args, config, defaults);
|
|
7680
7867
|
case "shell":
|
|
7681
|
-
return
|
|
7868
|
+
return run3(argv, args, config, plugins, defaults, true);
|
|
7682
7869
|
case "config":
|
|
7683
7870
|
return configEdit(args);
|
|
7684
7871
|
case "fix-perms":
|
|
7685
7872
|
return fixPerms(args, config);
|
|
7686
7873
|
case "run":
|
|
7687
|
-
return
|
|
7874
|
+
return run3(argv, args, config, plugins, defaults, false);
|
|
7688
7875
|
default:
|
|
7689
7876
|
throw new GamecrateError(`no such subcommand ${args.subcommand}`, Exit.Usage);
|
|
7690
7877
|
}
|
|
7691
7878
|
}
|
|
7879
|
+
function iconOption(plan, configFile) {
|
|
7880
|
+
const icon = windowIcon(plan, dirname14(configFile));
|
|
7881
|
+
return icon === undefined ? {} : { icon };
|
|
7882
|
+
}
|
|
7692
7883
|
function helpTopic(args) {
|
|
7693
7884
|
if (args.subcommand === "help")
|
|
7694
7885
|
return args.rest[0];
|
|
@@ -7730,7 +7921,7 @@ function reportEnvironment(problems) {
|
|
|
7730
7921
|
throw new GamecrateError(`${problems.length} environment problem(s)`, Exit.Environment, out.join(`
|
|
7731
7922
|
`));
|
|
7732
7923
|
}
|
|
7733
|
-
async function
|
|
7924
|
+
async function run3(argv, args, config, plugins, defaults, asShell) {
|
|
7734
7925
|
const game = requireGame(args, config);
|
|
7735
7926
|
const profile = launchProfile(args, defaults, config.games[game]);
|
|
7736
7927
|
const gameConfig = gameForImage(args, config, defaults, game);
|
|
@@ -7821,7 +8012,7 @@ run: gamecrate fix-perms ${game} ${profile}`);
|
|
|
7821
8012
|
}
|
|
7822
8013
|
async function execute(inputs) {
|
|
7823
8014
|
const { plan, args, config, identity, asShell, profileSpec, runDir, releaseSources } = inputs;
|
|
7824
|
-
await buildLocalMods(plan, buildPolicy(args, profileSpec));
|
|
8015
|
+
await buildLocalMods(plan, buildPolicy(args, profileSpec), currentRefs(plan.game));
|
|
7825
8016
|
await releaseSources();
|
|
7826
8017
|
const { facts, imageStart } = await readyImage(plan, config, args, asShell);
|
|
7827
8018
|
const modMounts = await stageMods(plan);
|
|
@@ -7910,6 +8101,7 @@ async function dispatchRun(spec, plan, runDir, trustExit, asShell) {
|
|
|
7910
8101
|
const window = asShell || plan.settings.display !== "x11" ? null : await adoptNewWindow({
|
|
7911
8102
|
executable: plan.gameConfig.executable,
|
|
7912
8103
|
title: windowTitle(plan),
|
|
8104
|
+
...iconOption(plan, await globalConfigPath()),
|
|
7913
8105
|
stripDelete: plan.gameConfig.ignoresWmDelete === true,
|
|
7914
8106
|
onClosed: () => {
|
|
7915
8107
|
windowClosed = true;
|
|
@@ -8088,12 +8280,38 @@ async function doctor(config, plugins) {
|
|
|
8088
8280
|
const gameConfig = config.games[game];
|
|
8089
8281
|
const sources = cachedSources(gameConfig, "modless", {}, config.dataRoot);
|
|
8090
8282
|
const { plan, problems } = await resolvePlan({ game, profile: "modless", root: config, plugins, sources });
|
|
8091
|
-
const all = [
|
|
8283
|
+
const all = [
|
|
8284
|
+
...problems,
|
|
8285
|
+
...await preflight(plan),
|
|
8286
|
+
...steamcmdProblems(game, gameConfig, config),
|
|
8287
|
+
...await baseDriftProblems(game, gameConfig)
|
|
8288
|
+
];
|
|
8092
8289
|
if (!reportDoctor(game, all))
|
|
8093
8290
|
failed = true;
|
|
8094
8291
|
}
|
|
8095
8292
|
return failed ? Exit.Environment : Exit.Ok;
|
|
8096
8293
|
}
|
|
8294
|
+
async function baseDriftProblems(game, gameConfig) {
|
|
8295
|
+
const ref = gameConfig.image.ref;
|
|
8296
|
+
if (ref.trim() === "")
|
|
8297
|
+
return [];
|
|
8298
|
+
const facts = await readImageFacts(ref);
|
|
8299
|
+
if (!facts.present || facts.runtime === null)
|
|
8300
|
+
return [];
|
|
8301
|
+
const base = RUNTIME_BASE[facts.launcher === "proton" ? "proton" : "xvfb"];
|
|
8302
|
+
const current = await repoDigest(base);
|
|
8303
|
+
if (current === null)
|
|
8304
|
+
return [];
|
|
8305
|
+
if (facts.runtime.endsWith(current))
|
|
8306
|
+
return [];
|
|
8307
|
+
return [
|
|
8308
|
+
{
|
|
8309
|
+
where: `/games/${game}/image/ref`,
|
|
8310
|
+
message: `${ref} was built on an older ${base}`,
|
|
8311
|
+
suggestion: `gamecrate steam build ${game} to rebuild it on the base you have`
|
|
8312
|
+
}
|
|
8313
|
+
];
|
|
8314
|
+
}
|
|
8097
8315
|
function steamcmdProblems(game, gameConfig, config) {
|
|
8098
8316
|
if (!usesWorkshop(gameConfig))
|
|
8099
8317
|
return [];
|