minecraft-dev-starter 0.0.0-stage → 0.1.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/AGENTS.md +166 -0
- package/README.md +124 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1521 -0
- package/package.json +54 -4
- package/src/client/Capsule.js +158 -0
- package/src/client/DetailTab.js +228 -0
- package/src/client/Flyout.js +191 -0
- package/src/client/LogTab.js +180 -0
- package/src/client/Skeleton.js +18 -0
- package/src/client/api.js +33 -0
- package/src/client/debug.js +27 -0
- package/src/client/format.js +89 -0
- package/src/client/h.js +8 -0
- package/src/client/icons.js +147 -0
- package/src/client/index.js +205 -0
- package/src/client/runtime.js +41 -0
- package/src/client/selection.js +154 -0
- package/src/client/state.js +233 -0
- package/src/client/store.js +34 -0
- package/src/client/styles.js +329 -0
- package/src/index.js +165 -0
- package/src/lib/api.js +186 -0
- package/src/lib/argv.js +66 -0
- package/src/lib/detect.js +460 -0
- package/src/lib/gradle.js +295 -0
- package/src/lib/java.js +204 -0
- package/src/lib/launches.js +495 -0
- package/src/lib/metadata.js +175 -0
- package/src/lib/scanner.js +131 -0
- package/src/lib/state.js +91 -0
- package/src/lib/text.js +92 -0
- package/src/lib/toml.js +281 -0
- package/src/tools.js +291 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动服务:把识别出来的 Gradle 任务跑成"实例",并管住它的生命周期。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不用 `ctx.subprocess` / `ctx.jobs`:两者都会随宿主作用域 dispose 被杀,
|
|
5
|
+
* 而游戏要能活过 DSH 会话(依据见 doc/调研-DSH插件机制.md §6)。
|
|
6
|
+
*
|
|
7
|
+
* 进程模型:
|
|
8
|
+
* - `child_process.spawn({ windowsHide: true })`,输出走管道收进环形缓冲并落盘;
|
|
9
|
+
* - Windows 上**不能** detached(会丢标准输出、还会多开一个控制台),
|
|
10
|
+
* POSIX 上保留 detached 以便按进程组整组终止;
|
|
11
|
+
* - 停止用 `taskkill /PID <pid> /T /F`(Windows)杀整棵树,不留 java 孤儿;
|
|
12
|
+
* - 宿主重启时按 `pid` 探活:探不到就说 `unknown`,不伪造结束原因。
|
|
13
|
+
* @module minecraft-dev-starter/lib/launches
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { createWriteStream } from 'node:fs'
|
|
18
|
+
import { mkdir, readFile } from 'node:fs/promises'
|
|
19
|
+
import { dirname } from 'node:path'
|
|
20
|
+
import { gradlewCommand, isAlive, TASK_PATH_PATTERN } from './argv.js'
|
|
21
|
+
import { loadState, logPath, saveState, stateDir } from './state.js'
|
|
22
|
+
|
|
23
|
+
/** 各目标"就绪"的日志特征。命中即把实例从 starting 置为 running。 */
|
|
24
|
+
const READY_MARKERS = {
|
|
25
|
+
client: [/Backend library: LWJGL/i, /OpenAL initialized/i, /\[Render thread\/INFO\]/],
|
|
26
|
+
server: [/Done \([0-9.]+s\)!/i, /For help, type "help"/i],
|
|
27
|
+
data: [/BUILD SUCCESSFUL/i],
|
|
28
|
+
gameTestServer: [/Done \([0-9.]+s\)!/i, /All required tests passed/i],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 这些特征说明是崩溃而不是正常退出。 */
|
|
32
|
+
const CRASH_MARKERS = [
|
|
33
|
+
/A fatal error has been detected/i,
|
|
34
|
+
/Mixin apply failed/i,
|
|
35
|
+
/Unsupported class file major version/i,
|
|
36
|
+
/No matching toolchains found/i,
|
|
37
|
+
/Could not resolve/i,
|
|
38
|
+
/BUILD FAILED/i,
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 崩溃归因规则。只做"看得懂的一句话",不做自动修复。
|
|
43
|
+
* 命中顺序有讲究:越具体的越靠前。
|
|
44
|
+
*/
|
|
45
|
+
const DIAGNOSES = [
|
|
46
|
+
{ pattern: /Could not resolve|Could not find [\w.:-]+:/, hint: '依赖解析失败:检查网络或 Gradle 离线缓存' },
|
|
47
|
+
{ pattern: /Unsupported class file major version/, hint: 'JDK 版本不匹配:换成工程要求的 JDK' },
|
|
48
|
+
{ pattern: /No matching toolchains found/, hint: '缺少所需版本的 JDK:装一个或用已有版本覆盖' },
|
|
49
|
+
{ pattern: /Address already in use|FAILED TO BIND TO PORT/i, hint: '端口被占用:先关掉占用的进程' },
|
|
50
|
+
{ pattern: /Mixin apply failed/i, hint: 'Mixin 注入失败:看日志里点名的 mixin 配置' },
|
|
51
|
+
{ pattern: /OutOfMemoryError|Metaspace/i, hint: '内存不足:调 gradle.properties 的 org.gradle.jvmargs' },
|
|
52
|
+
{ pattern: /crash-reports[\\/]/, hint: '游戏生成了 crash report:工程目录下的 crash-reports/' },
|
|
53
|
+
{ pattern: /BUILD FAILED/, hint: 'Gradle 构建失败:往上翻第一条 error' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 从日志尾部找一条能给人看的结论。
|
|
58
|
+
* @param {string[]} lines
|
|
59
|
+
* @returns {{ hint: string, line: string } | undefined}
|
|
60
|
+
*/
|
|
61
|
+
export function diagnose(lines) {
|
|
62
|
+
for (const rule of DIAGNOSES) {
|
|
63
|
+
const hit = lines.find((line) => rule.pattern.test(line))
|
|
64
|
+
if (hit !== undefined) return { hint: rule.hint, line: hit.trim().slice(0, 300) }
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {object} LaunchRequest
|
|
71
|
+
* @property {string} root
|
|
72
|
+
* @property {string} taskPath
|
|
73
|
+
* @property {string} label
|
|
74
|
+
* @property {string} kind
|
|
75
|
+
* @property {string} [module]
|
|
76
|
+
* @property {string[]} [extraArgs]
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{ dir?: string, platform?: string, spawnImpl?: typeof spawn, maxLogLines?: number, readyTimeoutMs?: number, commandFor?: typeof gradlewCommand }} [options]
|
|
81
|
+
*/
|
|
82
|
+
export function createLaunchService(options = {}) {
|
|
83
|
+
const dir = options.dir ?? stateDir()
|
|
84
|
+
const platform = options.platform ?? process.platform
|
|
85
|
+
const spawnImpl = options.spawnImpl ?? spawn
|
|
86
|
+
const commandFor = options.commandFor ?? gradlewCommand
|
|
87
|
+
const maxLogLines = options.maxLogLines ?? 2000
|
|
88
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 90_000
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @typedef {object} Runtime
|
|
92
|
+
* @property {string[]} ring
|
|
93
|
+
* @property {boolean} ready
|
|
94
|
+
* @property {NodeJS.Timeout | undefined} readyTimer
|
|
95
|
+
* @property {import('node:child_process').ChildProcess | undefined} child
|
|
96
|
+
*/
|
|
97
|
+
/** @type {Map<string, Runtime>} */
|
|
98
|
+
const runtimes = new Map()
|
|
99
|
+
|
|
100
|
+
/** 串行化状态写入,避免并发 start/stop 互相覆盖。 */
|
|
101
|
+
let chain = Promise.resolve()
|
|
102
|
+
/** @type {import('./state.js').PersistentState | undefined} */
|
|
103
|
+
let state
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @template T
|
|
107
|
+
* @param {() => Promise<T>} fn
|
|
108
|
+
* @returns {Promise<T>}
|
|
109
|
+
*/
|
|
110
|
+
function serialize(fn) {
|
|
111
|
+
const next = chain.then(fn, fn)
|
|
112
|
+
chain = next.then(
|
|
113
|
+
() => undefined,
|
|
114
|
+
() => undefined,
|
|
115
|
+
)
|
|
116
|
+
return next
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function ensureState() {
|
|
120
|
+
if (state !== undefined) return state
|
|
121
|
+
state = await loadState(dir)
|
|
122
|
+
for (const record of state.launches) {
|
|
123
|
+
if (record.status !== 'starting' && record.status !== 'running') continue
|
|
124
|
+
if (isAlive(record.pid)) continue
|
|
125
|
+
// 宿主重启后进程没了:不猜它怎么结束的
|
|
126
|
+
record.status = 'unknown'
|
|
127
|
+
record.endedAt = new Date().toISOString()
|
|
128
|
+
}
|
|
129
|
+
await saveState(dir, state)
|
|
130
|
+
return state
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 找这个工程当前活跃的实例。
|
|
135
|
+
* @param {string} root
|
|
136
|
+
*/
|
|
137
|
+
function activeFor(root) {
|
|
138
|
+
return (state?.launches ?? []).find(
|
|
139
|
+
(record) => record.root.toLowerCase() === root.toLowerCase() && (record.status === 'starting' || record.status === 'running'),
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {LaunchRequest} request
|
|
145
|
+
*/
|
|
146
|
+
async function start(request) {
|
|
147
|
+
if (!TASK_PATH_PATTERN.test(request.taskPath)) {
|
|
148
|
+
throw Object.assign(new Error(`不认识的 Gradle 任务:${request.taskPath}`), { code: 'BAD_TASK' })
|
|
149
|
+
}
|
|
150
|
+
const current = await ensureState()
|
|
151
|
+
return serialize(async () => {
|
|
152
|
+
const running = activeFor(request.root)
|
|
153
|
+
if (running !== undefined) {
|
|
154
|
+
throw Object.assign(new Error(`${request.root} 已有运行中的实例:${running.taskPath}`), {
|
|
155
|
+
code: 'ALREADY_RUNNING',
|
|
156
|
+
launch: running,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const id = `launch-${current.nextLaunch}`
|
|
161
|
+
current.nextLaunch += 1
|
|
162
|
+
const file = logPath(dir, id)
|
|
163
|
+
await mkdir(dirname(file), { recursive: true })
|
|
164
|
+
|
|
165
|
+
/** @type {import('./state.js').LaunchRecord} */
|
|
166
|
+
const record = {
|
|
167
|
+
id,
|
|
168
|
+
root: request.root,
|
|
169
|
+
taskPath: request.taskPath,
|
|
170
|
+
label: request.label,
|
|
171
|
+
kind: request.kind,
|
|
172
|
+
module: request.module ?? '.',
|
|
173
|
+
pid: null,
|
|
174
|
+
startedAt: new Date().toISOString(),
|
|
175
|
+
endedAt: null,
|
|
176
|
+
status: 'starting',
|
|
177
|
+
outcome: null,
|
|
178
|
+
exitCode: null,
|
|
179
|
+
signal: null,
|
|
180
|
+
logPath: file,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const command = commandFor({ platform, projectDir: request.root, taskPath: request.taskPath, extraArgs: request.extraArgs })
|
|
184
|
+
/** @type {Runtime} */
|
|
185
|
+
const runtime = { ring: [], ready: false, readyTimer: undefined, child: undefined }
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const child = spawnImpl(command.argv[0], command.argv.slice(1), {
|
|
189
|
+
cwd: command.cwd,
|
|
190
|
+
windowsHide: true,
|
|
191
|
+
windowsVerbatimArguments: command.windowsVerbatimArguments,
|
|
192
|
+
// Windows 上不能 detached:DETACHED_PROCESS 与 CREATE_NO_WINDOW 组合后
|
|
193
|
+
// 子进程的标准输出会整个丢掉(实测同一命令 detached 拿到 0 字节、
|
|
194
|
+
// attached 拿到完整输出),顺带还会让 java 自己新开一个可见控制台。
|
|
195
|
+
// POSIX 仍要 detached,否则 `kill(-pid)` 杀不到进程组。
|
|
196
|
+
detached: platform !== 'win32',
|
|
197
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
198
|
+
})
|
|
199
|
+
runtime.child = child
|
|
200
|
+
record.pid = child.pid ?? null
|
|
201
|
+
current.launches.unshift(record)
|
|
202
|
+
if (current.launches.length > 100) current.launches.length = 100
|
|
203
|
+
await saveState(dir, current)
|
|
204
|
+
|
|
205
|
+
const sink = createWriteStream(file, { flags: 'a' })
|
|
206
|
+
attachStream(runtime, record, child.stdout, sink)
|
|
207
|
+
attachStream(runtime, record, child.stderr, sink)
|
|
208
|
+
|
|
209
|
+
runtime.readyTimer = setTimeout(() => {
|
|
210
|
+
if (record.status === 'starting') record.readyTimedOut = true
|
|
211
|
+
}, readyTimeoutMs)
|
|
212
|
+
runtime.readyTimer.unref?.()
|
|
213
|
+
|
|
214
|
+
child.on('exit', (code, signal) => {
|
|
215
|
+
if (runtime.readyTimer !== undefined) clearTimeout(runtime.readyTimer)
|
|
216
|
+
record.pid = null
|
|
217
|
+
record.exitCode = code
|
|
218
|
+
record.signal = signal
|
|
219
|
+
const crashed = code !== 0 && code !== null
|
|
220
|
+
record.outcome = record.stoppedByUser === true ? 'killed' : crashed ? 'crashed' : 'normal'
|
|
221
|
+
record.status = 'exited'
|
|
222
|
+
record.endedAt = new Date().toISOString()
|
|
223
|
+
sink.end()
|
|
224
|
+
void serialize(async () => saveState(dir, current))
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
return { ...record, logPath: file }
|
|
228
|
+
} catch (error) {
|
|
229
|
+
record.status = 'exited'
|
|
230
|
+
record.outcome = 'failed'
|
|
231
|
+
record.endedAt = new Date().toISOString()
|
|
232
|
+
current.launches.unshift(record)
|
|
233
|
+
await saveState(dir, current)
|
|
234
|
+
throw Object.assign(new Error(`启动失败:${error instanceof Error ? error.message : String(error)}`), { code: 'SPAWN_FAILED' })
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 把一路输出接到环形缓冲、就绪判定与日志文件。
|
|
241
|
+
*
|
|
242
|
+
* 按字节切行再逐行解码:中文 Windows 上 Gradle 与游戏可能输出 GBK,
|
|
243
|
+
* 逐块 toString('utf8') 会把多字节字符切坏,逐行才有机会回退到 GBK。
|
|
244
|
+
* @param {Runtime} runtime
|
|
245
|
+
* @param {import('./state.js').LaunchRecord} record
|
|
246
|
+
* @param {NodeJS.ReadableStream | null} stream
|
|
247
|
+
* @param {import('node:fs').WriteStream} sink
|
|
248
|
+
*/
|
|
249
|
+
function attachStream(runtime, record, stream, sink) {
|
|
250
|
+
if (stream === null) return
|
|
251
|
+
/** @type {Buffer} */
|
|
252
|
+
let partial = Buffer.alloc(0)
|
|
253
|
+
stream.on('data', (chunk) => {
|
|
254
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
255
|
+
sink.write(buffer)
|
|
256
|
+
partial = partial.length === 0 ? buffer : Buffer.concat([partial, buffer])
|
|
257
|
+
let index = partial.indexOf(0x0a)
|
|
258
|
+
while (index >= 0) {
|
|
259
|
+
const line = partial.subarray(0, index)
|
|
260
|
+
partial = partial.subarray(index + 1)
|
|
261
|
+
pushLine(runtime, record, decodeLine(line))
|
|
262
|
+
index = partial.indexOf(0x0a)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
stream.on('end', () => {
|
|
266
|
+
if (partial.length > 0) pushLine(runtime, record, decodeLine(partial))
|
|
267
|
+
partial = Buffer.alloc(0)
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @param {Runtime} runtime
|
|
273
|
+
* @param {import('./state.js').LaunchRecord} record
|
|
274
|
+
* @param {string} line
|
|
275
|
+
*/
|
|
276
|
+
function pushLine(runtime, record, line) {
|
|
277
|
+
runtime.ring.push(line)
|
|
278
|
+
if (runtime.ring.length > maxLogLines) runtime.ring.shift()
|
|
279
|
+
if (runtime.ready || record.status !== 'starting') return
|
|
280
|
+
const markers = READY_MARKERS[/** @type {keyof typeof READY_MARKERS} */ (record.kind)] ?? READY_MARKERS.server
|
|
281
|
+
if (markers.some((pattern) => pattern.test(line))) {
|
|
282
|
+
runtime.ready = true
|
|
283
|
+
record.status = 'running'
|
|
284
|
+
if (runtime.readyTimer !== undefined) clearTimeout(runtime.readyTimer)
|
|
285
|
+
void serialize(async () => saveState(dir, state))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {string} launchId
|
|
291
|
+
* @param {{ graceMs?: number }} [request]
|
|
292
|
+
*/
|
|
293
|
+
async function stop(launchId, request = {}) {
|
|
294
|
+
const current = await ensureState()
|
|
295
|
+
const record = current.launches.find((entry) => entry.id === launchId)
|
|
296
|
+
if (record === undefined) throw Object.assign(new Error(`没有这个实例:${launchId}`), { code: 'UNKNOWN_LAUNCH' })
|
|
297
|
+
|
|
298
|
+
record.stoppedByUser = true
|
|
299
|
+
if (record.pid === null || !isAlive(record.pid)) {
|
|
300
|
+
if (record.status === 'starting' || record.status === 'running') {
|
|
301
|
+
record.status = 'unknown'
|
|
302
|
+
record.endedAt = new Date().toISOString()
|
|
303
|
+
}
|
|
304
|
+
await serialize(async () => saveState(dir, current))
|
|
305
|
+
return { ...record }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
await killTree(record.pid, platform, request.graceMs ?? 8000)
|
|
309
|
+
return { ...record }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @returns {Promise<Array<Record<string, unknown>>>} */
|
|
313
|
+
async function list() {
|
|
314
|
+
const current = await ensureState()
|
|
315
|
+
return current.launches.map((record) => snapshot(record))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @param {string} root
|
|
320
|
+
* @returns {Promise<Record<string, unknown> | undefined>}
|
|
321
|
+
*/
|
|
322
|
+
async function current(root) {
|
|
323
|
+
const currentState = await ensureState()
|
|
324
|
+
const record = currentState.launches.find(
|
|
325
|
+
(entry) => entry.root.toLowerCase() === root.toLowerCase() && (entry.status === 'starting' || entry.status === 'running'),
|
|
326
|
+
)
|
|
327
|
+
return record === undefined ? undefined : snapshot(record)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 读日志:文件是权威来源(宿主重启后内存缓冲就没了)。
|
|
332
|
+
* @param {string} launchId
|
|
333
|
+
* @param {number} offset 起始行号(0 起)
|
|
334
|
+
* @param {number} limit
|
|
335
|
+
*/
|
|
336
|
+
async function logs(launchId, offset = 0, limit = 400) {
|
|
337
|
+
const currentState = await ensureState()
|
|
338
|
+
const record = currentState.launches.find((entry) => entry.id === launchId)
|
|
339
|
+
if (record === undefined) throw Object.assign(new Error(`没有这个实例:${launchId}`), { code: 'UNKNOWN_LAUNCH' })
|
|
340
|
+
let all = []
|
|
341
|
+
try {
|
|
342
|
+
all = splitBufferLines(await readFile(record.logPath))
|
|
343
|
+
} catch {
|
|
344
|
+
all = []
|
|
345
|
+
}
|
|
346
|
+
const lines = all.slice(Math.max(0, offset), Math.max(0, offset) + limit)
|
|
347
|
+
const abnormal = record.outcome !== null && record.outcome !== 'normal'
|
|
348
|
+
return {
|
|
349
|
+
launchId,
|
|
350
|
+
lines,
|
|
351
|
+
nextOffset: Math.max(0, offset) + lines.length,
|
|
352
|
+
total: all.length,
|
|
353
|
+
status: record.status,
|
|
354
|
+
outcome: record.outcome ?? null,
|
|
355
|
+
exitCode: record.exitCode ?? null,
|
|
356
|
+
readyTimedOut: record.readyTimedOut === true,
|
|
357
|
+
startedAt: record.startedAt,
|
|
358
|
+
endedAt: record.endedAt ?? null,
|
|
359
|
+
label: record.label,
|
|
360
|
+
taskPath: record.taskPath,
|
|
361
|
+
root: record.root,
|
|
362
|
+
diagnosis: abnormal ? diagnose(all.slice(-300)) : undefined,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {import('./state.js').LaunchRecord} record
|
|
368
|
+
*/
|
|
369
|
+
function snapshot(record) {
|
|
370
|
+
const running = record.status === 'starting' || record.status === 'running'
|
|
371
|
+
const runtime = runtimes.get(record.id)
|
|
372
|
+
return {
|
|
373
|
+
id: record.id,
|
|
374
|
+
root: record.root,
|
|
375
|
+
taskPath: record.taskPath,
|
|
376
|
+
label: record.label,
|
|
377
|
+
kind: record.kind,
|
|
378
|
+
module: record.module,
|
|
379
|
+
pid: record.pid,
|
|
380
|
+
status: record.status,
|
|
381
|
+
outcome: record.outcome ?? null,
|
|
382
|
+
exitCode: record.exitCode ?? null,
|
|
383
|
+
startedAt: record.startedAt,
|
|
384
|
+
endedAt: record.endedAt ?? null,
|
|
385
|
+
logPath: record.logPath,
|
|
386
|
+
readyTimedOut: record.readyTimedOut === true,
|
|
387
|
+
tail: runtime?.ring.slice(-8) ?? (running ? [] : lastLinesFromMarker(record)),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 已结束的实例没有内存缓冲时,用日志尾部给界面一个摘要。
|
|
393
|
+
* @param {import('./state.js').LaunchRecord} record
|
|
394
|
+
* @returns {string[]}
|
|
395
|
+
*/
|
|
396
|
+
function lastLinesFromMarker(record) {
|
|
397
|
+
return crashSummary(record) === undefined ? [] : [crashSummary(record) ?? '']
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 粗粒度归因:M3 会做成规则表,这里先给一条能看懂的话。
|
|
402
|
+
* @param {import('./state.js').LaunchRecord} record
|
|
403
|
+
* @returns {string | undefined}
|
|
404
|
+
*/
|
|
405
|
+
function crashSummary(record) {
|
|
406
|
+
if (record.outcome === 'normal') return undefined
|
|
407
|
+
if (record.exitCode === null) return undefined
|
|
408
|
+
return `退出码 ${record.exitCode}`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { start, stop, list, current, logs, ensureState, stateDir: dir }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 杀整棵进程树。
|
|
416
|
+
* @param {number} pid
|
|
417
|
+
* @param {string} platform
|
|
418
|
+
* @param {number} graceMs
|
|
419
|
+
*/
|
|
420
|
+
async function killTree(pid, platform, graceMs) {
|
|
421
|
+
if (platform === 'win32') {
|
|
422
|
+
await new Promise((resolve) => {
|
|
423
|
+
const child = spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { windowsHide: true })
|
|
424
|
+
child.on('exit', () => resolve(undefined))
|
|
425
|
+
child.on('error', () => resolve(undefined))
|
|
426
|
+
})
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
process.kill(-pid, 'SIGTERM')
|
|
431
|
+
} catch {
|
|
432
|
+
try {
|
|
433
|
+
process.kill(pid, 'SIGTERM')
|
|
434
|
+
} catch {
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(graceMs, 3000)))
|
|
439
|
+
if (isAlive(pid)) {
|
|
440
|
+
try {
|
|
441
|
+
process.kill(-pid, 'SIGKILL')
|
|
442
|
+
} catch {
|
|
443
|
+
try {
|
|
444
|
+
process.kill(pid, 'SIGKILL')
|
|
445
|
+
} catch {
|
|
446
|
+
/* 已经没了 */
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** 供测试与调用方复用。 */
|
|
453
|
+
export { READY_MARKERS, CRASH_MARKERS, DIAGNOSES }
|
|
454
|
+
|
|
455
|
+
/** GBK 解码器;运行时不带完整 ICU 时为 undefined,此时只能退回 UTF-8。 */
|
|
456
|
+
const GBK_DECODER = (() => {
|
|
457
|
+
try {
|
|
458
|
+
return new TextDecoder('gbk')
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined
|
|
461
|
+
}
|
|
462
|
+
})()
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 一行输出 → 文本:先按 UTF-8,出现替换字符时回退 GBK(中文 Windows 的 Gradle 常见)。
|
|
466
|
+
* @param {Buffer} buffer
|
|
467
|
+
* @returns {string}
|
|
468
|
+
*/
|
|
469
|
+
export function decodeLine(buffer) {
|
|
470
|
+
const text = buffer.toString('utf8').replace(/\r$/, '')
|
|
471
|
+
if (!text.includes('\uFFFD') || GBK_DECODER === undefined) return text
|
|
472
|
+
try {
|
|
473
|
+
return GBK_DECODER.decode(buffer).replace(/\r$/, '')
|
|
474
|
+
} catch {
|
|
475
|
+
return text
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 整个文件的字节 → 按行解码(逐行才有机会对 GBK 回退)。
|
|
481
|
+
* @param {Buffer} buffer
|
|
482
|
+
* @returns {string[]}
|
|
483
|
+
*/
|
|
484
|
+
export function splitBufferLines(buffer) {
|
|
485
|
+
/** @type {string[]} */
|
|
486
|
+
const lines = []
|
|
487
|
+
let start = 0
|
|
488
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
489
|
+
if (buffer[index] !== 0x0a) continue
|
|
490
|
+
lines.push(decodeLine(buffer.subarray(start, index)))
|
|
491
|
+
start = index + 1
|
|
492
|
+
}
|
|
493
|
+
if (start < buffer.length) lines.push(decodeLine(buffer.subarray(start)))
|
|
494
|
+
return lines
|
|
495
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 读模组/插件元数据文件并解析成统一形状。
|
|
3
|
+
*
|
|
4
|
+
* 查找路径覆盖三种真实形态(本机 17 个工程实测):
|
|
5
|
+
* - `src/main/resources/...`(最常见)
|
|
6
|
+
* - `src/main/templates/...`(NeoForge 的 processResources 模板,占位符还没展开)
|
|
7
|
+
* - 子模块里各自一套(Architectury)
|
|
8
|
+
*
|
|
9
|
+
* @module minecraft-dev-starter/lib/metadata
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { parseToml } from './toml.js'
|
|
14
|
+
import { readText } from './text.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} MetadataHit
|
|
18
|
+
* @property {'fabric' | 'forge' | 'neoforge' | 'mcmod' | 'bukkit' | 'paper'} kind
|
|
19
|
+
* @property {string} file 相对于模块目录的路径
|
|
20
|
+
* @property {Record<string, unknown>} data
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const LOCATIONS = [
|
|
24
|
+
['fabric', 'src/main/resources/fabric.mod.json'],
|
|
25
|
+
['fabric', 'src/main/templates/fabric.mod.json'],
|
|
26
|
+
['neoforge', 'src/main/resources/META-INF/neoforge.mods.toml'],
|
|
27
|
+
['neoforge', 'src/main/templates/META-INF/neoforge.mods.toml'],
|
|
28
|
+
['forge', 'src/main/resources/META-INF/mods.toml'],
|
|
29
|
+
['forge', 'src/main/templates/META-INF/mods.toml'],
|
|
30
|
+
['mcmod', 'src/main/resources/mcmod.info'],
|
|
31
|
+
['mcmod', 'src/main/templates/mcmod.info'],
|
|
32
|
+
['paper', 'src/main/resources/paper-plugin.yml'],
|
|
33
|
+
['paper', 'src/main/templates/paper-plugin.yml'],
|
|
34
|
+
['bukkit', 'src/main/resources/plugin.yml'],
|
|
35
|
+
['bukkit', 'src/main/templates/plugin.yml'],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 扫一个模块目录下所有元数据文件。
|
|
40
|
+
* @param {string} moduleDir
|
|
41
|
+
* @returns {Promise<MetadataHit[]>}
|
|
42
|
+
*/
|
|
43
|
+
export async function locateMetadata(moduleDir) {
|
|
44
|
+
/** @type {MetadataHit[]} */
|
|
45
|
+
const hits = []
|
|
46
|
+
for (const [kind, rel] of LOCATIONS) {
|
|
47
|
+
const text = await readText(join(moduleDir, rel))
|
|
48
|
+
if (text === undefined) continue
|
|
49
|
+
const data = parseMetadataText(kind, text)
|
|
50
|
+
if (data !== undefined) hits.push({ kind, file: rel, data })
|
|
51
|
+
}
|
|
52
|
+
return hits
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {MetadataHit['kind']} kind
|
|
57
|
+
* @param {string} text
|
|
58
|
+
* @returns {Record<string, unknown> | undefined}
|
|
59
|
+
*/
|
|
60
|
+
function parseMetadataText(kind, text) {
|
|
61
|
+
try {
|
|
62
|
+
if (kind === 'fabric' || kind === 'mcmod') {
|
|
63
|
+
const parsed = JSON.parse(text)
|
|
64
|
+
if (kind === 'mcmod' && Array.isArray(parsed)) {
|
|
65
|
+
return /** @type {Record<string, unknown>} */ (parsed[0] ?? {})
|
|
66
|
+
}
|
|
67
|
+
return /** @type {Record<string, unknown>} */ (parsed)
|
|
68
|
+
}
|
|
69
|
+
if (kind === 'forge' || kind === 'neoforge') return parseModsToml(text)
|
|
70
|
+
return parseYamlSubset(text)
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `mods.toml` / `neoforge.mods.toml`。
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @returns {Record<string, unknown>}
|
|
80
|
+
*/
|
|
81
|
+
export function parseModsToml(text) {
|
|
82
|
+
const root = parseToml(text)
|
|
83
|
+
const mods = Array.isArray(root.mods) ? root.mods : []
|
|
84
|
+
const first = /** @type {Record<string, unknown>} */ (mods[0] ?? {})
|
|
85
|
+
/** @type {Array<{ modId?: string, versionRange?: string }>} */
|
|
86
|
+
const dependencies = []
|
|
87
|
+
const deps = root.dependencies
|
|
88
|
+
if (deps !== null && typeof deps === 'object' && !Array.isArray(deps)) {
|
|
89
|
+
for (const value of Object.values(/** @type {Record<string, unknown>} */ (deps))) {
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
for (const dep of value) {
|
|
92
|
+
if (dep !== null && typeof dep === 'object') {
|
|
93
|
+
dependencies.push(/** @type {{ modId?: string, versionRange?: string }} */ (dep))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
modLoader: root.modLoader,
|
|
101
|
+
loaderVersion: root.loaderVersion,
|
|
102
|
+
license: root.license,
|
|
103
|
+
modId: first.modId,
|
|
104
|
+
version: first.version,
|
|
105
|
+
displayName: first.displayName,
|
|
106
|
+
description: first.description,
|
|
107
|
+
dependencies,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 极简 YAML:只取顶层 `key: value`,够读 plugin.yml。
|
|
113
|
+
* @param {string} text
|
|
114
|
+
* @returns {Record<string, unknown>}
|
|
115
|
+
*/
|
|
116
|
+
export function parseYamlSubset(text) {
|
|
117
|
+
/** @type {Record<string, unknown>} */
|
|
118
|
+
const out = {}
|
|
119
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
120
|
+
if (/^\s/.test(raw)) continue // 嵌套结构不关心
|
|
121
|
+
const line = raw.trim()
|
|
122
|
+
if (line === '' || line.startsWith('#')) continue
|
|
123
|
+
const colon = line.indexOf(':')
|
|
124
|
+
if (colon <= 0) continue
|
|
125
|
+
const key = line.slice(0, colon).trim()
|
|
126
|
+
let value = line.slice(colon + 1).trim()
|
|
127
|
+
if (value.startsWith("'") || value.startsWith('"')) value = value.slice(1, -1)
|
|
128
|
+
value = value.replace(/\s+#.*$/, '')
|
|
129
|
+
out[key] = value
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 从一组命中里挑能代表工程的元数据(优先级:neoforge > forge > fabric > mcmod > paper > bukkit)。
|
|
136
|
+
* @param {MetadataHit[]} hits
|
|
137
|
+
* @returns {MetadataHit | undefined}
|
|
138
|
+
*/
|
|
139
|
+
export function primaryMetadata(hits) {
|
|
140
|
+
const order = ['neoforge', 'forge', 'fabric', 'mcmod', 'paper', 'bukkit']
|
|
141
|
+
for (const kind of order) {
|
|
142
|
+
const hit = hits.find((entry) => entry.kind === kind)
|
|
143
|
+
if (hit) return hit
|
|
144
|
+
}
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 把元数据里的 `depends` / `dependencies` 拉平成 `modId → 版本范围`。
|
|
150
|
+
* @param {MetadataHit | undefined} hit
|
|
151
|
+
* @returns {Record<string, string>}
|
|
152
|
+
*/
|
|
153
|
+
export function dependencyRanges(hit) {
|
|
154
|
+
/** @type {Record<string, string>} */
|
|
155
|
+
const out = {}
|
|
156
|
+
if (!hit) return out
|
|
157
|
+
const data = hit.data
|
|
158
|
+
const depends = data.depends
|
|
159
|
+
if (depends !== null && typeof depends === 'object' && !Array.isArray(depends)) {
|
|
160
|
+
for (const [key, value] of Object.entries(/** @type {Record<string, unknown>} */ (depends))) {
|
|
161
|
+
out[key] = typeof value === 'string' ? value : String(value)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const deps = data.dependencies
|
|
165
|
+
if (Array.isArray(deps)) {
|
|
166
|
+
for (const dep of deps) {
|
|
167
|
+
if (dep === null || typeof dep !== 'object') continue
|
|
168
|
+
const record = /** @type {{ modId?: unknown, versionRange?: unknown }} */ (dep)
|
|
169
|
+
if (typeof record.modId === 'string' && typeof record.versionRange === 'string') {
|
|
170
|
+
out[record.modId] = record.versionRange
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|