@co0ontty/wand 4.52.0 → 4.53.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/build-info.json +3 -3
- package/dist/distribution-manager.js +2 -2
- package/dist/ios-ota.d.ts +42 -0
- package/dist/ios-ota.js +440 -0
- package/dist/models.d.ts +8 -0
- package/dist/models.js +47 -6
- package/dist/process-manager.js +4 -2
- package/dist/server-session-routes.js +8 -1
- package/dist/server-update-routes.js +135 -1
- package/dist/server-workspace-routes.js +40 -17
- package/dist/session-transport.js +2 -2
- package/dist/web-ui/content/scripts.js +67 -67
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.js +29 -8
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-09-
|
|
4
|
-
"version": "4.
|
|
2
|
+
"commit": "dd763b29e0d0810f30a090302594ebe40a2e0c78",
|
|
3
|
+
"builtAt": "2026-09-06T07:59:01.447Z",
|
|
4
|
+
"version": "4.53.0",
|
|
5
5
|
"channel": "stable"
|
|
6
6
|
}
|
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { mkdir, readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { compareApkInstallOrder, compareSemver, extractSemver } from "./version-utils.js";
|
|
5
|
+
import { compareApkInstallOrder, compareSemver, compareWandInstallOrder, extractSemver } from "./version-utils.js";
|
|
6
6
|
const GITHUB_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
7
7
|
/**
|
|
8
8
|
* GitHub Release 正文还包含 Android/macOS/iOS 的安装指引;它们属于发布页,
|
|
@@ -262,7 +262,7 @@ export class DistributionManager {
|
|
|
262
262
|
extension: ".ipa",
|
|
263
263
|
configuredFile: config.ios.currentIpaFile,
|
|
264
264
|
downloadUrl: "/ios/download",
|
|
265
|
-
compareVersions:
|
|
265
|
+
compareVersions: compareWandInstallOrder,
|
|
266
266
|
});
|
|
267
267
|
}
|
|
268
268
|
async getSettings() {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface IosIpaMetadata {
|
|
2
|
+
bundleId: string;
|
|
3
|
+
bundleVersion: string;
|
|
4
|
+
bundleBuild: string;
|
|
5
|
+
title: string;
|
|
6
|
+
signed: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type IosOtaBlocker = "unsigned-ipa" | "not-https" | "remote-ipa";
|
|
9
|
+
export interface IosOtaManifestInput {
|
|
10
|
+
ipaUrl: string;
|
|
11
|
+
bundleId: string;
|
|
12
|
+
bundleVersion: string;
|
|
13
|
+
title: string;
|
|
14
|
+
displayImageUrl?: string;
|
|
15
|
+
fullSizeImageUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function publicOriginFromRequest(req: {
|
|
18
|
+
protocol?: string;
|
|
19
|
+
headers: Record<string, string | string[] | undefined>;
|
|
20
|
+
}): string | null;
|
|
21
|
+
export declare function normalizePublicOrigin(value: string | undefined | null): string | null;
|
|
22
|
+
export declare function absoluteUrl(origin: string, pathOrUrl: string): string;
|
|
23
|
+
export declare function buildItmsServicesUrl(manifestUrl: string): string;
|
|
24
|
+
export declare function collectIosOtaBlockers(options: {
|
|
25
|
+
signed: boolean;
|
|
26
|
+
origin: string | null;
|
|
27
|
+
source: "local" | "github";
|
|
28
|
+
}): IosOtaBlocker[];
|
|
29
|
+
export declare function buildIosOtaManifest(input: IosOtaManifestInput): string;
|
|
30
|
+
export declare function buildIosInstallPage(options: {
|
|
31
|
+
title: string;
|
|
32
|
+
version: string;
|
|
33
|
+
sizeLabel: string;
|
|
34
|
+
installUrl: string;
|
|
35
|
+
downloadUrl: string;
|
|
36
|
+
signed: boolean;
|
|
37
|
+
https: boolean;
|
|
38
|
+
}): string;
|
|
39
|
+
export declare function inspectIpa(filePath: string, fallback?: {
|
|
40
|
+
version?: string | null;
|
|
41
|
+
}): Promise<IosIpaMetadata>;
|
|
42
|
+
export declare function formatIosAssetSize(size: number): string;
|
package/dist/ios-ota.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { inflateRawSync } from "node:zlib";
|
|
3
|
+
const DEFAULT_BUNDLE_ID = "com.wand.app";
|
|
4
|
+
const DEFAULT_TITLE = "Wand";
|
|
5
|
+
export function publicOriginFromRequest(req) {
|
|
6
|
+
const forwardedProto = firstHeader(req.headers["x-forwarded-proto"])
|
|
7
|
+
?? forwardedParam(firstHeader(req.headers.forwarded), "proto");
|
|
8
|
+
const proto = normalizeProtocol(forwardedProto)
|
|
9
|
+
?? (firstHeader(req.headers["x-forwarded-ssl"])?.toLowerCase() === "on" ? "https" : undefined)
|
|
10
|
+
?? (firstHeader(req.headers["x-forwarded-scheme"])?.toLowerCase() === "https" ? "https" : undefined)
|
|
11
|
+
?? normalizeProtocol(req.protocol);
|
|
12
|
+
const host = firstHeader(req.headers["x-forwarded-host"])
|
|
13
|
+
?? forwardedParam(firstHeader(req.headers.forwarded), "host")
|
|
14
|
+
?? firstHeader(req.headers.host);
|
|
15
|
+
if (!proto || !host)
|
|
16
|
+
return null;
|
|
17
|
+
return `${proto}://${host}`;
|
|
18
|
+
}
|
|
19
|
+
export function normalizePublicOrigin(value) {
|
|
20
|
+
const raw = value?.trim();
|
|
21
|
+
if (!raw)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = new URL(raw);
|
|
25
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
26
|
+
return null;
|
|
27
|
+
if (!parsed.hostname || parsed.username || parsed.password)
|
|
28
|
+
return null;
|
|
29
|
+
return parsed.origin;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function absoluteUrl(origin, pathOrUrl) {
|
|
36
|
+
if (/^https?:\/\//i.test(pathOrUrl))
|
|
37
|
+
return pathOrUrl;
|
|
38
|
+
const base = origin.endsWith("/") ? origin : `${origin}/`;
|
|
39
|
+
return new URL(pathOrUrl.startsWith("/") ? pathOrUrl.slice(1) : pathOrUrl, base).href;
|
|
40
|
+
}
|
|
41
|
+
export function buildItmsServicesUrl(manifestUrl) {
|
|
42
|
+
return `itms-services://?action=download-manifest&url=${encodeURIComponent(manifestUrl)}`;
|
|
43
|
+
}
|
|
44
|
+
export function collectIosOtaBlockers(options) {
|
|
45
|
+
const blockers = [];
|
|
46
|
+
if (options.source !== "local")
|
|
47
|
+
blockers.push("remote-ipa");
|
|
48
|
+
if (!options.signed)
|
|
49
|
+
blockers.push("unsigned-ipa");
|
|
50
|
+
if (!options.origin?.startsWith("https://"))
|
|
51
|
+
blockers.push("not-https");
|
|
52
|
+
return blockers;
|
|
53
|
+
}
|
|
54
|
+
export function buildIosOtaManifest(input) {
|
|
55
|
+
const assets = [
|
|
56
|
+
dictEntries([
|
|
57
|
+
["kind", "software-package"],
|
|
58
|
+
["url", input.ipaUrl],
|
|
59
|
+
]),
|
|
60
|
+
];
|
|
61
|
+
if (input.displayImageUrl) {
|
|
62
|
+
assets.push(dictEntries([
|
|
63
|
+
["kind", "display-image"],
|
|
64
|
+
["url", input.displayImageUrl],
|
|
65
|
+
]));
|
|
66
|
+
}
|
|
67
|
+
if (input.fullSizeImageUrl) {
|
|
68
|
+
assets.push(dictEntries([
|
|
69
|
+
["kind", "full-size-image"],
|
|
70
|
+
["url", input.fullSizeImageUrl],
|
|
71
|
+
]));
|
|
72
|
+
}
|
|
73
|
+
return [
|
|
74
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
75
|
+
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
|
|
76
|
+
`<plist version="1.0">`,
|
|
77
|
+
`<dict>`,
|
|
78
|
+
`<key>items</key>`,
|
|
79
|
+
`<array>`,
|
|
80
|
+
`<dict>`,
|
|
81
|
+
`<key>assets</key>`,
|
|
82
|
+
`<array>`,
|
|
83
|
+
assets.join("\n"),
|
|
84
|
+
`</array>`,
|
|
85
|
+
`<key>metadata</key>`,
|
|
86
|
+
dictEntries([
|
|
87
|
+
["bundle-identifier", input.bundleId],
|
|
88
|
+
["bundle-version", input.bundleVersion],
|
|
89
|
+
["kind", "software"],
|
|
90
|
+
["title", input.title],
|
|
91
|
+
]),
|
|
92
|
+
`</dict>`,
|
|
93
|
+
`</array>`,
|
|
94
|
+
`</dict>`,
|
|
95
|
+
`</plist>`,
|
|
96
|
+
``,
|
|
97
|
+
].join("\n");
|
|
98
|
+
}
|
|
99
|
+
export function buildIosInstallPage(options) {
|
|
100
|
+
const warnings = [];
|
|
101
|
+
if (!options.https) {
|
|
102
|
+
warnings.push("当前页面不是 HTTPS。iOS 系统安装器会拒绝安装,请用受信任证书的公开地址打开。");
|
|
103
|
+
}
|
|
104
|
+
if (!options.signed) {
|
|
105
|
+
warnings.push("当前 IPA 未签名。系统安装会失败;签发后再放到更新目录即可 OTA 覆盖安装。");
|
|
106
|
+
}
|
|
107
|
+
const warningHtml = warnings.length
|
|
108
|
+
? `<div class="warn">${warnings.map((item) => `<p>${escapeHtml(item)}</p>`).join("")}</div>`
|
|
109
|
+
: "";
|
|
110
|
+
return `<!DOCTYPE html>
|
|
111
|
+
<html lang="zh-CN">
|
|
112
|
+
<head>
|
|
113
|
+
<meta charset="utf-8">
|
|
114
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
115
|
+
<title>安装 ${escapeHtml(options.title)}</title>
|
|
116
|
+
<style>
|
|
117
|
+
:root { color-scheme: light dark; }
|
|
118
|
+
body { font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 32px 20px; }
|
|
119
|
+
main { max-width: 420px; margin: 0 auto; }
|
|
120
|
+
h1 { font-size: 22px; margin: 0 0 8px; }
|
|
121
|
+
p { margin: 0 0 12px; color: #5c564f; }
|
|
122
|
+
.meta { color: #7a7168; font-size: 14px; margin-bottom: 20px; }
|
|
123
|
+
a.install { display: block; text-align: center; background: #c5653d; color: #fff; text-decoration: none; padding: 14px 16px; border-radius: 12px; font-weight: 650; }
|
|
124
|
+
a.secondary { display: block; text-align: center; margin-top: 12px; color: #c5653d; }
|
|
125
|
+
.warn { background: rgba(197,101,61,.12); border-radius: 12px; padding: 12px 14px; margin-bottom: 18px; }
|
|
126
|
+
.hint { font-size: 13px; color: #7a7168; margin-top: 18px; }
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<main>
|
|
131
|
+
<h1>安装 ${escapeHtml(options.title)}</h1>
|
|
132
|
+
<div class="meta">版本 ${escapeHtml(options.version)} · ${escapeHtml(options.sizeLabel)}</div>
|
|
133
|
+
${warningHtml}
|
|
134
|
+
<a class="install" href="${escapeHtml(options.installUrl)}">在 iPhone 上安装</a>
|
|
135
|
+
<a class="secondary" href="${escapeHtml(options.downloadUrl)}">下载 IPA</a>
|
|
136
|
+
<p class="hint">请用 Safari 打开本页。安装后如提示未受信任,到 设置 → 通用 → VPN 与设备管理 中信任,iOS 18 企业包可能需要重启。</p>
|
|
137
|
+
</main>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
export async function inspectIpa(filePath, fallback = {}) {
|
|
143
|
+
const entries = await listZipEntries(filePath);
|
|
144
|
+
const signed = entries.some((entry) => entry.name.endsWith("embedded.mobileprovision")
|
|
145
|
+
|| entry.name.includes("_CodeSignature/"));
|
|
146
|
+
const infoEntry = entries.find((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/i.test(entry.name));
|
|
147
|
+
let bundleId = DEFAULT_BUNDLE_ID;
|
|
148
|
+
let bundleVersion = fallback.version?.trim() || "";
|
|
149
|
+
let bundleBuild = bundleVersion;
|
|
150
|
+
let title = DEFAULT_TITLE;
|
|
151
|
+
if (infoEntry) {
|
|
152
|
+
try {
|
|
153
|
+
const plist = parseIosInfoPlist(await readZipEntry(filePath, infoEntry));
|
|
154
|
+
bundleId = stringValue(plist.CFBundleIdentifier) || bundleId;
|
|
155
|
+
bundleVersion = stringValue(plist.CFBundleShortVersionString) || bundleVersion;
|
|
156
|
+
bundleBuild = stringValue(plist.CFBundleVersion) || bundleVersion;
|
|
157
|
+
title = stringValue(plist.CFBundleDisplayName) || stringValue(plist.CFBundleName) || title;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// 解析失败时用文件名版本和默认 bundle id,仍允许检查更新。
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!bundleVersion)
|
|
164
|
+
bundleVersion = fallback.version?.trim() || "0.0.0";
|
|
165
|
+
if (!bundleBuild)
|
|
166
|
+
bundleBuild = bundleVersion;
|
|
167
|
+
return { bundleId, bundleVersion, bundleBuild, title, signed };
|
|
168
|
+
}
|
|
169
|
+
async function listZipEntries(filePath) {
|
|
170
|
+
const handle = await open(filePath, "r");
|
|
171
|
+
try {
|
|
172
|
+
const stat = await handle.stat();
|
|
173
|
+
const tailSize = Math.min(stat.size, 64 * 1024);
|
|
174
|
+
const tail = Buffer.alloc(tailSize);
|
|
175
|
+
await handle.read(tail, 0, tailSize, stat.size - tailSize);
|
|
176
|
+
const eocd = findEocd(tail);
|
|
177
|
+
if (!eocd)
|
|
178
|
+
throw new Error("IPA 不是有效的 zip。");
|
|
179
|
+
const cd = Buffer.alloc(eocd.cdSize);
|
|
180
|
+
await handle.read(cd, 0, eocd.cdSize, eocd.cdOffset);
|
|
181
|
+
const entries = [];
|
|
182
|
+
let offset = 0;
|
|
183
|
+
while (offset + 46 <= cd.length) {
|
|
184
|
+
if (cd.readUInt32LE(offset) !== 0x02014b50)
|
|
185
|
+
break;
|
|
186
|
+
const compression = cd.readUInt16LE(offset + 10);
|
|
187
|
+
const compressedSize = cd.readUInt32LE(offset + 20);
|
|
188
|
+
const uncompressedSize = cd.readUInt32LE(offset + 24);
|
|
189
|
+
const nameLength = cd.readUInt16LE(offset + 28);
|
|
190
|
+
const extraLength = cd.readUInt16LE(offset + 30);
|
|
191
|
+
const commentLength = cd.readUInt16LE(offset + 32);
|
|
192
|
+
const localHeaderOffset = cd.readUInt32LE(offset + 42);
|
|
193
|
+
const nameStart = offset + 46;
|
|
194
|
+
const nameEnd = nameStart + nameLength;
|
|
195
|
+
if (nameEnd > cd.length)
|
|
196
|
+
break;
|
|
197
|
+
entries.push({
|
|
198
|
+
name: cd.subarray(nameStart, nameEnd).toString("utf8"),
|
|
199
|
+
compression,
|
|
200
|
+
compressedSize,
|
|
201
|
+
uncompressedSize,
|
|
202
|
+
localHeaderOffset,
|
|
203
|
+
});
|
|
204
|
+
offset = nameEnd + extraLength + commentLength;
|
|
205
|
+
}
|
|
206
|
+
return entries;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
await handle.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function readZipEntry(filePath, entry) {
|
|
213
|
+
const handle = await open(filePath, "r");
|
|
214
|
+
try {
|
|
215
|
+
const header = Buffer.alloc(30);
|
|
216
|
+
await handle.read(header, 0, 30, entry.localHeaderOffset);
|
|
217
|
+
if (header.readUInt32LE(0) !== 0x04034b50)
|
|
218
|
+
throw new Error("IPA 本地文件头无效。");
|
|
219
|
+
const nameLength = header.readUInt16LE(26);
|
|
220
|
+
const extraLength = header.readUInt16LE(28);
|
|
221
|
+
const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
|
222
|
+
const compressed = Buffer.alloc(entry.compressedSize);
|
|
223
|
+
if (entry.compressedSize > 0) {
|
|
224
|
+
await handle.read(compressed, 0, entry.compressedSize, dataOffset);
|
|
225
|
+
}
|
|
226
|
+
if (entry.compression === 0)
|
|
227
|
+
return compressed;
|
|
228
|
+
if (entry.compression === 8)
|
|
229
|
+
return inflateRawSync(compressed);
|
|
230
|
+
throw new Error(`不支持的 IPA 压缩方式:${entry.compression}`);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
await handle.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function findEocd(tail) {
|
|
237
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
238
|
+
if (tail.readUInt32LE(i) !== 0x06054b50)
|
|
239
|
+
continue;
|
|
240
|
+
const commentLength = tail.readUInt16LE(i + 20);
|
|
241
|
+
if (i + 22 + commentLength !== tail.length)
|
|
242
|
+
continue;
|
|
243
|
+
return {
|
|
244
|
+
cdSize: tail.readUInt32LE(i + 12),
|
|
245
|
+
cdOffset: tail.readUInt32LE(i + 16),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
function parseIosInfoPlist(buffer) {
|
|
251
|
+
if (buffer.subarray(0, 8).toString("ascii") === "bplist00") {
|
|
252
|
+
const parsed = parseBinaryPlist(buffer);
|
|
253
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
254
|
+
? parsed
|
|
255
|
+
: {};
|
|
256
|
+
}
|
|
257
|
+
return parseXmlPlistStrings(buffer.toString("utf8"));
|
|
258
|
+
}
|
|
259
|
+
function parseXmlPlistStrings(xml) {
|
|
260
|
+
const result = {};
|
|
261
|
+
const pattern = /<key>([^<]+)<\/key>\s*<(string|integer)>([^<]*)<\/\2>/g;
|
|
262
|
+
for (const match of xml.matchAll(pattern)) {
|
|
263
|
+
result[match[1]] = match[3];
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
function parseBinaryPlist(buffer) {
|
|
268
|
+
const trailer = buffer.subarray(buffer.length - 32);
|
|
269
|
+
const offsetSize = trailer[6];
|
|
270
|
+
const objectRefSize = trailer[7];
|
|
271
|
+
const objectCount = Number(trailer.readBigUInt64BE(8));
|
|
272
|
+
const topObject = Number(trailer.readBigUInt64BE(16));
|
|
273
|
+
const offsetTableOffset = Number(trailer.readBigUInt64BE(24));
|
|
274
|
+
const offsetTable = buffer.subarray(offsetTableOffset);
|
|
275
|
+
const readOffset = (index) => readSizedInt(offsetTable, index * offsetSize, offsetSize);
|
|
276
|
+
const cache = new Array(objectCount);
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
const parseObject = (index) => {
|
|
279
|
+
if (index < 0 || index >= objectCount)
|
|
280
|
+
throw new Error("bplist 对象引用越界。");
|
|
281
|
+
if (seen.has(index))
|
|
282
|
+
return cache[index];
|
|
283
|
+
seen.add(index);
|
|
284
|
+
let cursor = readOffset(index);
|
|
285
|
+
const marker = buffer[cursor];
|
|
286
|
+
cursor += 1;
|
|
287
|
+
const nibble = marker & 0x0f;
|
|
288
|
+
const readLength = () => {
|
|
289
|
+
if (nibble !== 0x0f)
|
|
290
|
+
return nibble;
|
|
291
|
+
const lengthMarker = buffer[cursor];
|
|
292
|
+
cursor += 1;
|
|
293
|
+
const lengthSize = 1 << (lengthMarker & 0x0f);
|
|
294
|
+
const length = readSizedInt(buffer, cursor, lengthSize);
|
|
295
|
+
cursor += lengthSize;
|
|
296
|
+
return length;
|
|
297
|
+
};
|
|
298
|
+
switch (marker & 0xf0) {
|
|
299
|
+
case 0x00:
|
|
300
|
+
cache[index] = marker === 0x08 ? false : marker === 0x09 ? true : null;
|
|
301
|
+
return cache[index];
|
|
302
|
+
case 0x10: {
|
|
303
|
+
const size = 1 << nibble;
|
|
304
|
+
cache[index] = readSizedInt(buffer, cursor, size);
|
|
305
|
+
return cache[index];
|
|
306
|
+
}
|
|
307
|
+
case 0x20: {
|
|
308
|
+
const size = 1 << nibble;
|
|
309
|
+
cache[index] = size === 4 ? buffer.readFloatBE(cursor) : buffer.readDoubleBE(cursor);
|
|
310
|
+
return cache[index];
|
|
311
|
+
}
|
|
312
|
+
case 0x30:
|
|
313
|
+
cache[index] = new Date(978307200000 + buffer.readDoubleBE(cursor) * 1000);
|
|
314
|
+
return cache[index];
|
|
315
|
+
case 0x40: {
|
|
316
|
+
const length = readLength();
|
|
317
|
+
cache[index] = buffer.subarray(cursor, cursor + length);
|
|
318
|
+
return cache[index];
|
|
319
|
+
}
|
|
320
|
+
case 0x50: {
|
|
321
|
+
const length = readLength();
|
|
322
|
+
cache[index] = buffer.subarray(cursor, cursor + length).toString("ascii");
|
|
323
|
+
return cache[index];
|
|
324
|
+
}
|
|
325
|
+
case 0x60: {
|
|
326
|
+
const length = readLength();
|
|
327
|
+
const utf16 = buffer.subarray(cursor, cursor + length * 2);
|
|
328
|
+
const swapped = Buffer.alloc(utf16.length);
|
|
329
|
+
for (let i = 0; i + 1 < utf16.length; i += 2) {
|
|
330
|
+
swapped[i] = utf16[i + 1];
|
|
331
|
+
swapped[i + 1] = utf16[i];
|
|
332
|
+
}
|
|
333
|
+
cache[index] = swapped.toString("utf16le");
|
|
334
|
+
return cache[index];
|
|
335
|
+
}
|
|
336
|
+
case 0xa0: {
|
|
337
|
+
const length = readLength();
|
|
338
|
+
const values = [];
|
|
339
|
+
for (let i = 0; i < length; i += 1) {
|
|
340
|
+
values.push(parseObject(readSizedInt(buffer, cursor + i * objectRefSize, objectRefSize)));
|
|
341
|
+
}
|
|
342
|
+
cache[index] = values;
|
|
343
|
+
return cache[index];
|
|
344
|
+
}
|
|
345
|
+
case 0xd0: {
|
|
346
|
+
const length = readLength();
|
|
347
|
+
const record = {};
|
|
348
|
+
for (let i = 0; i < length; i += 1) {
|
|
349
|
+
const key = parseObject(readSizedInt(buffer, cursor + i * objectRefSize, objectRefSize));
|
|
350
|
+
const value = parseObject(readSizedInt(buffer, cursor + (length + i) * objectRefSize, objectRefSize));
|
|
351
|
+
record[String(key)] = value;
|
|
352
|
+
}
|
|
353
|
+
cache[index] = record;
|
|
354
|
+
return cache[index];
|
|
355
|
+
}
|
|
356
|
+
default:
|
|
357
|
+
cache[index] = null;
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
return parseObject(topObject);
|
|
362
|
+
}
|
|
363
|
+
function readSizedInt(buffer, offset, size) {
|
|
364
|
+
if (size === 1)
|
|
365
|
+
return buffer.readUInt8(offset);
|
|
366
|
+
if (size === 2)
|
|
367
|
+
return buffer.readUInt16BE(offset);
|
|
368
|
+
if (size === 4)
|
|
369
|
+
return buffer.readUInt32BE(offset);
|
|
370
|
+
if (size === 8)
|
|
371
|
+
return Number(buffer.readBigUInt64BE(offset));
|
|
372
|
+
let value = 0;
|
|
373
|
+
for (let i = 0; i < size; i += 1)
|
|
374
|
+
value = value * 256 + buffer[offset + i];
|
|
375
|
+
return value;
|
|
376
|
+
}
|
|
377
|
+
function dictEntries(entries) {
|
|
378
|
+
return [
|
|
379
|
+
`<dict>`,
|
|
380
|
+
...entries.flatMap(([key, value]) => [`<key>${escapeXml(key)}</key>`, `<string>${escapeXml(value)}</string>`]),
|
|
381
|
+
`</dict>`,
|
|
382
|
+
].join("\n");
|
|
383
|
+
}
|
|
384
|
+
function firstHeader(value) {
|
|
385
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
386
|
+
return raw?.split(",")[0]?.trim();
|
|
387
|
+
}
|
|
388
|
+
function forwardedParam(header, key) {
|
|
389
|
+
if (!header)
|
|
390
|
+
return undefined;
|
|
391
|
+
const target = `${key}=`;
|
|
392
|
+
for (const part of header.split(";")) {
|
|
393
|
+
const trimmed = part.trim();
|
|
394
|
+
if (!trimmed.toLowerCase().startsWith(target))
|
|
395
|
+
continue;
|
|
396
|
+
return trimmed.slice(target.length).replace(/^"|"$/g, "");
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
function normalizeProtocol(value) {
|
|
401
|
+
const proto = value?.trim().toLowerCase();
|
|
402
|
+
if (proto === "http" || proto === "https")
|
|
403
|
+
return proto;
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
function stringValue(value) {
|
|
407
|
+
if (typeof value === "string" && value.trim())
|
|
408
|
+
return value.trim();
|
|
409
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
410
|
+
return String(value);
|
|
411
|
+
return "";
|
|
412
|
+
}
|
|
413
|
+
function escapeXml(value) {
|
|
414
|
+
return value
|
|
415
|
+
.replaceAll("&", "&")
|
|
416
|
+
.replaceAll("<", "<")
|
|
417
|
+
.replaceAll(">", ">")
|
|
418
|
+
.replaceAll('"', """)
|
|
419
|
+
.replaceAll("'", "'");
|
|
420
|
+
}
|
|
421
|
+
function escapeHtml(value) {
|
|
422
|
+
return escapeXml(value);
|
|
423
|
+
}
|
|
424
|
+
function formatBytes(size) {
|
|
425
|
+
if (!Number.isFinite(size) || size <= 0)
|
|
426
|
+
return "未知大小";
|
|
427
|
+
if (size < 1024)
|
|
428
|
+
return `${size} B`;
|
|
429
|
+
const units = ["KB", "MB", "GB"];
|
|
430
|
+
let value = size / 1024;
|
|
431
|
+
let unit = 0;
|
|
432
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
433
|
+
value /= 1024;
|
|
434
|
+
unit += 1;
|
|
435
|
+
}
|
|
436
|
+
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
|
437
|
+
}
|
|
438
|
+
export function formatIosAssetSize(size) {
|
|
439
|
+
return formatBytes(size);
|
|
440
|
+
}
|
package/dist/models.d.ts
CHANGED
|
@@ -79,6 +79,14 @@ export declare function parseGrokModels(stdout: string): ClaudeModelInfo[];
|
|
|
79
79
|
* safe ID below the `MODEL` header.
|
|
80
80
|
*/
|
|
81
81
|
export declare function parseQoderModels(stdout: string): ClaudeModelInfo[];
|
|
82
|
+
/**
|
|
83
|
+
* Parse `pi --list-models`.
|
|
84
|
+
*
|
|
85
|
+
* Pi prints a padEnd-aligned table whose selectable `--model` value is
|
|
86
|
+
* `provider/id` (for example `xai/grok-4.6`). The first two columns are the
|
|
87
|
+
* only ones we keep; context / max-out / thinking / images are display-only.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parsePiModels(stdout: string): ClaudeModelInfo[];
|
|
82
90
|
/** Parse `opencode models`, whose stable machine-friendly output is one provider/model id per line. */
|
|
83
91
|
export declare function parseOpenCodeModels(stdout: string): ClaudeModelInfo[];
|
|
84
92
|
/** Parse the machine-readable model registry emitted by the installed Codex CLI. */
|
package/dist/models.js
CHANGED
|
@@ -18,10 +18,11 @@ const CLAUDE_PROBE_TIMEOUT_MS = 15_000;
|
|
|
18
18
|
const MAX_CLAUDE_MODEL_PROBES = 12;
|
|
19
19
|
const CLAUDE_PROBE_CONCURRENCY = 3;
|
|
20
20
|
const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
21
|
-
//
|
|
22
|
-
// provider
|
|
23
|
-
//
|
|
24
|
-
const QODER_MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._
|
|
21
|
+
// Shared catalog IDs are later forwarded to `--model`. Qoder and Pi both use
|
|
22
|
+
// provider-qualified values (`zhipu/glm5.2-cp`, `xai/grok-4.6`); Pi also emits
|
|
23
|
+
// `@` in some Cloudflare-style ids.
|
|
24
|
+
const QODER_MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
25
|
+
const PI_MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}\/[A-Za-z0-9@][A-Za-z0-9._:@/-]{0,127}$/;
|
|
25
26
|
const CLAUDE_BUILTIN_MODELS = [
|
|
26
27
|
{
|
|
27
28
|
id: "default",
|
|
@@ -309,6 +310,15 @@ async function probeQoderModels(runner, env) {
|
|
|
309
310
|
return { ok: false };
|
|
310
311
|
}
|
|
311
312
|
}
|
|
313
|
+
async function probePiModels(runner, env) {
|
|
314
|
+
try {
|
|
315
|
+
const { stdout } = await runner("pi", ["--list-models"], { env, timeout: 8000 });
|
|
316
|
+
return { ok: true, value: parsePiModels(stdout) };
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return { ok: false };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
312
322
|
function createOfficialModelsApi(apiKey) {
|
|
313
323
|
const client = new Anthropic({ apiKey });
|
|
314
324
|
return {
|
|
@@ -453,6 +463,36 @@ export function parseQoderModels(stdout) {
|
|
|
453
463
|
}
|
|
454
464
|
return [...cloneModels(QODER_FALLBACK_MODELS), ...discovered];
|
|
455
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* Parse `pi --list-models`.
|
|
468
|
+
*
|
|
469
|
+
* Pi prints a padEnd-aligned table whose selectable `--model` value is
|
|
470
|
+
* `provider/id` (for example `xai/grok-4.6`). The first two columns are the
|
|
471
|
+
* only ones we keep; context / max-out / thinking / images are display-only.
|
|
472
|
+
*/
|
|
473
|
+
export function parsePiModels(stdout) {
|
|
474
|
+
const discovered = [];
|
|
475
|
+
const seen = new Set(["default"]);
|
|
476
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
477
|
+
const line = rawLine.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, "").trim();
|
|
478
|
+
if (!line || /^provider\s+model\b/i.test(line))
|
|
479
|
+
continue;
|
|
480
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+(\S+)/);
|
|
481
|
+
if (!match || !/^\d/.test(match[3]))
|
|
482
|
+
continue;
|
|
483
|
+
const id = `${match[1]}/${match[2]}`;
|
|
484
|
+
if (!PI_MODEL_ID_PATTERN.test(id) || id.length > 128 || seen.has(id))
|
|
485
|
+
continue;
|
|
486
|
+
seen.add(id);
|
|
487
|
+
discovered.push({ id, label: id });
|
|
488
|
+
}
|
|
489
|
+
if (!discovered.length)
|
|
490
|
+
return cloneModels(PI_FALLBACK_MODELS);
|
|
491
|
+
return [
|
|
492
|
+
{ id: "default", label: "跟随 Pi 默认", alias: true },
|
|
493
|
+
...discovered,
|
|
494
|
+
];
|
|
495
|
+
}
|
|
456
496
|
/** Parse `opencode models`, whose stable machine-friendly output is one provider/model id per line. */
|
|
457
497
|
export function parseOpenCodeModels(stdout) {
|
|
458
498
|
const ids = Array.from(new Set(stdout
|
|
@@ -666,12 +706,13 @@ async function discoverModelCache(options, previous) {
|
|
|
666
706
|
const now = options.now?.() ?? new Date();
|
|
667
707
|
const env = resolveProbeEnv(options);
|
|
668
708
|
const runner = options.commandRunner ?? defaultCommandRunner;
|
|
669
|
-
const [claudeVersionProbe, codexProbe, opencodeProbe, grokProbe, qoderProbe, apiProbe] = await Promise.all([
|
|
709
|
+
const [claudeVersionProbe, codexProbe, opencodeProbe, grokProbe, qoderProbe, piProbe, apiProbe] = await Promise.all([
|
|
670
710
|
probeClaudeVersion(runner, env),
|
|
671
711
|
probeCodexModels(runner, env),
|
|
672
712
|
probeOpenCode(runner, env),
|
|
673
713
|
probeGrokModels(runner, env),
|
|
674
714
|
probeQoderModels(runner, env),
|
|
715
|
+
probePiModels(runner, env),
|
|
675
716
|
listClaudeModelsFromApi(options, env),
|
|
676
717
|
]);
|
|
677
718
|
const claudeVersion = claudeVersionProbe.ok ? claudeVersionProbe.value : previous.claudeVersion;
|
|
@@ -704,7 +745,7 @@ async function discoverModelCache(options, previous) {
|
|
|
704
745
|
opencodeModels: opencodeProbe.models.ok ? opencodeProbe.models.value : cloneModels(previous.opencodeModels),
|
|
705
746
|
grokModels: grokProbe.ok ? grokProbe.value : cloneModels(previous.grokModels),
|
|
706
747
|
qoderModels: qoderProbe.ok ? qoderProbe.value : cloneModels(previous.qoderModels),
|
|
707
|
-
piModels: cloneModels(previous.piModels),
|
|
748
|
+
piModels: piProbe.ok ? piProbe.value : cloneModels(previous.piModels),
|
|
708
749
|
claudeVersion,
|
|
709
750
|
opencodeVersion: opencodeProbe.version.ok ? opencodeProbe.version.value : previous.opencodeVersion,
|
|
710
751
|
refreshedAt: now.toISOString(),
|
package/dist/process-manager.js
CHANGED
|
@@ -1886,7 +1886,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
1886
1886
|
record.description = description;
|
|
1887
1887
|
const snapshot = this.snapshot(record);
|
|
1888
1888
|
this.storage.updateSessionRuntimeMetadata({ ...snapshot, titleGenerating: undefined });
|
|
1889
|
-
|
|
1889
|
+
// status, not output: PTY chunks share the output debounce window, and a
|
|
1890
|
+
// merged incremental+chunk frame is treated as chunk-only by the web client.
|
|
1891
|
+
this.emitEvent({ type: "status", sessionId: id, data: { title, description, summary: description } });
|
|
1890
1892
|
return snapshot;
|
|
1891
1893
|
}
|
|
1892
1894
|
setSessionTopicGenerating(id, titleGenerating) {
|
|
@@ -1894,7 +1896,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
1894
1896
|
if (!record || record.titleGenerating === titleGenerating)
|
|
1895
1897
|
return;
|
|
1896
1898
|
record.titleGenerating = titleGenerating;
|
|
1897
|
-
this.emitEvent({ type: "
|
|
1899
|
+
this.emitEvent({ type: "status", sessionId: id, data: { titleGenerating } });
|
|
1898
1900
|
}
|
|
1899
1901
|
/**
|
|
1900
1902
|
* Persist worktree merge progress through the manager that owns the live
|
|
@@ -210,7 +210,14 @@ function buildSessionListEntries(sessions) {
|
|
|
210
210
|
});
|
|
211
211
|
}
|
|
212
212
|
function sessionListRevision(entries, directoryNames = []) {
|
|
213
|
-
const entryState = entries.map((entry) => [
|
|
213
|
+
const entryState = entries.map((entry) => [
|
|
214
|
+
entry.key,
|
|
215
|
+
entry.sortTimestamp,
|
|
216
|
+
entry.session.title,
|
|
217
|
+
entry.session.status,
|
|
218
|
+
entry.session.ptyBusy === true,
|
|
219
|
+
entry.session.structuredState?.inFlight === true,
|
|
220
|
+
]);
|
|
214
221
|
const revisionState = directoryNames.length === 0
|
|
215
222
|
? entryState
|
|
216
223
|
: {
|