cortico 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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +43 -0
- package/src/boot.ts +46 -0
- package/src/bot.ts +1342 -0
- package/src/config-file.ts +41 -0
- package/src/core/README.md +92 -0
- package/src/core/billing.ts +26 -0
- package/src/core/blobs.ts +106 -0
- package/src/core/bus.ts +311 -0
- package/src/core/config-schema.ts +201 -0
- package/src/core/config.ts +262 -0
- package/src/core/core.ts +699 -0
- package/src/core/cost.ts +244 -0
- package/src/core/event-store.ts +304 -0
- package/src/core/fork.ts +113 -0
- package/src/core/generation.ts +118 -0
- package/src/core/instance-lock.ts +215 -0
- package/src/core/ipc-logger.ts +105 -0
- package/src/core/language.ts +62 -0
- package/src/core/log-context.ts +38 -0
- package/src/core/loop.ts +1858 -0
- package/src/core/markers.ts +48 -0
- package/src/core/prefix.ts +121 -0
- package/src/core/run.ts +95 -0
- package/src/core/secrets.ts +18 -0
- package/src/core/session.ts +56 -0
- package/src/core/sessions.ts +261 -0
- package/src/core/state.ts +104 -0
- package/src/core/template.ts +72 -0
- package/src/core/timers.ts +129 -0
- package/src/core/tool-log.ts +135 -0
- package/src/core/transcript.ts +92 -0
- package/src/core/truncate.ts +121 -0
- package/src/core/types.ts +1140 -0
- package/src/core/usage-log.ts +79 -0
- package/src/core/util.ts +386 -0
- package/src/deploy-listing.ts +160 -0
- package/src/deploy.ts +162 -0
- package/src/extensions/README.md +72 -0
- package/src/extensions/dry-mount.ts +390 -0
- package/src/extensions/manifest.ts +153 -0
- package/src/extensions/runtime.ts +42 -0
- package/src/extensions.ts +519 -0
- package/src/launcher.ts +285 -0
- package/src/paths.ts +155 -0
- package/src/protocol/open-responses/LICENSE +201 -0
- package/src/protocol/open-responses/README.md +14 -0
- package/src/protocol/open-responses/context-helpers.ts +27 -0
- package/src/protocol/open-responses/context-log.ts +59 -0
- package/src/protocol/open-responses/context.ts +89 -0
- package/src/protocol/open-responses/generated.ts +115 -0
- package/src/protocol/open-responses/index.ts +33 -0
- package/src/protocol/open-responses/openapi.json +4230 -0
- package/src/protocol/open-responses/stream.ts +146 -0
- package/src/protocol/open-responses/tokens.ts +31 -0
- package/src/providers/README.md +86 -0
- package/src/providers/base.ts +111 -0
- package/src/providers/configuration.ts +97 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/settings.ts +443 -0
- package/src/providers/console/strings.ts +74 -0
- package/src/providers/console/types.ts +11 -0
- package/src/providers/llamacpp/archive.ts +148 -0
- package/src/providers/llamacpp/catalog.ts +247 -0
- package/src/providers/llamacpp/config.ts +36 -0
- package/src/providers/llamacpp/console/client.ts +6 -0
- package/src/providers/llamacpp/console/models-panel.ts +120 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
- package/src/providers/llamacpp/console/server.ts +153 -0
- package/src/providers/llamacpp/index.ts +95 -0
- package/src/providers/llamacpp/native.ts +51 -0
- package/src/providers/llamacpp/options.ts +117 -0
- package/src/providers/llamacpp/runtime-store.ts +139 -0
- package/src/providers/llamacpp/runtime.ts +214 -0
- package/src/providers/llamacpp/server.ts +286 -0
- package/src/providers/llamacpp/strings.ts +230 -0
- package/src/providers/openai-responses-compat/config.ts +20 -0
- package/src/providers/openai-responses-compat/index.ts +89 -0
- package/src/providers/openai-responses-compat/native.ts +127 -0
- package/src/providers/openai-responses-compat/strings.ts +19 -0
- package/src/providers/pricebook.ts +75 -0
- package/src/providers/registry.ts +137 -0
- package/src/providers/strings.ts +71 -0
- package/src/providers/transport/chat.ts +156 -0
- package/src/providers/transport/errors.ts +48 -0
- package/src/providers/transport/history.ts +68 -0
- package/src/providers/transport/native-input.ts +83 -0
- package/src/providers/transport/native-types.ts +13 -0
- package/src/providers/transport/response-assembly.ts +191 -0
- package/src/providers/transport/response-http.ts +196 -0
- package/src/providers/transport/response-meters.ts +27 -0
- package/src/providers/transport/responses-input.ts +56 -0
- package/src/web/README.md +84 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
- package/src/web/client/console-pages/builtins.ts +10 -0
- package/src/web/client/console-pages/context.ts +135 -0
- package/src/web/client/console-pages/host.ts +633 -0
- package/src/web/client/console-pages/loader.ts +132 -0
- package/src/web/client/console-pages/strings.ts +90 -0
- package/src/web/client/console-pages/tools/strings.ts +49 -0
- package/src/web/client/console-pages/tools/view.ts +215 -0
- package/src/web/client/core/api.ts +200 -0
- package/src/web/client/core/language.ts +56 -0
- package/src/web/client/core/lifecycle.ts +132 -0
- package/src/web/client/core/router.ts +199 -0
- package/src/web/client/core/stream.ts +157 -0
- package/src/web/client/core/websocket.ts +82 -0
- package/src/web/client/features/appearance/index.ts +333 -0
- package/src/web/client/features/appearance/strings.ts +87 -0
- package/src/web/client/features/config/index.ts +11 -0
- package/src/web/client/features/config/strings.ts +41 -0
- package/src/web/client/features/config/view.ts +428 -0
- package/src/web/client/features/core/events.ts +197 -0
- package/src/web/client/features/core/index.ts +254 -0
- package/src/web/client/features/core/run.ts +56 -0
- package/src/web/client/features/core/runlog.ts +214 -0
- package/src/web/client/features/core/sessions.ts +53 -0
- package/src/web/client/features/core/strings.ts +157 -0
- package/src/web/client/features/extensions/index.ts +422 -0
- package/src/web/client/features/extensions/strings.ts +164 -0
- package/src/web/client/features/feature.ts +93 -0
- package/src/web/client/features/live/context.ts +280 -0
- package/src/web/client/features/live/fork.ts +156 -0
- package/src/web/client/features/live/index.ts +392 -0
- package/src/web/client/features/live/onboarding.ts +113 -0
- package/src/web/client/features/live/protocol.ts +122 -0
- package/src/web/client/features/live/sessions.ts +71 -0
- package/src/web/client/features/live/status.ts +68 -0
- package/src/web/client/features/live/strings.ts +252 -0
- package/src/web/client/features/live/timeline.ts +590 -0
- package/src/web/client/features/prompts/editor.ts +292 -0
- package/src/web/client/features/prompts/index.ts +346 -0
- package/src/web/client/features/prompts/strings.ts +136 -0
- package/src/web/client/features/prompts/view.ts +256 -0
- package/src/web/client/features/providers/index.ts +100 -0
- package/src/web/client/features/providers/strings.ts +23 -0
- package/src/web/client/features/settings/general.ts +28 -0
- package/src/web/client/features/settings/index.ts +82 -0
- package/src/web/client/features/settings/strings.ts +33 -0
- package/src/web/client/features/storage/index.ts +1 -0
- package/src/web/client/features/storage/strings.ts +45 -0
- package/src/web/client/features/storage/view.ts +154 -0
- package/src/web/client/features/usage/chart.ts +486 -0
- package/src/web/client/features/usage/index.ts +407 -0
- package/src/web/client/features/usage/labels.ts +46 -0
- package/src/web/client/features/usage/range.ts +36 -0
- package/src/web/client/features/usage/state.ts +37 -0
- package/src/web/client/features/usage/strings.ts +191 -0
- package/src/web/client/features/usage/tooltip.ts +86 -0
- package/src/web/client/features/usage/types.ts +96 -0
- package/src/web/client/features/worlds/index.ts +424 -0
- package/src/web/client/features/worlds/strings.ts +121 -0
- package/src/web/client/main.ts +273 -0
- package/src/web/client/shell/avatar.ts +233 -0
- package/src/web/client/shell/index.ts +549 -0
- package/src/web/client/shell/strings.ts +89 -0
- package/src/web/client/theme/handoff.ts +43 -0
- package/src/web/client/theme/palette.ts +109 -0
- package/src/web/client/theme/registry.ts +130 -0
- package/src/web/client/theme/storage.ts +72 -0
- package/src/web/client/theme/strings.ts +123 -0
- package/src/web/client/theme/studio.ts +366 -0
- package/src/web/client/ui/actions.ts +109 -0
- package/src/web/client/ui/data.ts +185 -0
- package/src/web/client/ui/dom.ts +23 -0
- package/src/web/client/ui/fields.ts +191 -0
- package/src/web/client/ui/format.ts +75 -0
- package/src/web/client/ui/icons.ts +183 -0
- package/src/web/client/ui/images.ts +91 -0
- package/src/web/client/ui/index.ts +89 -0
- package/src/web/client/ui/lamp.ts +125 -0
- package/src/web/client/ui/log.ts +98 -0
- package/src/web/client/ui/overlay.ts +184 -0
- package/src/web/client/ui/page.ts +8 -0
- package/src/web/client/ui/prompt-input.tsx +311 -0
- package/src/web/client/ui/sheet.ts +131 -0
- package/src/web/client/ui/strings.ts +71 -0
- package/src/web/console-pages.ts +367 -0
- package/src/web/files.ts +107 -0
- package/src/web/path-picker.ts +296 -0
- package/src/web/public/index.html +17 -0
- package/src/web/public/styles.css +1715 -0
- package/src/web/server.ts +1883 -0
- package/src/web/shared/client-panel.ts +603 -0
- package/src/web/shared/console-protocol.ts +560 -0
- package/src/web/shared/css.d.ts +1 -0
- package/src/web/shared/path-picker.ts +23 -0
- package/src/web/shared/theme.ts +256 -0
- package/src/web/theme-store.ts +37 -0
- package/src/world.ts +449 -0
- package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
- package/src/worlds/bilibili/README.md +176 -0
- package/src/worlds/bilibili/audience-admission.ts +995 -0
- package/src/worlds/bilibili/client.ts +528 -0
- package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
- package/src/worlds/bilibili/config.ts +185 -0
- package/src/worlds/bilibili/console/client.ts +202 -0
- package/src/worlds/bilibili/definition.ts +35 -0
- package/src/worlds/bilibili/gift-frame.ts +131 -0
- package/src/worlds/bilibili/normalize.ts +452 -0
- package/src/worlds/bilibili/overlay/announcement.ts +76 -0
- package/src/worlds/bilibili/overlay/assets.ts +82 -0
- package/src/worlds/bilibili/overlay/model.ts +494 -0
- package/src/worlds/bilibili/overlay/project.ts +202 -0
- package/src/worlds/bilibili/overlay/server.ts +286 -0
- package/src/worlds/bilibili/overlay/types.ts +217 -0
- package/src/worlds/bilibili/overlay/web/app.js +690 -0
- package/src/worlds/bilibili/overlay/web/editor.css +297 -0
- package/src/worlds/bilibili/overlay/web/editor.html +102 -0
- package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
- package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
- package/src/worlds/bilibili/overlay/web/styles.css +258 -0
- package/src/worlds/bilibili/protobuf.ts +123 -0
- package/src/worlds/bilibili/wire.ts +94 -0
- package/src/worlds/bilibili/world.ts +1389 -0
- package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
- package/src/worlds/console-fixture/console/client.ts +72 -0
- package/src/worlds/console-fixture/world.ts +73 -0
- package/src/worlds/index.ts +15 -0
- package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
- package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
- package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
- package/src/worlds/minecraft/README.md +917 -0
- package/src/worlds/minecraft/blueprint-plan.ts +915 -0
- package/src/worlds/minecraft/blueprint-registry.ts +566 -0
- package/src/worlds/minecraft/blueprint-repair.ts +236 -0
- package/src/worlds/minecraft/blueprint-resource.ts +272 -0
- package/src/worlds/minecraft/blueprint.ts +492 -0
- package/src/worlds/minecraft/body-lease.ts +340 -0
- package/src/worlds/minecraft/bridge.ts +749 -0
- package/src/worlds/minecraft/check.ts +651 -0
- package/src/worlds/minecraft/chests.ts +292 -0
- package/src/worlds/minecraft/client-launch.ts +272 -0
- package/src/worlds/minecraft/client-options.ts +85 -0
- package/src/worlds/minecraft/client-skins.ts +164 -0
- package/src/worlds/minecraft/client-window.ps1 +91 -0
- package/src/worlds/minecraft/client.ts +423 -0
- package/src/worlds/minecraft/combat-context.ts +109 -0
- package/src/worlds/minecraft/combat.ts +1552 -0
- package/src/worlds/minecraft/config.ts +499 -0
- package/src/worlds/minecraft/console/access.ts +218 -0
- package/src/worlds/minecraft/console/client.ts +244 -0
- package/src/worlds/minecraft/console/log.ts +110 -0
- package/src/worlds/minecraft/console/mount.ts +420 -0
- package/src/worlds/minecraft/console/skin.ts +257 -0
- package/src/worlds/minecraft/console/style.css +154 -0
- package/src/worlds/minecraft/console/world.ts +449 -0
- package/src/worlds/minecraft/deaths.ts +135 -0
- package/src/worlds/minecraft/definition.ts +18 -0
- package/src/worlds/minecraft/engine-child.ts +274 -0
- package/src/worlds/minecraft/engine-ipc.ts +98 -0
- package/src/worlds/minecraft/entity-facts.ts +132 -0
- package/src/worlds/minecraft/escape.ts +407 -0
- package/src/worlds/minecraft/executor.ts +14494 -0
- package/src/worlds/minecraft/explored.ts +156 -0
- package/src/worlds/minecraft/geometry.ts +205 -0
- package/src/worlds/minecraft/goal-plan.ts +647 -0
- package/src/worlds/minecraft/item-break.ts +126 -0
- package/src/worlds/minecraft/item-facts.ts +146 -0
- package/src/worlds/minecraft/item-pick.ts +67 -0
- package/src/worlds/minecraft/level-dat.ts +176 -0
- package/src/worlds/minecraft/log.ts +132 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
- package/src/worlds/minecraft/names.ts +377 -0
- package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
- package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
- package/src/worlds/minecraft/piglin.ts +49 -0
- package/src/worlds/minecraft/policy.ts +547 -0
- package/src/worlds/minecraft/precheck.ts +655 -0
- package/src/worlds/minecraft/proxy.ts +445 -0
- package/src/worlds/minecraft/ranged.ts +635 -0
- package/src/worlds/minecraft/readouts.ts +102 -0
- package/src/worlds/minecraft/round.ts +26 -0
- package/src/worlds/minecraft/search-observation.ts +92 -0
- package/src/worlds/minecraft/server-config.ts +467 -0
- package/src/worlds/minecraft/server.ts +658 -0
- package/src/worlds/minecraft/show.ts +84 -0
- package/src/worlds/minecraft/skills.ts +1763 -0
- package/src/worlds/minecraft/terrain.ts +1735 -0
- package/src/worlds/minecraft/window.ts +96 -0
- package/src/worlds/minecraft/works.ts +179 -0
- package/src/worlds/minecraft/world.ts +6438 -0
- package/src/worlds/qq/ENV_PROMPT.md +5 -0
- package/src/worlds/qq/config.ts +108 -0
- package/src/worlds/qq/console/client.ts +126 -0
- package/src/worlds/qq/console/events.ts +155 -0
- package/src/worlds/qq/console/gate.ts +232 -0
- package/src/worlds/qq/console/roster.ts +203 -0
- package/src/worlds/qq/console/style.css +65 -0
- package/src/worlds/qq/conversation.ts +28 -0
- package/src/worlds/qq/definition.ts +62 -0
- package/src/worlds/qq/driver.ts +375 -0
- package/src/worlds/qq/history-tools.ts +209 -0
- package/src/worlds/qq/normalize.ts +323 -0
- package/src/worlds/qq/qface-map.ts +282 -0
- package/src/worlds/qq/vision-prompt.ts +27 -0
- package/src/worlds/qq/vision.ts +642 -0
- package/src/worlds/qq/vlm.ts +142 -0
- package/src/worlds/qq/world.ts +1503 -0
- package/src/worlds/terminal/ENV_PROMPT.md +11 -0
- package/src/worlds/terminal/config.ts +9 -0
- package/src/worlds/terminal/definition.ts +14 -0
- package/src/worlds/terminal/world.ts +700 -0
- package/src/worlds/websearch/ENV_PROMPT.md +1 -0
- package/src/worlds/websearch/brave-client.ts +261 -0
- package/src/worlds/websearch/config.ts +75 -0
- package/src/worlds/websearch/definition.ts +20 -0
- package/src/worlds/websearch/world.ts +171 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 通过 CustomSkinLoader 的 LocalSkin 按账号名读取本地 PNG。
|
|
3
|
+
* 所选图片保存在部署 data/ 下,启动前写入每个客户端 gameDir 的 CustomSkinLoader/LocalSkin/skins/。
|
|
4
|
+
* model 设置为 auto,由 CustomSkinLoader 识别 classic 或 slim。
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import type { Logger } from '../../core/types.ts';
|
|
9
|
+
|
|
10
|
+
export type SkinRole = 'bot' | 'player';
|
|
11
|
+
|
|
12
|
+
export interface SkinInfo {
|
|
13
|
+
/** 材质尺寸;64x64 是现行格式,64x32 是 1.8 之前的老皮肤(没有第二层与左臂左腿) */
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
bytes: number;
|
|
17
|
+
at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CSL_DIR = 'CustomSkinLoader';
|
|
21
|
+
const CSL_CONFIG = 'CustomSkinLoader.json';
|
|
22
|
+
const LOCAL_SKIN_PATTERN = 'LocalSkin/skins/{USERNAME}.png';
|
|
23
|
+
|
|
24
|
+
/** 本地皮肤那条 loader 的完整声明;`Legacy` 是 CustomSkinLoader 里"按 URL 模板取图"的那类 */
|
|
25
|
+
const LOCAL_SKIN_ENTRY = {
|
|
26
|
+
name: 'LocalSkin',
|
|
27
|
+
type: 'Legacy',
|
|
28
|
+
checkPNG: false,
|
|
29
|
+
skin: LOCAL_SKIN_PATTERN,
|
|
30
|
+
model: 'auto',
|
|
31
|
+
cape: 'LocalSkin/capes/{USERNAME}.png',
|
|
32
|
+
elytra: 'LocalSkin/elytras/{USERNAME}.png',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** 读取 PNG IHDR 的宽高;文件头不完整或格式不符时返回 null。 */
|
|
36
|
+
export function pngSize(bytes: Uint8Array): { width: number; height: number } | null {
|
|
37
|
+
const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
38
|
+
if (bytes.length < 24) return null;
|
|
39
|
+
for (let i = 0; i < SIG.length; i++) if (bytes[i] !== SIG[i]) return null;
|
|
40
|
+
const u32 = (at: number): number =>
|
|
41
|
+
((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0;
|
|
42
|
+
return { width: u32(16), height: u32(20) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 能不能当皮肤用:PNG,且是 64x64(现行)或 64x32(1.8 之前) */
|
|
46
|
+
export function checkSkinBytes(bytes: Uint8Array): { width: number; height: number } | { error: string } {
|
|
47
|
+
const size = pngSize(bytes);
|
|
48
|
+
if (!size) return { error: '不是 PNG 图片' };
|
|
49
|
+
const { width, height } = size;
|
|
50
|
+
const ok = width === 64 && (height === 64 || height === 32);
|
|
51
|
+
if (!ok) return { error: `皮肤材质得是 64x64(或 1.8 之前的 64x32),这张是 ${width}x${height}` };
|
|
52
|
+
return size;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function storedSkinPath(storeDir: string, role: SkinRole): string {
|
|
56
|
+
return join(storeDir, `minecraft-skin-${role}.png`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readStoredSkin(storeDir: string, role: SkinRole): Buffer | null {
|
|
60
|
+
if (!storeDir) return null;
|
|
61
|
+
const path = storedSkinPath(storeDir, role);
|
|
62
|
+
return existsSync(path) ? readFileSync(path) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function storedSkinInfo(storeDir: string, role: SkinRole): SkinInfo | null {
|
|
66
|
+
const bytes = readStoredSkin(storeDir, role);
|
|
67
|
+
if (!bytes) return null;
|
|
68
|
+
const size = pngSize(bytes);
|
|
69
|
+
if (!size) return null;
|
|
70
|
+
const stat = statSync(storedSkinPath(storeDir, role));
|
|
71
|
+
return { ...size, bytes: bytes.length, at: stat.mtime.toISOString() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function setStoredSkin(storeDir: string, role: SkinRole, bytes: Uint8Array): SkinInfo | { error: string } {
|
|
75
|
+
if (!storeDir) return { error: '没有留底的地方:这份部署没有 data/ 目录' };
|
|
76
|
+
const checked = checkSkinBytes(bytes);
|
|
77
|
+
if ('error' in checked) return checked;
|
|
78
|
+
mkdirSync(storeDir, { recursive: true });
|
|
79
|
+
writeFileSync(storedSkinPath(storeDir, role), bytes);
|
|
80
|
+
return { ...checked, bytes: bytes.length, at: new Date().toISOString() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 删除保存的皮肤并返回原字节,供调用方核对客户端文件内容。 */
|
|
84
|
+
export function clearStoredSkin(storeDir: string, role: SkinRole): Buffer | null {
|
|
85
|
+
const prev = readStoredSkin(storeDir, role);
|
|
86
|
+
if (prev) rmSync(storedSkinPath(storeDir, role));
|
|
87
|
+
return prev;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 将本地皮肤 loader 排在首位,优先于远端来源;其余配置保持原样。 */
|
|
91
|
+
export function mergeSkinLoaderConfig(prev: string): string {
|
|
92
|
+
let obj: Record<string, unknown> = {};
|
|
93
|
+
if (prev.trim()) {
|
|
94
|
+
const parsed: unknown = JSON.parse(prev);
|
|
95
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) obj = parsed as Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
const list = Array.isArray(obj.loadlist) ? [...(obj.loadlist as unknown[])] : [];
|
|
98
|
+
const isLocal = (item: unknown): boolean => {
|
|
99
|
+
if (!item || typeof item !== 'object') return false;
|
|
100
|
+
const skin = (item as Record<string, unknown>).skin;
|
|
101
|
+
return typeof skin === 'string' && skin === LOCAL_SKIN_PATTERN;
|
|
102
|
+
};
|
|
103
|
+
const found = list.find(isLocal);
|
|
104
|
+
const rest = list.filter((item) => !isLocal(item));
|
|
105
|
+
obj.loadlist = [found ?? LOCAL_SKIN_ENTRY, ...rest];
|
|
106
|
+
return `${JSON.stringify(obj, null, 2)}\n`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 检测游戏目录中的 CustomSkinLoader 模组。 */
|
|
110
|
+
export function hasSkinMod(gameDir: string): boolean {
|
|
111
|
+
const mods = join(gameDir, 'mods');
|
|
112
|
+
if (!gameDir || !existsSync(mods)) return false;
|
|
113
|
+
return readdirSync(mods).some((f) => /^customskinloader.*\.jar$/i.test(f));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function installedSkinPath(gameDir: string, username: string): string {
|
|
117
|
+
return join(gameDir, CSL_DIR, 'LocalSkin', 'skins', `${username}.png`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function installedMatches(gameDir: string, username: string, bytes: Buffer | null): boolean {
|
|
121
|
+
if (!gameDir || !username || !bytes) return false;
|
|
122
|
+
const path = installedSkinPath(gameDir, username);
|
|
123
|
+
return existsSync(path) && readFileSync(path).equals(bytes);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 启动前按账号名写入所选 PNG,并合并本地 loader 配置;未选择皮肤时不写配置。 */
|
|
127
|
+
export function applySkins(
|
|
128
|
+
gameDir: string,
|
|
129
|
+
entries: Array<{ username: string; bytes: Buffer }>,
|
|
130
|
+
log: Logger,
|
|
131
|
+
): void {
|
|
132
|
+
const wanted = entries.filter((e) => e.username && e.bytes.length > 0);
|
|
133
|
+
if (!gameDir || wanted.length === 0) return;
|
|
134
|
+
const done: string[] = [];
|
|
135
|
+
try {
|
|
136
|
+
for (const { username, bytes } of wanted) {
|
|
137
|
+
const dst = installedSkinPath(gameDir, username);
|
|
138
|
+
if (existsSync(dst) && readFileSync(dst).equals(bytes)) continue;
|
|
139
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
140
|
+
writeFileSync(dst, bytes);
|
|
141
|
+
done.push(username);
|
|
142
|
+
}
|
|
143
|
+
const configFile = join(gameDir, CSL_DIR, CSL_CONFIG);
|
|
144
|
+
const prev = existsSync(configFile) ? readFileSync(configFile, 'utf8') : '';
|
|
145
|
+
const next = mergeSkinLoaderConfig(prev);
|
|
146
|
+
if (next !== prev) {
|
|
147
|
+
mkdirSync(join(gameDir, CSL_DIR), { recursive: true });
|
|
148
|
+
writeFileSync(configFile, next, 'utf8');
|
|
149
|
+
}
|
|
150
|
+
if (done.length > 0) log.info(`已铺皮肤到 ${gameDir}:${done.join('、')}`);
|
|
151
|
+
if (!hasSkinMod(gameDir)) {
|
|
152
|
+
log.warn(`${join(gameDir, 'mods')} 里没有 CustomSkinLoader,皮肤铺了也不会被读`);
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
log.warn(`皮肤铺设失败(不影响启动): ${(err as Error).message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 仅删除内容与 expect 相同的账号皮肤文件。 */
|
|
160
|
+
export function removeInstalledSkin(gameDir: string, username: string, expect: Buffer | null): boolean {
|
|
161
|
+
if (!installedMatches(gameDir, username, expect)) return false;
|
|
162
|
+
rmSync(installedSkinPath(gameDir, username));
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# 找一个进程名下的主窗口:有没有出来,以及改标题。
|
|
2
|
+
#
|
|
3
|
+
# 认哪扇窗只认 OwnerPid:调用方托管着那个进程,窗口归属是唯一无歧义的凭据。
|
|
4
|
+
# 没有按标题找的路——同一台机器上常有第二份同名同标题的客户端(人自己玩的那份)。
|
|
5
|
+
#
|
|
6
|
+
# 用法:
|
|
7
|
+
# 探测: powershell -File client-window.ps1 -OwnerPid 1234
|
|
8
|
+
# 改标题: powershell -File client-window.ps1 -OwnerPid 1234 -SetTitle CortiCam
|
|
9
|
+
# 输出:一行 JSON {ok, hwnd, title, error}
|
|
10
|
+
param(
|
|
11
|
+
[Parameter(Mandatory = $true)][int]$OwnerPid,
|
|
12
|
+
[string]$SetTitle = ''
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
$ErrorActionPreference = 'Stop'
|
|
16
|
+
|
|
17
|
+
Add-Type @'
|
|
18
|
+
using System;
|
|
19
|
+
using System.Runtime.InteropServices;
|
|
20
|
+
using System.Text;
|
|
21
|
+
public class MinecraftWin {
|
|
22
|
+
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
|
23
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
|
|
24
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
25
|
+
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
|
|
26
|
+
[DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
|
|
27
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder s, int n);
|
|
28
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
29
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
|
|
30
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern bool SetWindowText(IntPtr hWnd, string text);
|
|
31
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
|
|
32
|
+
}
|
|
33
|
+
'@
|
|
34
|
+
|
|
35
|
+
function Write-Result($obj) {
|
|
36
|
+
Write-Output ($obj | ConvertTo-Json -Compress)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# 一个进程可能有多扇窗(闪屏、对话框),取面积最大的那扇。
|
|
40
|
+
# 一扇都没有 = 这个客户端还没就绪,由调用方接着等,不去别处找。
|
|
41
|
+
# 参数不能叫 $pid:$PID 是 PowerShell 当前进程号,只读。
|
|
42
|
+
function Find-OwnerWindow([int]$targetPid) {
|
|
43
|
+
$script:OwnerPid = $targetPid
|
|
44
|
+
$script:best = [IntPtr]::Zero
|
|
45
|
+
$script:bestArea = 0
|
|
46
|
+
$script:bestTitle = ''
|
|
47
|
+
$cb = [MinecraftWin+EnumProc] {
|
|
48
|
+
param($hWnd, $lParam)
|
|
49
|
+
if (-not [MinecraftWin]::IsWindowVisible($hWnd)) { return $true }
|
|
50
|
+
if ([MinecraftWin]::IsIconic($hWnd)) { return $true }
|
|
51
|
+
$len = [MinecraftWin]::GetWindowTextLength($hWnd)
|
|
52
|
+
if ($len -le 0) { return $true }
|
|
53
|
+
$sb = New-Object System.Text.StringBuilder ($len + 1)
|
|
54
|
+
[void][MinecraftWin]::GetWindowText($hWnd, $sb, $sb.Capacity)
|
|
55
|
+
$title = $sb.ToString()
|
|
56
|
+
$wpid = 0
|
|
57
|
+
[void][MinecraftWin]::GetWindowThreadProcessId($hWnd, [ref]$wpid)
|
|
58
|
+
if ($wpid -ne $script:OwnerPid) { return $true }
|
|
59
|
+
$r = New-Object MinecraftWin+RECT
|
|
60
|
+
[void][MinecraftWin]::GetWindowRect($hWnd, [ref]$r)
|
|
61
|
+
$area = ($r.Right - $r.Left) * ($r.Bottom - $r.Top)
|
|
62
|
+
if ($area -gt $script:bestArea) {
|
|
63
|
+
$script:bestArea = $area
|
|
64
|
+
$script:best = $hWnd
|
|
65
|
+
$script:bestTitle = $title
|
|
66
|
+
}
|
|
67
|
+
return $true
|
|
68
|
+
}
|
|
69
|
+
[void][MinecraftWin]::EnumWindows($cb, [IntPtr]::Zero)
|
|
70
|
+
return @{ hwnd = $script:best; title = $script:bestTitle }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
$found = Find-OwnerWindow $OwnerPid
|
|
75
|
+
$best = $found.hwnd
|
|
76
|
+
|
|
77
|
+
if ($best -eq [IntPtr]::Zero) {
|
|
78
|
+
Write-Result @{ ok = $false; error = 'no-window' }
|
|
79
|
+
exit 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if ($SetTitle) {
|
|
83
|
+
[void][MinecraftWin]::SetWindowText($best, $SetTitle)
|
|
84
|
+
Write-Result @{ ok = $true; hwnd = [int64]$best; title = $SetTitle }
|
|
85
|
+
exit 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Write-Result @{ ok = $true; hwnd = [int64]$best; title = $found.title }
|
|
89
|
+
} catch {
|
|
90
|
+
Write-Result @{ ok = $false; error = $_.Exception.Message }
|
|
91
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 管理观察者或玩家客户端进程。Windows 通过 ownerPid 的可见窗口判断就绪,其他平台使用 stdout 标记。
|
|
3
|
+
* 进服后的附身和传送由 world.ts 编排;窗口标题按账号名设置,并在直连加载后重设一次。
|
|
4
|
+
*/
|
|
5
|
+
import { logLines } from '../../core/ipc-logger.ts';
|
|
6
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import type { Logger } from '../../core/types.ts';
|
|
10
|
+
import { buildClientLaunch, soleVersionId } from './client-launch.ts';
|
|
11
|
+
import { applyChatVisible, applyLaunchOptions, applySpectatorPlusConfig } from './client-options.ts';
|
|
12
|
+
import { applySkins } from './client-skins.ts';
|
|
13
|
+
import { findWindow, setWindowTitle } from './window.ts';
|
|
14
|
+
|
|
15
|
+
type ClientPhase = 'stopped' | 'starting' | 'running' | 'error';
|
|
16
|
+
|
|
17
|
+
export interface ClientState {
|
|
18
|
+
phase: ClientPhase;
|
|
19
|
+
/** 配置里开着(World 启动时会自动拉起);关着也仍可在面板里手动启停 */
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
detail: string | null;
|
|
22
|
+
pid: number | null;
|
|
23
|
+
/** 已检测到窗口就绪。 */
|
|
24
|
+
windowReady: boolean;
|
|
25
|
+
/** 当前进程所用目录;停机时为下一次启动的配置目录。 */
|
|
26
|
+
gameDir: string;
|
|
27
|
+
versionId: string;
|
|
28
|
+
username: string;
|
|
29
|
+
/** 游戏目录与版本齐备 */
|
|
30
|
+
configured: boolean;
|
|
31
|
+
/** 供控制台预览的命令行;解析失败时为 null。 */
|
|
32
|
+
command: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GameClientOptions {
|
|
36
|
+
/** 日志与状态里怎么称呼这一份客户端(「观察者客户端」/「玩家客户端」) */
|
|
37
|
+
label: string;
|
|
38
|
+
enabled: () => boolean;
|
|
39
|
+
gameDir: () => string;
|
|
40
|
+
versionId: () => string;
|
|
41
|
+
javaPath: () => string;
|
|
42
|
+
/** 额外 JVM 参数,空格分隔 */
|
|
43
|
+
jvmArgs: () => string;
|
|
44
|
+
username: () => string;
|
|
45
|
+
width: () => number;
|
|
46
|
+
height: () => number;
|
|
47
|
+
/** 进游戏直连服务器 */
|
|
48
|
+
autoJoin: () => boolean;
|
|
49
|
+
server: () => { host: string; port: number };
|
|
50
|
+
/**
|
|
51
|
+
* 启动前调整该游戏目录的设置:关 pauseOnLostFocus(切走时不弹暂停菜单),
|
|
52
|
+
* 并按 syncGui 写 SpectatorPlus 的同步屏幕开关。
|
|
53
|
+
*/
|
|
54
|
+
noPauseOnLostFocus: () => boolean;
|
|
55
|
+
/** 启动前写入 SpectatorPlus 的 GUI 同步开关;未指定时关闭,重启客户端生效。 */
|
|
56
|
+
syncGui?: () => boolean;
|
|
57
|
+
/** 启动前将 chatVisibility 设为 FULL,用于与观察者共用 gameDir 的玩家客户端。 */
|
|
58
|
+
chatUsable: () => boolean;
|
|
59
|
+
/** 启动前将所选账号皮肤写入客户端目录,由 CustomSkinLoader 读取。 */
|
|
60
|
+
skins?: () => Array<{ username: string; bytes: Buffer }>;
|
|
61
|
+
/** 异常退出后的自动重启上限;0 或省略时不重启。人为 stop() 永不重启。 */
|
|
62
|
+
restartMax?: () => number;
|
|
63
|
+
/** 第一次重启前等多久,之后逐次加倍 */
|
|
64
|
+
restartBackoffMs?: number;
|
|
65
|
+
/** 相邻异常退出间隔超过此值时,重启计数归零。 */
|
|
66
|
+
restartWindowMs?: number;
|
|
67
|
+
/** 非预期退出时通知;attempt 为重启序号,0 表示不再重启。 */
|
|
68
|
+
onCrash?: (info: { detail: string; attempt: number; max: number; delayMs: number }) => void;
|
|
69
|
+
/** World 是否正在关闭。关闭期的 Ctrl+C 按正常退出处理,不报故障或安排重启;省略时视为未关闭。 */
|
|
70
|
+
shuttingDown?: () => boolean;
|
|
71
|
+
log: Logger;
|
|
72
|
+
/** 窗口出现(自动重启后再次就绪也会来) */
|
|
73
|
+
onReady?: () => void;
|
|
74
|
+
/** 测试注入:替换 spawn 目标,跳过版本 JSON 解析 */
|
|
75
|
+
commandOverride?: { command: string; args: string[] };
|
|
76
|
+
windowPollMs?: number;
|
|
77
|
+
windowTimeoutMs?: number;
|
|
78
|
+
/** 直连加载后延迟重设窗口标题的时间。 */
|
|
79
|
+
titleSettleMs?: number;
|
|
80
|
+
/** 测试注入:窗口是否已出现 */
|
|
81
|
+
findWindow?: (opts: { ownerPid: number }) => Promise<boolean>;
|
|
82
|
+
/** 测试注入:改标题 */
|
|
83
|
+
setWindowTitle?: (opts: { ownerPid: number; title: string; log?: Logger }) => Promise<boolean>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** OBS 用来区分两份客户端的窗口标题:账号名。空名字不改。 */
|
|
87
|
+
export function clientWindowTitle(username: string): string | null {
|
|
88
|
+
const title = username.trim();
|
|
89
|
+
return title || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 将 Windows 32 位 NTSTATUS 退出码格式化为十六进制及已知含义。 */
|
|
93
|
+
const NATIVE_EXIT_REASONS: Record<number, string> = {
|
|
94
|
+
0xc0000005: '原生访问违例',
|
|
95
|
+
0xc0000017: '内存不足',
|
|
96
|
+
0xc000013a: '控制台 Ctrl+C 或关窗',
|
|
97
|
+
0xc0000409: '栈缓冲区溢出',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** 控制台 Ctrl+C / 关窗的原生退出码。关机流程里收到它不算崩 */
|
|
101
|
+
const CTRL_C_EXIT = 0xc000013a;
|
|
102
|
+
|
|
103
|
+
/** 非 Windows 平台通过 stdout 中的建窗标记判断就绪;Windows 使用 ownerPid 匹配窗口。 */
|
|
104
|
+
const WINDOW_READY_LINE = /Backend library: LWJGL/;
|
|
105
|
+
|
|
106
|
+
/** 保存标记两倍长度的尾部,以匹配跨 stdout chunk 的标记。 */
|
|
107
|
+
const WINDOW_SCAN_CARRY = 64;
|
|
108
|
+
|
|
109
|
+
/** 逐段扫描 stdout,保留上段尾部用于跨段匹配。 */
|
|
110
|
+
export function scanWindowReady(carry: string, chunk: string): { seen: boolean; carry: string } {
|
|
111
|
+
const scanned = carry + chunk;
|
|
112
|
+
if (WINDOW_READY_LINE.test(scanned)) return { seen: true, carry: '' };
|
|
113
|
+
return { seen: false, carry: scanned.slice(-WINDOW_SCAN_CARRY) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function explainExit(code: number | null): string {
|
|
117
|
+
if (code === null) return '进程已退出(没有退出码)';
|
|
118
|
+
if (code <= 0xffff) return `code=${code}`;
|
|
119
|
+
const reason = NATIVE_EXIT_REASONS[code];
|
|
120
|
+
return `code=${code}(0x${code.toString(16).toUpperCase()})${reason ? `,${reason}` : ''}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 将 natives jar 中的 DLL 解压到 natives 根目录,满足 LWJGL2 加载约定。 */
|
|
124
|
+
function prepareNatives(nativeJars: string[], nativesDir: string, log: Logger): void {
|
|
125
|
+
if (nativeJars.length === 0) return;
|
|
126
|
+
mkdirSync(nativesDir, { recursive: true });
|
|
127
|
+
for (const jar of nativeJars) {
|
|
128
|
+
try {
|
|
129
|
+
// Windows 自带的 bsdtar 认 zip;jar 就是 zip
|
|
130
|
+
const proc = spawn('tar', ['-xf', jar, '-C', nativesDir, '*.dll'], { windowsHide: true });
|
|
131
|
+
proc.on('error', () => undefined);
|
|
132
|
+
} catch {
|
|
133
|
+
/** 解压失败时保留 classpath 加载路径。 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// 解出来的 dll 可能带着 windows/x64/... 的路径,摊平到根下
|
|
137
|
+
const flatten = (dir: string, depth: number): void => {
|
|
138
|
+
if (depth > 6 || !existsSync(dir)) return;
|
|
139
|
+
for (const entry of readdirSync(dir)) {
|
|
140
|
+
const abs = join(dir, entry);
|
|
141
|
+
try {
|
|
142
|
+
if (statSync(abs).isDirectory()) flatten(abs, depth + 1);
|
|
143
|
+
else if (entry.toLowerCase().endsWith('.dll') && dir !== nativesDir) {
|
|
144
|
+
renameSync(abs, join(nativesDir, entry));
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
log.debug?.(`natives 摊平跳过 ${abs}: ${(err as Error).message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
setTimeout(() => flatten(nativesDir, 0), 1_500);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class GameClient {
|
|
155
|
+
private phase: ClientPhase = 'stopped';
|
|
156
|
+
private detail: string | null = null;
|
|
157
|
+
private proc: ChildProcess | null = null;
|
|
158
|
+
private windowReady = false;
|
|
159
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
160
|
+
private titleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
161
|
+
private restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
162
|
+
/** 相邻异常退出计数及上次时刻。 */
|
|
163
|
+
private crashCount = 0;
|
|
164
|
+
private lastCrashAt = 0;
|
|
165
|
+
private logTail = '';
|
|
166
|
+
/** 非 Windows 的就绪判定:{@link WINDOW_READY_LINE} 在 stdout 上出现过没有。 */
|
|
167
|
+
private windowLineSeen = false;
|
|
168
|
+
/** 跨 chunk 的接缝:锚点那行可能被切成两段送来。只留够拼回一行的长度。 */
|
|
169
|
+
private windowScanCarry = '';
|
|
170
|
+
/** 当前进程启动时使用的目录;配置改动在停止后生效。 */
|
|
171
|
+
private activeGameDir: string | null = null;
|
|
172
|
+
|
|
173
|
+
constructor(private readonly opts: GameClientOptions) {}
|
|
174
|
+
|
|
175
|
+
/** 当前已就绪进程的 PID,供帧源匹配窗口;未就绪时为 null。 */
|
|
176
|
+
windowHint(): { pid: number } | null {
|
|
177
|
+
const pid = this.proc?.pid;
|
|
178
|
+
return this.windowReady && pid !== undefined ? { pid } : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get running(): boolean {
|
|
182
|
+
return this.proc !== null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
directory(): string {
|
|
186
|
+
return this.activeGameDir ?? this.opts.gameDir();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async state(): Promise<ClientState> {
|
|
190
|
+
const gameDir = this.directory();
|
|
191
|
+
const versionId = this.opts.versionId() || (gameDir ? soleVersionId(gameDir) ?? '' : '');
|
|
192
|
+
const launch = this.resolveLaunch(gameDir);
|
|
193
|
+
const broken = 'error' in launch ? launch.error : null;
|
|
194
|
+
return {
|
|
195
|
+
phase: this.phase,
|
|
196
|
+
enabled: this.opts.enabled(),
|
|
197
|
+
detail: this.detail ?? broken,
|
|
198
|
+
pid: this.proc?.pid ?? null,
|
|
199
|
+
windowReady: this.windowReady,
|
|
200
|
+
gameDir,
|
|
201
|
+
versionId,
|
|
202
|
+
username: this.opts.username(),
|
|
203
|
+
configured: broken === null,
|
|
204
|
+
command: 'error' in launch ? null : [launch.command, ...launch.args].join(' '),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async start(): Promise<ClientState> {
|
|
209
|
+
if (this.phase === 'starting' || this.phase === 'running') return this.state();
|
|
210
|
+
const gameDir = this.opts.gameDir();
|
|
211
|
+
const launch = this.resolveLaunch(gameDir);
|
|
212
|
+
if ('error' in launch) {
|
|
213
|
+
this.phase = 'error';
|
|
214
|
+
this.detail = launch.error;
|
|
215
|
+
return this.state();
|
|
216
|
+
}
|
|
217
|
+
this.activeGameDir = gameDir;
|
|
218
|
+
if ('nativeJars' in launch) prepareNatives(launch.nativeJars, launch.nativesDir, this.opts.log);
|
|
219
|
+
if (!this.opts.commandOverride) {
|
|
220
|
+
if (this.opts.noPauseOnLostFocus()) {
|
|
221
|
+
applyLaunchOptions(gameDir, this.opts.log);
|
|
222
|
+
applySpectatorPlusConfig(gameDir, this.opts.log, this.opts.syncGui?.() ?? false);
|
|
223
|
+
}
|
|
224
|
+
if (this.opts.chatUsable()) applyChatVisible(gameDir, this.opts.log);
|
|
225
|
+
const skins = this.opts.skins?.();
|
|
226
|
+
if (skins) applySkins(gameDir, skins, this.opts.log);
|
|
227
|
+
}
|
|
228
|
+
this.logTail = '';
|
|
229
|
+
const proc = spawn(launch.command, launch.args, {
|
|
230
|
+
cwd: launch.cwd,
|
|
231
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
232
|
+
windowsHide: false,
|
|
233
|
+
});
|
|
234
|
+
this.proc = proc;
|
|
235
|
+
this.phase = 'starting';
|
|
236
|
+
this.windowReady = false;
|
|
237
|
+
this.windowLineSeen = false;
|
|
238
|
+
this.windowScanCarry = '';
|
|
239
|
+
this.detail = '客户端启动中';
|
|
240
|
+
const tail = (chunk: Buffer) => {
|
|
241
|
+
const text = chunk.toString();
|
|
242
|
+
this.logTail = (this.logTail + text).slice(-2000);
|
|
243
|
+
if (this.windowLineSeen) return;
|
|
244
|
+
const scan = scanWindowReady(this.windowScanCarry, text);
|
|
245
|
+
this.windowLineSeen = scan.seen;
|
|
246
|
+
this.windowScanCarry = scan.carry;
|
|
247
|
+
};
|
|
248
|
+
proc.stdout?.on('data', tail);
|
|
249
|
+
proc.stderr?.on('data', tail);
|
|
250
|
+
logLines(proc.stdout, this.opts.log.child('client'), 'debug', 'stdout');
|
|
251
|
+
logLines(proc.stderr, this.opts.log.child('client'), 'debug', 'stderr');
|
|
252
|
+
proc.on('error', (err) => {
|
|
253
|
+
if (this.proc !== proc) return;
|
|
254
|
+
this.fail(`进程启动失败: ${err.message}`);
|
|
255
|
+
});
|
|
256
|
+
proc.on('exit', (code) => {
|
|
257
|
+
if (this.proc !== proc) return;
|
|
258
|
+
this.proc = null;
|
|
259
|
+
this.windowReady = false;
|
|
260
|
+
if (this.phase === 'stopped') return; // 人为停止、正常退出及关闭期的 Ctrl+C 均不触发故障重启。
|
|
261
|
+
if (code === 0 || (code === CTRL_C_EXIT && this.opts.shuttingDown?.())) {
|
|
262
|
+
this.clearTimer();
|
|
263
|
+
this.phase = 'stopped';
|
|
264
|
+
this.detail = null;
|
|
265
|
+
this.activeGameDir = null;
|
|
266
|
+
this.opts.log.info(`${this.opts.label}正常退出(${explainExit(code)}),不重启`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const detail = `客户端退出 ${explainExit(code)};完整日志 ${this.gameLogPath()};stdout 末尾: ${this.logTail.slice(-400)}`;
|
|
270
|
+
this.opts.log.emit('error', `${this.opts.label}进程退出`, { event: 'exit', data: { exitCode: code, gameLog: this.gameLogPath() } });
|
|
271
|
+
this.fail(detail);
|
|
272
|
+
/** 仅进程非预期退出触发自动重启;spawn 失败和窗口就绪超时不重启。 */
|
|
273
|
+
this.scheduleRestart(detail);
|
|
274
|
+
});
|
|
275
|
+
this.opts.log.info(`${this.opts.label}启动中 pid=${proc.pid}`);
|
|
276
|
+
this.beginWindowPolling();
|
|
277
|
+
return this.state();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async stop(): Promise<ClientState> {
|
|
281
|
+
this.clearTimer();
|
|
282
|
+
this.phase = 'stopped';
|
|
283
|
+
this.detail = null;
|
|
284
|
+
this.windowReady = false;
|
|
285
|
+
const proc = this.proc;
|
|
286
|
+
this.proc = null;
|
|
287
|
+
if (proc && proc.exitCode === null) {
|
|
288
|
+
try { proc.kill(); } catch { /* 已退出 */ }
|
|
289
|
+
await new Promise<void>((resolve) => {
|
|
290
|
+
const force = setTimeout(() => {
|
|
291
|
+
try { proc.kill('SIGKILL'); } catch { /* 已退出 */ }
|
|
292
|
+
resolve();
|
|
293
|
+
}, 5_000);
|
|
294
|
+
proc.once('exit', () => {
|
|
295
|
+
clearTimeout(force);
|
|
296
|
+
resolve();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
this.opts.log.info(`${this.opts.label}已关闭`);
|
|
300
|
+
}
|
|
301
|
+
this.activeGameDir = null;
|
|
302
|
+
return this.state();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private resolveLaunch(gameDir = this.directory()): ReturnType<typeof buildClientLaunch> | { command: string; args: string[]; cwd: string | undefined } {
|
|
306
|
+
if (this.opts.commandOverride) {
|
|
307
|
+
return { command: this.opts.commandOverride.command, args: this.opts.commandOverride.args, cwd: undefined };
|
|
308
|
+
}
|
|
309
|
+
const server = this.opts.server();
|
|
310
|
+
return buildClientLaunch({
|
|
311
|
+
gameDir,
|
|
312
|
+
versionId: this.opts.versionId(),
|
|
313
|
+
javaPath: this.opts.javaPath(),
|
|
314
|
+
username: this.opts.username(),
|
|
315
|
+
jvmArgs: this.opts.jvmArgs().split(/\s+/).filter(Boolean),
|
|
316
|
+
width: this.opts.width(),
|
|
317
|
+
height: this.opts.height(),
|
|
318
|
+
joinServer: this.opts.autoJoin() ? server : null,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private beginWindowPolling(): void {
|
|
323
|
+
this.clearTimer();
|
|
324
|
+
const interval = this.opts.windowPollMs ?? 4_000;
|
|
325
|
+
const deadline = Date.now() + (this.opts.windowTimeoutMs ?? 180_000);
|
|
326
|
+
this.pollTimer = setInterval(async () => {
|
|
327
|
+
const pid = this.proc?.pid;
|
|
328
|
+
if (this.phase !== 'starting' || pid === undefined) {
|
|
329
|
+
this.clearTimer();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const seen = await this.windowSeen(pid);
|
|
333
|
+
if (seen) {
|
|
334
|
+
if (this.phase !== 'starting') return;
|
|
335
|
+
this.phase = 'running';
|
|
336
|
+
this.detail = null;
|
|
337
|
+
this.windowReady = true;
|
|
338
|
+
this.clearTimer();
|
|
339
|
+
this.opts.log.info(`${this.opts.label}窗口已就绪`);
|
|
340
|
+
if (process.platform !== 'win32') {
|
|
341
|
+
this.opts.log.warn(`${this.opts.label}窗口标题改不了(只有 Windows 那条路能改),`
|
|
342
|
+
+ 'OBS 里要靠人自己选窗口,不能按账号名认。');
|
|
343
|
+
}
|
|
344
|
+
void this.applyWindowTitle();
|
|
345
|
+
if (this.opts.autoJoin()) {
|
|
346
|
+
const settle = this.opts.titleSettleMs ?? 8_000;
|
|
347
|
+
this.titleTimer = setTimeout(() => { void this.applyWindowTitle(); }, settle);
|
|
348
|
+
}
|
|
349
|
+
this.opts.onReady?.();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (Date.now() > deadline) {
|
|
353
|
+
const waited = process.platform === 'win32'
|
|
354
|
+
? '等窗口超时'
|
|
355
|
+
: `等窗口超时(本平台按 stdout 上的「${WINDOW_READY_LINE.source}」判定)`;
|
|
356
|
+
this.fail(`${waited};完整日志 ${this.gameLogPath()};stdout 末尾: ${this.logTail.slice(-400)}`);
|
|
357
|
+
}
|
|
358
|
+
}, interval);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Windows 查询 ownerPid 对应的可见窗口;其他平台要求进程仍运行且已读到建窗标记。 */
|
|
362
|
+
private async windowSeen(pid: number): Promise<boolean> {
|
|
363
|
+
if (this.opts.findWindow) return this.opts.findWindow({ ownerPid: pid });
|
|
364
|
+
if (process.platform === 'win32') return findWindow({ ownerPid: pid, log: this.opts.log });
|
|
365
|
+
return this.proc?.exitCode === null && this.windowLineSeen;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 客户端日志文件路径。 */
|
|
369
|
+
private gameLogPath(): string {
|
|
370
|
+
return join(this.directory(), 'logs', 'latest.log');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async applyWindowTitle(): Promise<void> {
|
|
374
|
+
const title = clientWindowTitle(this.opts.username());
|
|
375
|
+
const pid = this.proc?.pid;
|
|
376
|
+
if (!title || pid === undefined) return;
|
|
377
|
+
const set = this.opts.setWindowTitle ?? setWindowTitle;
|
|
378
|
+
const ok = await set({ ownerPid: pid, title, log: this.opts.log });
|
|
379
|
+
if (ok) this.opts.log.info(`${this.opts.label}窗口标题已设为 ${title}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 异常退出按配置指数退避重启;相邻崩溃间隔超过 restartWindowMs 时重置计数,超过重启上限后停止重试并报告。 */
|
|
383
|
+
private scheduleRestart(detail: string): void {
|
|
384
|
+
const max = this.opts.restartMax?.() ?? 0;
|
|
385
|
+
const windowMs = this.opts.restartWindowMs ?? 600_000;
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
if (this.lastCrashAt > 0 && now - this.lastCrashAt > windowMs) this.crashCount = 0;
|
|
388
|
+
this.lastCrashAt = now;
|
|
389
|
+
this.crashCount += 1;
|
|
390
|
+
if (max <= 0 || this.crashCount > max) {
|
|
391
|
+
this.opts.onCrash?.({ detail, attempt: 0, max, delayMs: 0 });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const delayMs = (this.opts.restartBackoffMs ?? 30_000) * 2 ** (this.crashCount - 1);
|
|
395
|
+
this.opts.onCrash?.({ detail, attempt: this.crashCount, max, delayMs });
|
|
396
|
+
this.opts.log.info(`${this.opts.label}将在 ${Math.round(delayMs / 1000)} 秒后自动重启(第 ${this.crashCount}/${max} 次)`);
|
|
397
|
+
this.restartTimer = setTimeout(() => {
|
|
398
|
+
this.restartTimer = null;
|
|
399
|
+
if (this.phase === 'stopped') return;
|
|
400
|
+
void this.start();
|
|
401
|
+
}, delayMs);
|
|
402
|
+
this.restartTimer.unref?.();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private clearTimer(): void {
|
|
406
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
407
|
+
this.pollTimer = null;
|
|
408
|
+
if (this.titleTimer) clearTimeout(this.titleTimer);
|
|
409
|
+
this.titleTimer = null;
|
|
410
|
+
if (this.restartTimer) clearTimeout(this.restartTimer);
|
|
411
|
+
this.restartTimer = null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fail(detail: string): void {
|
|
415
|
+
this.clearTimer();
|
|
416
|
+
this.phase = 'error';
|
|
417
|
+
this.detail = detail;
|
|
418
|
+
this.windowReady = false;
|
|
419
|
+
if (this.proc === null) this.activeGameDir = null;
|
|
420
|
+
// 观察者窗口中断影响播出画面,按 error 记录。
|
|
421
|
+
this.opts.log.error(`${this.opts.label}异常: ${detail}`);
|
|
422
|
+
}
|
|
423
|
+
}
|