@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/window.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { basename } from 'node:path'
|
|
2
|
-
import { setTimeout as sleep } from 'node:timers/promises'
|
|
3
|
-
|
|
4
|
-
import { warn } from '../cli/output'
|
|
5
|
-
import { capture } from './run'
|
|
6
|
-
|
|
7
|
-
/** Long enough for a cold RimWorld start on a spinning disk, short enough to give up on. */
|
|
8
|
-
const WAIT_MS = 180_000
|
|
9
|
-
const POLL_MS = 500
|
|
10
|
-
|
|
11
|
-
export interface WindowWatch {
|
|
12
|
-
stop: () => void
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface Toplevel {
|
|
16
|
-
id: string
|
|
17
|
-
wmClass: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** `wmctrl -lx` is `id desktop class host title`, so the class is the third field. */
|
|
21
|
-
async function toplevels(): Promise<Toplevel[] | null> {
|
|
22
|
-
const { code, stdout } = await capture(['wmctrl', '-lx'])
|
|
23
|
-
if (code === 127) return null
|
|
24
|
-
|
|
25
|
-
const found: Toplevel[] = []
|
|
26
|
-
for (const line of stdout.split('\n')) {
|
|
27
|
-
const [id, , wmClass] = line.trim().split(/\s+/)
|
|
28
|
-
if (id !== undefined && wmClass !== undefined) found.push({ id, wmClass })
|
|
29
|
-
}
|
|
30
|
-
return found
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface AdoptOptions {
|
|
34
|
-
executable: string
|
|
35
|
-
title: string
|
|
36
|
-
/** Set for an engine that claims WM_DELETE_WINDOW and ignores it, RimWorld being the one. */
|
|
37
|
-
stripDelete: boolean
|
|
38
|
-
/** Called once the window a stripDelete run adopted is gone. */
|
|
39
|
-
onClosed: () => void
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Retitles the window a headed X11 run opens and fixes what the WM knows about it. Snapshots
|
|
44
|
-
* the screen first and takes the first window that was not there: an X client inside a
|
|
45
|
-
* container reports a container-local _NET_WM_PID and hostname, so neither of those identifies
|
|
46
|
-
* the run from out here. The WM_CLASS comes from the executable, which narrows it to this game
|
|
47
|
-
* rather than any window that opened.
|
|
48
|
-
*/
|
|
49
|
-
export async function adoptNewWindow(opts: AdoptOptions): Promise<WindowWatch> {
|
|
50
|
-
const before = await toplevels()
|
|
51
|
-
if (before === null) {
|
|
52
|
-
warn('wmctrl is not installed, so the window keeps the game\'s own title')
|
|
53
|
-
return { stop: () => {} }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const seen = new Set(before.map((w) => w.id))
|
|
57
|
-
const wanted = basename(opts.executable).toLowerCase()
|
|
58
|
-
let stopped = false
|
|
59
|
-
|
|
60
|
-
void (async () => {
|
|
61
|
-
const deadline = Date.now() + WAIT_MS
|
|
62
|
-
while (!stopped && Date.now() < deadline) {
|
|
63
|
-
await sleep(POLL_MS)
|
|
64
|
-
if (stopped) return
|
|
65
|
-
|
|
66
|
-
const now = (await toplevels()) ?? []
|
|
67
|
-
const match = now.find((w) => !seen.has(w.id) && w.wmClass.toLowerCase().includes(wanted))
|
|
68
|
-
if (match === undefined) continue
|
|
69
|
-
|
|
70
|
-
await capture(['wmctrl', '-i', '-r', match.id, '-N', opts.title])
|
|
71
|
-
await adopt(match.id, opts)
|
|
72
|
-
if (opts.stripDelete) await watchForClose(match.id, () => stopped, opts.onClosed)
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
})()
|
|
76
|
-
|
|
77
|
-
return { stop: () => void (stopped = true) }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* KWin answers a stripped close button by destroying the window, and sometimes by signalling
|
|
82
|
-
* the pid as well, so the only dependable news is that the window went away. RimWorld outlives
|
|
83
|
-
* it either way, alive with nothing on screen, which is what leaves the run to be torn down
|
|
84
|
-
* from out here.
|
|
85
|
-
*/
|
|
86
|
-
async function watchForClose(id: string, stopped: () => boolean, onClosed: () => void) {
|
|
87
|
-
let misses = 0
|
|
88
|
-
while (!stopped()) {
|
|
89
|
-
await sleep(POLL_MS)
|
|
90
|
-
if (stopped()) return
|
|
91
|
-
|
|
92
|
-
const now = await toplevels()
|
|
93
|
-
if (now === null) return
|
|
94
|
-
|
|
95
|
-
// Two polls, so a window being reconfigured rather than closed is not mistaken for one.
|
|
96
|
-
misses = now.some((w) => w.id === id) ? 0 : misses + 1
|
|
97
|
-
if (misses >= 2) {
|
|
98
|
-
onClosed()
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function adopt(id: string, opts: AdoptOptions): Promise<void> {
|
|
105
|
-
const protocols = await capture(['xprop', '-id', id, 'WM_PROTOCOLS'])
|
|
106
|
-
if (protocols.code === 127) {
|
|
107
|
-
warn('xprop is not installed, so nothing out here can fix the window\'s close button')
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
await claimPid(id)
|
|
112
|
-
if (opts.stripDelete) await dropDeleteProtocol(id, parseAtoms(protocols.stdout))
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The window carries its container pid, and the spoofed hostname makes the WM read that as
|
|
117
|
-
* local, so a kill by pid would signal whichever host process holds that number out here.
|
|
118
|
-
* Point it at the launcher: its SIGTERM path is a `docker stop`, which is graceful and then
|
|
119
|
-
* final, and RimWorld hangs mid-shutdown often enough that the final part matters.
|
|
120
|
-
*/
|
|
121
|
-
async function claimPid(id: string): Promise<void> {
|
|
122
|
-
const pid = String(process.pid)
|
|
123
|
-
await capture(['xprop', '-id', id, '-f', '_NET_WM_PID', '32c', '-set', '_NET_WM_PID', pid])
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Withdrawing the claim is what makes the titlebar X do anything: the WM stops asking and kills
|
|
128
|
-
* the client instead. Everything else in the list stays, _NET_WM_PING especially.
|
|
129
|
-
*/
|
|
130
|
-
async function dropDeleteProtocol(id: string, atoms: string[]): Promise<void> {
|
|
131
|
-
if (!atoms.includes('WM_DELETE_WINDOW')) return
|
|
132
|
-
|
|
133
|
-
const kept = atoms.filter((atom) => atom !== 'WM_DELETE_WINDOW')
|
|
134
|
-
if (kept.length === 0) {
|
|
135
|
-
await capture(['xprop', '-id', id, '-remove', 'WM_PROTOCOLS'])
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const format = `32${'a'.repeat(kept.length)}`
|
|
140
|
-
await capture([
|
|
141
|
-
'xprop', '-id', id, '-f', 'WM_PROTOCOLS', format, '-set', 'WM_PROTOCOLS', kept.join(', '),
|
|
142
|
-
])
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** `WM_PROTOCOLS(ATOM): protocols WM_DELETE_WINDOW, WM_TAKE_FOCUS`, or `: not found.` */
|
|
146
|
-
export function parseAtoms(stdout: string): string[] {
|
|
147
|
-
const list = stdout.slice(stdout.indexOf(':') + 1).replace(/^\s*protocols\s*/, '')
|
|
148
|
-
return list
|
|
149
|
-
.split(',')
|
|
150
|
-
.map((atom) => atom.trim())
|
|
151
|
-
.filter((atom) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(atom))
|
|
152
|
-
}
|