@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.
Files changed (51) hide show
  1. package/dist/gamecrate.js +246 -246
  2. package/dist/lib.js +296 -0
  3. package/dist/types/cli/args.d.ts +40 -0
  4. package/dist/types/cli/help.d.ts +7 -0
  5. package/dist/types/cli/output.d.ts +56 -0
  6. package/dist/types/config/builtin.d.ts +3 -0
  7. package/dist/types/config/jsonc.d.ts +5 -0
  8. package/dist/types/config/load.d.ts +45 -0
  9. package/dist/types/config/validate.d.ts +12 -0
  10. package/dist/types/docker/identity.d.ts +6 -0
  11. package/dist/types/docker/preflight.d.ts +3 -0
  12. package/dist/types/docker/run.d.ts +37 -0
  13. package/dist/types/docker/spec.d.ts +24 -0
  14. package/dist/types/docker/window.d.ts +21 -0
  15. package/dist/types/index.d.ts +2 -0
  16. package/dist/types/launch/generate.d.ts +8 -0
  17. package/dist/types/launch/instance.d.ts +20 -0
  18. package/dist/types/launch/prepare.d.ts +44 -0
  19. package/dist/types/launch/resolve.d.ts +17 -0
  20. package/dist/types/launch/stage.d.ts +13 -0
  21. package/dist/types/lib.d.ts +3 -0
  22. package/dist/types/mods/modindex.d.ts +29 -0
  23. package/dist/types/mods/staleness.d.ts +28 -0
  24. package/dist/types/mods/worktree.d.ts +18 -0
  25. package/dist/types/plugin.d.ts +35 -0
  26. package/dist/types/types.d.ts +394 -0
  27. package/package.json +15 -10
  28. package/src/cli/args.ts +0 -592
  29. package/src/cli/help.ts +0 -193
  30. package/src/cli/output.ts +0 -246
  31. package/src/config/builtin.ts +0 -19
  32. package/src/config/jsonc.ts +0 -21
  33. package/src/config/load.ts +0 -387
  34. package/src/config/validate.ts +0 -0
  35. package/src/docker/identity.ts +0 -25
  36. package/src/docker/preflight.ts +0 -243
  37. package/src/docker/run.ts +0 -212
  38. package/src/docker/spec.ts +0 -357
  39. package/src/docker/window.ts +0 -152
  40. package/src/index.ts +0 -875
  41. package/src/launch/generate.ts +0 -151
  42. package/src/launch/instance.ts +0 -106
  43. package/src/launch/prepare.ts +0 -332
  44. package/src/launch/resolve.ts +0 -383
  45. package/src/launch/stage.ts +0 -97
  46. package/src/lib.ts +0 -22
  47. package/src/mods/modindex.ts +0 -539
  48. package/src/mods/staleness.ts +0 -125
  49. package/src/mods/worktree.ts +0 -107
  50. package/src/plugin.ts +0 -152
  51. package/src/types.ts +0 -423
@@ -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
- }