@gamecrate/cli 0.1.0 → 1.0.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 +246 -246
- package/dist/lib.js +296 -0
- package/dist/types/cli/args.d.ts +40 -0
- package/dist/types/cli/help.d.ts +7 -0
- package/dist/types/cli/output.d.ts +56 -0
- package/dist/types/config/builtin.d.ts +3 -0
- package/dist/types/config/jsonc.d.ts +5 -0
- package/dist/types/config/load.d.ts +45 -0
- package/dist/types/config/validate.d.ts +12 -0
- package/dist/types/docker/identity.d.ts +6 -0
- package/dist/types/docker/preflight.d.ts +3 -0
- package/dist/types/docker/run.d.ts +37 -0
- package/dist/types/docker/spec.d.ts +24 -0
- package/dist/types/docker/window.d.ts +21 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/launch/generate.d.ts +8 -0
- package/dist/types/launch/instance.d.ts +20 -0
- package/dist/types/launch/prepare.d.ts +44 -0
- package/dist/types/launch/resolve.d.ts +17 -0
- package/dist/types/launch/stage.d.ts +13 -0
- package/dist/types/lib.d.ts +3 -0
- package/dist/types/mods/modindex.d.ts +29 -0
- package/dist/types/mods/staleness.d.ts +28 -0
- package/dist/types/mods/worktree.d.ts +18 -0
- package/dist/types/plugin.d.ts +35 -0
- package/dist/types/types.d.ts +394 -0
- package/package.json +15 -10
- package/src/cli/args.ts +0 -592
- package/src/cli/help.ts +0 -193
- package/src/cli/output.ts +0 -246
- package/src/config/builtin.ts +0 -19
- package/src/config/jsonc.ts +0 -21
- package/src/config/load.ts +0 -387
- package/src/config/validate.ts +0 -0
- package/src/docker/identity.ts +0 -25
- package/src/docker/preflight.ts +0 -243
- package/src/docker/run.ts +0 -212
- package/src/docker/spec.ts +0 -357
- package/src/docker/window.ts +0 -152
- package/src/index.ts +0 -875
- package/src/launch/generate.ts +0 -151
- package/src/launch/instance.ts +0 -106
- package/src/launch/prepare.ts +0 -332
- package/src/launch/resolve.ts +0 -383
- package/src/launch/stage.ts +0 -97
- package/src/lib.ts +0 -22
- package/src/mods/modindex.ts +0 -539
- package/src/mods/staleness.ts +0 -125
- package/src/mods/worktree.ts +0 -107
- package/src/plugin.ts +0 -152
- package/src/types.ts +0 -423
package/src/docker/run.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process'
|
|
2
|
-
import type { ChildProcess, StdioOptions } from 'node:child_process'
|
|
3
|
-
import { createWriteStream, mkdirSync } from 'node:fs'
|
|
4
|
-
import { open, readdir, stat } from 'node:fs/promises'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
|
-
import type { Readable, Writable } from 'node:stream'
|
|
7
|
-
import { setTimeout as sleep } from 'node:timers/promises'
|
|
8
|
-
import { TextDecoder } from 'node:util'
|
|
9
|
-
import type { DockerRunSpec } from '../types'
|
|
10
|
-
import { Exit } from '../types'
|
|
11
|
-
import { toDockerArgs } from './spec'
|
|
12
|
-
|
|
13
|
-
/** argv as one array, the way every caller here has it. */
|
|
14
|
-
export function spawnArgv(argv: string[], stdio: StdioOptions): ChildProcess {
|
|
15
|
-
return spawn(argv[0]!, argv.slice(1), { stdio })
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Rejects when the spawn itself fails, so a missing binary lands where a bad exit code would. */
|
|
19
|
-
export function exited(proc: ChildProcess): Promise<number> {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
proc.once('error', reject)
|
|
22
|
-
proc.once('close', (code) => resolve(code ?? 1))
|
|
23
|
-
})
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function collect(stream: Readable): Promise<string> {
|
|
27
|
-
const chunks: Buffer[] = []
|
|
28
|
-
for await (const chunk of stream) chunks.push(chunk as Buffer)
|
|
29
|
-
return Buffer.concat(chunks).toString('utf8')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Runs a short command and collects both streams. A missing binary is exit 127 with the spawn
|
|
34
|
-
* error as stderr, so every caller can report it the same way rather than throwing.
|
|
35
|
-
*/
|
|
36
|
-
export async function capture(argv: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
37
|
-
try {
|
|
38
|
-
const proc = spawnArgv(argv, ['ignore', 'pipe', 'pipe'])
|
|
39
|
-
const [stdout, stderr, code] = await Promise.all([
|
|
40
|
-
collect(proc.stdout!),
|
|
41
|
-
collect(proc.stderr!),
|
|
42
|
-
exited(proc),
|
|
43
|
-
])
|
|
44
|
-
return { code, stdout, stderr }
|
|
45
|
-
} catch (error) {
|
|
46
|
-
return { code: 127, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The tee'd combined stream, and what waitForMarker watches. */
|
|
51
|
-
export const STDOUT_LOG = 'stdout.log'
|
|
52
|
-
|
|
53
|
-
const MARKER_POLL_MS = 200
|
|
54
|
-
|
|
55
|
-
export interface RunOptions {
|
|
56
|
-
/** Run log directory; created if missing. Receives stdout.log. */
|
|
57
|
-
logDir: string
|
|
58
|
-
/** Passed to `docker stop --timeout` when a signal arrives. */
|
|
59
|
-
stopTimeoutSeconds?: number
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Spawns docker directly rather than through a pipeline, so the game's status is the status.
|
|
64
|
-
* `exec docker run | tee` returns tee's code, which is why the old scripts always reported 0.
|
|
65
|
-
*/
|
|
66
|
-
export async function runContainer(spec: DockerRunSpec, opts: RunOptions): Promise<number> {
|
|
67
|
-
const stopTimeout = opts.stopTimeoutSeconds ?? 10
|
|
68
|
-
|
|
69
|
-
mkdirSync(opts.logDir, { recursive: true })
|
|
70
|
-
const sink = createWriteStream(join(opts.logDir, STDOUT_LOG))
|
|
71
|
-
|
|
72
|
-
const proc = spawnArgv(['docker', ...toDockerArgs(spec)], ['inherit', 'pipe', 'pipe'])
|
|
73
|
-
|
|
74
|
-
let interrupted = false
|
|
75
|
-
const onSignal = () => {
|
|
76
|
-
if (interrupted) return
|
|
77
|
-
interrupted = true
|
|
78
|
-
void stopContainer(spec.name, stopTimeout)
|
|
79
|
-
}
|
|
80
|
-
process.on('SIGINT', onSignal)
|
|
81
|
-
process.on('SIGTERM', onSignal)
|
|
82
|
-
|
|
83
|
-
// Registered before the tees: an unhandled "error" event would take the process down, and
|
|
84
|
-
// draining the pipes first is what keeps a chatty container from filling them and stalling.
|
|
85
|
-
const code = exited(proc)
|
|
86
|
-
try {
|
|
87
|
-
await Promise.all([
|
|
88
|
-
tee(proc.stdout!, sink, process.stdout),
|
|
89
|
-
tee(proc.stderr!, sink, process.stderr),
|
|
90
|
-
])
|
|
91
|
-
const status = await code
|
|
92
|
-
return interrupted ? Exit.Interrupted : status
|
|
93
|
-
} finally {
|
|
94
|
-
process.off('SIGINT', onSignal)
|
|
95
|
-
process.off('SIGTERM', onSignal)
|
|
96
|
-
await new Promise<void>((resolve) => sink.end(resolve))
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export async function stopContainer(name: string, timeoutSeconds: number): Promise<void> {
|
|
101
|
-
const proc = spawnArgv(['docker', 'stop', '--timeout', String(timeoutSeconds), name], 'ignore')
|
|
102
|
-
await exited(proc).catch(() => {})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Host-side marker watch. Watches container stdout AND the game's own log file: RimWorld
|
|
107
|
-
* sends Verse.Log output to -logfile, never to stdout, so a stdout-only watch can never
|
|
108
|
-
* match a RimWorld mod's message.
|
|
109
|
-
*/
|
|
110
|
-
export async function waitForMarker(
|
|
111
|
-
sources: string[],
|
|
112
|
-
marker: string,
|
|
113
|
-
timeoutSeconds: number,
|
|
114
|
-
): Promise<boolean> {
|
|
115
|
-
const deadline = Date.now() + timeoutSeconds * 1000
|
|
116
|
-
const carry = Math.max(marker.length - 1, 0)
|
|
117
|
-
const seen = new Map<string, Watched>()
|
|
118
|
-
const startedAt = Date.now()
|
|
119
|
-
|
|
120
|
-
while (true) {
|
|
121
|
-
for (const path of await expandSources(sources)) {
|
|
122
|
-
let state = seen.get(path)
|
|
123
|
-
if (state === undefined) {
|
|
124
|
-
// Logs/Player-prev.log holds the PREVIOUS run's marker verbatim, so its history
|
|
125
|
-
// would match instantly. Skip what a file already held before this watch began,
|
|
126
|
-
// keyed on mtime so a fresh stdout.log is still read from byte zero.
|
|
127
|
-
state = { offset: await staleSize(path, startedAt), tail: '', decoder: new TextDecoder() }
|
|
128
|
-
seen.set(path, state)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (await scan(path, state, marker, carry)) return true
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (Date.now() >= deadline) return false
|
|
135
|
-
await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)))
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
interface Watched {
|
|
140
|
-
offset: number
|
|
141
|
-
tail: string
|
|
142
|
-
/** Per path: a shared streaming decoder corrupts every file after the first. */
|
|
143
|
-
decoder: TextDecoder
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Bytes to skip: a file last written before the watch started is a previous run's log. */
|
|
147
|
-
async function staleSize(path: string, startedAt: number): Promise<number> {
|
|
148
|
-
return stat(path).then(
|
|
149
|
-
(info) => (info.mtimeMs < startedAt ? info.size : 0),
|
|
150
|
-
() => 0,
|
|
151
|
-
)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Never throws: one unreadable file must not reject the whole watch. */
|
|
155
|
-
async function scan(
|
|
156
|
-
path: string,
|
|
157
|
-
state: Watched,
|
|
158
|
-
marker: string,
|
|
159
|
-
carry: number,
|
|
160
|
-
): Promise<boolean> {
|
|
161
|
-
const handle = await open(path, 'r').catch(() => null)
|
|
162
|
-
if (handle === null) return false
|
|
163
|
-
try {
|
|
164
|
-
const { size } = await handle.stat()
|
|
165
|
-
if (size < state.offset) {
|
|
166
|
-
state.offset = 0
|
|
167
|
-
state.tail = ''
|
|
168
|
-
state.decoder = new TextDecoder()
|
|
169
|
-
}
|
|
170
|
-
if (size <= state.offset) return false
|
|
171
|
-
|
|
172
|
-
const buffer = Buffer.alloc(size - state.offset)
|
|
173
|
-
const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset)
|
|
174
|
-
state.offset += bytesRead
|
|
175
|
-
const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true })
|
|
176
|
-
if (text.includes(marker)) return true
|
|
177
|
-
state.tail = carry > 0 ? text.slice(-carry) : ''
|
|
178
|
-
return false
|
|
179
|
-
} catch {
|
|
180
|
-
return false
|
|
181
|
-
} finally {
|
|
182
|
-
await handle.close().catch(() => {})
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** A source is a file or a directory of logs; directories are rescanned every poll. */
|
|
187
|
-
async function expandSources(sources: string[]): Promise<string[]> {
|
|
188
|
-
const out: string[] = []
|
|
189
|
-
for (const source of sources) {
|
|
190
|
-
const info = await stat(source).catch(() => null)
|
|
191
|
-
if (info === null) {
|
|
192
|
-
out.push(source)
|
|
193
|
-
continue
|
|
194
|
-
}
|
|
195
|
-
if (!info.isDirectory()) {
|
|
196
|
-
out.push(source)
|
|
197
|
-
continue
|
|
198
|
-
}
|
|
199
|
-
const entries = await readdir(source).catch((): string[] => [])
|
|
200
|
-
for (const entry of entries) {
|
|
201
|
-
if (entry.toLowerCase().endsWith('.log')) out.push(join(source, entry))
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return out
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function tee(stream: Readable, sink: Writable, mirror: NodeJS.WriteStream): Promise<void> {
|
|
208
|
-
for await (const chunk of stream) {
|
|
209
|
-
mirror.write(chunk as Buffer)
|
|
210
|
-
sink.write(chunk as Buffer)
|
|
211
|
-
}
|
|
212
|
-
}
|
package/src/docker/spec.ts
DELETED
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from 'node:fs'
|
|
2
|
-
import { homedir, hostname } from 'node:os'
|
|
3
|
-
import { basename, join } from 'node:path'
|
|
4
|
-
import type {
|
|
5
|
-
DataDirSpec,
|
|
6
|
-
DockerRunSpec,
|
|
7
|
-
Identity,
|
|
8
|
-
LaunchPlan,
|
|
9
|
-
Mount,
|
|
10
|
-
} from '../types'
|
|
11
|
-
import { GamecrateError, Exit } from '../types'
|
|
12
|
-
|
|
13
|
-
/** Container-side XDG_RUNTIME_DIR. A sized tmpfs; display and audio sockets land inside it. */
|
|
14
|
-
export const CONTAINER_RUNTIME_DIR = '/tmp/xdg'
|
|
15
|
-
|
|
16
|
-
/** Where the run directory is bound, so `-logfile /logs/Player.log` lands beside stdout.log. */
|
|
17
|
-
export const CONTAINER_LOG_DIR = '/logs'
|
|
18
|
-
|
|
19
|
-
/** X11's well-known socket directory. The path is the same on both sides or DISPLAY lies. */
|
|
20
|
-
const X11_SOCKET_DIR = '/tmp/.X11-unix'
|
|
21
|
-
|
|
22
|
-
/** Outside XDG_RUNTIME_DIR on purpose: that is a tmpfs, and a bind under it races the tmpfs. */
|
|
23
|
-
const CONTAINER_XAUTHORITY = '/tmp/xauth'
|
|
24
|
-
|
|
25
|
-
/** Persistent XDG root. Outside HOME because HOME is a tmpfs and a bind under it races. */
|
|
26
|
-
const CONTAINER_XDG_DIR = '/xdg'
|
|
27
|
-
|
|
28
|
-
const RUNTIME_DIR_SIZE = '64m'
|
|
29
|
-
const HOME_SIZE = '64m'
|
|
30
|
-
const MASK_SIZE = '1m'
|
|
31
|
-
|
|
32
|
-
export function buildRunSpec(
|
|
33
|
-
plan: LaunchPlan,
|
|
34
|
-
modMounts: Mount[],
|
|
35
|
-
identity: Identity,
|
|
36
|
-
): DockerRunSpec {
|
|
37
|
-
const { gameConfig: game, settings } = plan
|
|
38
|
-
const headed = plan.mode === 'headed'
|
|
39
|
-
|
|
40
|
-
const mounts: Mount[] = []
|
|
41
|
-
const env: Record<string, string> = {
|
|
42
|
-
HOME: identity.home,
|
|
43
|
-
USER: identity.user,
|
|
44
|
-
LOGNAME: identity.user,
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (game.gameFiles.source === 'mount') {
|
|
48
|
-
if (!game.gameFiles.host) {
|
|
49
|
-
throw new GamecrateError(
|
|
50
|
-
`gameFiles.source is "mount" but no host path is set for ${plan.game}`,
|
|
51
|
-
Exit.Config,
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
mounts.push({ type: 'bind', source: hostPath(game.gameFiles.host), target: game.gameFiles.container, readonly: true })
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
mounts.push({ type: 'bind', source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true })
|
|
58
|
-
// Nested per-mod binds sit inside the staged tree; the game never writes to a mod source.
|
|
59
|
-
for (const mount of modMounts) {
|
|
60
|
-
mounts.push(mount.type === 'bind' ? { ...mount, readonly: true } : mount)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Read-write on purpose: both engines Create() subdirectories at boot and a ro mount fails there.
|
|
64
|
-
mounts.push({ type: 'bind', source: hostPath(plan.dataDirHost), target: game.dataDir.container })
|
|
65
|
-
|
|
66
|
-
// xvfb-run -a picks a free display itself, which removes both the hardcoded :99 and the
|
|
67
|
-
// startup race the old launch.sh papered over with `sleep 2`.
|
|
68
|
-
const command = headed
|
|
69
|
-
? [game.executable]
|
|
70
|
-
: [
|
|
71
|
-
'xvfb-run',
|
|
72
|
-
'-a',
|
|
73
|
-
`--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
|
|
74
|
-
game.executable,
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
if (game.dataDir.mode === 'arg') {
|
|
78
|
-
command.push(validateDataDirArg(game.dataDir))
|
|
79
|
-
} else {
|
|
80
|
-
Object.assign(env, game.dataDir.env)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (game.logFile.mode === 'arg') {
|
|
84
|
-
mounts.push({ type: 'bind', source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR })
|
|
85
|
-
command.push(game.logFile.arg, `${CONTAINER_LOG_DIR}/Player.log`)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Unconditional, independent of the uid mode: a game may read mods from both roots.
|
|
89
|
-
for (const target of game.modsDir.mask ?? []) {
|
|
90
|
-
mounts.push({ type: 'tmpfs', target, size: MASK_SIZE, uid: identity.uid, gid: identity.gid, mode: '755' })
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (identity.uid !== 0) {
|
|
94
|
-
mounts.push({ type: 'tmpfs', target: identity.home, size: HOME_SIZE, uid: identity.uid, gid: identity.gid, mode: '700' })
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
mounts.push({
|
|
98
|
-
type: 'tmpfs',
|
|
99
|
-
target: CONTAINER_RUNTIME_DIR,
|
|
100
|
-
size: RUNTIME_DIR_SIZE,
|
|
101
|
-
uid: identity.uid,
|
|
102
|
-
gid: identity.gid,
|
|
103
|
-
mode: '700',
|
|
104
|
-
})
|
|
105
|
-
env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR
|
|
106
|
-
|
|
107
|
-
// HOME is a tmpfs, so $HOME/.config and $HOME/.local/share are empty every run and .NET's
|
|
108
|
-
// GetFolderPath hands back "" for a missing directory. Point XDG at a per-profile bind
|
|
109
|
-
// instead. A game that already sets XDG_DATA_HOME to its save dir never gets
|
|
110
|
-
// overwritten.
|
|
111
|
-
mounts.push({ type: 'bind', source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR })
|
|
112
|
-
env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`
|
|
113
|
-
env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`
|
|
114
|
-
env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`
|
|
115
|
-
|
|
116
|
-
if (headed) {
|
|
117
|
-
if (settings.display === 'x11') {
|
|
118
|
-
const x11 = x11Session()
|
|
119
|
-
if (x11) {
|
|
120
|
-
mounts.push({ type: 'bind', source: X11_SOCKET_DIR, target: X11_SOCKET_DIR })
|
|
121
|
-
env.DISPLAY = x11.display
|
|
122
|
-
env.XDG_SESSION_TYPE = 'x11'
|
|
123
|
-
env.SDL_VIDEODRIVER = 'x11'
|
|
124
|
-
env.QT_QPA_PLATFORM = 'xcb'
|
|
125
|
-
if (x11.xauthority) {
|
|
126
|
-
mounts.push({ type: 'bind', source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true })
|
|
127
|
-
env.XAUTHORITY = CONTAINER_XAUTHORITY
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
} else {
|
|
131
|
-
const wayland = waylandSocket()
|
|
132
|
-
if (wayland) {
|
|
133
|
-
const target = `${CONTAINER_RUNTIME_DIR}/${wayland.name}`
|
|
134
|
-
mounts.push({ type: 'bind', source: wayland.source, target })
|
|
135
|
-
env.WAYLAND_DISPLAY = wayland.name
|
|
136
|
-
env.XDG_SESSION_TYPE = 'wayland'
|
|
137
|
-
env.SDL_VIDEODRIVER = 'wayland'
|
|
138
|
-
env.QT_QPA_PLATFORM = 'wayland'
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (settings.audio) {
|
|
142
|
-
for (const socket of audioSockets()) {
|
|
143
|
-
mounts.push({ type: 'bind', source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` })
|
|
144
|
-
}
|
|
145
|
-
env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
// Offscreen modes get their X server from xvfb-run below; DISPLAY is set by it, not by us.
|
|
149
|
-
|
|
150
|
-
const deviceCgroupRules: string[] = []
|
|
151
|
-
if (settings.input) {
|
|
152
|
-
mounts.push({ type: 'bind', source: '/dev/input', target: '/dev/input', readonly: true })
|
|
153
|
-
deviceCgroupRules.push('c 13:* rmw')
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const devices: string[] = []
|
|
157
|
-
if (settings.gpu) devices.push('nvidia.com/gpu=all')
|
|
158
|
-
Object.assign(env, glEnv(settings.gpu))
|
|
159
|
-
|
|
160
|
-
command.push(...(settings.gameArgs ?? []))
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
image: game.image.ref,
|
|
164
|
-
name: containerName(plan),
|
|
165
|
-
labels: {
|
|
166
|
-
'gamecrate.game': plan.game,
|
|
167
|
-
'gamecrate.profile': plan.profile,
|
|
168
|
-
...(plan.instance === undefined ? {} : { 'gamecrate.instance': plan.instance }),
|
|
169
|
-
},
|
|
170
|
-
identity,
|
|
171
|
-
env,
|
|
172
|
-
mounts,
|
|
173
|
-
devices,
|
|
174
|
-
deviceCgroupRules,
|
|
175
|
-
network: settings.network,
|
|
176
|
-
memory: settings.memory,
|
|
177
|
-
memorySwap: settings.memory,
|
|
178
|
-
cpus: settings.cpus,
|
|
179
|
-
pidsLimit: settings.pidsLimit,
|
|
180
|
-
ulimits: ['core=0'],
|
|
181
|
-
workdir: game.gameFiles.container,
|
|
182
|
-
// An X client whose WM_CLIENT_MACHINE is foreign gets ` <@name>` stapled to its caption.
|
|
183
|
-
...(headed && settings.display === 'x11' ? { hostname: hostname() } : {}),
|
|
184
|
-
command,
|
|
185
|
-
extraArgs: [...(settings.dockerArgs ?? [])],
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Instances of one profile run side by side, so the name has to carry which one this is. */
|
|
190
|
-
export function containerName(plan: LaunchPlan): string {
|
|
191
|
-
const base = `gamecrate-${plan.game}-${plan.profile}`
|
|
192
|
-
return plan.instance === undefined ? base : `${base}-${plan.instance}`
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** What the window is renamed to, so a taskbar full of worktrees is readable. */
|
|
196
|
-
export function windowTitle(plan: LaunchPlan): string {
|
|
197
|
-
const base = `${plan.game} ${plan.profile}`
|
|
198
|
-
return plan.instance === undefined ? base : `${base} / ${plan.instance}`
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
export function toDockerArgs(spec: DockerRunSpec): string[] {
|
|
202
|
-
const args = ['run', '--rm', '--init', '--name', spec.name]
|
|
203
|
-
if (spec.hostname !== undefined) args.push('--hostname', spec.hostname)
|
|
204
|
-
|
|
205
|
-
for (const [key, value] of Object.entries(spec.labels)) args.push('--label', `${key}=${value}`)
|
|
206
|
-
args.push('--user', `${spec.identity.uid}:${spec.identity.gid}`)
|
|
207
|
-
for (const [key, value] of Object.entries(spec.env)) args.push('--env', `${key}=${value}`)
|
|
208
|
-
for (const mount of spec.mounts) args.push(...mountArgs(mount))
|
|
209
|
-
for (const device of spec.devices) args.push('--device', device)
|
|
210
|
-
for (const rule of spec.deviceCgroupRules) args.push('--device-cgroup-rule', rule)
|
|
211
|
-
for (const ulimit of spec.ulimits) args.push('--ulimit', ulimit)
|
|
212
|
-
|
|
213
|
-
args.push('--network', spec.network)
|
|
214
|
-
args.push('--memory', spec.memory)
|
|
215
|
-
args.push('--memory-swap', spec.memorySwap)
|
|
216
|
-
args.push('--cpus', String(spec.cpus))
|
|
217
|
-
args.push('--pids-limit', String(spec.pidsLimit))
|
|
218
|
-
args.push('--workdir', spec.workdir)
|
|
219
|
-
args.push(...spec.extraArgs)
|
|
220
|
-
// Acquisition is an earlier explicit step; the run must never fetch a different digest.
|
|
221
|
-
args.push('--pull=never')
|
|
222
|
-
// The image's own ENTRYPOINT is not ours to trust: RimWorld's is ["/bin/bash"], which
|
|
223
|
-
// would run the game's ELF as a shell script. State it explicitly every time.
|
|
224
|
-
const [entrypoint, ...rest] = spec.command
|
|
225
|
-
if (entrypoint !== undefined) args.push('--entrypoint', entrypoint)
|
|
226
|
-
args.push(spec.image, ...rest)
|
|
227
|
-
|
|
228
|
-
return args
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* `--mount` for binds so a missing source errors instead of being created root-owned;
|
|
233
|
-
* `--tmpfs` for tmpfs because `--mount type=tmpfs` has no uid=/gid= options.
|
|
234
|
-
*/
|
|
235
|
-
function mountArgs(mount: Mount): string[] {
|
|
236
|
-
if (mount.type === 'tmpfs') {
|
|
237
|
-
const opts = ['rw']
|
|
238
|
-
if (mount.uid !== undefined) opts.push(`uid=${mount.uid}`)
|
|
239
|
-
if (mount.gid !== undefined) opts.push(`gid=${mount.gid}`)
|
|
240
|
-
if (mount.mode) opts.push(`mode=${mount.mode}`)
|
|
241
|
-
opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`)
|
|
242
|
-
return ['--tmpfs', `${mount.target}:${opts.join(',')}`]
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (!mount.source) {
|
|
246
|
-
throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config)
|
|
247
|
-
}
|
|
248
|
-
const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`]
|
|
249
|
-
if (mount.readonly) fields.push('readonly')
|
|
250
|
-
return ['--mount', fields.map(csvField).join(',')]
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Docker parses the option string as CSV, so a comma in a path has to be quoted. */
|
|
254
|
-
function csvField(field: string): string {
|
|
255
|
-
if (!field.includes(',') && !field.includes('"')) return field
|
|
256
|
-
return `"${field.replaceAll('"', '""')}"`
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* RimWorld's TryGetCommandLineArg splits argv on `=` and requires exactly two parts. A path
|
|
261
|
-
* with `=` falls back to an ephemeral in-container path and `--rm` takes the save with it (L10).
|
|
262
|
-
*/
|
|
263
|
-
function validateDataDirArg(dataDir: Extract<DataDirSpec, { mode: 'arg' }>): string {
|
|
264
|
-
if (dataDir.container.includes('=')) {
|
|
265
|
-
throw new GamecrateError(
|
|
266
|
-
`container data path contains "=": ${dataDir.container}`,
|
|
267
|
-
Exit.Config,
|
|
268
|
-
'RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.',
|
|
269
|
-
)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const parts = dataDir.arg.split('=')
|
|
273
|
-
if (parts.length !== 2) {
|
|
274
|
-
throw new GamecrateError(
|
|
275
|
-
`dataDir.arg must contain exactly one "=": ${dataDir.arg}`,
|
|
276
|
-
Exit.Config,
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (trimSlash(parts[1] ?? '') !== trimSlash(dataDir.container)) {
|
|
281
|
-
throw new GamecrateError(
|
|
282
|
-
`dataDir.arg points at ${parts[1]} but the mount target is ${dataDir.container}`,
|
|
283
|
-
Exit.Config,
|
|
284
|
-
'The engine would write to a path that is not the mounted data directory.',
|
|
285
|
-
)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return dataDir.arg
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function trimSlash(path: string): string {
|
|
292
|
-
return path.length > 1 ? path.replace(/\/+$/, '') : path
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** The image bakes llvmpipe, so the tool states the whole GL story rather than inheriting it. */
|
|
296
|
-
function glEnv(gpu: boolean): Record<string, string> {
|
|
297
|
-
if (!gpu) return { LIBGL_ALWAYS_SOFTWARE: '1', GALLIUM_DRIVER: 'llvmpipe' }
|
|
298
|
-
|
|
299
|
-
const env: Record<string, string> = { LIBGL_ALWAYS_SOFTWARE: '0', GALLIUM_DRIVER: '' }
|
|
300
|
-
if (hasNvidia()) {
|
|
301
|
-
env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia'
|
|
302
|
-
env.__NV_PRIME_RENDER_OFFLOAD = '1'
|
|
303
|
-
}
|
|
304
|
-
return env
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/** Gated on the detected vendor, not on the game: this class of host also carries radeon_icd. */
|
|
308
|
-
function hasNvidia(): boolean {
|
|
309
|
-
return (
|
|
310
|
-
existsSync('/dev/nvidiactl') ||
|
|
311
|
-
existsSync('/etc/cdi/nvidia.yaml') ||
|
|
312
|
-
existsSync('/usr/share/vulkan/icd.d/nvidia_icd.json')
|
|
313
|
-
)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* The host X session a headed run joins. The cookie is looked up separately from the socket
|
|
318
|
-
* because XWayland under a display manager keeps it in XDG_RUNTIME_DIR, not ~/.Xauthority.
|
|
319
|
-
* Whether the socket directory is really there is checkBindSources' job, as with every bind.
|
|
320
|
-
*/
|
|
321
|
-
export function x11Session(): { display: string; xauthority: string | null } | null {
|
|
322
|
-
const display = process.env.DISPLAY
|
|
323
|
-
if (!display) return null
|
|
324
|
-
|
|
325
|
-
const cookie = process.env.XAUTHORITY ?? join(homedir(), '.Xauthority')
|
|
326
|
-
return { display, xauthority: existsSync(cookie) ? cookie : null }
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export function waylandSocket(): { source: string; name: string } | null {
|
|
330
|
-
const display = process.env.WAYLAND_DISPLAY
|
|
331
|
-
const runtime = process.env.XDG_RUNTIME_DIR
|
|
332
|
-
if (!display) return null
|
|
333
|
-
|
|
334
|
-
const source = display.startsWith('/') ? display : runtime ? join(runtime, display) : null
|
|
335
|
-
if (!source || !existsSync(source)) return null
|
|
336
|
-
return { source, name: basename(source) }
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function audioSockets(): { source: string; name: string }[] {
|
|
340
|
-
const runtime = process.env.XDG_RUNTIME_DIR
|
|
341
|
-
if (!runtime) return []
|
|
342
|
-
|
|
343
|
-
const found: { source: string; name: string }[] = []
|
|
344
|
-
for (const name of ['pipewire-0', 'pulse/native']) {
|
|
345
|
-
const source = join(runtime, name)
|
|
346
|
-
if (existsSync(source)) found.push({ source, name })
|
|
347
|
-
}
|
|
348
|
-
return found
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function hostPath(path: string): string {
|
|
352
|
-
try {
|
|
353
|
-
return realpathSync(path)
|
|
354
|
-
} catch {
|
|
355
|
-
return path
|
|
356
|
-
}
|
|
357
|
-
}
|